Repository: vendure-ecommerce/ngx-translate-extract
Pull request #57 (2024-11-04 19:26:35)

feat: Add support for the ngx-translate `_()` marker function
Closes: #56


Patch:
diff --git a/src/parsers/marker.parser.ts b/src/parsers/marker.parser.ts
index 64dccfe..f740ed1 100644
--- a/src/parsers/marker.parser.ts
+++ b/src/parsers/marker.parser.ts
@@ -1,15 +1,18 @@
 import { ParserInterface } from './parser.interface.js';
 import { TranslationCollection } from '../utils/translation.collection.js';
 import { getNamedImportAlias, findFunctionCallExpressions, getStringsFromExpression, getAST } from '../utils/ast-helpers.js';
+import { SourceFile } from 'typescript';
 
-const MARKER_MODULE_NAME = 'ngx-translate-extract-marker';
+const MARKER_MODULE_NAME = new RegExp('ngx-translate-extract-marker');
 const MARKER_IMPORT_NAME = 'marker';
+const NGX_TRANSLATE_MARKER_MODULE_NAME = '@ngx-translate/core';
+const NGX_TRANSLATE_MARKER_IMPORT_NAME = '_';
 
 export class MarkerParser implements ParserInterface {
 	public extract(source: string, filePath: string): TranslationCollection | null {
 		const sourceFile = getAST(source, filePath);
 
-		const markerImportName = getNamedImportAlias(sourceFile, MARKER_IMPORT_NAME, new RegExp(MARKER_MODULE_NAME));
+		const markerImportName = this.getMarkerImportNameFromSource(sourceFile);
 		if (!markerImportName) {
 			return null;
 		}
@@ -27,4 +30,12 @@ export class MarkerParser implements ParserInterface {
 		});
 		return collection;
 	}
+
+	private getMarkerImportNameFromSource(sourceFile: SourceFile): string {
+		const markerImportName =
+			getNamedImportAlias(sourceFile, MARKER_IMPORT_NAME, MARKER_MODULE_NAME) ||
+			getNamedImportAlias(sourceFile, NGX_TRANSLATE_MARKER_IMPORT_NAME, NGX_TRANSLATE_MARKER_MODULE_NAME);
+
+		return markerImportName ?? '';
+	}
 }
diff --git a/src/utils/ast-helpers.ts b/src/utils/ast-helpers.ts
index 0309f1f..0b859ec 100644
--- a/src/utils/ast-helpers.ts
+++ b/src/utils/ast-helpers.ts
@@ -8,7 +8,11 @@ import pkg, {
 	CallExpression,
 	Expression,
 	StringLiteral,
-	SourceFile
+	SourceFile,
+	PropertyDeclaration,
+	PropertyAccessExpression,
+	isPropertyAccessExpression,
+	isCallExpression
 } from 'typescript';
 const { SyntaxKind, isStringLiteralLike, isArrayLiteralExpression, isBinaryExpression, isConditionalExpression } = pkg;
 
@@ -142,15 +146,15 @@ export function findClassPropertiesConstructorParameterByType(node: ClassDeclara
 }
 
 export function findClassPropertiesDeclarationByType(node: ClassDeclaration, type: string): string[] {
-	const query = `PropertyDeclaration:has(TypeReference > Identifier[name="${type}"]) > Identifier`;
-	const result = tsquery<Identifier>(node, query);
-	return result.map((n) => n.text);
+	const query = `PropertyDeclaration:has(TypeReference > Identifier[name="${type}"])`;
+	const result = tsquery<PropertyDeclaration>(node, query);
+	return result.map((n) => n.name.getText());
 }
 
 export function findClassPropertiesDeclarationByInject(node: ClassDeclaration, type: string): string[] {
-	const query = `PropertyDeclaration:has(CallExpression > Identifier[name="inject"]):has(CallExpression > Identifier[name="${type}"]) > Identifier`;
-	const result = tsquery<Identifier>(node, query);
-	return result.map((n) => n.text);
+	const query = `PropertyDeclaration:has(CallExpression > Identifier[name="inject"]):has(CallExpression > Identifier[name="${type}"])`;
+	const result = tsquery<PropertyDeclaration>(node, query);
+	return result.map((n) => n.name.getText());
 }
 
 export function findClassPropertiesGetterByType(node: ClassDeclaration, type: string): string[] {
@@ -179,22 +183,23 @@ export function findPropertyCallExpressions(node: Node, prop: string, fnName: st
 	if (Array.isArray(fnName)) {
 		fnName = fnName.join('|');
 	}
-	const query = 'CallExpression > ' +
-        `PropertyAccessExpression:has(Identifier[name=/^(${fnName})$/]):has(PropertyAccessExpression:has(Identifier[name="${prop}"]):has(ThisKeyword)) > ` +
-        `PropertyAccessExpression:has(ThisKeyword) > Identifier[name="${prop}"]`;
-	const nodes = tsquery<Identifier>(node, query);
-	// Since the direct descendant operator (>) is not supported in :has statements, we need to
-	// check manually whether everything is correctly matched
-	const filtered = nodes.reduce<CallExpression[]>((result: CallExpression[], n: Node) => {
-		if (
-			tsquery(n.parent, 'PropertyAccessExpression > ThisKeyword').length > 0 &&
-			tsquery(n.parent.parent, `PropertyAccessExpression > Identifier[name=/^(${fnName})$/]`).length > 0
-		) {
-			result.push(n.parent.parent.parent as CallExpression);
+
+	const query = `CallExpression > PropertyAccessExpression:has(Identifier[name=/^(${fnName})$/]):has(PropertyAccessExpression:has(ThisKeyword))`;
+	const result = tsquery<PropertyAccessExpression>(node, query);
+
+	const nodes: CallExpression[] = [];
+	result.forEach((n) => {
+		const identifier = isPropertyAccessExpression(n.expression) ? n.expression.name : null;
+		const property = identifier?.parent;
+		const method = property?.parent;
+		const callExpression = method?.parent;
+
+		if (identifier?.getText() === prop && isCallExpression(callExpression)) {
+			nodes.push(callExpression);
 		}
-		return result;
-	}, []);
-	return filtered;
+	});
+
+	return nodes;
 }
 
 export function getStringsFromExpression(expression: Expression): string[] {


Test patch:
diff --git a/tests/parsers/marker.parser.spec.ts b/tests/parsers/marker.parser.spec.ts
index ec20cfb..26b3b35 100644
--- a/tests/parsers/marker.parser.spec.ts
+++ b/tests/parsers/marker.parser.spec.ts
@@ -75,16 +75,68 @@ describe('MarkerParser', () => {
 	it('should not break after bracket syntax casting', () => {
 		const contents = `
 		import { marker } from '@colsen1991/ngx-translate-extract-marker';
-		
+
 		marker('hello');
-		const input: unknown = 'hello';    
+		const input: unknown = 'hello';
 		const myNiceVar1 = input as string;
 		marker('hello.after.as.syntax');
-			
+
 		const myNiceVar2 = <string>input;
 		marker('hello.after.bracket.syntax');
 		`;
 		const keys = parser.extract(contents, componentFilename).keys();
 		expect(keys).to.deep.equal(['hello', 'hello.after.as.syntax', 'hello.after.bracket.syntax']);
 	});
+
+	describe('marker from @ngx-translate/core', () => {
+		it('should extract strings using marker (_) function', () => {
+			const contents = `
+			import {_} from '@ngx-translate/core';
+			_('Hello world');
+			_(['I', 'am', 'extracted']);
+			otherFunction('But I am not');
+			_(message || 'binary expression');
+			_(message ? message : 'conditional operator');
+			_('FOO.bar');
+		`;
+			const keys = parser.extract(contents, componentFilename)?.keys();
+			expect(keys).to.deep.equal(['Hello world', 'I', 'am', 'extracted', 'binary expression', 'conditional operator', 'FOO.bar']);
+		});
+
+		it('should extract strings using an alias function', () => {
+			const contents = `
+			import {_ as marker} from '@ngx-translate/core';
+			marker('Hello world');
+			marker(['I', 'am', 'extracted']);
+			otherFunction('But I am not');
+			marker(message || 'binary expression');
+			marker(message ? message : 'conditional operator');
+			marker('FOO.bar');
+		`;
+			const keys = parser.extract(contents, componentFilename)?.keys();
+			expect(keys).to.deep.equal(['Hello world', 'I', 'am', 'extracted', 'binary expression', 'conditional operator', 'FOO.bar']);
+		});
+
+		it('should extract split strings', () => {
+			const contents = `
+			import {_} from '@ngx-translate/core';
+			_('Hello ' + 'world');
+			_('This is a ' + 'very ' + 'very ' + 'very ' + 'very ' + 'long line.');
+			_('Mix ' + \`of \` + 'different ' + \`types\`);
+		`;
+			const keys = parser.extract(contents, componentFilename).keys();
+			expect(keys).to.deep.equal(['Hello world', 'This is a very very very very long line.', 'Mix of different types']);
+		});
+
+		it('should extract split strings while keeping html tags', () => {
+			const contents = `
+			import {_} from '@ngx-translate/core';
+			_('Hello ' + 'world');
+			_('This <em>is</em> a ' + 'very ' + 'very ' + 'very ' + 'very ' + 'long line.');
+			_('Mix ' + \`of \` + 'different ' + \`types\`);
+		`;
+			const keys = parser.extract(contents, componentFilename).keys();
+			expect(keys).to.deep.equal(['Hello world', 'This <em>is</em> a very very very very long line.', 'Mix of different types']);
+		});
+	})
 });
diff --git a/tests/parsers/service.parser.spec.ts b/tests/parsers/service.parser.spec.ts
index 2b16239..5bf5fb1 100644
--- a/tests/parsers/service.parser.spec.ts
+++ b/tests/parsers/service.parser.spec.ts
@@ -314,6 +314,22 @@ describe('ServiceParser', () => {
 		expect(keys).to.deep.equal(['Hello World']);
 	});
 
+	it('should extract strings when TranslateService is declared as a private property', () => {
+		const contents = `
+			export class MyComponent {
+				readonly #translate: TranslateService;
+				public constructor(translateService: TranslateService) {
+					this.#translate = translateService;
+				}
+				public test() {
+					this.#translate.instant('Hello World');
+				}
+			}
+		`;
+		const keys = parser.extract(contents, componentFilename)?.keys();
+		expect(keys).to.deep.equal(['Hello World']);
+	});
+
 	it('should extract strings when TranslateService is injected using the inject function ', () => {
 		const contents = `
 			export class MyComponent {
@@ -328,6 +344,23 @@ describe('ServiceParser', () => {
 		expect(keys).to.deep.equal(['Hello World']);
 	});
 
+	it('should locate TranslateService when it is a JavaScript native private property', () => {
+		const contents = `
+			@Component({})
+			export class MyComponent {
+			    readonly #translate = inject(TranslateService);
+				
+				public test() {
+					this.#translate.get('get.works');
+					this.#translate.stream('stream.works');
+					this.#translate.instant('instant.works');
+				}
+			}
+		`;
+		const keys = parser.extract(contents, componentFilename)?.keys();
+		expect(keys).to.deep.equal(['get.works', 'stream.works', 'instant.works']);
+	});
+
 	it('should extract strings passed to TranslateServices methods only', () => {
 		const contents = `
 			export class AppComponent implements OnInit {