Repository: vendure-ecommerce/ngx-translate-extract (ts)

Issue:
TranslateService used as an injected const does not work in a const method (like HttpInterceptorFn)
A fix was done for readonly inject earlier in version 9.1.1. Additionally somewhat related support is required for cases like ResolveFn, HttpInterceptorFn,... which are const's rather than classes.

In ng14 or something the following syntax 
`@Injectable()
export class ErrorToastInterceptor implements HttpInterceptor {`
was (optionally) changed to
`export const errorToastInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {`

Where the TranslationService is injected as a const, like:
`const translateService = inject(TranslateService);`

`translateService.instant('KEY_NAME')` is unfortunately not picked up :( I guess a method has to be added to the ast-helpers.ts to find the const translateService injectable, in a const method. 

Thanks for looking into it!

Pull request description:
feat: Add support for extraction of translation keys from function expressions
I tested it in a couple of small / medium sized projects and there was not any issue. However @michaelbromley if you merge this PR it would be better, if possible, to do a pre-release (beta/ next) before proceeding to a stable release to allow for more testing and make sure that these changes won't cause disruption.

Closes #44

Patch:
diff --git a/README.md b/README.md
index 2ec7dde..ebcfbfb 100644
--- a/README.md
+++ b/README.md
@@ -115,9 +115,10 @@ Output
   --strip-prefix, -sp         Strip prefix from key                                                       [string]
 
 Extracted key value (defaults to empty string)
-  --key-as-default-value, -k     Use key as default value                                                [boolean]
-  --null-as-default-value, -n    Use null as default value                                               [boolean]
-  --string-as-default-value, -d  Use string as default value                                              [string]
+  --key-as-default-value, -k           Use key as default value                                          [boolean]
+  --key-as-initial-default-value, -ki  Use key as initial default value                                  [boolean]
+  --null-as-default-value, -n          Use null as default value                                         [boolean]
+  --string-as-default-value, -d        Use string as default value                                        [string]
 
 Options:
   --version, -v  Show version number                                                                     [boolean]
diff --git a/src/cli/cli.ts b/src/cli/cli.ts
index ded7af5..d486c38 100755
--- a/src/cli/cli.ts
+++ b/src/cli/cli.ts
@@ -11,6 +11,7 @@ import { FunctionParser } from '../parsers/function.parser.js';
 import { PostProcessorInterface } from '../post-processors/post-processor.interface.js';
 import { SortByKeyPostProcessor } from '../post-processors/sort-by-key.post-processor.js';
 import { KeyAsDefaultValuePostProcessor } from '../post-processors/key-as-default-value.post-processor.js';
+import { KeyAsInitialDefaultValuePostProcessor } from '../post-processors/key-as-initial-default-value.post-processor.js';
 import { NullAsDefaultValuePostProcessor } from '../post-processors/null-as-default-value.post-processor.js';
 import { StringAsDefaultValuePostProcessor } from '../post-processors/string-as-default-value.post-processor.js';
 import { PurgeObsoleteKeysPostProcessor } from '../post-processors/purge-obsolete-keys.post-processor.js';
@@ -99,19 +100,25 @@ const cli = await y
 		alias: 'k',
 		describe: 'Use key as default value',
 		type: 'boolean',
-		conflicts: ['null-as-default-value', 'string-as-default-value']
+		conflicts: ['key-as-initial-default-value', 'null-as-default-value', 'string-as-default-value']
+	})
+	.option('key-as-initial-default-value', {
+		alias: 'ki',
+		describe: 'Use key as initial default value',
+		type: 'boolean',
+		conflicts: ['key-as-default-value', 'null-as-default-value', 'string-as-default-value']
 	})
 	.option('null-as-default-value', {
 		alias: 'n',
 		describe: 'Use null as default value',
 		type: 'boolean',
-		conflicts: ['key-as-default-value', 'string-as-default-value']
+		conflicts: ['key-as-default-value', 'key-as-initial-default-value', 'string-as-default-value']
 	})
 	.option('string-as-default-value', {
 		alias: 'd',
 		describe: 'Use string as default value',
 		type: 'string',
-		conflicts: ['null-as-default-value', 'key-as-default-value']
+		conflicts: ['null-as-default-value', 'key-as-default-value', 'key-as-initial-default-value']
 	})
 	.option('strip-prefix', {
 		alias: 'sp',
@@ -119,8 +126,9 @@ const cli = await y
 		type: 'string'
 	})
 	.group(['format', 'format-indentation', 'sort', 'clean', 'replace', 'strip-prefix'], 'Output')
-	.group(['key-as-default-value', 'null-as-default-value', 'string-as-default-value'], 'Extracted key value (defaults to empty string)')
+	.group(['key-as-default-value', 'key-as-initial-default-value', 'null-as-default-value', 'string-as-default-value'], 'Extracted key value (defaults to empty string)')
 	.conflicts('key-as-default-value', 'null-as-default-value')
+	.conflicts('key-as-initial-default-value', 'null-as-default-value')
 	.example('$0 -i ./src-a/ -i ./src-b/ -o strings.json', 'Extract (ts, html) from multiple paths')
 	.example("$0 -i './{src-a,src-b}/' -o strings.json", 'Extract (ts, html) from multiple paths using brace expansion')
 	.example('$0 -i ./src/ -o ./i18n/da.json -o ./i18n/en.json', 'Extract (ts, html) and save to da.json and en.json')
@@ -156,6 +164,8 @@ if (cli.clean) {
 }
 if (cli.keyAsDefaultValue) {
 	postProcessors.push(new KeyAsDefaultValuePostProcessor());
+} else if (cli.keyAsInitialDefaultValue) {
+	postProcessors.push(new KeyAsInitialDefaultValuePostProcessor());
 } else if (cli.nullAsDefaultValue) {
 	postProcessors.push(new NullAsDefaultValuePostProcessor());
 } else if (cli.stringAsDefaultValue) {
diff --git a/src/index.ts b/src/index.ts
index 58a0720..1e8ebd4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -19,6 +19,7 @@ export * from './compilers/po.compiler.js';
 
 export * from './post-processors/post-processor.interface.js';
 export * from './post-processors/key-as-default-value.post-processor.js';
+export * from './post-processors/key-as-initial-default-value.post-processor.js';
 export * from './post-processors/purge-obsolete-keys.post-processor.js';
 export * from './post-processors/sort-by-key.post-processor.js';
 export * from './post-processors/strip-prefix.post-processor.js';
diff --git a/src/parsers/marker.parser.ts b/src/parsers/marker.parser.ts
index 1015be5..c9812da 100644
--- a/src/parsers/marker.parser.ts
+++ b/src/parsers/marker.parser.ts
@@ -1,4 +1,5 @@
-import { tsquery } from '@phenomnomnominal/tsquery';
+import { ScriptKind, tsquery } from '@phenomnomnominal/tsquery';
+import { extname } from 'path';
 
 import { ParserInterface } from './parser.interface.js';
 import { TranslationCollection } from '../utils/translation.collection.js';
@@ -9,7 +10,16 @@ const MARKER_IMPORT_NAME = 'marker';
 
 export class MarkerParser implements ParserInterface {
 	public extract(source: string, filePath: string): TranslationCollection | null {
-		const sourceFile = tsquery.ast(source, filePath);
+		const supportedScriptTypes: Record<string, ScriptKind> = {
+			'.js': ScriptKind.JS,
+			'.jsx': ScriptKind.JSX,
+			'.ts': ScriptKind.TS,
+			'.tsx': ScriptKind.TSX
+		};
+
+		const scriptKind = supportedScriptTypes[extname(filePath)] ?? ScriptKind.TS;
+
+		const sourceFile = tsquery.ast(source, filePath, scriptKind);
 
 		const markerImportName = getNamedImportAlias(sourceFile, MARKER_MODULE_NAME, MARKER_IMPORT_NAME);
 		if (!markerImportName) {
diff --git a/src/parsers/pipe.parser.ts b/src/parsers/pipe.parser.ts
index 5449287..5959b22 100644
--- a/src/parsers/pipe.parser.ts
+++ b/src/parsers/pipe.parser.ts
@@ -15,7 +15,8 @@ import {
 	TmplAstSwitchBlock,
 	TmplAstDeferredBlock,
 	TmplAstForLoopBlock,
-	TmplAstElement
+	TmplAstElement,
+	KeyedRead
 } from '@angular/compiler';
 
 import { ParserInterface } from './parser.interface.js';
@@ -195,6 +196,19 @@ export class PipeParser implements ParserInterface {
 			return this.getTranslatablesFromAsts(ast.args);
 		}
 
+		// immediately accessed static object or array - the angular parser bundles this as "KeyedRead", where:
+		// { 'a': 1, 'b': 2 }[ 'a' ];
+		//                     ^^^ <- keyedRead.key
+		// ^^^^^^^^^^^^^^^^^^ <- keyedRead.receiver
+		//
+		// html examples:
+		// - { key1: 'value1' | translate, key2: 'value2' | translate }[key]
+		// - [ 'value1' | translate, 'value2' | translate ][key]
+		// - [ 'foo', 'bar' ][ 'key' | translate ]
+		if (ast instanceof KeyedRead) {
+			return this.getTranslatablesFromAsts([ast.receiver, ast.key]);
+		}
+
 		return [];
 	}
 
diff --git a/src/parsers/service.parser.ts b/src/parsers/service.parser.ts
index 9dcede9..d2cbb21 100644
--- a/src/parsers/service.parser.ts
+++ b/src/parsers/service.parser.ts
@@ -17,7 +17,9 @@ import {
 	findMethodParameterByType,
 	findConstructorDeclaration,
 	getSuperClassName,
-	getImportPath
+	getImportPath,
+	findFunctionExpressions,
+	findVariableNameByInjectType
 } from '../utils/ast-helpers.js';
 
 const TRANSLATE_SERVICE_TYPE_REFERENCE = 'TranslateService';
@@ -28,29 +30,41 @@ export class ServiceParser implements ParserInterface {
 
 	public extract(source: string, filePath: string): TranslationCollection | null {
 		const sourceFile = tsquery.ast(source, filePath);
-
 		const classDeclarations = findClassDeclarations(sourceFile);
-		if (!classDeclarations) {
+		const functionDeclarations = findFunctionExpressions(sourceFile);
+
+		if (!classDeclarations && !functionDeclarations) {
 			return null;
 		}
 
 		let collection: TranslationCollection = new TranslationCollection();
 
+		const translateServiceCallExpressions: CallExpression[] = [];
+
+		functionDeclarations.forEach((fnDeclaration) => {
+			const translateServiceVariableName = findVariableNameByInjectType(fnDeclaration, TRANSLATE_SERVICE_TYPE_REFERENCE);
+			const callExpressions = findMethodCallExpressions(sourceFile, translateServiceVariableName, TRANSLATE_SERVICE_METHOD_NAMES);
+			translateServiceCallExpressions.push(...callExpressions);
+		});
+
 		classDeclarations.forEach((classDeclaration) => {
 			const callExpressions = [
 				...this.findConstructorParamCallExpressions(classDeclaration),
 				...this.findPropertyCallExpressions(classDeclaration, sourceFile)
 			];
 
-			callExpressions.forEach((callExpression) => {
+			translateServiceCallExpressions.push(...callExpressions);
+		});
+
+		translateServiceCallExpressions
+			.filter((callExpression) => !!callExpression.arguments?.[0])
+			.forEach((callExpression) => {
 				const [firstArg] = callExpression.arguments;
-				if (!firstArg) {
-					return;
-				}
+
 				const strings = getStringsFromExpression(firstArg);
 				collection = collection.addKeys(strings, filePath);
 			});
-		});
+
 		return collection;
 	}
 
diff --git a/src/post-processors/key-as-initial-default-value.post-processor.ts b/src/post-processors/key-as-initial-default-value.post-processor.ts
new file mode 100644
index 0000000..ccb31e5
--- /dev/null
+++ b/src/post-processors/key-as-initial-default-value.post-processor.ts
@@ -0,0 +1,10 @@
+import {TranslationCollection, TranslationInterface} from '../utils/translation.collection.js';
+import { PostProcessorInterface } from './post-processor.interface.js';
+
+export class KeyAsInitialDefaultValuePostProcessor implements PostProcessorInterface {
+	public name: string = 'KeyAsInitialDefaultValue';
+
+	public process(draft: TranslationCollection, extracted: TranslationCollection, existing: TranslationCollection): TranslationCollection {
+		return draft.map((key: string, val: TranslationInterface): TranslationInterface => val.value === '' && !existing.has(key) ? {value: key, sourceFiles: (val?.sourceFiles || [])} : val);
+	}
+}
diff --git a/src/utils/ast-helpers.ts b/src/utils/ast-helpers.ts
index 62dab9d..6c9e6ee 100644
--- a/src/utils/ast-helpers.ts
+++ b/src/utils/ast-helpers.ts
@@ -42,6 +42,10 @@ export function findClassDeclarations(node: Node, name: string = null): ClassDec
 	return tsquery<ClassDeclaration>(node, query);
 }
 
+export function findFunctionExpressions(node: Node) {
+	return tsquery(node, 'VariableDeclaration > ArrowFunction, VariableDeclaration > FunctionExpression');
+}
+
 export function getSuperClassName(node: Node): string | null {
 	const query = 'ClassDeclaration > HeritageClause Identifier';
 	const [result] = tsquery<Identifier>(node, query);
@@ -78,12 +82,23 @@ export function findMethodParameterByType(node: Node, type: string): string | nu
 	return null;
 }
 
+export function findVariableNameByInjectType(node: Node, type: string): string | null {
+	const query = `VariableDeclaration:has(Identifier[name="inject"]):has(CallExpression > Identifier[name="${type}"]) > Identifier`;
+	const [result] = tsquery<Identifier>(node, query);
+
+	return result?.text ?? null;
+}
+
 export function findMethodCallExpressions(node: Node, propName: string, fnName: string | string[]): CallExpression[] {
-	if (Array.isArray(fnName)) {
-		fnName = fnName.join('|');
-	}
-	const query = `CallExpression > PropertyAccessExpression:has(Identifier[name=/^(${fnName})$/]):has(PropertyAccessExpression:has(Identifier[name="${propName}"]):not(:has(ThisKeyword)))`;
-	return tsquery<PropertyAccessExpression>(node, query).map((n) => n.parent as CallExpression);
+	const functionNames = typeof fnName === 'string' ? [fnName] : fnName;
+
+	const fnNameRegex = functionNames.join('|');
+
+	const query = `CallExpression > PropertyAccessExpression:has(Identifier[name=/^(${fnNameRegex})$/]):has(PropertyAccessExpression:has(Identifier[name="${propName}"]):not(:has(ThisKeyword)))`;
+
+	return tsquery(node, query)
+		.filter((n) => functionNames.includes(n.getLastToken().getText()))
+		.map((n) => n.parent as CallExpression);
 }
 
 export function findClassPropertiesConstructorParameterByType(node: ClassDeclaration, type: string): string[] {


Test patch:
diff --git a/tests/parsers/marker.parser.spec.ts b/tests/parsers/marker.parser.spec.ts
index db6f0e9..ec20cfb 100644
--- a/tests/parsers/marker.parser.spec.ts
+++ b/tests/parsers/marker.parser.spec.ts
@@ -71,4 +71,20 @@ describe('MarkerParser', () => {
 		const keys = parser.extract(contents, componentFilename).keys();
 		expect(keys).to.deep.equal(['Hello world']);
 	});
+
+	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 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']);
+	});
 });
diff --git a/tests/parsers/pipe.parser.spec.ts b/tests/parsers/pipe.parser.spec.ts
index 9eca297..d126b26 100644
--- a/tests/parsers/pipe.parser.spec.ts
+++ b/tests/parsers/pipe.parser.spec.ts
@@ -73,6 +73,34 @@ describe('PipeParser', () => {
 				expect(keys).to.deep.equal(['Hello', 'World']);
 			});
 
+			it('should extract strings from object map', () => {
+				const contents = `{{ {
+					choice1: 'Hello' | ${translatePipeName},
+					choice2: 'World' | ${translatePipeName},
+				}[choice] }}`;
+				const keys = parser.extract(contents, templateFilename).keys();
+				expect(keys).to.deep.equal(['Hello', 'World']);
+			});
+
+			it('should extract strings from object map inside attribute', () => {
+				const contents = `
+					<span [attr]="{
+						choice1: 'Hello' | ${translatePipeName},
+						choice2: 'World' | ${translatePipeName}
+					}[choice]"></span>`;
+				const keys = parser.extract(contents, templateFilename).keys();
+				expect(keys).to.deep.equal(['Hello', 'World']);
+			});
+
+			it('should extract strings from KeyedRead.key', () => {
+				const contents = `{{ {
+					choice1: 'Foo',
+					choice2: 'Bar',
+				}[ 'choice' | ${translatePipeName} ] }}`;
+				const keys = parser.extract(contents, templateFilename).keys();
+				expect(keys).to.deep.equal(['choice']);
+			});
+
 			it('should extract strings from object', () => {
 				const contents = `{{ { foo: 'Hello' | ${translatePipeName}, bar: ['World' | ${translatePipeName}], deep: { nested: { baz: 'Yes' | ${translatePipeName} } } } | json }}`;
 				const keys = parser.extract(contents, templateFilename).keys();
diff --git a/tests/parsers/service.parser.spec.ts b/tests/parsers/service.parser.spec.ts
index 8f6e235..66bcc86 100644
--- a/tests/parsers/service.parser.spec.ts
+++ b/tests/parsers/service.parser.spec.ts
@@ -547,4 +547,67 @@ describe('ServiceParser', () => {
 		const keys = parser.extract(contents, componentFilename)?.keys();
 		expect(keys).to.deep.equal([]);
 	});
+
+	describe('function expressions', () => {
+		it('should extract from arrow function expression', () => {
+			const contents = `
+			export const errorInterceptor: HttpInterceptorFn = (req, next) => {
+				const translateService = inject(TranslateService);
+				const router = inject(Router);
+	
+				translateService.instant('translation.key');
+	
+				const nestedFunction = () => {
+					translateService.instant('translation.key.from.nested.function');
+				}
+			};
+			`;
+			const keys = parser.extract(contents, componentFilename)?.keys();
+			expect(keys).to.deep.equal(['translation.key', 'translation.key.from.nested.function']);
+		});
+
+		it('should extract from function expression', () => {
+			const contents = `
+			export const errorInterceptor: HttpInterceptorFn = function(req, next) {
+				const translateService = inject(TranslateService);
+				const router = inject(Router);
+	
+				translateService.instant('translation.key')
+	
+				const nestedFunction = function() {
+					translateService.instant('translation.key.from.nested.function');
+				}
+			};
+			`;
+			const keys = parser.extract(contents, componentFilename)?.keys();
+			expect(keys).to.deep.equal(['translation.key', 'translation.key.from.nested.function']);
+		});
+
+		it("should extract strings from TranslateService's get(), instant() and stream() method", () => {
+			const contents = `
+			export const errorInterceptor: HttpInterceptorFn = (req, next) => {
+				const translate = inject(TranslateService);
+	
+				translate.get('get.translation.key')
+				translate.instant('instant.translation.key')
+				translate.stream('stream.translation.key')
+			};
+			`;
+			const keys = parser.extract(contents, componentFilename)?.keys();
+			expect(keys).to.deep.equal(['get.translation.key', 'instant.translation.key', 'stream.translation.key']);
+		});
+
+		it('should not extract chained function calls', () => {
+			const contents = `
+			export const errorInterceptor: HttpInterceptorFn = (req, next) => {
+				const translate = inject(TranslateService);
+	
+				const strings = ['a', 'b', 'c'];
+				strings.map(string => translate.instant(string)).join(', ');
+			};
+			`;
+			const keys = parser.extract(contents, componentFilename)?.keys();
+			expect(keys).to.deep.equal([]);
+		});
+	});
 });
diff --git a/tests/post-processors/key-as-initial-default-value.post-processor.spec.ts b/tests/post-processors/key-as-initial-default-value.post-processor.spec.ts
new file mode 100644
index 0000000..cd776db
--- /dev/null
+++ b/tests/post-processors/key-as-initial-default-value.post-processor.spec.ts
@@ -0,0 +1,33 @@
+import { describe, beforeEach, expect, it } from 'vitest';
+
+import { PostProcessorInterface } from '../../src/post-processors/post-processor.interface.js';
+import { KeyAsInitialDefaultValuePostProcessor } from '../../src/post-processors/key-as-initial-default-value.post-processor.js';
+import { TranslationCollection } from '../../src/utils/translation.collection.js';
+
+describe('KeyAsInitialDefaultValuePostProcessor', () => {
+	let processor: PostProcessorInterface;
+
+	beforeEach(() => {
+		processor = new KeyAsInitialDefaultValuePostProcessor();
+	});
+
+	it('should use key as default value', () => {
+		const collection = new TranslationCollection({
+			'I have no value': { value: '', sourceFiles: [] },
+			'I have no value but I exist': { value: '', sourceFiles: [] },
+			'I am already translated': { value: 'Jeg er allerede oversat', sourceFiles: [] },
+			'Use this key as value as well': { value: '', sourceFiles: ['path/to/file.ts'] }
+		});
+		const extracted = new TranslationCollection();
+		const existing = new TranslationCollection({
+			'I have no value but I exist': { value: '', sourceFiles: [] }
+		});
+
+		expect(processor.process(collection, extracted, existing).values).to.deep.equal({
+			'I have no value': { value: 'I have no value', sourceFiles: [] },
+			'I have no value but I exist': { value: '', sourceFiles: [] },
+			'I am already translated': { value: 'Jeg er allerede oversat', sourceFiles: [] },
+			'Use this key as value as well': { value: 'Use this key as value as well', sourceFiles: ['path/to/file.ts'] }
+		});
+	});
+});