diff --git a/README.md b/README.md index f0d5d176..e9773b6d 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ If you want to use spaces instead, you can do the following: ### Marker function If you want to extract strings that are not passed directly to `TranslateService`'s `get()`/`instant()`/`stream()` methods, you can wrap them in a marker function to let `ngx-translate-extract` know you want to extract them. +> Do not use the marker function with the `NamespaceTranslateService`! The tool will extract the marker without adding the set namespace and the `NamespaceTranslateService` will search for the key, prefixed with the namespace! + Install marker function: `npm install @biesbjerg/ngx-translate-extract-marker` @@ -66,6 +68,8 @@ _('Extract me'); _Note: `ngx-translate-extract` will automatically detect the import name_ + + ### Commandline arguments ``` Usage: diff --git a/package-lock.json b/package-lock.json index 61e5f6c1..c421f976 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@biesbjerg/ngx-translate-extract", - "version": "7.0.2", + "version": "7.0.3", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -170,6 +170,15 @@ "integrity": "sha512-vyxR57nv8NfcU0GZu8EUXZLTbCMupIUwy95LJ6lllN+JRPG25CwMHoB1q5xKh8YKhQnHYRAn4yW2yuHbf/5xgg==", "dev": true }, + "@types/mock-fs": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.0.tgz", + "integrity": "sha512-FUqxhURwqFtFBCuUj3uQMp7rPSQs//b3O9XecAVxhqS9y4/W8SIJEZFq2mmpnFVZBXwR/2OyPLE97CpyYiB8Mw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/node": { "version": "14.11.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.11.2.tgz", @@ -1535,6 +1544,12 @@ } } }, + "mock-fs": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-4.13.0.tgz", + "integrity": "sha512-DD0vOdofJdoaRNtnWcrXe6RQbpHkPPmtqGq14uRX0F8ZKJ5nv89CVTYl/BZdppDxBDaV0hl75htg3abpEWlPZA==", + "dev": true + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index d34e2174..d13f0201 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@types/glob": "^7.1.3", "@types/mkdirp": "^1.0.1", "@types/mocha": "^8.0.3", + "@types/mock-fs": "^4.13.0", "@types/node": "^14.11.2", "@types/yargs": "^15.0.7", "braces": "^3.0.2", @@ -76,6 +77,7 @@ "husky": "^4.3.0", "lint-staged": "^10.4.0", "mocha": "^8.1.3", + "mock-fs": "^4.13.0", "prettier": "^2.1.2", "rimraf": "^3.0.2", "ts-node": "^9.0.0", diff --git a/src/cli/cli.ts b/src/cli/cli.ts index d3df4b37..b104e3ba 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -17,6 +17,9 @@ import { CompilerInterface } from '../compilers/compiler.interface'; import { CompilerFactory } from '../compilers/compiler.factory'; import { normalizePaths } from '../utils/fs-helpers'; import { donateMessage } from '../utils/donate'; +import { NamespacePipeParser } from '../parsers/namespace-pipe.parser'; +import { NamespaceDirectiveParser } from '../parsers/namespace-directive.parser'; +import { NamespaceServiceParser } from '../parsers/namespace-service.parser'; // First parsing pass to be able to access pattern argument for use input/output arguments const y = yargs.option('patterns', { @@ -122,7 +125,15 @@ const extractTask = new ExtractTask(cli.input, cli.output, { }); // Parsers -const parsers: ParserInterface[] = [new PipeParser(), new DirectiveParser(), new ServiceParser(), new MarkerParser()]; +const parsers: ParserInterface[] = [ + new PipeParser(), + new DirectiveParser(), + new ServiceParser(), + new MarkerParser(), + new NamespacePipeParser(), + new NamespaceDirectiveParser(), + new NamespaceServiceParser() +]; extractTask.setParsers(parsers); // Post processors diff --git a/src/parsers/directive.parser.ts b/src/parsers/directive.parser.ts index d910b728..e50d5fe8 100644 --- a/src/parsers/directive.parser.ts +++ b/src/parsers/directive.parser.ts @@ -21,10 +21,11 @@ import { ParserInterface } from './parser.interface'; import { TranslationCollection } from '../utils/translation.collection'; import { isPathAngularComponent, extractComponentInlineTemplate } from '../utils/utils'; -const TRANSLATE_ATTR_NAME = 'translate'; type ElementLike = Element | Template; export class DirectiveParser implements ParserInterface { + protected TRANSLATE_ATTR_NAME = 'translate'; + public extract(source: string, filePath: string): TranslationCollection | null { let collection: TranslationCollection = new TranslationCollection(); @@ -35,13 +36,13 @@ export class DirectiveParser implements ParserInterface { const elements: ElementLike[] = this.getElementsWithTranslateAttribute(nodes); elements.forEach((element) => { - const attribute = this.getAttribute(element, TRANSLATE_ATTR_NAME); + const attribute = this.getAttribute(element, this.TRANSLATE_ATTR_NAME); if (attribute?.value) { collection = collection.add(attribute.value); return; } - const boundAttribute = this.getBoundAttribute(element, TRANSLATE_ATTR_NAME); + const boundAttribute = this.getBoundAttribute(element, this.TRANSLATE_ATTR_NAME); if (boundAttribute?.value) { this.getLiteralPrimitives(boundAttribute.value).forEach((literalPrimitive) => { collection = collection.add(literalPrimitive.value); @@ -64,10 +65,10 @@ export class DirectiveParser implements ParserInterface { protected getElementsWithTranslateAttribute(nodes: Node[]): ElementLike[] { let elements: ElementLike[] = []; nodes.filter(this.isElementLike).forEach((element) => { - if (this.hasAttribute(element, TRANSLATE_ATTR_NAME)) { + if (this.hasAttribute(element, this.TRANSLATE_ATTR_NAME)) { elements = [...elements, element]; } - if (this.hasBoundAttribute(element, TRANSLATE_ATTR_NAME)) { + if (this.hasBoundAttribute(element, this.TRANSLATE_ATTR_NAME)) { elements = [...elements, element]; } const childElements = this.getElementsWithTranslateAttribute(element.children); diff --git a/src/parsers/namespace-directive.parser.ts b/src/parsers/namespace-directive.parser.ts new file mode 100644 index 00000000..27688ea5 --- /dev/null +++ b/src/parsers/namespace-directive.parser.ts @@ -0,0 +1,18 @@ +import { TranslationCollection } from '../utils/translation.collection'; +import { extractNamespace } from '../utils/utils'; +import { DirectiveParser } from './directive.parser'; + +export class NamespaceDirectiveParser extends DirectiveParser { + protected TRANSLATE_ATTR_NAME = 'namespace-translate'; + + public extract(source: string, filePath: string): TranslationCollection | null { + const keys = super.extract(source, filePath); + // only try to extract namespace if the namespaceTranslate pipe has been used. + if (!keys || keys.isEmpty()) { + return keys; + } + const namespace = extractNamespace(source, filePath); + keys.prefixKeys(namespace); + return keys; + } +} diff --git a/src/parsers/namespace-pipe.parser.ts b/src/parsers/namespace-pipe.parser.ts new file mode 100644 index 00000000..4ccd46d0 --- /dev/null +++ b/src/parsers/namespace-pipe.parser.ts @@ -0,0 +1,18 @@ +import { file } from 'mock-fs/lib/filesystem'; +import { PipeParser } from './pipe.parser'; +import { extractNamespace } from '../utils/utils'; + +export class NamespacePipeParser extends PipeParser { + protected TRANSLATE_PIPE_NAME = 'namespaceTranslate'; + + public extract(source: string, filePath: string) { + const keys = super.extract(source, filePath); + // only try to extract namespace if the namespaceTranslate pipe has been used. + if (!keys || keys.isEmpty()) { + return keys; + } + const namespace = extractNamespace(source, filePath); + keys.prefixKeys(namespace); + return keys; + } +} diff --git a/src/parsers/namespace-service.parser.ts b/src/parsers/namespace-service.parser.ts new file mode 100644 index 00000000..5294dd98 --- /dev/null +++ b/src/parsers/namespace-service.parser.ts @@ -0,0 +1,19 @@ +import { TranslationCollection } from '../utils/translation.collection'; +import { ServiceParser } from './service.parser'; +import { extractNamespace } from '../utils/utils'; + +export class NamespaceServiceParser extends ServiceParser { + protected TRANSLATE_SERVICE_TYPE_REFERENCE = 'NamespaceTranslateService'; + protected TRANSLATE_SERVICE_METHOD_NAMES = ['get', 'instant', 'stream']; + + public extract(source: string, filePath: string): TranslationCollection | null { + const keys = super.extract(source, filePath); + // only try to extract namespace if the namespaceTranslate pipe has been used. + if (!keys || keys.isEmpty()) { + return keys; + } + const namespace = extractNamespace(source, filePath); + keys.prefixKeys(namespace); + return keys; + } +} diff --git a/src/parsers/pipe.parser.ts b/src/parsers/pipe.parser.ts index 73a2c89d..5e8e9027 100644 --- a/src/parsers/pipe.parser.ts +++ b/src/parsers/pipe.parser.ts @@ -16,9 +16,9 @@ import { ParserInterface } from './parser.interface'; import { TranslationCollection } from '../utils/translation.collection'; import { isPathAngularComponent, extractComponentInlineTemplate } from '../utils/utils'; -const TRANSLATE_PIPE_NAME = 'translate'; - export class PipeParser implements ParserInterface { + protected TRANSLATE_PIPE_NAME = 'translate'; + public extract(source: string, filePath: string): TranslationCollection | null { if (filePath && isPathAngularComponent(filePath)) { source = extractComponentInlineTemplate(source); @@ -54,7 +54,7 @@ export class PipeParser implements ParserInterface { if (node?.attributes) { const translateableAttributes = node.attributes.filter((attr: TmplAstTextAttribute) => { - return attr.name === TRANSLATE_PIPE_NAME; + return attr.name === this.TRANSLATE_PIPE_NAME; }); ret = [...ret, ...translateableAttributes]; } @@ -143,7 +143,7 @@ export class PipeParser implements ParserInterface { } protected expressionIsOrHasBindingPipe(exp: any): exp is BindingPipe { - if (exp.name && exp.name === TRANSLATE_PIPE_NAME) { + if (exp.name && exp.name === this.TRANSLATE_PIPE_NAME) { return true; } if (exp.exp && exp.exp instanceof BindingPipe) { diff --git a/src/parsers/service.parser.ts b/src/parsers/service.parser.ts index d444a0f3..c4248db0 100644 --- a/src/parsers/service.parser.ts +++ b/src/parsers/service.parser.ts @@ -13,10 +13,10 @@ import { findConstructorDeclaration } from '../utils/ast-helpers'; -const TRANSLATE_SERVICE_TYPE_REFERENCE = 'TranslateService'; -const TRANSLATE_SERVICE_METHOD_NAMES = ['get', 'instant', 'stream']; - export class ServiceParser implements ParserInterface { + protected TRANSLATE_SERVICE_TYPE_REFERENCE = 'TranslateService'; + protected TRANSLATE_SERVICE_METHOD_NAMES = ['get', 'instant', 'stream']; + public extract(source: string, filePath: string): TranslationCollection | null { const sourceFile = tsquery.ast(source, filePath); @@ -50,15 +50,15 @@ export class ServiceParser implements ParserInterface { if (!constructorDeclaration) { return []; } - const paramName = findMethodParameterByType(constructorDeclaration, TRANSLATE_SERVICE_TYPE_REFERENCE); - return findMethodCallExpressions(constructorDeclaration, paramName, TRANSLATE_SERVICE_METHOD_NAMES); + const paramName = findMethodParameterByType(constructorDeclaration, this.TRANSLATE_SERVICE_TYPE_REFERENCE); + return findMethodCallExpressions(constructorDeclaration, paramName, this.TRANSLATE_SERVICE_METHOD_NAMES); } protected findPropertyCallExpressions(classDeclaration: ClassDeclaration): CallExpression[] { - const propName: string = findClassPropertyByType(classDeclaration, TRANSLATE_SERVICE_TYPE_REFERENCE); + const propName: string = findClassPropertyByType(classDeclaration, this.TRANSLATE_SERVICE_TYPE_REFERENCE); if (!propName) { return []; } - return findPropertyCallExpressions(classDeclaration, propName, TRANSLATE_SERVICE_METHOD_NAMES); + return findPropertyCallExpressions(classDeclaration, propName, this.TRANSLATE_SERVICE_METHOD_NAMES); } } diff --git a/src/utils/ast-helpers.ts b/src/utils/ast-helpers.ts index 6e5cd231..c5da785d 100644 --- a/src/utils/ast-helpers.ts +++ b/src/utils/ast-helpers.ts @@ -12,7 +12,8 @@ import { Expression, isBinaryExpression, isConditionalExpression, - PropertyAccessExpression + PropertyAccessExpression, + StringLiteral } from 'typescript'; export function getNamedImports(node: Node, moduleName: string): NamedImports[] { @@ -155,3 +156,11 @@ export function getStringsFromExpression(expression: Expression): string[] { } return []; } + +export function getTranslationNamespace(node: Node, translationNamespaceImportName: string): string | undefined { + const query = `ClassDeclaration > Decorator > CallExpression ObjectLiteralExpression PropertyAssignment:has(Identifier[name='providers']) ArrayLiteralExpression ObjectLiteralExpression:has(PropertyAssignment:has(Identifier[name="${translationNamespaceImportName}"])) PropertyAssignment:has(Identifier[name="useValue"]) StringLiteral`; + + const [namespace] = tsquery(node, query); + + return namespace?.text ?? undefined; +} diff --git a/src/utils/translation.collection.ts b/src/utils/translation.collection.ts index b302e3b4..39ba9f7b 100644 --- a/src/utils/translation.collection.ts +++ b/src/utils/translation.collection.ts @@ -90,4 +90,10 @@ export class TranslationCollection { return new TranslationCollection(values); } + + public prefixKeys(prefix: string): void { + const values: TranslationType = {}; + this.forEach((key, value) => (values[prefix + '.' + key] = value)); + this.values = values; + } } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 54471190..f6cf850f 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,3 +1,44 @@ +import { tsquery } from '@phenomnomnominal/tsquery'; +import * as fs from 'fs'; +import ts from 'typescript'; +import { getNamedImportAlias, getTranslationNamespace } from './ast-helpers'; + +const NAMESPACE_MODULE_NAME = '@ngx-translate/core'; +const TRANSLATION_NAMESPACE_NAME = 'TRANSLATION_NAMESPACE'; + +export function extractNamespace(source: string, filePath: string): string { + let sourceFile: ts.SourceFile; + // if html tries to get ts file where the namespace should be set + if (filePath.endsWith('.html')) { + try { + const tsFilePath = filePath.replace('.html', '.ts'); + const tsFile = fs.readFileSync(tsFilePath, 'utf-8'); + sourceFile = tsquery.ast(tsFile, filePath); + } catch (err) { + throw new Error(`unable to load corresponding ts file for ${filePath} where namespace should be defined!`); + } + } else { + sourceFile = tsquery.ast(source, filePath); + } + + const translationNamespaceImportName = getNamedImportAlias(sourceFile, NAMESPACE_MODULE_NAME, TRANSLATION_NAMESPACE_NAME); + if (!translationNamespaceImportName) { + throw new Error( + `"${TRANSLATION_NAMESPACE_NAME}" not imported in "${filePath}". Namespace has to be provided to component when using namespace translation service, pipe or directive from "${NAMESPACE_MODULE_NAME}"!!` + ); + } + + const namespace = getTranslationNamespace(sourceFile, translationNamespaceImportName); + + if (!namespace || namespace === '') { + throw new Error( + `No ${TRANSLATION_NAMESPACE_NAME} provided in "${filePath}". Namespace has to be provided to component when using namespace translation service, pipe or directive from "${NAMESPACE_MODULE_NAME}"!!` + ); + } + + return namespace; +} + /** * Assumes file is an Angular component if type is javascript/typescript */ diff --git a/tests/parsers/namespace-directive.parser.spec.ts b/tests/parsers/namespace-directive.parser.spec.ts new file mode 100644 index 00000000..01e5591c --- /dev/null +++ b/tests/parsers/namespace-directive.parser.spec.ts @@ -0,0 +1,215 @@ +import { expect } from 'chai'; +import * as mockFs from 'mock-fs'; + +import { DirectiveParser } from '../../src/parsers/directive.parser'; +import { NamespaceDirectiveParser } from '../../src/parsers/namespace-directive.parser'; + +describe('NamespaceDirectiveParser', () => { + const templateFilename: string = 'test.template.html'; + const componentFilename: string = 'test.component.ts'; + + let parser: NamespaceDirectiveParser; + + beforeEach(() => { + mockFs({ 'test.template.ts': tsWorking }); + parser = new NamespaceDirectiveParser(); + }); + + it('should throw error if no ts file with the same same as html file can be found', () => { + try { + const contents = `
`; + parser.extract(contents, 'no-matching-ts-file.html'); + } catch (err) { + expect(err).not.undefined; + expect(err).not.null; + } + }); + + it('should not throw error if no ts file with the same same as html file can be found because namespace-translate not used', () => { + const contents = `
`; + const keys = parser.extract(contents, 'no-matching-ts-file.html'); + expect(keys.values).to.deep.equal({}); + }); + + it('should extract keys when using literal map in bound attribute', () => { + const contents = `
`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.value1', 'test.value2']); + }); + + it('should extract keys when using literal arrays in bound attribute', () => { + const contents = `
`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.value1', 'test.value2']); + }); + + it('should extract keys when using binding pipe in bound attribute', () => { + const contents = `
`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.KEY1']); + }); + + it('should extract keys when using binary expression in bound attribute', () => { + const contents = `
`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.KEY1']); + }); + + it('should extract keys when using literal primitive in bound attribute', () => { + const contents = `
`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.KEY1']); + }); + + it('should extract keys when using conditional in bound attribute', () => { + const contents = `
`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.KEY1', 'test.KEY2']); + }); + + it('should extract keys when using nested conditionals in bound attribute', () => { + const contents = `
`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Sunny and warm', 'test.Sunny but cold', 'test.Not sunny']); + }); + + it('should extract keys when using interpolation', () => { + const contents = `
`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.KEY1', 'test.KEY3']); + }); + + it('should extract keys keeping proper whitespace', () => { + const contents = ` +
+ Wubba + Lubba + Dub Dub +
+ `; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Wubba Lubba Dub Dub']); + }); + + it('should use element contents as key when no translate attribute value is present', () => { + const contents = '
Hello World
'; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello World']); + }); + + it('should use translate attribute value as key when present', () => { + const contents = '
Hello World
'; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.MY_KEY']); + }); + + it('should extract keys from child elements when translate attribute is present', () => { + const contents = `
Hello World
`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello', 'test.World']); + }); + + it('should not extract keys from child elements when translate attribute is not present', () => { + const contents = `
Hello World
`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello']); + }); + + it('should extract and parse inline template', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + selector: 'test', + template: '

Hello World

', + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class TestComponent { } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal(['test.Hello World']); + }); + + it('should extract contents when no translate attribute value is provided', () => { + const contents = '
Hello World
'; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello World']); + }); + + it('should extract translate attribute value if provided', () => { + const contents = '
Hello World
'; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.KEY']); + }); + + it('should not extract translate pipe in html tag', () => { + const contents = `

{{ 'Audiobooks for personal development' | translate }}

`; + const collection = parser.extract(contents, templateFilename); + expect(collection.values).to.deep.equal({}); + }); + + it('should extract contents from custom elements', () => { + const contents = `Hello World`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello World']); + }); + + it('should extract from template without leading/trailing whitespace', () => { + const contents = ` +
There + are currently no students in this class. The good news is, adding students is really easy! Just use the options + at the top. +
+ `; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal([ + 'test.There are currently no students in this class. The good news is, adding students is really easy! Just use the options at the top.' + ]); + }); + + it('should extract keys from element without leading/trailing whitespace', () => { + const contents = ` +
+ this is an example + of a long label +
+ +
+

+ this is an example + of another a long label +

+
+ `; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.this is an example of a long label', 'test.this is an example of another a long label']); + }); + + it('should collapse excessive whitespace', () => { + const contents = '

this is an example

'; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.this is an example']); + }); + + afterEach(() => { + mockFs.restore(); + }); +}); + +const tsWorking = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + + @Component({ + selector: 'app-nested', + templateUrl: './nested.component.html', + styleUrls: ['./nested.component.css'], + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class NestedComponent implements OnInit { + + constructor(@Self() nts: NamespaceTranslateService) { console.log(nts["namespace"]) } + + ngOnInit(): void { + } + + } +`; diff --git a/tests/parsers/namespace-pipe.parser.spec.ts b/tests/parsers/namespace-pipe.parser.spec.ts new file mode 100644 index 00000000..16a91f4c --- /dev/null +++ b/tests/parsers/namespace-pipe.parser.spec.ts @@ -0,0 +1,239 @@ +import { expect } from 'chai'; + +import { NamespacePipeParser } from '../../src/parsers/namespace-pipe.parser'; +import * as mockFs from 'mock-fs'; + +describe('NamespacePipeParser', () => { + const templateFilename: string = 'test.template.html'; + + let parser: NamespacePipeParser; + + beforeEach(() => { + mockFs({ 'test.template.ts': tsWorking }); + parser = new NamespacePipeParser(); + }); + + it('should throw error if no ts file with the same same as html file can be found', () => { + try { + const contents = `
{{"test"|namespaceTranslate}}
`; + parser.extract(contents, 'no-matching-ts-file.html'); + } catch (err) { + expect(err).not.undefined; + expect(err).not.null; + } + }); + + it('should not throw error if no ts file with the same same as html file can be found because namespaceTranslate not used', () => { + const contents = `
{{"test"|translate}}
`; + const keys = parser.extract(contents, 'no-matching-ts-file.html'); + expect(keys.values).to.deep.equal({}); + }); + + it('should only extract string using pipe', () => { + const contents = ``; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.SomeKey_NotWorking']); + }); + + it('should extract string using pipe, but between quotes only', () => { + const contents = ``; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.user.settings.form.phone.placeholder']); + }); + + it('should extract interpolated strings using namespaceTranslate pipe', () => { + const contents = `Hello {{ 'World' | namespaceTranslate }}`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.World']); + }); + + it('should extract interpolated strings when namespaceTranslate pipe is used before other pipes', () => { + const contents = `Hello {{ 'World' | namespaceTranslate | upper }}`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.World']); + }); + + it('should extract interpolated strings when namespaceTranslate pipe is used after other pipes', () => { + const contents = `Hello {{ 'World' | upper | namespaceTranslate }}`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.World']); + }); + + it('should extract strings from ternary operators inside interpolations', () => { + const contents = `{{ (condition ? 'Hello' : 'World') | namespaceTranslate }}`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello', 'test.World']); + }); + + it('should extract strings from ternary operators right expression', () => { + const contents = `{{ condition ? null : ('World' | namespaceTranslate) }}`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.World']); + }); + + it('should extract strings from ternary operators inside attribute bindings', () => { + const contents = ``; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.World']); + }); + + it('should extract strings from ternary operators left expression', () => { + const contents = `{{ condition ? ('World' | namespaceTranslate) : null }}`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.World']); + }); + + it('should extract strings inside string concatenation', () => { + const contents = `{{ 'a' + ('Hello' | namespaceTranslate) + 'b' + 'c' + ('World' | namespaceTranslate) + 'd' }}`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello', 'test.World']); + }); + + it('should extract strings from object', () => { + const contents = `{{ { foo: 'Hello' | namespaceTranslate, bar: ['World' | namespaceTranslate], deep: { nested: { baz: 'Yes' | namespaceTranslate } } } | json }}`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello', 'test.World', 'test.Yes']); + }); + + it('should extract strings from ternary operators inside attribute bindings', () => { + const contents = ``; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello', 'test.World']); + }); + + it('should extract strings from nested expressions', () => { + const contents = ``; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello', 'test.World']); + }); + + it('should extract strings from nested ternary operators ', () => { + const contents = `

{{ (condition ? 'Hello' : anotherCondition ? 'Nested' : 'World' ) | namespaceTranslate }}

`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello', 'test.Nested', 'test.World']); + }); + + it('should extract strings from ternary operators inside attribute interpolations', () => { + const contents = ``; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello', 'test.World']); + }); + + it('should extract strings with escaped quotes', () => { + const contents = `Hello {{ 'World\\'s largest potato' | namespaceTranslate }}`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal([`test.World's largest potato`]); + }); + + it('should extract strings with multiple escaped quotes', () => { + const contents = `{{ 'C\\'est ok. C\\'est ok' | namespaceTranslate }}`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal([`test.C'est ok. C'est ok`]); + }); + + it('should extract interpolated strings using namespaceTranslate pipe in attributes', () => { + const contents = ``; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello World']); + }); + + it('should extract bound strings using namespaceTranslate pipe in attributes', () => { + const contents = ``; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello World']); + }); + + it('should extract multiple entries from nodes', () => { + const contents = ` + + + {{ 'Info' | namespaceTranslate }} + + + + + + + {{ 'Loading...' | namespaceTranslate }} + + + + `; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Info', 'test.Loading...']); + }); + + it('should extract strings on same line', () => { + const contents = ``; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Hello', 'test.World']); + }); + + it('should extract strings from this template', () => { + const contents = ` + + + + + + +

+ {{ error }} +

+
+
+
+ +
+ `; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.Name', 'test.Create account']); + }); + + it('should not extract variables', () => { + const contents = '

{{ message | namespaceTranslate }}

'; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal([]); + }); + + it('should be able to extract without html', () => { + const contents = `{{ 'message' | namespaceTranslate }}`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal(['test.message']); + }); + + it('should ignore calculated values', () => { + const contents = `{{ 'SOURCES.' + source.name + '.NAME_PLURAL' | namespaceTranslate }}`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal([]); + }); + + it('should not extract pipe argument', () => { + const contents = `{{ value | valueToTranslationKey: 'argument' | namespaceTranslate }}`; + const keys = parser.extract(contents, templateFilename).keys(); + expect(keys).to.deep.equal([]); + }); + + afterEach(() => { + mockFs.restore(); + }); +}); + +const tsWorking = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + + @Component({ + selector: 'app-nested', + templateUrl: './nested.component.html', + styleUrls: ['./nested.component.css'], + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class NestedComponent implements OnInit { + + constructor(@Self() nts: NamespaceTranslateService) { console.log(nts["namespace"]) } + + ngOnInit(): void { + } + + } +`; diff --git a/tests/parsers/namespace-service.parser.spec.ts b/tests/parsers/namespace-service.parser.spec.ts new file mode 100644 index 00000000..e64da0d9 --- /dev/null +++ b/tests/parsers/namespace-service.parser.spec.ts @@ -0,0 +1,360 @@ +import { expect } from 'chai'; + +import { NamespaceServiceParser } from '../../src/parsers/namespace-service.parser'; + +describe('NamespaceServiceParser', () => { + const componentFilename: string = 'test.component.ts'; + + let parser: NamespaceServiceParser; + + beforeEach(() => { + parser = new NamespaceServiceParser(); + }); + + it('should extract strings when NamespaceTranslateService is accessed directly via constructor parameter', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class MyComponent { + public constructor(protected translateService: NamespaceTranslateService) { + translateService.get('It works!'); + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal(['test.It works!']); + }); + + it('should support extracting binary expressions', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor(protected _translateService: NamespaceTranslateService) { } + public test() { + const message = 'The Message'; + this._translateService.get(message || 'Fallback message'); + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal(['test.Fallback message']); + }); + + it('should support conditional operator', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor(protected _translateService: NamespaceTranslateService) { } + public test() { + const message = 'The Message'; + this._translateService.get(message ? message : 'Fallback message'); + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal(['test.Fallback message']); + }); + + it('should extract strings in NamespaceTranslateService\'s get() method', () => { + const contents = ` import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor(protected _translateService: NamespaceTranslateService) { } + public test() { + this._translateService.get('Hello World'); + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal(['test.Hello World']); + }); + + it('should extract strings in NamespaceTranslateService\'s instant() method', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor(protected _translateService: NamespaceTranslateService) { } + public test() { + this._translateService.instant('Hello World'); + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal(['test.Hello World']); + }); + + it('should extract strings in NamespaceTranslateService\'s stream() method', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor(protected _translateService: NamespaceTranslateService) { } + public test() { + this._translateService.stream('Hello World'); + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal(['test.Hello World']); + }); + + it('should extract array of strings in NamespaceTranslateService\'s get() method', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor(protected _translateService: NamespaceTranslateService) { } + public test() { + this._translateService.get(['Hello', 'World']); + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal(['test.Hello', 'test.World']); + }); + + it('should extract array of strings in NamespaceTranslateService\'s instant() method', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor(protected _translateService: NamespaceTranslateService) { } + public test() { + this._translateService.instant(['Hello', 'World']); + } + `; + const key = parser.extract(contents, componentFilename).keys(); + expect(key).to.deep.equal(['test.Hello', 'test.World']); + }); + + it('should extract array of strings in NamespaceTranslateService\'s stream() method', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor(protected _translateService: NamespaceTranslateService) { } + public test() { + this._translateService.stream(['Hello', 'World']); + } + `; + const key = parser.extract(contents, componentFilename).keys(); + expect(key).to.deep.equal(['test.Hello', 'test.World']); + }); + + it('should extract string arrays encapsulated in backticks', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor(protected _translateService: NamespaceTranslateService) { } + public test() { + this._translateService.get([\`Hello\`, \`World\`]); + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal(['test.Hello', 'test.World']); + }); + + it('should not extract strings in get()/instant()/stream() methods of other services', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor( + protected _translateService: NamespaceTranslateService, + protected _otherService: OtherService + ) { } + public test() { + this._otherService.get('Hello World'); + this._otherService.instant('Hi there'); + this._otherService.stream('Hi there'); + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal([]); + }); + + it('should extract strings with liberal spacing', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor( + protected _translateService: NamespaceTranslateService, + protected _otherService: OtherService + ) { } + public test() { + this._translateService.instant('Hello'); + this._translateService.get ( 'World' ); + this._translateService.instant ( ['How'] ); + this._translateService.get([ 'Are' ]); + this._translateService.get([ 'You' , 'Today' ]); + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal(['test.Hello', 'test.World', 'test.How', 'test.Are', 'test.You', 'test.Today']); + }); + + it('should not extract string when not accessing property', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor(protected trans: NamespaceTranslateService) { } + public test() { + trans.get("You are expected at {{time}}", {time: moment.format('H:mm')}).subscribe(); + } + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal([]); + }); + + it('should extract string with params on same line', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor(protected _translateService: NamespaceTranslateService) { } + public test() { + this._translateService.get('You are expected at {{time}}', {time: moment.format('H:mm')}); + } + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal(['test.You are expected at {{time}}']); + }); + + it('should not crash when constructor parameter has no type', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor(protected _translateService) { } + public test() { + this._translateService.instant('Hello World'); + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal([]); + }); + + it('should not extract variables', () => { + const contents = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + @Component({ + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class AppComponent { + public constructor(protected translateService: NamespaceTranslateService) { } + public test() { + this.translateService.get(["yes", variable]).then(translations => { + console.log(translations[variable]); + }); + } + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal(['test.yes']); + }); + + // it('should extract strings from all classes in the file', () => { + // const contents = ` + // import { Injectable } from '@angular/core'; + // import { NamespaceTranslateService } from '@ngx-translate/core'; + // import { NamespaceServiceParser } from '../../src/parsers/namespace-service.parser'; + // export class Stuff { + // thing: string; + // translate: any; + // constructor(thing: string) { + // this.translate.get('Not me'); + // this.thing = thing; + // } + // } + // @Injectable() + // export class MyComponent { + // constructor(public translate: NamespaceTranslateService) { + // this.translate.instant("Extract me!"); + // } + // } + // export class OtherClass { + // constructor(thing: string, _translate: NamespaceTranslateService) { + // this._translate.get("Do not extract me"); + // } + // } + // @Injectable() + // export class AuthService { + // constructor(public translate: NamespaceTranslateService) { + // this.translate.instant("Hello!"); + // } + // } + // `; + // const keys = parser.extract(contents, componentFilename).keys(); + // expect(keys).to.deep.equal(['Extract me!', 'Hello!']); + // }); + + // it('should extract strings when NamespaceTranslateService is declared as a property', () => { + // const contents = ` + // export class MyComponent { + // protected translateService: NamespaceTranslateService; + // public constructor() { + // this.translateService = new NamespaceTranslateService(); + // } + // public test() { + // this.translateService.instant('Hello World'); + // } + // `; + // const keys = parser.extract(contents, componentFilename).keys(); + // expect(keys).to.deep.equal(['Hello World']); + // }); + + // it('should extract strings passed to TranslateServices methods only', () => { + // const contents = ` + // import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + // @Component({ + // providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + // }) + // export class AppComponent implements OnInit { + // constructor(protected config: Config, protected translateService: NamespaceTranslateService) {} + + // public ngOnInit(): void { + // this.localizeBackButton(); + // } + + // protected localizeBackButton(): void { + // this.translateService.onLangChange.subscribe((event: LangChangeEvent) => { + // this.config.set('backButtonText', this.translateService.instant('Back')); + // }); + // } + // } + // `; + // const keys = parser.extract(contents, componentFilename).keys(); + // expect(keys).to.deep.equal(['Back']); + // }); +}); diff --git a/tests/utils/extractNamespace.spec.ts b/tests/utils/extractNamespace.spec.ts new file mode 100644 index 00000000..1895f31d --- /dev/null +++ b/tests/utils/extractNamespace.spec.ts @@ -0,0 +1,81 @@ +import { expect } from 'chai'; +import * as mockFs from 'mock-fs'; + +import { extractNamespace } from '../../src/utils/utils'; + +describe('extractNamespace', () => { + beforeEach(() => { + mockFs({ + 'working.ts': tsWorking, + 'ts.without.import': tsWithoutImport, + 'ts.without.namespaceCall': tsWithoutCall, + 'html.without.ts.html': '
{{"Test"|namespaceTranslate}}
', + 'html.with.ts.html': '
{{"Test"|namespaceTranslate}}
', + 'html.with.ts.ts': tsWorking + }); + }); + + it('should find namespace in ts file', () => { + const namespace = extractNamespace(tsWorking, 'test.file.ts'); + expect(namespace).equal('test'); + }); + + it('should find namespace in corresponding ts file of given html file', () => { + const namespace = extractNamespace('
some html
', 'html.with.ts.html'); + expect(namespace).equal('test'); + }); + + it('should find not find corresponding ts file', () => { + try { + extractNamespace(tsWithoutImport, 'html.without.ts.html'); + } catch (err) { + expect(err).not.null; + expect(err).not.undefined; + } + }); + + it('should not find namespace import', () => { + try { + extractNamespace(tsWithoutImport, 'ts.without.import'); + } catch (err) { + expect(err).not.null; + expect(err).not.undefined; + } + }); + + it('should not find namespace call', () => { + try { + extractNamespace(tsWithoutCall, 'ts.without.namespaceCall'); + } catch (err) { + expect(err).not.null; + expect(err).not.undefined; + } + }); + + afterEach(() => { + mockFs.restore(); + }); +}); + +const tsWithoutImport = ``; + +const tsWithoutCall = `import {TRANSLATION_NAMESPACE} from "@ngx-translate/core";`; + +const tsWorking = ` + import {TRANSLATION_NAMESPACE} from "@ngx-translate/core"; + + @Component({ + selector: 'app-nested', + templateUrl: './nested.component.html', + styleUrls: ['./nested.component.css'], + providers: [{ provide: TRANSLATION_NAMESPACE, useValue: "test" }, NamespaceTranslateService] + }) + export class NestedComponent implements OnInit { + + constructor(@Self() nts: NamespaceTranslateService) { console.log(nts["namespace"]) } + + ngOnInit(): void { + } + + } +`;