Skip to content

Commit

Permalink
feat(TranslateCompiler): new compiler that you can override to pre-pr…
Browse files Browse the repository at this point in the history
…ocess translations

Whenever translations are added (manually or by a loader), they are passed to the compiler for
pre-processing. The default (TranslateFakeCompiler) does nothing.
  • Loading branch information
Lukas Rieder authored and ocombe committed Aug 14, 2017
1 parent 37f144e commit 1107d98
Show file tree
Hide file tree
Showing 7 changed files with 196 additions and 19 deletions.
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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'`
Expand Down
5 changes: 5 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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},
Expand Down
20 changes: 20 additions & 0 deletions src/translate.compiler.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
39 changes: 29 additions & 10 deletions src/translate.parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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;
});
}
}
12 changes: 9 additions & 3 deletions src/translate.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -152,13 +153,15 @@ 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
* @param useDefaultLang whether we should use default language translation when current language translation is missing.
*/
constructor(public store: TranslateStore,
public currentLoader: TranslateLoader,
public compiler: TranslateCompiler,
public parser: TranslateParser,
public missingTranslationHandler: MissingTranslationHandler,
@Inject(USE_DEFAULT_LANG) private useDefaultLang: boolean = true,
Expand Down Expand Up @@ -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<*>}
*/
Expand All @@ -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) => {
Expand All @@ -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 {
Expand Down Expand Up @@ -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]});
}
Expand Down
111 changes: 111 additions & 0 deletions tests/translate.compiler.spec.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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');
});
});
});
10 changes: 7 additions & 3 deletions tests/translate.parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down

0 comments on commit 1107d98

Please sign in to comment.