diff --git a/README.md b/README.md index 87305b8a..6fe348ef 100644 --- a/README.md +++ b/README.md @@ -74,15 +74,16 @@ export class SharedModule { } When you lazy load a module, you should use the `forChild` static method to import the `TranslateModule`. -Since lazy loaded modules use a different injector from the rest of your application, you can configure them separately with a different loader/parser/missing translations handler. +Since lazy loaded modules use a different injector from the rest of your application, you can configure them separately with a different loader/compiler/parser/missing translations handler. You can also isolate the service by using `isolate: true`. In which case the service is a completely isolated instance (for translations, current lang, events, ...). -Otherwise, by default, it will share its data with other instances of the service (but you can still use a different loader/parser/handler even if you don't isolate the service). +Otherwise, by default, it will share its data with other instances of the service (but you can still use a different loader/compiler/parser/handler even if you don't isolate the service). ```ts @NgModule({ imports: [ TranslateModule.forChild({ loader: {provide: TranslateLoader, useClass: CustomLoader}, + compiler: {provide: TranslateCompiler, useClass: CustomCompiler}, parser: {provide: TranslateParser, useClass: CustomParser}, missingTranslationHandler: {provide: MissingTranslationHandler, useClass: CustomHandler}, isolate: true @@ -352,6 +353,16 @@ export class AppModule { } ``` [Another custom loader example with translations stored in Firebase](FIREBASE_EXAMPLE.md) +#### How to use a compiler to preprocess translation values + +By default, translation values are added "as-is". You can configure a `compiler` that implements `TranslateCompiler` to pre-process translation values when they are added (either manually or by a loader). A compiler has the following methods: + +- `compile(value: string, lang: string): string | Function`: Compiles a string to a function or another string. +- `compileTranslations(translations: any, lang: string): any`: Compiles a (possibly nested) object of translation values to a structurally identical object of compiled translation values. + +Using a compiler opens the door for powerful pre-processing of translation values. As long as the compiler outputs a compatible interpolation string or an interpolation function, arbitrary input syntax can be supported. + + #### How to handle missing translations You can setup a provider for the `MissingTranslationHandler` in the bootstrap of your application (recommended), or in the `providers` property of a component. It will be called when the requested translation is not available. The only required method is `handle` where you can do whatever you want. If this method returns a value or an observable (that should return a string), then this will be used. Just don't forget that it will be called synchronously from the `instant` method. @@ -396,9 +407,10 @@ export class AppModule { } If you need it for some reason, you can use the `TranslateParser` service. #### Methods: -- `interpolate(expr: string, params?: any): string`: Interpolates a string to replace parameters. +- `interpolate(expr: string | Function, params?: any): string`: Interpolates a string to replace parameters or calls the interpolation function with the parameters. `This is a {{ key }}` ==> `This is a value` with `params = { key: "value" }` + `(params) => \`This is a ${params.key}\` ==> `This is a value` with `params = { key: "value" }` - `getValue(target: any, key: string): any`: Gets a value from an object by composed key `parser.getValue({ key1: { keyA: 'valueI' }}, 'key1.keyA') ==> 'valueI'` diff --git a/index.ts b/index.ts index 5e7f5bbf..47ff798f 100644 --- a/index.ts +++ b/index.ts @@ -3,6 +3,7 @@ import {TranslateLoader, TranslateFakeLoader} from "./src/translate.loader"; import {TranslateService} from "./src/translate.service"; import {MissingTranslationHandler, FakeMissingTranslationHandler} from "./src/missing-translation-handler"; import {TranslateParser, TranslateDefaultParser} from "./src/translate.parser"; +import {TranslateCompiler, TranslateFakeCompiler} from "./src/translate.compiler"; import {TranslateDirective} from "./src/translate.directive"; import {TranslatePipe} from "./src/translate.pipe"; import {TranslateStore} from "./src/translate.store"; @@ -13,11 +14,13 @@ export * from "./src/translate.loader"; export * from "./src/translate.service"; export * from "./src/missing-translation-handler"; export * from "./src/translate.parser"; +export * from "./src/translate.compiler"; export * from "./src/translate.directive"; export * from "./src/translate.pipe"; export interface TranslateModuleConfig { loader?: Provider; + compiler?: Provider; parser?: Provider; missingTranslationHandler?: Provider; // isolate the service instance, only works for lazy loaded modules or components with the "providers" property @@ -46,6 +49,7 @@ export class TranslateModule { ngModule: TranslateModule, providers: [ config.loader || {provide: TranslateLoader, useClass: TranslateFakeLoader}, + config.compiler || {provide: TranslateCompiler, useClass: TranslateFakeCompiler}, config.parser || {provide: TranslateParser, useClass: TranslateDefaultParser}, config.missingTranslationHandler || {provide: MissingTranslationHandler, useClass: FakeMissingTranslationHandler}, TranslateStore, @@ -66,6 +70,7 @@ export class TranslateModule { ngModule: TranslateModule, providers: [ config.loader || {provide: TranslateLoader, useClass: TranslateFakeLoader}, + config.compiler || {provide: TranslateCompiler, useClass: TranslateFakeCompiler}, config.parser || {provide: TranslateParser, useClass: TranslateDefaultParser}, config.missingTranslationHandler || {provide: MissingTranslationHandler, useClass: FakeMissingTranslationHandler}, {provide: USE_STORE, useValue: config.isolate}, diff --git a/src/translate.compiler.ts b/src/translate.compiler.ts new file mode 100644 index 00000000..9cb241f3 --- /dev/null +++ b/src/translate.compiler.ts @@ -0,0 +1,20 @@ +import {Injectable} from "@angular/core"; + +export abstract class TranslateCompiler { + abstract compile(value: string, lang: string): string | Function; + abstract compileTranslations(translations: any, lang: string): any; +} + +/** + * This compiler is just a placeholder that does nothing, in case you don't need a compiler at all + */ +@Injectable() +export class TranslateFakeCompiler extends TranslateCompiler { + compile(value: string, lang: string): string | Function { + return value; + } + + compileTranslations(translations: any, lang: string): any { + return translations; + } +} diff --git a/src/translate.parser.ts b/src/translate.parser.ts index 373da2a2..00745a75 100644 --- a/src/translate.parser.ts +++ b/src/translate.parser.ts @@ -9,7 +9,7 @@ export abstract class TranslateParser { * @param params * @returns {string} */ - abstract interpolate(expr: string, params?: any): string; + abstract interpolate(expr: string | Function, params?: any): string; /** * Gets a value from an object by composed key @@ -18,25 +18,29 @@ export abstract class TranslateParser { * @param key * @returns {string} */ - abstract getValue(target: any, key: string): string + abstract getValue(target: any, key: string): any } @Injectable() export class TranslateDefaultParser extends TranslateParser { templateMatcher: RegExp = /{{\s?([^{}\s]*)\s?}}/g; - public interpolate(expr: string, params?: any): string { - if(typeof expr !== 'string' || !params) { - return expr; + public interpolate(expr: string | Function, params?: any): string { + let result: string; + + if(typeof expr === 'string') { + result = this.interpolateString(expr, params); + } else if(typeof expr === 'function') { + result = this.interpolateFunction(expr, params); + } else { + // this should not happen, but an unrelated TranslateService test depends on it + result = expr as string; } - return expr.replace(this.templateMatcher, (substring: string, b: string) => { - let r = this.getValue(params, b); - return isDefined(r) ? r : substring; - }); + return result; } - getValue(target: any, key: string): string { + getValue(target: any, key: string): any { let keys = key.split('.'); key = ''; do { @@ -53,4 +57,19 @@ export class TranslateDefaultParser extends TranslateParser { return target; } + + private interpolateFunction(fn: Function, params?: any) { + return fn(params); + } + + private interpolateString(expr: string, params?: any) { + if (!params) { + return expr; + } + + return expr.replace(this.templateMatcher, (substring: string, b: string) => { + let r = this.getValue(params, b); + return isDefined(r) ? r : substring; + }); + } } diff --git a/src/translate.service.ts b/src/translate.service.ts index 6808ef61..ed7deeda 100644 --- a/src/translate.service.ts +++ b/src/translate.service.ts @@ -12,6 +12,7 @@ import "rxjs/add/operator/take"; import {TranslateStore} from "./translate.store"; import {TranslateLoader} from "./translate.loader"; +import {TranslateCompiler} from "./translate.compiler"; import {MissingTranslationHandler, MissingTranslationHandlerParams} from "./missing-translation-handler"; import {TranslateParser} from "./translate.parser"; import {mergeDeep, isDefined} from "./util"; @@ -152,6 +153,7 @@ export class TranslateService { * * @param store an instance of the store (that is supposed to be unique) * @param currentLoader An instance of the loader currently used + * @param compiler An instance of the compiler currently used * @param parser An instance of the parser currently used * @param missingTranslationHandler A handler for missing translations. * @param isolate whether this service should use the store or not @@ -159,6 +161,7 @@ export class TranslateService { */ constructor(public store: TranslateStore, public currentLoader: TranslateLoader, + public compiler: TranslateCompiler, public parser: TranslateParser, public missingTranslationHandler: MissingTranslationHandler, @Inject(USE_DEFAULT_LANG) private useDefaultLang: boolean = true, @@ -250,6 +253,7 @@ export class TranslateService { /** * Gets an object of translations for a given language with the current loader + * and passes it through the compiler * @param lang * @returns {Observable<*>} */ @@ -259,7 +263,7 @@ export class TranslateService { this.loadingTranslations.take(1) .subscribe((res: Object) => { - this.translations[lang] = res; + this.translations[lang] = this.compiler.compileTranslations(res, lang); this.updateLangs(); this.pending = false; }, (err: any) => { @@ -271,11 +275,13 @@ export class TranslateService { /** * Manually sets an object of translations for a given language + * after passing it through the compiler * @param lang * @param translations * @param shouldMerge */ public setTranslation(lang: string, translations: Object, shouldMerge: boolean = false): void { + translations = this.compiler.compileTranslations(translations, lang); if(shouldMerge && this.translations[lang]) { this.translations[lang] = mergeDeep(this.translations[lang], translations); } else { @@ -462,13 +468,13 @@ export class TranslateService { } /** - * Sets the translated value of a key + * Sets the translated value of a key, after compiling it * @param key * @param value * @param lang */ public set(key: string, value: string, lang: string = this.currentLang): void { - this.translations[lang][key] = value; + this.translations[lang][key] = this.compiler.compile(value, lang); this.updateLangs(); this.onTranslationChange.emit({lang: lang, translations: this.translations[lang]}); } diff --git a/tests/translate.compiler.spec.ts b/tests/translate.compiler.spec.ts new file mode 100644 index 00000000..a8bcb2c3 --- /dev/null +++ b/tests/translate.compiler.spec.ts @@ -0,0 +1,111 @@ +import {Injector} from "@angular/core"; +import {TestBed, getTestBed} from "@angular/core/testing"; +import {TranslateService, TranslateModule, TranslateLoader, TranslateCompiler, TranslateFakeCompiler} from "../index"; +import {Observable} from "rxjs/Observable"; + +let translations: any = {LOAD: 'This is a test'}; + +class FakeLoader implements TranslateLoader { + getTranslation(lang: string): Observable { + return Observable.of(translations); + } +} + +describe('TranslateCompiler', () => { + let injector: Injector; + let translate: TranslateService; + + let prepare = (_injector: Injector) => { + translate = _injector.get(TranslateService); + }; + + describe('with default TranslateFakeCompiler', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: {provide: TranslateLoader, useClass: FakeLoader}, + compiler: {provide: TranslateCompiler, useClass: TranslateFakeCompiler} + }) + ], + }); + injector = getTestBed(); + prepare(injector); + + translate.use('en'); + }); + + it('should use the correct compiler', () => { + expect(translate).toBeDefined(); + expect(translate.compiler).toBeDefined(); + expect(translate.compiler instanceof TranslateFakeCompiler).toBeTruthy(); + }); + + it('should use the compiler on loading translations', () => { + translate.get('LOAD').subscribe((res: string) => { + expect(res).toBe('This is a test'); + }); + }); + + it('should use the compiler on manually adding a translation object', () => { + translate.setTranslation('en', {'SET-TRANSLATION': 'A manually added translation'}); + expect(translate.instant('SET-TRANSLATION')).toBe('A manually added translation'); + }); + + it('should use the compiler on manually adding a single translation', () => { + translate.set('SET', 'Another manually added translation', 'en'); + expect(translate.instant('SET')).toBe('Another manually added translation'); + }); + }); + + describe('with a custom compiler implementation', () => { + class CustomCompiler implements TranslateCompiler { + compile(value: string, lang: string): string { + return value + '|compiled'; + } + compileTranslations(translation: any, lang: string): Object { + return Object.keys(translation).reduce((acc: any, key) => { + acc[key] = () => translation[key] + '|compiled'; + return acc; + }, {}); + } + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: {provide: TranslateLoader, useClass: FakeLoader}, + compiler: {provide: TranslateCompiler, useClass: CustomCompiler} + }) + ], + }); + injector = getTestBed(); + prepare(injector); + + translate.use('en'); + }); + + it('should use the correct compiler', () => { + expect(translate).toBeDefined(); + expect(translate.compiler).toBeDefined(); + expect(translate.compiler instanceof CustomCompiler).toBeTruthy(); + }); + + it('should use the compiler on loading translations', () => { + translate.get('LOAD').subscribe((res: string) => { + expect(res).toBe('This is a test|compiled'); + }); + }); + + it('should use the compiler on manually adding a translation object', () => { + translate.setTranslation('en', {'SET-TRANSLATION': 'A manually added translation'}); + expect(translate.instant('SET-TRANSLATION')).toBe('A manually added translation|compiled'); + }); + + it('should use the compiler on manually adding a single translation', () => { + translate.set('SET', 'Another manually added translation', 'en'); + expect(translate.instant('SET')).toBe('Another manually added translation|compiled'); + }); + }); +}); diff --git a/tests/translate.parser.spec.ts b/tests/translate.parser.spec.ts index 1f98f233..596c5de5 100644 --- a/tests/translate.parser.spec.ts +++ b/tests/translate.parser.spec.ts @@ -13,20 +13,24 @@ describe('Parser', () => { expect(parser instanceof TranslateParser).toBeTruthy(); }); - it('should interpolate', () => { + it('should interpolate strings', () => { expect(parser.interpolate("This is a {{ key }}", {key: "value"})).toEqual("This is a value"); }); - it('should interpolate with falsy values', () => { + it('should interpolate strings with falsy values', () => { expect(parser.interpolate("This is a {{ key }}", {key: ""})).toEqual("This is a "); expect(parser.interpolate("This is a {{ key }}", {key: 0})).toEqual("This is a 0"); }); - it('should interpolate with object properties', () => { + it('should interpolate strings with object properties', () => { expect(parser.interpolate("This is a {{ key1.key2 }}", {key1: {key2: "value2"}})).toEqual("This is a value2"); expect(parser.interpolate("This is a {{ key1.key2.key3 }}", {key1: {key2: {key3: "value3"}}})).toEqual("This is a value3"); }); + it('should support interpolation functions', () => { + expect(parser.interpolate((v: string) => v.toUpperCase() + ' YOU!', 'bless')).toBe('BLESS YOU!'); + }); + it('should get the addressed value', () => { expect(parser.getValue({key1: {key2: "value2"}}, 'key1.key2')).toEqual("value2"); expect(parser.getValue({key1: {key2: "value"}}, 'keyWrong.key2')).not.toBeDefined();