├── .nvmrc ├── src ├── cli.ts ├── cli │ ├── tasks │ │ ├── task.interface.ts │ │ └── extract.task.ts │ └── cli.ts ├── cache │ ├── cache-interface.ts │ ├── null-cache.ts │ └── file-cache.ts ├── parsers │ ├── parser.interface.ts │ ├── function.parser.ts │ ├── marker.parser.ts │ ├── service.parser.ts │ ├── directive.parser.ts │ └── pipe.parser.ts ├── post-processors │ ├── post-processor.interface.ts │ ├── purge-obsolete-keys.post-processor.ts │ ├── strip-prefix.post-processor.ts │ ├── key-as-default-value.post-processor.ts │ ├── null-as-default-value.post-processor.ts │ ├── key-as-initial-default-value.post-processor.ts │ ├── string-as-default-value.post-processor.ts │ └── sort-by-key.post-processor.ts ├── utils │ ├── cli-color.ts │ ├── utils.ts │ ├── fs-helpers.ts │ ├── translation.collection.ts │ └── ast-helpers.ts ├── compilers │ ├── compiler.interface.ts │ ├── compiler.factory.ts │ ├── namespaced-json.compiler.ts │ ├── json.compiler.ts │ └── po.compiler.ts └── index.ts ├── .npmrc ├── .prettierrc ├── tests ├── cli │ ├── fixtures │ │ ├── marker.fixture.ts │ │ ├── ngx-translate.marker.fixture.ts │ │ ├── translate-pipe.component.fixture.ts │ │ ├── translate-directive.component.fixture.ts │ │ ├── private-translate-service.component.fixture.ts │ │ └── translate-service.component.fixture.ts │ ├── cli.spec.ts │ └── __snapshots__ │ │ └── cli.spec.ts.snap ├── compilers │ ├── json.compiler.spec.ts │ ├── po.compiler.spec.ts │ └── namespaced-json.compiler.spec.ts ├── post-processors │ ├── key-as-default-value.post-processor.spec.ts │ ├── purge-obsolete-keys.post-processor.spec.ts │ ├── key-as-initial-default-value.post-processor.spec.ts │ ├── null-as-default-value.post-processor.spec.ts │ ├── string-as-default-value.post-processor.spec.ts │ ├── strip-prefix.post-processor.spec.ts │ └── sort-by-key.post-processor.spec.ts ├── utils │ ├── cli-color.spec.ts │ ├── fs-helpers.spec.ts │ ├── translation.collection.spec.ts │ └── ast-helpers.spec.ts └── parsers │ ├── utils.spec.ts │ ├── function.parser.spec.ts │ ├── marker.parser.spec.ts │ └── directive.parser.spec.ts ├── tsconfig.spec.json ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── LICENSE ├── package.json ├── tools └── build.js ├── .oxlintrc.json ├── README.md └── CHANGELOG.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | import('./cli/cli.js'); 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org 2 | engine-strict=true 3 | -------------------------------------------------------------------------------- /src/cli/tasks/task.interface.ts: -------------------------------------------------------------------------------- 1 | export interface TaskInterface { 2 | execute(): void; 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "endOfLine": "lf", 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "printWidth": 145, 8 | "useTabs": true 9 | } 10 | -------------------------------------------------------------------------------- /src/cache/cache-interface.ts: -------------------------------------------------------------------------------- 1 | export interface CacheInterface { 2 | persist(): void; 3 | get(uniqueContents: KEY, generator: () => RESULT): RESULT; 4 | } 5 | -------------------------------------------------------------------------------- /tests/cli/fixtures/marker.fixture.ts: -------------------------------------------------------------------------------- 1 | import { marker } from '@biesbjerg/ngx-translate-extract-marker'; 2 | 3 | const title = marker('marker.dashboard.title'); 4 | const description = marker('marker.dashboard.description'); 5 | -------------------------------------------------------------------------------- /tests/cli/fixtures/ngx-translate.marker.fixture.ts: -------------------------------------------------------------------------------- 1 | import { _ } from '@ngx-translate/core'; 2 | 3 | const title = _('ngx-translate.marker.dashboard.title'); 4 | const description = _('ngx-translate.marker.dashboard.description'); 5 | -------------------------------------------------------------------------------- /src/parsers/parser.interface.ts: -------------------------------------------------------------------------------- 1 | import { TranslationCollection } from '../utils/translation.collection.js'; 2 | 3 | export interface ParserInterface { 4 | extract(source: string, filePath: string): TranslationCollection | null; 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "noImplicitAny": false, 6 | }, 7 | "include": [ 8 | "tests/**/*.ts", 9 | "src/**/*.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/cache/null-cache.ts: -------------------------------------------------------------------------------- 1 | import { CacheInterface } from './cache-interface.js'; 2 | 3 | export class NullCache implements CacheInterface { 4 | persist() {} 5 | get(_uniqueContents: KEY, generator: () => RESULT): RESULT { 6 | return generator(); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/post-processors/post-processor.interface.ts: -------------------------------------------------------------------------------- 1 | import { TranslationCollection } from '../utils/translation.collection.js'; 2 | 3 | export interface PostProcessorInterface { 4 | name: string; 5 | 6 | process(draft: TranslationCollection, extracted?: TranslationCollection, existing?: TranslationCollection): TranslationCollection; 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = tab 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .vscode 3 | .idea 4 | 5 | # Logs and other files 6 | npm-debug.log* 7 | .DS_Store 8 | 9 | # Compiled files 10 | src/**/*.js 11 | tests/**/*.js 12 | tests/cli/tmp 13 | dist 14 | 15 | # Extracted strings 16 | strings.json 17 | strings.pot 18 | 19 | # Dependency directory 20 | node_modules 21 | 22 | # Source maps for JS builds 23 | *.js.map 24 | -------------------------------------------------------------------------------- /src/utils/cli-color.ts: -------------------------------------------------------------------------------- 1 | import { styleText } from 'node:util'; 2 | 3 | export const cyan = (text: string) => styleText('cyan', text); 4 | export const green = (text: string) => styleText('green', text); 5 | export const bold = (text: string) => styleText('bold', text); 6 | export const dim = (text: string) => styleText('dim', text); 7 | export const red = (text: string) => styleText('red', text); 8 | -------------------------------------------------------------------------------- /tests/cli/fixtures/translate-pipe.component.fixture.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TranslatePipe } from "@ngx-translate/core"; 3 | 4 | @Component({ 5 | selector: 'app-home', 6 | standalone: true, 7 | imports: [TranslatePipe], 8 | template: ` 9 |
10 |

{{ 'pipe.comp.welcome' | translate }}

11 |

{{ 'pipe.comp.description' | translate }}

12 |
13 | ` 14 | }) 15 | export class TranslatePipeComponentFixture {} 16 | -------------------------------------------------------------------------------- /tests/cli/fixtures/translate-directive.component.fixture.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TranslateDirective } from '@ngx-translate/core'; 3 | 4 | @Component({ 5 | selector: 'app-home', 6 | standalone: true, 7 | imports: [TranslateDirective], 8 | template: ` 9 |
10 |

11 |

12 |
13 | ` 14 | }) 15 | export class TranslateDirectiveComponentFixture {} 16 | -------------------------------------------------------------------------------- /src/post-processors/purge-obsolete-keys.post-processor.ts: -------------------------------------------------------------------------------- 1 | import { TranslationCollection } from '../utils/translation.collection.js'; 2 | import { PostProcessorInterface } from './post-processor.interface.js'; 3 | 4 | export class PurgeObsoleteKeysPostProcessor implements PostProcessorInterface { 5 | public name: string = 'PurgeObsoleteKeys'; 6 | 7 | public process(draft: TranslationCollection, extracted: TranslationCollection): TranslationCollection { 8 | return draft.intersect(extracted); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/compilers/compiler.interface.ts: -------------------------------------------------------------------------------- 1 | import { TranslationCollection } from '../utils/translation.collection.js'; 2 | 3 | export enum CompilerType { 4 | Pot = 'pot', 5 | Json = 'json', 6 | NamespacedJson = 'namespaced-json', 7 | } 8 | 9 | export interface CompilerOptions { 10 | indentation?: string; 11 | trailingNewline?: boolean; 12 | poSourceLocation?: boolean; 13 | } 14 | 15 | export interface CompilerInterface { 16 | extension: string; 17 | 18 | compile(collection: TranslationCollection): string; 19 | 20 | parse(contents: string): TranslationCollection; 21 | } 22 | -------------------------------------------------------------------------------- /src/post-processors/strip-prefix.post-processor.ts: -------------------------------------------------------------------------------- 1 | import { TranslationCollection } from '../utils/translation.collection.js'; 2 | import { PostProcessorInterface } from './post-processor.interface.js'; 3 | 4 | interface Options { 5 | prefix: string; 6 | } 7 | 8 | export class StripPrefixPostProcessor implements PostProcessorInterface { 9 | public name: string = 'StripPrefix'; 10 | 11 | constructor(private options: Options) {} 12 | 13 | public process(draft: TranslationCollection): TranslationCollection { 14 | return draft.stripKeyPrefix(this.options.prefix); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "noImplicitReturns": true, 5 | "noImplicitThis": true, 6 | "removeComments": true, 7 | "declaration": true, 8 | "skipLibCheck": true, 9 | "target": "es2022", 10 | "lib": [ 11 | "es2022" 12 | ], 13 | "module": "nodenext", 14 | "esModuleInterop": true, 15 | "moduleResolution": "nodenext", 16 | "outDir": "dist", 17 | }, 18 | "include": [ 19 | "src/**/*.ts", 20 | ], 21 | "exclude": [] 22 | } 23 | -------------------------------------------------------------------------------- /src/post-processors/key-as-default-value.post-processor.ts: -------------------------------------------------------------------------------- 1 | import {TranslationCollection, TranslationInterface} from '../utils/translation.collection.js'; 2 | import { PostProcessorInterface } from './post-processor.interface.js'; 3 | 4 | export class KeyAsDefaultValuePostProcessor implements PostProcessorInterface { 5 | public name: string = 'KeyAsDefaultValue'; 6 | 7 | public process(draft: TranslationCollection): TranslationCollection { 8 | return draft.map((key: string, val: TranslationInterface): TranslationInterface => val.value === '' ? {value: key, sourceFiles: (val?.sourceFiles || [])} : val); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Assumes file is an Angular component if type is javascript/typescript 3 | */ 4 | export function isPathAngularComponent(path: string): boolean { 5 | return /\.ts|js$/i.test(path); 6 | } 7 | 8 | /** 9 | * Extract inline template from a component 10 | */ 11 | export function extractComponentInlineTemplate(contents: string): string { 12 | const regExp = /template\s*:\s*(["'`])([\s\S]*?)\1/; 13 | 14 | const match = regExp.exec(contents); 15 | if (match !== null) { 16 | return match[2]; 17 | } 18 | return ''; 19 | } 20 | 21 | export function stripBOM(contents: string): string { 22 | return contents.trim(); 23 | } 24 | -------------------------------------------------------------------------------- /src/post-processors/null-as-default-value.post-processor.ts: -------------------------------------------------------------------------------- 1 | import {TranslationCollection, TranslationInterface} from '../utils/translation.collection.js'; 2 | import { PostProcessorInterface } from './post-processor.interface.js'; 3 | 4 | export class NullAsDefaultValuePostProcessor implements PostProcessorInterface { 5 | public name: string = 'NullAsDefaultValue'; 6 | 7 | public process(draft: TranslationCollection, extracted: TranslationCollection, existing: TranslationCollection): TranslationCollection { 8 | return draft.map((key, val) => (existing.get(key) === undefined ? {value: null, sourceFiles: []} : val)); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: 'ci' 2 | on: 3 | pull_request: 4 | branches: [ "master" ] 5 | workflow_dispatch: 6 | workflow_call: 7 | jobs: 8 | build: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, windows-latest] 13 | node-version: ['20.x', '22.x', '24.x'] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | cache: 'npm' 21 | - run: npm ci 22 | - run: npm run lint 23 | - run: npm test 24 | - run: npm run build 25 | -------------------------------------------------------------------------------- /tests/cli/fixtures/private-translate-service.component.fixture.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TranslateService } from '@ngx-translate/core'; 3 | 4 | @Component({ 5 | selector: 'app-private-translate-service', 6 | standalone: true, 7 | template: ` 8 |
9 |

{{ welcomeMessage }}

10 |

{{ descriptionMessage }}

11 |
12 | ` 13 | }) 14 | export class PrivateTranslateServiceComponentFixture { 15 | private readonly #translate = inject(TranslateService); 16 | 17 | readonly welcomeMessage = this.#translate.instant('private-translate-service.comp.welcome'); 18 | readonly descriptionMessage = this.#translate.instant('private-translate-service.comp.description'); 19 | } 20 | -------------------------------------------------------------------------------- /src/post-processors/key-as-initial-default-value.post-processor.ts: -------------------------------------------------------------------------------- 1 | import {TranslationCollection, TranslationInterface} from '../utils/translation.collection.js'; 2 | import { PostProcessorInterface } from './post-processor.interface.js'; 3 | 4 | export class KeyAsInitialDefaultValuePostProcessor implements PostProcessorInterface { 5 | public name: string = 'KeyAsInitialDefaultValue'; 6 | 7 | public process(draft: TranslationCollection, extracted: TranslationCollection, existing: TranslationCollection): TranslationCollection { 8 | return draft.map((key: string, val: TranslationInterface): TranslationInterface => val.value === '' && !existing.has(key) ? {value: key, sourceFiles: (val?.sourceFiles || [])} : val); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/compilers/compiler.factory.ts: -------------------------------------------------------------------------------- 1 | import { CompilerInterface, CompilerOptions, CompilerType } from './compiler.interface.js'; 2 | import { JsonCompiler } from './json.compiler.js'; 3 | import { NamespacedJsonCompiler } from './namespaced-json.compiler.js'; 4 | import { PoCompiler } from './po.compiler.js'; 5 | 6 | export class CompilerFactory { 7 | public static create(format: CompilerType, options?: CompilerOptions): CompilerInterface { 8 | switch (format) { 9 | case CompilerType.Pot: 10 | return new PoCompiler(options); 11 | case CompilerType.Json: 12 | return new JsonCompiler(options); 13 | case CompilerType.NamespacedJson: 14 | return new NamespacedJsonCompiler(options); 15 | default: 16 | throw new Error(`Unknown format: ${format}`); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/post-processors/string-as-default-value.post-processor.ts: -------------------------------------------------------------------------------- 1 | import { TranslationCollection } from '../utils/translation.collection.js'; 2 | import { PostProcessorInterface } from './post-processor.interface.js'; 3 | 4 | interface Options { 5 | defaultValue: string; 6 | } 7 | 8 | export class StringAsDefaultValuePostProcessor implements PostProcessorInterface { 9 | public name: string = 'StringAsDefaultValue'; 10 | 11 | public constructor(protected options: Options) {} 12 | 13 | public process(draft: TranslationCollection, extracted: TranslationCollection, existing: TranslationCollection): TranslationCollection { 14 | return draft.map((key, val) => (existing.get(key) === undefined ? {value: this.options.defaultValue, sourceFiles: (val?.sourceFiles || [])} : val)); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/compilers/json.compiler.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, it } from 'vitest'; 2 | 3 | import { TranslationCollection } from '../../src/utils/translation.collection.js'; 4 | import { JsonCompiler } from '../../src/compilers/json.compiler.js'; 5 | 6 | describe('JsonCompiler', () => { 7 | let compiler: JsonCompiler; 8 | 9 | beforeEach(() => { 10 | compiler = new JsonCompiler(); 11 | }); 12 | 13 | it('should parse to a translation interface', () => { 14 | const contents = ` 15 | { 16 | "key": "value", 17 | "secondKey": "" 18 | } 19 | `; 20 | const collection: TranslationCollection = compiler.parse(contents); 21 | expect(collection.values).to.deep.equal({ 22 | 'key': {value: 'value', sourceFiles: []}, 23 | 'secondKey': {value: '', sourceFiles: []} 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Kim Biesbjerg 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils/translation.collection.js'; 2 | export * from './utils/utils.js'; 3 | 4 | export * from './cli/cli.js'; 5 | export * from './cli/tasks/task.interface.js'; 6 | export * from './cli/tasks/extract.task.js'; 7 | 8 | export * from './parsers/parser.interface.js'; 9 | export * from './parsers/directive.parser.js'; 10 | export * from './parsers/pipe.parser.js'; 11 | export * from './parsers/service.parser.js'; 12 | export * from './parsers/marker.parser.js'; 13 | 14 | export * from './compilers/compiler.interface.js'; 15 | export * from './compilers/compiler.factory.js'; 16 | export * from './compilers/json.compiler.js'; 17 | export * from './compilers/namespaced-json.compiler.js'; 18 | export * from './compilers/po.compiler.js'; 19 | 20 | export * from './post-processors/post-processor.interface.js'; 21 | export * from './post-processors/key-as-default-value.post-processor.js'; 22 | export * from './post-processors/key-as-initial-default-value.post-processor.js'; 23 | export * from './post-processors/purge-obsolete-keys.post-processor.js'; 24 | export * from './post-processors/sort-by-key.post-processor.js'; 25 | export * from './post-processors/strip-prefix.post-processor.js'; 26 | -------------------------------------------------------------------------------- /src/parsers/function.parser.ts: -------------------------------------------------------------------------------- 1 | import { ParserInterface } from './parser.interface.js'; 2 | import { TranslationCollection } from '../utils/translation.collection.js'; 3 | import { getStringsFromExpression, findSimpleCallExpressions, getAST } from '../utils/ast-helpers.js'; 4 | import pkg from 'typescript'; 5 | const { isIdentifier } = pkg; 6 | 7 | export class FunctionParser implements ParserInterface { 8 | constructor(private fnName: string) {} 9 | 10 | public extract(source: string, filePath: string): TranslationCollection | null { 11 | const sourceFile = getAST(source, filePath); 12 | 13 | let collection: TranslationCollection = new TranslationCollection(); 14 | 15 | const callExpressions = findSimpleCallExpressions(sourceFile, this.fnName); 16 | callExpressions.forEach((callExpression) => { 17 | if (!isIdentifier(callExpression.expression) 18 | || callExpression.expression.escapedText !== this.fnName) { 19 | return; 20 | } 21 | 22 | const [firstArg] = callExpression.arguments; 23 | if (!firstArg) { 24 | return; 25 | } 26 | const strings = getStringsFromExpression(firstArg); 27 | collection = collection.addKeys(strings, filePath); 28 | }); 29 | return collection; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/cli/fixtures/translate-service.component.fixture.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { TranslateService } from '@ngx-translate/core'; 3 | 4 | @Component({ 5 | selector: 'app-home', 6 | standalone: true, 7 | template: ` 8 |
9 |

{{ welcomeMessage }}

10 |

{{ descriptionMessage }}

11 |
12 | 13 | @if (showMore()) { 14 | 15 |

{{ detailsMessage }}

16 | } @else { 17 | 18 | } 19 | ` 20 | }) 21 | export class TranslateServiceComponentFixture { 22 | private readonly translate = inject(TranslateService); 23 | 24 | readonly welcomeMessage = this.translate.instant('translate-service.comp.welcome'); 25 | readonly descriptionMessage = this.translate.instant('translate-service.comp.description'); 26 | readonly detailsMessage = this.translate.instant('translate-service.comp.details'); 27 | readonly showMoreLabel = this.translate.instant('translate-service.comp.show-more-label'); 28 | readonly showLessLabel = this.translate.instant('translate-service.comp.show-less-label'); 29 | 30 | readonly showMore = signal(false); 31 | } 32 | -------------------------------------------------------------------------------- /src/post-processors/sort-by-key.post-processor.ts: -------------------------------------------------------------------------------- 1 | import { TranslationCollection } from '../utils/translation.collection.js'; 2 | import { PostProcessorInterface } from './post-processor.interface.js'; 3 | 4 | export class SortByKeyPostProcessor implements PostProcessorInterface { 5 | public name: string = 'SortByKey'; 6 | 7 | // More information on sort sensitivity: https://tc39.es/ecma402/#sec-collator-comparestrings 8 | // Passing undefined will be treated as 'variant' by default: https://tc39.es/ecma402/#sec-intl.collator 9 | public sortSensitivity: 'base' | 'accent' | 'case' | 'variant' | undefined = undefined; 10 | 11 | constructor(sortSensitivity: string | undefined) { 12 | if (isOfTypeSortSensitivity(sortSensitivity)) { 13 | this.sortSensitivity = sortSensitivity; 14 | } else { 15 | throw new Error(`Unknown sortSensitivity: ${sortSensitivity}`); 16 | } 17 | } 18 | 19 | public process(draft: TranslationCollection): TranslationCollection { 20 | const compareFn = this.sortSensitivity ? new Intl.Collator('en', { sensitivity: this.sortSensitivity }).compare : undefined; 21 | return draft.sort(compareFn); 22 | } 23 | } 24 | 25 | function isOfTypeSortSensitivity(keyInput: string | undefined): keyInput is 'base' | 'accent' | 'case' | 'variant' | undefined { 26 | return ['base', 'accent', 'case', 'variant'].includes(keyInput) || keyInput === undefined; 27 | } 28 | -------------------------------------------------------------------------------- /tests/post-processors/key-as-default-value.post-processor.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, it } from 'vitest'; 2 | 3 | import { PostProcessorInterface } from '../../src/post-processors/post-processor.interface.js'; 4 | import { KeyAsDefaultValuePostProcessor } from '../../src/post-processors/key-as-default-value.post-processor.js'; 5 | import { TranslationCollection } from '../../src/utils/translation.collection.js'; 6 | 7 | describe('KeyAsDefaultValuePostProcessor', () => { 8 | let processor: PostProcessorInterface; 9 | 10 | beforeEach(() => { 11 | processor = new KeyAsDefaultValuePostProcessor(); 12 | }); 13 | 14 | it('should use key as default value', () => { 15 | const collection = new TranslationCollection({ 16 | 'I have no value': {value: '', sourceFiles: []}, 17 | 'I am already translated': {value: 'Jeg er allerede oversat', sourceFiles: null}, 18 | 'Use this key as value as well': {value: '', sourceFiles: ['path/to/file.ts']} 19 | }); 20 | const extracted = new TranslationCollection(); 21 | const existing = new TranslationCollection(); 22 | 23 | expect(processor.process(collection, extracted, existing).values).to.deep.equal({ 24 | 'I have no value': {value: 'I have no value', sourceFiles: []}, 25 | 'I am already translated': {value: 'Jeg er allerede oversat', sourceFiles: null}, 26 | 'Use this key as value as well': {value: 'Use this key as value as well', sourceFiles: ['path/to/file.ts']} 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/compilers/namespaced-json.compiler.ts: -------------------------------------------------------------------------------- 1 | import { CompilerInterface, CompilerOptions } from './compiler.interface.js'; 2 | import {TranslationCollection, TranslationInterface, TranslationType} from '../utils/translation.collection.js'; 3 | import { stripBOM } from '../utils/utils.js'; 4 | 5 | import { flatten, unflatten } from 'flat'; 6 | 7 | export class NamespacedJsonCompiler implements CompilerInterface { 8 | public indentation: string = '\t'; 9 | public trailingNewline: boolean = false; 10 | 11 | public extension = 'json'; 12 | 13 | constructor(options?: CompilerOptions) { 14 | if (options && typeof options.indentation !== 'undefined') { 15 | this.indentation = options.indentation; 16 | } 17 | if (options && typeof options.trailingNewline !== 'undefined') { 18 | this.trailingNewline = options.trailingNewline; 19 | } 20 | } 21 | 22 | public compile(collection: TranslationCollection): string { 23 | const values = unflatten( 24 | collection.toKeyValueObject(), 25 | {object: true, overwrite: true} 26 | ); 27 | return JSON.stringify(values, null, this.indentation) + (this.trailingNewline ? '\n' : ''); 28 | } 29 | 30 | public parse(contents: string): TranslationCollection { 31 | const values: Record = flatten(JSON.parse(stripBOM(contents))); 32 | const newValues: TranslationType = {}; 33 | Object.entries(values).forEach(([key, value]: [string, string]) => newValues[key] = {value: value, sourceFiles: []}); 34 | return new TranslationCollection(newValues); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/utils/cli-color.spec.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, describe, it, expect } from 'vitest'; 2 | 3 | import { cyan, green, bold, dim, red } from './../../src/utils/cli-color.js'; 4 | 5 | process.env.FORCE_COLOR = '1'; 6 | 7 | describe('cli-color', () => { 8 | const sampleText = 'Sample text'; 9 | let originalForceColor: string | undefined; 10 | 11 | beforeAll(() => { 12 | originalForceColor = process.env.FORCE_COLOR; 13 | process.env.FORCE_COLOR = '1'; 14 | }); 15 | 16 | afterAll(() => { 17 | process.env.FORCE_COLOR = originalForceColor; 18 | }); 19 | 20 | it('should wrap text in cyan', () => { 21 | const result = cyan(sampleText); 22 | const ANSICyan = `\u001b[36m${sampleText}\u001b[39m`; 23 | expect(result).toBe(ANSICyan); 24 | }); 25 | 26 | it('should wrap text in green', () => { 27 | const result = green(sampleText); 28 | const ANSIGreen = `\u001b[32m${sampleText}\u001b[39m`; 29 | expect(result).toBe(ANSIGreen); 30 | }); 31 | 32 | it('should wrap text in bold', () => { 33 | const result = bold(sampleText); 34 | const ANSIBold = `\u001b[1m${sampleText}\u001b[22m`; 35 | expect(result).toBe(ANSIBold); 36 | }); 37 | 38 | it('should wrap text in dim', () => { 39 | const result = dim(sampleText); 40 | const ANSIDim = `\u001b[2m${sampleText}\u001b[22m`; 41 | expect(result).toBe(ANSIDim); 42 | }); 43 | 44 | it('should wrap text in red', () => { 45 | const result = red(sampleText); 46 | const ANSIRed = `\u001b[31m${sampleText}\u001b[39m`; 47 | expect(result).toBe(ANSIRed); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/post-processors/purge-obsolete-keys.post-processor.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, it } from 'vitest'; 2 | 3 | import { PostProcessorInterface } from '../../src/post-processors/post-processor.interface.js'; 4 | import { PurgeObsoleteKeysPostProcessor } from '../../src/post-processors/purge-obsolete-keys.post-processor.js'; 5 | import { TranslationCollection } from '../../src/utils/translation.collection.js'; 6 | 7 | describe('PurgeObsoleteKeysPostProcessor', () => { 8 | let postProcessor: PostProcessorInterface; 9 | 10 | beforeEach(() => { 11 | postProcessor = new PurgeObsoleteKeysPostProcessor(); 12 | }); 13 | 14 | it('should purge obsolete keys', () => { 15 | const draft = new TranslationCollection({ 16 | 'I am completely new': {value: '', sourceFiles: []}, 17 | 'I already exist': {value: '', sourceFiles: []}, 18 | 'I already exist but was not present in extract': {value: '', sourceFiles: []} 19 | }); 20 | const extracted = new TranslationCollection({ 21 | 'I am completely new': {value: '', sourceFiles: []}, 22 | 'I already exist': {value: '', sourceFiles: []} 23 | }); 24 | const existing = new TranslationCollection({ 25 | 'I already exist': {value: '', sourceFiles: []}, 26 | 'I already exist but was not present in extract': {value: '', sourceFiles: []} 27 | }); 28 | 29 | expect(postProcessor.process(draft, extracted, existing).values).to.deep.equal({ 30 | 'I am completely new': {value: '', sourceFiles: []}, 31 | 'I already exist': {value: '', sourceFiles: []} 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /tests/post-processors/key-as-initial-default-value.post-processor.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, it } from 'vitest'; 2 | 3 | import { PostProcessorInterface } from '../../src/post-processors/post-processor.interface.js'; 4 | import { KeyAsInitialDefaultValuePostProcessor } from '../../src/post-processors/key-as-initial-default-value.post-processor.js'; 5 | import { TranslationCollection } from '../../src/utils/translation.collection.js'; 6 | 7 | describe('KeyAsInitialDefaultValuePostProcessor', () => { 8 | let processor: PostProcessorInterface; 9 | 10 | beforeEach(() => { 11 | processor = new KeyAsInitialDefaultValuePostProcessor(); 12 | }); 13 | 14 | it('should use key as default value', () => { 15 | const collection = new TranslationCollection({ 16 | 'I have no value': { value: '', sourceFiles: [] }, 17 | 'I have no value but I exist': { value: '', sourceFiles: [] }, 18 | 'I am already translated': { value: 'Jeg er allerede oversat', sourceFiles: [] }, 19 | 'Use this key as value as well': { value: '', sourceFiles: ['path/to/file.ts'] } 20 | }); 21 | const extracted = new TranslationCollection(); 22 | const existing = new TranslationCollection({ 23 | 'I have no value but I exist': { value: '', sourceFiles: [] } 24 | }); 25 | 26 | expect(processor.process(collection, extracted, existing).values).to.deep.equal({ 27 | 'I have no value': { value: 'I have no value', sourceFiles: [] }, 28 | 'I have no value but I exist': { value: '', sourceFiles: [] }, 29 | 'I am already translated': { value: 'Jeg er allerede oversat', sourceFiles: [] }, 30 | 'Use this key as value as well': { value: 'Use this key as value as well', sourceFiles: ['path/to/file.ts'] } 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/parsers/marker.parser.ts: -------------------------------------------------------------------------------- 1 | import { ParserInterface } from './parser.interface.js'; 2 | import { TranslationCollection } from '../utils/translation.collection.js'; 3 | import { getNamedImportAlias, findFunctionCallExpressions, getStringsFromExpression, getAST } from '../utils/ast-helpers.js'; 4 | import { SourceFile } from 'typescript'; 5 | 6 | const MARKER_MODULE_NAME = new RegExp('ngx-translate-extract-marker'); 7 | const MARKER_IMPORT_NAME = 'marker'; 8 | const NGX_TRANSLATE_MARKER_MODULE_NAME = '@ngx-translate/core'; 9 | const NGX_TRANSLATE_MARKER_IMPORT_NAME = '_'; 10 | 11 | export class MarkerParser implements ParserInterface { 12 | public extract(source: string, filePath: string): TranslationCollection | null { 13 | const sourceFile = getAST(source, filePath); 14 | 15 | const markerImportName = this.getMarkerImportNameFromSource(sourceFile); 16 | if (!markerImportName) { 17 | return null; 18 | } 19 | 20 | let collection: TranslationCollection = new TranslationCollection(); 21 | 22 | const callExpressions = findFunctionCallExpressions(sourceFile, markerImportName); 23 | callExpressions.forEach((callExpression) => { 24 | const [firstArg] = callExpression.arguments; 25 | if (!firstArg) { 26 | return; 27 | } 28 | const strings = getStringsFromExpression(firstArg); 29 | collection = collection.addKeys(strings, filePath); 30 | }); 31 | return collection; 32 | } 33 | 34 | private getMarkerImportNameFromSource(sourceFile: SourceFile): string { 35 | const markerImportName = 36 | getNamedImportAlias(sourceFile, MARKER_IMPORT_NAME, MARKER_MODULE_NAME) || 37 | getNamedImportAlias(sourceFile, NGX_TRANSLATE_MARKER_IMPORT_NAME, NGX_TRANSLATE_MARKER_MODULE_NAME); 38 | 39 | return markerImportName ?? ''; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/compilers/json.compiler.ts: -------------------------------------------------------------------------------- 1 | import { CompilerInterface, CompilerOptions } from './compiler.interface.js'; 2 | import {TranslationCollection, TranslationInterface, TranslationType} from '../utils/translation.collection.js'; 3 | import { stripBOM } from '../utils/utils.js'; 4 | 5 | import { flatten } from 'flat'; 6 | 7 | export class JsonCompiler implements CompilerInterface { 8 | public indentation: string = '\t'; 9 | public trailingNewline: boolean = false; 10 | 11 | public extension: string = 'json'; 12 | 13 | constructor(options?: CompilerOptions) { 14 | if (options && typeof options.indentation !== 'undefined') { 15 | this.indentation = options.indentation; 16 | } 17 | if (options && typeof options.trailingNewline !== 'undefined') { 18 | this.trailingNewline = options.trailingNewline; 19 | } 20 | } 21 | 22 | public compile(collection: TranslationCollection): string { 23 | return JSON.stringify(collection.toKeyValueObject(), null, this.indentation) + (this.trailingNewline ? '\n' : ''); 24 | } 25 | 26 | public parse(contents: string): TranslationCollection { 27 | let values = JSON.parse(stripBOM(contents)); 28 | if (this.isNamespacedJsonFormat(values)) { 29 | values = flatten(values); 30 | } 31 | const newValues: TranslationType = {}; 32 | Object.entries(values).forEach(([key, value]: [string, string]) => newValues[key] = {value: value, sourceFiles: []}); 33 | return new TranslationCollection(newValues); 34 | } 35 | 36 | protected isNamespacedJsonFormat(values: unknown): boolean { 37 | if (!isObject(values)) { 38 | return false; 39 | } 40 | 41 | return Object.keys(values).some((key) => typeof values[key] === 'object'); 42 | } 43 | } 44 | 45 | function isObject(value: unknown): value is Record { 46 | return typeof value === 'object' && value !== null; 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vendure/ngx-translate-extract", 3 | "version": "10.1.2", 4 | "description": "Extract strings from projects using ngx-translate", 5 | "author": "Kim Biesbjerg ", 6 | "license": "MIT", 7 | "type": "module", 8 | "scripts": { 9 | "build": "node tools/build.js", 10 | "build:dev": "npm run clean && tsc", 11 | "watch": "npm run clean && tsc --watch", 12 | "clean": "tsc --build --clean", 13 | "test": "vitest", 14 | "lint": "oxlint" 15 | }, 16 | "dependencies": { 17 | "@phenomnomnominal/tsquery": "^6.1.3", 18 | "braces": "^3.0.3", 19 | "flat": "^6.0.1", 20 | "gettext-parser": "^8.0.0", 21 | "glob": "^13.0.0", 22 | "yargs": "^18.0.0" 23 | }, 24 | "devDependencies": { 25 | "@angular/compiler": "^21.0.3", 26 | "@types/braces": "^3.0.5", 27 | "@types/gettext-parser": "^8.0.0", 28 | "@types/node": "^20.19.25", 29 | "@types/yargs": "^17.0.35", 30 | "oxlint": "^1.31.0", 31 | "prettier": "^3.7.4", 32 | "typescript": "~5.9.2", 33 | "vitest": "^4.0.15" 34 | }, 35 | "peerDependencies": { 36 | "@angular/compiler": ">=20.0.0", 37 | "typescript": ">=5.8.0" 38 | }, 39 | "bin": { 40 | "ngx-translate-extract": "cli.js" 41 | }, 42 | "repository": { 43 | "type": "git", 44 | "url": "git+ssh://git@github.com/vendure-ecommerce/ngx-translate-extract.git" 45 | }, 46 | "keywords": [ 47 | "angular", 48 | "ionic", 49 | "ngx-translate", 50 | "extract", 51 | "extractor", 52 | "translate", 53 | "translation", 54 | "i18n", 55 | "gettext" 56 | ], 57 | "bugs": { 58 | "url": "https://github.com/vendure-ecommerce/ngx-translate-extract/issues" 59 | }, 60 | "homepage": "https://github.com/vendure-ecommerce/ngx-translate-extract", 61 | "engines": { 62 | "node": ">=20.19.0", 63 | "npm": ">=10" 64 | }, 65 | "publishConfig": { 66 | "registry": "https://registry.npmjs.org" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/parsers/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { isPathAngularComponent, extractComponentInlineTemplate } from '../../src/utils/utils.js'; 4 | 5 | describe('Utils', () => { 6 | it('should recognize js extension as angular component', () => { 7 | const result = isPathAngularComponent('test.js'); 8 | expect(result).to.equal(true); 9 | }); 10 | 11 | it('should recognize ts extension as angular component', () => { 12 | const result = isPathAngularComponent('test.ts'); 13 | expect(result).to.equal(true); 14 | }); 15 | 16 | it('should not recognize html extension as angular component', () => { 17 | const result = isPathAngularComponent('test.html'); 18 | expect(result).to.equal(false); 19 | }); 20 | 21 | it('should extract inline template', () => { 22 | const contents = ` 23 | @Component({ 24 | selector: 'test', 25 | template: '

Hello World

' 26 | }) 27 | export class TestComponent { } 28 | `; 29 | const template = extractComponentInlineTemplate(contents); 30 | expect(template).to.equal('

Hello World

'); 31 | }); 32 | 33 | it('should extract inline template without html', () => { 34 | const contents = ` 35 | @Component({ 36 | selector: 'test', 37 | template: '{{ "Hello World" | translate }}' 38 | }) 39 | export class TestComponent { } 40 | `; 41 | const template = extractComponentInlineTemplate(contents); 42 | expect(template).to.equal('{{ "Hello World" | translate }}'); 43 | }); 44 | 45 | it('should extract inline template spanning multiple lines', () => { 46 | const contents = ` 47 | @Component({ 48 | selector: 'test', 49 | template: ' 50 |

51 | Hello World 52 |

53 | ', 54 | styles: [' 55 | p { 56 | color: red; 57 | } 58 | '] 59 | }) 60 | export class TestComponent { } 61 | `; 62 | const template = extractComponentInlineTemplate(contents); 63 | expect(template).to.equal('\n\t\t\t\t\t

\n\t\t\t\t\t\tHello World\n\t\t\t\t\t

\n\t\t\t\t'); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /tests/post-processors/null-as-default-value.post-processor.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, it } from 'vitest'; 2 | 3 | import { PostProcessorInterface } from '../../src/post-processors/post-processor.interface.js'; 4 | import { NullAsDefaultValuePostProcessor } from '../../src/post-processors/null-as-default-value.post-processor.js'; 5 | import { TranslationCollection } from '../../src/utils/translation.collection.js'; 6 | 7 | describe('NullAsDefaultValuePostProcessor', () => { 8 | let processor: PostProcessorInterface; 9 | 10 | beforeEach(() => { 11 | processor = new NullAsDefaultValuePostProcessor(); 12 | }); 13 | 14 | it('should use null as default value', () => { 15 | const draft = new TranslationCollection({ 'String A': {value: '', sourceFiles: []} }); 16 | const extracted = new TranslationCollection({ 'String A': {value: '', sourceFiles: []} }); 17 | const existing = new TranslationCollection(); 18 | expect(processor.process(draft, extracted, existing).values).to.deep.equal({ 19 | 'String A': {value: null, sourceFiles: []} 20 | }); 21 | }); 22 | 23 | it('should keep existing value even if it is an empty string', () => { 24 | const draft = new TranslationCollection({ 'String A': {value: '', sourceFiles: []} }); 25 | const extracted = new TranslationCollection({ 'String A': {value: '', sourceFiles: []} }); 26 | const existing = new TranslationCollection({ 'String A': {value: '', sourceFiles: []} }); 27 | expect(processor.process(draft, extracted, existing).values).to.deep.equal({ 28 | 'String A': {value: '', sourceFiles: []} 29 | }); 30 | }); 31 | 32 | it('should keep existing value', () => { 33 | const draft = new TranslationCollection({ 'String A': {value: 'Streng A', sourceFiles: []} }); 34 | const extracted = new TranslationCollection({ 'String A': {value: 'Streng A', sourceFiles: []} }); 35 | const existing = new TranslationCollection({ 'String A': {value: 'Streng A', sourceFiles: []} }); 36 | expect(processor.process(draft, extracted, existing).values).to.deep.equal({ 37 | 'String A': {value: 'Streng A', sourceFiles: []} 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /tests/post-processors/string-as-default-value.post-processor.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, it } from 'vitest'; 2 | 3 | import { PostProcessorInterface } from '../../src/post-processors/post-processor.interface.js'; 4 | import { StringAsDefaultValuePostProcessor } from '../../src/post-processors/string-as-default-value.post-processor.js'; 5 | import { TranslationCollection } from '../../src/utils/translation.collection.js'; 6 | 7 | describe('StringAsDefaultValuePostProcessor', () => { 8 | let processor: PostProcessorInterface; 9 | 10 | beforeEach(() => { 11 | processor = new StringAsDefaultValuePostProcessor({ defaultValue: 'default' }); 12 | }); 13 | 14 | it('should use string as default value', () => { 15 | const draft = new TranslationCollection({ 'String A': {value: '', sourceFiles: []} }); 16 | const extracted = new TranslationCollection({ 'String A': {value: '', sourceFiles: []} }); 17 | const existing = new TranslationCollection(); 18 | expect(processor.process(draft, extracted, existing).values).to.deep.equal({ 19 | 'String A': {value: 'default', sourceFiles: []} 20 | }); 21 | }); 22 | 23 | it('should keep existing value even if it is an empty string', () => { 24 | const draft = new TranslationCollection({ 'String A': {value: '', sourceFiles: []} }); 25 | const extracted = new TranslationCollection({ 'String A': {value: '', sourceFiles: []} }); 26 | const existing = new TranslationCollection({ 'String A': {value: '', sourceFiles: []} }); 27 | expect(processor.process(draft, extracted, existing).values).to.deep.equal({ 28 | 'String A': {value: '', sourceFiles: []} 29 | }); 30 | }); 31 | 32 | it('should keep existing value', () => { 33 | const draft = new TranslationCollection({ 'String A': {value: 'Streng A', sourceFiles: []} }); 34 | const extracted = new TranslationCollection({ 'String A': {value: 'Streng A', sourceFiles: []} }); 35 | const existing = new TranslationCollection({ 'String A': {value: 'Streng A', sourceFiles: []} }); 36 | expect(processor.process(draft, extracted, existing).values).to.deep.equal({ 37 | 'String A': {value: 'Streng A', sourceFiles: []} 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /tools/build.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; 4 | import { styleText } from 'node:util'; 5 | import { join, resolve } from 'node:path'; 6 | import { execSync } from 'node:child_process'; 7 | import { stdout } from 'node:process'; 8 | 9 | import { parseConfigFileTextToJson } from 'typescript'; 10 | 11 | const PROJECT_NAME = 'ngx-translate-extract'; 12 | const SEPARATOR = '-'.repeat(80); 13 | const CARRIAGE_CHAR = stdout.isTTY ? '\r' : '\n'; 14 | 15 | console.log(`\nBuilding ${PROJECT_NAME}`); 16 | console.log(SEPARATOR); 17 | 18 | // Read tsconfig.json 19 | const tsConfigPath = resolve(process.cwd(), 'tsconfig.json'); 20 | const tsConfigRaw = readFileSync(tsConfigPath, 'utf-8'); 21 | const tsconfig = parseConfigFileTextToJson(tsConfigPath, tsConfigRaw).config; 22 | 23 | // Get outDir from tsconfig.json 24 | const outDir = tsconfig?.compilerOptions?.outDir ?? null; 25 | const resolvedOutDir = resolve(process.cwd(), outDir); 26 | 27 | // Empty dist 28 | stdout.write(`⏳ Emptying '${outDir}' directory`); 29 | rmSync(resolvedOutDir, { recursive: true, force: true }); 30 | mkdirSync(resolvedOutDir, { recursive: true }); 31 | stdout.write(`${CARRIAGE_CHAR}✔ Emptying '${outDir}' directory\n`); 32 | 33 | // Copy assets 34 | const filesToCopy = ['LICENSE', 'README.md']; 35 | filesToCopy.forEach(fileName => { 36 | console.log(`ℹ Copying ${fileName}`); 37 | copyFileSync(fileName, join(resolvedOutDir, fileName)); 38 | }); 39 | 40 | // Copy and clean package.json 41 | const pkgPath = resolve(process.cwd(), 'package.json'); 42 | const packageJson = JSON.parse(readFileSync(pkgPath, 'utf-8')); 43 | 44 | const keysToRemove = ['devDependencies', 'scripts']; 45 | keysToRemove.forEach(key => { 46 | console.log(`ℹ Removing ${key} from package.json`); 47 | delete packageJson[key]; 48 | }); 49 | writeFileSync(join(resolvedOutDir, 'package.json'), JSON.stringify(packageJson, null, 2)); 50 | console.log(`✔ Copied assets to '${outDir}' directory`); 51 | 52 | // Build 53 | stdout.write(`⏳ Building project`); 54 | execSync(`tsc --project tsconfig.json`, { stdio: 'inherit' }); 55 | stdout.write(`${CARRIAGE_CHAR}✔ Building project\n`); 56 | console.log(SEPARATOR); 57 | console.log(styleText('green', `DONE`)); 58 | -------------------------------------------------------------------------------- /src/utils/fs-helpers.ts: -------------------------------------------------------------------------------- 1 | import { basename, sep, posix } from 'node:path'; 2 | 3 | import * as os from 'node:os'; 4 | import * as fs from 'node:fs'; 5 | import braces from 'braces'; 6 | 7 | export function normalizeHomeDir(path: string): string { 8 | if (path.substring(0, 1) === '~') { 9 | return `${os.homedir()}/${path.substring(1)}`; 10 | } 11 | return path; 12 | } 13 | 14 | /** 15 | * Normalizes a file path by replacing the current working directory (`cwd`) 16 | * with its base name and converting path separators to POSIX style. 17 | */ 18 | export function normalizeFilePath(filePath: string): string { 19 | const cwd = 'process' in globalThis ? process.cwd() : ''; 20 | const cwdBaseName = basename(cwd); 21 | 22 | if (!filePath.startsWith(cwd)) { 23 | return filePath; 24 | } 25 | 26 | return filePath.replace(cwd, cwdBaseName).replaceAll(sep, posix.sep); 27 | } 28 | 29 | /** 30 | * Expands a pattern with braces, handling Windows-style separators. 31 | */ 32 | export function expandPattern(pattern: string): string[] { 33 | const isWindows = sep === '\\'; 34 | 35 | // Windows escaped separators can cause the brace "{" in the pattern to be also escaped and ignored by braces lib. 36 | // For that reason we convert separators to posix for braces and then back to the original. 37 | // For example, without replacing the separators the first case below is not parsed correctly: 38 | // 'dir\\{en,fr}.json' => ['dir\\{en,fr}.json'] // Pattern is ignored 39 | // 'dir\\locale.{en,fr}.json' => ['dir\\locale.en.json', 'dir\\locale.fr.json'] // Pattern is recognised 40 | const bracesCompatiblePattern = isWindows ? pattern.replaceAll(sep, posix.sep) : pattern; 41 | 42 | const output = braces(bracesCompatiblePattern, { expand: true, keepEscaping: true }); 43 | 44 | return isWindows ? output.map((path) => path.replaceAll(posix.sep, sep)) : output; 45 | } 46 | 47 | export function normalizePaths(patterns: string[], defaultPatterns: string[] = []): string[] { 48 | return patterns 49 | .map((pattern) => 50 | expandPattern(pattern) 51 | .map((path) => { 52 | path = normalizeHomeDir(path); 53 | if (fs.existsSync(path) && fs.statSync(path).isDirectory()) { 54 | return defaultPatterns.map((defaultPattern) => path + defaultPattern); 55 | } 56 | return path; 57 | }) 58 | .flat() 59 | ) 60 | .flat(); 61 | } 62 | -------------------------------------------------------------------------------- /tests/post-processors/strip-prefix.post-processor.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, it } from 'vitest'; 2 | import { PostProcessorInterface } from '../../src/post-processors/post-processor.interface.js'; 3 | import { StripPrefixPostProcessor } from '../../src/post-processors/strip-prefix.post-processor.js'; 4 | import { TranslationCollection } from '../../src/utils/translation.collection.js'; 5 | 6 | describe('StripPrefixPostProcessor', () => { 7 | let processor: PostProcessorInterface; 8 | 9 | beforeEach(() => { 10 | processor = new StripPrefixPostProcessor({ prefix: 'TEST.' }); 11 | }); 12 | 13 | it('should remove prefix from key', () => { 14 | const draft = new TranslationCollection({ 'TEST.StringA': { value: '', sourceFiles: [] } }); 15 | const extracted = new TranslationCollection({ 'TEST.StringA': { value: '', sourceFiles: [] } }); 16 | const existing = new TranslationCollection(); 17 | expect(processor.process(draft, extracted, existing).values).to.deep.equal({ 18 | StringA: { value: '', sourceFiles: [] } 19 | }); 20 | }); 21 | 22 | it('should only remove prefix if it is first', () => { 23 | const draft = new TranslationCollection({ 'DUMMY.TEST.StringA': { value: '', sourceFiles: [] } }); 24 | const extracted = new TranslationCollection({ 'DUMMY.TEST.StringA': { value: '', sourceFiles: [] } }); 25 | const existing = new TranslationCollection(); 26 | expect(processor.process(draft, extracted, existing).values).to.deep.equal({ 27 | 'DUMMY.TEST.StringA': { value: '', sourceFiles: [] } 28 | }); 29 | }); 30 | 31 | it('should ignore case when removing prefix', () => { 32 | const draft = new TranslationCollection({ 33 | 'test.StringA': { value: '', sourceFiles: [] }, 34 | 'teST.StringB': { value: '', sourceFiles: [] }, 35 | 'Test.StringC': { value: '', sourceFiles: [] } 36 | }); 37 | const extracted = new TranslationCollection({ 38 | 'test.StringA': { value: '', sourceFiles: [] }, 39 | 'teST.StringB': { value: '', sourceFiles: [] }, 40 | 'Test.StringC': { value: '', sourceFiles: [] } 41 | }); 42 | const existing = new TranslationCollection(); 43 | expect(processor.process(draft, extracted, existing).values).to.deep.equal({ 44 | StringA: { value: '', sourceFiles: [] }, 45 | StringB: { value: '', sourceFiles: [] }, 46 | StringC: { value: '', sourceFiles: [] } 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/compilers/po.compiler.ts: -------------------------------------------------------------------------------- 1 | import { po } from 'gettext-parser'; 2 | 3 | import { CompilerInterface, CompilerOptions } from './compiler.interface.js'; 4 | import { TranslationCollection, TranslationInterface, TranslationType } from '../utils/translation.collection.js'; 5 | 6 | export class PoCompiler implements CompilerInterface { 7 | public extension: string = 'po'; 8 | 9 | /** 10 | * Translation domain 11 | */ 12 | public domain: string = ''; 13 | 14 | /** Whether to include file location comments. **/ 15 | private readonly includeSources: boolean = true; 16 | 17 | constructor(options?: CompilerOptions) { 18 | this.includeSources = options?.poSourceLocation ?? true; 19 | } 20 | 21 | public compile(collection: TranslationCollection): string { 22 | const data = { 23 | charset: 'utf-8', 24 | headers: { 25 | 'mime-version': '1.0', 26 | 'content-type': 'text/plain; charset=utf-8', 27 | 'content-transfer-encoding': '8bit' 28 | }, 29 | translations: { 30 | [this.domain]: Object.keys(collection.values) 31 | .reduce( 32 | (translations, key) => { 33 | const entry: TranslationInterface = collection.get(key); 34 | const comments = this.includeSources ? {reference: entry.sourceFiles?.join('\n')} : undefined; 35 | return { 36 | ...translations, 37 | [key]: { 38 | msgid: key, 39 | msgstr: entry.value, 40 | comments: comments 41 | } 42 | }; 43 | }, 44 | {} 45 | ) 46 | } 47 | }; 48 | 49 | return po.compile(data, {}).toString('utf8'); 50 | } 51 | 52 | public parse(contents: string): TranslationCollection { 53 | const parsedPo = po.parse(contents, { defaultCharset: 'utf8' }); 54 | const poTranslations = parsedPo.translations?.[this.domain]; 55 | 56 | if (!poTranslations) { 57 | return new TranslationCollection(); 58 | } 59 | 60 | const translationEntries = Object.entries(poTranslations) 61 | const convertedTranslations: TranslationType = {}; 62 | for (const [msgid, message] of translationEntries) { 63 | if (msgid === this.domain) { 64 | continue; 65 | } 66 | 67 | convertedTranslations[msgid] = { 68 | value: message.msgstr.at(-1), 69 | sourceFiles: message.comments?.reference?.split('\n') || [] 70 | }; 71 | } 72 | 73 | return new TranslationCollection(convertedTranslations); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | release_type: 6 | description: 'Type of release (release or prerelease)' 7 | default: 'release' 8 | type: string 9 | push: 10 | branches: [ 'master' ] 11 | 12 | jobs: 13 | build: 14 | uses: ./.github/workflows/ci.yml 15 | release: 16 | if: | 17 | github.event_name == 'workflow_dispatch' || 18 | (github.event_name == 'push' && 19 | (startsWith(github.event.head_commit.message, 'release:') || 20 | startsWith(github.event.head_commit.message, 'prerelease:'))) 21 | runs-on: ubuntu-latest 22 | needs: [build] 23 | permissions: 24 | contents: write 25 | id-token: write 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | # Setup .npmrc file to publish to npm 31 | - uses: actions/setup-node@v4 32 | with: 33 | node-version: '22.x' 34 | registry-url: 'https://registry.npmjs.org' 35 | # Install dependencies without modifying package-lock.json file 36 | - name: Install deps 37 | run: npm ci 38 | 39 | - name: Build 40 | run: npm run build 41 | 42 | - name: Publish 43 | if: ${{ (inputs.release_type == 'release') || (github.event.head_commit.message && startsWith(github.event.head_commit.message, 'release:')) }} 44 | run: npm publish ./dist 45 | env: 46 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 47 | NPM_CONFIG_PROVENANCE: true 48 | 49 | - name: Publish Next 50 | if: ${{ (inputs.release_type == 'prerelease') || (github.event.head_commit.message && startsWith(github.event.head_commit.message, 'prerelease:')) }} 51 | run: npm publish ./dist --tag next 52 | env: 53 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 54 | NPM_CONFIG_PROVENANCE: true 55 | 56 | - name: Tag release commit 57 | run: | 58 | TAG_NAME="v$(node -p "require('./package.json').version")" 59 | git config user.name "github-actions[bot]" 60 | git config user.email "github-actions[bot]@users.noreply.github.com" 61 | 62 | if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then 63 | echo "Tag $TAG_NAME already exists. Skipping." 64 | else 65 | git tag "$TAG_NAME" 66 | git push origin "$TAG_NAME" 67 | fi 68 | -------------------------------------------------------------------------------- /tests/utils/fs-helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path'; 2 | 3 | import { afterEach, beforeEach, describe, it, expect, vi, MockInstance } from 'vitest'; 4 | 5 | import { expandPattern, normalizeFilePath } from '../../src/utils/fs-helpers'; 6 | 7 | vi.mock('node:path', async (importOriginal) => ({ 8 | ...(await importOriginal()), 9 | sep: '/' 10 | })); 11 | 12 | describe('normalizeFilePath', () => { 13 | let processCwdMock: MockInstance<() => string>; 14 | 15 | beforeEach(() => { 16 | processCwdMock = vi.spyOn(process, 'cwd').mockReturnValue('/home/user/project'); 17 | }); 18 | 19 | afterEach(() => { 20 | vi.restoreAllMocks(); 21 | }); 22 | 23 | it('should replace the cwd with its base name and convert to POSIX separators', () => { 24 | expect(normalizeFilePath('/home/user/project/src/file.ts')).toBe('project/src/file.ts'); 25 | }); 26 | 27 | it('should handle paths without the cwd correctly', () => { 28 | expect(normalizeFilePath('/another/path/src/file.ts')).toBe('/another/path/src/file.ts'); 29 | }); 30 | 31 | it('should handle Windows-style paths correctly', () => { 32 | processCwdMock.mockReturnValue('C:\\Users\\User\\project'); 33 | // The path.basename method in Node.js is platform-aware which means that when it's called on 34 | // Linux, path.basename may interpret C:\\Users\\User\\project as a full path rather than just 35 | // a directory. 36 | vi.spyOn(path, 'basename').mockImplementation((path: string) => path.split('\\').pop() ?? ''); 37 | vi.spyOn(path, 'sep', 'get').mockReturnValue('\\'); 38 | expect(normalizeFilePath('C:\\Users\\User\\project\\src\\file.ts')).toBe('project/src/file.ts'); 39 | }); 40 | 41 | it('should return the base name of the cwd for cwd itself', () => { 42 | expect(normalizeFilePath('/home/user/project')).toBe('project'); 43 | }); 44 | }); 45 | 46 | describe('expandPattern', () => { 47 | it('should expand a simple pattern with default separator', () => { 48 | const result = expandPattern('dir/{en,fr,de}.json'); 49 | expect(result).toEqual(['dir/en.json', 'dir/fr.json', 'dir/de.json']); 50 | }); 51 | 52 | it('should expand a pattern with Windows-style separator', () => { 53 | vi.spyOn(path, 'sep', 'get').mockReturnValue('\\'); 54 | const result = expandPattern('C:\\Users\\User\\dir\\{en,fr,de}.json'); 55 | expect(result).toEqual([ 56 | 'C:\\Users\\User\\dir\\en.json', 57 | 'C:\\Users\\User\\dir\\fr.json', 58 | 'C:\\Users\\User\\dir\\de.json', 59 | ]); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /tests/compilers/po.compiler.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, it } from 'vitest'; 2 | 3 | import { TranslationCollection } from '../../src/utils/translation.collection.js'; 4 | import { PoCompiler } from '../../src/compilers/po.compiler.js'; 5 | 6 | describe('PoCompiler', () => { 7 | let compiler: PoCompiler; 8 | 9 | beforeEach(() => { 10 | compiler = new PoCompiler(); 11 | }); 12 | 13 | it('should still include html ', () => { 14 | const collection = new TranslationCollection({ 15 | 'A test': {value: 'Un test', sourceFiles: ['path/to/file.ts', 'path/to/other/file.ts']}, 16 | 'With a lot of html included': {value: 'Avec beaucoup d\'html inclus', sourceFiles: ['path/to/file.ts']} 17 | }); 18 | const result: Buffer = Buffer.from(compiler.compile(collection)); 19 | expect(result.toString('utf8')).to.equal( 20 | 'msgid ""\n' 21 | + 'msgstr ""\n"' 22 | + 'mime-version: 1.0\\n"\n"' 23 | + 'Content-Type: text/plain; charset=utf-8\\n"\n"' 24 | + 'Content-Transfer-Encoding: 8bit\\n"\n\n' 25 | + '#: path/to/file.ts\n' 26 | + '#: path/to/other/file.ts\n' 27 | + 'msgid "A test"\n' 28 | + 'msgstr "Un test"\n\n' 29 | + '#: path/to/file.ts\n' 30 | + 'msgid "With a lot of html included"\n' 31 | + 'msgstr "Avec beaucoup d\'html inclus"\n' 32 | ); 33 | }); 34 | 35 | it('should parse po content', () => { 36 | const poTemplate = ` 37 | msgid "" 38 | msgstr "" 39 | "Project-Id-Version: MyApp 1.0\n" 40 | "Report-Msgid-Bugs-To: \n" 41 | "POT-Creation-Date: 2024-07-28 12:34+0000\n" 42 | "PO-Revision-Date: 2024-07-28 12:34+0000\n" 43 | "Last-Translator: Jane Doe \n" 44 | "Language-Team: English \n" 45 | "Language: de\n" 46 | "MIME-Version: 1.0\n" 47 | "Content-Type: text/plain; charset=UTF-8\n" 48 | "Content-Transfer-Encoding: 8bit\n" 49 | 50 | #: src/main.ts:14 51 | msgid "Hello, World!" 52 | msgstr "Hallo, Welt!" 53 | 54 | #: src/main.ts:26 55 | msgid "Submit" 56 | msgstr "Absenden" 57 | 58 | #: src/main.ts:30 59 | msgid "Cancel" 60 | msgstr "Abbrechen" 61 | `; 62 | 63 | expect(compiler.parse(poTemplate)).toMatchObject({ 64 | "values": { 65 | "Cancel": { 66 | "sourceFiles": [ 67 | "src/main.ts:30", 68 | ], 69 | "value": "Abbrechen", 70 | }, 71 | "Hello, World!": { 72 | "sourceFiles": [ 73 | "src/main.ts:14", 74 | ], 75 | "value": "Hallo, Welt!", 76 | }, 77 | "Submit": { 78 | "sourceFiles": [ 79 | "src/main.ts:26", 80 | ], 81 | "value": "Absenden", 82 | }, 83 | }, 84 | }) 85 | }) 86 | }); 87 | -------------------------------------------------------------------------------- /src/cache/file-cache.ts: -------------------------------------------------------------------------------- 1 | import type { CacheInterface } from './cache-interface.js'; 2 | import crypto from 'node:crypto'; 3 | import fs from 'node:fs'; 4 | import path from 'node:path'; 5 | import { fileURLToPath } from 'node:url'; 6 | 7 | const getHash = (value: string) => crypto.createHash('sha256').update(value).digest('hex'); 8 | 9 | export class FileCache implements CacheInterface { 10 | private tapped: Record = {}; 11 | private cached?: Readonly> = undefined; 12 | private originalCache?: string; 13 | private versionHash?: string; 14 | 15 | constructor(private cacheFile: string) {} 16 | 17 | public get(uniqueContents: KEY, generator: () => RESULT): RESULT { 18 | if (!this.cached) { 19 | this.readCache(); 20 | this.versionHash = this.getVersionHash(); 21 | } 22 | 23 | const key = getHash(`${this.versionHash}${uniqueContents}`); 24 | 25 | if (key in this.cached) { 26 | this.tapped[key] = this.cached[key]; 27 | 28 | return this.cached[key]; 29 | } 30 | 31 | return (this.tapped[key] = generator()); 32 | } 33 | 34 | public persist(): void { 35 | const newCache = JSON.stringify(this.sortByKey(this.tapped), null, 2); 36 | if (newCache === this.originalCache) { 37 | return; 38 | } 39 | 40 | const file = this.getCacheFile(); 41 | const dir = path.dirname(file); 42 | 43 | const stats = fs.statSync(dir, { throwIfNoEntry: false }); 44 | if (!stats) { 45 | fs.mkdirSync(dir); 46 | } 47 | 48 | const tmpFile = `${file}~${getHash(newCache)}`; 49 | 50 | fs.writeFileSync(tmpFile, newCache, { encoding: 'utf-8' }); 51 | fs.rmSync(file, { force: true, recursive: false }); 52 | fs.renameSync(tmpFile, file); 53 | } 54 | 55 | private sortByKey(unordered: Record): Record { 56 | return Object.keys(unordered) 57 | .sort() 58 | .reduce((obj, key) => { 59 | obj[key] = unordered[key]; 60 | return obj; 61 | }, {} as Record); 62 | } 63 | 64 | private readCache(): void { 65 | try { 66 | this.originalCache = fs.readFileSync(this.getCacheFile(), { encoding: 'utf-8' }); 67 | this.cached = JSON.parse(this.originalCache) ?? {}; 68 | } catch { 69 | this.originalCache = undefined; 70 | this.cached = {}; 71 | } 72 | } 73 | 74 | private getVersionHash(): string { 75 | const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); 76 | const packageJson = fs.readFileSync(path.join(projectRoot, 'package.json'), { encoding: 'utf-8' }); 77 | 78 | return getHash(packageJson); 79 | } 80 | 81 | private getCacheFile(): string { 82 | return `${this.cacheFile}-ngx-translate-extract-cache.json`; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/parsers/function.parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, it } from 'vitest'; 2 | 3 | import { FunctionParser } from '../../src/parsers/function.parser.js'; 4 | 5 | describe('FunctionParser', () => { 6 | const componentFilename: string = 'test.component.ts'; 7 | 8 | let parser: FunctionParser; 9 | 10 | beforeEach(() => { 11 | parser = new FunctionParser('MK'); 12 | }); 13 | 14 | it('should extract strings using marker function', () => { 15 | const contents = ` 16 | MK('Hello world'); 17 | MK(['I', 'am', 'extracted']); 18 | otherFunction('But I am not'); 19 | MK(message || 'binary expression'); 20 | MK(message ? message : 'conditional operator'); 21 | MK('FOO.bar'); 22 | `; 23 | const keys = parser.extract(contents, componentFilename).keys(); 24 | expect(keys).to.deep.equal(['Hello world', 'I', 'am', 'extracted', 'binary expression', 'conditional operator', 'FOO.bar']); 25 | }); 26 | 27 | it('should extract split strings', () => { 28 | const contents = ` 29 | MK('Hello ' + 'world'); 30 | MK('This is a ' + 'very ' + 'very ' + 'very ' + 'very ' + 'long line.'); 31 | MK('Mix ' + \`of \` + 'different ' + \`types\`); 32 | `; 33 | const keys = parser.extract(contents, componentFilename).keys(); 34 | expect(keys).to.deep.equal(['Hello world', 'This is a very very very very long line.', 'Mix of different types']); 35 | }); 36 | 37 | it('should extract split strings while keeping html tags', () => { 38 | const contents = ` 39 | MK('Hello ' + 'world'); 40 | MK('This is a ' + 'very ' + 'very ' + 'very ' + 'very ' + 'long line.'); 41 | MK('Mix ' + \`of \` + 'different ' + \`types\`); 42 | `; 43 | const keys = parser.extract(contents, componentFilename).keys(); 44 | expect(keys).to.deep.equal(['Hello world', 'This is a very very very very long line.', 'Mix of different types']); 45 | }); 46 | 47 | it('should extract the strings', () => { 48 | const contents = ` 49 | 50 | export class AppModule { 51 | constructor() { 52 | MK('DYNAMIC_TRAD.val1'); 53 | MK('DYNAMIC_TRAD.val2'); 54 | } 55 | } 56 | `; 57 | const keys = parser.extract(contents, componentFilename).keys(); 58 | expect(keys).to.deep.equal(['DYNAMIC_TRAD.val1', 'DYNAMIC_TRAD.val2']); 59 | }); 60 | 61 | it('should not break after bracket syntax casting', () => { 62 | const contents = ` 63 | export class AppModule { 64 | constructor() { 65 | const input: unknown = 'hello'; 66 | const myNiceVar1 = input as string; 67 | MK('hello.after.as.syntax'); 68 | 69 | const myNiceVar2 = input; 70 | MK('hello.after.bracket.syntax'); 71 | } 72 | } 73 | `; 74 | const keys = parser.extract(contents, componentFilename).keys(); 75 | expect(keys).to.deep.equal([ 'hello.after.as.syntax', 'hello.after.bracket.syntax']); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /tests/cli/cli.spec.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'node:child_process'; 2 | import { access, readdir, readFile, rm } from 'node:fs/promises'; 3 | import { resolve, dirname } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { promisify } from 'node:util'; 6 | 7 | import { afterAll, beforeAll, describe, test } from 'vitest'; 8 | 9 | const execAsync = promisify(exec); 10 | const __dirname = dirname(fileURLToPath(import.meta.url)); 11 | 12 | const REPOSITORY_ROOT = resolve(__dirname, '../..'); 13 | const FIXTURES_PATH = resolve(REPOSITORY_ROOT, 'tests/cli/fixtures/'); 14 | const TMP_PATH = resolve(REPOSITORY_ROOT, 'tests/cli/tmp'); 15 | const CLI_PATH = resolve(TMP_PATH, 'dist/cli.js'); 16 | 17 | let nextFileId = 0; 18 | const createUniqueFileName = (fileName: string) => resolve(TMP_PATH, `${nextFileId++}-${fileName}`); 19 | 20 | describe.concurrent('CLI Integration Tests', () => { 21 | beforeAll(async () => { 22 | try { 23 | await execAsync(`npm run build:dev -- --outDir ${TMP_PATH}/dist`); 24 | } catch (err) { 25 | console.error('Error during build in beforeAll:', err); 26 | throw err; 27 | } 28 | }); 29 | 30 | afterAll(async () => { 31 | await rm(TMP_PATH, { recursive: true }); 32 | }); 33 | 34 | test('shows the version', async ({expect}) => { 35 | const packageJson = JSON.parse(await readFile(resolve(REPOSITORY_ROOT, 'package.json'), 'utf8')); 36 | const { stdout } = await execAsync(`node ${CLI_PATH} --version`); 37 | 38 | expect(stdout.trim()).toBe(packageJson.version); 39 | }) 40 | 41 | test('shows the expected output when extracting', async ({expect}) => { 42 | const OUTPUT_FILE = createUniqueFileName('strings.json'); 43 | const fixtureFiles = await readdir(FIXTURES_PATH); 44 | const { stdout } = await execAsync(`node ${CLI_PATH} --input ${FIXTURES_PATH} --output ${OUTPUT_FILE} --format=json`); 45 | 46 | expect(stdout).toContain('Extracting:'); 47 | fixtureFiles.forEach(file => expect(stdout).toContain(file)); 48 | 49 | expect(stdout).toContain('Found 15 strings.'); 50 | expect(stdout).toContain('Saving:'); 51 | 52 | expect(stdout).toContain(OUTPUT_FILE); 53 | expect(stdout).toContain('Done.'); 54 | }) 55 | 56 | test('extracts translation keys to a .json file', async ({ expect }) => { 57 | const OUTPUT_FILE = createUniqueFileName('strings.json'); 58 | await execAsync(`node ${CLI_PATH} --input ${FIXTURES_PATH} --output ${OUTPUT_FILE} --format=json`); 59 | 60 | const extracted = await readFile(OUTPUT_FILE, { encoding: 'utf8' }); 61 | 62 | expect(extracted).toMatchSnapshot(); 63 | }); 64 | 65 | test('extracts translation keys to a .json file with namespaced-json format', async ({ expect }) => { 66 | const OUTPUT_FILE = createUniqueFileName('strings.json'); 67 | await execAsync(`node ${CLI_PATH} --input ${FIXTURES_PATH} --output ${OUTPUT_FILE} --format=namespaced-json`); 68 | 69 | const extracted = await readFile(OUTPUT_FILE, { encoding: 'utf8' }); 70 | 71 | expect(extracted).toMatchSnapshot(); 72 | }); 73 | 74 | test('extracts translation keys to multiple files', async ({ expect, task }) => { 75 | const OUTPUT_PATH = resolve(TMP_PATH, task.id); 76 | const OUTPUT_PATTERN = resolve(OUTPUT_PATH, '{en,fr}.json'); 77 | const EXPECTED_EN_FILE = resolve(OUTPUT_PATH, 'en.json'); 78 | const EXPECTED_FR_FILE = resolve(OUTPUT_PATH, 'fr.json'); 79 | 80 | await execAsync(`node ${CLI_PATH} --input ${FIXTURES_PATH} --output ${OUTPUT_PATTERN} --format=json`); 81 | 82 | await expect(Promise.all([access(EXPECTED_EN_FILE), access(EXPECTED_FR_FILE)])).resolves.not.toThrow(); 83 | }); 84 | 85 | test('extracts translation keys to a .po file', async ({ expect }) => { 86 | const OUTPUT_FILE = createUniqueFileName('strings.po'); 87 | await execAsync(`node ${CLI_PATH} --input ${FIXTURES_PATH} --output ${OUTPUT_FILE} --format=pot`); 88 | 89 | const extracted = await readFile(OUTPUT_FILE, { encoding: 'utf8' }); 90 | 91 | expect(extracted).toMatchSnapshot(); 92 | }); 93 | 94 | test('extracts translation keys to a .po file without file location comments', async ({ expect }) => { 95 | const OUTPUT_FILE = createUniqueFileName('strings.po'); 96 | await execAsync(`node ${CLI_PATH} --input ${FIXTURES_PATH} --output ${OUTPUT_FILE} --format=pot --no-po-source-locations`); 97 | 98 | const extracted = await readFile(OUTPUT_FILE, { encoding: 'utf8' }); 99 | 100 | expect(extracted).toMatchSnapshot(); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/utils/translation.collection.ts: -------------------------------------------------------------------------------- 1 | import { normalizeFilePath } from './fs-helpers.js'; 2 | 3 | export interface TranslationType { 4 | [key: string]: TranslationInterface; 5 | } 6 | 7 | export interface TranslationInterface { 8 | value: string; 9 | sourceFiles: string[]; 10 | } 11 | 12 | export class TranslationCollection { 13 | public values: TranslationType = {}; 14 | 15 | public constructor(values: TranslationType = {}) { 16 | this.values = values; 17 | } 18 | 19 | public add(key: string, val: string, sourceFile: string): TranslationCollection { 20 | const translation = this.values[key] 21 | ? {...this.values[key]} 22 | : {value: val, sourceFiles: []}; 23 | translation.sourceFiles.push(normalizeFilePath(sourceFile)); 24 | return new TranslationCollection({...this.values, [key]: translation}); 25 | } 26 | 27 | public addKeys(keys: string[], sourceFile: string): TranslationCollection { 28 | const values = keys.reduce( 29 | (results, key) => ({ 30 | ...results, 31 | [key]: {value: '', sourceFiles: [normalizeFilePath(sourceFile)]} 32 | }), 33 | {} as TranslationType 34 | ); 35 | return new TranslationCollection({...this.values, ...values}); 36 | } 37 | 38 | public remove(key: string): TranslationCollection { 39 | return this.filter((k) => key !== k); 40 | } 41 | 42 | public forEach(callback: (key?: string, val?: TranslationInterface) => void): TranslationCollection { 43 | Object.keys(this.values).forEach((key) => callback.call(this, key, this.values[key])); 44 | return this; 45 | } 46 | 47 | public filter(callback: (key?: string, val?: TranslationInterface) => boolean): TranslationCollection { 48 | const values: TranslationType = {}; 49 | this.forEach((key, val) => { 50 | if (callback.call(this, key, val)) { 51 | values[key] = val; 52 | } 53 | }); 54 | return new TranslationCollection(values); 55 | } 56 | 57 | public map(callback: (key?: string, val?: TranslationInterface) => TranslationInterface): TranslationCollection { 58 | const values: TranslationType = {}; 59 | this.forEach((key, val) => { 60 | values[key] = callback.call(this, key, val); 61 | }); 62 | return new TranslationCollection(values); 63 | } 64 | 65 | public union(collection: TranslationCollection): TranslationCollection { 66 | return new TranslationCollection({ ...this.values, ...collection.values }); 67 | } 68 | 69 | public intersect(collection: TranslationCollection): TranslationCollection { 70 | const values: TranslationType = {}; 71 | this.filter((key) => collection.has(key)).forEach((key, val) => { 72 | values[key] = val; 73 | }); 74 | 75 | return new TranslationCollection(values); 76 | } 77 | 78 | public has(key: string): boolean { 79 | return Object.hasOwn(this.values, key); 80 | } 81 | 82 | public get(key: string): TranslationInterface { 83 | return this.values[key]; 84 | } 85 | 86 | public keys(): string[] { 87 | return Object.keys(this.values); 88 | } 89 | 90 | public count(): number { 91 | return Object.keys(this.values).length; 92 | } 93 | 94 | public isEmpty(): boolean { 95 | return Object.keys(this.values).length === 0; 96 | } 97 | 98 | public sort(compareFn?: (a: string, b: string) => number): TranslationCollection { 99 | const values: TranslationType = {}; 100 | this.keys() 101 | .sort(compareFn) 102 | .forEach((key) => { 103 | values[key] = this.get(key); 104 | }); 105 | 106 | return new TranslationCollection(values); 107 | } 108 | 109 | public toKeyValueObject(): {[key: string]: string} { 110 | const jsonTranslations: {[key: string]: string} = {}; 111 | Object.entries(this.values).map(([key, value]: [string, TranslationInterface]) => jsonTranslations[key] = value.value); 112 | return jsonTranslations; 113 | } 114 | 115 | public stripKeyPrefix(prefix: string): TranslationCollection { 116 | const cleanedValues: TranslationType = {}; 117 | const lowercasePrefix = prefix.toLowerCase(); 118 | for (const key in this.values) { 119 | if (this.has(key)) { 120 | const lowercaseKey = key.toLowerCase(); 121 | if (lowercaseKey.startsWith(lowercasePrefix)) { 122 | const cleanedKey = key.substring(prefix.length); 123 | cleanedValues[cleanedKey] = this.values[key]; 124 | } else { 125 | cleanedValues[key] = this.values[key]; 126 | } 127 | } 128 | } 129 | 130 | return new TranslationCollection(cleanedValues); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /tests/compilers/namespaced-json.compiler.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, it } from 'vitest'; 2 | 3 | import { TranslationCollection } from '../../src/utils/translation.collection.js'; 4 | import { NamespacedJsonCompiler } from '../../src/compilers/namespaced-json.compiler.js'; 5 | 6 | describe('NamespacedJsonCompiler', () => { 7 | let compiler: NamespacedJsonCompiler; 8 | 9 | beforeEach(() => { 10 | compiler = new NamespacedJsonCompiler(); 11 | }); 12 | 13 | it('should parse to a translation interface', () => { 14 | const contents = ` 15 | { 16 | "key": "value", 17 | "secondKey": "" 18 | } 19 | `; 20 | const collection: TranslationCollection = compiler.parse(contents); 21 | expect(collection.values).to.deep.equal({ 22 | 'key': {value: 'value', sourceFiles: []}, 23 | 'secondKey': {value: '', sourceFiles: []} 24 | }); 25 | }); 26 | 27 | it('should flatten keys on parse', () => { 28 | const contents = ` 29 | { 30 | "NAMESPACE": { 31 | "KEY": { 32 | "FIRST_KEY": "", 33 | "SECOND_KEY": "VALUE" 34 | } 35 | } 36 | } 37 | `; 38 | const collection: TranslationCollection = compiler.parse(contents); 39 | expect(collection.values).to.deep.equal({ 40 | 'NAMESPACE.KEY.FIRST_KEY': {value: '', sourceFiles: []}, 41 | 'NAMESPACE.KEY.SECOND_KEY': {value: 'VALUE', sourceFiles: []} 42 | }); 43 | }); 44 | 45 | it('should unflatten keys on compile', () => { 46 | const collection = new TranslationCollection({ 47 | 'NAMESPACE.KEY.FIRST_KEY': {value: '', sourceFiles: []}, 48 | 'NAMESPACE.KEY.SECOND_KEY': {value: 'VALUE', sourceFiles: ['path/to/file.ts']} 49 | }); 50 | const result: string = compiler.compile(collection); 51 | expect(result).to.equal('{\n\t"NAMESPACE": {\n\t\t"KEY": {\n\t\t\t"FIRST_KEY": "",\n\t\t\t"SECOND_KEY": "VALUE"\n\t\t}\n\t}\n}'); 52 | }); 53 | 54 | it('should correctly unflatten when base key has empty value and nested keys exist', () => { 55 | const collection = new TranslationCollection({ 56 | NAMESPACE: { value: '', sourceFiles: [] }, 57 | 'NAMESPACE.FIRST_KEY': { value: 'FIRST_KEY_VALUE', sourceFiles: [] }, 58 | 'NAMESPACE.SECOND_KEY': { value: 'SECOND_KEY_VALUE', sourceFiles: [] }, 59 | }); 60 | const result: string = compiler.compile(collection); 61 | expect(result).to.equal( 62 | // prettier-ignore 63 | '{\n' + 64 | '\t"NAMESPACE": {\n' + 65 | '\t\t"FIRST_KEY": "FIRST_KEY_VALUE",\n' + 66 | '\t\t"SECOND_KEY": "SECOND_KEY_VALUE"\n' + 67 | '\t}\n' + 68 | '}', 69 | ); 70 | }); 71 | 72 | it('should NOT overwrite existing entries', () => { 73 | const translations = JSON.stringify({ 74 | NAMESPACE: { 75 | FIRST_KEY: 'FIRST_KEY_VALUE', 76 | SECOND_KEY: 'SECOND_KEY_VALUE', 77 | }, 78 | }); 79 | const newTranslations = JSON.stringify({ 80 | NAMESPACE: '', 81 | }); 82 | const existing = compiler.parse(translations); 83 | const extracted = compiler.parse(newTranslations); 84 | 85 | const combined = extracted.union(existing); 86 | const result = compiler.compile(combined); 87 | expect(result).to.equal( 88 | // prettier-ignore 89 | '{\n' + 90 | '\t"NAMESPACE": {\n' + 91 | '\t\t"FIRST_KEY": "FIRST_KEY_VALUE",\n' + 92 | '\t\t"SECOND_KEY": "SECOND_KEY_VALUE"\n' + 93 | '\t}\n' + 94 | '}', 95 | ); 96 | }); 97 | 98 | it('should preserve numeric values on compile', () => { 99 | const collection = new TranslationCollection({ 100 | 'option.0': {value: '', sourceFiles: []}, 101 | 'option.1': {value: '', sourceFiles: []}, 102 | 'option.2': {value: '', sourceFiles: []} 103 | }); 104 | const result: string = compiler.compile(collection); 105 | expect(result).to.equal('{\n\t"option": {\n\t\t"0": "",\n\t\t"1": "",\n\t\t"2": ""\n\t}\n}'); 106 | }); 107 | 108 | it('should use custom indentation chars', () => { 109 | const collection = new TranslationCollection({ 110 | 'NAMESPACE.KEY.FIRST_KEY': {value: '', sourceFiles: []}, 111 | 'NAMESPACE.KEY.SECOND_KEY': {value: 'VALUE', sourceFiles: ['path/to/file.ts']} 112 | }); 113 | const customCompiler = new NamespacedJsonCompiler({ 114 | indentation: ' ' 115 | }); 116 | const result: string = customCompiler.compile(collection); 117 | expect(result).to.equal('{\n "NAMESPACE": {\n "KEY": {\n "FIRST_KEY": "",\n "SECOND_KEY": "VALUE"\n }\n }\n}'); 118 | }); 119 | 120 | it('should not reorder keys when compiled', () => { 121 | const collection = new TranslationCollection({ 122 | BROWSE: {value: '', sourceFiles: []}, 123 | LOGIN: {value: '', sourceFiles: []} 124 | }); 125 | const result: string = compiler.compile(collection); 126 | expect(result).to.equal('{\n\t"BROWSE": "",\n\t"LOGIN": ""\n}'); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /.oxlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/oxlint/configuration_schema.json", 3 | "plugins": [ 4 | "typescript" 5 | ], 6 | "categories": { 7 | "correctness": "error", 8 | "perf": "error" 9 | }, 10 | "env": { 11 | "node": true 12 | }, 13 | "ignorePatterns": [ 14 | "**/fixtures/**" 15 | ], 16 | "rules": { 17 | "for-direction": "error", 18 | "getter-return": "error", 19 | "no-async-promise-executor": "error", 20 | "no-case-declarations": "error", 21 | "no-class-assign": "error", 22 | "no-compare-neg-zero": "error", 23 | "no-cond-assign": "error", 24 | "no-const-assign": "error", 25 | "no-constant-binary-expression": "error", 26 | "no-constant-condition": "error", 27 | "no-control-regex": "error", 28 | "no-debugger": "error", 29 | "no-delete-var": "error", 30 | "no-dupe-class-members": "error", 31 | "no-dupe-else-if": "error", 32 | "no-dupe-keys": "error", 33 | "no-duplicate-case": "error", 34 | "no-empty": "error", 35 | "no-empty-character-class": "error", 36 | "no-empty-pattern": "error", 37 | "no-empty-static-block": "error", 38 | "no-ex-assign": "error", 39 | "no-extra-boolean-cast": "error", 40 | "no-fallthrough": "error", 41 | "no-func-assign": "error", 42 | "no-global-assign": "error", 43 | "no-import-assign": "error", 44 | "no-invalid-regexp": "error", 45 | "no-irregular-whitespace": "error", 46 | "no-loss-of-precision": "error", 47 | "no-new-native-nonconstructor": "error", 48 | "no-nonoctal-decimal-escape": "error", 49 | "no-obj-calls": "error", 50 | "no-prototype-builtins": "error", 51 | "no-redeclare": "error", 52 | "no-regex-spaces": "error", 53 | "no-self-assign": "error", 54 | "no-setter-return": "error", 55 | "no-shadow-restricted-names": "error", 56 | "no-sparse-arrays": "error", 57 | "no-this-before-super": "error", 58 | "no-undef": "error", 59 | "no-unexpected-multiline": "error", 60 | "no-unreachable": "error", 61 | "no-unsafe-finally": "error", 62 | "no-unsafe-negation": "error", 63 | "no-unsafe-optional-chaining": "error", 64 | "no-unused-labels": "error", 65 | "no-unused-private-class-members": "error", 66 | "no-unused-vars": "error", 67 | "no-useless-backreference": "error", 68 | "no-useless-catch": "error", 69 | "no-useless-escape": "error", 70 | "no-with": "error", 71 | "no-var": "error", 72 | "no-unused-expressions": "error", 73 | "no-array-constructor": "error", 74 | "require-yield": "error", 75 | "use-isnan": "error", 76 | "valid-typeof": "error", 77 | "prefer-rest-params": "error", 78 | "prefer-spread": "error", 79 | "typescript/ban-ts-comment": "error", 80 | "typescript/no-duplicate-enum-values": "error", 81 | "typescript/no-empty-object-type": "error", 82 | "typescript/no-explicit-any": "error", 83 | "typescript/no-extra-non-null-assertion": "error", 84 | "typescript/no-misused-new": "error", 85 | "typescript/no-namespace": "error", 86 | "typescript/no-non-null-asserted-optional-chain": "error", 87 | "typescript/no-require-imports": "error", 88 | "typescript/no-this-alias": "error", 89 | "typescript/no-unnecessary-type-constraint": "error", 90 | "typescript/no-unsafe-declaration-merging": "error", 91 | "typescript/no-unsafe-function-type": "error", 92 | "typescript/no-wrapper-object-types": "error", 93 | "typescript/prefer-as-const": "error", 94 | "typescript/prefer-namespace-keyword": "error", 95 | "typescript/triple-slash-reference": "error" 96 | }, 97 | "overrides": [ 98 | { 99 | "files": [ 100 | "**/*.ts", 101 | "**/*.tsx", 102 | "**/*.mts", 103 | "**/*.cts" 104 | ], 105 | "rules": { 106 | "getter-return": "off", 107 | "no-class-assign": "off", 108 | "no-const-assign": "off", 109 | "no-dupe-class-members": "off", 110 | "no-dupe-keys": "off", 111 | "no-func-assign": "off", 112 | "no-import-assign": "off", 113 | "no-new-native-nonconstructor": "off", 114 | "no-obj-calls": "off", 115 | "no-redeclare": "off", 116 | "no-setter-return": "off", 117 | "no-this-before-super": "off", 118 | "no-undef": "off", 119 | "no-unreachable": "off", 120 | "no-unsafe-negation": "off", 121 | "no-with": "off" 122 | } 123 | }, 124 | { 125 | "files": ["tests/**"], 126 | "plugins": ["vitest"], 127 | "env": { 128 | "vitest": true 129 | }, 130 | "categories": { 131 | "style": "off", 132 | "pedantic": "off" 133 | }, 134 | "rules": { 135 | "vitest/no-disabled-tests": "error", 136 | "no-unused-vars": "off" 137 | } 138 | } 139 | ] 140 | } 141 | -------------------------------------------------------------------------------- /tests/utils/translation.collection.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, it } from 'vitest'; 2 | 3 | import { TranslationCollection } from '../../src/utils/translation.collection.js'; 4 | 5 | describe('StringCollection', () => { 6 | let collection: TranslationCollection; 7 | 8 | beforeEach(() => { 9 | collection = new TranslationCollection(); 10 | }); 11 | 12 | it('should initialize with key/value pairs', () => { 13 | collection = new TranslationCollection({ key1: {value: 'val1', sourceFiles: []}, key2: {value: 'val2', sourceFiles: []} }); 14 | expect(collection.values).to.deep.equal({ key1: {value: 'val1', sourceFiles: []}, key2: {value: 'val2', sourceFiles: []} }); 15 | }); 16 | 17 | it('should add key with value', () => { 18 | const newCollection = collection.add('theKey', 'theVal', 'path/to/file.ts'); 19 | expect(newCollection.get('theKey')).to.deep.equal({value: 'theVal', sourceFiles: ['path/to/file.ts']}); 20 | }); 21 | 22 | it('should add second source file on duplicate key', () => { 23 | const newCollection = collection.add('theKey', 'theVal', 'path/to/file.ts'); 24 | expect(newCollection.get('theKey')).to.deep.equal({value: 'theVal', sourceFiles: ['path/to/file.ts']}); 25 | 26 | const updatedCollection = newCollection.add('theKey', 'theVal', 'path/to/another-file.ts'); 27 | expect(updatedCollection.get('theKey')).to.deep.equal({value: 'theVal', sourceFiles: ['path/to/file.ts', 'path/to/another-file.ts']}); 28 | }); 29 | 30 | it('should add key with default value', () => { 31 | collection = collection.add('theKey', '', 'path/to/file.ts'); 32 | expect(collection.get('theKey')).to.deep.equal({value: '', sourceFiles: ['path/to/file.ts']}); 33 | }); 34 | 35 | it('should not mutate collection when adding key', () => { 36 | collection.add('theKey', 'theVal', 'path/to/file.ts'); 37 | expect(collection.has('theKey')).to.equal(false); 38 | }); 39 | 40 | it('should add array of keys with default value', () => { 41 | collection = collection.addKeys(['key1', 'key2'], 'path/to/some-file.ts'); 42 | expect(collection.values).to.deep.equal({ key1: {value: '', sourceFiles: ['path/to/some-file.ts']}, key2: {value: '', sourceFiles: ['path/to/some-file.ts']} }); 43 | }); 44 | 45 | it('should return true when collection has key', () => { 46 | collection = collection.add('key', '', ''); 47 | expect(collection.has('key')).to.equal(true); 48 | }); 49 | 50 | it('should return false when collection does not have key', () => { 51 | expect(collection.has('key')).to.equal(false); 52 | }); 53 | 54 | it('should remove key', () => { 55 | collection = new TranslationCollection({ removeThisKey: {value: '', sourceFiles: []} }); 56 | collection = collection.remove('removeThisKey'); 57 | expect(collection.has('removeThisKey')).to.equal(false); 58 | }); 59 | 60 | it('should not mutate collection when removing key', () => { 61 | collection = new TranslationCollection({ removeThisKey: {value: '', sourceFiles: []} }); 62 | collection.remove('removeThisKey'); 63 | expect(collection.has('removeThisKey')).to.equal(true); 64 | }); 65 | 66 | it('should return number of keys', () => { 67 | collection = collection.addKeys(['key1', 'key2', 'key3'], 'some/path.html'); 68 | expect(collection.count()).to.equal(3); 69 | }); 70 | 71 | it('should merge with other collection', () => { 72 | collection = collection.add('oldKey', 'oldVal', ''); 73 | const newCollection = new TranslationCollection({ newKey: {value: 'newVal', sourceFiles: ['']} }); 74 | expect(collection.union(newCollection).values).to.deep.equal({ 75 | oldKey: {value: 'oldVal', sourceFiles: ['']}, 76 | newKey: {value: 'newVal', sourceFiles: ['']} 77 | }); 78 | }); 79 | 80 | it('should intersect with passed collection', () => { 81 | collection = collection.addKeys(['red', 'green', 'blue'], ''); 82 | const newCollection = new TranslationCollection({ red: {value: '', sourceFiles: ['']}, blue: {value: '', sourceFiles: ['']} }); 83 | expect(collection.intersect(newCollection).values).to.deep.equal({ red: {value: '', sourceFiles: ['']}, blue: {value: '', sourceFiles: ['']} }); 84 | }); 85 | 86 | it('should intersect with passed collection and keep original values', () => { 87 | collection = new TranslationCollection({ red: {value: 'rød', sourceFiles: []}, green: {value: 'grøn', sourceFiles: []}, blue: {value: 'blå', sourceFiles: []} }); 88 | const newCollection = new TranslationCollection({ red: {value: 'no value', sourceFiles: []}, blue: {value: 'also no value', sourceFiles: []} }); 89 | expect(collection.intersect(newCollection).values).to.deep.equal({ red: {value: 'rød', sourceFiles: []}, blue: {value: 'blå', sourceFiles: []} }); 90 | }); 91 | 92 | it('should sort keys alphabetically', () => { 93 | collection = new TranslationCollection({ red: {value: 'rød', sourceFiles: []}, green: {value: 'grøn', sourceFiles: []}, blue: {value: 'blå', sourceFiles: []} }); 94 | collection = collection.sort(); 95 | expect(collection.keys()).deep.equal(['blue', 'green', 'red']); 96 | }); 97 | 98 | it('should map values', () => { 99 | collection = new TranslationCollection({ red: {value: 'rød', sourceFiles: []}, green: {value: 'grøn', sourceFiles: []}, blue: {value: 'blå', sourceFiles: []} }); 100 | collection = collection.map((key, val) => ({value: 'mapped value', sourceFiles: []})); 101 | expect(collection.values).to.deep.equal({ 102 | red: {value: 'mapped value', sourceFiles: []}, 103 | green: {value: 'mapped value', sourceFiles: []}, 104 | blue: {value: 'mapped value', sourceFiles: []} 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /tests/cli/__snapshots__/cli.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`CLI Integration Tests > extracts translation keys to a .json file 1`] = ` 4 | "{ 5 | "translate-service.comp.welcome": "", 6 | "translate-service.comp.description": "", 7 | "translate-service.comp.details": "", 8 | "translate-service.comp.show-more-label": "", 9 | "translate-service.comp.show-less-label": "", 10 | "pipe.comp.welcome": "", 11 | "pipe.comp.description": "", 12 | "directive.comp.welcome": "", 13 | "directive.comp.description": "", 14 | "private-translate-service.comp.welcome": "", 15 | "private-translate-service.comp.description": "", 16 | "ngx-translate.marker.dashboard.title": "", 17 | "ngx-translate.marker.dashboard.description": "", 18 | "marker.dashboard.title": "", 19 | "marker.dashboard.description": "" 20 | }" 21 | `; 22 | 23 | exports[`CLI Integration Tests > extracts translation keys to a .json file with namespaced-json format 1`] = ` 24 | "{ 25 | "translate-service": { 26 | "comp": { 27 | "welcome": "", 28 | "description": "", 29 | "details": "", 30 | "show-more-label": "", 31 | "show-less-label": "" 32 | } 33 | }, 34 | "pipe": { 35 | "comp": { 36 | "welcome": "", 37 | "description": "" 38 | } 39 | }, 40 | "directive": { 41 | "comp": { 42 | "welcome": "", 43 | "description": "" 44 | } 45 | }, 46 | "private-translate-service": { 47 | "comp": { 48 | "welcome": "", 49 | "description": "" 50 | } 51 | }, 52 | "ngx-translate": { 53 | "marker": { 54 | "dashboard": { 55 | "title": "", 56 | "description": "" 57 | } 58 | } 59 | }, 60 | "marker": { 61 | "dashboard": { 62 | "title": "", 63 | "description": "" 64 | } 65 | } 66 | }" 67 | `; 68 | 69 | exports[`CLI Integration Tests > extracts translation keys to a .po file 1`] = ` 70 | "msgid "" 71 | msgstr "" 72 | "mime-version: 1.0\\n" 73 | "Content-Type: text/plain; charset=utf-8\\n" 74 | "Content-Transfer-Encoding: 8bit\\n" 75 | 76 | #: ngx-translate-extract/tests/cli/fixtures/translate-service.component.fixture.ts 77 | msgid "translate-service.comp.welcome" 78 | msgstr "" 79 | 80 | #: ngx-translate-extract/tests/cli/fixtures/translate-service.component.fixture.ts 81 | msgid "translate-service.comp.description" 82 | msgstr "" 83 | 84 | #: ngx-translate-extract/tests/cli/fixtures/translate-service.component.fixture.ts 85 | msgid "translate-service.comp.details" 86 | msgstr "" 87 | 88 | #: ngx-translate-extract/tests/cli/fixtures/translate-service.component.fixture.ts 89 | msgid "translate-service.comp.show-more-label" 90 | msgstr "" 91 | 92 | #: ngx-translate-extract/tests/cli/fixtures/translate-service.component.fixture.ts 93 | msgid "translate-service.comp.show-less-label" 94 | msgstr "" 95 | 96 | #: ngx-translate-extract/tests/cli/fixtures/translate-pipe.component.fixture.ts 97 | msgid "pipe.comp.welcome" 98 | msgstr "" 99 | 100 | #: ngx-translate-extract/tests/cli/fixtures/translate-pipe.component.fixture.ts 101 | msgid "pipe.comp.description" 102 | msgstr "" 103 | 104 | #: ngx-translate-extract/tests/cli/fixtures/translate-directive.component.fixture.ts 105 | msgid "directive.comp.welcome" 106 | msgstr "" 107 | 108 | #: ngx-translate-extract/tests/cli/fixtures/translate-directive.component.fixture.ts 109 | msgid "directive.comp.description" 110 | msgstr "" 111 | 112 | #: ngx-translate-extract/tests/cli/fixtures/private-translate-service.component.fixture.ts 113 | msgid "private-translate-service.comp.welcome" 114 | msgstr "" 115 | 116 | #: ngx-translate-extract/tests/cli/fixtures/private-translate-service.component.fixture.ts 117 | msgid "private-translate-service.comp.description" 118 | msgstr "" 119 | 120 | #: ngx-translate-extract/tests/cli/fixtures/ngx-translate.marker.fixture.ts 121 | msgid "ngx-translate.marker.dashboard.title" 122 | msgstr "" 123 | 124 | #: ngx-translate-extract/tests/cli/fixtures/ngx-translate.marker.fixture.ts 125 | msgid "ngx-translate.marker.dashboard.description" 126 | msgstr "" 127 | 128 | #: ngx-translate-extract/tests/cli/fixtures/marker.fixture.ts 129 | msgid "marker.dashboard.title" 130 | msgstr "" 131 | 132 | #: ngx-translate-extract/tests/cli/fixtures/marker.fixture.ts 133 | msgid "marker.dashboard.description" 134 | msgstr "" 135 | " 136 | `; 137 | 138 | exports[`CLI Integration Tests > extracts translation keys to a .po file without file location comments 1`] = ` 139 | "msgid "" 140 | msgstr "" 141 | "mime-version: 1.0\\n" 142 | "Content-Type: text/plain; charset=utf-8\\n" 143 | "Content-Transfer-Encoding: 8bit\\n" 144 | 145 | msgid "translate-service.comp.welcome" 146 | msgstr "" 147 | 148 | msgid "translate-service.comp.description" 149 | msgstr "" 150 | 151 | msgid "translate-service.comp.details" 152 | msgstr "" 153 | 154 | msgid "translate-service.comp.show-more-label" 155 | msgstr "" 156 | 157 | msgid "translate-service.comp.show-less-label" 158 | msgstr "" 159 | 160 | msgid "pipe.comp.welcome" 161 | msgstr "" 162 | 163 | msgid "pipe.comp.description" 164 | msgstr "" 165 | 166 | msgid "directive.comp.welcome" 167 | msgstr "" 168 | 169 | msgid "directive.comp.description" 170 | msgstr "" 171 | 172 | msgid "private-translate-service.comp.welcome" 173 | msgstr "" 174 | 175 | msgid "private-translate-service.comp.description" 176 | msgstr "" 177 | 178 | msgid "ngx-translate.marker.dashboard.title" 179 | msgstr "" 180 | 181 | msgid "ngx-translate.marker.dashboard.description" 182 | msgstr "" 183 | 184 | msgid "marker.dashboard.title" 185 | msgstr "" 186 | 187 | msgid "marker.dashboard.description" 188 | msgstr "" 189 | " 190 | `; 191 | -------------------------------------------------------------------------------- /tests/parsers/marker.parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, it } from 'vitest'; 2 | 3 | import { MarkerParser } from '../../src/parsers/marker.parser.js'; 4 | 5 | describe('MarkerParser', () => { 6 | const componentFilename: string = 'test.component.ts'; 7 | 8 | let parser: MarkerParser; 9 | 10 | beforeEach(() => { 11 | parser = new MarkerParser(); 12 | }); 13 | 14 | it('should extract strings using marker function', () => { 15 | const contents = ` 16 | import { marker } from '@biesbjerg/ngx-translate-extract-marker'; 17 | marker('Hello world'); 18 | marker(['I', 'am', 'extracted']); 19 | otherFunction('But I am not'); 20 | marker(message || 'binary expression'); 21 | marker(message ? message : 'conditional operator'); 22 | marker('FOO.bar'); 23 | `; 24 | const keys = parser.extract(contents, componentFilename).keys(); 25 | expect(keys).to.deep.equal(['Hello world', 'I', 'am', 'extracted', 'binary expression', 'conditional operator', 'FOO.bar']); 26 | }); 27 | 28 | it('should extract split strings', () => { 29 | const contents = ` 30 | import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; 31 | _('Hello ' + 'world'); 32 | _('This is a ' + 'very ' + 'very ' + 'very ' + 'very ' + 'long line.'); 33 | _('Mix ' + \`of \` + 'different ' + \`types\`); 34 | `; 35 | const keys = parser.extract(contents, componentFilename).keys(); 36 | expect(keys).to.deep.equal(['Hello world', 'This is a very very very very long line.', 'Mix of different types']); 37 | }); 38 | 39 | it('should extract split strings while keeping html tags', () => { 40 | const contents = ` 41 | import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; 42 | _('Hello ' + 'world'); 43 | _('This is a ' + 'very ' + 'very ' + 'very ' + 'very ' + 'long line.'); 44 | _('Mix ' + \`of \` + 'different ' + \`types\`); 45 | `; 46 | const keys = parser.extract(contents, componentFilename).keys(); 47 | expect(keys).to.deep.equal(['Hello world', 'This is a very very very very long line.', 'Mix of different types']); 48 | }); 49 | 50 | it('should extract the strings', () => { 51 | const contents = ` 52 | import { marker } from '@biesbjerg/ngx-translate-extract-marker'; 53 | 54 | export class AppModule { 55 | constructor() { 56 | marker('DYNAMIC_TRAD.val1'); 57 | marker('DYNAMIC_TRAD.val2'); 58 | } 59 | } 60 | `; 61 | const keys = parser.extract(contents, componentFilename).keys(); 62 | expect(keys).to.deep.equal(['DYNAMIC_TRAD.val1', 'DYNAMIC_TRAD.val2']); 63 | }); 64 | 65 | it('should handle forks of the original @biesbjerg/ngx-translate-extract-marker', () => { 66 | const contents = ` 67 | import { marker } from '@colsen1991/ngx-translate-extract-marker'; 68 | 69 | marker('Hello world') 70 | `; 71 | const keys = parser.extract(contents, componentFilename).keys(); 72 | expect(keys).to.deep.equal(['Hello world']); 73 | }); 74 | 75 | it('should not break after bracket syntax casting', () => { 76 | const contents = ` 77 | import { marker } from '@colsen1991/ngx-translate-extract-marker'; 78 | 79 | marker('hello'); 80 | const input: unknown = 'hello'; 81 | const myNiceVar1 = input as string; 82 | marker('hello.after.as.syntax'); 83 | 84 | const myNiceVar2 = input; 85 | marker('hello.after.bracket.syntax'); 86 | `; 87 | const keys = parser.extract(contents, componentFilename).keys(); 88 | expect(keys).to.deep.equal(['hello', 'hello.after.as.syntax', 'hello.after.bracket.syntax']); 89 | }); 90 | 91 | describe('marker from @ngx-translate/core', () => { 92 | it('should extract strings using marker (_) function', () => { 93 | const contents = ` 94 | import {_} from '@ngx-translate/core'; 95 | _('Hello world'); 96 | _(['I', 'am', 'extracted']); 97 | otherFunction('But I am not'); 98 | _(message || 'binary expression'); 99 | _(message ? message : 'conditional operator'); 100 | _('FOO.bar'); 101 | `; 102 | const keys = parser.extract(contents, componentFilename)?.keys(); 103 | expect(keys).to.deep.equal(['Hello world', 'I', 'am', 'extracted', 'binary expression', 'conditional operator', 'FOO.bar']); 104 | }); 105 | 106 | it('should extract strings using an alias function', () => { 107 | const contents = ` 108 | import {_ as marker} from '@ngx-translate/core'; 109 | marker('Hello world'); 110 | marker(['I', 'am', 'extracted']); 111 | otherFunction('But I am not'); 112 | marker(message || 'binary expression'); 113 | marker(message ? message : 'conditional operator'); 114 | marker('FOO.bar'); 115 | `; 116 | const keys = parser.extract(contents, componentFilename)?.keys(); 117 | expect(keys).to.deep.equal(['Hello world', 'I', 'am', 'extracted', 'binary expression', 'conditional operator', 'FOO.bar']); 118 | }); 119 | 120 | it('should extract split strings', () => { 121 | const contents = ` 122 | import {_} from '@ngx-translate/core'; 123 | _('Hello ' + 'world'); 124 | _('This is a ' + 'very ' + 'very ' + 'very ' + 'very ' + 'long line.'); 125 | _('Mix ' + \`of \` + 'different ' + \`types\`); 126 | `; 127 | const keys = parser.extract(contents, componentFilename).keys(); 128 | expect(keys).to.deep.equal(['Hello world', 'This is a very very very very long line.', 'Mix of different types']); 129 | }); 130 | 131 | it('should extract split strings while keeping html tags', () => { 132 | const contents = ` 133 | import {_} from '@ngx-translate/core'; 134 | _('Hello ' + 'world'); 135 | _('This is a ' + 'very ' + 'very ' + 'very ' + 'very ' + 'long line.'); 136 | _('Mix ' + \`of \` + 'different ' + \`types\`); 137 | `; 138 | const keys = parser.extract(contents, componentFilename).keys(); 139 | expect(keys).to.deep.equal(['Hello world', 'This is a very very very very long line.', 'Mix of different types']); 140 | }); 141 | }) 142 | }); 143 | -------------------------------------------------------------------------------- /tests/utils/ast-helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { ScriptKind, tsquery } from '@phenomnomnominal/tsquery'; 2 | import { beforeEach, describe, it, expect, vi } from 'vitest'; 3 | import { LanguageVariant } from 'typescript'; 4 | 5 | import { getAST, getNamedImport, getNamedImportAlias } from '../../src/utils/ast-helpers'; 6 | 7 | describe('getAST()', () => { 8 | const tsqueryAstSpy = vi.spyOn(tsquery, 'ast'); 9 | 10 | beforeEach(() => { 11 | tsqueryAstSpy.mockClear(); 12 | }); 13 | 14 | it('should return the AST for a TypeScript source with a .ts file extension', () => { 15 | const source = 'const x: number = 42;'; 16 | const fileName = 'example.ts'; 17 | 18 | const result = getAST(source, fileName); 19 | 20 | expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.TS); 21 | expect(result.languageVariant).toBe(LanguageVariant.Standard); 22 | }); 23 | 24 | it('should return the AST for a TypeScript source with a .tsx file extension', () => { 25 | const source = 'const x: number = 42;'; 26 | const fileName = 'example.tsx'; 27 | 28 | const result = getAST(source, fileName); 29 | 30 | expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.TSX); 31 | expect(result.languageVariant).toBe(LanguageVariant.JSX); 32 | }); 33 | 34 | it('should return the AST for a JavaScript source with a .js file extension', () => { 35 | const source = 'const x = 42;'; 36 | const fileName = 'example.js'; 37 | 38 | const result = getAST(source, fileName); 39 | 40 | expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.JS); 41 | // JS files also return JSX language variant. 42 | expect(result.languageVariant).toBe(LanguageVariant.JSX); 43 | }); 44 | 45 | it('should return the AST for a JavaScript source with a .jsx file extension', () => { 46 | const source = 'const x = 42;'; 47 | const fileName = 'example.jsx'; 48 | 49 | const result = getAST(source, fileName); 50 | 51 | expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.JSX); 52 | expect(result.languageVariant).toBe(LanguageVariant.JSX); 53 | }); 54 | 55 | it('should use ScriptKind.TS if the file extension is unsupported', () => { 56 | const source = 'const x: number = 42;'; 57 | const fileName = 'example.unknown'; 58 | 59 | const result = getAST(source, fileName); 60 | 61 | expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.TS); 62 | expect(result.languageVariant).toBe(LanguageVariant.Standard); 63 | }); 64 | 65 | it('should use ScriptKind.TS if no file name is provided', () => { 66 | const source = 'const x: number = 42;'; 67 | 68 | const result = getAST(source); 69 | 70 | expect(tsqueryAstSpy).toHaveBeenCalledWith(source, '', ScriptKind.TS); 71 | expect(result.languageVariant).toBe(LanguageVariant.Standard); 72 | }); 73 | }); 74 | 75 | describe('getNamedImport()', () => { 76 | describe('with a normal import', () => { 77 | const node = tsquery.ast(` 78 | import { Base } from './src/base'; 79 | 80 | export class Test extends CoreBase { 81 | public constructor() { 82 | super(); 83 | this.translate.instant("test"); 84 | } 85 | } 86 | `); 87 | 88 | it('should return the original class name when given exact import path', () => { 89 | expect(getNamedImport(node, 'CoreBase', './src/base')).to.equal(null); 90 | expect(getNamedImport(node, 'Base', './src/base')).to.equal('Base'); 91 | }); 92 | 93 | it('should return the original class name when given a regex pattern for the import path', () => { 94 | expect(getNamedImport(node, 'CoreBase', new RegExp('base'))).to.equal(null); 95 | expect(getNamedImport(node, 'Base', new RegExp('base'))).to.equal('Base'); 96 | }); 97 | }); 98 | 99 | describe('with an aliased import', () => { 100 | const node = tsquery.ast(` 101 | import { Base as CoreBase } from './src/base'; 102 | 103 | export class Test extends CoreBase { 104 | public constructor() { 105 | super(); 106 | this.translate.instant("test"); 107 | } 108 | } 109 | `); 110 | 111 | it('should return the original class name when given an alias and exact import path', () => { 112 | expect(getNamedImport(node, 'CoreBase', './src/base')).to.equal('Base'); 113 | expect(getNamedImport(node, 'Base', './src/base')).to.equal('Base'); 114 | }); 115 | 116 | it('should return the original class name when given an alias and a regex pattern for the import path', () => { 117 | expect(getNamedImport(node, 'CoreBase', new RegExp('base'))).to.equal('Base'); 118 | expect(getNamedImport(node, 'Base', new RegExp('base'))).to.equal('Base'); 119 | }); 120 | }); 121 | }); 122 | 123 | describe('getNamedImportAlias()', () => { 124 | describe('with a normal import', () => { 125 | const node = tsquery.ast(` 126 | import { Base } from './src/base'; 127 | 128 | export class Test extends CoreBase { 129 | public constructor() { 130 | super(); 131 | this.translate.instant("test"); 132 | } 133 | } 134 | `); 135 | 136 | it('should return the original class name when given exact import path', () => { 137 | expect(getNamedImportAlias(node, 'CoreBase', './src/base')).to.equal(null); 138 | expect(getNamedImportAlias(node, 'Base', './src/base')).to.equal('Base'); 139 | }); 140 | 141 | it('should return the original class name when given a regex pattern for the import', () => { 142 | expect(getNamedImportAlias(node, 'CoreBase', new RegExp('base'))).to.equal(null); 143 | expect(getNamedImportAlias(node, 'Base', new RegExp('base'))).to.equal('Base'); 144 | }); 145 | }); 146 | 147 | describe('with an aliased import', () => { 148 | const node = tsquery.ast(` 149 | import { Base as CoreBase } from './src/base'; 150 | 151 | export class Test extends CoreBase { 152 | public constructor() { 153 | super(); 154 | this.translate.instant("test"); 155 | } 156 | } 157 | `); 158 | 159 | it('should return the aliased class name when given an alias and exact import path', () => { 160 | expect(getNamedImportAlias(node, 'CoreBase', './src/base')).to.equal('CoreBase'); 161 | expect(getNamedImportAlias(node, 'Base', './src/base')).to.equal('CoreBase'); 162 | }); 163 | 164 | it('should return the aliased class name when given an alias and a regex pattern for the import path', () => { 165 | expect(getNamedImportAlias(node, 'CoreBase', new RegExp('base'))).to.equal('CoreBase'); 166 | expect(getNamedImportAlias(node, 'Base', new RegExp('base'))).to.equal('CoreBase'); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /src/cli/tasks/extract.task.ts: -------------------------------------------------------------------------------- 1 | import { globSync } from 'glob'; 2 | import * as fs from 'node:fs'; 3 | import * as path from 'node:path'; 4 | 5 | import { TranslationCollection, TranslationType } from '../../utils/translation.collection.js'; 6 | import { TaskInterface } from './task.interface.js'; 7 | import { cyan, green, bold, dim, red } from '../../utils/cli-color.js'; 8 | import { ParserInterface } from '../../parsers/parser.interface.js'; 9 | import { PostProcessorInterface } from '../../post-processors/post-processor.interface.js'; 10 | import { CompilerInterface } from '../../compilers/compiler.interface.js'; 11 | import type { CacheInterface } from '../../cache/cache-interface.js'; 12 | import { NullCache } from '../../cache/null-cache.js'; 13 | 14 | export interface ExtractTaskOptionsInterface { 15 | replace?: boolean; 16 | } 17 | 18 | export class ExtractTask implements TaskInterface { 19 | protected options: ExtractTaskOptionsInterface = { 20 | replace: false 21 | }; 22 | 23 | protected parsers: ParserInterface[] = []; 24 | protected postProcessors: PostProcessorInterface[] = []; 25 | protected compiler: CompilerInterface; 26 | protected cache: CacheInterface = new NullCache(); 27 | 28 | public constructor(protected inputs: string[], protected outputs: string[], options?: ExtractTaskOptionsInterface) { 29 | this.inputs = inputs.map((input) => path.resolve(input)); 30 | this.outputs = outputs.map((output) => path.resolve(output)); 31 | this.options = { ...this.options, ...options }; 32 | } 33 | 34 | public execute(): void { 35 | if (!this.compiler) { 36 | throw new Error('No compiler configured'); 37 | } 38 | 39 | this.printEnabledParsers(); 40 | this.printEnabledPostProcessors(); 41 | this.printEnabledCompiler(); 42 | 43 | this.out(bold('Extracting:')); 44 | const extracted = this.extract(); 45 | this.out(green('\nFound %d strings.\n'), extracted.count()); 46 | 47 | this.out(bold('Saving:')); 48 | 49 | this.outputs.forEach((output) => { 50 | let dir: string = output; 51 | let filename: string = `strings.${this.compiler.extension}`; 52 | if (!fs.existsSync(output) || !fs.statSync(output).isDirectory()) { 53 | dir = path.dirname(output); 54 | filename = path.basename(output); 55 | } 56 | 57 | const outputPath: string = path.join(dir, filename); 58 | 59 | let existing: TranslationCollection = new TranslationCollection(); 60 | if (!this.options.replace && fs.existsSync(outputPath)) { 61 | try { 62 | existing = this.compiler.parse(fs.readFileSync(outputPath, 'utf-8')); 63 | } catch (e) { 64 | this.out('%s %s', dim(`- ${outputPath}`), red('[ERROR]')); 65 | throw e; 66 | } 67 | } 68 | 69 | // merge extracted strings with existing 70 | const draft = extracted.union(existing); 71 | 72 | // Run collection through post processors 73 | const final = this.process(draft, extracted, existing); 74 | 75 | // Save 76 | try { 77 | let event = 'CREATED'; 78 | if (fs.existsSync(outputPath)) { 79 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 80 | this.options.replace ? (event = 'REPLACED') : (event = 'MERGED'); 81 | } 82 | this.save(outputPath, final); 83 | this.out('%s %s', dim(`- ${outputPath}`), green(`[${event}]`)); 84 | } catch (e) { 85 | this.out('%s %s', dim(`- ${outputPath}`), red('[ERROR]')); 86 | throw e; 87 | } 88 | }); 89 | 90 | this.cache.persist(); 91 | } 92 | 93 | public setParsers(parsers: ParserInterface[]): this { 94 | this.parsers = parsers; 95 | return this; 96 | } 97 | 98 | public setCache(cache: CacheInterface): this { 99 | this.cache = cache; 100 | return this; 101 | } 102 | 103 | public setPostProcessors(postProcessors: PostProcessorInterface[]): this { 104 | this.postProcessors = postProcessors; 105 | return this; 106 | } 107 | 108 | public setCompiler(compiler: CompilerInterface): this { 109 | this.compiler = compiler; 110 | return this; 111 | } 112 | 113 | /** 114 | * Extract strings from specified input dirs using configured parsers 115 | */ 116 | protected extract(): TranslationCollection { 117 | const collectionTypes: TranslationType[] = []; 118 | let skipped = 0; 119 | this.inputs.forEach((pattern) => { 120 | this.getFiles(pattern).forEach((filePath) => { 121 | const contents: string = fs.readFileSync(filePath, 'utf-8'); 122 | skipped += 1; 123 | const cachedCollectionValues = this.cache.get(`${pattern}:${filePath}:${contents}`, () => { 124 | skipped -= 1; 125 | this.out(dim('- %s'), filePath); 126 | return this.parsers 127 | .map((parser) => { 128 | const extracted = parser.extract(contents, filePath); 129 | return extracted instanceof TranslationCollection ? extracted.values : undefined; 130 | }) 131 | .filter((result): result is TranslationType => result && !!Object.keys(result).length); 132 | }); 133 | 134 | collectionTypes.push(...cachedCollectionValues); 135 | }); 136 | }); 137 | 138 | if (skipped) { 139 | this.out(dim('- %s unchanged files skipped via cache'), skipped); 140 | } 141 | 142 | const values: TranslationType = {}; 143 | for (const collectionType of collectionTypes) { 144 | Object.assign(values, collectionType); 145 | } 146 | 147 | return new TranslationCollection(values); 148 | } 149 | 150 | /** 151 | * Run strings through configured post processors 152 | */ 153 | protected process(draft: TranslationCollection, extracted: TranslationCollection, existing: TranslationCollection): TranslationCollection { 154 | this.postProcessors.forEach((postProcessor) => { 155 | draft = postProcessor.process(draft, extracted, existing); 156 | }); 157 | return draft; 158 | } 159 | 160 | /** 161 | * Compile and save translations 162 | * @param output 163 | * @param collection 164 | */ 165 | protected save(output: string, collection: TranslationCollection): void { 166 | const dir = path.dirname(output); 167 | if (!fs.existsSync(dir)) { 168 | fs.mkdirSync(dir, { recursive: true }); 169 | } 170 | fs.writeFileSync(output, this.compiler.compile(collection)); 171 | } 172 | 173 | /** 174 | * Get all files matching pattern 175 | */ 176 | protected getFiles(pattern: string): string[] { 177 | // Ensure that the pattern consistently uses forward slashes ("/") 178 | // for cross-platform compatibility, as Glob patterns should always use "/" 179 | const sanitizedPattern = pattern.split(path.sep).join(path.posix.sep); 180 | return globSync(sanitizedPattern).filter((filePath) => fs.statSync(filePath).isFile()); 181 | } 182 | 183 | protected out(...args: unknown[]): void { 184 | console.log.apply(this, args); 185 | } 186 | 187 | protected printEnabledParsers(): void { 188 | this.out(cyan('Enabled parsers:')); 189 | if (this.parsers.length) { 190 | this.out(cyan(dim(this.parsers.map((parser) => `- ${parser.constructor.name}`).join('\n')))); 191 | } else { 192 | this.out(cyan(dim('(none)'))); 193 | } 194 | this.out(); 195 | } 196 | 197 | protected printEnabledPostProcessors(): void { 198 | this.out(cyan('Enabled post processors:')); 199 | if (this.postProcessors.length) { 200 | this.out(cyan(dim(this.postProcessors.map((postProcessor) => `- ${postProcessor.constructor.name}`).join('\n')))); 201 | } else { 202 | this.out(cyan(dim('(none)'))); 203 | } 204 | this.out(); 205 | } 206 | 207 | protected printEnabledCompiler(): void { 208 | this.out(cyan('Compiler:')); 209 | this.out(cyan(dim(`- ${this.compiler.constructor.name}`))); 210 | this.out(); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/parsers/service.parser.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import fs from 'node:fs'; 3 | 4 | import { ClassDeclaration, CallExpression, SourceFile, findConfigFile, parseConfigFileTextToJson } from 'typescript'; 5 | 6 | import { ParserInterface } from './parser.interface.js'; 7 | import { TranslationCollection } from '../utils/translation.collection.js'; 8 | import { 9 | findClassDeclarations, 10 | findClassPropertiesByType, 11 | findPropertyCallExpressions, 12 | findMethodCallExpressions, 13 | getStringsFromExpression, 14 | findMethodParameterByType, 15 | findConstructorDeclaration, 16 | getSuperClassName, 17 | getImportPath, 18 | findFunctionExpressions, 19 | findVariableNameByInjectType, 20 | findInlineInjectCallExpressions, 21 | getAST, 22 | getNamedImport 23 | } from '../utils/ast-helpers.js'; 24 | 25 | const TRANSLATE_SERVICE_TYPE_REFERENCE = 'TranslateService'; 26 | const TRANSLATE_SERVICE_METHOD_NAMES = ['get', 'instant', 'stream']; 27 | 28 | export class ServiceParser implements ParserInterface { 29 | private static propertyMap = new Map(); 30 | 31 | public extract(source: string, filePath: string): TranslationCollection | null { 32 | const sourceFile = getAST(source, filePath); 33 | const classDeclarations = findClassDeclarations(sourceFile); 34 | const functionDeclarations = findFunctionExpressions(sourceFile); 35 | 36 | if (!classDeclarations && !functionDeclarations) { 37 | return null; 38 | } 39 | 40 | let collection: TranslationCollection = new TranslationCollection(); 41 | 42 | const translateServiceCallExpressions: CallExpression[] = []; 43 | 44 | functionDeclarations.forEach((fnDeclaration) => { 45 | const translateServiceVariableName = findVariableNameByInjectType(fnDeclaration, TRANSLATE_SERVICE_TYPE_REFERENCE); 46 | const callExpressions = findMethodCallExpressions(sourceFile, translateServiceVariableName, TRANSLATE_SERVICE_METHOD_NAMES); 47 | const inlineInjectCallExpressions = findInlineInjectCallExpressions(sourceFile, TRANSLATE_SERVICE_TYPE_REFERENCE, TRANSLATE_SERVICE_METHOD_NAMES); 48 | translateServiceCallExpressions.push(...callExpressions, ...inlineInjectCallExpressions); 49 | }); 50 | 51 | classDeclarations.forEach((classDeclaration) => { 52 | const callExpressions = [ 53 | ...this.findConstructorParamCallExpressions(classDeclaration), 54 | ...this.findPropertyCallExpressions(classDeclaration, sourceFile) 55 | ]; 56 | 57 | translateServiceCallExpressions.push(...callExpressions); 58 | }); 59 | 60 | translateServiceCallExpressions 61 | .filter((callExpression) => !!callExpression.arguments?.[0]) 62 | .forEach((callExpression) => { 63 | const [firstArg] = callExpression.arguments; 64 | 65 | const strings = getStringsFromExpression(firstArg); 66 | collection = collection.addKeys(strings, filePath); 67 | }); 68 | 69 | return collection; 70 | } 71 | 72 | protected findConstructorParamCallExpressions(classDeclaration: ClassDeclaration): CallExpression[] { 73 | const constructorDeclaration = findConstructorDeclaration(classDeclaration); 74 | if (!constructorDeclaration) { 75 | return []; 76 | } 77 | const paramName = findMethodParameterByType(constructorDeclaration, TRANSLATE_SERVICE_TYPE_REFERENCE); 78 | const methodCallExpressions = findMethodCallExpressions(constructorDeclaration, paramName, TRANSLATE_SERVICE_METHOD_NAMES); 79 | const inlineInjectCallExpressions = findInlineInjectCallExpressions(constructorDeclaration, TRANSLATE_SERVICE_TYPE_REFERENCE, TRANSLATE_SERVICE_METHOD_NAMES) 80 | // Calls of the TranslateService when injected using the inject function within the constructor 81 | const translateServiceLocalVariableName = findVariableNameByInjectType(constructorDeclaration, TRANSLATE_SERVICE_TYPE_REFERENCE); 82 | const localVariableCallExpressions = translateServiceLocalVariableName 83 | ? findMethodCallExpressions(constructorDeclaration, translateServiceLocalVariableName, TRANSLATE_SERVICE_METHOD_NAMES) 84 | : []; 85 | 86 | return [...methodCallExpressions, ...localVariableCallExpressions, ...inlineInjectCallExpressions]; 87 | } 88 | 89 | protected findPropertyCallExpressions(classDeclaration: ClassDeclaration, sourceFile: SourceFile): CallExpression[] { 90 | const propNames = findClassPropertiesByType(classDeclaration, TRANSLATE_SERVICE_TYPE_REFERENCE); 91 | 92 | if (propNames.length === 0) { 93 | propNames.push(...this.findParentClassProperties(classDeclaration, sourceFile)); 94 | } 95 | 96 | return propNames.flatMap((name) => findPropertyCallExpressions(classDeclaration, name, TRANSLATE_SERVICE_METHOD_NAMES)); 97 | } 98 | 99 | private findParentClassProperties(classDeclaration: ClassDeclaration, ast: SourceFile): string[] { 100 | const superClassNameOrAlias = getSuperClassName(classDeclaration); 101 | if (!superClassNameOrAlias) { 102 | return []; 103 | } 104 | 105 | const importPath = getImportPath(ast, superClassNameOrAlias); 106 | if (!importPath) { 107 | // parent class must be in the same file and will be handled automatically, so we can 108 | // skip it here 109 | return []; 110 | } 111 | 112 | // Resolve the actual name of the superclass from the named import 113 | const superClassName = getNamedImport(ast, superClassNameOrAlias, importPath); 114 | const currDir = path.join(path.dirname(ast.fileName), '/'); 115 | 116 | const cacheKey = `${currDir}|${importPath}`; 117 | if (ServiceParser.propertyMap.has(cacheKey)) { 118 | return ServiceParser.propertyMap.get(cacheKey); 119 | } 120 | 121 | let superClassPath: string; 122 | if (importPath.startsWith('.')) { 123 | // relative import, use currDir 124 | superClassPath = path.resolve(currDir, importPath); 125 | } else if (importPath.startsWith('/')) { 126 | // absolute relative import, use path directly 127 | superClassPath = importPath; 128 | } else { 129 | // absolute import, use baseUrl if present 130 | let baseUrl = currDir; 131 | const tsconfigFilePath = findConfigFile(currDir, fs.existsSync); 132 | if (tsconfigFilePath) { 133 | const tsConfigFile = fs.readFileSync(tsconfigFilePath, { encoding: 'utf8' }); 134 | const config = parseConfigFileTextToJson(tsconfigFilePath, tsConfigFile).config; 135 | const compilerOptionsBaseUrl = config.compilerOptions?.baseUrl ?? ''; 136 | baseUrl = path.resolve(path.dirname(tsconfigFilePath), compilerOptionsBaseUrl); 137 | } 138 | 139 | superClassPath = path.resolve(baseUrl, importPath); 140 | } 141 | const superClassFile = superClassPath + '.ts'; 142 | let potentialSuperFiles: string[]; 143 | if (fs.existsSync(superClassFile) && fs.lstatSync(superClassFile).isFile()) { 144 | potentialSuperFiles = [superClassFile]; 145 | } else if (fs.existsSync(superClassPath) && fs.lstatSync(superClassPath).isDirectory()) { 146 | potentialSuperFiles = fs 147 | .readdirSync(superClassPath) 148 | .filter((file) => file.endsWith('.ts')) 149 | .map((file) => path.join(superClassPath, file)); 150 | } else { 151 | // we cannot find the superclass, so just assume that no translate property exists 152 | return []; 153 | } 154 | 155 | const allSuperClassPropertyNames: string[] = []; 156 | potentialSuperFiles.forEach((file) => { 157 | const superClassFileContent = fs.readFileSync(file, 'utf8'); 158 | const superClassAst = getAST(superClassFileContent, file); 159 | const superClassDeclarations = findClassDeclarations(superClassAst, superClassName); 160 | const superClassPropertyNames = superClassDeclarations 161 | .flatMap((superClassDeclaration) => findClassPropertiesByType(superClassDeclaration, TRANSLATE_SERVICE_TYPE_REFERENCE)); 162 | if (superClassPropertyNames.length > 0) { 163 | ServiceParser.propertyMap.set(cacheKey, superClassPropertyNames); 164 | allSuperClassPropertyNames.push(...superClassPropertyNames); 165 | } else { 166 | superClassDeclarations.forEach((declaration) => 167 | allSuperClassPropertyNames.push(...this.findParentClassProperties(declaration, superClassAst)) 168 | ); 169 | } 170 | }); 171 | return allSuperClassPropertyNames; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/parsers/directive.parser.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AST, 3 | ASTWithSource, 4 | Binary, 5 | BindingPipe, 6 | Conditional, 7 | Interpolation, 8 | LiteralArray, 9 | LiteralMap, 10 | LiteralPrimitive, 11 | parseTemplate, 12 | TmplAstBoundAttribute as BoundAttribute, 13 | TmplAstElement as Element, 14 | TmplAstNode as Node, 15 | TmplAstTemplate as Template, 16 | TmplAstText as Text, 17 | TmplAstTextAttribute as TextAttribute, 18 | ParseSourceSpan, 19 | TmplAstIfBlock, 20 | TmplAstSwitchBlock, 21 | TmplAstForLoopBlock, 22 | TmplAstDeferredBlock, 23 | ParenthesizedExpression, 24 | } from '@angular/compiler'; 25 | 26 | import { ParserInterface } from './parser.interface.js'; 27 | import { TranslationCollection } from '../utils/translation.collection.js'; 28 | import { extractComponentInlineTemplate, isPathAngularComponent } from '../utils/utils.js'; 29 | 30 | interface BlockNode { 31 | nameSpan: ParseSourceSpan; 32 | sourceSpan: ParseSourceSpan; 33 | startSourceSpan: ParseSourceSpan; 34 | endSourceSpan: ParseSourceSpan | null; 35 | children: Node[] | undefined; 36 | visit(visitor: unknown): Result; 37 | } 38 | 39 | export const TRANSLATE_ATTR_NAMES = ['translate', 'marker']; 40 | type ElementLike = Element | Template; 41 | 42 | export class DirectiveParser implements ParserInterface { 43 | public extract(source: string, filePath: string): TranslationCollection | null { 44 | let collection: TranslationCollection = new TranslationCollection(); 45 | 46 | if (filePath && isPathAngularComponent(filePath)) { 47 | source = extractComponentInlineTemplate(source); 48 | } 49 | const nodes: Node[] = this.parseTemplate(source, filePath); 50 | const elements: ElementLike[] = this.getElementsWithTranslateAttribute(nodes); 51 | 52 | elements.forEach((element) => { 53 | const attribute = this.getAttribute(element, TRANSLATE_ATTR_NAMES); 54 | if (attribute?.value) { 55 | collection = collection.add(attribute.value, '', filePath); 56 | return; 57 | } 58 | 59 | const boundAttribute = this.getBoundAttribute(element, TRANSLATE_ATTR_NAMES); 60 | if (boundAttribute?.value) { 61 | this.getLiteralPrimitives(boundAttribute.value).forEach((literalPrimitive) => { 62 | collection = collection.add(literalPrimitive.value, '', filePath); 63 | }); 64 | return; 65 | } 66 | 67 | const textNodes = this.getTextNodes(element); 68 | textNodes.forEach((textNode) => { 69 | collection = collection.add(textNode.value.trim(), '', filePath); 70 | }); 71 | }); 72 | return collection; 73 | } 74 | 75 | /** 76 | * Find all ElementLike nodes with a translate attribute 77 | * @param nodes 78 | */ 79 | protected getElementsWithTranslateAttribute(nodes: Node[]): ElementLike[] { 80 | let elements: ElementLike[] = []; 81 | 82 | nodes.filter(this.isElementLike).forEach((element) => { 83 | if (this.hasAttributes(element, TRANSLATE_ATTR_NAMES)) { 84 | elements = [...elements, element]; 85 | } 86 | if (this.hasBoundAttribute(element, TRANSLATE_ATTR_NAMES)) { 87 | elements = [...elements, element]; 88 | } 89 | const childElements = this.getElementsWithTranslateAttribute(element.children); 90 | if (childElements.length) { 91 | elements = [...elements, ...childElements]; 92 | } 93 | }); 94 | 95 | nodes.filter(this.isBlockNode).forEach((node) => elements.push(...this.getElementsWithTranslateAttributeFromBlockNodes(node))); 96 | 97 | return elements; 98 | } 99 | 100 | /** 101 | * Get the child elements that are inside a block node (e.g. @if, @deferred) 102 | */ 103 | protected getElementsWithTranslateAttributeFromBlockNodes(blockNode: BlockNode) { 104 | let blockChildren = blockNode.children; 105 | 106 | if (blockNode instanceof TmplAstIfBlock) { 107 | blockChildren = blockNode.branches.map((branch) => branch.children).flat(); 108 | } 109 | 110 | if (blockNode instanceof TmplAstSwitchBlock) { 111 | blockChildren = blockNode.cases.map((branch) => branch.children).flat(); 112 | } 113 | 114 | if (blockNode instanceof TmplAstForLoopBlock) { 115 | const emptyBlockChildren = blockNode.empty?.children ?? []; 116 | blockChildren.push(...emptyBlockChildren); 117 | } 118 | 119 | if (blockNode instanceof TmplAstDeferredBlock) { 120 | const placeholderBlockChildren = blockNode.placeholder?.children ?? []; 121 | const loadingBlockChildren = blockNode.loading?.children ?? []; 122 | const errorBlockChildren = blockNode.error?.children ?? []; 123 | 124 | blockChildren.push(...placeholderBlockChildren, ...loadingBlockChildren, ...errorBlockChildren); 125 | } 126 | 127 | return this.getElementsWithTranslateAttribute(blockChildren); 128 | } 129 | 130 | /** 131 | * Get direct child nodes of type Text 132 | * @param element 133 | */ 134 | protected getTextNodes(element: ElementLike): Text[] { 135 | return element.children.filter(this.isText); 136 | } 137 | 138 | /** 139 | * Check if attribute is present on element 140 | * @param element 141 | * @param name 142 | */ 143 | protected hasAttributes(element: ElementLike, name: string[]): boolean { 144 | return this.getAttribute(element, name) !== undefined; 145 | } 146 | 147 | /** 148 | * Get attribute value if present on element 149 | * @param element 150 | * @param names 151 | */ 152 | protected getAttribute(element: ElementLike, names: string[]): TextAttribute { 153 | return element.attributes.find((attribute) => names.includes(attribute.name)); 154 | } 155 | 156 | /** 157 | * Check if bound attribute is present on element 158 | * @param element 159 | * @param names 160 | */ 161 | protected hasBoundAttribute(element: ElementLike, names: string[]): boolean { 162 | return this.getBoundAttribute(element, names) !== undefined; 163 | } 164 | 165 | /** 166 | * Get bound attribute if present on element 167 | * @param element 168 | * @param names 169 | */ 170 | protected getBoundAttribute(element: ElementLike, names: string[]): BoundAttribute { 171 | return element.inputs.find((input) => !input.keySpan.details.startsWith('attr.') && names.includes(input.name)); 172 | } 173 | 174 | /** 175 | * Get literal primitives from expression 176 | * @param exp 177 | */ 178 | protected getLiteralPrimitives(exp: AST): LiteralPrimitive[] { 179 | if (exp instanceof LiteralPrimitive) { 180 | return [exp]; 181 | } 182 | 183 | let visit: AST[] = []; 184 | if (exp instanceof Interpolation) { 185 | visit = exp.expressions; 186 | } else if (exp instanceof LiteralArray) { 187 | visit = exp.expressions; 188 | } else if (exp instanceof LiteralMap) { 189 | visit = exp.values; 190 | } else if (exp instanceof BindingPipe) { 191 | visit = [exp.exp]; 192 | } else if (exp instanceof Conditional) { 193 | visit = [exp.trueExp, exp.falseExp]; 194 | } else if (exp instanceof Binary) { 195 | visit = [exp.left, exp.right]; 196 | } else if (exp instanceof ASTWithSource) { 197 | visit = [exp.ast]; 198 | } else if (exp instanceof ParenthesizedExpression) { 199 | visit = [exp.expression]; 200 | } 201 | 202 | let results: LiteralPrimitive[] = []; 203 | visit.forEach((child) => { 204 | results = [...results, ...this.getLiteralPrimitives(child)]; 205 | }); 206 | return results; 207 | } 208 | 209 | /** 210 | * Check if node type is ElementLike 211 | * @param node 212 | */ 213 | protected isElementLike(node: Node): node is ElementLike { 214 | return node instanceof Element || node instanceof Template; 215 | } 216 | 217 | /** 218 | * Check if node type is BlockNode 219 | * @param node 220 | */ 221 | protected isBlockNode(node: Node): node is BlockNode { 222 | return ( 223 | Object.hasOwn(node, 'nameSpan') && 224 | Object.hasOwn(node, 'sourceSpan') && 225 | Object.hasOwn(node, 'startSourceSpan') && 226 | Object.hasOwn(node, 'endSourceSpan') 227 | ); 228 | } 229 | 230 | /** 231 | * Check if node type is Text 232 | * @param node 233 | */ 234 | protected isText(node: Node): node is Text { 235 | return node instanceof Text; 236 | } 237 | 238 | /** 239 | * Parse a template into nodes 240 | * @param template 241 | * @param path 242 | */ 243 | protected parseTemplate(template: string, path: string): Node[] { 244 | return parseTemplate(template, path).nodes; 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/parsers/pipe.parser.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AST, 3 | TmplAstNode, 4 | parseTemplate, 5 | BindingPipe, 6 | LiteralPrimitive, 7 | Conditional, 8 | Binary, 9 | LiteralMap, 10 | LiteralArray, 11 | Interpolation, 12 | Call, 13 | TmplAstIfBlock, 14 | TmplAstSwitchBlock, 15 | TmplAstDeferredBlock, 16 | TmplAstForLoopBlock, 17 | TmplAstElement, 18 | KeyedRead, 19 | ASTWithSource, 20 | ParenthesizedExpression, 21 | } from '@angular/compiler'; 22 | 23 | import { ParserInterface } from './parser.interface.js'; 24 | import { TranslationCollection } from '../utils/translation.collection.js'; 25 | import { isPathAngularComponent, extractComponentInlineTemplate } from '../utils/utils.js'; 26 | 27 | export const TRANSLATE_PIPE_NAMES = ['translate', 'marker']; 28 | 29 | function traverseAstNodes( 30 | nodes: (NODE | null)[], 31 | visitor: (node: NODE) => RESULT[], 32 | accumulator: RESULT[] = [] 33 | ): RESULT[] { 34 | for (const node of nodes) { 35 | if (node) { 36 | traverseAstNode(node, visitor, accumulator); 37 | } 38 | } 39 | 40 | return accumulator; 41 | } 42 | 43 | function traverseAstNode( 44 | node: NODE, 45 | visitor: (node: NODE) => RESULT[], 46 | accumulator: RESULT[] = [] 47 | ): RESULT[] { 48 | accumulator.push(...visitor(node)); 49 | 50 | const children: TmplAstNode[] = []; 51 | // children of templates, html elements or blocks 52 | if ('children' in node && node.children) { 53 | children.push(...node.children); 54 | } 55 | 56 | // contents of @for extra sibling block @empty 57 | if (node instanceof TmplAstForLoopBlock) { 58 | children.push(node.empty); 59 | } 60 | 61 | // contents of @defer extra sibling blocks @error, @placeholder and @loading 62 | if (node instanceof TmplAstDeferredBlock) { 63 | children.push(node.error); 64 | children.push(node.loading); 65 | children.push(node.placeholder); 66 | } 67 | 68 | // contents of @if and @else (ignoring the @if(...) condition statement though) 69 | if (node instanceof TmplAstIfBlock) { 70 | children.push(...node.branches.flatMap((inner) => inner.children)); 71 | } 72 | 73 | // contents of @case blocks (ignoring the @switch(...) statement though) 74 | if (node instanceof TmplAstSwitchBlock) { 75 | children.push(...node.cases.flatMap((inner) => inner.children)); 76 | } 77 | 78 | return traverseAstNodes(children, visitor, accumulator); 79 | } 80 | 81 | export class PipeParser implements ParserInterface { 82 | public extract(source: string, filePath: string): TranslationCollection { 83 | if (filePath && isPathAngularComponent(filePath)) { 84 | source = extractComponentInlineTemplate(source); 85 | } 86 | 87 | let collection: TranslationCollection = new TranslationCollection(); 88 | const nodes: TmplAstNode[] = this.parseTemplate(source, filePath); 89 | 90 | const pipes = traverseAstNodes(nodes, (node) => this.findPipesInNode(node)); 91 | 92 | pipes.forEach((pipe) => { 93 | this.parseTranslationKeysFromPipe(pipe).forEach((key) => { 94 | if (key === '') { 95 | return; 96 | } 97 | collection = collection.add(key, '', filePath); 98 | }); 99 | }); 100 | return collection; 101 | } 102 | 103 | protected findPipesInNode(node: TmplAstNode): BindingPipe[] { 104 | const ret: BindingPipe[] = []; 105 | 106 | if ('value' in node && node.value instanceof ASTWithSource) { 107 | ret.push(...this.getTranslatablesFromAst(node.value.ast)); 108 | } 109 | 110 | if ('attributes' in node && Array.isArray(node.attributes)) { 111 | const translatableAttributes = node.attributes.filter((attr) => TRANSLATE_PIPE_NAMES.includes(attr.name)); 112 | ret.push(...ret, ...translatableAttributes); 113 | } 114 | 115 | if ('inputs' in node && Array.isArray(node.inputs)) { 116 | node.inputs.forEach((input) => { 117 | // 118 | if (input.value instanceof ASTWithSource) { 119 | ret.push(...this.getTranslatablesFromAst(input.value.ast)); 120 | } 121 | }); 122 | } 123 | 124 | if ('templateAttrs' in node && Array.isArray(node.templateAttrs)) { 125 | node.templateAttrs.forEach((attr) => { 126 | // 127 | if (attr.value instanceof ASTWithSource) { 128 | ret.push(...this.getTranslatablesFromAst(attr.value.ast)); 129 | } 130 | }); 131 | } 132 | 133 | return ret; 134 | } 135 | 136 | protected parseTranslationKeysFromPipe(pipeContent: AST): string[] { 137 | const ret: string[] = []; 138 | if (pipeContent instanceof LiteralPrimitive) { 139 | ret.push(`${pipeContent.value}`); 140 | } else if (pipeContent instanceof Conditional) { 141 | ret.push(...this.parseTranslationKeysFromPipe(pipeContent.trueExp)); 142 | ret.push(...this.parseTranslationKeysFromPipe(pipeContent.falseExp)); 143 | } else if (pipeContent instanceof BindingPipe) { 144 | ret.push(...this.parseTranslationKeysFromPipe(pipeContent.exp)); 145 | } else if (pipeContent instanceof ParenthesizedExpression) { 146 | ret.push(...this.parseTranslationKeysFromPipe(pipeContent.expression)); 147 | } else if (this.isLogicalOrNullishCoalescingExpression(pipeContent)) { 148 | if (pipeContent.left instanceof LiteralPrimitive) { 149 | ret.push(`${pipeContent.left.value}`); 150 | } 151 | if (pipeContent.right instanceof LiteralPrimitive) { 152 | ret.push(`${pipeContent.right.value}`); 153 | } 154 | } 155 | return ret; 156 | } 157 | 158 | protected getTranslatablesFromAst(ast: AST): BindingPipe[] { 159 | if (ast instanceof BindingPipe) { 160 | // the entire expression is the translate pipe, e.g.: 161 | // - 'foo' | translate 162 | // - (condition ? 'foo' : 'bar') | translate 163 | if (TRANSLATE_PIPE_NAMES.includes(ast.name)) { 164 | // also visit the pipe arguments - interpolateParams object 165 | return [ast, ...this.getTranslatablesFromAsts(ast.args)]; 166 | } 167 | 168 | // not the translate pipe - ignore the pipe, visit the expression and arguments, e.g.: 169 | // - { foo: 'Hello' | translate } | json 170 | // - value | date: ('mediumDate' | translate) 171 | return this.getTranslatablesFromAsts([ast.exp, ...ast.args]); 172 | } 173 | 174 | // angular double curly bracket interpolation, e.g.: 175 | // - {{ expressions }} 176 | if (ast instanceof Interpolation) { 177 | return this.getTranslatablesFromAsts(ast.expressions); 178 | } 179 | 180 | // ternary operator, e.g.: 181 | // - condition ? null : ('foo' | translate) 182 | // - condition ? ('foo' | translate) : null 183 | if (ast instanceof Conditional) { 184 | return this.getTranslatablesFromAsts([ast.trueExp, ast.falseExp]); 185 | } 186 | 187 | // string concatenation, e.g.: 188 | // - 'foo' + 'bar' + ('baz' | translate) 189 | if (ast instanceof Binary) { 190 | if (ast?.left && ast?.right) { 191 | return this.getTranslatablesFromAsts([ast.left, ast.right]); 192 | } 193 | } 194 | 195 | // object - ignore the keys, visit all values, e.g.: 196 | // - { key1: 'value1' | translate, key2: 'value2' | translate } 197 | if (ast instanceof LiteralMap) { 198 | return this.getTranslatablesFromAsts(ast.values); 199 | } 200 | 201 | // array - visit all its values, e.g.: 202 | // - [ 'value1' | translate, 'value2' | translate ] 203 | if (ast instanceof LiteralArray) { 204 | return this.getTranslatablesFromAsts(ast.expressions); 205 | } 206 | 207 | if (ast instanceof Call) { 208 | return this.getTranslatablesFromAsts(ast.args); 209 | } 210 | 211 | // immediately accessed static object or array - the angular parser bundles this as "KeyedRead", where: 212 | // { 'a': 1, 'b': 2 }[ 'a' ]; 213 | // ^^^ <- keyedRead.key 214 | // ^^^^^^^^^^^^^^^^^^ <- keyedRead.receiver 215 | // 216 | // html examples: 217 | // - { key1: 'value1' | translate, key2: 'value2' | translate }[key] 218 | // - [ 'value1' | translate, 'value2' | translate ][key] 219 | // - [ 'foo', 'bar' ][ 'key' | translate ] 220 | if (ast instanceof KeyedRead) { 221 | return this.getTranslatablesFromAsts([ast.receiver, ast.key]); 222 | } 223 | 224 | if(ast instanceof ParenthesizedExpression) { 225 | return this.getTranslatablesFromAsts([ast.expression]); 226 | } 227 | 228 | return []; 229 | } 230 | 231 | protected getTranslatablesFromAsts(asts: AST[]): BindingPipe[] { 232 | return this.flatten(asts.map((ast) => this.getTranslatablesFromAst(ast))); 233 | } 234 | 235 | protected flatten(array: T[][]): T[] { 236 | return [].concat(...array); 237 | } 238 | 239 | protected parseTemplate(template: string, path: string): TmplAstNode[] { 240 | return parseTemplate(template, path).nodes; 241 | } 242 | 243 | /** Checks whether a Binary node uses a logical (&&, ||) or nullish coalescing (??) operator. */ 244 | protected isLogicalOrNullishCoalescingExpression(expr: unknown): expr is Binary { 245 | return ( 246 | expr instanceof Binary && 247 | (expr.operation === '&&' || expr.operation === '||' || expr.operation === '??') 248 | ); 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/cli/cli.ts: -------------------------------------------------------------------------------- 1 | import yargs from 'yargs'; 2 | 3 | import { ExtractTask } from './tasks/extract.task.js'; 4 | import { ParserInterface } from '../parsers/parser.interface.js'; 5 | import { PipeParser } from '../parsers/pipe.parser.js'; 6 | import { DirectiveParser } from '../parsers/directive.parser.js'; 7 | import { ServiceParser } from '../parsers/service.parser.js'; 8 | import { MarkerParser } from '../parsers/marker.parser.js'; 9 | import { FunctionParser } from '../parsers/function.parser.js'; 10 | import { PostProcessorInterface } from '../post-processors/post-processor.interface.js'; 11 | import { SortByKeyPostProcessor } from '../post-processors/sort-by-key.post-processor.js'; 12 | import { KeyAsDefaultValuePostProcessor } from '../post-processors/key-as-default-value.post-processor.js'; 13 | import { KeyAsInitialDefaultValuePostProcessor } from '../post-processors/key-as-initial-default-value.post-processor.js'; 14 | import { NullAsDefaultValuePostProcessor } from '../post-processors/null-as-default-value.post-processor.js'; 15 | import { StringAsDefaultValuePostProcessor } from '../post-processors/string-as-default-value.post-processor.js'; 16 | import { PurgeObsoleteKeysPostProcessor } from '../post-processors/purge-obsolete-keys.post-processor.js'; 17 | import { StripPrefixPostProcessor } from '../post-processors/strip-prefix.post-processor.js'; 18 | import { CompilerInterface, CompilerType } from '../compilers/compiler.interface.js'; 19 | import { CompilerFactory } from '../compilers/compiler.factory.js'; 20 | import { green, red } from '../utils/cli-color.js'; 21 | import { normalizePaths } from '../utils/fs-helpers.js'; 22 | import { FileCache } from '../cache/file-cache.js'; 23 | import { TranslationType } from '../utils/translation.collection.js'; 24 | 25 | // First parsing pass to be able to access pattern argument for use input/output arguments 26 | const y = yargs().option('patterns', { 27 | alias: 'p', 28 | describe: 'Default patterns', 29 | type: 'array', 30 | default: ['/**/*.html', '/**/*.ts'], 31 | string: true, 32 | hidden: true 33 | }); 34 | 35 | const parsed = await y.parse(); 36 | 37 | const cli = await y 38 | .usage('Extract strings from files for translation.\nUsage: $0 [options]') 39 | .version() 40 | .alias('version', 'v') 41 | .help('help') 42 | .alias('help', 'h') 43 | .option('input', { 44 | alias: 'i', 45 | describe: 'Paths you would like to extract strings from. You can use path expansion, glob patterns and multiple paths', 46 | default: [process.env.PWD], 47 | type: 'array', 48 | normalize: true, 49 | demandOption: true 50 | }) 51 | .coerce('input', (input: string[]) => normalizePaths(input, parsed.patterns)) 52 | .option('output', { 53 | alias: 'o', 54 | describe: 'Paths where you would like to save extracted strings. You can use path expansion, glob patterns and multiple paths', 55 | type: 'array', 56 | normalize: true, 57 | demandOption: true 58 | }) 59 | .coerce('output', (output: string[]) => normalizePaths(output, parsed.patterns)) 60 | .option('format', { 61 | alias: 'f', 62 | describe: 'Format', 63 | default: CompilerType.Json, 64 | choices: [CompilerType.Json, CompilerType.NamespacedJson, CompilerType.Pot] 65 | }) 66 | .option('format-indentation', { 67 | alias: 'fi', 68 | describe: 'Format indentation (JSON/Namedspaced JSON)', 69 | default: '\t', 70 | type: 'string' 71 | }) 72 | .option('replace', { 73 | alias: 'r', 74 | describe: 'Replace the contents of output file if it exists (Merges by default)', 75 | type: 'boolean' 76 | }) 77 | .option('sort', { 78 | alias: 's', 79 | describe: 'Sort strings in alphabetical order', 80 | type: 'boolean' 81 | }) 82 | .option('sort-sensitivity', { 83 | alias: 'ss', 84 | describe: 'Sort sensitivitiy of strings (only to be used when sorting)', 85 | type: 'string', 86 | choices: ['base', 'accent', 'case', 'variant'], 87 | default: undefined 88 | }) 89 | .option('po-source-locations', { 90 | describe: 'Include file location comments in .po files', 91 | type: 'boolean', 92 | default: true, 93 | }) 94 | .option('clean', { 95 | alias: 'c', 96 | describe: 'Remove obsolete strings after merge', 97 | type: 'boolean' 98 | }) 99 | .option('cache-file', { 100 | describe: 'Cache parse results to speed up consecutive runs', 101 | type: 'string' 102 | }) 103 | .option('marker', { 104 | alias: 'm', 105 | describe: 'Name of a custom marker function for extracting strings', 106 | type: 'string', 107 | default: undefined 108 | }) 109 | .option('key-as-default-value', { 110 | alias: 'k', 111 | describe: 'Use key as default value', 112 | type: 'boolean', 113 | conflicts: ['key-as-initial-default-value', 'null-as-default-value', 'string-as-default-value'] 114 | }) 115 | .option('key-as-initial-default-value', { 116 | alias: 'ki', 117 | describe: 'Use key as initial default value', 118 | type: 'boolean', 119 | conflicts: ['key-as-default-value', 'null-as-default-value', 'string-as-default-value'] 120 | }) 121 | .option('null-as-default-value', { 122 | alias: 'n', 123 | describe: 'Use null as default value', 124 | type: 'boolean', 125 | conflicts: ['key-as-default-value', 'key-as-initial-default-value', 'string-as-default-value'] 126 | }) 127 | .option('string-as-default-value', { 128 | alias: 'd', 129 | describe: 'Use string as default value', 130 | type: 'string', 131 | conflicts: ['null-as-default-value', 'key-as-default-value', 'key-as-initial-default-value'] 132 | }) 133 | .option('strip-prefix', { 134 | alias: 'sp', 135 | describe: 'Strip a prefix from the extracted key', 136 | type: 'string' 137 | }) 138 | .option('trailing-newline', { 139 | describe: 'Add a trailing newline to the output', 140 | type: 'boolean', 141 | default: false 142 | }) 143 | .group(['format', 'format-indentation', 'sort', 'sort-sensitivity', 'clean', 'replace', 'strip-prefix', 'trailing-newline', 'po-source-locations'], 'Output') 144 | .group(['key-as-default-value', 'key-as-initial-default-value', 'null-as-default-value', 'string-as-default-value'], 'Extracted key value (defaults to empty string)') 145 | .conflicts('key-as-default-value', 'null-as-default-value') 146 | .conflicts('key-as-initial-default-value', 'null-as-default-value') 147 | .example('$0 -i ./src-a/ -i ./src-b/ -o strings.json', 'Extract (ts, html) from multiple paths') 148 | .example("$0 -i './{src-a,src-b}/' -o strings.json", 'Extract (ts, html) from multiple paths using brace expansion') 149 | .example('$0 -i ./src/ -o ./i18n/da.json -o ./i18n/en.json', 'Extract (ts, html) and save to da.json and en.json') 150 | .example("$0 -i ./src/ -o './i18n/{en,da}.json'", 'Extract (ts, html) and save to da.json and en.json using brace expansion') 151 | .example("$0 -i './src/**/*.{ts,tsx,html}' -o strings.json", 'Extract from ts, tsx and html') 152 | .example("$0 -i './src/**/!(*.spec).{ts,html}' -o strings.json", 'Extract from ts, html, excluding files with ".spec" in filename') 153 | .example("$0 -i ./src/ -o strings.json -sp 'PREFIX.'", "Strip the prefix 'PREFIX.' from the json keys") 154 | .wrap(110) 155 | .exitProcess(true) 156 | .parse(process.argv); 157 | 158 | const extractTask = new ExtractTask(cli.input, cli.output, { 159 | replace: cli.replace 160 | }); 161 | 162 | // Parsers 163 | const parsers: ParserInterface[] = [new PipeParser(), new DirectiveParser(), new ServiceParser()]; 164 | if (cli.marker) { 165 | parsers.push(new FunctionParser(cli.marker)); 166 | } else { 167 | parsers.push(new MarkerParser()); 168 | } 169 | extractTask.setParsers(parsers); 170 | 171 | if (cli.cacheFile) { 172 | extractTask.setCache(new FileCache(cli.cacheFile)); 173 | } 174 | 175 | // Post processors 176 | const postProcessors: PostProcessorInterface[] = []; 177 | if (cli.clean) { 178 | postProcessors.push(new PurgeObsoleteKeysPostProcessor()); 179 | } 180 | if (cli.keyAsDefaultValue) { 181 | postProcessors.push(new KeyAsDefaultValuePostProcessor()); 182 | } else if (cli.keyAsInitialDefaultValue) { 183 | postProcessors.push(new KeyAsInitialDefaultValuePostProcessor()); 184 | } else if (cli.nullAsDefaultValue) { 185 | postProcessors.push(new NullAsDefaultValuePostProcessor()); 186 | } else if (cli.stringAsDefaultValue) { 187 | postProcessors.push(new StringAsDefaultValuePostProcessor({ defaultValue: cli.stringAsDefaultValue as string })); 188 | } 189 | 190 | if (cli.stripPrefix) { 191 | postProcessors.push(new StripPrefixPostProcessor({ prefix: cli.stripPrefix as string })); 192 | } 193 | 194 | if (cli.sort) { 195 | postProcessors.push(new SortByKeyPostProcessor(cli.sortSensitivity)); 196 | } 197 | extractTask.setPostProcessors(postProcessors); 198 | 199 | // Compiler 200 | const compiler: CompilerInterface = CompilerFactory.create(cli.format, { 201 | indentation: cli.formatIndentation, 202 | trailingNewline: cli.trailingNewline, 203 | poSourceLocation: cli.poSourceLocations, 204 | }); 205 | extractTask.setCompiler(compiler); 206 | 207 | // Run task 208 | try { 209 | extractTask.execute(); 210 | console.log(green('\nDone.\n')); 211 | process.exit(0); 212 | } catch (e) { 213 | console.log(red(`\nAn error occurred: ${e}\n`)); 214 | process.exit(1); 215 | } 216 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ngx-translate-extract 2 | 3 | [![npm](https://img.shields.io/npm/v/%40vendure%2Fngx-translate-extract)](https://www.npmjs.com/package/@vendure/ngx-translate-extract) 4 | [![build status](https://github.com/vendure-ecommerce/ngx-translate-extract/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/vendure-ecommerce/ngx-translate-extract/actions/workflows/ci.yml) 5 | [![Download](https://img.shields.io/npm/dm/%40vendure%2Fngx-translate-extract)](https://www.npmjs.com/package/%40vendure%2Fngx-translate-extract) 6 | 7 | > Angular translations extractor (plugin for [@ngx-translate](https://github.com/ngx-translate/core)) 8 | 9 | > ✓ _Compatible with server-side rendering (SSR/SSG)_ 10 | 11 | Extract translatable (ngx-translate) strings and save as a JSON or Gettext pot file. 12 | Merges with existing strings if the output file already exists. 13 | 14 | ## History 15 | 16 | This project was originally created by [Kim Biesbjerg](https://github.com/biesbjerg/ngx-translate-extract). 17 | Unfortunately he was unable to continue to maintain it so the Vendure team [agreed to take over maintenance](https://github.com/biesbjerg/ngx-translate-extract/issues/246#issuecomment-1211682548) of this fork. 18 | 19 | ## Install 20 | 21 | Install the package in your project: 22 | 23 | ```bash 24 | npm install @vendure/ngx-translate-extract --save-dev 25 | # or 26 | yarn add @vendure/ngx-translate-extract --dev 27 | ``` 28 | 29 | Choose the version corresponding to your Angular version: 30 | 31 | | Angular | ngx-translate-extract | 32 | |---------|--------------------------------------------------------------------------------------------| 33 | | >= 20 | 10.x | 34 | | 17 - 19 | 9.x | 35 | | 13 – 16 | 8.x | 36 | | 8 – 12 | [@biesbjerg/ngx-translate-extract](https://github.com/biesbjerg/ngx-translate-extract) 7.x | 37 | 38 | Add a script to your project's `package.json`: 39 | 40 | ```json 41 | "scripts": { 42 | "i18n:init": "ngx-translate-extract --input ./src --output ./src/assets/i18n/template.json --key-as-default-value --replace --format json", 43 | "i18n:extract": "ngx-translate-extract --input ./src --output ./src/assets/i18n/{en,da,de,fi,nb,nl,sv}.json --clean --format json" 44 | } 45 | ``` 46 | 47 | You can now run `npm run i18n:extract` and it will extract strings from your project. 48 | 49 | ## Usage 50 | 51 | **Extract from dir and save to file** 52 | 53 | ```bash 54 | ngx-translate-extract --input ./src --output ./src/assets/i18n/strings.json 55 | ``` 56 | 57 | **Extract from multiple dirs** 58 | 59 | ```bash 60 | ngx-translate-extract --input ./src-a ./src-b --output ./src/assets/i18n/strings.json 61 | ``` 62 | 63 | **Extract and save to multiple files using path expansion** 64 | 65 | ```bash 66 | ngx-translate-extract --input ./src --output ./src/i18n/{da,en}.json 67 | ``` 68 | 69 | **Strip prefix from the generated json keys** 70 | 71 | Useful when loading multiple translation files in the same application and prefixing them automatically 72 | 73 | ```bash 74 | ngx-translate-extract --input ./src --output ./src/i18n/{da,en}.json --strip-prefix 'PREFIX.' 75 | ``` 76 | 77 | **Cache for consecutive runs** 78 | 79 | If your project grows rather large, runs can take seconds. With this cache, unchanged files don't need 80 | to be parsed again, keeping consecutive runs under .5 seconds. 81 | 82 | ```bash 83 | ngx-translate-extract --cache-file node_modules/.i18n-cache/my-cache-file --input ./src --output ./src/i18n/{da,en}.json 84 | ``` 85 | 86 | ### JSON indentation 87 | 88 | Tabs are used by default for indentation when saving extracted strings in json formats: 89 | 90 | If you want to use spaces instead, you can do the following: 91 | 92 | ```bash 93 | ngx-translate-extract --input ./src --output ./src/i18n/en.json --format-indentation ' ' 94 | ``` 95 | 96 | ### Sorting 97 | 98 | Extracted keys are by default not sorted. You can enable sorting by using the `--sort` or `-s` flag. 99 | 100 | If sorting is enabled, the keys will be sorted using the default variant sort sensitivity. Other sort sensitivity options are also available using the `--sort-sensitivity` or `-ss` flag: 101 | - `base`: Strings that differ in base letters are unequal. For example `a !== b`, `a === á`, `a === A` 102 | - `accent`: Strings that differ in base letters and accents are unequal. For example `a !== b`, `a !== á`, `a === A` 103 | - `case`: Strings that differ in base letters or casing are unequal. For example `a !== b`, `a === á`, `a !== A` 104 | - `variant`: Strings that differ in base letters, accents, or casing are unequal. For example `a !== b`, `a !== á`, `a !== A` 105 | 106 | ### Marker function 107 | 108 | If you want to extract strings that are not passed directly to `NgxTranslate.TranslateService`'s 109 | `get()`/`instant()`/`stream()` methods, or its `translate` pipe or directive, you can wrap them 110 | in the marker function from `@ngx-translate/core` to let `ngx-translate-extract` know you want to extract them. 111 | 112 | **Example:** _Using the marker function_ 113 | ```typescript 114 | // your-component.ts 115 | import { Component, inject } from '@angular/core'; 116 | import { TranslateService, _ } from '@ngx-translate/core'; 117 | 118 | const welcomeMessage = _('app.your-component.welcome'); 119 | 120 | @Component({ 121 | selector: 'your-component', 122 | template: `

{{ message }}

`, 123 | }) 124 | export class YourComponent { 125 | private readonly translate = inject(TranslateService); 126 | 127 | readonly message = this.translate.instant(welcomeMessage); 128 | } 129 | ``` 130 | 131 | For more advanced use cases where a marker pipe or directive is required, or if you are using a version of `ngx-translate` 132 | prior to v16, you can use the following library: 133 | 134 | ```bash 135 | npm install @colsen1991/ngx-translate-extract-marker 136 | ``` 137 | 138 | See [@colsen1991/ngx-translate-extract-marker](https://github.com/colsen1991/ngx-translate-extract-marker/blob/master/README.md) documentation for more information. 139 | 140 | ### Commandline arguments 141 | 142 | ```bash 143 | Usage: 144 | ngx-translate-extract [options] 145 | 146 | Output 147 | --format, -f Format [string] [choices: "json", "namespaced-json", "pot"] [default: "json"] 148 | --format-indentation, --fi Format indentation (JSON/Namedspaced JSON) [string] [default: "\t"] 149 | --sort, -s Sort strings in alphabetical order [boolean] 150 | --sort-sensitivity, -ss Sensitivity when sorting strings (only when sort is enabled) [string] 151 | --clean, -c Remove obsolete strings after merge [boolean] 152 | --replace, -r Replace the contents of output file if it exists (Merges by default) [boolean] 153 | --strip-prefix, -sp Strip prefix from key [string] 154 | --trailing-newline Add a trailing newline to the output. [boolean] 155 | --po-source-locations Include file location comments in .po files [boolean] [default: true] 156 | 157 | Extracted key value (defaults to empty string) 158 | --key-as-default-value, -k Use key as default value [boolean] 159 | --key-as-initial-default-value, -ki Use key as initial default value [boolean] 160 | --null-as-default-value, -n Use null as default value [boolean] 161 | --string-as-default-value, -d Use string as default value [string] 162 | 163 | Options: 164 | --version, -v Show version number [boolean] 165 | --help, -h Show help [boolean] 166 | --input, -i Paths you would like to extract strings from. You can use path expansion, glob patterns and 167 | multiple paths [array] [required] [default: ["./"]] 168 | --output, -o Paths where you would like to save extracted strings. You can use path expansion, glob 169 | patterns and multiple paths [array] [required] 170 | --cache-file Cache parse results to speed up consecutive runs [string] 171 | --marker, -m Custom marker function name [string] 172 | 173 | Examples: 174 | ngx-translate-extract -i ./src-a/ -i ./src-b/ -o strings.json Extract (ts, html) from multiple paths 175 | ngx-translate-extract -i './{src-a,src-b}/' -o strings.json Extract (ts, html) from multiple paths using brace expansion 176 | ngx-translate-extract -i ./src/ -o ./i18n/da.json -o ./i18n/en.json Extract (ts, html) and save to da.json and en.json 177 | ngx-translate-extract -i ./src/ -o './i18n/{en,da}.json' Extract (ts, html) and save to da.json and en.json using brace expansion 178 | ngx-translate-extract -i './src/**/*.{ts,tsx,html}' -o strings.json Extract from ts, tsx and html 179 | ngx-translate-extract -i './src/**/!(*.spec).{ts,html}' -o strings.json Extract from ts, html, excluding files with ".spec" 180 | ngx-translate-extract -i './src/' -o strings.json -sp 'PREFIX.' Strip the prefix "PREFIX." from the json keys 181 | ``` 182 | 183 | ## Note for GetText users 184 | 185 | Please pay attention of which version of `gettext-parser` you actually use in your project. 186 | For instance, `gettext-parser:1.2.2` does not support HTML tags in translation keys. 187 | 188 | ## Credits 189 | 190 | - Original library, idea and code: [Kim Biesbjerg](https://github.com/biesbjerg/ngx-translate-extract) ❤️ 191 | - Further updates and improvements by [bartholomej](https://github.com/bartholomej) ❤️ 192 | - Further updates and improvements by [P4](https://github.com/P4) ❤️ 193 | - Further updates and improvements by [colsen1991](https://github.com/colsen1991) ❤️ 194 | - Further updates and improvements by [tmijieux](https://github.com/tmijieux) ❤️ 195 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v10.1.2 (2025-12-04) 4 | 5 | - Fix directive parser incorrectly extracting strings from attribute bindings (e.g. `[attr.translate]`), as these are not processed by `ngx-translate` ([#121](https://github.com/vendure-ecommerce/ngx-translate-extract/issues/121)) 6 | 7 | ## v10.1.1 (2025-11-17) 8 | 9 | - Fix service parser incorrectly extracting strings from method calls on non-`TranslateService` types ([#116](https://github.com/vendure-ecommerce/ngx-translate-extract/issues/116)) 10 | - Fix service parser not detecting keys when `TranslateService` is provided via `inject()` inside the constructor ([#113](https://github.com/vendure-ecommerce/ngx-translate-extract/issues/113)) 11 | - Replace `tsconfig` and `JSON5` packages with TypeScript's built-in configuration utilities ([#115](https://github.com/vendure-ecommerce/ngx-translate-extract/issues/115)) 12 | - Replace `colorette` with Node's built-in `util.styleText` ([#112](https://github.com/vendure-ecommerce/ngx-translate-extract/issues/112)) 13 | 14 | ## v10.1.0 (2025-09-09) 15 | 16 | - Add new `--trailing-newline` option to control whether a trailing newline is added to the output ([#104](https://github.com/vendure-ecommerce/ngx-translate-extract/issues/104)) 17 | - Exclude empty strings from extracted keys ([#105](https://github.com/vendure-ecommerce/ngx-translate-extract/issues/105)) 18 | 19 | ## v10.0.1 (2025-07-29) 20 | 21 | - Avoid redundant property lookups on parent class in service parser ([#99](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/99)) 22 | - Locate `package.json` in new location for the `--cache-file` option ([#96](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/96)) 23 | - Fix version resolution for the `--version` option ([#97](https://github.com/vendure-ecommerce/ngx-translate-extract/issues/97)) 24 | - Fix parser not detecting keys when the `translate` pipe is used within logical expressions (`&&`, `||`, `??`) ([#94](https://github.com/vendure-ecommerce/ngx-translate-extract/issues/94)) 25 | 26 | ## v10.0.0 (2025-07-11) 27 | 28 | - Add support for Angular 20 ([#77](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/77)) 29 | - Add trailing newline to `.po` output files to ensure POSIX compliance ([#70](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/70)) 30 | 31 | **BREAKING CHANGES** 32 | 33 | - Minimum Angular version required is now 20. 34 | - Minimum Node.js version required is now v20.19.0 to align with Angular 20 requirements. 35 | - Minimum TypeScript version required is now v5.8 to align with Angular 20 requirements. 36 | 37 | ## v9.4.2 (2025-06-30) 38 | 39 | - Prevent overwriting of existing translations in namespaced-json format ([#85](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/85)) 40 | 41 | ## v9.4.1 (2025-06-27) 42 | 43 | - Fix parser not detecting `TranslateService` when used via inline-injection ([#74](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/74)) 44 | - Fix issue where brace patterns were ignored due to escaped braces on Windows ([#72](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/72)) 45 | 46 | ## v9.4.0 (2024-12-17) 47 | 48 | - Use relative paths in .po file source comments 49 | - Add `po-source-locations` CLI option to control whether source locations are included in .po files ([#63](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/63)) 50 | 51 | ## v9.3.1 (2024-11-19) 52 | 53 | - Resolve runtime error with CommonJS module imports from 'typescript' ([#60](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/60)) 54 | - Fix extraction of translation keys from nested function expressions ([#61](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/61)) 55 | 56 | 57 | ## v9.3.0 (2024-11-18) 58 | 59 | - Fix parser not locating `TranslateService` in private fields using the `#` syntax ([#55](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/55)) 60 | - Add support for the ngx-translate `_()` marker function ([#57](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/57)) 61 | 62 | ## v9.2.1 (2024-07-19) 63 | 64 | - Fix service parser to recognize the TranslateService property from an aliased superclass ([#53](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/53)) 65 | 66 | ## v9.2.0 (2024-06-10) 67 | 68 | Contains all changes from `v9.2.0-next.0` plus: 69 | 70 | - Make sort sensitivity opt-in and configurable ([#41](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/41)) 71 | - Fix service and function parsing when used after bracket syntax casting expression ([#51](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/51)) 72 | 73 | ## v9.2.0-next.0 (2024-05-21) 74 | 75 | This is a pre-release available as `@vendure/ngx-translate-extract@next`. Due to some significant refactors to internals, 76 | we are releasing a pre-release version to allow for testing before the final release. 77 | 78 | It contains the following changes: 79 | 80 | - Support finding translations pipe in KeyedRead nodes ([#47](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/47)) 81 | - Fix marker function parsing when used after bracket syntax casting expression ([#45](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/45)) 82 | - Add key-as-initial-default-value flag ([#49](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/49)) 83 | - Add support for extraction of translation keys from function expressions ([#46](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/46)) 84 | 85 | 86 | ## v9.1.1 (2024-03-08) 87 | 88 | - Fix TranslateService not resolved when injected with readonly keyword ([#39](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/39)) 89 | 90 | ## v9.1.0 (2024-02-05) 91 | 92 | - Add support for caching via the new `--cache-file` option ([#38](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/38)) 93 | 94 | ## v9.0.3 (2023-11-28) 95 | 96 | - Fix `RangeError: Maximum call stack size exceeded` on nested templates ([#34](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/34)) 97 | - Fix alphabetical order of extracted keys ([#35](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/35)) 98 | 99 | ## v9.0.2 (2023-11-24) 100 | 101 | - Fix import from glob packages ([#31](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/31)) 102 | - Fix extract for Windows file paths ([#32](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/32)) 103 | 104 | ## v9.0.1 (2023-11-23) 105 | 106 | - Update dependencies & removed unused dependencies ([#29](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/29)) 107 | - fix: Fix syntax error when parsing tsconfig file ([#30](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/30)) Fixes [#24](https://github.com/vendure-ecommerce/ngx-translate-extract/issues/24) 108 | 109 | ## v9.0.0 (2023-11-21) 110 | 111 | - feat: Add support for new Angular v17 control flow syntax ([#27](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/27)) 112 | 113 | **BREAKING CHANGES** 114 | 115 | - minimum angular version required bumped to 17 116 | - minimum node version required bumped to v18.13.0 to be aligned with the Angular 17 requirements 117 | - minimum TypeScript version required bumped to v5.2 to be aligned with the Angular 17 requirements 118 | 119 | ## v8.3.0 (2023-11-21) 120 | - Add support for the `--strip-prefix` option ([#23](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/23)) 121 | 122 | ## v8.2.3 (2023-09-27) 123 | - Enable extraction from subclasses without declaration ([#21](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/21)) 124 | - Fix chained function calls ([#21](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/21)) 125 | - Add tests ([#21](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/21)) 126 | - Extract translations when service injected using `inject()` function ([#22](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/22)) 127 | 128 | ## v8.2.2 (2023-08-10) 129 | - Fix extraction error with --null-as-default-value ([#18](https://github.com/vendure-ecommerce/ngx-translate-extract/issues/18)) 130 | 131 | ## v8.2.1 (2023-07-21) 132 | - Fix extraction error introduced in the last version ([#14](https://github.com/vendure-ecommerce/ngx-translate-extract/issues/14)) 133 | - Add `braces` to dependencies ([#9](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/9)) 134 | 135 | ## v8.2.0 (2023-07-03) 136 | - Add source locations in PO compiler output ([#13](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/13)) 137 | 138 | ## v8.1.1 (2023-05-11) 139 | 140 | - Update tsquery dependency to allow usage with TypeScript v5 ([#10](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/10)) 141 | 142 | ## v8.1.0 (2023-03-15) 143 | 144 | - Accommodate marker pipe and directive 145 | - Enable support for other marker packages apart from the original from [Kim Biesbjerg](https://github.com/biesbjerg/ngx-translate-extract-marker) 146 | - Merged [P4's](https://github.com/P4) PRs ([#1](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/1), [#2](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/2)) in order to improve the pipe parser when it comes to pipe args and structural directives 147 | - Fixed some botched imports 148 | - Re-added --marker/-m option to CLI thanks to [tmijieux's](https://github.com/tmijieux) [PR](https://github.com/colsen1991/ngx-translate-extract/pull/1) 149 | - Moved to eslint and fixed errors/warnings 150 | - Other minor clerical changes and small refactoring 151 | - Remove dependency on a specific version of the Angular compiler. Instead, we rely on the peer dependency. [#3](https://github.com/vendure-ecommerce/ngx-translate-extract/issues/3) 152 | 153 | ## v8.0.5 (2023-03-02) 154 | 155 | - fix(pipe-parser): Search for pipe in structural directives [#1](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/1) 156 | 157 | This fix will now detect the pipe in code like this: 158 | 159 | ``` 160 | 163 | ``` 164 | 165 | - fix: Find uses of translate pipe in pipe arguments [#2](https://github.com/vendure-ecommerce/ngx-translate-extract/pull/2) 166 | 167 | Fixes the following: 168 | 169 | 170 | ```angular2html 171 | {{ 'value' | testPipe: ('test1' | translate) }} // finds nothing, misses 'test1' 172 | {{ 'Hello' | translate: {world: ('World' | translate)} }} // finds 'Hello', misses 'World' 173 | {{ 'previewHeader' | translate:{filename: filename || ('video' | translate)} }} // finds 'previewHeader', misses 'video' 174 | ``` 175 | 176 | ## v8.0.3 (2022-12-15) 177 | 178 | - First package published under the @vendure namespace 179 | - Update references in README 180 | 181 | ## v8 - v8.0.2 182 | 183 | - Support for Angular 13 + 14 added by https://github.com/bartholomej 184 | 185 | ## Prior to v8 186 | 187 | See the [releases in the original repo](https://github.com/biesbjerg/ngx-translate-extract/releases). 188 | -------------------------------------------------------------------------------- /src/utils/ast-helpers.ts: -------------------------------------------------------------------------------- 1 | import { extname } from 'node:path'; 2 | import { ScriptKind, tsquery } from '@phenomnomnominal/tsquery'; 3 | import pkg, { 4 | Node, 5 | Identifier, 6 | ClassDeclaration, 7 | ConstructorDeclaration, 8 | CallExpression, 9 | Expression, 10 | StringLiteral, 11 | SourceFile, 12 | PropertyDeclaration, 13 | PropertyAccessExpression 14 | } from 'typescript'; 15 | 16 | // Importing non-type members from 'typescript' this way to prevent runtime errors such as: 17 | // `SyntaxError: Named export 'isCallExpression' not found. The requested module 'typescript' is a CommonJS module, 18 | // which may not support all module.exports as named exports.` 19 | const { 20 | isArrayLiteralExpression, 21 | isBinaryExpression, 22 | isCallExpression, 23 | isConditionalExpression, 24 | isPropertyAccessExpression, 25 | isStringLiteralLike, 26 | SyntaxKind 27 | } = pkg; 28 | 29 | export function getAST(source: string, fileName = ''): SourceFile { 30 | const supportedScriptTypes: Record = { 31 | '.js': ScriptKind.JS, 32 | '.jsx': ScriptKind.JSX, 33 | '.ts': ScriptKind.TS, 34 | '.tsx': ScriptKind.TSX 35 | }; 36 | 37 | const scriptKind = supportedScriptTypes[extname(fileName)] ?? ScriptKind.TS; 38 | 39 | return tsquery.ast(source, fileName, scriptKind); 40 | } 41 | 42 | /** 43 | * Retrieves the identifiers for the given module name from import statements within the provided AST node. 44 | */ 45 | export function getNamedImportIdentifiers(node: Node, moduleName: string, importPath: string | RegExp): Identifier[] { 46 | const importStringLiteralValue = importPath instanceof RegExp ? `value=${importPath.toString()}` : `value="${importPath}"`; 47 | 48 | const query = `ImportDeclaration:has(StringLiteral[${importStringLiteralValue}]) ImportSpecifier:has(Identifier[name="${moduleName}"]) > Identifier`; 49 | 50 | return tsquery(node, query); 51 | } 52 | 53 | /** 54 | * Retrieves the original named import from a given node, import name, and import path. 55 | * 56 | * @example 57 | * // Example import statement within a file 58 | * import { Base as CoreBase } from './src/base'; 59 | * 60 | * getNamedImport(node, 'Base', './src/base') -> 'Base' 61 | * getNamedImport(node, 'CoreBase', './src/base') -> 'Base' 62 | */ 63 | export function getNamedImport(node: Node, importName: string, importPath: string | RegExp): string | null { 64 | const identifiers = getNamedImportIdentifiers(node, importName, importPath); 65 | 66 | return identifiers.at(0)?.text ?? null; 67 | } 68 | 69 | /** 70 | * Retrieves the alias of the named import from a given node, import name, and import path. 71 | * 72 | * @example 73 | * // Example import statement within a file 74 | * import { Base as CoreBase } from './src/base'; 75 | * 76 | * getNamedImport(node, 'Base', './src/base') -> 'CoreBase' 77 | * getNamedImport(node, 'CoreBase', './src/base') -> 'CoreBase' 78 | */ 79 | export function getNamedImportAlias(node: Node, importName: string, importPath: string | RegExp): string | null { 80 | const identifiers = getNamedImportIdentifiers(node, importName, importPath); 81 | 82 | return identifiers.at(-1)?.text ?? null; 83 | } 84 | 85 | export function findClassDeclarations(node: Node, name: string = null): ClassDeclaration[] { 86 | let query = 'ClassDeclaration'; 87 | if (name) { 88 | query += `:has(Identifier[name="${name}"])`; 89 | } 90 | return tsquery(node, query); 91 | } 92 | 93 | export function findFunctionExpressions(node: Node) { 94 | return tsquery(node, 'VariableDeclaration ArrowFunction, VariableDeclaration FunctionExpression'); 95 | } 96 | 97 | export function getSuperClassName(node: Node): string | null { 98 | const query = 'ClassDeclaration > HeritageClause Identifier'; 99 | const [result] = tsquery(node, query); 100 | return result?.text; 101 | } 102 | 103 | export function getImportPath(node: Node, className: string): string | null { 104 | const query = `ImportDeclaration:has(Identifier[name="${className}"]) StringLiteral`; 105 | const [result] = tsquery(node, query); 106 | return result?.text; 107 | } 108 | 109 | export function findClassPropertiesByType(node: ClassDeclaration, type: string): string[] { 110 | return [ 111 | ...findClassPropertiesConstructorParameterByType(node, type), 112 | ...findClassPropertiesDeclarationByType(node, type), 113 | ...findClassPropertiesDeclarationByInject(node, type), 114 | ...findClassPropertiesGetterByType(node, type) 115 | ]; 116 | } 117 | 118 | export function findConstructorDeclaration(node: ClassDeclaration): ConstructorDeclaration { 119 | const query = 'Constructor'; 120 | const [result] = tsquery(node, query); 121 | return result; 122 | } 123 | 124 | export function findMethodParameterByType(node: Node, type: string): string | null { 125 | const query = `Parameter:has(TypeReference > Identifier[name="${type}"]) > Identifier`; 126 | const [result] = tsquery(node, query); 127 | if (result) { 128 | return result.text; 129 | } 130 | return null; 131 | } 132 | 133 | export function findVariableNameByInjectType(node: Node, type: string): string | null { 134 | const query = `VariableDeclaration:has(Identifier[name="inject"]):has(CallExpression > Identifier[name="${type}"]) > Identifier`; 135 | const [result] = tsquery(node, query); 136 | 137 | return result?.text ?? null; 138 | } 139 | 140 | export function findMethodCallExpressions(node: Node, propName: string, fnName: string | string[]): CallExpression[] { 141 | const functionNames = typeof fnName === 'string' ? [fnName] : fnName; 142 | 143 | const fnNameRegex = functionNames.join('|'); 144 | 145 | const query = `CallExpression > PropertyAccessExpression:has(Identifier[name=/^(${fnNameRegex})$/]):has(PropertyAccessExpression:has(Identifier[name="${propName}"]):not(:has(ThisKeyword)))`; 146 | 147 | return tsquery(node, query) 148 | .filter((n) => functionNames.includes(n.getLastToken().getText())) 149 | .map((n) => n.parent as CallExpression); 150 | } 151 | 152 | export function findInlineInjectCallExpressions(node: Node, injectType: string, fnName: string | string[]): CallExpression[] { 153 | const functionNames = typeof fnName === 'string' ? [fnName] : fnName; 154 | 155 | const fnNameRegex = functionNames.join('|'); 156 | 157 | const query = `CallExpression > PropertyAccessExpression:has(Identifier[name=/^(${fnNameRegex})$/]):has(CallExpression:has(Identifier[name="inject"]):has(Identifier[name="${injectType}"]))`; 158 | 159 | return tsquery(node, query) 160 | .filter((n) => functionNames.includes(n.getLastToken().getText())) 161 | .map((n) => n.parent as CallExpression); 162 | } 163 | 164 | export function findClassPropertiesConstructorParameterByType(node: ClassDeclaration, type: string): string[] { 165 | const query = `Constructor Parameter:has(TypeReference > Identifier[name="${type}"]):has(PublicKeyword,ProtectedKeyword,PrivateKeyword,ReadonlyKeyword) > Identifier`; 166 | const result = tsquery(node, query); 167 | return result.map((n) => n.text); 168 | } 169 | 170 | export function findClassPropertiesDeclarationByType(node: ClassDeclaration, type: string): string[] { 171 | const query = `PropertyDeclaration:has(TypeReference > Identifier[name="${type}"])`; 172 | const result = tsquery(node, query); 173 | return result.map((n) => n.name.getText()); 174 | } 175 | 176 | export function findClassPropertiesDeclarationByInject(node: ClassDeclaration, type: string): string[] { 177 | const query = `PropertyDeclaration:has(CallExpression > Identifier[name="inject"]):has(CallExpression > Identifier[name="${type}"])`; 178 | const result = tsquery(node, query); 179 | return result.map((n) => n.name.getText()); 180 | } 181 | 182 | export function findClassPropertiesGetterByType(node: ClassDeclaration, type: string): string[] { 183 | const query = `GetAccessor:has(TypeReference > Identifier[name="${type}"]) > Identifier`; 184 | const result = tsquery(node, query); 185 | return result.map((n) => n.text); 186 | } 187 | 188 | export function findFunctionCallExpressions(node: Node, fnName: string | string[]): CallExpression[] { 189 | if (Array.isArray(fnName)) { 190 | fnName = fnName.join('|'); 191 | } 192 | const query = `CallExpression:has(Identifier[name="${fnName}"]):not(:has(PropertyAccessExpression))`; 193 | return tsquery(node, query); 194 | } 195 | 196 | export function findSimpleCallExpressions(node: Node, fnName: string) { 197 | if (Array.isArray(fnName)) { 198 | fnName = fnName.join('|'); 199 | } 200 | const query = `CallExpression:has(Identifier[name="${fnName}"])`; 201 | return tsquery(node, query); 202 | } 203 | 204 | export function findPropertyCallExpressions(node: Node, prop: string, fnName: string | string[]): CallExpression[] { 205 | if (Array.isArray(fnName)) { 206 | fnName = fnName.join('|'); 207 | } 208 | 209 | const query = `CallExpression > PropertyAccessExpression:has(Identifier[name=/^(${fnName})$/]):has(PropertyAccessExpression:has(ThisKeyword))`; 210 | const result = tsquery(node, query); 211 | 212 | const nodes: CallExpression[] = []; 213 | result.forEach((n) => { 214 | const identifier = isPropertyAccessExpression(n.expression) ? n.expression.name : null; 215 | const property = identifier?.parent; 216 | const method = property?.parent; 217 | const callExpression = method?.parent; 218 | 219 | if (identifier?.getText() === prop && isCallExpression(callExpression)) { 220 | nodes.push(callExpression); 221 | } 222 | }); 223 | 224 | return nodes; 225 | } 226 | 227 | export function getStringsFromExpression(expression: Expression): string[] { 228 | if (isStringLiteralLike(expression) && expression.text !== '') { 229 | return [expression.text]; 230 | } 231 | 232 | if (isArrayLiteralExpression(expression)) { 233 | return expression.elements.reduce((result: string[], element: Expression) => { 234 | const strings = getStringsFromExpression(element); 235 | return [...result, ...strings]; 236 | }, []); 237 | } 238 | 239 | if (isBinaryExpression(expression)) { 240 | const [left] = getStringsFromExpression(expression.left); 241 | const [right] = getStringsFromExpression(expression.right); 242 | 243 | if (expression.operatorToken.kind === SyntaxKind.PlusToken) { 244 | if (typeof left === 'string' && typeof right === 'string') { 245 | return [left + right]; 246 | } 247 | } 248 | 249 | if (expression.operatorToken.kind === SyntaxKind.BarBarToken) { 250 | const result = []; 251 | if (typeof left === 'string') { 252 | result.push(left); 253 | } 254 | if (typeof right === 'string') { 255 | result.push(right); 256 | } 257 | return result; 258 | } 259 | } 260 | 261 | if (isConditionalExpression(expression)) { 262 | const [whenTrue] = getStringsFromExpression(expression.whenTrue); 263 | const [whenFalse] = getStringsFromExpression(expression.whenFalse); 264 | 265 | const result = []; 266 | if (typeof whenTrue === 'string') { 267 | result.push(whenTrue); 268 | } 269 | if (typeof whenFalse === 'string') { 270 | result.push(whenFalse); 271 | } 272 | return result; 273 | } 274 | return []; 275 | } 276 | -------------------------------------------------------------------------------- /tests/parsers/directive.parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, it } from 'vitest'; 2 | 3 | import { DirectiveParser, TRANSLATE_ATTR_NAMES } from '../../src/parsers/directive.parser.js'; 4 | 5 | describe('DirectiveParser', () => { 6 | const templateFilename: string = 'test.template.html'; 7 | const componentFilename: string = 'test.component.ts'; 8 | 9 | let parser: DirectiveParser; 10 | 11 | beforeEach(() => { 12 | parser = new DirectiveParser(); 13 | }); 14 | 15 | TRANSLATE_ATTR_NAMES.forEach((translateAttrName) => { 16 | describe(`with attribute name '${translateAttrName}'`, () => { 17 | it('should extract keys when using literal map in bound attribute', () => { 18 | const contents = `
`; 19 | const keys = parser.extract(contents, templateFilename).keys(); 20 | expect(keys).to.deep.equal(['value1', 'value2']); 21 | }); 22 | 23 | it('should extract keys when using literal arrays in bound attribute', () => { 24 | const contents = `
`; 25 | const keys = parser.extract(contents, templateFilename).keys(); 26 | expect(keys).to.deep.equal(['value1', 'value2']); 27 | }); 28 | 29 | it('should extract keys when using binding pipe in bound attribute', () => { 30 | const contents = `
`; 31 | const keys = parser.extract(contents, templateFilename).keys(); 32 | expect(keys).to.deep.equal(['KEY1']); 33 | }); 34 | 35 | it('should extract keys when using binary expression in bound attribute', () => { 36 | const contents = `
`; 37 | const keys = parser.extract(contents, templateFilename).keys(); 38 | expect(keys).to.deep.equal(['KEY1']); 39 | }); 40 | 41 | it('should extract keys when using literal primitive in bound attribute', () => { 42 | const contents = `
`; 43 | const keys = parser.extract(contents, templateFilename).keys(); 44 | expect(keys).to.deep.equal(['KEY1']); 45 | }); 46 | 47 | it('should not extract keys when using attr style attribute', () => { 48 | const contents = `
`; 49 | const keys = parser.extract(contents, templateFilename).keys(); 50 | expect(keys).to.deep.equal([]); 51 | }); 52 | 53 | it('should extract keys when using conditional in bound attribute', () => { 54 | const contents = `
`; 55 | const keys = parser.extract(contents, templateFilename).keys(); 56 | expect(keys).to.deep.equal(['KEY1', 'KEY2']); 57 | }); 58 | 59 | it('should extract keys when using nested conditionals in bound attribute', () => { 60 | const contents = `
`; 61 | const keys = parser.extract(contents, templateFilename).keys(); 62 | expect(keys).to.deep.equal(['Sunny and warm', 'Sunny but cold', 'Not sunny']); 63 | }); 64 | 65 | it('should extract keys when using interpolation', () => { 66 | const contents = `
`; 67 | const keys = parser.extract(contents, templateFilename).keys(); 68 | expect(keys).to.deep.equal(['KEY1', 'KEY3']); 69 | }); 70 | 71 | it('should extract keys keeping proper whitespace', () => { 72 | const contents = ` 73 |
74 | Wubba 75 | Lubba 76 | Dub Dub 77 |
78 | `; 79 | const keys = parser.extract(contents, templateFilename).keys(); 80 | expect(keys).to.deep.equal(['Wubba Lubba Dub Dub']); 81 | }); 82 | 83 | it('should use element contents as key when no translate attribute value is present', () => { 84 | const contents = `
Hello World
`; 85 | const keys = parser.extract(contents, templateFilename).keys(); 86 | expect(keys).to.deep.equal(['Hello World']); 87 | }); 88 | 89 | it('should use translate attribute value as key when present', () => { 90 | const contents = `
Hello World
`; 91 | const keys = parser.extract(contents, templateFilename).keys(); 92 | expect(keys).to.deep.equal(['MY_KEY']); 93 | }); 94 | 95 | it('should extract keys from child elements when translate attribute is present', () => { 96 | const contents = `
Hello World
`; 97 | const keys = parser.extract(contents, templateFilename).keys(); 98 | expect(keys).to.deep.equal(['Hello', 'World']); 99 | }); 100 | 101 | it('should not extract keys from child elements when translate attribute is not present', () => { 102 | const contents = `
Hello World
`; 103 | const keys = parser.extract(contents, templateFilename).keys(); 104 | expect(keys).to.deep.equal(['Hello']); 105 | }); 106 | 107 | it('should extract and parse inline template', () => { 108 | const contents = ` 109 | @Component({ 110 | selector: 'test', 111 | template: '

Hello World

' 112 | }) 113 | export class TestComponent { } 114 | `; 115 | const keys = parser.extract(contents, componentFilename).keys(); 116 | expect(keys).to.deep.equal(['Hello World']); 117 | }); 118 | 119 | it('should extract contents when no translate attribute value is provided', () => { 120 | const contents = `
Hello World
`; 121 | const keys = parser.extract(contents, templateFilename).keys(); 122 | expect(keys).to.deep.equal(['Hello World']); 123 | }); 124 | 125 | it('should extract translate attribute value if provided', () => { 126 | const contents = `
Hello World
`; 127 | const keys = parser.extract(contents, templateFilename).keys(); 128 | expect(keys).to.deep.equal(['KEY']); 129 | }); 130 | 131 | it('should not extract translate pipe in html tag', () => { 132 | const contents = `

{{ 'Audiobooks for personal development' | ${translateAttrName} }}

`; 133 | const collection = parser.extract(contents, templateFilename); 134 | expect(collection.values).to.deep.equal({}); 135 | }); 136 | 137 | it('should extract contents from custom elements', () => { 138 | const contents = `Hello World`; 139 | const keys = parser.extract(contents, templateFilename).keys(); 140 | expect(keys).to.deep.equal(['Hello World']); 141 | }); 142 | 143 | it('should extract from template without leading/trailing whitespace', () => { 144 | const contents = ` 145 |
There 146 | are currently no students in this class. The good news is, adding students is really easy! Just use the options 147 | at the top. 148 |
149 | `; 150 | const keys = parser.extract(contents, templateFilename).keys(); 151 | expect(keys).to.deep.equal([ 152 | 'There are currently no students in this class. The good news is, adding students is really easy! Just use the options at the top.' 153 | ]); 154 | }); 155 | 156 | it('should extract keys from element without leading/trailing whitespace', () => { 157 | const contents = ` 158 |
159 | this is an example 160 | of a long label 161 |
162 | 163 |
164 |

165 | this is an example 166 | of another a long label 167 |

168 |
169 | `; 170 | const keys = parser.extract(contents, templateFilename).keys(); 171 | expect(keys).to.deep.equal(['this is an example of a long label', 'this is an example of another a long label']); 172 | }); 173 | 174 | it('should collapse excessive whitespace', () => { 175 | const contents = `

this is an example

`; 176 | const keys = parser.extract(contents, templateFilename).keys(); 177 | expect(keys).to.deep.equal(['this is an example']); 178 | }); 179 | 180 | describe('Built-in control flow', () => { 181 | it('should extract keys from elements inside an @if/@else block', () => { 182 | const contents = ` 183 | @if (loggedIn) { 184 |

if.block

185 | } @else if (condition) { 186 |

elseif.block

187 | } @else { 188 |

else.block

189 | } 190 | `; 191 | 192 | const keys = parser.extract(contents, templateFilename)?.keys(); 193 | expect(keys).to.deep.equal(['if.block', 'elseif.block', 'else.block']); 194 | }); 195 | 196 | it('should extract keys from elements inside a @for/@empty block', () => { 197 | const contents = ` 198 | @for (user of users; track user.id) { 199 |

for.block

200 | } @empty { 201 |

for.empty.block

202 | } 203 | `; 204 | 205 | const keys = parser.extract(contents, templateFilename).keys(); 206 | expect(keys).to.deep.equal(['for.block', 'for.empty.block']); 207 | }); 208 | 209 | it('should extract keys from elements inside an @switch/@case block', () => { 210 | const contents = ` 211 | @switch (condition) { 212 | @case (caseA) { 213 |

switch.caseA

214 | } 215 | @case (caseB) { 216 |

switch.caseB

217 | } 218 | @default { 219 |

switch.default

220 | } 221 | } 222 | `; 223 | 224 | const keys = parser.extract(contents, templateFilename).keys(); 225 | expect(keys).to.deep.equal(['switch.caseA', 'switch.caseB', 'switch.default']); 226 | }); 227 | 228 | it('should extract keys from elements inside an @deferred/@error/@loading/@placeholder block', () => { 229 | const contents = ` 230 | @defer (on viewport) { 231 |

defer

232 | } @loading { 233 |

defer.loading

234 | } @error { 235 |

defer.error

236 | } @placeholder { 237 |

defer.placeholder

238 | } 239 | `; 240 | 241 | const keys = parser.extract(contents, templateFilename).keys(); 242 | expect(keys).to.deep.equal(['defer', 'defer.placeholder', 'defer.loading', 'defer.error']); 243 | }); 244 | 245 | it('should extract keys from nested blocks', () => { 246 | const contents = ` 247 | @if (loggedIn) { 248 |

if.block

249 | @if (nestedCondition) { 250 | @if (nestedCondition) { 251 |

nested.if.block

252 | } @else { 253 |

nested.else.block

254 | } 255 | } @else if (nestedElseIfCondition) { 256 |

nested.elseif.block

257 | } 258 | } @else if (condition) { 259 |

elseif.block

260 | } @else { 261 |

else.block

262 | } 263 | `; 264 | 265 | const keys = parser.extract(contents, templateFilename)?.keys(); 266 | expect(keys).to.deep.equal([ 267 | 'if.block', 268 | 'elseif.block', 269 | 'else.block', 270 | 'nested.elseif.block', 271 | 'nested.if.block', 272 | 'nested.else.block' 273 | ]); 274 | }); 275 | }); 276 | }); 277 | }); 278 | }); 279 | -------------------------------------------------------------------------------- /tests/post-processors/sort-by-key.post-processor.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, expect, it } from 'vitest'; 2 | 3 | import { PostProcessorInterface } from '../../src/post-processors/post-processor.interface.js'; 4 | import { SortByKeyPostProcessor } from '../../src/post-processors/sort-by-key.post-processor.js'; 5 | import { TranslationCollection } from '../../src/utils/translation.collection.js'; 6 | 7 | describe('SortByKeyPostProcessor - should throw error if sort sensitivity is not known', () => { 8 | it('should throw error', () => { 9 | expect(() => new SortByKeyPostProcessor('invalidSortSensitivityOption')).throw('Unknown sortSensitivity: invalidSortSensitivityOption'); 10 | }); 11 | }); 12 | 13 | describe('SortByKeyPostProcessor - undefined sort sensitivity should sort as variant sort sensitivity', () => { 14 | let processor: PostProcessorInterface; 15 | 16 | beforeEach(() => { 17 | processor = new SortByKeyPostProcessor(undefined); 18 | }); 19 | 20 | it('should sort keys alphanumerically', () => { 21 | const collection = new TranslationCollection({ 22 | z: { value: 'last value', sourceFiles: [] }, 23 | a: { value: 'a value', sourceFiles: [] }, 24 | '9': { value: 'a numeric key', sourceFiles: [] }, 25 | b: { value: 'another value', sourceFiles: [] } 26 | }); 27 | const extracted = new TranslationCollection(); 28 | const existing = new TranslationCollection(); 29 | 30 | // Assert all values are processed correctly 31 | expect(processor.process(collection, extracted, existing).values).to.deep.equal({ 32 | '9': { value: 'a numeric key', sourceFiles: [] }, 33 | a: { value: 'a value', sourceFiles: [] }, 34 | b: { value: 'another value', sourceFiles: [] }, 35 | z: { value: 'last value', sourceFiles: [] } 36 | }); 37 | 38 | // Assert all keys are in the correct order 39 | expect(processor.process(collection, extracted, existing).keys()).toStrictEqual(['9', 'a', 'b', 'z']); 40 | }); 41 | 42 | it('should perform variant sensitive sorting', () => { 43 | const collection = new TranslationCollection({ 44 | c: { value: 'letter c', sourceFiles: [] }, 45 | j: { value: 'letter j', sourceFiles: [] }, 46 | b: { value: 'letter b', sourceFiles: [] }, 47 | a: { value: 'letter a', sourceFiles: [] }, 48 | à: { value: 'letter à', sourceFiles: [] }, 49 | h: { value: 'letter h', sourceFiles: [] }, 50 | B: { value: 'letter B', sourceFiles: [] }, 51 | H: { value: 'letter H', sourceFiles: [] }, 52 | i: { value: 'letter i', sourceFiles: [] }, 53 | C: { value: 'letter C', sourceFiles: [] }, 54 | e: { value: 'letter e', sourceFiles: [] }, 55 | f: { value: 'letter f', sourceFiles: [] }, 56 | d: { value: 'letter d', sourceFiles: [] }, 57 | A: { value: 'letter A', sourceFiles: [] }, 58 | g: { value: 'letter g', sourceFiles: [] } 59 | }); 60 | 61 | // Assert all values are processed correctly 62 | expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).values).to.deep.equal({ 63 | A: { value: 'letter A', sourceFiles: [] }, 64 | a: { value: 'letter a', sourceFiles: [] }, 65 | à: { value: 'letter à', sourceFiles: [] }, 66 | B: { value: 'letter B', sourceFiles: [] }, 67 | b: { value: 'letter b', sourceFiles: [] }, 68 | c: { value: 'letter c', sourceFiles: [] }, 69 | C: { value: 'letter C', sourceFiles: [] }, 70 | d: { value: 'letter d', sourceFiles: [] }, 71 | e: { value: 'letter e', sourceFiles: [] }, 72 | f: { value: 'letter f', sourceFiles: [] }, 73 | g: { value: 'letter g', sourceFiles: [] }, 74 | H: { value: 'letter H', sourceFiles: [] }, 75 | h: { value: 'letter h', sourceFiles: [] }, 76 | i: { value: 'letter i', sourceFiles: [] }, 77 | j: { value: 'letter j', sourceFiles: [] } 78 | }); 79 | 80 | // Assert all keys are in the correct order 81 | expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).keys()).toStrictEqual([ 82 | 'A', 83 | 'B', 84 | 'C', 85 | 'H', 86 | 'a', 87 | 'b', 88 | 'c', 89 | 'd', 90 | 'e', 91 | 'f', 92 | 'g', 93 | 'h', 94 | 'i', 95 | 'j', 96 | 'à' 97 | ]); 98 | }); 99 | }); 100 | 101 | describe('SortByKeyPostProcessor - base sensitivity should treat all base characters as equal', () => { 102 | let processor: PostProcessorInterface; 103 | 104 | beforeEach(() => { 105 | processor = new SortByKeyPostProcessor('base'); 106 | }); 107 | 108 | it('should sort keys alphanumerically', () => { 109 | const collection = new TranslationCollection({ 110 | z: { value: 'last value', sourceFiles: [] }, 111 | a: { value: 'a value', sourceFiles: [] }, 112 | '9': { value: 'a numeric key', sourceFiles: [] }, 113 | b: { value: 'another value', sourceFiles: [] } 114 | }); 115 | const extracted = new TranslationCollection(); 116 | const existing = new TranslationCollection(); 117 | 118 | // Assert all values are processed correctly 119 | expect(processor.process(collection, extracted, existing).values).to.deep.equal({ 120 | '9': { value: 'a numeric key', sourceFiles: [] }, 121 | a: { value: 'a value', sourceFiles: [] }, 122 | b: { value: 'another value', sourceFiles: [] }, 123 | z: { value: 'last value', sourceFiles: [] } 124 | }); 125 | 126 | // Assert all keys are in the correct order 127 | expect(processor.process(collection, extracted, existing).keys()).toStrictEqual(['9', 'a', 'b', 'z']); 128 | }); 129 | 130 | it('should perform base sensitive sorting', () => { 131 | const collection = new TranslationCollection({ 132 | c: { value: 'letter c', sourceFiles: [] }, 133 | j: { value: 'letter j', sourceFiles: [] }, 134 | b: { value: 'letter b', sourceFiles: [] }, 135 | a: { value: 'letter a', sourceFiles: [] }, 136 | à: { value: 'letter à', sourceFiles: [] }, 137 | h: { value: 'letter h', sourceFiles: [] }, 138 | B: { value: 'letter B', sourceFiles: [] }, 139 | H: { value: 'letter H', sourceFiles: [] }, 140 | i: { value: 'letter i', sourceFiles: [] }, 141 | C: { value: 'letter C', sourceFiles: [] }, 142 | e: { value: 'letter e', sourceFiles: [] }, 143 | f: { value: 'letter f', sourceFiles: [] }, 144 | d: { value: 'letter d', sourceFiles: [] }, 145 | A: { value: 'letter A', sourceFiles: [] }, 146 | g: { value: 'letter g', sourceFiles: [] } 147 | }); 148 | 149 | // Assert all values are processed correctly 150 | expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).values).to.deep.equal({ 151 | c: { value: 'letter c', sourceFiles: [] }, 152 | j: { value: 'letter j', sourceFiles: [] }, 153 | b: { value: 'letter b', sourceFiles: [] }, 154 | a: { value: 'letter a', sourceFiles: [] }, 155 | à: { value: 'letter à', sourceFiles: [] }, 156 | h: { value: 'letter h', sourceFiles: [] }, 157 | B: { value: 'letter B', sourceFiles: [] }, 158 | H: { value: 'letter H', sourceFiles: [] }, 159 | i: { value: 'letter i', sourceFiles: [] }, 160 | C: { value: 'letter C', sourceFiles: [] }, 161 | e: { value: 'letter e', sourceFiles: [] }, 162 | f: { value: 'letter f', sourceFiles: [] }, 163 | d: { value: 'letter d', sourceFiles: [] }, 164 | A: { value: 'letter A', sourceFiles: [] }, 165 | g: { value: 'letter g', sourceFiles: [] } 166 | }); 167 | 168 | // Assert all keys are in the correct order 169 | expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).keys()).toStrictEqual([ 170 | 'a', 171 | 'à', 172 | 'A', 173 | 'b', 174 | 'B', 175 | 'c', 176 | 'C', 177 | 'd', 178 | 'e', 179 | 'f', 180 | 'g', 181 | 'h', 182 | 'H', 183 | 'i', 184 | 'j' 185 | ]); 186 | }); 187 | }); 188 | 189 | describe('SortByKeyPostProcessor - accent sensitivity should sort treat lowercase and uppercase as equal but accents and diacretics as not equal', () => { 190 | let processor: PostProcessorInterface; 191 | 192 | beforeEach(() => { 193 | processor = new SortByKeyPostProcessor('accent'); 194 | }); 195 | 196 | it('should sort keys alphanumerically', () => { 197 | const collection = new TranslationCollection({ 198 | z: { value: 'last value', sourceFiles: [] }, 199 | a: { value: 'a value', sourceFiles: [] }, 200 | '9': { value: 'a numeric key', sourceFiles: [] }, 201 | b: { value: 'another value', sourceFiles: [] } 202 | }); 203 | const extracted = new TranslationCollection(); 204 | const existing = new TranslationCollection(); 205 | 206 | // Assert all values are processed correctly 207 | expect(processor.process(collection, extracted, existing).values).to.deep.equal({ 208 | '9': { value: 'a numeric key', sourceFiles: [] }, 209 | a: { value: 'a value', sourceFiles: [] }, 210 | b: { value: 'another value', sourceFiles: [] }, 211 | z: { value: 'last value', sourceFiles: [] } 212 | }); 213 | 214 | // Assert all keys are in the correct order 215 | expect(processor.process(collection, extracted, existing).keys()).toStrictEqual(['9', 'a', 'b', 'z']); 216 | }); 217 | 218 | it('should perform accent sensitive sorting', () => { 219 | const collection = new TranslationCollection({ 220 | c: { value: 'letter c', sourceFiles: [] }, 221 | j: { value: 'letter j', sourceFiles: [] }, 222 | b: { value: 'letter b', sourceFiles: [] }, 223 | a: { value: 'letter a', sourceFiles: [] }, 224 | à: { value: 'letter à', sourceFiles: [] }, 225 | h: { value: 'letter h', sourceFiles: [] }, 226 | B: { value: 'letter B', sourceFiles: [] }, 227 | H: { value: 'letter H', sourceFiles: [] }, 228 | i: { value: 'letter i', sourceFiles: [] }, 229 | C: { value: 'letter C', sourceFiles: [] }, 230 | e: { value: 'letter e', sourceFiles: [] }, 231 | f: { value: 'letter f', sourceFiles: [] }, 232 | d: { value: 'letter d', sourceFiles: [] }, 233 | A: { value: 'letter A', sourceFiles: [] }, 234 | g: { value: 'letter g', sourceFiles: [] } 235 | }); 236 | 237 | // Assert all values are processed correctly 238 | expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).values).to.deep.equal({ 239 | c: { value: 'letter c', sourceFiles: [] }, 240 | j: { value: 'letter j', sourceFiles: [] }, 241 | b: { value: 'letter b', sourceFiles: [] }, 242 | a: { value: 'letter a', sourceFiles: [] }, 243 | à: { value: 'letter à', sourceFiles: [] }, 244 | h: { value: 'letter h', sourceFiles: [] }, 245 | B: { value: 'letter B', sourceFiles: [] }, 246 | H: { value: 'letter H', sourceFiles: [] }, 247 | i: { value: 'letter i', sourceFiles: [] }, 248 | C: { value: 'letter C', sourceFiles: [] }, 249 | e: { value: 'letter e', sourceFiles: [] }, 250 | f: { value: 'letter f', sourceFiles: [] }, 251 | d: { value: 'letter d', sourceFiles: [] }, 252 | A: { value: 'letter A', sourceFiles: [] }, 253 | g: { value: 'letter g', sourceFiles: [] } 254 | }); 255 | 256 | // Assert all keys are in the correct order 257 | expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).keys()).toStrictEqual([ 258 | 'a', 259 | 'A', 260 | 'à', 261 | 'b', 262 | 'B', 263 | 'c', 264 | 'C', 265 | 'd', 266 | 'e', 267 | 'f', 268 | 'g', 269 | 'h', 270 | 'H', 271 | 'i', 272 | 'j' 273 | ]); 274 | }); 275 | }); 276 | 277 | describe('SortByKeyPostProcessor - case sensitivity should treat lowercase and uppercase as not equal but accents and diacretics as equal', () => { 278 | let processor: PostProcessorInterface; 279 | 280 | beforeEach(() => { 281 | processor = new SortByKeyPostProcessor('case'); 282 | }); 283 | 284 | it('should sort keys alphanumerically', () => { 285 | const collection = new TranslationCollection({ 286 | z: { value: 'last value', sourceFiles: [] }, 287 | a: { value: 'a value', sourceFiles: [] }, 288 | '9': { value: 'a numeric key', sourceFiles: [] }, 289 | b: { value: 'another value', sourceFiles: [] } 290 | }); 291 | const extracted = new TranslationCollection(); 292 | const existing = new TranslationCollection(); 293 | 294 | // Assert all values are processed correctly 295 | expect(processor.process(collection, extracted, existing).values).to.deep.equal({ 296 | '9': { value: 'a numeric key', sourceFiles: [] }, 297 | a: { value: 'a value', sourceFiles: [] }, 298 | b: { value: 'another value', sourceFiles: [] }, 299 | z: { value: 'last value', sourceFiles: [] } 300 | }); 301 | 302 | // Assert all keys are in the correct order 303 | expect(processor.process(collection, extracted, existing).keys()).toStrictEqual(['9', 'a', 'b', 'z']); 304 | }); 305 | 306 | it('should perform case sensitive sorting', () => { 307 | const collection = new TranslationCollection({ 308 | c: { value: 'letter c', sourceFiles: [] }, 309 | j: { value: 'letter j', sourceFiles: [] }, 310 | b: { value: 'letter b', sourceFiles: [] }, 311 | a: { value: 'letter a', sourceFiles: [] }, 312 | à: { value: 'letter à', sourceFiles: [] }, 313 | h: { value: 'letter h', sourceFiles: [] }, 314 | B: { value: 'letter B', sourceFiles: [] }, 315 | H: { value: 'letter H', sourceFiles: [] }, 316 | i: { value: 'letter i', sourceFiles: [] }, 317 | C: { value: 'letter C', sourceFiles: [] }, 318 | e: { value: 'letter e', sourceFiles: [] }, 319 | f: { value: 'letter f', sourceFiles: [] }, 320 | d: { value: 'letter d', sourceFiles: [] }, 321 | A: { value: 'letter A', sourceFiles: [] }, 322 | g: { value: 'letter g', sourceFiles: [] } 323 | }); 324 | 325 | // Assert all values are processed correctly 326 | expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).values).to.deep.equal({ 327 | c: { value: 'letter c', sourceFiles: [] }, 328 | j: { value: 'letter j', sourceFiles: [] }, 329 | b: { value: 'letter b', sourceFiles: [] }, 330 | a: { value: 'letter a', sourceFiles: [] }, 331 | à: { value: 'letter à', sourceFiles: [] }, 332 | h: { value: 'letter h', sourceFiles: [] }, 333 | B: { value: 'letter B', sourceFiles: [] }, 334 | H: { value: 'letter H', sourceFiles: [] }, 335 | i: { value: 'letter i', sourceFiles: [] }, 336 | C: { value: 'letter C', sourceFiles: [] }, 337 | e: { value: 'letter e', sourceFiles: [] }, 338 | f: { value: 'letter f', sourceFiles: [] }, 339 | d: { value: 'letter d', sourceFiles: [] }, 340 | A: { value: 'letter A', sourceFiles: [] }, 341 | g: { value: 'letter g', sourceFiles: [] } 342 | }); 343 | 344 | // Assert all keys are in the correct order 345 | expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).keys()).toStrictEqual([ 346 | 'a', 347 | 'à', 348 | 'A', 349 | 'b', 350 | 'B', 351 | 'c', 352 | 'C', 353 | 'd', 354 | 'e', 355 | 'f', 356 | 'g', 357 | 'h', 358 | 'H', 359 | 'i', 360 | 'j' 361 | ]); 362 | }); 363 | }); 364 | 365 | describe('SortByKeyPostProcessor - variant sensitivity should treat lowercase, uppercase, accents and diacretics as not equal', () => { 366 | let processor: PostProcessorInterface; 367 | 368 | beforeEach(() => { 369 | processor = new SortByKeyPostProcessor('variant'); 370 | }); 371 | 372 | it('should sort keys alphanumerically', () => { 373 | const collection = new TranslationCollection({ 374 | z: { value: 'last value', sourceFiles: [] }, 375 | a: { value: 'a value', sourceFiles: [] }, 376 | '9': { value: 'a numeric key', sourceFiles: [] }, 377 | b: { value: 'another value', sourceFiles: [] } 378 | }); 379 | const extracted = new TranslationCollection(); 380 | const existing = new TranslationCollection(); 381 | 382 | // Assert all values are processed correctly 383 | expect(processor.process(collection, extracted, existing).values).to.deep.equal({ 384 | '9': { value: 'a numeric key', sourceFiles: [] }, 385 | a: { value: 'a value', sourceFiles: [] }, 386 | b: { value: 'another value', sourceFiles: [] }, 387 | z: { value: 'last value', sourceFiles: [] } 388 | }); 389 | 390 | // Assert all keys are in the correct order 391 | expect(processor.process(collection, extracted, existing).keys()).toStrictEqual(['9', 'a', 'b', 'z']); 392 | }); 393 | 394 | it('should perform variant sensitive sorting', () => { 395 | const collection = new TranslationCollection({ 396 | c: { value: 'letter c', sourceFiles: [] }, 397 | j: { value: 'letter j', sourceFiles: [] }, 398 | b: { value: 'letter b', sourceFiles: [] }, 399 | a: { value: 'letter a', sourceFiles: [] }, 400 | à: { value: 'letter à', sourceFiles: [] }, 401 | h: { value: 'letter h', sourceFiles: [] }, 402 | B: { value: 'letter B', sourceFiles: [] }, 403 | H: { value: 'letter H', sourceFiles: [] }, 404 | i: { value: 'letter i', sourceFiles: [] }, 405 | C: { value: 'letter C', sourceFiles: [] }, 406 | e: { value: 'letter e', sourceFiles: [] }, 407 | f: { value: 'letter f', sourceFiles: [] }, 408 | d: { value: 'letter d', sourceFiles: [] }, 409 | A: { value: 'letter A', sourceFiles: [] }, 410 | g: { value: 'letter g', sourceFiles: [] } 411 | }); 412 | 413 | // Assert all values are processed correctly 414 | expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).values).to.deep.equal({ 415 | c: { value: 'letter c', sourceFiles: [] }, 416 | j: { value: 'letter j', sourceFiles: [] }, 417 | b: { value: 'letter b', sourceFiles: [] }, 418 | a: { value: 'letter a', sourceFiles: [] }, 419 | à: { value: 'letter à', sourceFiles: [] }, 420 | h: { value: 'letter h', sourceFiles: [] }, 421 | B: { value: 'letter B', sourceFiles: [] }, 422 | H: { value: 'letter H', sourceFiles: [] }, 423 | i: { value: 'letter i', sourceFiles: [] }, 424 | C: { value: 'letter C', sourceFiles: [] }, 425 | e: { value: 'letter e', sourceFiles: [] }, 426 | f: { value: 'letter f', sourceFiles: [] }, 427 | d: { value: 'letter d', sourceFiles: [] }, 428 | A: { value: 'letter A', sourceFiles: [] }, 429 | g: { value: 'letter g', sourceFiles: [] } 430 | }); 431 | 432 | // Assert all keys are in the correct order 433 | expect(processor.process(collection, new TranslationCollection(), new TranslationCollection()).keys()).toStrictEqual([ 434 | 'a', 435 | 'A', 436 | 'à', 437 | 'b', 438 | 'B', 439 | 'c', 440 | 'C', 441 | 'd', 442 | 'e', 443 | 'f', 444 | 'g', 445 | 'h', 446 | 'H', 447 | 'i', 448 | 'j' 449 | ]); 450 | }); 451 | }); 452 | --------------------------------------------------------------------------------