From 4401f0db60d7f2ee7e07b5934092481de0c4f348 Mon Sep 17 00:00:00 2001 From: "ben.12" Date: Sun, 5 Mar 2023 18:43:01 +0100 Subject: [PATCH 1/6] fix(#621): allow to define fordibben characters in interpolation configuration --- libs/transloco/src/lib/transloco.config.ts | 144 +++---- .../transloco/src/lib/transloco.transpiler.ts | 389 +++++++++--------- 2 files changed, 270 insertions(+), 263 deletions(-) diff --git a/libs/transloco/src/lib/transloco.config.ts b/libs/transloco/src/lib/transloco.config.ts index 35a80376..2806c6b5 100644 --- a/libs/transloco/src/lib/transloco.config.ts +++ b/libs/transloco/src/lib/transloco.config.ts @@ -1,72 +1,72 @@ -import { InjectionToken } from '@angular/core'; - -import { AvailableLangs } from './types'; - -export interface TranslocoConfig { - defaultLang: string; - reRenderOnLangChange: boolean; - prodMode: boolean; - fallbackLang?: string | string[]; - failedRetries: number; - availableLangs: AvailableLangs; - flatten: { - aot: boolean; - }; - missingHandler: { - logMissingKey: boolean; - useFallbackTranslation: boolean; - allowEmpty: boolean; - }; - interpolation: [string, string]; -} - -export const TRANSLOCO_CONFIG = new InjectionToken( - 'TRANSLOCO_CONFIG', - { - providedIn: 'root', - factory: () => defaultConfig, - } -); - -export const defaultConfig: TranslocoConfig = { - defaultLang: 'en', - reRenderOnLangChange: false, - prodMode: false, - failedRetries: 2, - fallbackLang: [], - availableLangs: [], - missingHandler: { - logMissingKey: true, - useFallbackTranslation: false, - allowEmpty: false, - }, - flatten: { - aot: false, - }, - interpolation: ['{{', '}}'], -}; - -type DeepPartial = T extends Array - ? T - : T extends object - ? { [P in keyof T]?: DeepPartial } - : T; - -export type PartialTranslocoConfig = DeepPartial; - -export function translocoConfig( - config: PartialTranslocoConfig = {} -): TranslocoConfig { - return { - ...defaultConfig, - ...config, - missingHandler: { - ...defaultConfig.missingHandler, - ...config.missingHandler, - }, - flatten: { - ...defaultConfig.flatten, - ...config.flatten, - }, - }; -} +import { InjectionToken } from '@angular/core'; + +import { AvailableLangs } from './types'; + +export interface TranslocoConfig { + defaultLang: string; + reRenderOnLangChange: boolean; + prodMode: boolean; + fallbackLang?: string | string[]; + failedRetries: number; + availableLangs: AvailableLangs; + flatten: { + aot: boolean; + }; + missingHandler: { + logMissingKey: boolean; + useFallbackTranslation: boolean; + allowEmpty: boolean; + }; + interpolation: [start: string, end: string, forbiddenChars?: string]; +} + +export const TRANSLOCO_CONFIG = new InjectionToken( + 'TRANSLOCO_CONFIG', + { + providedIn: 'root', + factory: () => defaultConfig, + } +); + +export const defaultConfig: TranslocoConfig = { + defaultLang: 'en', + reRenderOnLangChange: false, + prodMode: false, + failedRetries: 2, + fallbackLang: [], + availableLangs: [], + missingHandler: { + logMissingKey: true, + useFallbackTranslation: false, + allowEmpty: false, + }, + flatten: { + aot: false, + }, + interpolation: ['{{', '}}', '{}'], +}; + +type DeepPartial = T extends Array + ? T + : T extends object + ? { [P in keyof T]?: DeepPartial } + : T; + +export type PartialTranslocoConfig = DeepPartial; + +export function translocoConfig( + config: PartialTranslocoConfig = {} +): TranslocoConfig { + return { + ...defaultConfig, + ...config, + missingHandler: { + ...defaultConfig.missingHandler, + ...config.missingHandler, + }, + flatten: { + ...defaultConfig.flatten, + ...config.flatten, + }, + }; +} diff --git a/libs/transloco/src/lib/transloco.transpiler.ts b/libs/transloco/src/lib/transloco.transpiler.ts index ee6b2df1..860586ea 100644 --- a/libs/transloco/src/lib/transloco.transpiler.ts +++ b/libs/transloco/src/lib/transloco.transpiler.ts @@ -1,191 +1,198 @@ -import { - Inject, - Injectable, - InjectionToken, - Injector, - Optional, -} from '@angular/core'; - -import { HashMap, Translation } from './types'; -import { getValue, isDefined, isObject, isString, setValue } from './helpers'; -import { - defaultConfig, - TRANSLOCO_CONFIG, - TranslocoConfig, -} from './transloco.config'; - -export const TRANSLOCO_TRANSPILER = new InjectionToken( - 'TRANSLOCO_TRANSPILER' -); - -export interface TranslocoTranspiler { - // TODO: Change parameters to object in the next major release - transpile( - value: any, - params: HashMap, - translation: Translation, - key: string - ): any; - - onLangChanged?(lang: string): void; -} - -@Injectable() -export class DefaultTranspiler implements TranslocoTranspiler { - protected interpolationMatcher: RegExp; - - constructor(@Optional() @Inject(TRANSLOCO_CONFIG) config?: TranslocoConfig) { - this.interpolationMatcher = resolveMatcher(config ?? defaultConfig); - } - - transpile( - value: any, - params: HashMap = {}, - translation: Translation, - key: string - ): any { - if (isString(value)) { - return value.replace(this.interpolationMatcher, (_, match) => { - match = match.trim(); - if (isDefined(params[match])) { - return params[match]; - } - - return isDefined(translation[match]) - ? this.transpile(translation[match], params, translation, key) - : ''; - }); - } else if (params) { - if (isObject(value)) { - value = this.handleObject(value, params, translation, key); - } else if (Array.isArray(value)) { - value = this.handleArray(value, params, translation, key); - } - } - - return value; - } - - /** - * - * @example - * - * const en = { - * a: { - * b: { - * c: "Hello {{ value }}" - * } - * } - * } - * - * const params = { - * "b.c": { value: "Transloco "} - * } - * - * service.selectTranslate('a', params); - * - * // the first param will be the result of `en.a`. - * // the second param will be `params`. - * parser.transpile(value, params, {}); - * - * - */ - protected handleObject( - value: any, - params: HashMap = {}, - translation: Translation, - key: string - ) { - let result = value; - - Object.keys(params).forEach((p) => { - // get the value of "b.c" inside "a" => "Hello {{ value }}" - const v = getValue(result, p); - // get the params of "b.c" => { value: "Transloco" } - const getParams = getValue(params, p); - - // transpile the value => "Hello Transloco" - const transpiled = this.transpile(v, getParams, translation, key); - - // set "b.c" to `transpiled` - result = setValue(result, p, transpiled); - }); - - return result; - } - - protected handleArray( - value: string[], - params: HashMap = {}, - translation: Translation, - key: string - ) { - return value.map((v) => this.transpile(v, params, translation, key)); - } -} - -function resolveMatcher(config: TranslocoConfig): RegExp { - const [start, end] = config.interpolation; - - return new RegExp(`${start}(.*?)${end}`, 'g'); -} - -export interface TranslocoTranspilerFunction { - transpile(...args: string[]): any; -} - -export function getFunctionArgs(argsString: string): string[] { - const splitted = argsString ? argsString.split(',') : []; - const args = []; - for (let i = 0; i < splitted.length; i++) { - let value = splitted[i].trim(); - while (value[value.length - 1] === '\\') { - i++; - value = value.replace('\\', ',') + splitted[i]; - } - args.push(value); - } - - return args; -} - -@Injectable() -export class FunctionalTranspiler - extends DefaultTranspiler - implements TranslocoTranspiler -{ - constructor(private injector: Injector) { - super(); - } - - transpile( - value: any, - params: HashMap = {}, - translation: Translation, - key: string - ): any { - let transpiled = value; - if (isString(value)) { - transpiled = value.replace( - /\[\[\s*(\w+)\((.*?)\)\s*]]/g, - (match: string, functionName: string, args: string) => { - try { - const func: TranslocoTranspilerFunction = - this.injector.get(functionName); - - return func.transpile(...getFunctionArgs(args)); - } catch (e: any) { - let message = `There is an error in: '${value}'. - Check that the you used the right syntax in your translation and that the implementation of ${functionName} is correct.`; - if (e.message.includes('NullInjectorError')) { - message = `You are using the '${functionName}' function in your translation but no provider was found!`; - } - throw new Error(message); - } - } - ); - } - - return super.transpile(transpiled, params, translation, key); - } -} +import { + Inject, + Injectable, + InjectionToken, + Injector, + Optional, +} from '@angular/core'; + +import { HashMap, Translation } from './types'; +import { getValue, isDefined, isObject, isString, setValue } from './helpers'; +import { + defaultConfig, + TRANSLOCO_CONFIG, + TranslocoConfig, +} from './transloco.config'; + +export const TRANSLOCO_TRANSPILER = new InjectionToken( + 'TRANSLOCO_TRANSPILER' +); + +export interface TranslocoTranspiler { + // TODO: Change parameters to object in the next major release + transpile( + value: any, + params: HashMap, + translation: Translation, + key: string + ): any; + + onLangChanged?(lang: string): void; +} + +@Injectable() +export class DefaultTranspiler implements TranslocoTranspiler { + protected interpolationMatcher: RegExp; + + constructor(@Optional() @Inject(TRANSLOCO_CONFIG) config?: TranslocoConfig) { + this.interpolationMatcher = resolveMatcher(config ?? defaultConfig); + } + + transpile( + value: any, + params: HashMap = {}, + translation: Translation, + key: string + ): any { + if (isString(value)) { + return value.replace(this.interpolationMatcher, (_, match) => { + match = match.trim(); + if (isDefined(params[match])) { + return params[match]; + } + + return isDefined(translation[match]) + ? this.transpile(translation[match], params, translation, key) + : ''; + }); + } else if (params) { + if (isObject(value)) { + value = this.handleObject(value, params, translation, key); + } else if (Array.isArray(value)) { + value = this.handleArray(value, params, translation, key); + } + } + + return value; + } + + /** + * + * @example + * + * const en = { + * a: { + * b: { + * c: "Hello {{ value }}" + * } + * } + * } + * + * const params = { + * "b.c": { value: "Transloco "} + * } + * + * service.selectTranslate('a', params); + * + * // the first param will be the result of `en.a`. + * // the second param will be `params`. + * parser.transpile(value, params, {}); + * + * + */ + protected handleObject( + value: any, + params: HashMap = {}, + translation: Translation, + key: string + ) { + let result = value; + + Object.keys(params).forEach((p) => { + // get the value of "b.c" inside "a" => "Hello {{ value }}" + const v = getValue(result, p); + // get the params of "b.c" => { value: "Transloco" } + const getParams = getValue(params, p); + + // transpile the value => "Hello Transloco" + const transpiled = this.transpile(v, getParams, translation, key); + + // set "b.c" to `transpiled` + result = setValue(result, p, transpiled); + }); + + return result; + } + + protected handleArray( + value: string[], + params: HashMap = {}, + translation: Translation, + key: string + ) { + return value.map((v) => this.transpile(v, params, translation, key)); + } +} + +function resolveMatcher(config: TranslocoConfig): RegExp { + const [start, end, forbiddenChars] = config.interpolation; + const matchingParamName = forbiddenChars != undefined ? `[^${escapeForRegExp(forbiddenChars)}]` : '.'; + return new RegExp( + `${escapeForRegExp(start)}(${matchingParamName}*?)${escapeForRegExp(end)}`, + 'g' + ); +} + +function escapeForRegExp(text: string) { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); +} + +export interface TranslocoTranspilerFunction { + transpile(...args: string[]): any; +} + +export function getFunctionArgs(argsString: string): string[] { + const splitted = argsString ? argsString.split(',') : []; + const args = []; + for (let i = 0; i < splitted.length; i++) { + let value = splitted[i].trim(); + while (value[value.length - 1] === '\\') { + i++; + value = value.replace('\\', ',') + splitted[i]; + } + args.push(value); + } + + return args; +} + +@Injectable() +export class FunctionalTranspiler + extends DefaultTranspiler + implements TranslocoTranspiler +{ + constructor(private injector: Injector) { + super(); + } + + transpile( + value: any, + params: HashMap = {}, + translation: Translation, + key: string + ): any { + let transpiled = value; + if (isString(value)) { + transpiled = value.replace( + /\[\[\s*(\w+)\((.*?)\)\s*]]/g, + (match: string, functionName: string, args: string) => { + try { + const func: TranslocoTranspilerFunction = + this.injector.get(functionName); + + return func.transpile(...getFunctionArgs(args)); + } catch (e: any) { + let message = `There is an error in: '${value}'. + Check that the you used the right syntax in your translation and that the implementation of ${functionName} is correct.`; + if (e.message.includes('NullInjectorError')) { + message = `You are using the '${functionName}' function in your translation but no provider was found!`; + } + throw new Error(message); + } + } + ); + } + + return super.transpile(transpiled, params, translation, key); + } +} From 9f635415af040a704291fb49becaa7a491b7206b Mon Sep 17 00:00:00 2001 From: "ben.12" Date: Sat, 25 Mar 2023 00:46:45 +0100 Subject: [PATCH 2/6] fix(#621): add check and unit tests in transloco-validation for forbidden characters --- libs/transloco-validator/jest.config.ts | 15 ++++ libs/transloco-validator/karma.conf.js | 16 ---- libs/transloco-validator/project.json | 8 +- libs/transloco-validator/src/index.ts | 11 ++- .../src/lib/transloco-validator.spec.ts | 36 +++++++++ .../src/lib/transloco-validator.ts | 76 ++++++++++++++----- libs/transloco-validator/tsconfig.spec.json | 3 +- 7 files changed, 122 insertions(+), 43 deletions(-) create mode 100644 libs/transloco-validator/jest.config.ts delete mode 100644 libs/transloco-validator/karma.conf.js create mode 100644 libs/transloco-validator/src/lib/transloco-validator.spec.ts diff --git a/libs/transloco-validator/jest.config.ts b/libs/transloco-validator/jest.config.ts new file mode 100644 index 00000000..8a767135 --- /dev/null +++ b/libs/transloco-validator/jest.config.ts @@ -0,0 +1,15 @@ +export default { + displayName: 'transloco-validator', + + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + }, + }, + transform: { + '^.+\\.[tj]sx?$': 'ts-jest', + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/libs/transloco-validator', + preset: '../../jest.preset.js', +}; diff --git a/libs/transloco-validator/karma.conf.js b/libs/transloco-validator/karma.conf.js deleted file mode 100644 index 8a3b4229..00000000 --- a/libs/transloco-validator/karma.conf.js +++ /dev/null @@ -1,16 +0,0 @@ -// Karma configuration file, see link for more information -// https://karma-runner.github.io/1.0/config/configuration-file.html - -const { join } = require('path'); -const getBaseKarmaConfig = require('../../karma.conf'); - -module.exports = function (config) { - const baseConfig = getBaseKarmaConfig(); - config.set({ - ...baseConfig, - coverageIstanbulReporter: { - ...baseConfig.coverageIstanbulReporter, - dir: join(__dirname, '../../coverage/libs/transloco-validator'), - }, - }); -}; diff --git a/libs/transloco-validator/project.json b/libs/transloco-validator/project.json index 80eb7c50..1a35af02 100644 --- a/libs/transloco-validator/project.json +++ b/libs/transloco-validator/project.json @@ -22,11 +22,11 @@ "outputs": ["{options.outputFile}"] }, "test": { - "executor": "@angular-devkit/build-angular:karma", + "executor": "@nrwl/jest:jest", + "outputs": ["coverage/libs/transloco-schematics"], "options": { - "main": "libs/transloco-validator/src/test-setup.ts", - "tsConfig": "libs/transloco-validator/tsconfig.spec.json", - "karmaConfig": "libs/transloco-validator/karma.conf.js" + "jestConfig": "libs/transloco-validator/jest.config.ts", + "passWithNoTests": true } }, "version": { diff --git a/libs/transloco-validator/src/index.ts b/libs/transloco-validator/src/index.ts index 37616d58..26e999eb 100644 --- a/libs/transloco-validator/src/index.ts +++ b/libs/transloco-validator/src/index.ts @@ -1,5 +1,12 @@ #!/usr/bin/env node +import commandLineArgs from 'command-line-args'; import validator from './lib/transloco-validator'; -const translationFilePaths = process.argv.slice(2); -validator(translationFilePaths); + +const optionDefinitions: commandLineArgs.OptionDefinition[] = [ + { name: 'interpolationForbiddenChars', type: String, defaultValue: '{}' }, + { name: 'file', alias: 'f', type: String, multiple: true, defaultOption: true }, + ]; + +const { interpolationForbiddenChars, translationFilePaths } = commandLineArgs(optionDefinitions); +validator(interpolationForbiddenChars, translationFilePaths); diff --git a/libs/transloco-validator/src/lib/transloco-validator.spec.ts b/libs/transloco-validator/src/lib/transloco-validator.spec.ts new file mode 100644 index 00000000..8289e9eb --- /dev/null +++ b/libs/transloco-validator/src/lib/transloco-validator.spec.ts @@ -0,0 +1,36 @@ +import validator from './transloco-validator'; +import fs from 'fs'; + +jest.mock('fs'); + +describe('transloco-validator', () => { + + it('should find duplicated keys', () => { + jest.mocked(fs.readFileSync).mockImplementation(() => '{"test":{"dupl1":"data","dupl1": "data","test": [{"dupl2":"data","dupl2": "data"}]}}'); + + const callValidator = () => validator('', ['mytest.json']); + expect(callValidator).toThrowError(new Error("Found duplicate keys: .test.dupl1,.test.test[0].dupl2 (mytest.json)")); + }) + + it('should find forbidden keys', () => { + jest.mocked(fs.readFileSync).mockImplementation(() => '{"test":{"forbidden{":"data","forbidden}": "data","test": [{"for{bidden}":"data"}]}}'); + + const callValidator = () => validator('{}', ['mytest.json']); + expect(callValidator).toThrowError(new Error("Found forbidden characters [{}] in keys: .test.forbidden{,.test.forbidden},.test.test[0].for{bidden} (mytest.json)")); + }) + + it('should find syntax error', () => { + jest.mocked(fs.readFileSync).mockImplementation(() => '{"test":{"erreur"}}'); + + const callValidator = () => validator('', ['mytest.json']); + expect(callValidator).toThrowError(new SyntaxError("Unexpected token } in JSON at position 17 (mytest.json)")); + }) + + it('should return success', () => { + jest.mocked(fs.readFileSync).mockImplementation(() => '{"test":{"erreur":123}}'); + + const callValidator = () => validator('', ['mytest.json']); + expect(callValidator).not.toThrowError(); + }) + +}) \ No newline at end of file diff --git a/libs/transloco-validator/src/lib/transloco-validator.ts b/libs/transloco-validator/src/lib/transloco-validator.ts index 5c8acc85..d96c3332 100644 --- a/libs/transloco-validator/src/lib/transloco-validator.ts +++ b/libs/transloco-validator/src/lib/transloco-validator.ts @@ -1,20 +1,56 @@ -import fs from 'fs'; - -import findDuplicatedPropertyKeys from 'find-duplicated-property-keys'; - -export default function (translationFilePaths: string[]) { - translationFilePaths.forEach((path) => { - const translation = fs.readFileSync(path, 'utf-8'); - - // Verify that we can parse the JSON - JSON.parse(translation); - - // Verify that we don't have any duplicate keys - const result = findDuplicatedPropertyKeys(translation); - if (result.length) { - throw new Error( - `Found duplicate keys: ${result.map(({ key }) => key)} (${path})` - ); - } - }); -} +import fs from 'fs'; + +import findDuplicatedPropertyKeys from 'find-duplicated-property-keys'; + +export default function (interpolationForbiddenChars: string, translationFilePaths: string[]) { + translationFilePaths.forEach((path) => { + const translation = fs.readFileSync(path, 'utf-8'); + + // Verify that we can parse the JSON + let parsedTranslation; + try { + parsedTranslation = JSON.parse(translation); + } catch(error) { + throw new SyntaxError( + `${error.message} (${path})` + ); + } + + // Verify that we don't have any duplicate keys + const duplicatedKeys = findDuplicatedPropertyKeys(translation); + if (duplicatedKeys.length) { + throw new Error( + `Found duplicate keys: ${duplicatedKeys.map(dupl => dupl.toString())} (${path})` + ); + } + + const forbiddenKeys = findPropertyKeysContaining(parsedTranslation, interpolationForbiddenChars); + if (forbiddenKeys.length) { + throw new Error( + `Found forbidden characters [${interpolationForbiddenChars}] in keys: ${forbiddenKeys} (${path})` + ); + } + }); +} + +function findPropertyKeysContaining(object: unknown, chars: string, parent = '') { + const found = []; + if (Array.isArray(object)) { + for(let i = 0; i < object.length; i++) { + const value = object[i]; + found.push(...findPropertyKeysContaining(value, chars, `${parent}[${i}]`)); + } + } else if (typeof object === 'object') { + for(const key in object) { + const value = object[key]; + for (const char of chars) { + if (key.includes(char)) { + found.push(parent + '.' + key); + break; + } + } + found.push(...findPropertyKeysContaining(value, chars, `${parent}.${key}`)); + } + } + return found; +} \ No newline at end of file diff --git a/libs/transloco-validator/tsconfig.spec.json b/libs/transloco-validator/tsconfig.spec.json index 64a8be2b..cf9fc9ad 100644 --- a/libs/transloco-validator/tsconfig.spec.json +++ b/libs/transloco-validator/tsconfig.spec.json @@ -2,8 +2,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", + "esModuleInterop": true, "module": "commonjs", - "types": ["jasmine", "node"] + "types": ["jest", "node"] }, "files": ["src/test-setup.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"] From 90a44baa8027925621044d4cec0a5104e80d306e Mon Sep 17 00:00:00 2001 From: "ben.12" Date: Sat, 25 Mar 2023 12:00:30 +0100 Subject: [PATCH 3/6] fix(#621): add messageformat unit test --- .../src/lib/messageformat.transpiler.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/libs/transloco-messageformat/src/lib/messageformat.transpiler.spec.ts b/libs/transloco-messageformat/src/lib/messageformat.transpiler.spec.ts index bc4b8865..3f29857a 100644 --- a/libs/transloco-messageformat/src/lib/messageformat.transpiler.spec.ts +++ b/libs/transloco-messageformat/src/lib/messageformat.transpiler.spec.ts @@ -122,6 +122,16 @@ function assertParser(config: MessageformatConfig) { expect(parsedMale).toEqual('The smart boy named Henkie won his race'); }); + it('should translate simple param and interpolate params inside messageformat string with joined braces', () => { + const parsedMale = parser.transpile( + '{count, plural, =1 {1 person} other {{count} people}}', + { count: 10 }, + {}, + 'key' + ); + expect(parsedMale).toEqual('10 people'); + }); + it('should translate simple param and interpolate params inside messageformat string using custom interpolation markers', () => { const parsedMale = parserWithCustomInterpolation.transpile( 'The <<< value >>> { gender, select, male {boy named <<< name >>> won his} female {girl named <<< name >>> won her} other {person named <<< name >>> won their}} race', From 032601ccb7cb573dae74a7808d517c5ac92724df Mon Sep 17 00:00:00 2001 From: "ben.12" Date: Sat, 25 Mar 2023 12:32:16 +0100 Subject: [PATCH 4/6] fix(#621): transloco-validation - Fix arguments reading --- libs/transloco-validator/src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/transloco-validator/src/index.ts b/libs/transloco-validator/src/index.ts index 26e999eb..08fc3e54 100644 --- a/libs/transloco-validator/src/index.ts +++ b/libs/transloco-validator/src/index.ts @@ -5,8 +5,8 @@ import validator from './lib/transloco-validator'; const optionDefinitions: commandLineArgs.OptionDefinition[] = [ { name: 'interpolationForbiddenChars', type: String, defaultValue: '{}' }, - { name: 'file', alias: 'f', type: String, multiple: true, defaultOption: true }, + { name: 'file', alias: 'f', type: String, multiple: true, defaultValue: [], defaultOption: true }, ]; -const { interpolationForbiddenChars, translationFilePaths } = commandLineArgs(optionDefinitions); -validator(interpolationForbiddenChars, translationFilePaths); +const { interpolationForbiddenChars, file } = commandLineArgs(optionDefinitions); +validator(interpolationForbiddenChars, file); From 1c6af4d9bd4b7e35be7012a4dd5b3e4445913ee9 Mon Sep 17 00:00:00 2001 From: "ben.12" Date: Sat, 1 Apr 2023 13:06:17 +0200 Subject: [PATCH 5/6] fix(#621): update config options and validator docs --- docs/docs/getting-started/config-options.mdx | 4 ++-- docs/docs/tools/validator.mdx | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/docs/getting-started/config-options.mdx b/docs/docs/getting-started/config-options.mdx index fe5a90ac..c3a746ba 100644 --- a/docs/docs/getting-started/config-options.mdx +++ b/docs/docs/getting-started/config-options.mdx @@ -106,10 +106,10 @@ translocoConfig({ ``` ### `interpolation` -The start and end markings for parameters: (defaults to `['{{', '}}']`) +The start and end markings for parameters and forbidden characters for parameter names: (defaults to `['{{', '}}', '{}']`) ```ts translocoConfig({ // This will enable you to specify parameters as such: `"Hello <<>>"` - interpolation: ['<<<', '>>>'] + interpolation: ['<<<', '>>>', '<>'] }) ``` diff --git a/docs/docs/tools/validator.mdx b/docs/docs/tools/validator.mdx index dc6c7bb7..8dfb5d4f 100644 --- a/docs/docs/tools/validator.mdx +++ b/docs/docs/tools/validator.mdx @@ -19,9 +19,20 @@ This package provides validation for translation files. It validates that the JS } }, "lint-staged": { - "src/assets/i18n/*.json": ["transloco-validator"] + "src/assets/i18n/*.json": ["transloco-validator [options]"] } } ``` This will make sure no one accidentally pushes an invalid translation file. + + +### Options + +#### Interpolation forbidden characters for parameter names. + +- `--interpolationForbiddenChars` + + `type`: `string` + + `default`: `{}` From 73db4939c0bf38940da6eccff9d7e00dfb6e0442 Mon Sep 17 00:00:00 2001 From: "ben.12" Date: Sun, 6 Aug 2023 18:42:10 +0200 Subject: [PATCH 6/6] fix(#621): fix after rebase --- libs/transloco-validator/src/index.ts | 1 + .../src/lib/transloco-validator.spec.ts | 5 +- .../src/lib/transloco-validator.ts | 110 ++--- .../src/lib/tests/transpiler.spec.ts | 16 +- libs/transloco/src/lib/transloco.config.ts | 144 +++---- .../transloco/src/lib/transloco.transpiler.ts | 396 +++++++++--------- 6 files changed, 344 insertions(+), 328 deletions(-) diff --git a/libs/transloco-validator/src/index.ts b/libs/transloco-validator/src/index.ts index 08fc3e54..76af356d 100644 --- a/libs/transloco-validator/src/index.ts +++ b/libs/transloco-validator/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node import commandLineArgs from 'command-line-args'; + import validator from './lib/transloco-validator'; diff --git a/libs/transloco-validator/src/lib/transloco-validator.spec.ts b/libs/transloco-validator/src/lib/transloco-validator.spec.ts index 8289e9eb..8098c8c7 100644 --- a/libs/transloco-validator/src/lib/transloco-validator.spec.ts +++ b/libs/transloco-validator/src/lib/transloco-validator.spec.ts @@ -1,6 +1,7 @@ -import validator from './transloco-validator'; import fs from 'fs'; +import validator from './transloco-validator'; + jest.mock('fs'); describe('transloco-validator', () => { @@ -23,7 +24,7 @@ describe('transloco-validator', () => { jest.mocked(fs.readFileSync).mockImplementation(() => '{"test":{"erreur"}}'); const callValidator = () => validator('', ['mytest.json']); - expect(callValidator).toThrowError(new SyntaxError("Unexpected token } in JSON at position 17 (mytest.json)")); + expect(callValidator).toThrowError(SyntaxError); }) it('should return success', () => { diff --git a/libs/transloco-validator/src/lib/transloco-validator.ts b/libs/transloco-validator/src/lib/transloco-validator.ts index d96c3332..d98c13dd 100644 --- a/libs/transloco-validator/src/lib/transloco-validator.ts +++ b/libs/transloco-validator/src/lib/transloco-validator.ts @@ -1,56 +1,56 @@ -import fs from 'fs'; - -import findDuplicatedPropertyKeys from 'find-duplicated-property-keys'; - -export default function (interpolationForbiddenChars: string, translationFilePaths: string[]) { - translationFilePaths.forEach((path) => { - const translation = fs.readFileSync(path, 'utf-8'); - - // Verify that we can parse the JSON - let parsedTranslation; - try { - parsedTranslation = JSON.parse(translation); - } catch(error) { - throw new SyntaxError( - `${error.message} (${path})` - ); - } - - // Verify that we don't have any duplicate keys - const duplicatedKeys = findDuplicatedPropertyKeys(translation); - if (duplicatedKeys.length) { - throw new Error( - `Found duplicate keys: ${duplicatedKeys.map(dupl => dupl.toString())} (${path})` - ); - } - - const forbiddenKeys = findPropertyKeysContaining(parsedTranslation, interpolationForbiddenChars); - if (forbiddenKeys.length) { - throw new Error( - `Found forbidden characters [${interpolationForbiddenChars}] in keys: ${forbiddenKeys} (${path})` - ); - } - }); -} - -function findPropertyKeysContaining(object: unknown, chars: string, parent = '') { - const found = []; - if (Array.isArray(object)) { - for(let i = 0; i < object.length; i++) { - const value = object[i]; - found.push(...findPropertyKeysContaining(value, chars, `${parent}[${i}]`)); - } - } else if (typeof object === 'object') { - for(const key in object) { - const value = object[key]; - for (const char of chars) { - if (key.includes(char)) { - found.push(parent + '.' + key); - break; - } - } - found.push(...findPropertyKeysContaining(value, chars, `${parent}.${key}`)); - } - } - return found; +import fs from 'fs'; + +import findDuplicatedPropertyKeys from 'find-duplicated-property-keys'; + +export default function (interpolationForbiddenChars: string, translationFilePaths: string[]) { + translationFilePaths.forEach((path) => { + const translation = fs.readFileSync(path, 'utf-8'); + + // Verify that we can parse the JSON + let parsedTranslation; + try { + parsedTranslation = JSON.parse(translation); + } catch(error) { + throw new SyntaxError( + `${error.message} (${path})` + ); + } + + // Verify that we don't have any duplicate keys + const duplicatedKeys = findDuplicatedPropertyKeys(translation); + if (duplicatedKeys.length) { + throw new Error( + `Found duplicate keys: ${duplicatedKeys.map(dupl => dupl.toString())} (${path})` + ); + } + + const forbiddenKeys = findPropertyKeysContaining(parsedTranslation, interpolationForbiddenChars); + if (forbiddenKeys.length) { + throw new Error( + `Found forbidden characters [${interpolationForbiddenChars}] in keys: ${forbiddenKeys} (${path})` + ); + } + }); +} + +function findPropertyKeysContaining(object: unknown, chars: string, parent = '') { + const found = []; + if (Array.isArray(object)) { + for(let i = 0; i < object.length; i++) { + const value = object[i]; + found.push(...findPropertyKeysContaining(value, chars, `${parent}[${i}]`)); + } + } else if (typeof object === 'object') { + for(const key in object) { + const value = object[key]; + for (const char of chars) { + if (key.includes(char)) { + found.push(parent + '.' + key); + break; + } + } + found.push(...findPropertyKeysContaining(value, chars, `${parent}.${key}`)); + } + } + return found; } \ No newline at end of file diff --git a/libs/transloco/src/lib/tests/transpiler.spec.ts b/libs/transloco/src/lib/tests/transpiler.spec.ts index 32795231..97137621 100644 --- a/libs/transloco/src/lib/tests/transpiler.spec.ts +++ b/libs/transloco/src/lib/tests/transpiler.spec.ts @@ -123,12 +123,26 @@ describe('TranslocoTranspiler', () => { function testDefaultBehaviour( parser: TranslocoTranspiler, - [start, end]: [string, string] = defaultConfig.interpolation + [start, end, forbiddenChars]: [string, string, string?] = defaultConfig.interpolation ) { function wrapParam(param: string) { return `${start} ${param} ${end}`; } + it('should skip if forbidden chars are used', () => { + if (forbiddenChars?.length) { + for (const char of forbiddenChars) { + const parsed = parser.transpile( + `Hello ${wrapParam('value ' + char)}`, + { value: 'World' }, + {}, + 'key' + ); + expect(parsed).toEqual(`Hello ${wrapParam('value ' + char)}`); + } + } + }); + it('should translate simple string from params', () => { const parsed = parser.transpile( `Hello ${wrapParam('value')}`, diff --git a/libs/transloco/src/lib/transloco.config.ts b/libs/transloco/src/lib/transloco.config.ts index 2806c6b5..4cf4f078 100644 --- a/libs/transloco/src/lib/transloco.config.ts +++ b/libs/transloco/src/lib/transloco.config.ts @@ -1,72 +1,72 @@ -import { InjectionToken } from '@angular/core'; - -import { AvailableLangs } from './types'; - -export interface TranslocoConfig { - defaultLang: string; - reRenderOnLangChange: boolean; - prodMode: boolean; - fallbackLang?: string | string[]; - failedRetries: number; - availableLangs: AvailableLangs; - flatten: { - aot: boolean; - }; - missingHandler: { - logMissingKey: boolean; - useFallbackTranslation: boolean; - allowEmpty: boolean; - }; - interpolation: [start: string, end: string, forbiddenChars?: string]; -} - -export const TRANSLOCO_CONFIG = new InjectionToken( - 'TRANSLOCO_CONFIG', - { - providedIn: 'root', - factory: () => defaultConfig, - } -); - -export const defaultConfig: TranslocoConfig = { - defaultLang: 'en', - reRenderOnLangChange: false, - prodMode: false, - failedRetries: 2, - fallbackLang: [], - availableLangs: [], - missingHandler: { - logMissingKey: true, - useFallbackTranslation: false, - allowEmpty: false, - }, - flatten: { - aot: false, - }, - interpolation: ['{{', '}}', '{}'], -}; - -type DeepPartial = T extends Array - ? T - : T extends object - ? { [P in keyof T]?: DeepPartial } - : T; - -export type PartialTranslocoConfig = DeepPartial; - -export function translocoConfig( - config: PartialTranslocoConfig = {} -): TranslocoConfig { - return { - ...defaultConfig, - ...config, - missingHandler: { - ...defaultConfig.missingHandler, - ...config.missingHandler, - }, - flatten: { - ...defaultConfig.flatten, - ...config.flatten, - }, - }; -} +import { InjectionToken } from '@angular/core'; + +import { AvailableLangs } from './types'; + +export interface TranslocoConfig { + defaultLang: string; + reRenderOnLangChange: boolean; + prodMode: boolean; + fallbackLang?: string | string[]; + failedRetries: number; + availableLangs: AvailableLangs; + flatten: { + aot: boolean; + }; + missingHandler: { + logMissingKey: boolean; + useFallbackTranslation: boolean; + allowEmpty: boolean; + }; + interpolation: [start: string, end: string, forbiddenChars?: string]; +} + +export const TRANSLOCO_CONFIG = new InjectionToken( + 'TRANSLOCO_CONFIG', + { + providedIn: 'root', + factory: () => defaultConfig, + } +); + +export const defaultConfig: TranslocoConfig = { + defaultLang: 'en', + reRenderOnLangChange: false, + prodMode: false, + failedRetries: 2, + fallbackLang: [], + availableLangs: [], + missingHandler: { + logMissingKey: true, + useFallbackTranslation: false, + allowEmpty: false, + }, + flatten: { + aot: false, + }, + interpolation: ['{{', '}}', '{}'], +}; + +type DeepPartial = T extends Array + ? T + : T extends object + ? { [P in keyof T]?: DeepPartial } + : T; + +export type PartialTranslocoConfig = DeepPartial; + +export function translocoConfig( + config: PartialTranslocoConfig = {} +): TranslocoConfig { + return { + ...defaultConfig, + ...config, + missingHandler: { + ...defaultConfig.missingHandler, + ...config.missingHandler, + }, + flatten: { + ...defaultConfig.flatten, + ...config.flatten, + }, + }; +} diff --git a/libs/transloco/src/lib/transloco.transpiler.ts b/libs/transloco/src/lib/transloco.transpiler.ts index 860586ea..1883cace 100644 --- a/libs/transloco/src/lib/transloco.transpiler.ts +++ b/libs/transloco/src/lib/transloco.transpiler.ts @@ -1,198 +1,198 @@ -import { - Inject, - Injectable, - InjectionToken, - Injector, - Optional, -} from '@angular/core'; - -import { HashMap, Translation } from './types'; -import { getValue, isDefined, isObject, isString, setValue } from './helpers'; -import { - defaultConfig, - TRANSLOCO_CONFIG, - TranslocoConfig, -} from './transloco.config'; - -export const TRANSLOCO_TRANSPILER = new InjectionToken( - 'TRANSLOCO_TRANSPILER' -); - -export interface TranslocoTranspiler { - // TODO: Change parameters to object in the next major release - transpile( - value: any, - params: HashMap, - translation: Translation, - key: string - ): any; - - onLangChanged?(lang: string): void; -} - -@Injectable() -export class DefaultTranspiler implements TranslocoTranspiler { - protected interpolationMatcher: RegExp; - - constructor(@Optional() @Inject(TRANSLOCO_CONFIG) config?: TranslocoConfig) { - this.interpolationMatcher = resolveMatcher(config ?? defaultConfig); - } - - transpile( - value: any, - params: HashMap = {}, - translation: Translation, - key: string - ): any { - if (isString(value)) { - return value.replace(this.interpolationMatcher, (_, match) => { - match = match.trim(); - if (isDefined(params[match])) { - return params[match]; - } - - return isDefined(translation[match]) - ? this.transpile(translation[match], params, translation, key) - : ''; - }); - } else if (params) { - if (isObject(value)) { - value = this.handleObject(value, params, translation, key); - } else if (Array.isArray(value)) { - value = this.handleArray(value, params, translation, key); - } - } - - return value; - } - - /** - * - * @example - * - * const en = { - * a: { - * b: { - * c: "Hello {{ value }}" - * } - * } - * } - * - * const params = { - * "b.c": { value: "Transloco "} - * } - * - * service.selectTranslate('a', params); - * - * // the first param will be the result of `en.a`. - * // the second param will be `params`. - * parser.transpile(value, params, {}); - * - * - */ - protected handleObject( - value: any, - params: HashMap = {}, - translation: Translation, - key: string - ) { - let result = value; - - Object.keys(params).forEach((p) => { - // get the value of "b.c" inside "a" => "Hello {{ value }}" - const v = getValue(result, p); - // get the params of "b.c" => { value: "Transloco" } - const getParams = getValue(params, p); - - // transpile the value => "Hello Transloco" - const transpiled = this.transpile(v, getParams, translation, key); - - // set "b.c" to `transpiled` - result = setValue(result, p, transpiled); - }); - - return result; - } - - protected handleArray( - value: string[], - params: HashMap = {}, - translation: Translation, - key: string - ) { - return value.map((v) => this.transpile(v, params, translation, key)); - } -} - -function resolveMatcher(config: TranslocoConfig): RegExp { - const [start, end, forbiddenChars] = config.interpolation; - const matchingParamName = forbiddenChars != undefined ? `[^${escapeForRegExp(forbiddenChars)}]` : '.'; - return new RegExp( - `${escapeForRegExp(start)}(${matchingParamName}*?)${escapeForRegExp(end)}`, - 'g' - ); -} - -function escapeForRegExp(text: string) { - return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); -} - -export interface TranslocoTranspilerFunction { - transpile(...args: string[]): any; -} - -export function getFunctionArgs(argsString: string): string[] { - const splitted = argsString ? argsString.split(',') : []; - const args = []; - for (let i = 0; i < splitted.length; i++) { - let value = splitted[i].trim(); - while (value[value.length - 1] === '\\') { - i++; - value = value.replace('\\', ',') + splitted[i]; - } - args.push(value); - } - - return args; -} - -@Injectable() -export class FunctionalTranspiler - extends DefaultTranspiler - implements TranslocoTranspiler -{ - constructor(private injector: Injector) { - super(); - } - - transpile( - value: any, - params: HashMap = {}, - translation: Translation, - key: string - ): any { - let transpiled = value; - if (isString(value)) { - transpiled = value.replace( - /\[\[\s*(\w+)\((.*?)\)\s*]]/g, - (match: string, functionName: string, args: string) => { - try { - const func: TranslocoTranspilerFunction = - this.injector.get(functionName); - - return func.transpile(...getFunctionArgs(args)); - } catch (e: any) { - let message = `There is an error in: '${value}'. - Check that the you used the right syntax in your translation and that the implementation of ${functionName} is correct.`; - if (e.message.includes('NullInjectorError')) { - message = `You are using the '${functionName}' function in your translation but no provider was found!`; - } - throw new Error(message); - } - } - ); - } - - return super.transpile(transpiled, params, translation, key); - } -} +import { + Inject, + Injectable, + InjectionToken, + Injector, + Optional, +} from '@angular/core'; + +import { HashMap, Translation } from './types'; +import { getValue, isDefined, isObject, isString, setValue } from './helpers'; +import { + defaultConfig, + TRANSLOCO_CONFIG, + TranslocoConfig, +} from './transloco.config'; + +export const TRANSLOCO_TRANSPILER = new InjectionToken( + 'TRANSLOCO_TRANSPILER' +); + +export interface TranslocoTranspiler { + // TODO: Change parameters to object in the next major release + transpile( + value: any, + params: HashMap, + translation: Translation, + key: string + ): any; + + onLangChanged?(lang: string): void; +} + +@Injectable() +export class DefaultTranspiler implements TranslocoTranspiler { + protected interpolationMatcher: RegExp; + + constructor(@Optional() @Inject(TRANSLOCO_CONFIG) config?: TranslocoConfig) { + this.interpolationMatcher = resolveMatcher(config ?? defaultConfig); + } + + transpile( + value: any, + params: HashMap = {}, + translation: Translation, + key: string + ): any { + if (isString(value)) { + return value.replace(this.interpolationMatcher, (_, match) => { + match = match.trim(); + if (isDefined(params[match])) { + return params[match]; + } + + return isDefined(translation[match]) + ? this.transpile(translation[match], params, translation, key) + : ''; + }); + } else if (params) { + if (isObject(value)) { + value = this.handleObject(value, params, translation, key); + } else if (Array.isArray(value)) { + value = this.handleArray(value, params, translation, key); + } + } + + return value; + } + + /** + * + * @example + * + * const en = { + * a: { + * b: { + * c: "Hello {{ value }}" + * } + * } + * } + * + * const params = { + * "b.c": { value: "Transloco "} + * } + * + * service.selectTranslate('a', params); + * + * // the first param will be the result of `en.a`. + * // the second param will be `params`. + * parser.transpile(value, params, {}); + * + * + */ + protected handleObject( + value: any, + params: HashMap = {}, + translation: Translation, + key: string + ) { + let result = value; + + Object.keys(params).forEach((p) => { + // get the value of "b.c" inside "a" => "Hello {{ value }}" + const v = getValue(result, p); + // get the params of "b.c" => { value: "Transloco" } + const getParams = getValue(params, p); + + // transpile the value => "Hello Transloco" + const transpiled = this.transpile(v, getParams, translation, key); + + // set "b.c" to `transpiled` + result = setValue(result, p, transpiled); + }); + + return result; + } + + protected handleArray( + value: string[], + params: HashMap = {}, + translation: Translation, + key: string + ) { + return value.map((v) => this.transpile(v, params, translation, key)); + } +} + +function resolveMatcher(config: TranslocoConfig): RegExp { + const [start, end, forbiddenChars] = config.interpolation; + const matchingParamName = forbiddenChars != undefined ? `[^${escapeForRegExp(forbiddenChars)}]` : '.'; + return new RegExp( + `${escapeForRegExp(start)}(${matchingParamName}*?)${escapeForRegExp(end)}`, + 'g' + ); +} + +function escapeForRegExp(text: string) { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); +} + +export interface TranslocoTranspilerFunction { + transpile(...args: string[]): any; +} + +export function getFunctionArgs(argsString: string): string[] { + const splitted = argsString ? argsString.split(',') : []; + const args = []; + for (let i = 0; i < splitted.length; i++) { + let value = splitted[i].trim(); + while (value[value.length - 1] === '\\') { + i++; + value = value.replace('\\', ',') + splitted[i]; + } + args.push(value); + } + + return args; +} + +@Injectable() +export class FunctionalTranspiler + extends DefaultTranspiler + implements TranslocoTranspiler +{ + constructor(private injector: Injector) { + super(); + } + + transpile( + value: any, + params: HashMap = {}, + translation: Translation, + key: string + ): any { + let transpiled = value; + if (isString(value)) { + transpiled = value.replace( + /\[\[\s*(\w+)\((.*?)\)\s*]]/g, + (match: string, functionName: string, args: string) => { + try { + const func: TranslocoTranspilerFunction = + this.injector.get(functionName); + + return func.transpile(...getFunctionArgs(args)); + } catch (e: any) { + let message = `There is an error in: '${value}'. + Check that the you used the right syntax in your translation and that the implementation of ${functionName} is correct.`; + if (e.message.includes('NullInjectorError')) { + message = `You are using the '${functionName}' function in your translation but no provider was found!`; + } + throw new Error(message); + } + } + ); + } + + return super.transpile(transpiled, params, translation, key); + } +}