├── .husky ├── pre-commit └── commit-msg ├── .prettierrc ├── .gitignore ├── .lintstagedrc ├── logo.png ├── commitlint.config.js ├── src ├── marker.ts ├── keys-builder │ ├── .DS_Store │ ├── typescript │ │ ├── types.ts │ │ ├── pure-function.extractor.ts │ │ ├── inline-template.ts │ │ ├── marker.extractor.ts │ │ ├── signal.extractor.ts │ │ ├── service.extractor.ts │ │ ├── build-keys-from-ast-nodes.ts │ │ └── index.ts │ ├── template │ │ ├── types.ts │ │ ├── index.ts │ │ ├── pipe.extractor.ts │ │ └── directive.extractor.ts │ ├── utils │ │ ├── scope.utils.ts │ │ ├── remove-extra-keys.ts │ │ ├── run-prettier.ts │ │ ├── extract-keys.ts │ │ ├── resolvers.utils.ts │ │ ├── get-current-translation.ts │ │ └── create-translation.ts │ ├── build-keys.ts │ ├── build-translation-file.ts │ ├── index.ts │ ├── add-key.ts │ ├── create-translation-files.ts │ └── add-comment-section-keys.ts ├── public-api.ts ├── utils │ ├── init-extraction.ts │ ├── collection.utils.ts │ ├── string.utils.ts │ ├── regexs.utils.ts │ ├── json.utils.ts │ ├── normalize-glob-path.ts │ ├── file.utils.ts │ ├── validators.utils.ts │ ├── logger.ts │ ├── object.utils.ts │ ├── keys.utils.ts │ ├── path.utils.ts │ ├── resolve-config.ts │ └── resolve-project-base-path.ts ├── keys-detective │ ├── get-translation-files-path.ts │ ├── map-diff-to-keys.ts │ ├── index.ts │ └── build-table.ts ├── messages.ts ├── index.ts ├── types.ts ├── webpack-plugin │ └── generate-keys.ts ├── config.ts └── cli-options.ts ├── .babelrc.js ├── __tests__ ├── buildTranslationFiles │ ├── template-extraction │ │ ├── prefix │ │ │ ├── src │ │ │ │ ├── nested-scope.ts │ │ │ │ ├── admin-scope.ts │ │ │ │ ├── 3.html │ │ │ │ ├── 4.html │ │ │ │ └── 2.html │ │ │ └── prefix-spec.ts │ │ ├── ng-container │ │ │ ├── src │ │ │ │ ├── todos-scope.ts │ │ │ │ ├── nested-scope.ts │ │ │ │ ├── admin-scope.ts │ │ │ │ ├── 3.html │ │ │ │ └── 2.html │ │ │ ├── with-params │ │ │ │ └── 1.html │ │ │ └── ng-container-spec.ts │ │ ├── ng-template │ │ │ ├── src │ │ │ │ ├── todos-scope.ts │ │ │ │ ├── nested-scope.ts │ │ │ │ ├── admin-scope.ts │ │ │ │ ├── 3.html │ │ │ │ ├── 5.html │ │ │ │ └── 2.html │ │ │ ├── with-params │ │ │ │ └── 1.html │ │ │ └── ng-template-spec.ts │ │ ├── scope │ │ │ ├── src │ │ │ │ ├── scopes.ts │ │ │ │ ├── component-scopes.ts │ │ │ │ └── 1.html │ │ │ └── scope-spec.ts │ │ ├── pipe │ │ │ ├── with-params │ │ │ │ └── 1.html │ │ │ ├── src │ │ │ │ ├── 4.html │ │ │ │ ├── 1.html │ │ │ │ ├── 3.html │ │ │ │ ├── 6.html │ │ │ │ └── 5.html │ │ │ └── pipe-spec.ts │ │ ├── control-flow │ │ │ ├── control-flow-spec.ts │ │ │ └── src │ │ │ │ └── 1.html │ │ └── directive │ │ │ ├── src │ │ │ └── 1.html │ │ │ ├── with-params │ │ │ └── 1.html │ │ │ └── directive-spec.ts │ ├── config-options │ │ ├── scope-mapping │ │ │ ├── src │ │ │ │ ├── todos-scope.ts │ │ │ │ ├── scopes.ts │ │ │ │ ├── 1.html │ │ │ │ └── component-scopes.ts │ │ │ └── scope-mapping-spec.ts │ │ ├── unflat │ │ │ ├── src │ │ │ │ └── 1.ts │ │ │ └── unflat-spec.ts │ │ ├── multi-input │ │ │ ├── src │ │ │ │ ├── folder-2 │ │ │ │ │ ├── todos-scope.ts │ │ │ │ │ └── 3.html │ │ │ │ └── folder-1 │ │ │ │ │ ├── nested-scope.ts │ │ │ │ │ ├── admin-scope.ts │ │ │ │ │ └── 2.html │ │ │ └── multi-input-spec.ts │ │ ├── unflat-sort │ │ │ ├── src │ │ │ │ └── 1.html │ │ │ └── unflat-sort-spec.ts │ │ ├── remove-extra-keys │ │ │ └── src │ │ │ │ ├── 1-before.html.in │ │ │ │ └── 1-after.html.in │ │ └── unflat-problematic-keys │ │ │ ├── src │ │ │ └── 1.html │ │ │ └── unflat-problomatic-keys-spec.ts │ ├── ts-extraction │ │ ├── service │ │ │ ├── src │ │ │ │ ├── todos-scope.ts │ │ │ │ ├── inject.ts │ │ │ │ ├── private-property.ts │ │ │ │ ├── store-in-variable.ts │ │ │ │ └── constructor-injection-2.ts │ │ │ ├── with-params │ │ │ │ ├── todos-scope.ts │ │ │ │ ├── inject.ts │ │ │ │ ├── private-property.ts │ │ │ │ └── store-in-variable.ts │ │ │ └── service-spec.ts │ │ ├── signal │ │ │ ├── src │ │ │ │ ├── with-alias.ts │ │ │ │ ├── basic.ts │ │ │ │ └── mixed-import.ts │ │ │ └── signal-spec.ts │ │ ├── pure-function │ │ │ ├── src │ │ │ │ └── issue-192.ts │ │ │ ├── with-params │ │ │ │ └── 1.ts │ │ │ └── pure-function-spec.ts │ │ ├── marker │ │ │ ├── src │ │ │ │ ├── basic.ts │ │ │ │ ├── incorrect-imports.ts │ │ │ │ └── with-alias.ts │ │ │ └── marker-spec.ts │ │ └── inline-template │ │ │ ├── inline-template-spec.ts │ │ │ └── src │ │ │ └── 1.ts │ ├── comments │ │ ├── src │ │ │ ├── 1.html │ │ │ ├── 1.ts │ │ │ ├── 3.ts │ │ │ ├── 2.html │ │ │ ├── 3.html │ │ │ └── 2.ts │ │ └── comments-spec.ts │ ├── build-translation-utils.ts │ └── buildTranslationFiles.spec.ts ├── resolveConfig │ └── src │ │ ├── 1.html │ │ └── folder │ │ └── 2.html ├── findMissingKeys │ ├── add-missing-keys │ │ ├── src │ │ │ └── 1.html │ │ └── add-missing-keys-spec.ts │ └── findMissingKeys.spec.ts ├── defaultConfig.spec.ts ├── mapDiffToKeys.spec.ts └── spec-utils.ts ├── vitest.config.ts ├── tsconfig.spec.json ├── tsconfig.json ├── .github ├── workflows │ └── ci.yml ├── pull_request_template.md └── ISSUE_TEMPLATE │ ├── feature-request.yaml │ └── bug-report.yaml ├── scripts └── post-build.js ├── LICENSE ├── README.md ├── BREAKING_CHANGES.md ├── CODE_OF_CONDUCT.md └── package.json /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | dist 4 | **/i18n 5 | .idx 6 | .vscode -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,js,json,yml}": ["prettier --write"] 3 | } 4 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsverse/transloco-keys-manager/HEAD/logo.png -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /src/marker.ts: -------------------------------------------------------------------------------- 1 | export function marker(key: T): T { 2 | return key; 3 | } 4 | -------------------------------------------------------------------------------- /src/keys-builder/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsverse/transloco-keys-manager/HEAD/src/keys-builder/.DS_Store -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-env', 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /src/keys-builder/typescript/types.ts: -------------------------------------------------------------------------------- 1 | export type TSExtractorResult = { 2 | key: string; 3 | lang: string; 4 | params: string[]; 5 | }[]; 6 | -------------------------------------------------------------------------------- /src/public-api.ts: -------------------------------------------------------------------------------- 1 | export { TranslocoExtractKeysWebpackPlugin } from './webpack-plugin/webpack-plugin'; 2 | export { marker } from './marker'; 3 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/prefix/src/nested-scope.ts: -------------------------------------------------------------------------------- 1 | export const v = { provide: TRANSLOCO_SCOPE, useValue: 'todos-page' }; 2 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/scope-mapping/src/todos-scope.ts: -------------------------------------------------------------------------------- 1 | export const d = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: 'scope1', 4 | }; 5 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/unflat/src/1.ts: -------------------------------------------------------------------------------- 1 | import {} from '@jsverse/transloco'; 2 | 3 | class a { 4 | /** t(a.1) */ 5 | method() {} 6 | } 7 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-container/src/todos-scope.ts: -------------------------------------------------------------------------------- 1 | const d = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: 'todos-page', 4 | }; 5 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-template/src/todos-scope.ts: -------------------------------------------------------------------------------- 1 | const f = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: 'todos-page', 4 | }; 5 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/service/src/todos-scope.ts: -------------------------------------------------------------------------------- 1 | export const d = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: 'todos-page', 4 | }; 5 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/multi-input/src/folder-2/todos-scope.ts: -------------------------------------------------------------------------------- 1 | const d = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: 'todos-page', 4 | }; 5 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-container/src/nested-scope.ts: -------------------------------------------------------------------------------- 1 | const f = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: 'nested/scope', 4 | }; 5 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-template/src/nested-scope.ts: -------------------------------------------------------------------------------- 1 | const f = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: 'nested/scope', 4 | }; 5 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/service/with-params/todos-scope.ts: -------------------------------------------------------------------------------- 1 | export const d = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: 'todos-page', 4 | }; 5 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/multi-input/src/folder-1/nested-scope.ts: -------------------------------------------------------------------------------- 1 | const f = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: 'nested/scope', 4 | }; 5 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-template/src/admin-scope.ts: -------------------------------------------------------------------------------- 1 | const v = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: { scope: 'admin-page', alias: 'adminPage' }, 4 | }; 5 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/multi-input/src/folder-1/admin-scope.ts: -------------------------------------------------------------------------------- 1 | const f = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: { scope: 'admin-page', alias: 'adminPage' }, 4 | }; 5 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-container/src/admin-scope.ts: -------------------------------------------------------------------------------- 1 | const f = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: { scope: 'admin-page', alias: 'adminPage' }, 4 | }; 5 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/prefix/src/admin-scope.ts: -------------------------------------------------------------------------------- 1 | export const a = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: { scope: 'admin-page', alias: 'adminPage' }, 4 | }; 5 | -------------------------------------------------------------------------------- /src/utils/init-extraction.ts: -------------------------------------------------------------------------------- 1 | import { ExtractionResult } from '../types'; 2 | 3 | export function initExtraction(): ExtractionResult { 4 | return { scopeToKeys: { __global: {} }, fileCount: 0 }; 5 | } 6 | -------------------------------------------------------------------------------- /__tests__/resolveConfig/src/1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /__tests__/resolveConfig/src/folder/2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /__tests__/findMissingKeys/add-missing-keys/src/1.html: -------------------------------------------------------------------------------- 1 |

{{ '1' | transloco }}

2 |

{{ 'a.b' | transloco }}

3 |

{{ 'c.d' | transloco }}

4 |

{{ '4' | transloco }}

5 |

{{ '5' | transloco }}

6 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/unflat-sort/src/1.html: -------------------------------------------------------------------------------- 1 | 2 | {{ t('b.c.x') }} 3 | {{ t('b.c.p') }} 4 | {{ t('b.c.a') }} 5 | {{ t('b.b.a') }} 6 | {{ t('b.b.b') }} 7 | 8 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/remove-extra-keys/src/1-before.html.in: -------------------------------------------------------------------------------- 1 | 2 | {{ t('1') }} 3 | {{ t('2') }} 4 | {{ t('group1.1') }} 5 | {{ t('group1.2') }} 6 | {{ t('group2.1') }} 7 | {{ t('group2.2') }} 8 | 9 | -------------------------------------------------------------------------------- /src/keys-builder/template/types.ts: -------------------------------------------------------------------------------- 1 | import type { ExtractorConfig } from '../../types.js'; 2 | 3 | export interface TemplateExtractorConfig extends ExtractorConfig { 4 | content?: string; 5 | } 6 | 7 | export interface ContainersMetadata { 8 | containerContent: string; 9 | read?: string; 10 | } 11 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/scope/src/scopes.ts: -------------------------------------------------------------------------------- 1 | export const d = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: 'scope1', 4 | }; 5 | export const b = provideTranslocoScope({ 6 | scope: 'scope2', 7 | alias: 'scopeAlias2', 8 | }); 9 | export const c = provideTranslocoScope('scope3'); 10 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/scope-mapping/src/scopes.ts: -------------------------------------------------------------------------------- 1 | export const d = { 2 | provide: TRANSLOCO_SCOPE, 3 | useValue: 'scope1', 4 | }; 5 | export const b = provideTranslocoScope({ 6 | scope: 'scope2', 7 | alias: 'scopeAlias2', 8 | }); 9 | export const c = provideTranslocoScope('scope3'); 10 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/pipe/with-params/1.html: -------------------------------------------------------------------------------- 1 |
2 | {{ '1' | transloco:{'1': ''} }} 3 |

4 |
5 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /src/keys-detective/get-translation-files-path.ts: -------------------------------------------------------------------------------- 1 | import { FileFormats } from '../types'; 2 | import { normalizedGlob } from '../utils/normalize-glob-path'; 3 | 4 | export function getTranslationFilesPath( 5 | path: string, 6 | fileFormat: FileFormats, 7 | ): string[] { 8 | return normalizedGlob(`${path}/**/*.${fileFormat}`); 9 | } 10 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/scope-mapping/src/1.html: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | environment: 'node', 7 | include: ['**/__tests__/**/*.spec.ts'], 8 | coverage: { 9 | reporter: ['text', 'html'], 10 | exclude: ['node_modules/'], 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/remove-extra-keys/src/1-after.html.in: -------------------------------------------------------------------------------- 1 | 2 | {{ t('2') }} 3 | {{ t('group1.2') }} 4 | 5 | {{ t('group3.2') }} 6 | 7 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/service/src/inject.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject } from '@angular/core'; 2 | import { TranslocoService } from '@jsverse/transloco'; 3 | 4 | @Component({ 5 | selector: 'inject-test', 6 | template: ``, 7 | }) 8 | export class InjectTestClass implements OnInit { 9 | transloco = inject(TranslocoService); 10 | 11 | ngOnInit() { 12 | this.transloco.translate('inject.test'); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/collection.utils.ts: -------------------------------------------------------------------------------- 1 | import { isUndefined } from './validators.utils'; 2 | 3 | export function coerceArray(value: T | T[]): NonNullable[]; 4 | export function coerceArray( 5 | value: T | readonly T[], 6 | ): readonly NonNullable[]; 7 | export function coerceArray(value: T | T[]): NonNullable[] { 8 | if (isUndefined(value)) return []; 9 | 10 | return (Array.isArray(value) ? value : [value]) as NonNullable[]; 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "ES2022", 5 | "esModuleInterop": true, 6 | "moduleResolution": "node", 7 | "types": ["vitest/globals"], 8 | "lib": ["es2020"] 9 | }, 10 | "files": ["vitest.config.ts"], 11 | "include": [ 12 | "__tests__/**/*-utils.ts", 13 | "__tests__/**/*-spec.ts", 14 | "__tests__/**/*.spec.ts" 15 | ], 16 | "exclude": ["node_modules"] 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/string.utils.ts: -------------------------------------------------------------------------------- 1 | export function sanitizeForRegex(str: string) { 2 | return str 3 | .split('') 4 | .map((char) => (['$', '^', '/'].includes(char) ? `\\${char}` : char)) 5 | .join(''); 6 | } 7 | 8 | export function toCamelCase(str: string) { 9 | return str 10 | .replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => 11 | index === 0 ? word.toLowerCase() : word.toUpperCase(), 12 | ) 13 | .replace(/\s+|_|-|\//g, ''); 14 | } 15 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/comments/src/1.html: -------------------------------------------------------------------------------- 1 | 2 |

{{ 'need.transloco' | transloco }}

3 |
4 |

{{title}}

5 | 6 | 7 | 8 |

{{ t(title) }}

9 |
10 | 11 | 12 | 13 |

{{ t(title) }}

14 |
15 |
16 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/service/with-params/inject.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject } from '@angular/core'; 2 | import { TranslocoService } from '@jsverse/transloco'; 3 | 4 | @Component({ 5 | selector: 'inject-test', 6 | template: ``, 7 | }) 8 | export class InjectTestClass implements OnInit { 9 | transloco = inject(TranslocoService); 10 | 11 | ngOnInit() { 12 | this.transloco.translate('inject.test', { inject: { test: 1 } }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-template/src/3.html: -------------------------------------------------------------------------------- 1 | 2 |

ddsds

3 | {{ t('32') }} 4 | 5 |

dsds

6 | 7 | dsds 8 | 9 | 10 | {{ b('33') }} 11 | 12 | 13 | 14 | 15 |
16 |
17 | -------------------------------------------------------------------------------- /src/keys-builder/typescript/pure-function.extractor.ts: -------------------------------------------------------------------------------- 1 | import { SourceFile } from 'typescript'; 2 | import { tsquery } from '@phenomnomnominal/tsquery'; 3 | 4 | import { buildKeysFromASTNodes } from './build-keys-from-ast-nodes'; 5 | import { TSExtractorResult } from './types'; 6 | 7 | export function pureFunctionExtractor(ast: SourceFile): TSExtractorResult { 8 | const fns = tsquery(ast, `CallExpression Identifier[text=translate]`); 9 | 10 | return buildKeysFromASTNodes(fns); 11 | } 12 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/unflat-problematic-keys/src/1.html: -------------------------------------------------------------------------------- 1 | 2 | {{ t('a') }} 3 | {{ t('a.b') }} 4 | {{ t('a.c') }} 5 | {{ t('b.a') }} 6 | {{ t('b.b') }} 7 | {{ t('b') }} 8 | {{ t('c') }} 9 | {{ t('d.1') }} 10 | {{ t('d.2') }} 11 | {{ t('e.a') }} 12 | {{ t('e.aa') }} 13 | {{ t('f') }} 14 | {{ t('f.a') }} 15 | {{ t('f.b') }} 16 | {{ t('f.a.a') }} 17 | {{ t('f.a.b') }} 18 | {{ t('f.b.a.a') }} 19 | 20 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/service/src/private-property.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject } from '@angular/core'; 2 | import { TranslocoService } from '@jsverse/transloco'; 3 | 4 | @Component({ 5 | selector: 'private-class-field-test', 6 | template: ``, 7 | }) 8 | export class PrivateClassFieldInjectTestClass implements OnInit { 9 | #transloco = inject(TranslocoService); 10 | 11 | ngOnInit() { 12 | this.#transloco.translate('private-class-field.test'); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/regexs.utils.ts: -------------------------------------------------------------------------------- 1 | import { sanitizeForRegex } from './string.utils'; 2 | 3 | export const regexFactoryMap = { 4 | ts: { 5 | comments: () => /\/\*\*[^]+?\*\//g, 6 | }, 7 | template: { 8 | comments: () => //g, 9 | validateComment: (marker: string) => 10 | new RegExp( 11 | ``, 12 | ), 13 | }, 14 | markerValues: (marker: string) => 15 | new RegExp(`\\b${sanitizeForRegex(marker)}\\(([^)]+)\\)`, 'g'), 16 | }; 17 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/comments/src/1.ts: -------------------------------------------------------------------------------- 1 | // @jsverse/transloco' 2 | 3 | class a { 4 | /** 5 | * t(a.some.key, 10, 10.1) 6 | */ 7 | method() {} 8 | 9 | /** 10 | * t( 11 | * 10.2, 12 | * 10.3, 10.4, 13 | * 10.5, 14 | * , 15 | * 10.6.7 16 | * ) 17 | */ 18 | method() {} 19 | 20 | /** 21 | * 22 | * some other comment with t 23 | * 24 | * hello world 25 | */ 26 | hello() {} 27 | 28 | /** t(11, 11.1, 11.2.3) */ 29 | method() {} 30 | 31 | /** 32 | * noextract(banana) 33 | */ 34 | } 35 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-template/with-params/1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{t('7') + 'a'}} 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/service/with-params/private-property.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, inject } from '@angular/core'; 2 | import { TranslocoService } from '@jsverse/transloco'; 3 | 4 | @Component({ 5 | selector: 'private-class-field-test', 6 | template: ``, 7 | }) 8 | export class PrivateClassFieldInjectTestClass implements OnInit { 9 | #transloco = inject(TranslocoService); 10 | 11 | ngOnInit() { 12 | this.#transloco.translate('private-class-field.test', { 13 | 'private-class-field': { test: 1 }, 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/comments/src/3.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { TRANSLOCO_SCOPE } from '@jsverse/transloco'; 3 | 4 | @Component({ 5 | selector: 'app-home', 6 | templateUrl: './home.component.html', 7 | providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'admin' }], 8 | styleUrls: ['./home.component.scss'], 9 | }) 10 | export class HomeComponent implements OnInit { 11 | constructor() {} 12 | 13 | /** 14 | * t( 15 | * from.comment, 16 | * pretty.cool.da 17 | * ) 18 | */ 19 | ngOnInit() {} 20 | } 21 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/signal/src/with-alias.ts: -------------------------------------------------------------------------------- 1 | import { translateSignal as tS } from '@jsverse/transloco'; 2 | 3 | @Component({ 4 | selector: 'bla-bla', 5 | template: ` 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 |
displayedTitle()
12 | {{ column }} 13 |
16 | `, 17 | }) 18 | export class Basic { 19 | displayedTitle = tS('title2'); 20 | displayedColumns = tS(['username2', 'password2']); 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/json.utils.ts: -------------------------------------------------------------------------------- 1 | import { ParseError, parse, printParseErrorCode } from 'jsonc-parser'; 2 | 3 | export function jsoncParser(filepath: string, content: string) { 4 | const errors: ParseError[] = []; 5 | const result = parse(content, errors, { 6 | allowTrailingComma: true, 7 | }); 8 | if (errors.length > 0) { 9 | const { error, offset } = errors[0]; 10 | throw new Error( 11 | `Failed to parse "${filepath}" as JSON AST Object. ${printParseErrorCode( 12 | error, 13 | )} at location: ${offset}.`, 14 | ); 15 | } 16 | return result; 17 | } 18 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/signal/src/basic.ts: -------------------------------------------------------------------------------- 1 | import { translateSignal } from '@jsverse/transloco'; 2 | 3 | @Component({ 4 | selector: 'bla-bla', 5 | template: ` 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 |
displayedTitle()
12 | {{ column }} 13 |
16 | `, 17 | }) 18 | export class Basic { 19 | displayedTitle = translateSignal('title'); 20 | displayedColumns = translateSignal(['username', 'password']); 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "esModuleInterop": true, 5 | "outDir": "dist", 6 | "module": "esnext", 7 | "target": "es2022", 8 | "declaration": true, 9 | "moduleResolution": "bundler", 10 | "lib": ["es2022"], 11 | "typeRoots": [], 12 | "sourceMap": true 13 | }, 14 | "include": ["src"], 15 | "exclude": ["__tests__"], 16 | "tsc-alias": { 17 | "resolveFullPaths": true, 18 | "fileExtensions": { 19 | "inputGlob": "{js,jsx,mjs}", 20 | "outputCheck": ["js", "json", "jsx", "mjs"] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/pure-function/src/issue-192.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { translate } from '@jsverse/transloco'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | templateUrl: './app.component.html', 7 | styleUrl: './app.component.scss', 8 | }) 9 | export class AppComponent { 10 | constructor() { 11 | translate('1'); 12 | } 13 | 14 | getString(): string { 15 | return '9'; 16 | } 17 | 18 | extractionProblem(): void { 19 | translate('2'); 20 | const foo = this.getString(); 21 | translate(['3', '4']); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/keys-builder/utils/scope.utils.ts: -------------------------------------------------------------------------------- 1 | import { Scopes } from '../../types'; 2 | 3 | let scopeToAlias: Scopes['scopeToAlias'] = {}; 4 | let aliasToScope: Scopes['aliasToScope'] = {}; 5 | 6 | export function addScope(scope: string, alias: string) { 7 | scopeToAlias[scope] = alias; 8 | aliasToScope[alias] = scope; 9 | } 10 | 11 | export function getScopes() { 12 | return { scopeToAlias, aliasToScope }; 13 | } 14 | 15 | export function hasScope(scope: string) { 16 | return scopeToAlias.hasOwnProperty(scope); 17 | } 18 | 19 | export function resetScopes() { 20 | scopeToAlias = {}; 21 | aliasToScope = {}; 22 | } 23 | -------------------------------------------------------------------------------- /__tests__/findMissingKeys/findMissingKeys.spec.ts: -------------------------------------------------------------------------------- 1 | import { resetScopes } from '../../src/keys-builder/utils/scope.utils'; 2 | import { spyOnConsole, spyOnProcess } from '../spec-utils'; 3 | import { testAddMissingKeysConfig } from './add-missing-keys/add-missing-keys-spec'; 4 | import { describe, beforeAll, afterEach } from 'vitest'; 5 | 6 | describe('findMissingKeys', () => { 7 | beforeAll(() => { 8 | spyOnConsole('warn'); 9 | spyOnProcess('exit'); 10 | }); 11 | 12 | // Reset to ensure the scopes are not being shared among the tests. 13 | afterEach(() => resetScopes()); 14 | 15 | testAddMissingKeysConfig(); 16 | }); 17 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/signal/src/mixed-import.ts: -------------------------------------------------------------------------------- 1 | import { TranslocoService, translateSignal } from '@jsverse/transloco'; 2 | 3 | @Component({ 4 | selector: 'bla-bla', 5 | template: ` 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 |
displayedTitle()
12 | {{ column }} 13 |
16 | `, 17 | }) 18 | export class Basic { 19 | transloco = inject(TranslocoService); 20 | 21 | displayedTitle = translateSignal('title3'); 22 | displayedColumns = translateSignal(['username3', 'password3']); 23 | } 24 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/pure-function/with-params/1.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { translate } from '@jsverse/transloco'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | templateUrl: './app.component.html', 7 | styleUrl: './app.component.scss', 8 | }) 9 | export class AppComponent { 10 | constructor() { 11 | translate('1', { 1: '' }); 12 | translate(['2'], { 2: '' }); 13 | } 14 | 15 | getString(): string { 16 | return '9'; 17 | } 18 | 19 | extractionProblem(): void { 20 | const foo = this.getString(); 21 | translate('4', { foo: '', a: '', b: { c: '' } }); 22 | } 23 | } 24 | 25 | translate('3', { 3: '' }); 26 | -------------------------------------------------------------------------------- /src/utils/normalize-glob-path.ts: -------------------------------------------------------------------------------- 1 | import { GlobOptionsWithFileTypesFalse } from 'glob'; 2 | import { sync as globSync } from 'glob'; 3 | 4 | export function normalizedGlob( 5 | path: string, 6 | options: GlobOptionsWithFileTypesFalse = {}, 7 | ) { 8 | // on Windows system the path will have `\` which are used a escape characters in glob 9 | // therefore we have to escape those for the glob to work correctly on those systems 10 | const normalizedPath = path.replace(/\\/g, '/'); 11 | const mergedOptions: GlobOptionsWithFileTypesFalse = { 12 | ...options, 13 | ignore: ['node_modules/**', 'tmp/**', 'coverage/**', 'dist/**'], 14 | }; 15 | 16 | return globSync(normalizedPath, mergedOptions); 17 | } 18 | -------------------------------------------------------------------------------- /src/keys-builder/typescript/inline-template.ts: -------------------------------------------------------------------------------- 1 | import { tsquery } from '@phenomnomnominal/tsquery'; 2 | import { SourceFile } from 'typescript'; 3 | 4 | import { ExtractorConfig } from '../../types'; 5 | import { templateExtractor } from '../template'; 6 | 7 | export function inlineTemplateExtractor( 8 | ast: SourceFile, 9 | config: ExtractorConfig, 10 | ) { 11 | const [inlineTemplate] = tsquery( 12 | ast, 13 | 'ClassDeclaration Decorator CallExpression:has([name=Component]) ObjectLiteralExpression PropertyAssignment:has([name=template]) NoSubstitutionTemplateLiteral', 14 | ); 15 | 16 | if (inlineTemplate) { 17 | templateExtractor({ ...config, content: inlineTemplate.getText() }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Keys Manager 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | tasks: 11 | strategy: 12 | matrix: 13 | task: [build, test] 14 | runs-on: ubuntu-latest 15 | name: ${{ matrix.task }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Setup pnpm 20 | uses: pnpm/action-setup@v4 21 | with: 22 | version: 9 23 | 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: '20' 27 | cache: pnpm 28 | 29 | - name: Install dependencies 30 | run: pnpm i 31 | 32 | - name: Run ${{ matrix.task }} 33 | run: pnpm ${{ matrix.task }} 34 | -------------------------------------------------------------------------------- /src/keys-detective/map-diff-to-keys.ts: -------------------------------------------------------------------------------- 1 | import type { Diff, DiffEdit } from 'deep-diff'; 2 | 3 | import { buildPath } from '../utils/path.utils'; 4 | import { isObject } from '../utils/validators.utils'; 5 | 6 | export function mapDiffToKeys( 7 | diffArr: Diff[], 8 | side: 'lhs' | 'rhs', 9 | ): string { 10 | const keys = diffArr.reduce((acc, diff) => { 11 | const base = diff.path!.join('.'); 12 | const keys = !isObject((diff as DiffEdit)[side]) 13 | ? [`'${base}'`] 14 | : buildPath((diff as DiffEdit)[side]).map( 15 | (inner) => `'${base}.${inner}'`, 16 | ); 17 | 18 | acc.push(...keys); 19 | 20 | return acc; 21 | }, [] as string[]); 22 | 23 | return keys.join('\n'); 24 | } 25 | -------------------------------------------------------------------------------- /__tests__/defaultConfig.spec.ts: -------------------------------------------------------------------------------- 1 | import { defaultConfig } from '../src/config'; 2 | import { describe, expect, it } from 'vitest'; 3 | 4 | describe('defaultConfig', () => { 5 | it('should set the input path to "app"', () => { 6 | let { input } = defaultConfig(); 7 | expect(input).toEqual(['src/app']); 8 | input = defaultConfig({ projectType: 'application' }).input; 9 | expect(input).toEqual(['src/app']); 10 | }); 11 | 12 | it('should set the input path to "lib"', () => { 13 | let { input } = defaultConfig({ projectType: 'library' }); 14 | expect(input).toEqual(['src/lib']); 15 | }); 16 | 17 | it('should set the output format to "json"', () => { 18 | expect(defaultConfig({}).fileFormat).toEqual('json'); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/keys-builder/utils/remove-extra-keys.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from '../../utils/validators.utils'; 2 | import { Translation } from '../../types'; 3 | 4 | export function removeExtraKeys( 5 | currentTranslation: Translation, 6 | extractedTranslation: Translation, 7 | ): Translation { 8 | const resolved: Translation = {}; 9 | 10 | for (const key in currentTranslation) { 11 | if (!extractedTranslation.hasOwnProperty(key)) { 12 | continue; 13 | } 14 | 15 | if (isObject(currentTranslation[key])) { 16 | resolved[key] = removeExtraKeys( 17 | currentTranslation[key], 18 | extractedTranslation[key], 19 | ); 20 | } else { 21 | resolved[key] = currentTranslation[key]; 22 | } 23 | } 24 | 25 | return resolved; 26 | } 27 | -------------------------------------------------------------------------------- /src/keys-builder/utils/run-prettier.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'node:fs'; 2 | 3 | import { readFile } from '../../utils/file.utils'; 4 | 5 | export async function runPrettier(filePaths: string[]) { 6 | try { 7 | const prettier = await import('prettier'); 8 | const options = await prettier.resolveConfig(filePaths[0]); 9 | if (options) { 10 | for (const filePath of filePaths) { 11 | const formatted = await prettier.format(readFile(filePath), { 12 | ...options, 13 | filepath: filePath, 14 | }); 15 | writeFileSync(filePath, formatted); 16 | } 17 | } 18 | } catch (e: any) { 19 | if (e.code !== 'MODULE_NOT_FOUND') { 20 | console.warn('Failed to run prettier', e.message); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/marker/src/basic.ts: -------------------------------------------------------------------------------- 1 | import { marker } from '@jsverse/transloco-keys-manager/marker'; 2 | 3 | @Component({ 4 | selector: 'bla-bla', 5 | template: ` 6 | 7 | 8 | 11 | 12 | 13 | 16 | 17 |
9 | {{ column | transloco }} 10 |
14 | {{ row[column] }} 15 |
18 | `, 19 | }) 20 | export class Basic { 21 | data = [ 22 | { username: 'alex', password: '12345678' }, 23 | { username: 'bob', password: 'password' }, 24 | ]; 25 | displayedColumns = [marker('username'), marker('password')]; 26 | } 27 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/marker/src/incorrect-imports.ts: -------------------------------------------------------------------------------- 1 | import { marker } from 'incorrect-import'; 2 | 3 | @Component({ 4 | selector: 'bla-bla', 5 | template: ` 6 | 7 | 8 | 11 | 12 | 13 | 16 | 17 |
9 | {{ column | transloco }} 10 |
14 | {{ row[column] }} 15 |
18 | `, 19 | }) 20 | export class TableComponent { 21 | data = [ 22 | { username3: 'alex', password3: '12345678' }, 23 | { username3: 'bob', password3: 'password' }, 24 | ]; 25 | displayedColumns = [marker('username3'), marker('password3')]; 26 | } 27 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/comments/src/2.html: -------------------------------------------------------------------------------- 1 | 2 |

{{ 'need.transloco' | transloco }}

3 | 4 | 8 |

{{ 'need.transloco' | transloco }}

9 | 10 | 11 | 16 | 17 | 18 |
19 | 20 |
{{title}}
21 |
22 |
23 |
24 | 25 | 30 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-template/src/5.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

ddsds

4 | 5 | {{test('38.1')}} {{ 6 | test('39.1') 7 | }} 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | {{t('41') + 'a'}} 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/marker/src/with-alias.ts: -------------------------------------------------------------------------------- 1 | import { marker as t } from '@jsverse/transloco-keys-manager/marker'; 2 | 3 | @Component({ 4 | selector: 'bla-bla', 5 | template: ` 6 | 7 | 8 | 11 | 12 | 13 | 16 | 17 |
9 | {{ column | transloco }} 10 |
14 | {{ row[column] }} 15 |
18 | `, 19 | }) 20 | export class TableComponent { 21 | data = [ 22 | { username4: 'alex', password4: '12345678' }, 23 | { username4: 'bob', password4: 'password' }, 24 | ]; 25 | displayedColumns = [t('username4'), t('password4')]; 26 | } 27 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/scope/src/component-scopes.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TRANSLOCO_SCOPE, provideTranslocoScope } from '@jsverse/transloco'; 3 | 4 | @Component({ 5 | selector: 'app-home', 6 | templateUrl: './home.component.html', 7 | providers: [ 8 | { provide: TRANSLOCO_SCOPE, useValue: 'scope4' }, 9 | { provide: TRANSLOCO_SCOPE, useValue: { scope: 'scope5' } }, 10 | { 11 | provide: TRANSLOCO_SCOPE, 12 | useValue: { scope: 'scope6', alias: 'scopeAlias6' }, 13 | }, 14 | provideTranslocoScope([ 15 | 'scope7', 16 | { scope: 'scope8' }, 17 | { scope: 'scope9', alias: 'scopeAlias9' }, 18 | ]), 19 | ], 20 | styleUrls: ['./home.component.scss'], 21 | }) 22 | export class HomeComponent {} 23 | -------------------------------------------------------------------------------- /src/utils/file.utils.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | 3 | import { stringify } from './object.utils'; 4 | 5 | export function readFile(file: string): string; 6 | export function readFile(file: string, config: { parse: false }): string; 7 | export function readFile( 8 | file: string, 9 | config: { parse: true }, 10 | ): Record; 11 | export function readFile( 12 | file: string, 13 | { parse }: { parse: boolean } = { parse: false }, 14 | ): string | object { 15 | const content = readFileSync(file, { encoding: 'utf-8' }); 16 | 17 | if (parse) { 18 | return JSON.parse(content); 19 | } 20 | 21 | return content; 22 | } 23 | 24 | export function writeFile(fileName: string, content: object) { 25 | writeFileSync(fileName, stringify(content), { encoding: 'utf-8' }); 26 | } 27 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/scope-mapping/src/component-scopes.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TRANSLOCO_SCOPE, provideTranslocoScope } from '@jsverse/transloco'; 3 | 4 | @Component({ 5 | selector: 'app-home', 6 | templateUrl: './home.component.html', 7 | providers: [ 8 | { provide: TRANSLOCO_SCOPE, useValue: 'scope4' }, 9 | { provide: TRANSLOCO_SCOPE, useValue: { scope: 'scope5' } }, 10 | { 11 | provide: TRANSLOCO_SCOPE, 12 | useValue: { scope: 'scope6', alias: 'scopeAlias6' }, 13 | }, 14 | provideTranslocoScope('scope7'), 15 | provideTranslocoScope({ scope: 'scope8' }), 16 | provideTranslocoScope({ scope: 'scope9', alias: 'scopeAlias9' }), 17 | ], 18 | styleUrls: ['./home.component.scss'], 19 | }) 20 | export class HomeComponent {} 21 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/scope/src/1.html: -------------------------------------------------------------------------------- 1 | 2 | {{ t('1') }} 3 | 4 | 5 | 6 | {{ t('1') }} 7 | 8 | 9 | 10 | {{ t('1') }} 11 | 12 | 13 | 14 | {{ t('1') }} 15 | 16 | 17 | 18 | {{ t('1') }} 19 | 20 | 21 | 22 | {{ t('1') }} 23 | 24 | 25 | 26 | {{ t('1') }} 27 | 28 | 29 | 30 | {{ t('scope8.1') }} 31 | 32 | 33 | 34 | {{ t('1') }} 35 | 36 | 37 | -------------------------------------------------------------------------------- /scripts/post-build.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | // const glob = require("glob"); 3 | // const terser = require("terser"); 4 | 5 | // const [,,mode] = process.argv; 6 | copyAssets(); 7 | 8 | function copyAssets() { 9 | const { 10 | scripts, 11 | devDependencies, 12 | ['lint-staged']: _, 13 | config, 14 | husky, 15 | ...cleanPackage 16 | } = JSON.parse(fs.readFileSync('package.json').toString()); 17 | fs.writeFileSync('dist/package.json', JSON.stringify(cleanPackage, null, 2)); 18 | fs.copyFileSync('README.md', 'dist/README.md'); 19 | } 20 | 21 | // function minify() { 22 | // glob.sync('dist/**/*.js').forEach(async (filePath) => { 23 | // const {code} = await terser.minify(fs.readFileSync(filePath, "utf8")); 24 | // fs.writeFileSync(filePath, code); 25 | // }); 26 | // } 27 | -------------------------------------------------------------------------------- /src/keys-builder/build-keys.ts: -------------------------------------------------------------------------------- 1 | import { Config, ScopeMap } from '../types'; 2 | import { checkForProblematicUnflatKeys } from '../utils/keys.utils'; 3 | import { mergeDeep } from '../utils/object.utils'; 4 | 5 | import { extractTemplateKeys } from './template'; 6 | import { extractTSKeys } from './typescript'; 7 | 8 | export function buildKeys(config: Config) { 9 | const [template, ts] = [extractTemplateKeys(config), extractTSKeys(config)]; 10 | 11 | const scopeToKeys = mergeDeep( 12 | {}, 13 | template.scopeToKeys, 14 | ts.scopeToKeys, 15 | ) as ScopeMap; 16 | const fileCount = template.fileCount + ts.fileCount; 17 | 18 | if (config.unflat) { 19 | for (const scopeKeys of Object.values(scopeToKeys)) { 20 | checkForProblematicUnflatKeys(scopeKeys); 21 | } 22 | } 23 | 24 | return { scopeToKeys, fileCount }; 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/validators.utils.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, lstatSync } from 'fs'; 2 | 3 | export function isObject(value: any): value is Record { 4 | return value && typeof value === 'object' && !Array.isArray(value); 5 | } 6 | 7 | export function isFunction(value: any): value is (...args: any[]) => any { 8 | return typeof value === 'function'; 9 | } 10 | 11 | export function isNil(value: unknown): value is undefined | null { 12 | return isUndefined(value) || value === null; 13 | } 14 | 15 | export function notNil(value: T | undefined | null): value is T { 16 | return !isNil(value); 17 | } 18 | 19 | export function isDirectory(path: string): boolean { 20 | return existsSync(path) && lstatSync(path).isDirectory(); 21 | } 22 | 23 | export function isString(value: any): value is string { 24 | return value && typeof value === 'string'; 25 | } 26 | 27 | export function isUndefined(value: any): value is undefined { 28 | return value === undefined; 29 | } 30 | -------------------------------------------------------------------------------- /src/keys-builder/utils/extract-keys.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Config, 3 | ExtractionResult, 4 | ExtractorConfig, 5 | FileType, 6 | ScopeMap, 7 | } from '../../types'; 8 | import { initExtraction } from '../../utils/init-extraction'; 9 | import { devlog } from '../../utils/logger'; 10 | import { normalizedGlob } from '../../utils/normalize-glob-path'; 11 | 12 | export function extractKeys( 13 | { input, scopes, defaultValue, files }: Config, 14 | fileType: FileType, 15 | extractor: (config: ExtractorConfig) => ScopeMap, 16 | ): ExtractionResult { 17 | let { scopeToKeys } = initExtraction(); 18 | 19 | const fileList = 20 | files || 21 | input.map((path) => normalizedGlob(`${path}/**/*.${fileType}`)).flat(); 22 | 23 | for (const file of fileList) { 24 | devlog('extraction', 'Extracting keys', { file, fileType }); 25 | scopeToKeys = extractor({ file, defaultValue, scopes, scopeToKeys }); 26 | } 27 | 28 | return { scopeToKeys, fileCount: fileList.length }; 29 | } 30 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/multi-input/src/folder-2/3.html: -------------------------------------------------------------------------------- 1 | 2 |

ddsds

3 | {{ t('32') }} 4 | 5 |

dsds

6 | 7 | dsds 8 | 9 |

dskdsds {{ '37' | transloco}}

10 | 11 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ad aperiam consequatur facilis ipsa maxime non optio qui 12 | reiciendis velit voluptate. Atque deserunt dignissimos explicabo natus placeat sunt veniam voluptates? Itaque. 13 | 14 | 15 | 16 | {{ b('33') }} 17 | 18 |

dskdsds {{ '38' | transloco}}

19 | 20 | 21 | {{ t('36') }} 22 |
23 |

dskdsds

24 | 25 |
26 |
27 |
28 | -------------------------------------------------------------------------------- /src/keys-builder/typescript/marker.extractor.ts: -------------------------------------------------------------------------------- 1 | import { SourceFile, Node } from 'typescript'; 2 | import { tsquery } from '@phenomnomnominal/tsquery'; 3 | 4 | import { buildKeysFromASTNodes } from './build-keys-from-ast-nodes'; 5 | import { TSExtractorResult } from './types'; 6 | 7 | export function markerExtractor(ast: SourceFile): TSExtractorResult { 8 | // workaround from https://github.com/estools/esquery/issues/68 9 | const [importNode] = tsquery( 10 | ast, 11 | `ImportDeclaration:has([text=/^@(jsverse|ngneat)\\x2Ftransloco-keys-manager/])`, 12 | ); 13 | if (!importNode) { 14 | return []; 15 | } 16 | const markerName = getMarkerName(importNode); 17 | const fns = tsquery(ast, `CallExpression Identifier[text=${markerName}]`); 18 | 19 | return buildKeysFromASTNodes(fns, [markerName]); 20 | } 21 | 22 | function getMarkerName(importNode: Node) { 23 | const [defaultName, alias] = tsquery( 24 | importNode, 25 | 'ImportSpecifier Identifier', 26 | ); 27 | return (alias || defaultName).getText(); 28 | } 29 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/prefix/src/3.html: -------------------------------------------------------------------------------- 1 | 2 |

ddsds

3 | {{ t('1') }} 4 | 5 |

dsds

6 | 7 | dsds 8 | 9 |

dskdsds {{ '1' | transloco}}

10 | 11 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ad aperiam consequatur facilis ipsa maxime non optio qui 12 | reiciendis velit voluptate. Atque deserunt dignissimos explicabo natus placeat sunt veniam voluptates? Itaque. 13 | 14 | 15 | 16 | {{ b('1') }} 17 | 18 |

dskdsds {{ '2' | transloco}}

19 | 20 | 21 | {{ t('2') }} 22 |
23 |

dskdsds

24 | 25 |
26 |
27 |
28 | -------------------------------------------------------------------------------- /src/keys-builder/typescript/signal.extractor.ts: -------------------------------------------------------------------------------- 1 | import { SourceFile, Node } from 'typescript'; 2 | import { tsquery } from '@phenomnomnominal/tsquery'; 3 | 4 | import { buildKeysFromASTNodes } from './build-keys-from-ast-nodes'; 5 | import { TSExtractorResult } from './types'; 6 | 7 | export function signalExtractor(ast: SourceFile): TSExtractorResult { 8 | // workaround from https://github.com/estools/esquery/issues/68 9 | const [importNode] = tsquery( 10 | ast, 11 | `ImportDeclaration:has([text=/^@(jsverse|ngneat)\\x2Ftransloco/]):has(Identifier[name=translateSignal])`, 12 | ); 13 | if (!importNode) { 14 | return []; 15 | } 16 | const signalName = getSignalName(importNode); 17 | const fns = tsquery(ast, `CallExpression Identifier[text=${signalName}]`); 18 | 19 | return buildKeysFromASTNodes(fns, [signalName]); 20 | } 21 | 22 | function getSignalName(importNode: Node) { 23 | const [defaultName, alias] = tsquery( 24 | importNode, 25 | 'ImportSpecifier:has(Identifier[name=translateSignal]) Identifier', 26 | ); 27 | return (alias || defaultName).getText(); 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | import ora, { Ora } from 'ora'; 3 | 4 | let spinner: Ora; 5 | 6 | function noop() {} 7 | 8 | const isProd = process.env.PRODUCTION; 9 | const defaultLogger = { 10 | log: (...msg: string[]) => (isProd ? noop : console.log(...msg)), 11 | success: (msg: string) => (isProd ? noop : spinner.succeed(msg)), 12 | startSpinner: (msg: string) => (isProd ? noop : (spinner = ora().start(msg))), 13 | }; 14 | 15 | export function getLogger() { 16 | return defaultLogger; 17 | } 18 | 19 | type DebugNamespaces = 'config' | 'paths' | 'scopes' | 'extraction'; 20 | 21 | export function devlog( 22 | namespace: DebugNamespaces, 23 | tag: string, 24 | values: Record, 25 | ) { 26 | if (!debug.enabled(`tkm:${namespace}`)) return; 27 | 28 | console.log(`\n\x1b[4m🐞 DEBUG - ${tag}:\x1b[0m`); 29 | // To prevent from logging the namespace twice, we set an empty namespace and enable it 30 | const log = debug(''); 31 | log.enabled = true; 32 | 33 | for (const [variable, value] of Object.entries(values)) { 34 | log(`${variable}: %O`, value); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2020 Shahar Kazaz and Netanel Basal. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/keys-builder/build-translation-file.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | 3 | import { Config, Translation } from '../types'; 4 | 5 | import { createTranslation } from './utils/create-translation'; 6 | import { getCurrentTranslation } from './utils/get-current-translation'; 7 | 8 | export interface FileAction { 9 | path: string; 10 | type: 'new' | 'modified'; 11 | } 12 | 13 | interface BuildTranslationOptions 14 | extends Required>, 15 | Partial> { 16 | path: string; 17 | translation?: Translation; 18 | } 19 | 20 | export function buildTranslationFile({ 21 | path, 22 | translation = {}, 23 | replace = false, 24 | removeExtraKeys = false, 25 | fileFormat, 26 | }: BuildTranslationOptions): FileAction { 27 | const currentTranslation = getCurrentTranslation({ path, fileFormat }); 28 | 29 | fs.outputFileSync( 30 | path, 31 | createTranslation({ 32 | currentTranslation, 33 | translation, 34 | replace, 35 | removeExtraKeys, 36 | fileFormat, 37 | }), 38 | ); 39 | 40 | return { type: currentTranslation ? 'modified' : 'new', path }; 41 | } 42 | -------------------------------------------------------------------------------- /src/keys-builder/template/index.ts: -------------------------------------------------------------------------------- 1 | import { Config, ExtractionResult } from '../../types'; 2 | import { readFile } from '../../utils/file.utils'; 3 | import { extractKeys } from '../utils/extract-keys'; 4 | 5 | import { TemplateExtractorConfig } from './types'; 6 | import { templateCommentsExtractor } from './comments.extractor'; 7 | import { directiveExtractor } from './directive.extractor'; 8 | import { pipeExtractor } from './pipe.extractor'; 9 | import { structuralDirectiveExtractor } from './structural-directive.extractor'; 10 | 11 | export function extractTemplateKeys(config: Config): ExtractionResult { 12 | return extractKeys(config, 'html', templateExtractor); 13 | } 14 | 15 | export function templateExtractor(config: TemplateExtractorConfig) { 16 | const { file, scopeToKeys } = config; 17 | let content = config.content || readFile(file); 18 | if (!content.includes('transloco')) return scopeToKeys; 19 | 20 | const resolvedConfig = { ...config, content }; 21 | pipeExtractor(resolvedConfig); 22 | templateCommentsExtractor(resolvedConfig); 23 | directiveExtractor(resolvedConfig); 24 | structuralDirectiveExtractor(resolvedConfig); 25 | 26 | return scopeToKeys; 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/object.utils.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from '../config'; 2 | 3 | import { isObject } from './validators.utils'; 4 | 5 | export function stringify(val: object) { 6 | const { sort } = getConfig(); 7 | let value = val; 8 | 9 | if (sort) { 10 | value = sortKeys(val); 11 | } 12 | 13 | return JSON.stringify(value, null, 2); 14 | } 15 | 16 | function sortKeys(val: Record) { 17 | return Object.keys(val) 18 | .sort() 19 | .reduce( 20 | (acc, key) => { 21 | acc[key] = isObject(val[key]) ? sortKeys(val[key]) : val[key]; 22 | return acc; 23 | }, 24 | {} as Record, 25 | ); 26 | } 27 | 28 | export function mergeDeep(target: object, ...sources: any[]) { 29 | if (!sources.length) return target; 30 | const source = sources.shift(); 31 | 32 | if (isObject(target) && isObject(source)) { 33 | for (const key in source) { 34 | if (isObject(source[key])) { 35 | if (!target[key]) Object.assign(target, { [key]: {} }); 36 | mergeDeep(target[key], source[key]); 37 | } else { 38 | Object.assign(target, { [key]: source[key] }); 39 | } 40 | } 41 | } 42 | 43 | return mergeDeep(target, ...sources); 44 | } 45 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/service/src/store-in-variable.ts: -------------------------------------------------------------------------------- 1 | import { TranslocoService } from '@jsverse/transloco'; 2 | import { inject } from '@angular/core'; 3 | import { Observable, zip, switchMap, tap, map, of } from 'rxjs'; 4 | import { PermissionService } from './permission.service'; 5 | import { MatSnackBar } from '@angular/material/snack-bar'; 6 | 7 | export function hasPermissionFactory(): Observable { 8 | const permission = inject(PermissionService); 9 | const translate = inject(TranslocoService); 10 | const snackBarManager = inject(MatSnackBar); 11 | 12 | return permission.hasPermissions().pipe( 13 | switchMap((hasPermission) => { 14 | if (!hasPermission) { 15 | return zip([ 16 | translate.selectTranslate('permission.snackbar.no-permission'), 17 | translate.selectTranslate('permission.snackbar.close'), 18 | ]).pipe( 19 | tap(([message, close]) => { 20 | snackBarManager.open(message, close, { 21 | duration: 3000, 22 | horizontalPosition: 'right', 23 | }); 24 | }), 25 | map(() => false), 26 | ); 27 | } 28 | return of(true); 29 | }), 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/messages.ts: -------------------------------------------------------------------------------- 1 | export const messages = { 2 | keepFlat: 'Keep certain keys flat?', 3 | keysFound: (keysCount: number, filesCount: number) => 4 | `${keysCount} keys were found in ${filesCount} ${ 5 | filesCount > 1 ? 'files' : 'file' 6 | }.`, 7 | startBuild: (langsCount: number) => 8 | `Starting Translation ${langsCount > 1 ? 'Files' : 'File'} Build`, 9 | startSearch: 'Starting Search For Missing Keys', 10 | extract: 'Extracting Template and Component Keys', 11 | creatingFiles: 'Created the following translation files:', 12 | merged: (len: number) => 13 | `Existing translation file${len > 1 ? 's were' : ' was'} found and merged`, 14 | checkMissing: 'Checking for missing keys', 15 | pathDoesntExist: `path provided doesn't exist!`, 16 | pathIsNotDir: `requires a directory.`, 17 | summary: 'Summary', 18 | noMissing: 'No missing keys were found', 19 | defaultValue: 'Enter default key value', 20 | addMissing: 'Add missing keys automatically?', 21 | missingValue: 'Missing value for', 22 | done: 'Done!', 23 | problematicKeysForUnflat: (keys: string[]) => 24 | `The following keys won't be accessible when unflatting the object:\n ${keys 25 | .map((k) => `"${k}"`) 26 | .join(', ')}`, 27 | }; 28 | -------------------------------------------------------------------------------- /src/utils/keys.utils.ts: -------------------------------------------------------------------------------- 1 | import { messages } from '../messages'; 2 | 3 | import { getLogger } from './logger'; 4 | import { isObject } from './validators.utils'; 5 | 6 | export function countKeys(obj: Record): number { 7 | return Object.keys(obj).reduce( 8 | (acc, curr) => (isObject(obj[curr]) ? acc + countKeys(obj[curr]) : ++acc), 9 | 0, 10 | ); 11 | } 12 | 13 | export function checkForProblematicUnflatKeys(obj: object) { 14 | const sortedKeys = Object.keys(obj).sort(); 15 | const problematicKeys = []; 16 | const lastKeyIndex = sortedKeys.length - 1; 17 | 18 | for (let i = 0; i < lastKeyIndex; ) { 19 | const key = sortedKeys[i]; 20 | const prefix = `${key}.`; 21 | let isChildKey = sortedKeys[++i].startsWith(prefix); 22 | 23 | if (isChildKey) { 24 | problematicKeys.push(key); 25 | 26 | while (isChildKey && i <= lastKeyIndex) { 27 | problematicKeys.push(sortedKeys[i]); 28 | isChildKey = i < lastKeyIndex && sortedKeys[++i].startsWith(prefix); 29 | } 30 | } 31 | } 32 | 33 | if (problematicKeys.length) { 34 | const logger = getLogger(); 35 | logger.log( 36 | '\x1b[31m%s\x1b[0m', 37 | '⚠️', 38 | messages.problematicKeysForUnflat(problematicKeys), 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/service/with-params/store-in-variable.ts: -------------------------------------------------------------------------------- 1 | import { TranslocoService } from '@jsverse/transloco'; 2 | import { inject } from '@angular/core'; 3 | import { Observable, zip, switchMap, tap, map, of } from 'rxjs'; 4 | import { PermissionService } from './permission.service'; 5 | import { MatSnackBar } from '@angular/material/snack-bar'; 6 | 7 | export function hasPermissionFactory(): Observable { 8 | const permission = inject(PermissionService); 9 | const translate = inject(TranslocoService); 10 | const snackBarManager = inject(MatSnackBar); 11 | 12 | return permission.hasPermissions().pipe( 13 | switchMap((hasPermission) => { 14 | if (!hasPermission) { 15 | return zip([ 16 | translate.selectTranslate('variable', { variable: 'hasPermission' }), 17 | translate.selectTranslate('another.variable', { 18 | another: { variable: 'hasPermission' }, 19 | }), 20 | ]).pipe( 21 | tap(([message, close]) => { 22 | snackBarManager.open(message, close, { 23 | duration: 3000, 24 | horizontalPosition: 'right', 25 | }); 26 | }), 27 | map(() => false), 28 | ); 29 | } 30 | return of(true); 31 | }), 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## PR Checklist 2 | 3 | Please check if your PR fulfills the following requirements: 4 | 5 | - [ ] The commit message follows our guidelines: https://github.com/jsverse/transloco/blob/master/CONTRIBUTING.md#commit 6 | - [ ] Tests for the changes have been added (for bug fixes / features) 7 | - [ ] Docs have been added / updated (for bug fixes / features) 8 | 9 | ## PR Type 10 | 11 | What kind of change does this PR introduce? 12 | 13 | 14 | 15 | ``` 16 | [ ] Bugfix 17 | [ ] Feature 18 | [ ] Code style update (formatting, local variables) 19 | [ ] Refactoring (no functional changes, no api changes) 20 | [ ] Build related changes 21 | [ ] CI related changes 22 | [ ] Documentation content changes 23 | [ ] Other... Please describe: 24 | ``` 25 | 26 | ## What is the current behavior? 27 | 28 | 29 | 30 | Issue Number: N/A 31 | 32 | ## What is the new behavior? 33 | 34 | ## Does this PR introduce a breaking change? 35 | 36 | ``` 37 | [ ] Yes 38 | [ ] No 39 | ``` 40 | 41 | 42 | 43 | ## Other information 44 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import commandLineArgs from 'command-line-args'; 3 | import commandLineUsage from 'command-line-usage'; 4 | 5 | import { optionDefinitions, sections } from './cli-options'; 6 | import { buildTranslationFiles } from './keys-builder'; 7 | import { findMissingKeys } from './keys-detective'; 8 | import { Config } from './types'; 9 | 10 | const mainDefinitions = [{ name: 'command', defaultOption: true }]; 11 | 12 | const mainOptions = commandLineArgs(mainDefinitions, { 13 | stopAtFirstUnknown: true, 14 | }); 15 | const argv = mainOptions._unknown || []; 16 | 17 | const config = commandLineArgs(optionDefinitions, { 18 | camelCase: true, 19 | argv, 20 | }); 21 | const { help } = config; 22 | 23 | if (help) { 24 | const usage = commandLineUsage(sections); 25 | // Don't delete, it's the help menu 26 | console.log(usage); 27 | process.exit(); 28 | } 29 | 30 | const resolvedConfig = { 31 | ...config, 32 | command: mainOptions.command, 33 | ...(config.input ? { input: config.input.split(',') } : {}), 34 | } as Config; 35 | 36 | if (resolvedConfig.command === 'extract') { 37 | buildTranslationFiles(resolvedConfig); 38 | } else if (resolvedConfig.command === 'find') { 39 | findMissingKeys(resolvedConfig); 40 | } else { 41 | console.log(`Please provide an action...`); 42 | } 43 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/pipe/src/4.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 9 |
10 | 17 | 18 |
19 | 20 | 21 |
22 |
23 | 25 | {{ '26' | transloco}} 26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-container/src/3.html: -------------------------------------------------------------------------------- 1 | 2 |

ddsds

3 | {{ t('32') }} 4 | 5 |

dsds

6 | 7 | dsds 8 | 9 |

dskdsds {{ '37' | transloco}}

10 | 11 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ad aperiam consequatur facilis ipsa maxime non optio qui 12 | reiciendis velit voluptate. Atque deserunt dignissimos explicabo natus placeat sunt veniam voluptates? Itaque. 13 | 14 | 15 | 16 | {{ b('33') }} 17 | 18 |

dskdsds {{ '38' | transloco}}

19 | 20 | 21 | {{ t('36') }} 22 |
23 |

dskdsds

24 | 25 |
    26 |
  • {{ variable | another : t('40') }}
  • 27 |
  • {{ variable | another : t('41') | lowercase }}
  • 28 |
  • {{ variable | another : {a: t('42')} | lowercase }}
  • 29 |
  • {{ t('43') | another: t('44') }}
  • 30 |
  • {{ t('45') | another: ('46' | transloco) }}
  • 31 |
32 |
33 |
34 |
35 | -------------------------------------------------------------------------------- /src/keys-builder/index.ts: -------------------------------------------------------------------------------- 1 | import { setConfig } from '../config'; 2 | import { messages } from '../messages'; 3 | import { Config } from '../types'; 4 | import { countKeys } from '../utils/keys.utils'; 5 | import { getLogger } from '../utils/logger'; 6 | import { resolveConfig } from '../utils/resolve-config'; 7 | 8 | import { buildKeys } from './build-keys'; 9 | import { createTranslationFiles } from './create-translation-files'; 10 | 11 | /** The main function, collects the settings and starts the files build. */ 12 | export async function buildTranslationFiles(inlineConfig: Config) { 13 | const logger = getLogger(); 14 | const config = resolveConfig(inlineConfig); 15 | setConfig(config); 16 | 17 | logger.log( 18 | '\x1b[4m%s\x1b[0m', 19 | `\n${messages.startBuild(config.langs.length)} 👷🏗\n`, 20 | ); 21 | logger.startSpinner(`${messages.extract} 🗝`); 22 | 23 | const result = buildKeys(config); 24 | const { scopeToKeys, fileCount } = result; 25 | 26 | logger.success(`${messages.extract} 🗝`); 27 | 28 | let keysFound = 0; 29 | for (const [_, scopeKeys] of Object.entries(scopeToKeys)) { 30 | keysFound += countKeys(scopeKeys as object); 31 | } 32 | 33 | logger.log( 34 | '\x1b[34m%s\x1b[0m', 35 | 'ℹ', 36 | messages.keysFound(keysFound, fileCount), 37 | ); 38 | 39 | await createTranslationFiles({ 40 | scopeToKeys, 41 | ...config, 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/multi-input/src/folder-1/2.html: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | {{t("23")}} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 29 | 30 | 31 | 32 | 33 | {{translate("30")}} 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-container/src/2.html: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | {{t("23")}} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 29 | 30 | 31 | 32 | 33 | {{translate("30")}} 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/prefix/src/4.html: -------------------------------------------------------------------------------- 1 | 2 |

ddsds

3 | {{ t('1') }} 4 | 5 |

dsds

6 | 7 | dsds 8 | 9 | 10 | {{ b('1') }} 11 | 12 | 13 | 14 | 15 | 16 | 23 | 24 | 25 | 26 | 27 | {{c("9")}} 28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 |
36 | -------------------------------------------------------------------------------- /__tests__/mapDiffToKeys.spec.ts: -------------------------------------------------------------------------------- 1 | import { mapDiffToKeys } from '../src/keys-detective/map-diff-to-keys'; 2 | import type { Diff } from 'deep-diff'; 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | describe('mapDiffToKeys', () => { 6 | it('should pass when no missing or extra keys were found', () => { 7 | expect(mapDiffToKeys([], 'lhs')).toEqual(''); 8 | expect(mapDiffToKeys([], 'rhs')).toEqual(''); 9 | }); 10 | 11 | it('should return the correct keys for added properties', () => { 12 | const diffArr: Diff[] = [ 13 | { kind: 'N', path: ['a'], rhs: 1 }, 14 | { kind: 'N', path: ['b', 'c'], rhs: 2 }, 15 | ]; 16 | 17 | const result = mapDiffToKeys(diffArr, 'rhs'); 18 | expect(result).toBe(["'a'", "'b.c'"].join('\n')); 19 | }); 20 | 21 | it('should return the correct keys for deleted properties', () => { 22 | const diffArr: Diff[] = [ 23 | { kind: 'D', path: ['a'], lhs: 1 }, 24 | { kind: 'D', path: ['b', 'c'], lhs: 2 }, 25 | ]; 26 | 27 | const result = mapDiffToKeys(diffArr, 'lhs'); 28 | expect(result).toBe(["'a'", "'b.c'"].join('\n')); 29 | }); 30 | 31 | it('should handle nested objects correctly', () => { 32 | const diffArr: Diff[] = [ 33 | { kind: 'N', path: ['a'], rhs: { b: { c: 1 } } }, 34 | ]; 35 | 36 | const result = mapDiffToKeys(diffArr, 'rhs'); 37 | 38 | expect(result).toBe("'a.b.c'"); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/control-flow/control-flow-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { generateKeys, mockResolveProjectBasePath } from '../../../spec-utils'; 9 | import { Config } from '../../../../src/types'; 10 | import { describe, beforeEach, it } from 'vitest'; 11 | 12 | mockResolveProjectBasePath(sourceRoot); 13 | 14 | /** 15 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 16 | * This thing is still in WIP at Jest, so keep an eye on it. 17 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 18 | */ 19 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 20 | 21 | export function testControlFlowExtraction(fileFormat: Config['fileFormat']) { 22 | describe('Control flow', () => { 23 | const type: TranslationTestCase = 'template-extraction/control-flow'; 24 | const config = buildConfig({ type, config: { fileFormat } }); 25 | 26 | beforeEach(() => removeI18nFolder(type)); 27 | 28 | it('should work with control flow', () => { 29 | let expected = generateKeys({ end: 27 }); 30 | buildTranslationFiles(config); 31 | assertTranslation({ type, expected, fileFormat }); 32 | }); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/keys-builder/add-key.ts: -------------------------------------------------------------------------------- 1 | import { messages } from '../messages'; 2 | import { BaseParams } from '../types'; 3 | import { isFunction, isNil, isString } from '../utils/validators.utils'; 4 | 5 | interface AddKeysParams extends BaseParams { 6 | scopeAlias: string | null; 7 | keyWithoutScope: string; 8 | params?: string[]; 9 | } 10 | 11 | export function addKey({ 12 | defaultValue, 13 | scopeToKeys, 14 | scopeAlias, 15 | keyWithoutScope, 16 | scopes, 17 | params = [], 18 | }: AddKeysParams) { 19 | if (!keyWithoutScope) { 20 | return; 21 | } 22 | 23 | const scopePath = scopeAlias && scopes.aliasToScope[scopeAlias]; 24 | const keyWithScope = scopeAlias 25 | ? `${scopeAlias}.${keyWithoutScope}` 26 | : keyWithoutScope; 27 | const paramsWithInterpolation = params.map((p) => `{{${p}}}`).join(' '); 28 | 29 | const keyValue = isNil(defaultValue) 30 | ? `${messages.missingValue} '${keyWithScope}'` 31 | : defaultValue 32 | .replace('{{key}}', keyWithScope) 33 | .replace('{{keyWithoutScope}}', keyWithoutScope) 34 | .replace('{{params}}', paramsWithInterpolation) 35 | .replace('{{scope}}', scopeAlias || ''); 36 | 37 | if (scopePath) { 38 | if (!scopeToKeys[scopePath]) { 39 | scopeToKeys[scopePath] = {}; 40 | } 41 | scopeToKeys[scopePath][keyWithoutScope] = keyValue; 42 | } else { 43 | scopeToKeys.__global[keyWithoutScope] = keyValue; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/keys-detective/index.ts: -------------------------------------------------------------------------------- 1 | import { setConfig } from '../config'; 2 | import { buildKeys } from '../keys-builder/build-keys'; 3 | import { messages } from '../messages'; 4 | import { Config } from '../types'; 5 | import { getLogger } from '../utils/logger'; 6 | import { resolveConfig } from '../utils/resolve-config'; 7 | 8 | import { compareKeysToFiles } from './compare-keys-to-files'; 9 | import { getTranslationFilesPath } from './get-translation-files-path'; 10 | 11 | export function findMissingKeys(inlineConfig: Config) { 12 | const logger = getLogger(); 13 | const config = resolveConfig(inlineConfig); 14 | setConfig(config); 15 | 16 | const { translationsPath, fileFormat } = config; 17 | const translationFiles = getTranslationFilesPath( 18 | translationsPath, 19 | fileFormat, 20 | ); 21 | 22 | if (translationFiles.length === 0) { 23 | console.log('No translation files found.'); 24 | return; 25 | } 26 | 27 | logger.log('\n 🕵 🔎', `\x1b[4m${messages.startSearch}\x1b[0m`, '🔍 🕵\n'); 28 | logger.startSpinner(`${messages.extract} `); 29 | 30 | const result = buildKeys(config); 31 | logger.success(`${messages.extract} 🗝`); 32 | 33 | const { addMissingKeys, emitErrorOnExtraKeys, unflat } = config; 34 | compareKeysToFiles({ 35 | scopeToKeys: result.scopeToKeys, 36 | translationsPath, 37 | addMissingKeys, 38 | emitErrorOnExtraKeys, 39 | fileFormat, 40 | unflat, 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > The Transloco packages are now published under the **@jsverse** scope, update your dependencies to get the latest features 🚀 3 | 4 |

5 | 6 |

7 | 8 | > 🦄 The Key to a Better Translation Experience 9 | 10 | ![Build Status](https://github.com/jsverse/transloco-keys-manager/actions/workflows/ci.yml/badge.svg) 11 | [![NPM Version](https://img.shields.io/npm/v/%40jsverse%2Ftransloco-keys-manager)](https://www.npmjs.com/package/@jsverse/transloco-keys-manager) 12 | 13 | Translation is a tiresome and repetitive task. Each time we add new text, we need to create a new entry in the translation file, find the correct placement for it, etc. Moreover, when we delete existing keys, we need to remember to remove them from each translation file. 14 | 15 | To make the process less burdensome, we've created two tools for the Transloco library, which will do the monotonous work for you. 16 | 17 | ## 🍻Key Features 18 | 19 | - ✅  Extract Translate Keys 20 | - ✅  Scopes Support 21 | - ✅  Webpack Plugin 22 | - ✅  Find Missing and Extra Keys 23 | 24 | Read the full documentation on the [official Transloco documentation site](https://jsverse.gitbook.io/transloco/tools/keys-manager-tkm). 25 | 26 | ## Contributors ✨ 27 | 28 | Thank goes to all these wonderful [people who contributed](https://github.com/jsverse/transloco-keys-manager/graphs/contributors) ❤️ 29 | -------------------------------------------------------------------------------- /src/keys-builder/utils/resolvers.utils.ts: -------------------------------------------------------------------------------- 1 | import { Scopes } from '../../types'; 2 | 3 | export function resolveAliasAndKey( 4 | key: string, 5 | scopes: Scopes, 6 | ): [string, string | null] { 7 | /** 8 | * 9 | * It can be one of the following: 10 | * 11 | * {{ 'title' | transloco }} 12 | * 13 | * {{ 'scopeAlias.title' | transloco }} 14 | * 15 | */ 16 | const [scopeAliasOrKey, ...actualKey] = key.split('.'); 17 | const scopeAliasExists = scopes.aliasToScope.hasOwnProperty(scopeAliasOrKey); 18 | const translationKey = scopeAliasExists ? actualKey.join('.') : key; 19 | 20 | return [translationKey, scopeAliasExists ? scopeAliasOrKey : null]; 21 | } 22 | 23 | /** 24 | * 25 | * Resolve the scope alias 26 | * 27 | * @example 28 | * 29 | * scopePath: 'some/nested' => someNested 30 | * scopePath: 'some/nested/en' => someNested 31 | * 32 | */ 33 | export function resolveScopeAlias({ 34 | scopePath, 35 | scopes, 36 | }: { 37 | scopePath: string; 38 | scopes: Scopes; 39 | }) { 40 | const scopeAlias = scopes.scopeToAlias[scopePath]; 41 | if (scopeAlias) { 42 | return scopeAlias; 43 | } 44 | 45 | // Otherwise we're probably have a language in the scope: some/nested/en 46 | const splitted = scopePath.split('/'); 47 | 48 | // Remove the lang 49 | splitted.pop(); 50 | 51 | const scopePathWithoutLang = splitted.join('/'); 52 | return scopePathWithoutLang && scopes.scopeToAlias[scopePathWithoutLang]; 53 | } 54 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/prefix/src/2.html: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | {{t("4")}} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 29 | 30 | 31 | 32 | 33 | {{translate("4")}} 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/directive/src/1.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 7 |
8 | 9 | 11 | 12 | 13 | 14 | 17 | 27 | 28 |
29 |
30 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/unflat/unflat-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { defaultValue, mockResolveProjectBasePath } from '../../../spec-utils'; 9 | import { Config } from '../../../../src/types'; 10 | import { describe, beforeEach, it } from 'vitest'; 11 | 12 | mockResolveProjectBasePath(sourceRoot); 13 | 14 | /** 15 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 16 | * This thing is still in WIP at Jest, so keep an eye on it. 17 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 18 | */ 19 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 20 | 21 | export function testUnflatExtraction(fileFormat: Config['fileFormat']) { 22 | describe('unflat', () => { 23 | const type: TranslationTestCase = 'config-options/unflat'; 24 | const config = buildConfig({ type, config: { unflat: true, fileFormat } }); 25 | 26 | beforeEach(() => removeI18nFolder(type)); 27 | 28 | it('should work with unflat true', () => { 29 | const expected = { 30 | global: { 31 | a: { 32 | '1': defaultValue, 33 | }, 34 | }, 35 | }; 36 | buildTranslationFiles(config); 37 | assertTranslation({ type, expected: expected.global, fileFormat }); 38 | }); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/marker/marker-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { defaultValue, mockResolveProjectBasePath } from '../../../spec-utils'; 9 | import { Config } from '../../../../src/types'; 10 | import { describe, beforeEach, it } from 'vitest'; 11 | 12 | mockResolveProjectBasePath(sourceRoot); 13 | 14 | /** 15 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 16 | * This thing is still in WIP at Jest, so keep an eye on it. 17 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 18 | */ 19 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 20 | 21 | export function testMarkerExtraction(fileFormat: Config['fileFormat']) { 22 | describe('marker', () => { 23 | const type: TranslationTestCase = 'ts-extraction/marker'; 24 | 25 | beforeEach(() => removeI18nFolder(type)); 26 | 27 | it('should work with marker', () => { 28 | const config = buildConfig({ type, config: { fileFormat } }); 29 | 30 | const expected = { 31 | username4: defaultValue, 32 | password4: defaultValue, 33 | username: defaultValue, 34 | password: defaultValue, 35 | }; 36 | buildTranslationFiles(config); 37 | assertTranslation({ type, expected, fileFormat }); 38 | }); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/keys-builder/typescript/service.extractor.ts: -------------------------------------------------------------------------------- 1 | import { tsquery } from '@phenomnomnominal/tsquery'; 2 | import { SourceFile } from 'typescript'; 3 | import ts from 'typescript'; 4 | 5 | import { buildKeysFromASTNodes } from './build-keys-from-ast-nodes'; 6 | import { TSExtractorResult } from './types'; 7 | 8 | function buildInjectFunctionQuery(nodeType: string) { 9 | return `${nodeType}:has(CallExpression:has(Identifier[name=inject]):has(Identifier[name=TranslocoService]))`; 10 | } 11 | 12 | export function serviceExtractor(ast: SourceFile): TSExtractorResult { 13 | const constructorInjection = 14 | 'Constructor Parameter:has(TypeReference Identifier[name=TranslocoService])'; 15 | const injectFunction = ['PropertyDeclaration', 'VariableDeclaration'].map( 16 | buildInjectFunctionQuery, 17 | ); 18 | const serviceNameQuery = [constructorInjection, injectFunction].join(','); 19 | const serviceNameNodes = tsquery(ast, serviceNameQuery); 20 | 21 | let result: TSExtractorResult = []; 22 | 23 | for (const serviceName of serviceNameNodes) { 24 | if ( 25 | ts.isParameter(serviceName) || 26 | ts.isPropertyDeclaration(serviceName) || 27 | ts.isVariableDeclaration(serviceName) 28 | ) { 29 | const propName = serviceName.name.getText(); 30 | const methodNodes = tsquery( 31 | ast, 32 | `PropertyAccessExpression:has([text="${propName}"])`, 33 | ); 34 | 35 | result = result.concat(buildKeysFromASTNodes(methodNodes)); 36 | } 37 | } 38 | 39 | return result; 40 | } 41 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-container/with-params/1.html: -------------------------------------------------------------------------------- 1 | 2 |

ddsds

3 | {{ t('1', {'1': 'asd'}) }} 4 | 5 |

dsds

6 | 7 | dsds 8 | 9 | 10 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ad aperiam consequatur facilis ipsa maxime non optio qui 11 | reiciendis velit voluptate. Atque deserunt dignissimos explicabo natus placeat sunt veniam voluptates? Itaque. 12 | 13 | 14 | 15 | {{ b('2', {'2': 'asd'}) }} 16 | 17 | 18 | 19 | {{ t('5', {'5': 'asd'}) }} 20 | 21 | 22 |
    23 |
  • {{ variable | another : t('6', {'6': 123}) }}
  • 24 |
  • {{ variable | another : t('7', {'7': 12 }) | lowercase }}
  • 25 |
  • {{ variable | another : {a: t('8', {'8': {} })} | lowercase }}
  • 26 |
  • {{ t('10') | another: t('9') }}
  • 27 |
  • {{ t('11') | another: ('asd') }}
  • 28 | {{ b('nested', {a: '', b: {c: {d: ''} } }) }} 29 | {{ t('skip.params', someVariable) }} 30 |
31 |
32 |
33 |
34 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/comments/src/3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

ddsds

4 | 5 | 6 |

dsds

7 | 8 | dsds 9 | 10 | 11 | 12 | 13 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ad aperiam consequatur facilis ipsa maxime non optio qui 14 | reiciendis velit voluptate. Atque deserunt dignissimos explicabo natus placeat sunt veniam voluptates? Itaque. 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 | 30 | 31 | 32 |
33 | 34 | 35 |
{{title}}
36 |
37 |
38 |
-------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Config = { 2 | input: string[]; 3 | config: string; 4 | project: string; 5 | translationsPath: string; 6 | langs: string[]; 7 | defaultValue: undefined | string; 8 | replace: boolean; 9 | addMissingKeys: boolean; 10 | removeExtraKeys: boolean; 11 | emitErrorOnExtraKeys: boolean; 12 | scopes: Scopes; 13 | scopePathMap?: { 14 | [scopeAlias: string]: string; 15 | }; 16 | files: string[]; 17 | output: string; 18 | marker: string; 19 | sort: boolean; 20 | unflat: boolean; 21 | command: 'extract' | 'find'; 22 | fileFormat: FileFormats; 23 | }; 24 | 25 | export type FileFormats = 'json' | 'pot'; 26 | export type FileType = 'ts' | 'html'; 27 | 28 | export type ExtractionResult = { 29 | scopeToKeys: ScopeMap; 30 | fileCount: number; 31 | }; 32 | 33 | export type ExtractorConfig = { 34 | file: string; 35 | scopes: Scopes; 36 | defaultValue?: string; 37 | scopeToKeys: ScopeMap; 38 | }; 39 | 40 | export type Scopes = { 41 | // scope/path => scopePath 42 | scopeToAlias: { 43 | [scope: string]: string; 44 | }; 45 | // scopePath => scope/path 46 | aliasToScope: { 47 | [scopeAlias: string]: string; 48 | }; 49 | }; 50 | 51 | export type ScopeMap = { 52 | __global: Record; 53 | [scopePath: string]: Record; 54 | }; 55 | 56 | export enum TEMPLATE_TYPE { 57 | STRUCTURAL, 58 | NG_TEMPLATE, 59 | } 60 | 61 | export type BaseParams = { 62 | defaultValue?: string; 63 | scopeToKeys: ScopeMap; 64 | scopes: Scopes; 65 | }; 66 | 67 | export type Translation = Record; 68 | export type OrArray = T | T[]; 69 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-template/src/2.html: -------------------------------------------------------------------------------- 1 | 2 | 9 | {{ t("") }} 10 | 11 | 12 | 13 | {{t("23")}} 14 | 15 | 16 | 17 | 18 | {{ t('') }} 19 | 20 | 21 | 22 | 23 | 30 | 31 | 32 | 33 | 34 | {{translate("30")}} 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/inline-template/inline-template-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { 9 | defaultValue, 10 | generateKeys, 11 | mockResolveProjectBasePath, 12 | } from '../../../spec-utils'; 13 | import { Config } from '../../../../src/types'; 14 | import { describe, beforeEach, it } from 'vitest'; 15 | 16 | mockResolveProjectBasePath(sourceRoot); 17 | 18 | /** 19 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 20 | * This thing is still in WIP at Jest, so keep an eye on it. 21 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 22 | */ 23 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 24 | 25 | export function testInlineTemplateExtraction(fileFormat: Config['fileFormat']) { 26 | describe('inline template', () => { 27 | const type: TranslationTestCase = 'ts-extraction/inline-template'; 28 | const config = buildConfig({ type, config: { fileFormat } }); 29 | 30 | beforeEach(() => removeI18nFolder(type)); 31 | 32 | it('should work with inline templates', () => { 33 | const expected = generateKeys({ end: 23 }); 34 | ['Processing archive...', 'Restore Options'].forEach((nonNumericKey) => { 35 | expected[nonNumericKey] = defaultValue; 36 | }); 37 | buildTranslationFiles(config); 38 | assertTranslation({ type, expected, fileFormat }); 39 | }); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/pipe/src/1.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 7 |
8 | 9 | 11 | 12 | 13 | 14 | 18 | 30 | 31 |
32 |
33 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/scope-mapping/scope-mapping-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { generateKeys, mockResolveProjectBasePath } from '../../../spec-utils'; 9 | import { Config } from '../../../../src/types'; 10 | import { beforeEach, describe, it } from 'vitest'; 11 | 12 | mockResolveProjectBasePath(sourceRoot); 13 | 14 | /** 15 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 16 | * This thing is still in WIP at Jest, so keep an eye on it. 17 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 18 | */ 19 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 20 | 21 | export function testScopeMappingConfig(fileFormat: Config['fileFormat']) { 22 | describe('Scope mapping', () => { 23 | const type: TranslationTestCase = 'config-options/scope-mapping'; 24 | const config = buildConfig({ 25 | type, 26 | config: { 27 | fileFormat, 28 | scopePathMap: { 29 | scope1: `./${sourceRoot}/${type}/i18n/scopes/mapped`, 30 | }, 31 | }, 32 | }); 33 | 34 | beforeEach(() => removeI18nFolder(type)); 35 | 36 | it('should work with scope mapping', () => { 37 | let expected = generateKeys({ end: 3 }); 38 | buildTranslationFiles(config); 39 | assertTranslation({ 40 | type, 41 | path: 'scopes/mapped/', 42 | expected, 43 | fileFormat, 44 | }); 45 | }); 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/signal/signal-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { defaultValue, mockResolveProjectBasePath } from '../../../spec-utils'; 9 | import { Config } from '../../../../src/types'; 10 | import { describe, beforeEach, it } from 'vitest'; 11 | 12 | mockResolveProjectBasePath(sourceRoot); 13 | 14 | /** 15 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 16 | * This thing is still in WIP at Jest, so keep an eye on it. 17 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 18 | */ 19 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 20 | 21 | export function testSignalExtraction(fileFormat: Config['fileFormat']) { 22 | describe('signal', () => { 23 | const type: TranslationTestCase = 'ts-extraction/signal'; 24 | 25 | beforeEach(() => removeI18nFolder(type)); 26 | 27 | it('should work with signals', () => { 28 | const config = buildConfig({ type, config: { fileFormat } }); 29 | 30 | const expected = { 31 | username: defaultValue, 32 | password: defaultValue, 33 | title: defaultValue, 34 | username2: defaultValue, 35 | password2: defaultValue, 36 | title2: defaultValue, 37 | username3: defaultValue, 38 | password3: defaultValue, 39 | title3: defaultValue 40 | }; 41 | buildTranslationFiles(config); 42 | assertTranslation({ type, expected, fileFormat }); 43 | }); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/directive/with-params/1.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | 7 |
8 | 9 | 11 | 12 | 13 | 14 | 17 | 26 | 27 |
28 |
29 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/scope/scope-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { generateKeys, mockResolveProjectBasePath } from '../../../spec-utils'; 9 | import { Config } from '../../../../src/types'; 10 | import { describe, beforeEach, it } from 'vitest'; 11 | 12 | mockResolveProjectBasePath(sourceRoot); 13 | 14 | /** 15 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 16 | * This thing is still in WIP at Jest, so keep an eye on it. 17 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 18 | */ 19 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 20 | 21 | export function testScopeExtraction(fileFormat: Config['fileFormat']) { 22 | describe('scope', () => { 23 | const type: TranslationTestCase = 'template-extraction/scope'; 24 | const config = buildConfig({ type, config: { fileFormat } }); 25 | 26 | beforeEach(() => removeI18nFolder(type)); 27 | 28 | it('should work with scope', () => { 29 | const scopes = Array.from(Array(9), (_, index) => `scope${index + 1}`); 30 | 31 | const expected: Record> = {}; 32 | for (const scope of scopes) { 33 | expected[scope] = generateKeys({ end: 1 }); 34 | } 35 | 36 | buildTranslationFiles(config); 37 | scopes.forEach((scope) => { 38 | assertTranslation({ 39 | type, 40 | expected: expected[scope], 41 | path: `${scope}/`, 42 | fileFormat, 43 | }); 44 | }); 45 | }); 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/unflat-sort/unflat-sort-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { defaultValue, mockResolveProjectBasePath } from '../../../spec-utils'; 9 | import { Config } from '../../../../src/types'; 10 | import { describe, beforeEach, it } from 'vitest'; 11 | 12 | mockResolveProjectBasePath(sourceRoot); 13 | 14 | /** 15 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 16 | * This thing is still in WIP at Jest, so keep an eye on it. 17 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 18 | */ 19 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 20 | 21 | export function testUnflatSortExtraction(fileFormat: Config['fileFormat']) { 22 | describe('unflat-sort', () => { 23 | const type: TranslationTestCase = 'config-options/unflat-sort'; 24 | const config = buildConfig({ 25 | type, 26 | config: { unflat: true, sort: true, fileFormat }, 27 | }); 28 | 29 | beforeEach(() => removeI18nFolder(type)); 30 | 31 | it('should work with unflat and sort true', () => { 32 | const expected = { 33 | global: { 34 | b: { 35 | b: { 36 | a: defaultValue, 37 | b: defaultValue, 38 | }, 39 | c: { 40 | a: defaultValue, 41 | p: defaultValue, 42 | x: defaultValue, 43 | }, 44 | }, 45 | }, 46 | }; 47 | buildTranslationFiles(config); 48 | assertTranslation({ type, expected: expected.global, fileFormat }); 49 | }); 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /src/keys-builder/utils/get-current-translation.ts: -------------------------------------------------------------------------------- 1 | import { unflatten } from 'flat'; 2 | import fs from 'fs-extra'; 3 | import { po } from 'gettext-parser'; 4 | 5 | import { getConfig } from '../../config'; 6 | import { FileFormats, Translation } from '../../types'; 7 | 8 | function parseJson(path: string): Translation { 9 | return fs.readJsonSync(path, { throws: false }) || {}; 10 | } 11 | 12 | function parsePot(path: string) { 13 | try { 14 | const file = fs.readFileSync(path, 'utf8'); 15 | const parsed = po.parse(file, 'utf8'); 16 | 17 | if (!Object.keys(parsed.translations).length) { 18 | return {}; 19 | } 20 | 21 | const value = Object.keys(parsed.translations['']) 22 | .filter((key) => key.length > 0) 23 | .reduce( 24 | (acc, key) => { 25 | return { 26 | ...acc, 27 | [key]: parsed.translations[''][key].msgstr.pop()!, 28 | }; 29 | }, 30 | {} as Record, 31 | ); 32 | 33 | return getConfig().unflat 34 | ? unflatten, Translation>(value, { 35 | object: true, 36 | }) 37 | : value; 38 | } catch (e: any) { 39 | if (e.code === 'ENOENT') { 40 | return {}; 41 | } 42 | 43 | console.warn( 44 | 'Something is wrong with the provided file at "%s":', 45 | path, 46 | e.message, 47 | ); 48 | 49 | return {}; 50 | } 51 | } 52 | 53 | const parsers: Record Translation> = { 54 | json: parseJson, 55 | pot: parsePot, 56 | }; 57 | 58 | interface GetTranslationsOptions { 59 | path: string; 60 | fileFormat: FileFormats; 61 | } 62 | 63 | export function getCurrentTranslation({ 64 | path, 65 | fileFormat, 66 | }: GetTranslationsOptions): Translation { 67 | return parsers[fileFormat](path); 68 | } 69 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/pure-function/pure-function-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { 9 | generateKeys, 10 | mockResolveProjectBasePath, 11 | paramsTestConfig, 12 | resolveValueWithParams, 13 | } from '../../../spec-utils'; 14 | import { Config } from '../../../../src/types'; 15 | import { describe, beforeEach, it } from 'vitest'; 16 | 17 | mockResolveProjectBasePath(sourceRoot); 18 | 19 | /** 20 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 21 | * This thing is still in WIP at Jest, so keep an eye on it. 22 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 23 | */ 24 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 25 | 26 | export function testPureFunctionExtraction(fileFormat: Config['fileFormat']) { 27 | describe('pure-function', () => { 28 | const type: TranslationTestCase = 'ts-extraction/pure-function'; 29 | const config = buildConfig({ type, config: { fileFormat } }); 30 | 31 | beforeEach(() => removeI18nFolder(type)); 32 | 33 | it('should work with the pure `translate` function', () => { 34 | const expected = generateKeys({ end: 4 }); 35 | 36 | buildTranslationFiles(config); 37 | assertTranslation({ type, expected, fileFormat }); 38 | }); 39 | 40 | it('should extract params', () => { 41 | const expected = { 42 | ...generateKeys({ end: 3, withParams: true }), 43 | 4: resolveValueWithParams(['foo', 'a', 'b.c']), 44 | }; 45 | 46 | buildTranslationFiles(paramsTestConfig(config)); 47 | assertTranslation({ type, expected, fileFormat }); 48 | }); 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /src/keys-builder/create-translation-files.ts: -------------------------------------------------------------------------------- 1 | import { messages } from '../messages'; 2 | import { Config, ScopeMap } from '../types'; 3 | import { getLogger } from '../utils/logger'; 4 | import { buildScopeFilePaths } from '../utils/path.utils'; 5 | 6 | import { buildTranslationFile, FileAction } from './build-translation-file'; 7 | import { runPrettier } from './utils/run-prettier'; 8 | 9 | export async function createTranslationFiles({ 10 | scopeToKeys, 11 | langs, 12 | output, 13 | replace, 14 | removeExtraKeys, 15 | scopes, 16 | fileFormat, 17 | }: Config & { scopeToKeys: ScopeMap }) { 18 | const logger = getLogger(); 19 | 20 | const scopeFiles = buildScopeFilePaths({ 21 | aliasToScope: scopes.aliasToScope, 22 | langs, 23 | output, 24 | fileFormat, 25 | }); 26 | const globalFiles = langs.map((lang) => ({ 27 | path: `${output}/${lang}.${fileFormat}`, 28 | })); 29 | const actions: FileAction[] = []; 30 | 31 | for (const { path } of globalFiles) { 32 | actions.push( 33 | buildTranslationFile({ 34 | path, 35 | translation: scopeToKeys.__global, 36 | replace, 37 | removeExtraKeys, 38 | fileFormat, 39 | }), 40 | ); 41 | } 42 | 43 | for (const { path, scope } of scopeFiles) { 44 | actions.push( 45 | buildTranslationFile({ 46 | path, 47 | translation: scopeToKeys[scope], 48 | replace, 49 | removeExtraKeys, 50 | fileFormat, 51 | }), 52 | ); 53 | } 54 | 55 | if (fileFormat === 'json') { 56 | await runPrettier(actions.map(({ path }) => path)); 57 | } 58 | 59 | const newFiles = actions.filter((action) => action.type === 'new'); 60 | 61 | if (newFiles.length) { 62 | logger.success(`${messages.creatingFiles} 🗂`); 63 | logger.log(newFiles.map((action) => action.path).join('\n')); 64 | } 65 | 66 | logger.log(`\n 🌵 ${messages.done} 🌵`); 67 | } 68 | -------------------------------------------------------------------------------- /src/webpack-plugin/generate-keys.ts: -------------------------------------------------------------------------------- 1 | import { TranslocoGlobalConfig } from '@jsverse/transloco-utils'; 2 | import { unflatten } from 'flat'; 3 | import { sync as globSync } from 'glob'; 4 | import { basename } from 'node:path'; 5 | 6 | import { ScopeMap, Config } from '../types'; 7 | import { readFile, writeFile } from '../utils/file.utils'; 8 | import { mergeDeep } from '../utils/object.utils'; 9 | 10 | type Params = { 11 | translationPath: string; 12 | scopeToKeys: ScopeMap; 13 | config: Config & TranslocoGlobalConfig; 14 | }; 15 | 16 | function filterLangs(config: Params['config']) { 17 | return function (path: string) { 18 | return config.langs.find( 19 | (lang) => lang === basename(path).replace(`.${config.fileFormat}`, ''), 20 | ); 21 | }; 22 | } 23 | 24 | /** 25 | * In use in the Webpack Plugin 26 | */ 27 | export function generateKeys({ translationPath, scopeToKeys, config }: Params) { 28 | const scopePaths = config.scopePathMap || {}; 29 | 30 | let result = []; 31 | 32 | for (const [scope, path] of Object.entries(scopePaths)) { 33 | const keys = scopeToKeys[scope]; 34 | if (keys) { 35 | result.push({ 36 | keys, 37 | files: globSync(`${path}/*.${config.fileFormat}`).filter( 38 | filterLangs(config), 39 | ), 40 | }); 41 | } 42 | } 43 | 44 | for (const [scope, keys] of Object.entries(scopeToKeys)) { 45 | if (keys) { 46 | const isGlobal = scope === '__global'; 47 | 48 | result.push({ 49 | keys, 50 | files: globSync( 51 | `${translationPath}/${isGlobal ? '' : scope}*.${config.fileFormat}`, 52 | ).filter(filterLangs(config)), 53 | }); 54 | } 55 | } 56 | 57 | for (let { files, keys } of result) { 58 | if (config.unflat) { 59 | keys = unflatten(keys); 60 | } 61 | for (const filePath of files) { 62 | const translation = readFile(filePath, { parse: true }); 63 | writeFile(filePath, mergeDeep({}, keys, translation)); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/pipe/src/3.html: -------------------------------------------------------------------------------- 1 |
2 | 8 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 26 | 27 | 37 | 38 |
39 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/directive/directive-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { 9 | defaultValue, 10 | generateKeys, 11 | mockResolveProjectBasePath, 12 | resolveValueWithParams, 13 | paramsTestConfig, 14 | } from '../../../spec-utils'; 15 | import { Config } from '../../../../src/types'; 16 | import { describe, beforeEach, it } from 'vitest'; 17 | 18 | mockResolveProjectBasePath(sourceRoot); 19 | 20 | /** 21 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 22 | * This thing is still in WIP at Jest, so keep an eye on it. 23 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 24 | */ 25 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 26 | 27 | export function testDirectiveExtraction(fileFormat: Config['fileFormat']) { 28 | describe('Directive', () => { 29 | const type: TranslationTestCase = 'template-extraction/directive'; 30 | const config = buildConfig({ type, config: { fileFormat } }); 31 | 32 | beforeEach(() => removeI18nFolder(type)); 33 | 34 | it('should work with directive', () => { 35 | const expected = generateKeys({ end: 24 }); 36 | ['Processing archive...', 'Restore Options'].forEach((nonNumericKey) => { 37 | expected[nonNumericKey] = defaultValue; 38 | }); 39 | buildTranslationFiles(config); 40 | assertTranslation({ type, expected, fileFormat }); 41 | }); 42 | 43 | it('should extract params', () => { 44 | const expected = { 45 | ...generateKeys({ end: 3, withParams: true }), 46 | ...generateKeys({ start: 4, end: 5 }), 47 | 'admin.key': resolveValueWithParams(['a', 'b.c', 'b.d.e', 'b.f']), 48 | }; 49 | buildTranslationFiles(paramsTestConfig(config)); 50 | assertTranslation({ type, expected, fileFormat }); 51 | }); 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/control-flow/src/1.html: -------------------------------------------------------------------------------- 1 | @let myVar = '27' | transloco; 2 | {{ '1' | transloco }} 3 | 4 | 5 | {{ t('3') }} 6 | 7 | 8 | {{ t('4') }} 9 | 10 | 11 | 12 | @if (a > b) { 13 | {{ '5' | transloco }} 14 | 15 | 16 | 17 | {{ t('7') }} 18 | 19 | 20 | {{ t('8') }} 21 | 22 | } @else if (b > a) { 23 | {{ '9' | transloco }} 24 | } @else { 25 | 26 | } 27 | 28 | @for (item of items; track item.id) { 29 | {{ '11' | transloco }} 30 | } @empty { 31 | {{ '12' | transloco }} 32 | } 33 | 34 | @switch (condition) { 35 | @case (caseA) { 36 | {{ '13' | transloco }} 37 | } 38 | @default { 39 | {{ '14' | transloco }} 40 | } 41 | } 42 | 43 | @defer { 44 | {{ '15' | transloco }} 45 | } @error { 46 | {{ '16' | transloco }} 47 | } @placeholder { 48 | {{ '17' | transloco }} 49 | } @loading { 50 | {{ '18' | transloco }} 51 | } 52 | 53 | 54 | @if (a > b) { 55 | {{ t('19') }} 56 | } @else if (b > a) { 57 | 58 | } @else { 59 | @for (item of items; track item.id) { 60 | {{ t('21') }} 61 | } @empty { 62 | @switch (condition) { 63 | @case (caseA) { 64 | {{ t('22') }} 65 | } 66 | @default { 67 | @defer { 68 | {{ '23' | transloco }} 69 | } @error { 70 | 71 | } @placeholder { 72 | {{ t('25') }} 73 | } @loading { 74 | {{ t('26') }} 75 | } 76 | } 77 | } 78 | } 79 | } 80 | 81 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/build-translation-utils.ts: -------------------------------------------------------------------------------- 1 | import nodePath from 'node:path'; 2 | import { 3 | assertPartialTranslation as _assertPartialTranslation, 4 | assertTranslation as _assertTranslation, 5 | AssertTranslationParams, 6 | buildConfig as _buildConfig, 7 | BuildConfigOptions, 8 | removeI18nFolder as _removeI18nFolder, 9 | } from '../spec-utils'; 10 | 11 | export const sourceRoot = '__tests__/buildTranslationFiles'; 12 | 13 | export type TranslationTestCase = 14 | | 'template-extraction/pipe' 15 | | 'template-extraction/directive' 16 | | 'template-extraction/ng-container' 17 | | 'template-extraction/ng-template' 18 | | 'template-extraction/control-flow' 19 | | 'template-extraction/prefix' 20 | | 'template-extraction/scope' 21 | | 'ts-extraction/service' 22 | | 'ts-extraction/pure-function' 23 | | 'ts-extraction/marker' 24 | | 'ts-extraction/signal' 25 | | 'ts-extraction/inline-template' 26 | | 'config-options/unflat' 27 | | 'config-options/unflat-sort' 28 | | 'config-options/unflat-problematic-keys' 29 | | 'config-options/multi-input' 30 | | 'config-options/scope-mapping' 31 | | 'config-options/remove-extra-keys' 32 | | 'comments'; 33 | 34 | type WithTestCase = T & { type: TranslationTestCase }; 35 | 36 | export function buildConfig( 37 | options: WithTestCase>, 38 | ) { 39 | return _buildConfig({ 40 | ...options, 41 | sourceRoot: nodePath.join(sourceRoot, options.type), 42 | }); 43 | } 44 | 45 | type AssertParams = WithTestCase>; 46 | 47 | export function assertTranslation({ type, ...params }: AssertParams) { 48 | _assertTranslation({ 49 | ...params, 50 | root: nodePath.join(sourceRoot, type), 51 | }); 52 | } 53 | 54 | export function assertPartialTranslation({ type, ...params }: AssertParams) { 55 | _assertPartialTranslation({ 56 | ...params, 57 | root: nodePath.join(sourceRoot, type), 58 | }); 59 | } 60 | 61 | export function removeI18nFolder(type: TranslationTestCase) { 62 | _removeI18nFolder(nodePath.join(sourceRoot, type)); 63 | } 64 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/multi-input/multi-input-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { 9 | defaultValue, 10 | generateKeys, 11 | mockResolveProjectBasePath, 12 | } from '../../../spec-utils'; 13 | import { Config } from '../../../../src/types'; 14 | import nodePath from 'node:path'; 15 | import { beforeEach, describe, it } from 'vitest'; 16 | 17 | mockResolveProjectBasePath(sourceRoot); 18 | 19 | /** 20 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 21 | * This thing is still in WIP at Jest, so keep an eye on it. 22 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 23 | */ 24 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 25 | 26 | export function testMultiInputsConfig(fileFormat: Config['fileFormat']) { 27 | describe('Multi Inputs', () => { 28 | const type: TranslationTestCase = 'config-options/multi-input'; 29 | const basePath = nodePath.join(type, 'src'); 30 | const config = buildConfig({ 31 | type, 32 | config: { 33 | fileFormat, 34 | input: [1, 2].map((v) => 35 | nodePath.join(sourceRoot, basePath, `folder-${v}`), 36 | ), 37 | }, 38 | }); 39 | 40 | beforeEach(() => removeI18nFolder(type)); 41 | 42 | it('should work with multiple inputs', () => { 43 | let expected = generateKeys({ end: 39 }); 44 | buildTranslationFiles(config); 45 | assertTranslation({ type, expected, fileFormat }); 46 | }); 47 | 48 | it('should work with scopes', () => { 49 | let expected = { 50 | '1': defaultValue, 51 | '2.1': defaultValue, 52 | '3.1': defaultValue, 53 | '4': defaultValue, 54 | '5': defaultValue, 55 | }; 56 | 57 | buildTranslationFiles(config); 58 | assertTranslation({ 59 | type, 60 | expected, 61 | path: 'admin-page/', 62 | fileFormat, 63 | }); 64 | }); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /src/keys-detective/build-table.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import Table from 'cli-table3'; 3 | 4 | import { messages } from '../messages'; 5 | import { getLogger } from '../utils/logger'; 6 | 7 | import { mapDiffToKeys } from './map-diff-to-keys'; 8 | 9 | type Params = { 10 | addMissingKeys: boolean; 11 | emitErrorOnExtraKeys: boolean; 12 | langs: string[]; 13 | diffsPerLang: { 14 | [lang: string]: { 15 | missing: any[]; 16 | extra: any[]; 17 | }; 18 | }; 19 | }; 20 | 21 | export function buildTable({ 22 | langs, 23 | diffsPerLang, 24 | addMissingKeys, 25 | emitErrorOnExtraKeys, 26 | }: Params) { 27 | const logger = getLogger(); 28 | if (langs.length > 0) { 29 | let displayAddedMsg = false; 30 | let hasExtraKeys = false; 31 | 32 | logger.success(`\x1b[4m${messages.summary}\x1b[0m\n`); 33 | const table = new Table({ 34 | style: { 35 | border: ['white'], 36 | }, 37 | head: ['File Name', 'Missing Keys', 'Extra Keys'].map((h) => 38 | chalk.cyan(h), 39 | ), 40 | }); 41 | 42 | for (let i = 0; i < langs.length; i++) { 43 | const row: any = []; 44 | const { missing, extra } = diffsPerLang[langs[i]]; 45 | const hasMissing = missing.length > 0; 46 | const hasExtra = extra.length > 0; 47 | 48 | if (!(hasExtra || hasMissing)) continue; 49 | 50 | row.push(chalk.blueBright(langs[i])); 51 | 52 | if (hasMissing) { 53 | row.push(mapDiffToKeys(missing, 'rhs')); 54 | displayAddedMsg = true; 55 | } else { 56 | row.push('--'); 57 | } 58 | 59 | if (hasExtra) { 60 | row.push(mapDiffToKeys(extra, 'lhs')); 61 | hasExtraKeys = true; 62 | } else { 63 | row.push('--'); 64 | } 65 | table.push(row); 66 | } 67 | 68 | logger.log(table.toString()); 69 | if (displayAddedMsg) { 70 | if (addMissingKeys) { 71 | logger.success(`Added all missing keys\n`); 72 | } else { 73 | process.exit(1); 74 | } 75 | } 76 | 77 | if (hasExtraKeys && emitErrorOnExtraKeys) { 78 | process.exit(2); 79 | } 80 | } else { 81 | logger.log(`\n🎉 ${messages.noMissing} 🎉\n`); 82 | } 83 | 84 | logger.log('\n'); 85 | } 86 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from './types'; 2 | 3 | let config: Config; 4 | 5 | export function setConfig(_config: Config) { 6 | config = _config; 7 | } 8 | 9 | export function getConfig(): Config { 10 | return config; 11 | } 12 | 13 | export type ProjectType = 'application' | 'library'; 14 | 15 | interface Options { 16 | projectType?: ProjectType; 17 | sourceRoot?: string; 18 | } 19 | export function defaultConfig({ 20 | projectType = 'application', 21 | sourceRoot = 'src', 22 | }: Options = {}): Omit< 23 | Config, 24 | | 'config' 25 | | 'project' 26 | | 'scopes' 27 | | 'scopePathMap' 28 | | 'unflat' 29 | | 'command' 30 | | 'files' 31 | > { 32 | const isApp = projectType === 'application'; 33 | const input = `${sourceRoot}/${isApp ? 'app' : 'lib'}`; 34 | const i18nPath = `${sourceRoot}/assets/i18n`; 35 | 36 | return { 37 | // The source directory for all files using the translation keys 38 | input: [input], 39 | 40 | // The target directory for all generated translation files 41 | output: i18nPath, 42 | 43 | // The language files to generate 44 | langs: ['en'], 45 | 46 | // The marker sign for dynamic values 47 | marker: 't', 48 | 49 | // Whether to sort the keys 50 | sort: false, 51 | 52 | /** 53 | * Relevant only for the Extractor 54 | */ 55 | 56 | // The default value of a generated key 57 | defaultValue: undefined, 58 | 59 | // Replace the contents of a translation file (if it exists) with the generated one (default value is false, in which case files are merged) 60 | replace: false, 61 | 62 | // Remove missing keys from existing translation files 63 | removeExtraKeys: false, 64 | 65 | /** 66 | * Relevant only for the Detective 67 | */ 68 | 69 | // Add missing keys that were found by the detective (default value is false) 70 | addMissingKeys: false, 71 | 72 | // Emit an error and exit the process if extra keys were found (defaults to `false`) 73 | emitErrorOnExtraKeys: false, 74 | 75 | // The path for the root translation files (for example: assets/i18n) 76 | translationsPath: i18nPath, 77 | 78 | // The translation files format (`json`, `pot`) default is `json` 79 | fileFormat: 'json', 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /src/keys-builder/add-comment-section-keys.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from '../config'; 2 | import { BaseParams } from '../types'; 3 | import { regexFactoryMap } from '../utils/regexs.utils'; 4 | 5 | import { addKey } from './add-key'; 6 | import { resolveAliasAndKey } from './utils/resolvers.utils'; 7 | 8 | interface ExtractCommentsParams extends BaseParams { 9 | content: string; 10 | regexFactory(): RegExp; 11 | read?: string; 12 | } 13 | 14 | function stringToKeys(valueRegex: RegExp) { 15 | return function (str: string) { 16 | // Remove the wrapper, t(some.key) => some.key 17 | return ( 18 | str 19 | .replace(valueRegex, '$1') 20 | // Support multi keys t(a, b.c, d) 21 | .split(',') 22 | // Remove spaces 23 | .map((v) => v.replace(/[*\n]/g, '').trim()) 24 | // Remove empty keys 25 | .filter((key) => key.length > 0) 26 | ); 27 | }; 28 | } 29 | 30 | function flatten(acc: string[], strings: string[]) { 31 | acc.push(...strings); 32 | 33 | return acc; 34 | } 35 | 36 | export function addCommentSectionKeys({ 37 | content, 38 | regexFactory, 39 | read = '', 40 | ...baseParams 41 | }: ExtractCommentsParams) { 42 | const marker = getConfig().marker; 43 | const regex = regexFactory(); 44 | let commentsSection = regex.exec(content); 45 | 46 | while (commentsSection) { 47 | const valueRegex = regexFactoryMap.markerValues(marker); 48 | // Get the rawKeys from the dynamic section 49 | const markers = commentsSection[0].match(valueRegex); 50 | 51 | commentsSection = regex.exec(content); 52 | 53 | if (!markers) continue; 54 | 55 | markers 56 | .map(stringToKeys(valueRegex)) 57 | .reduce(flatten, []) 58 | .forEach((currentKey) => { 59 | const withRead = read ? `${read}.${currentKey}` : currentKey; 60 | 61 | let [translationKey, scopeAlias] = resolveAliasAndKey( 62 | withRead, 63 | baseParams.scopes, 64 | ); 65 | 66 | if (!scopeAlias) { 67 | // It means this is a global key 68 | translationKey = withRead; 69 | } 70 | 71 | addKey({ 72 | ...baseParams, 73 | keyWithoutScope: translationKey, 74 | scopeAlias, 75 | }); 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/inline-template/src/1.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterViewInit, 3 | Component, 4 | ElementRef, 5 | Inject, 6 | OnDestroy, 7 | OnInit, 8 | Renderer2, 9 | ViewChild, 10 | } from '@angular/core'; 11 | 12 | const SEARCH_INTERVAL = 400; 13 | const ITEM_ANIMATION_DURATION = 350; 14 | const SCROLL_ANIMATION = 500; 15 | 16 | @Component({ 17 | selector: 'bla-bla', 18 | template: `
19 |
20 | 21 |
22 | 27 |
28 | 29 | 34 | 35 | 36 | 37 | 44 | 65 | 66 |
67 |
`, 68 | }) 69 | export class Something implements OnInit, AfterViewInit, OnDestroy {} 70 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-template/ng-template-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { 9 | defaultValue, 10 | generateKeys, 11 | mockResolveProjectBasePath, 12 | paramsTestConfig, 13 | } from '../../../spec-utils'; 14 | import { Config } from '../../../../src/types'; 15 | import { describe, beforeEach, it } from 'vitest'; 16 | 17 | mockResolveProjectBasePath(sourceRoot); 18 | 19 | /** 20 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 21 | * This thing is still in WIP at Jest, so keep an eye on it. 22 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 23 | */ 24 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 25 | 26 | export function testNgTemplateExtraction(fileFormat: Config['fileFormat']) { 27 | describe('ng-template', () => { 28 | const type: TranslationTestCase = 'template-extraction/ng-template'; 29 | const config = buildConfig({ type, config: { fileFormat } }); 30 | 31 | beforeEach(() => removeI18nFolder(type)); 32 | 33 | it('should work with ngTemplate', () => { 34 | let expected = generateKeys({ end: 42 }); 35 | buildTranslationFiles(config); 36 | assertTranslation({ type, expected, fileFormat }); 37 | }); 38 | 39 | it('should work with scopes', () => { 40 | let expected = { 41 | '1': defaultValue, 42 | '2.1': defaultValue, 43 | '3.1': defaultValue, 44 | '4': defaultValue, 45 | '5': defaultValue, 46 | }; 47 | 48 | buildTranslationFiles(config); 49 | assertTranslation({ 50 | type, 51 | expected, 52 | path: 'todos-page/', 53 | fileFormat, 54 | }); 55 | }); 56 | 57 | it('should extract params', () => { 58 | const expected = { 59 | ...generateKeys({ 60 | start: 2, 61 | end: 6, 62 | withParams: true, 63 | }), 64 | ...generateKeys({ start: 7, end: 8 }), 65 | }; 66 | buildTranslationFiles(paramsTestConfig(config)); 67 | assertTranslation({ type, expected, fileFormat }); 68 | }); 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/pipe/pipe-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { 9 | defaultValue, 10 | generateKeys, 11 | mockResolveProjectBasePath, 12 | resolveValueWithParams, 13 | paramsTestConfig, 14 | } from '../../../spec-utils'; 15 | import { Config } from '../../../../src/types'; 16 | import { describe, beforeEach, it } from 'vitest'; 17 | 18 | mockResolveProjectBasePath(sourceRoot); 19 | 20 | /** 21 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 22 | * This thing is still in WIP at Jest, so keep an eye on it. 23 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 24 | */ 25 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 26 | 27 | export function testPipeExtraction(fileFormat: Config['fileFormat']) { 28 | describe('Pipe', () => { 29 | const type: TranslationTestCase = 'template-extraction/pipe'; 30 | const config = buildConfig({ type, config: { fileFormat } }); 31 | 32 | beforeEach(() => removeI18nFolder(type)); 33 | 34 | it('should work with pipe', () => { 35 | const expected = { 36 | ...generateKeys({ end: 48 }), 37 | '49.50.51.52': defaultValue, 38 | ...generateKeys({ start: 53, end: 62 }), 39 | '63.64.65': defaultValue, 40 | ...generateKeys({ start: 66, end: 79 }), 41 | '{{count}} items': defaultValue, 42 | }; 43 | [ 44 | 'Restore Options', 45 | 'Processing archive...', 46 | 'admin.1', 47 | 'admin.2', 48 | 'admin.3', 49 | ].forEach((nonNumericKey) => { 50 | expected[nonNumericKey] = defaultValue; 51 | }); 52 | 53 | buildTranslationFiles(config); 54 | assertTranslation({ type, expected, fileFormat }); 55 | }); 56 | 57 | it('should extract params', () => { 58 | const expected = { 59 | ...generateKeys({ end: 2, withParams: true }), 60 | 'admin.1': resolveValueWithParams(['a', 'b.c']), 61 | }; 62 | buildTranslationFiles(paramsTestConfig(config)); 63 | assertTranslation({ type, expected, fileFormat }); 64 | }); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /__tests__/findMissingKeys/add-missing-keys/add-missing-keys-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultValue, 3 | mockResolveProjectBasePath, 4 | buildConfig, 5 | assertTranslation, 6 | removeI18nFolder, 7 | } from '../../spec-utils'; 8 | import { Config } from '../../../src/types'; 9 | import nodePath from 'node:path'; 10 | import fs from 'fs-extra'; 11 | import { unflatten } from 'flat'; 12 | import { describe, beforeEach, it } from 'vitest'; 13 | 14 | const sourceRoot = '__tests__/findMissingKeys/add-missing-keys'; 15 | mockResolveProjectBasePath(sourceRoot); 16 | 17 | /** 18 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 19 | * This thing is still in WIP at Jest, so keep an eye on it. 20 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 21 | */ 22 | const { findMissingKeys } = await import('../../../src/keys-detective'); 23 | 24 | const missingJson = { 25 | '1': defaultValue, 26 | 'a.b': defaultValue, 27 | '5': defaultValue, 28 | }; 29 | 30 | const expectedJson = { 31 | ...missingJson, 32 | 'c.d': defaultValue, 33 | '4': defaultValue, 34 | }; 35 | 36 | export function testAddMissingKeysConfig( 37 | fileFormat: Config['fileFormat'] = 'json', 38 | ) { 39 | describe('Add Missing Keys', () => { 40 | const config = buildConfig({ 41 | config: { 42 | fileFormat, 43 | addMissingKeys: true, 44 | }, 45 | sourceRoot, 46 | }); 47 | const translationPath = nodePath.join(sourceRoot, 'i18n', 'en.json'); 48 | 49 | beforeEach(() => removeI18nFolder(sourceRoot)); 50 | 51 | it('should add missing keys to translation', () => { 52 | fs.ensureFileSync(translationPath); 53 | fs.writeFileSync(translationPath, JSON.stringify(missingJson)); 54 | findMissingKeys(config); 55 | assertTranslation({ 56 | expected: expectedJson, 57 | fileFormat, 58 | root: sourceRoot, 59 | }); 60 | }); 61 | 62 | it('should respect unflat option', () => { 63 | fs.ensureFileSync(translationPath); 64 | fs.writeFileSync(translationPath, JSON.stringify(unflatten(missingJson))); 65 | findMissingKeys({ ...config, unflat: true }); 66 | assertTranslation({ 67 | expected: unflatten(expectedJson), 68 | fileFormat, 69 | root: sourceRoot, 70 | }); 71 | }); 72 | }); 73 | } 74 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | title: "Feature:" 4 | 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Is there an existing issue for this? 9 | description: Please search to see if an issue already exists for the bug you encountered. 10 | options: 11 | - label: I have searched the existing issues 12 | required: true 13 | 14 | - type: textarea 15 | id: feature-behavior 16 | attributes: 17 | label: Is your feature request related to a problem? Please describe 18 | description: A clear and concise description of what the problem is. 19 | placeholder: Ex. I'm always frustrated when [...] 20 | 21 | - type: textarea 22 | id: solution 23 | attributes: 24 | label: Describe the solution you'd like 25 | placeholder: A clear and concise description of what you want to happen. 26 | 27 | - type: textarea 28 | id: solution-alternatives 29 | attributes: 30 | label: Describe alternatives you've considered 31 | placeholder: A clear and concise description of any alternative solutions or features you've considered. 32 | 33 | - type: textarea 34 | id: current-behavior 35 | attributes: 36 | label: Describe alternatives you've considered 37 | render: markdown 38 | placeholder: A clear and concise description of the current behavior. It's best to provide an example, for that you could use our [stackblitz example](https://stackblitz.com/edit/transloco-example) 39 | validations: 40 | required: true 41 | 42 | - type: textarea 43 | id: expected-behavior 44 | attributes: 45 | label: Describe alternatives you've considered 46 | placeholder: A clear and concise description of the expected behavior. It's best to provide an example. 47 | validations: 48 | required: true 49 | 50 | - type: textarea 51 | id: other 52 | attributes: 53 | label: Additional context 54 | description: Links? References? Anything that will give us more context about the issue you are encountering! 55 | 56 | - type: dropdown 57 | id: contribution 58 | attributes: 59 | label: I would like to make a pull request for this feature 60 | options: 61 | - 'Yes 🚀' 62 | - 'No' 63 | validations: 64 | required: true 65 | -------------------------------------------------------------------------------- /src/keys-builder/utils/create-translation.ts: -------------------------------------------------------------------------------- 1 | import { flatten, unflatten } from 'flat'; 2 | import { po } from 'gettext-parser'; 3 | 4 | import { getConfig } from '../../config'; 5 | import { FileFormats, Translation } from '../../types'; 6 | import { mergeDeep, stringify } from '../../utils/object.utils'; 7 | import { removeExtraKeys } from './remove-extra-keys'; 8 | 9 | interface CreateTranslationOptions { 10 | currentTranslation: Translation; 11 | translation: Translation; 12 | replace: boolean; 13 | removeExtraKeys: boolean; 14 | fileFormat: FileFormats; 15 | } 16 | 17 | function resolveTranslation({ 18 | currentTranslation, 19 | translation, 20 | replace, 21 | removeExtraKeys: removeExtraKeysParam, 22 | }: CreateTranslationOptions): Translation { 23 | if (replace) { 24 | return mergeDeep({}, translation); 25 | } 26 | 27 | if (removeExtraKeysParam) { 28 | currentTranslation = removeExtraKeys(currentTranslation, translation); 29 | } 30 | 31 | return mergeDeep({}, translation, currentTranslation); 32 | } 33 | 34 | function createJson(config: CreateTranslationOptions) { 35 | const { translation } = config; 36 | 37 | return stringify( 38 | resolveTranslation({ 39 | ...config, 40 | translation: getConfig().unflat 41 | ? unflatten(translation, { object: true }) 42 | : translation, 43 | }), 44 | ); 45 | } 46 | 47 | function createPot(config: CreateTranslationOptions) { 48 | const resolved: Translation = getConfig().unflat 49 | ? flatten(resolveTranslation(config)) 50 | : resolveTranslation(config); 51 | 52 | return po 53 | .compile({ 54 | charset: 'utf-8', 55 | headers: { 56 | 'mime-version': '1.0', 57 | 'content-type': 'text/plain; charset=utf-8', 58 | 'content-transfer-encoding': '8bit', 59 | }, 60 | translations: { 61 | '': Object.entries(resolved).reduce( 62 | (acc, [msgid, msgstr]) => ({ 63 | ...acc, 64 | [msgid]: { msgid, msgstr }, 65 | }), 66 | {}, 67 | ), 68 | }, 69 | }) 70 | .toString('utf8'); 71 | } 72 | 73 | const compilers: Record< 74 | FileFormats, 75 | (config: CreateTranslationOptions) => string 76 | > = { 77 | json: createJson, 78 | pot: createPot, 79 | }; 80 | 81 | export function createTranslation(config: CreateTranslationOptions): string { 82 | return compilers[config.fileFormat](config); 83 | } 84 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug in the Transloco Keys Manager package 3 | title: "Bug:" 4 | 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Is there an existing issue for this? 9 | description: Please search to see if an issue already exists for the bug you encountered. 10 | options: 11 | - label: I have searched the existing issues 12 | required: true 13 | 14 | - type: dropdown 15 | id: is-regression 16 | attributes: 17 | label: Is this a regression? 18 | options: 19 | - 'Yes' 20 | - 'No' 21 | validations: 22 | required: true 23 | 24 | - type: textarea 25 | id: current-behavior 26 | attributes: 27 | label: Current behavior 28 | validations: 29 | required: true 30 | 31 | - type: textarea 32 | id: expected-behavior 33 | attributes: 34 | label: Expected behavior 35 | validations: 36 | required: true 37 | 38 | - type: input 39 | id: reproduction 40 | attributes: 41 | label: Please provide a link to a minimal reproduction of the bug 42 | validations: 43 | required: true 44 | 45 | - type: textarea 46 | id: transloco-config 47 | attributes: 48 | label: Transloco Config 49 | render: markdown 50 | placeholder: Provide the Transloco configuration if relevant to the issue 51 | 52 | - type: textarea 53 | id: debug-logs 54 | attributes: 55 | label: Debug Logs 56 | render: markdown 57 | placeholder: Run the keys manager in [debug mode](https://github.com/ngneat/transloco-keys-manager#-debugging) and add any relevant information 58 | 59 | - type: textarea 60 | id: environment 61 | attributes: 62 | label: Please provide the environment you discovered this bug in 63 | render: markdown 64 | value: | 65 | Transloco: 66 | Transloco Keys Manager: 67 | Angular: 68 | Node: 69 | Package Manager: 70 | OS: 71 | 72 | - type: textarea 73 | id: other 74 | attributes: 75 | label: Additional context 76 | description: Links? References? Anything that will give us more context about the issue you are encountering! 77 | 78 | - type: dropdown 79 | id: contribution 80 | attributes: 81 | label: I would like to make a pull request for this bug 82 | options: 83 | - 'Yes 🚀' 84 | - 'No' 85 | validations: 86 | required: true 87 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/pipe/src/6.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 5 | 7 | 8 |
10 | {{ '59' | transloco | another }} 11 | {{ '60' | another | transloco | other }} 12 | {{ '61' | another | other | transloco }} 13 |
14 | {{ var + 'not' | transloco }} 15 | 16 |
17 | 18 | 22 | 23 |
{{ '63.64.65' | another | other | transloco }}
24 | 27 | 28 | 30 | 31 | 36 | 37 | 38 | {{ '66' | transloco }} 39 | {{ condition ? '67' | transloco : 'dont take' }} 40 | 41 | {{ var | another : {foo: '68' | transloco} | other : ('69' | transloco) }} 42 | {{ var | another : ('70' | transloco) | lowercase }} 43 | 44 | 47 | 49 | {{ (('77' | transloco) + 'a').split(('78' | transloco) || ',') }} 50 | 51 | 52 | {{ '{{count}} items' | transloco:{ count: item.usageCount } }} 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/prefix/prefix-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { generateKeys, mockResolveProjectBasePath } from '../../../spec-utils'; 9 | import { Config } from '../../../../src/types'; 10 | import { describe, beforeEach, it } from 'vitest'; 11 | 12 | mockResolveProjectBasePath(sourceRoot); 13 | 14 | /** 15 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 16 | * This thing is still in WIP at Jest, so keep an eye on it. 17 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 18 | */ 19 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 20 | 21 | export function testPrefixExtraction(fileFormat: Config['fileFormat']) { 22 | describe('prefix', () => { 23 | const type: TranslationTestCase = 'template-extraction/prefix'; 24 | const config = buildConfig({ type, config: { fileFormat } }); 25 | 26 | beforeEach(() => removeI18nFolder(type)); 27 | 28 | it('should work with legacy read', () => { 29 | const expected = { 30 | global: { 31 | ...generateKeys({ end: 3 }), 32 | ...generateKeys({ 33 | end: 23, 34 | prefix: 'site-header.navigation.route', 35 | }), 36 | ...generateKeys({ end: 5, prefix: 'site-header.navigation' }), 37 | ...generateKeys({ end: 10, prefix: 'right-pane.actions' }), 38 | ...generateKeys({ end: 1, prefix: 'templates.translations' }), 39 | ...generateKeys({ end: 3, prefix: 'nested.translation' }), 40 | ...generateKeys({ 41 | end: 3, 42 | prefix: 'some.other.nested.that-is-tested', 43 | }), 44 | ...generateKeys({ end: 12, prefix: 'ternary.nested' }), 45 | ...generateKeys({ end: 2, prefix: 'nested' }), 46 | ...generateKeys({ 47 | end: 2, 48 | prefix: 'site-header.navigation.route.nested', 49 | }), 50 | }, 51 | todos: { 52 | ...generateKeys({ end: 2, prefix: 'numbers' }), 53 | }, 54 | }; 55 | 56 | buildTranslationFiles(config); 57 | assertTranslation({ type, expected: expected.global, fileFormat }); 58 | assertTranslation({ 59 | type, 60 | expected: expected.todos, 61 | path: 'todos-page/', 62 | fileFormat, 63 | }); 64 | }); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/config-options/unflat-problematic-keys/unflat-problomatic-keys-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { defaultValue, mockResolveProjectBasePath } from '../../../spec-utils'; 9 | import { Config } from '../../../../src/types'; 10 | import { messages } from '../../../../src/messages'; 11 | import { beforeEach, describe, it, vi, afterEach, expect } from 'vitest'; 12 | 13 | mockResolveProjectBasePath(sourceRoot); 14 | 15 | /** 16 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 17 | * This thing is still in WIP at Jest, so keep an eye on it. 18 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 19 | */ 20 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 21 | 22 | export function testUnflatProblomaticKeysConfig( 23 | fileFormat: Config['fileFormat'], 24 | ) { 25 | describe('Unflat problematic keys', () => { 26 | const type: TranslationTestCase = 'config-options/unflat-problematic-keys'; 27 | const config = buildConfig({ type, config: { unflat: true, fileFormat } }); 28 | const spy = vi.spyOn(messages, 'problematicKeysForUnflat'); 29 | 30 | beforeEach(() => { 31 | spy.mockReset(); 32 | removeI18nFolder(type); 33 | }); 34 | 35 | it('should work with unflat true and problematic keys', () => { 36 | const expected = { 37 | global: { 38 | a: defaultValue, 39 | b: defaultValue, 40 | c: defaultValue, 41 | d: { 42 | '1': defaultValue, 43 | '2': defaultValue, 44 | }, 45 | e: { 46 | a: defaultValue, 47 | aa: defaultValue, 48 | }, 49 | f: defaultValue, 50 | }, 51 | }; 52 | const expectedProblematicKeys = [ 53 | 'a', 54 | 'a.b', 55 | 'a.c', 56 | 'b', 57 | 'b.a', 58 | 'b.b', 59 | 'f', 60 | 'f.a', 61 | 'f.a.a', 62 | 'f.a.b', 63 | 'f.b', 64 | 'f.b.a.a', 65 | ]; 66 | 67 | buildTranslationFiles(config); 68 | 69 | expect(spy).toHaveBeenCalledTimes(1); 70 | expect(spy).toHaveBeenCalledWith(expectedProblematicKeys); 71 | assertTranslation({ type, expected: expected.global, fileFormat }); 72 | }); 73 | 74 | afterEach(() => { 75 | spy.mockReset(); 76 | }); 77 | }); 78 | } 79 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/ng-container/ng-container-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../../build-translation-utils'; 8 | import { 9 | defaultValue, 10 | generateKeys, 11 | mockResolveProjectBasePath, 12 | paramsTestConfig, 13 | resolveValueWithParams, 14 | } from '../../../spec-utils'; 15 | import { Config } from '../../../../src/types'; 16 | import { describe, beforeEach, it } from 'vitest'; 17 | 18 | mockResolveProjectBasePath(sourceRoot); 19 | 20 | /** 21 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 22 | * This thing is still in WIP at Jest, so keep an eye on it. 23 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 24 | */ 25 | const { buildTranslationFiles } = await import('../../../../src/keys-builder'); 26 | 27 | export function testNgContainerExtraction(fileFormat: Config['fileFormat']) { 28 | describe('ng-container', () => { 29 | const type: TranslationTestCase = 'template-extraction/ng-container'; 30 | const config = buildConfig({ type, config: { fileFormat } }); 31 | 32 | beforeEach(() => removeI18nFolder(type)); 33 | 34 | it('should work with ngContainer', () => { 35 | let expected = generateKeys({ end: 46 }); 36 | // See https://github.com/ngneat/transloco-keys-manager/issues/87 37 | expected["Bob's Burgers"] = 38 | expected['another(test)'] = 39 | expected['last "one"'] = 40 | defaultValue; 41 | buildTranslationFiles(config); 42 | assertTranslation({ type, expected, fileFormat }); 43 | }); 44 | 45 | it('should work with scopes', () => { 46 | let expected = { 47 | '1': defaultValue, 48 | '2.1': defaultValue, 49 | '3.1': defaultValue, 50 | '4': defaultValue, 51 | '5': defaultValue, 52 | }; 53 | 54 | buildTranslationFiles(config); 55 | assertTranslation({ 56 | type, 57 | expected, 58 | path: 'admin-page/', 59 | fileFormat, 60 | }); 61 | }); 62 | 63 | it('should extract params', () => { 64 | const expected = { 65 | ...generateKeys({ 66 | end: 7, 67 | withParams: true, 68 | }), 69 | ...generateKeys({ start: 8, end: 11 }), 70 | nested: resolveValueWithParams(['a', 'b.c.d']), 71 | 'skip.params': defaultValue, 72 | }; 73 | buildTranslationFiles(paramsTestConfig(config)); 74 | assertTranslation({ type, expected, fileFormat }); 75 | }); 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /src/utils/path.utils.ts: -------------------------------------------------------------------------------- 1 | import path, { sep } from 'node:path'; 2 | 3 | import { getConfig } from '../config'; 4 | import { Config, Scopes } from '../types'; 5 | 6 | import { isObject } from './validators.utils'; 7 | 8 | export function pathUnixFormat(path: string) { 9 | return path.split(sep).join('/'); 10 | } 11 | 12 | export function buildPath(obj: Record) { 13 | return Object.keys(obj).reduce((acc, curr) => { 14 | const keys = isObject(obj[curr]) 15 | ? buildPath(obj[curr]).map((inner) => `${curr}.${inner}`) 16 | : [curr]; 17 | acc.push(...keys); 18 | 19 | return acc; 20 | }, [] as string[]); 21 | } 22 | 23 | interface Options extends Pick { 24 | filePath: string; 25 | } 26 | 27 | /** 28 | * /Users/username/www/folderName/src/assets/i18n/admin/es.json => { scope: admin, lang: es } 29 | * /Users/username/www/folderName/src/assets/i18n/es.json => { scope: undefined, lang: es } 30 | */ 31 | export function getScopeAndLangFromPath({ 32 | filePath, 33 | translationsPath, 34 | fileFormat, 35 | }: Options) { 36 | filePath = pathUnixFormat(filePath); 37 | translationsPath = pathUnixFormat(translationsPath); 38 | 39 | if (!translationsPath.endsWith('/')) { 40 | translationsPath = `${translationsPath}/`; 41 | } 42 | 43 | const [_, pathWithScope] = filePath.split(translationsPath); 44 | const scopePath = pathWithScope.split('/'); 45 | const removeExtension = (str: string) => str.replace(`.${fileFormat}`, ''); 46 | 47 | let scope, lang; 48 | if (scopePath.length > 1) { 49 | lang = removeExtension(scopePath.pop()!); 50 | scope = scopePath.join('/'); 51 | } else { 52 | lang = removeExtension(scopePath[0]); 53 | } 54 | 55 | return { scope, lang }; 56 | } 57 | 58 | function resolvePath(configPath = '') { 59 | return path.resolve(process.cwd(), configPath); 60 | } 61 | 62 | export function resolveConfigPaths(config: Config) { 63 | config.input = config.input.map(resolvePath); 64 | config.output = resolvePath(config.output); 65 | config.translationsPath = resolvePath(config.translationsPath); 66 | } 67 | 68 | type ScopeFiles = { path: string; scope: string }[]; 69 | 70 | export function buildScopeFilePaths({ 71 | aliasToScope, 72 | output, 73 | langs, 74 | fileFormat, 75 | }: Pick & { 76 | aliasToScope: Scopes['aliasToScope']; 77 | }) { 78 | const { scopePathMap = {} } = getConfig(); 79 | return Object.values(aliasToScope).reduce( 80 | (files: ScopeFiles, scope: string) => { 81 | langs.forEach((lang) => { 82 | let bastPath = scopePathMap[scope] 83 | ? scopePathMap[scope] 84 | : `${output}/${scope}`; 85 | 86 | files.push({ 87 | path: `${bastPath}/${lang}.${fileFormat}`, 88 | scope, 89 | }); 90 | }); 91 | 92 | return files; 93 | }, 94 | [], 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/keys-builder/typescript/build-keys-from-ast-nodes.ts: -------------------------------------------------------------------------------- 1 | import { Node, StringLiteral, NoSubstitutionTemplateLiteral } from 'typescript'; 2 | import ts from 'typescript'; 3 | 4 | import { TSExtractorResult } from './types'; 5 | import { flatten } from 'flat'; 6 | 7 | export function buildKeysFromASTNodes( 8 | nodes: Node[], 9 | allowedMethods = ['translate', 'selectTranslate'], 10 | ): TSExtractorResult { 11 | const result: TSExtractorResult = []; 12 | 13 | for (let node of nodes) { 14 | if (!ts.isCallExpression(node.parent)) continue; 15 | 16 | const method = node.parent.expression; 17 | let methodName = ''; 18 | if (ts.isIdentifier(method)) { 19 | methodName = method.text; 20 | } else if (ts.isPropertyAccessExpression(method)) { 21 | methodName = method.name.text; 22 | } 23 | if (!allowedMethods.includes(methodName)) { 24 | continue; 25 | } 26 | 27 | const [keyNode, paramsNode, langNode] = node.parent.arguments; 28 | let lang = isStringNode(langNode) ? langNode.text : ''; 29 | let keys: string[] = []; 30 | const params: string[] = 31 | paramsNode && ts.isObjectLiteralExpression(paramsNode) 32 | ? resolveParams(paramsNode) 33 | : []; 34 | 35 | if (isStringNode(keyNode)) { 36 | keys = [keyNode.text]; 37 | } else if (ts.isArrayLiteralExpression(keyNode)) { 38 | keys = keyNode.elements.filter(isStringNode).map((node) => node.text); 39 | } 40 | 41 | for (const key of keys) { 42 | result.push({ key, lang, params }); 43 | } 44 | } 45 | 46 | return result; 47 | } 48 | 49 | function isStringNode( 50 | node: Node, 51 | ): node is StringLiteral | NoSubstitutionTemplateLiteral { 52 | return ( 53 | node && 54 | (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) 55 | ); 56 | } 57 | 58 | function resolveParams(params: ts.ObjectLiteralExpression): string[] { 59 | return Object.keys(flatten(traverseParams(params))); 60 | } 61 | 62 | function traverseParams( 63 | params: ts.ObjectLiteralExpression, 64 | ): Record { 65 | const properties: Record = {}; 66 | 67 | // Recursive function to handle nested properties 68 | function processProperty(property: ts.PropertyAssignment) { 69 | const key = property.name.getText().replace(/['"]/g, ''); 70 | const initializer = property.initializer; 71 | 72 | if (!initializer) return; 73 | 74 | if (ts.isObjectLiteralExpression(initializer)) { 75 | // Handle nested object 76 | properties[key] = traverseParams(initializer); 77 | } else { 78 | // Simple value (string, number, etc.) 79 | properties[key] = initializer.getText(); 80 | } 81 | } 82 | 83 | // Iterate through the properties of the ObjectLiteralExpression 84 | for (const property of params.properties) { 85 | if (ts.isPropertyAssignment(property)) { 86 | processProperty(property); 87 | } 88 | } 89 | 90 | // Convert the properties object to a JSON string 91 | return properties; 92 | } 93 | -------------------------------------------------------------------------------- /src/keys-builder/typescript/index.ts: -------------------------------------------------------------------------------- 1 | import { tsquery, ScriptKind } from '@phenomnomnominal/tsquery'; 2 | 3 | import { 4 | Config, 5 | ExtractionResult, 6 | ExtractorConfig, 7 | ScopeMap, 8 | Scopes, 9 | } from '../../types'; 10 | import { readFile } from '../../utils/file.utils'; 11 | import { regexFactoryMap } from '../../utils/regexs.utils'; 12 | import { addCommentSectionKeys } from '../add-comment-section-keys'; 13 | import { addKey } from '../add-key'; 14 | import { extractKeys } from '../utils/extract-keys'; 15 | import { resolveScopeAlias } from '../utils/resolvers.utils'; 16 | 17 | import { inlineTemplateExtractor } from './inline-template'; 18 | import { markerExtractor } from './marker.extractor'; 19 | import { pureFunctionExtractor } from './pure-function.extractor'; 20 | import { serviceExtractor } from './service.extractor'; 21 | import { signalExtractor } from './signal.extractor'; 22 | 23 | export function extractTSKeys(config: Config): ExtractionResult { 24 | return extractKeys(config, 'ts', TSExtractor); 25 | } 26 | 27 | const translocoImport = /@(jsverse|ngneat)\/transloco/; 28 | const translocoKeysManagerImport = /@(jsverse|ngneat)\/transloco-keys-manager/; 29 | function TSExtractor(config: ExtractorConfig): ScopeMap { 30 | const { file, scopes, defaultValue, scopeToKeys } = config; 31 | const content = readFile(file); 32 | const extractors = []; 33 | 34 | if (translocoImport.test(content)) { 35 | extractors.push(serviceExtractor, pureFunctionExtractor, signalExtractor); 36 | } 37 | 38 | if (translocoKeysManagerImport.test(content)) { 39 | extractors.push(markerExtractor); 40 | } 41 | 42 | const ast = tsquery.ast(content, undefined, ScriptKind.TS); 43 | const baseParams = { 44 | scopeToKeys, 45 | scopes, 46 | defaultValue, 47 | }; 48 | 49 | extractors 50 | .map((ex) => ex(ast)) 51 | .flat() 52 | .forEach(({ key, lang, params }) => { 53 | const [keyWithoutScope, scopeAlias] = resolveAliasAndKeyFromService( 54 | key, 55 | lang, 56 | scopes, 57 | ); 58 | addKey({ 59 | scopeAlias, 60 | keyWithoutScope, 61 | params, 62 | ...baseParams, 63 | }); 64 | }); 65 | 66 | /** Check for dynamic markings */ 67 | addCommentSectionKeys({ 68 | content, 69 | regexFactory: regexFactoryMap.ts.comments, 70 | ...baseParams, 71 | }); 72 | 73 | inlineTemplateExtractor(ast, config); 74 | 75 | return scopeToKeys; 76 | } 77 | 78 | /** 79 | * 80 | * It can be one of the following: 81 | * 82 | * translate('2', {}, 'some/nested'); 83 | * translate('3', {}, 'some/nested/en'); 84 | * translate('globalKey'); 85 | * 86 | */ 87 | function resolveAliasAndKeyFromService( 88 | key: string, 89 | scopePath: string, 90 | scopes: Scopes, 91 | ): [string, string | null] { 92 | // It means that it's the global 93 | if (!scopePath) { 94 | return [key, null]; 95 | } 96 | 97 | const scopeAlias = resolveScopeAlias({ scopePath, scopes }); 98 | 99 | return [key, scopeAlias]; 100 | } 101 | -------------------------------------------------------------------------------- /src/cli-options.ts: -------------------------------------------------------------------------------- 1 | export const optionDefinitions = [ 2 | { 3 | name: 'project', 4 | type: String, 5 | description: 'Name of the targeted project', 6 | }, 7 | { 8 | name: 'config', 9 | alias: 'c', 10 | type: String, 11 | description: 'Path to a custom transloco config', 12 | }, 13 | { 14 | name: 'input', 15 | alias: 'i', 16 | type: String, 17 | description: 18 | 'The source directory for all files using the translation keys', 19 | }, 20 | { 21 | name: 'output', 22 | alias: 'o', 23 | type: String, 24 | description: 'The target directory for all generated translation files', 25 | }, 26 | { 27 | name: 'langs', 28 | alias: 'l', 29 | type: String, 30 | multiple: true, 31 | description: 'The languages files to generate', 32 | }, 33 | { 34 | name: 'file-format', 35 | alias: 'f', 36 | type: String, 37 | description: 38 | 'The translation file format (`json`, `pot`) default is `json`', 39 | }, 40 | { 41 | name: 'marker', 42 | alias: 'm', 43 | type: String, 44 | description: `The marker sign for dynamic values`, 45 | }, 46 | { 47 | name: 'replace', 48 | alias: 'r', 49 | type: Boolean, 50 | description: 51 | 'Replace the contents of a translation file (if it exists) with the generated one (default value is false, in which case files are merged)', 52 | }, 53 | { 54 | name: 'remove-extra-keys', 55 | alias: 'R', 56 | type: Boolean, 57 | description: 'Remove extra keys from existing translation files', 58 | }, 59 | { 60 | name: 'sort', 61 | alias: 's', 62 | type: Boolean, 63 | description: `Sort keys using the sort() method`, 64 | }, 65 | { 66 | name: 'unflat', 67 | alias: 'u', 68 | type: Boolean, 69 | description: `Unflat the translation file`, 70 | }, 71 | { 72 | name: 'default-value', 73 | alias: 'd', 74 | type: String, 75 | description: `The default value of a generated key`, 76 | }, 77 | { 78 | name: 'add-missing-keys', 79 | alias: 'a', 80 | type: Boolean, 81 | description: 82 | 'Add missing keys that were found by the detective (default value is false)', 83 | }, 84 | { 85 | name: 'emit-error-on-extra-keys', 86 | alias: 'e', 87 | type: Boolean, 88 | description: 89 | 'Emit an error and exit the process if extra keys were found (defaults to `false`)', 90 | }, 91 | { 92 | name: 'translations-path', 93 | alias: 'p', 94 | type: String, 95 | description: 'Where are the main translation files', 96 | }, 97 | { name: 'help', alias: 'h', type: Boolean, description: 'Help me, please!' }, 98 | ]; 99 | 100 | export const sections = [ 101 | { 102 | header: '🔥 Transloco Keys Manager', 103 | content: 'Extract and find missing keys', 104 | }, 105 | { 106 | header: 'Actions', 107 | content: [ 108 | '$ transloco-keys-manager extract', 109 | '$ transloco-keys-manager find', 110 | ], 111 | }, 112 | { 113 | header: 'Options', 114 | optionList: optionDefinitions, 115 | }, 116 | ]; 117 | -------------------------------------------------------------------------------- /src/utils/resolve-config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getGlobalConfig, 3 | TranslocoGlobalConfig, 4 | } from '@jsverse/transloco-utils'; 5 | import chalk from 'chalk'; 6 | import { existsSync } from 'fs'; 7 | 8 | import { defaultConfig } from '../config'; 9 | import { getScopes } from '../keys-builder/utils/scope.utils'; 10 | import { messages } from '../messages'; 11 | import { Config } from '../types'; 12 | 13 | import { devlog } from './logger'; 14 | import { resolveConfigPaths } from './path.utils'; 15 | import { resolveProjectBasePath } from './resolve-project-base-path'; 16 | import { updateScopesMap } from './update-scopes-map'; 17 | import { isDirectory } from './validators.utils'; 18 | 19 | export function resolveConfig(inlineConfig: Partial): Config { 20 | const { projectBasePath: sourceRoot, projectType } = resolveProjectBasePath( 21 | inlineConfig.project, 22 | ); 23 | const defaults = defaultConfig({ projectType, sourceRoot }); 24 | const fileConfig = getGlobalConfig(inlineConfig.config || sourceRoot); 25 | const userConfig = { ...flatFileConfig(fileConfig), ...inlineConfig }; 26 | const mergedConfig = { ...defaults, ...userConfig } as Config; 27 | 28 | devlog('config', 'Config', { 29 | Default: defaults, 30 | 'Transloco file': flatFileConfig(fileConfig), 31 | Inline: inlineConfig, 32 | Merged: mergedConfig, 33 | }); 34 | 35 | resolveConfigPaths(mergedConfig); 36 | 37 | devlog('paths', 'Configuration Paths', { 38 | Input: mergedConfig.input, 39 | Output: mergedConfig.output, 40 | Translations: mergedConfig.translationsPath, 41 | }); 42 | 43 | validateDirectories(mergedConfig); 44 | 45 | updateScopesMap({ input: mergedConfig.input }); 46 | 47 | devlog('scopes', 'Scopes', { 48 | 'Scopes map': getScopes().scopeToAlias, 49 | }); 50 | 51 | return { ...mergedConfig, scopes: getScopes() }; 52 | } 53 | 54 | function flatFileConfig({ 55 | keysManager, 56 | rootTranslationsPath, 57 | langs, 58 | scopePathMap, 59 | }: TranslocoGlobalConfig): Partial { 60 | if (keysManager?.input) { 61 | keysManager.input = Array.isArray(keysManager.input) 62 | ? keysManager.input 63 | : keysManager.input.split(','); 64 | } 65 | 66 | return { 67 | ...(rootTranslationsPath ? { translationsPath: rootTranslationsPath } : {}), 68 | ...(langs ? { langs } : {}), 69 | ...(scopePathMap ? { scopePathMap } : {}), 70 | ...(keysManager as Omit & 71 | Pick), 72 | }; 73 | } 74 | 75 | function validateDirectories({ input, translationsPath, command }: Config) { 76 | let invalidPath = false; 77 | const log = (path: string, prop: string) => { 78 | const msg = existsSync(path) 79 | ? messages.pathIsNotDir 80 | : messages.pathDoesntExist; 81 | console.log(chalk.bgRed.black(`${prop} ${msg}`)); 82 | }; 83 | 84 | for (const path of input) { 85 | if (!isDirectory(path)) { 86 | invalidPath = true; 87 | log(path, 'Input'); 88 | } 89 | } 90 | 91 | if (command === 'find' && !isDirectory(translationsPath)) { 92 | invalidPath = true; 93 | log(translationsPath, 'Translations'); 94 | } 95 | 96 | invalidPath && process.exit(); 97 | } 98 | -------------------------------------------------------------------------------- /BREAKING_CHANGES.md: -------------------------------------------------------------------------------- 1 | # Transloco Keys Manager V7 2 | 3 | Support Angular 20 4 | 5 | # Transloco Keys Manager V6 6 | 7 | All the debug namespaces are now prefixed with `tkm:` to avoid conflicts with other libraries. 8 | 9 | # Transloco Keys Manager V5 10 | 11 | The source root is now only prefixed to the **default** config, which means you need to write the full path relative to 12 | the project root. 13 | If I had the following config: 14 | 15 | ```ts 16 | import {TranslocoGlobalConfig} from "@jsverse/transloco-utils"; 17 | 18 | const config: TranslocoGlobalConfig = { 19 | rootTranslationsPath: 'assets/i18n/', 20 | langs: ['it', 'en'], 21 | keysManager: { 22 | input: ['app'], 23 | output: 'assets/i18n/' 24 | }, 25 | } 26 | 27 | export default config; 28 | ``` 29 | 30 | When migrating to v5 I'll need to prefix the paths with the source root: 31 | ```ts 32 | import {TranslocoGlobalConfig} from "@jsverse/transloco-utils"; 33 | 34 | const config: TranslocoGlobalConfig = { 35 | // 👇 36 | rootTranslationsPath: 'src/assets/i18n/', 37 | langs: ['it', 'en'], 38 | keysManager: { 39 | input: [ 40 | // 👇 41 | 'src/app', 42 | // 🥳 Scanning non buildable libs is now supported 43 | 'projects/ui-lib/src/lib' 44 | ], 45 | // 👇 46 | output: 'src/assets/i18n/' 47 | }, 48 | } 49 | 50 | export default config; 51 | ``` 52 | 53 | This change is necessary to [allow scanning arbitrary folders](https://github.com/jsverse/transloco-keys-manager/issues/160) and will open support for a more dynamic features. 54 | 55 | # Transloco Keys Manager V4 56 | 57 | The library is now ESM only in order to use the newer versions of the angular compiler. 58 | The publishing scope has changes from `@ngneat/transloco-keys-manager` to `@jsverse/transloco-keys-manager`, 59 | this means you'll need to update the import paths of the marker functions in case you are using it. 60 | 61 | # Transloco Keys Manager V2 62 | 63 | #### Paths resolution 64 | 65 | All the paths configuration (`input`, `output`, and `translationsPath`) will now be prefixed with the `sourceRoot` value. 66 | The `sourceRoot` value is determined by the following logic: 67 | 68 | 1. `angular.json` file is missing: 69 | - Will default to `src`. 70 | 2. `angular.json` file is present: 71 | - Will default to the `defaultProject`'s `sourceRoot` value. 72 | - If `--project` option is provided, will extract the project's `sourceRoot` value. 73 | 74 | #### Dynamic Template Keys 75 | 76 | Comments in the templates will now **inherit the `read` [input](https://jsverse.github.io/transloco/docs/structural-directive/#utilizing-the-read-input) value** (if exists), and will be prefixed with it: 77 | ```html 78 | 79 | 80 | ... 81 | 82 | 83 | ... 84 | 85 | 86 | 87 | ``` 88 | 89 | The extracted keys for the code above will be: 90 | ```json 91 | { 92 | "this.is.cool": "", 93 | "messages.success": "", 94 | "messages.error": "", 95 | "general.ok": "", 96 | "general.cancel": "" 97 | } 98 | ``` 99 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at netanel7799@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /src/keys-builder/template/pipe.extractor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AST, 3 | BindingPipe, 4 | LiteralPrimitive, 5 | ParenthesizedExpression, 6 | tmplAstVisitAll, 7 | } from '@angular/compiler'; 8 | 9 | import { ExtractorConfig, OrArray } from '../../types'; 10 | import { addKey } from '../add-key'; 11 | import { resolveAliasAndKey } from '../utils/resolvers.utils'; 12 | 13 | import { TemplateExtractorConfig } from './types'; 14 | import { parseTemplate, resolveKeysFromLiteralMap } from './utils'; 15 | import { notNil } from '../../utils/validators.utils'; 16 | import { coerceArray } from '../../utils/collection.utils'; 17 | 18 | import { 19 | AstPipeCollector, 20 | isBindingPipe, 21 | isConditionalExpression, 22 | isLiteralExpression, 23 | isLiteralMap, 24 | TmplPipeCollector, 25 | } from '@jsverse/angular-utils'; 26 | 27 | export function pipeExtractor(config: TemplateExtractorConfig) { 28 | const parsedTemplate = parseTemplate(config); 29 | const tmplVisitor = new TmplPipeCollector('transloco'); 30 | tmplAstVisitAll(tmplVisitor, parsedTemplate.nodes); 31 | const astVisitor = new AstPipeCollector(); 32 | astVisitor.visitAll([...tmplVisitor.astTrees], {}); 33 | const keysWithParams = astVisitor.pipes 34 | .get('transloco') 35 | ?.map((p) => resolveKeyAndParam(p.node)) 36 | .flat() 37 | .filter(notNil); 38 | if (keysWithParams) { 39 | addKeysFromAst(keysWithParams, config); 40 | } 41 | } 42 | 43 | interface KeyWithParam { 44 | keyNode: LiteralPrimitive; 45 | paramsNode: AST; 46 | } 47 | 48 | function resolveKeyNode(ast: OrArray): LiteralPrimitive[] { 49 | return coerceArray(ast) 50 | .flatMap((expression) => { 51 | if (isLiteralExpression(expression)) { 52 | return expression; 53 | } else if (isConditionalExpression(expression)) { 54 | return resolveKeyNode([expression.trueExp, expression.falseExp]); 55 | } else if (expression instanceof ParenthesizedExpression) { 56 | return resolveKeyNode(expression.expression); 57 | } 58 | return undefined; 59 | }) 60 | .filter(notNil); 61 | } 62 | 63 | function resolveKeyAndParam( 64 | pipe: BindingPipe, 65 | paramsNode?: AST, 66 | ): KeyWithParam | KeyWithParam[] | null { 67 | const resolvedParams: AST = paramsNode ?? pipe.args[0]; 68 | if (isBindingPipe(pipe.exp)) { 69 | let nestedPipe = pipe; 70 | while (isBindingPipe(nestedPipe.exp)) { 71 | nestedPipe = nestedPipe.exp; 72 | } 73 | 74 | return resolveKeyAndParam(nestedPipe, resolvedParams); 75 | } else { 76 | const keyNodes = resolveKeyNode(pipe.exp); 77 | if (keyNodes.length >= 1) { 78 | return keyNodes.map((keyNode) => ({ 79 | keyNode, 80 | paramsNode: resolvedParams, 81 | })); 82 | } 83 | } 84 | 85 | return null; 86 | } 87 | 88 | function addKeysFromAst(keys: KeyWithParam[], config: ExtractorConfig): void { 89 | for (const { keyNode, paramsNode } of keys) { 90 | const [key, scopeAlias] = resolveAliasAndKey(keyNode.value, config.scopes); 91 | const params = isLiteralMap(paramsNode) 92 | ? resolveKeysFromLiteralMap(paramsNode) 93 | : []; 94 | addKey({ 95 | ...config, 96 | keyWithoutScope: key, 97 | scopeAlias, 98 | params, 99 | }); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/comments/src/2.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * some commment 3 | */ 4 | // @jsverse/transloco 5 | import { 6 | ChangeDetectionStrategy, 7 | ChangeDetectorRef, 8 | Component, 9 | ElementRef, 10 | EventEmitter, 11 | forwardRef, 12 | HostBinding, 13 | Input, 14 | Output, 15 | ViewEncapsulation, 16 | } from '@angular/core'; 17 | import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; 18 | 19 | export type ExtendedGridOptions = { 20 | onRowDataUpdated: (event) => void; 21 | }; 22 | 23 | const valueAccessor = { 24 | provide: NG_VALUE_ACCESSOR, 25 | useExisting: forwardRef(() => GridComponent), 26 | multi: true, 27 | }; 28 | 29 | @Component({}) 30 | export class GridComponent 31 | extends BaseCustomControl 32 | implements ControlValueAccessor 33 | { 34 | @Output() rowDataChanged = new EventEmitter(); 35 | 36 | private hasInfinitePagination = false; 37 | 38 | /** t(200) */ 39 | api: DatoGridAPI; 40 | gridColumnApi: ColumnApi; 41 | gridOptions; 42 | 43 | /** 44 | * t(admin.1, admin.2.3, admin.4, admin.5555) 45 | */ 46 | @Input() enableSorting = true; 47 | @Input() enableFilter = true; 48 | @Input() enableColResize = true; 49 | 50 | @Input() 51 | set options(options) { 52 | options.columnDefs = translateColumns(options.columnDefs); 53 | this.disableColumnMenu(options.columnDefs); 54 | } 55 | 56 | /** 57 | * t(201, 202, 203.204) 58 | * 59 | * 60 | * t(205) 61 | */ 62 | get options() { 63 | return this.gridOptions; 64 | } 65 | 66 | constructor( 67 | private element: ElementRef, 68 | private cdr: ChangeDetectorRef, 69 | ) { 70 | super(); 71 | 72 | this.gridOptions = { ...this.defaultGridOptions }; 73 | } 74 | 75 | /** 76 | * On grid ready 77 | * @param {GridReadyEvent} event 78 | * 79 | * t( 80 | * 206, 81 | * 207.208 82 | * ) 83 | */ 84 | onGridReady(event: GridReadyEvent) {} 85 | 86 | /** 87 | * call ag-grid's size all columns to fit to container 88 | * t(209) 89 | */ 90 | fitToContainer(): void { 91 | this.gridOptions.api.sizeColumnsToFit(); 92 | } 93 | 94 | /** 95 | * call ag-grid's size all columns to fit to content 96 | */ 97 | fitToContent(): void { 98 | this.gridOptions.columnApi.autoSizeAllColumns(); 99 | if (width > bodyWidth) { 100 | this.fitToContainer(); 101 | } 102 | } 103 | 104 | /** 105 | * Updaet the columns definitions 106 | * @param {(ColDef | ColGroupDef)[]} columnDefs 107 | */ 108 | updateColumns(columnDefs: (ColDef | ColGroupDef)[]) { 109 | this.gridHelper.updateColumns(this.api.gridApi, columnDefs); 110 | } 111 | 112 | writeValue(obj: any): void { 113 | const rows = obj ? coerceArray(obj) : []; 114 | 115 | if (this.api.ready) { 116 | this.api.setSelectedRows(rows); 117 | } else { 118 | /** 119 | * t(210, 211) t(212, 213.214) 120 | */ 121 | } 122 | } 123 | 124 | /** 125 | * disable the column menu and leave only the filter 126 | */ 127 | private disableColumnMenu(columnDefs: ColDef[]): void { 128 | columnDefs.forEach((def: ColDef) => { 129 | def.menuTabs = ['filterMenuTab']; 130 | }); 131 | } 132 | 133 | /** 134 | * 135 | * t( 136 | * 215, 137 | * 216, 138 | * 217.218 139 | * ) 140 | * 141 | * 142 | */ 143 | } 144 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/buildTranslationFiles.spec.ts: -------------------------------------------------------------------------------- 1 | import { resetScopes } from '../../src/keys-builder/utils/scope.utils'; 2 | import { FileFormats } from '../../src/types'; 3 | import { spyOnConsole, spyOnProcess } from '../spec-utils'; 4 | import { testPipeExtraction } from './template-extraction/pipe/pipe-spec'; 5 | import { testDirectiveExtraction } from './template-extraction/directive/directive-spec'; 6 | import { testNgContainerExtraction } from './template-extraction/ng-container/ng-container-spec'; 7 | import { testNgTemplateExtraction } from './template-extraction/ng-template/ng-template-spec'; 8 | import { testControlFlowExtraction } from './template-extraction/control-flow/control-flow-spec'; 9 | import { testPrefixExtraction } from './template-extraction/prefix/prefix-spec'; 10 | import { testScopeExtraction } from './template-extraction/scope/scope-spec'; 11 | import { testServiceExtraction } from './ts-extraction/service/service-spec'; 12 | import { testPureFunctionExtraction } from './ts-extraction/pure-function/pure-function-spec'; 13 | import { testMarkerExtraction } from './ts-extraction/marker/marker-spec'; 14 | import { testSignalExtraction } from './ts-extraction/signal/signal-spec'; 15 | import { testInlineTemplateExtraction } from './ts-extraction/inline-template/inline-template-spec'; 16 | import { testCommentsExtraction } from './comments/comments-spec'; 17 | import { testUnflatSortExtraction } from './config-options/unflat-sort/unflat-sort-spec'; 18 | import { testUnflatProblomaticKeysConfig } from './config-options/unflat-problematic-keys/unflat-problomatic-keys-spec'; 19 | import { testUnflatExtraction } from './config-options/unflat/unflat-spec'; 20 | import { testScopeMappingConfig } from './config-options/scope-mapping/scope-mapping-spec'; 21 | import { testRemoveExtraKeysConfig } from './config-options/remove-extra-keys/remove-extra-keys-spec'; 22 | import { testMultiInputsConfig } from './config-options/multi-input/multi-input-spec'; 23 | import { describe, beforeAll, afterEach } from 'vitest'; 24 | 25 | const formats: FileFormats[] = ['pot', 'json']; 26 | 27 | describe.each(formats)('buildTranslationFiles in %s', (fileFormat) => { 28 | beforeAll(() => { 29 | spyOnConsole('warn'); 30 | spyOnProcess('exit'); 31 | }); 32 | 33 | // Reset to ensure the scopes are not being shared among the tests. 34 | afterEach(() => resetScopes()); 35 | 36 | describe('Template Extraction', () => { 37 | testPipeExtraction(fileFormat); 38 | 39 | testDirectiveExtraction(fileFormat); 40 | 41 | testNgContainerExtraction(fileFormat); 42 | 43 | testNgTemplateExtraction(fileFormat); 44 | 45 | testControlFlowExtraction(fileFormat); 46 | 47 | testPrefixExtraction(fileFormat); 48 | 49 | testScopeExtraction(fileFormat); 50 | }); 51 | 52 | describe('Typescript Extraction', () => { 53 | testServiceExtraction(fileFormat); 54 | 55 | testPureFunctionExtraction(fileFormat); 56 | 57 | testMarkerExtraction(fileFormat); 58 | 59 | testSignalExtraction(fileFormat); 60 | 61 | testInlineTemplateExtraction(fileFormat); 62 | }); 63 | 64 | describe('Config options', () => { 65 | testUnflatExtraction(fileFormat); 66 | 67 | testUnflatSortExtraction(fileFormat); 68 | 69 | testUnflatProblomaticKeysConfig(fileFormat); 70 | 71 | testScopeMappingConfig(fileFormat); 72 | 73 | testMultiInputsConfig(fileFormat); 74 | 75 | testRemoveExtraKeysConfig(fileFormat); 76 | }); 77 | 78 | testCommentsExtraction(fileFormat); 79 | }); 80 | -------------------------------------------------------------------------------- /src/utils/resolve-project-base-path.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { cosmiconfigSync } from 'cosmiconfig'; 3 | import path from 'path'; 4 | 5 | import { ProjectType } from '../config'; 6 | 7 | import { coerceArray } from './collection.utils'; 8 | import { jsoncParser } from './json.utils'; 9 | import { isString } from './validators.utils'; 10 | import { normalizedGlob } from './normalize-glob-path'; 11 | 12 | const angularConfigFile = ['angular.json', '.angular.json']; 13 | const workspaceConfigFile = 'workspace.json'; 14 | const projectConfigFile = 'project.json'; 15 | const defaultSourceRoot = 'src'; 16 | 17 | function searchConfig(searchPlaces: string[] | string, searchFrom = '') { 18 | const cwd = process.cwd(); 19 | const resolvePath = path.resolve(cwd, searchFrom); 20 | const stopDir = path.resolve(cwd, '../'); 21 | 22 | return cosmiconfigSync('', { 23 | stopDir, 24 | loaders: { 25 | '.json': jsoncParser, 26 | }, 27 | searchPlaces: coerceArray(searchPlaces), 28 | }).search(resolvePath)?.config; 29 | } 30 | 31 | function logNotFound(searchPlaces: string[]) { 32 | console.log( 33 | chalk.black.bgRed( 34 | `Unable to load workspace config from ${searchPlaces.join( 35 | ', ', 36 | )}. Defaulting source root to '${defaultSourceRoot}'`, 37 | ), 38 | ); 39 | } 40 | 41 | export function resolveProjectBasePath(projectName?: string): { 42 | projectBasePath: string; 43 | projectType?: ProjectType; 44 | } { 45 | let projectPath = ''; 46 | 47 | if (projectName) { 48 | projectPath = normalizedGlob(`**/${projectName}`)[0]; 49 | } 50 | 51 | const angularConfig = searchConfig(angularConfigFile, projectPath); 52 | const workspaceConfig = searchConfig(workspaceConfigFile, projectPath); 53 | const projectConfig = searchConfig(projectConfigFile, projectPath); 54 | 55 | if (!angularConfig && !workspaceConfig && !projectConfig) { 56 | logNotFound([...angularConfigFile, workspaceConfigFile, projectConfigFile]); 57 | 58 | return { projectBasePath: defaultSourceRoot }; 59 | } 60 | 61 | let resolved: ReturnType | null = null; 62 | 63 | for (const config of [angularConfig, workspaceConfig, projectConfig]) { 64 | resolved = resolveProject(config, projectName); 65 | if (resolved) { 66 | break; 67 | } 68 | } 69 | 70 | if (!resolved) { 71 | console.log( 72 | chalk.black.bgRed( 73 | `Unable to resolve \`projectBasePath\` from configuration. Defaulting source root to '${defaultSourceRoot}'`, 74 | ), 75 | ); 76 | 77 | return { projectBasePath: defaultSourceRoot }; 78 | } 79 | 80 | return { 81 | projectBasePath: resolved.sourceRoot, 82 | projectType: resolved.projectType, 83 | }; 84 | } 85 | 86 | function resolveProject( 87 | config: Record, 88 | projectName: string | undefined, 89 | ): { sourceRoot: string; projectType: ProjectType } | null { 90 | let projectConfig = config; 91 | 92 | if (config?.projects) { 93 | projectName = 94 | projectName || config.defaultProject || Object.keys(config.projects)[0]; 95 | const project = config.projects[projectName!]; 96 | projectConfig = isString(project) 97 | ? searchConfig(projectConfigFile, project) 98 | : project; 99 | } 100 | 101 | if (projectConfig?.sourceRoot) { 102 | return { 103 | sourceRoot: projectConfig.sourceRoot, 104 | projectType: projectConfig.projectType, 105 | }; 106 | } 107 | 108 | return null; 109 | } 110 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/service/service-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertPartialTranslation, 3 | assertTranslation, 4 | buildConfig, 5 | removeI18nFolder, 6 | sourceRoot, 7 | TranslationTestCase, 8 | } from '../../build-translation-utils'; 9 | import { 10 | defaultValue, 11 | generateKeys, 12 | mockResolveProjectBasePath, 13 | buildKeysFromParams, 14 | paramsTestConfig, 15 | } from '../../../spec-utils'; 16 | import { Config } from '../../../../src/types'; 17 | import { describe, beforeEach, it } from 'vitest'; 18 | 19 | mockResolveProjectBasePath(sourceRoot); 20 | 21 | /** 22 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 23 | * This thing is still in WIP at Jest, so keep an eye on it. 24 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 25 | */ 26 | import { buildTranslationFiles } from '../../../../src/keys-builder'; 27 | 28 | export function testServiceExtraction(fileFormat: Config['fileFormat']) { 29 | describe('service', () => { 30 | const type: TranslationTestCase = 'ts-extraction/service'; 31 | const config = buildConfig({ type, config: { fileFormat } }); 32 | 33 | beforeEach(() => removeI18nFolder(type)); 34 | 35 | it('should work with service', () => { 36 | const expected = { 37 | ...generateKeys({ end: 19 }), 38 | ...{ '20.21.22.23': defaultValue }, 39 | ...generateKeys({ start: 24, end: 33 }), 40 | 'inject.test': defaultValue, 41 | 'private-class-field.test': defaultValue, 42 | 'permission.snackbar.no-permission': defaultValue, 43 | 'permission.snackbar.close': defaultValue, 44 | }; 45 | 46 | buildTranslationFiles(config); 47 | assertTranslation({ type, expected, fileFormat }); 48 | }); 49 | 50 | it('should work with scopes', () => { 51 | const expected = { 52 | todos: { 53 | '1': defaultValue, 54 | '2.1': defaultValue, 55 | }, 56 | admin: { 57 | '3.1': defaultValue, 58 | '4': defaultValue, 59 | }, 60 | nested: { 61 | '5': defaultValue, 62 | '6.1': defaultValue, 63 | }, 64 | }; 65 | 66 | buildTranslationFiles(config); 67 | assertTranslation({ 68 | type, 69 | expected: expected.todos, 70 | path: 'todos-page/', 71 | fileFormat, 72 | }); 73 | assertTranslation({ 74 | type, 75 | expected: expected.admin, 76 | path: 'admin-page/', 77 | fileFormat, 78 | }); 79 | assertTranslation({ 80 | type, 81 | expected: expected.nested, 82 | path: 'nested/scope/', 83 | fileFormat, 84 | }); 85 | }); 86 | 87 | it('should work when passing an array of keys', () => { 88 | const expected = generateKeys({ start: 26, end: 33 }); 89 | 90 | buildTranslationFiles(config); 91 | assertPartialTranslation({ type, expected, fileFormat }); 92 | }); 93 | 94 | it('should extract params', () => { 95 | const expected = { 96 | ...generateKeys({ end: 11, withParams: true }), 97 | ...buildKeysFromParams([ 98 | 'inject.test', 99 | 'private-class-field.test', 100 | 'variable', 101 | 'another.variable', 102 | ]), 103 | }; 104 | 105 | buildTranslationFiles(paramsTestConfig(config)); 106 | assertTranslation({ type, expected, fileFormat }); 107 | }); 108 | }); 109 | } 110 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jsverse/transloco-keys-manager", 3 | "version": "7.1.0", 4 | "description": "Extract translatable keys from projects that uses Transloco", 5 | "engines": { 6 | "node": ">=20.19.0" 7 | }, 8 | "type": "module", 9 | "exports": { 10 | ".": { 11 | "import": "./public-api.js", 12 | "types": "./public-api.d.ts" 13 | }, 14 | "./marker": { 15 | "import": "./marker.js", 16 | "types": "./marker.d.ts" 17 | } 18 | }, 19 | "bin": { 20 | "transloco-keys-manager": "index.js" 21 | }, 22 | "scripts": { 23 | "release": "standard-version --infile ./CHANGELOG.md", 24 | "commit": "git-cz", 25 | "test": "vitest run", 26 | "test:watch": "vitest", 27 | "test:coverage": "vitest run --coverage", 28 | "start": "npm run format:all && tsc --watch", 29 | "prebuild": "npm run clean:dist", 30 | "build": "tsc && tsc-alias", 31 | "postbuild": "node ./scripts/post-build.js", 32 | "clean:dist": "rimraf dist", 33 | "format:all": "prettier --write src/**/*.ts && prettier --write __tests__/*.ts" 34 | }, 35 | "license": "MIT", 36 | "publishConfig": { 37 | "access": "public" 38 | }, 39 | "repository": { 40 | "type": "git", 41 | "url": "git+https://github.com/jsverse/transloco-keys-manager.git" 42 | }, 43 | "keywords": [ 44 | "angular", 45 | "angular 2", 46 | "i18n", 47 | "extract", 48 | "transloco", 49 | "translate", 50 | "keys", 51 | "tool", 52 | "cli", 53 | "webpack" 54 | ], 55 | "author": "Shahar Kazaz", 56 | "bugs": { 57 | "url": "https://github.com/jsverse/transloco-keys-manager/issues" 58 | }, 59 | "homepage": "https://jsverse.gitbook.io/transloco/tools/keys-manager-tkm", 60 | "config": { 61 | "commitizen": { 62 | "path": "cz-conventional-changelog" 63 | } 64 | }, 65 | "dependencies": { 66 | "@angular/compiler": ">= 20.0.0 < 22.0.0", 67 | "@jsverse/angular-utils": "1.0.0-beta.6", 68 | "@jsverse/transloco-utils": "8.2.0", 69 | "@phenomnomnominal/tsquery": "6.1.3", 70 | "chalk": "5.6.2", 71 | "cheerio": "1.1.2", 72 | "cli-table3": "0.6.5", 73 | "command-line-args": "6.0.1", 74 | "command-line-usage": "7.0.3", 75 | "cosmiconfig": "9.0.0", 76 | "debug": "4.4.3", 77 | "deep-diff": "1.0.2", 78 | "flat": "6.0.1", 79 | "fs-extra": "11.3.2", 80 | "gettext-parser": "8.0.0", 81 | "glob": "13.0.0", 82 | "jsonc-parser": "3.3.1", 83 | "ora": "9.0.0" 84 | }, 85 | "devDependencies": { 86 | "@angular/build": "^21.0.0", 87 | "@angular/compiler-cli": "^21.0.0", 88 | "@babel/preset-env": "7.28.5", 89 | "@babel/preset-typescript": "7.28.5", 90 | "@commitlint/cli": "20.1.0", 91 | "@commitlint/config-angular": "20.0.0", 92 | "@commitlint/config-conventional": "20.0.0", 93 | "@types/command-line-args": "^5.2.3", 94 | "@types/command-line-usage": "^5.0.4", 95 | "@types/debug": "4.1.12", 96 | "@types/deep-diff": "1.0.5", 97 | "@types/fs-extra": "11.0.4", 98 | "@types/gettext-parser": "^4.0.4", 99 | "@types/glob": "^9.0.0", 100 | "@typescript-eslint/eslint-plugin": "7.18.0", 101 | "@vitest/ui": "^4.0.12", 102 | "c8": "^10.1.3", 103 | "cross-env": "7.0.3", 104 | "domhandler": "^5.0.3", 105 | "git-cz": "4.9.0", 106 | "husky": "9.1.7", 107 | "lint-staged": "16.2.7", 108 | "lodash.isequal": "4.5.0", 109 | "prettier": "3.6.2", 110 | "rimraf": "6.1.2", 111 | "standard-version": "9.5.0", 112 | "tsc-alias": "^1.8.16", 113 | "typescript": "5.9.3", 114 | "vitest": "^4.0.12" 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/comments/comments-spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertTranslation, 3 | buildConfig, 4 | removeI18nFolder, 5 | sourceRoot, 6 | TranslationTestCase, 7 | } from '../build-translation-utils'; 8 | import { 9 | defaultValue, 10 | generateKeys, 11 | mockResolveProjectBasePath, 12 | } from '../../spec-utils'; 13 | import { Config } from '../../../src/types'; 14 | import { beforeEach, describe, it } from 'vitest'; 15 | 16 | mockResolveProjectBasePath(sourceRoot); 17 | 18 | /** 19 | * With ESM modules, you need to mock the modules beforehand (with jest.unstable_mockModule) and import them ashynchronously afterwards. 20 | * This thing is still in WIP at Jest, so keep an eye on it. 21 | * @see https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm 22 | */ 23 | const { buildTranslationFiles } = await import('../../../src/keys-builder'); 24 | 25 | export function testCommentsExtraction(fileFormat: Config['fileFormat']) { 26 | describe('comments', () => { 27 | const type: TranslationTestCase = 'comments'; 28 | const config = buildConfig({ type, config: { fileFormat } }); 29 | 30 | beforeEach(() => removeI18nFolder(type)); 31 | 32 | it('should work with comments', () => { 33 | const expected = { 34 | global: { 35 | 'a.some.key': defaultValue, 36 | 'b.some.key': defaultValue, 37 | 'c.some.key': defaultValue, 38 | 'prefix_c.some.key': defaultValue, 39 | 'need.transloco': defaultValue, 40 | '1.some': defaultValue, 41 | ...generateKeys({ end: 8 }), 42 | '10': defaultValue, 43 | '13': defaultValue, 44 | '11.12': defaultValue, 45 | 'hey.man': defaultValue, 46 | 'whats.app': defaultValue, 47 | '101': defaultValue, 48 | '111.12': defaultValue, 49 | hello: defaultValue, 50 | 'hey1.man': defaultValue, 51 | 'whats1.app': defaultValue, 52 | hello1: defaultValue, 53 | '131': defaultValue, 54 | ...generateKeys({ end: 5, prefix: '10' }), 55 | '10.6.7': defaultValue, 56 | '11': defaultValue, 57 | '11.1': defaultValue, 58 | '11.2.3': defaultValue, 59 | '200': defaultValue, 60 | '201': defaultValue, 61 | '202': defaultValue, 62 | '203.204': defaultValue, 63 | '205': defaultValue, 64 | '206': defaultValue, 65 | '207.208': defaultValue, 66 | '209': defaultValue, 67 | '210': defaultValue, 68 | '211': defaultValue, 69 | '212': defaultValue, 70 | '213.214': defaultValue, 71 | '215': defaultValue, 72 | '216': defaultValue, 73 | '217.218': defaultValue, 74 | 'from.comment': defaultValue, 75 | 'pretty.cool.da': defaultValue, 76 | ...generateKeys({ end: 4, prefix: 'global' }), 77 | ...generateKeys({ end: 8, prefix: 'outer.read' }), 78 | ...generateKeys({ end: 4, prefix: 'inner.read' }), 79 | ...generateKeys({ end: 2, prefix: 'another.container' }), 80 | }, 81 | admin: { 82 | '1': defaultValue, 83 | '2.3': defaultValue, 84 | '4': defaultValue, 85 | '5555': defaultValue, 86 | }, 87 | }; 88 | buildTranslationFiles(config); 89 | 90 | assertTranslation({ type, expected: expected.global, fileFormat }); 91 | assertTranslation({ 92 | type, 93 | expected: expected.admin, 94 | path: 'admin/', 95 | fileFormat, 96 | }); 97 | }); 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/ts-extraction/service/src/constructor-injection-2.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterViewInit, 3 | Component, 4 | ElementRef, 5 | Inject, 6 | OnDestroy, 7 | OnInit, 8 | Renderer2, 9 | ViewChild, 10 | } from '@angular/core'; 11 | import { TranslocoService, TRANSLOCO_SCOPE } from '@jsverse/transloco'; 12 | 13 | const SEARCH_INTERVAL = 400; 14 | const ITEM_ANIMATION_DURATION = 350; 15 | const SCROLL_ANIMATION = 500; 16 | 17 | @Component({ 18 | selector: 'bla-bla', 19 | templateUrl: './left-nav.component.html', 20 | providers: [ 21 | { 22 | provide: TRANSLOCO_SCOPE, 23 | useValue: 'nested/scope', 24 | }, 25 | ], 26 | }) 27 | export class Something implements OnInit, AfterViewInit, OnDestroy { 28 | hasSearch: boolean; 29 | items: any = []; 30 | 31 | private _contextMenuListener; 32 | 33 | private _setTimeoutDelegate = null; 34 | 35 | constructor( 36 | public transloco: TranslocoService, 37 | private element: ElementRef, 38 | private renderer: Renderer2, 39 | private routeService: RouteService, 40 | ) { 41 | this._dispose = [ 42 | this._onStateChange(), 43 | this.transloco.translate('1', {}, 'todos-page'), 44 | transloco.selectTranslate('2.1', {}, 'todos-page'), 45 | reaction( 46 | () => this.leftNavStore.itemClicked, 47 | (data) => { 48 | if (data !== 0) { 49 | this._isAnimateScroll = true; 50 | this.ps.directiveRef.update(); 51 | } 52 | }, 53 | ), 54 | reaction((data) => { 55 | this._onStateChange(true); 56 | }), 57 | ]; 58 | 59 | if (!isMobile()) { 60 | this._contextMenuListener = [ 61 | renderer.listen(element.nativeElement, 'contextmenu', (event) => { 62 | setRightClickedItem(null); 63 | }), 64 | renderer.listen(element.nativeElement, 'click', (event) => { 65 | setRightClickedItem(null); 66 | }), 67 | ]; 68 | } 69 | 70 | this._searchEvent = debounce(() => {}, SEARCH_INTERVAL); 71 | } 72 | 73 | ngOnInit() { 74 | this.transloco.translate('3.1', {}, 'admin-page'); 75 | } 76 | 77 | ngOnDestroy() { 78 | this.leftNavStore.clearSearch(); 79 | if (this._dispose) { 80 | this._dispose.forEach((d) => d()); 81 | } 82 | if (this._contextMenuListener) { 83 | this._contextMenuListener.forEach((d) => d()); 84 | } 85 | if (this._setTimeoutDelegate) { 86 | clearTimeout(this._setTimeoutDelegate); 87 | } 88 | } 89 | 90 | scrollToPosition(nativeElement: any) { 91 | this._setTimeoutDelegate && clearTimeout(this._setTimeoutDelegate); 92 | 93 | if (this._isAnimateScroll) { 94 | this._setTimeoutDelegate = setTimeout(() => { 95 | $(this.ps.directiveRef.elementRef.nativeElement).animate( 96 | { 97 | scrollTop: nativeElement.offsetTop, 98 | bb: this.transloco.translate('4', {}, `admin-page/en`), 99 | }, 100 | SCROLL_ANIMATION, 101 | ); 102 | }, 2 * ITEM_ANIMATION_DURATION); 103 | } else { 104 | this._setTimeoutDelegate = setTimeout(() => { 105 | this.transloco.selectTranslate(`5`, {}, 'nested/scope'); 106 | this.perfectScrollbar.directiveRef.update(); 107 | }, ITEM_ANIMATION_DURATION); 108 | } 109 | } 110 | 111 | onScroll(event) { 112 | this.transloco.selectTranslate(`6.1`, {}, `nested/scope/es`); 113 | } 114 | 115 | onSearch() { 116 | this._searchEvent(); 117 | } 118 | 119 | trackItem(index, item) { 120 | return item ? item.name + item.state : null; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/keys-builder/template/directive.extractor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AST, 3 | ASTWithSource, 4 | ParenthesizedExpression, 5 | TmplAstBoundAttribute, 6 | TmplAstNode, 7 | TmplAstTextAttribute, 8 | } from '@angular/compiler'; 9 | 10 | import { ExtractorConfig, OrArray } from '../../types'; 11 | import { addKey } from '../add-key'; 12 | import { resolveAliasAndKey } from '../utils/resolvers.utils'; 13 | 14 | import { TemplateExtractorConfig } from './types'; 15 | import { 16 | isBlockNode, 17 | isBoundAttribute, 18 | isElement, 19 | isInterpolation, 20 | isSupportedNode, 21 | isTemplate, 22 | isTextAttribute, 23 | parseTemplate, 24 | resolveBlockChildNodes, 25 | resolveKeysFromLiteralMap, 26 | } from './utils'; 27 | import { coerceArray } from '../../utils/collection.utils'; 28 | import { isConditionalExpression, isLiteralExpression, isLiteralMap } from '@jsverse/angular-utils'; 29 | 30 | export function directiveExtractor(config: TemplateExtractorConfig) { 31 | const ast = parseTemplate(config); 32 | traverse(ast.nodes, config); 33 | } 34 | 35 | function traverse(nodes: TmplAstNode[], config: ExtractorConfig) { 36 | for (const node of nodes) { 37 | if (isBlockNode(node)) { 38 | traverse(resolveBlockChildNodes(node), config); 39 | continue; 40 | } 41 | 42 | if (!isSupportedNode(node, [isTemplate, isElement])) { 43 | continue; 44 | } 45 | 46 | const params = node.inputs 47 | .filter(isTranslocoParams) 48 | .map((ast) => { 49 | const value = ast.value; 50 | if (value instanceof ASTWithSource && isLiteralMap(value.ast)) { 51 | return resolveKeysFromLiteralMap(value.ast); 52 | } 53 | 54 | return []; 55 | }) 56 | .flat(); 57 | const keys = [...node.inputs, ...node.attributes] 58 | .filter(isTranslocoDirective) 59 | .map((ast) => { 60 | let value = ast.value; 61 | if (value instanceof ASTWithSource) { 62 | value = value.ast; 63 | } 64 | 65 | return isInterpolation(value) ? (value.expressions as AST[]) : value; 66 | }) 67 | .flat() 68 | .map(resolveKey) 69 | .flat(); 70 | addKeys(keys, params, config); 71 | traverse(node.children, config); 72 | } 73 | } 74 | 75 | function isTranslocoDirective( 76 | ast: unknown, 77 | ): ast is TmplAstBoundAttribute | TmplAstTextAttribute { 78 | return ( 79 | (isBoundAttribute(ast) || isTextAttribute(ast)) && ast.name === 'transloco' 80 | ); 81 | } 82 | 83 | function isTranslocoParams(ast: unknown): ast is TmplAstBoundAttribute { 84 | return isBoundAttribute(ast) && ast.name === 'translocoParams'; 85 | } 86 | 87 | function resolveKey(ast: OrArray): string[] { 88 | return coerceArray(ast) 89 | .map((expression) => { 90 | if (typeof expression === 'string') { 91 | return expression; 92 | } else if (isConditionalExpression(expression)) { 93 | return resolveKey([expression.trueExp, expression.falseExp]); 94 | } else if (isLiteralExpression(expression)) { 95 | return expression.value; 96 | } else if (expression instanceof ParenthesizedExpression) { 97 | return resolveKey(expression.expression); 98 | } 99 | }) 100 | .filter(Boolean) 101 | .flat(); 102 | } 103 | 104 | function addKeys( 105 | keys: string[], 106 | params: string[], 107 | config: ExtractorConfig, 108 | ): void { 109 | for (const rawKey of keys) { 110 | const [key, scopeAlias] = resolveAliasAndKey(rawKey, config.scopes); 111 | addKey({ 112 | ...config, 113 | keyWithoutScope: key, 114 | scopeAlias, 115 | params, 116 | }); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /__tests__/spec-utils.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../src/types'; 2 | import nodePath from 'node:path'; 3 | import { sourceRoot } from './buildTranslationFiles/build-translation-utils'; 4 | import fs from 'fs-extra'; 5 | import { expect, vi } from 'vitest'; 6 | import { getCurrentTranslation } from '../src/keys-builder/utils/get-current-translation'; 7 | 8 | export function noop() {} 9 | 10 | export function spyOnConsole(method: 'log' | 'warn') { 11 | return vi.spyOn(console, method).mockImplementation(noop); 12 | } 13 | 14 | export function spyOnProcess(method: 'exit') { 15 | return vi.spyOn(process, method).mockImplementation(noop as any); 16 | } 17 | 18 | export function mockResolveProjectBasePath(projectBasePath: string) { 19 | vi.mock('src/utils/resolve-project-base-path.ts', () => ({ 20 | resolveProjectBasePath: vi.fn().mockReturnValue({ projectBasePath }), 21 | })); 22 | } 23 | 24 | export const defaultValue = 'missing'; 25 | export const defaultValueWithParams = 'missing{{params}}'; 26 | 27 | export function resolveValueWithParams(params: string[]) { 28 | return defaultValueWithParams.replace( 29 | '{{params}}', 30 | params.map((p) => `{{${p}}}`).join(' '), 31 | ); 32 | } 33 | 34 | export function buildKeysFromParams(params: string[]) { 35 | return params.reduce( 36 | (acc, p) => { 37 | acc[p] = resolveValueWithParams([p]); 38 | 39 | return acc; 40 | }, 41 | {} as Record, 42 | ); 43 | } 44 | 45 | export interface BuildConfigOptions { 46 | config?: Partial; 47 | sourceRoot: string; 48 | } 49 | 50 | export function buildConfig({ config = {}, sourceRoot }: BuildConfigOptions) { 51 | const output = nodePath.join(sourceRoot, `i18n`); 52 | return { 53 | input: [nodePath.join(sourceRoot, 'src')], 54 | output, 55 | translationsPath: output, 56 | langs: ['en', 'es', 'it'], 57 | defaultValue, 58 | ...config, 59 | } as Config; 60 | } 61 | 62 | export function paramsTestConfig(config: Config) { 63 | return { 64 | ...config, 65 | input: [nodePath.resolve(config.input[0], '../', 'with-params')], 66 | defaultValue: defaultValueWithParams, 67 | }; 68 | } 69 | 70 | export function removeI18nFolder(root = sourceRoot) { 71 | fs.removeSync(nodePath.join(root, 'i18n')); 72 | } 73 | 74 | export interface AssertTranslationParams extends Pick { 75 | expected: object; 76 | path?: string; 77 | root: string; 78 | } 79 | 80 | export function assertTranslation({ 81 | expected, 82 | ...rest 83 | }: AssertTranslationParams) { 84 | expect(loadTranslationFile(rest)).toEqual(expected); 85 | } 86 | 87 | export function assertPartialTranslation({ 88 | expected, 89 | ...rest 90 | }: AssertTranslationParams) { 91 | expect(loadTranslationFile(rest)).toMatchObject(expected); 92 | } 93 | 94 | function loadTranslationFile({ 95 | path, 96 | fileFormat, 97 | root, 98 | }: Omit) { 99 | return getCurrentTranslation({ 100 | path: nodePath.join(root, 'i18n', `${path || ''}en.${fileFormat}`), 101 | fileFormat, 102 | }); 103 | } 104 | 105 | interface GenerateKeysParams { 106 | start?: number; 107 | end: number; 108 | prefix?: string; 109 | withParams?: boolean; 110 | } 111 | export function generateKeys({ 112 | start = 1, 113 | end, 114 | prefix, 115 | withParams = false, 116 | }: GenerateKeysParams): { [index: string]: string } { 117 | let keys: Record = {}; 118 | for (let i = start; i <= end; i++) { 119 | const key = prefix ? `${prefix}.${i}` : `${i}`; 120 | if (withParams) { 121 | keys = { 122 | ...keys, 123 | ...buildKeysFromParams([key]), 124 | }; 125 | } else { 126 | keys[key] = defaultValue; 127 | } 128 | } 129 | return keys; 130 | } 131 | -------------------------------------------------------------------------------- /__tests__/buildTranslationFiles/template-extraction/pipe/src/5.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
{{"28" | transloco}}
4 |
5 |
6 |
7 | 8 |
9 |
{{"29" | transloco: {id: 1}}
10 | 12 | 13 |
{{"30" | transloco: { name: 'Avi' } }} 15 |
16 |
17 | 18 | 19 | 21 | 22 | 23 |
24 |
{{"31" | transloco}}
25 | 27 | {{ "32" | transloco }} 28 | 29 |
30 |
31 |
32 | 33 | 50 |
51 | 52 | 53 | {{'37' | transloco}} 54 | {{ (condition ? "38" : '39') | transloco: { name: "ccc"} }} 55 | 56 | 57 | 58 |
59 | {{ (condition ? '40' : 60 | 61 | '41') | transloco: { name: "ccc"} }} 62 |
63 | 64 |
65 | {{ (condition ? '42' : 66 | 67 | '43') | transloco: { name: "ccc"} }} 68 |
69 | 70 |
71 |
72 | 74 | 75 |
77 | {{ '48' | transloco }} 78 | {{ '49.50.51.52' | transloco }} 79 |
80 | {{ 'not' + 4 | transloco }} 81 | 82 | {{ 'numer' ++ 4 | transloco }} 83 | 84 |
85 | 86 | --------------------------------------------------------------------------------