feat(compiler): allow missing translations (#14113)

closes #13861
This commit is contained in:
Gion Kunz 2017-01-10 14:14:41 +01:00 committed by Miško Hevery
parent 5885c52c1f
commit 8775ab9495
9 changed files with 114 additions and 25 deletions

View File

@ -6,6 +6,8 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {MissingTranslationStrategy} from '@angular/core';
import {HtmlParser} from '../ml_parser/html_parser'; import {HtmlParser} from '../ml_parser/html_parser';
import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../ml_parser/interpolation_config'; import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../ml_parser/interpolation_config';
import {ParseTreeResult} from '../ml_parser/parser'; import {ParseTreeResult} from '../ml_parser/parser';
@ -26,7 +28,9 @@ export class I18NHtmlParser implements HtmlParser {
// TODO(vicb): remove the interpolationConfig from the Xtb serializer // TODO(vicb): remove the interpolationConfig from the Xtb serializer
constructor( constructor(
private _htmlParser: HtmlParser, private _translations?: string, private _htmlParser: HtmlParser, private _translations?: string,
private _translationsFormat?: string) {} private _translationsFormat?: string,
private _missingTranslationStrategy:
MissingTranslationStrategy = MissingTranslationStrategy.Error) {}
parse( parse(
source: string, url: string, parseExpansionForms: boolean = false, source: string, url: string, parseExpansionForms: boolean = false,
@ -46,7 +50,8 @@ export class I18NHtmlParser implements HtmlParser {
} }
const serializer = this._createSerializer(); const serializer = this._createSerializer();
const translationBundle = TranslationBundle.load(this._translations, url, serializer); const translationBundle = TranslationBundle.load(
this._translations, url, serializer, this._missingTranslationStrategy);
return mergeTranslations(parseResult.rootNodes, translationBundle, interpolationConfig, [], {}); return mergeTranslations(parseResult.rootNodes, translationBundle, interpolationConfig, [], {});
} }

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {ParseError, ParseSourceSpan} from '../parse_util'; import {ParseError, ParseErrorLevel, ParseSourceSpan} from '../parse_util';
/** /**
* An i18n error. * An i18n error.
@ -14,3 +14,7 @@ import {ParseError, ParseSourceSpan} from '../parse_util';
export class I18nError extends ParseError { export class I18nError extends ParseError {
constructor(span: ParseSourceSpan, msg: string) { super(span, msg); } constructor(span: ParseSourceSpan, msg: string) { super(span, msg); }
} }
export class I18nWarning extends ParseError {
constructor(span: ParseSourceSpan, msg: string) { super(span, msg, ParseErrorLevel.WARNING); }
}

View File

@ -6,11 +6,15 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {MissingTranslationStrategy} from '@angular/core';
import {warn} from '../facade/lang';
import * as html from '../ml_parser/ast'; import * as html from '../ml_parser/ast';
import {HtmlParser} from '../ml_parser/html_parser'; import {HtmlParser} from '../ml_parser/html_parser';
import {serializeNodes} from './digest';
import * as i18n from './i18n_ast'; import * as i18n from './i18n_ast';
import {I18nError} from './parse_util'; import {I18nError, I18nWarning} from './parse_util';
import {PlaceholderMapper, Serializer} from './serializers/serializer'; import {PlaceholderMapper, Serializer} from './serializers/serializer';
/** /**
@ -22,22 +26,31 @@ export class TranslationBundle {
constructor( constructor(
private _i18nNodesByMsgId: {[msgId: string]: i18n.Node[]} = {}, private _i18nNodesByMsgId: {[msgId: string]: i18n.Node[]} = {},
public digest: (m: i18n.Message) => string, public digest: (m: i18n.Message) => string,
public mapperFactory?: (m: i18n.Message) => PlaceholderMapper) { public mapperFactory?: (m: i18n.Message) => PlaceholderMapper,
this._i18nToHtml = new I18nToHtmlVisitor(_i18nNodesByMsgId, digest, mapperFactory); missingTranslationStrategy: MissingTranslationStrategy = MissingTranslationStrategy.Warning) {
this._i18nToHtml =
new I18nToHtmlVisitor(_i18nNodesByMsgId, digest, mapperFactory, missingTranslationStrategy);
} }
// Creates a `TranslationBundle` by parsing the given `content` with the `serializer`. // Creates a `TranslationBundle` by parsing the given `content` with the `serializer`.
static load(content: string, url: string, serializer: Serializer): TranslationBundle { static load(
content: string, url: string, serializer: Serializer,
missingTranslationStrategy: MissingTranslationStrategy): TranslationBundle {
const i18nNodesByMsgId = serializer.load(content, url); const i18nNodesByMsgId = serializer.load(content, url);
const digestFn = (m: i18n.Message) => serializer.digest(m); const digestFn = (m: i18n.Message) => serializer.digest(m);
const mapperFactory = (m: i18n.Message) => serializer.createNameMapper(m); const mapperFactory = (m: i18n.Message) => serializer.createNameMapper(m);
return new TranslationBundle(i18nNodesByMsgId, digestFn, mapperFactory); return new TranslationBundle(
i18nNodesByMsgId, digestFn, mapperFactory, missingTranslationStrategy);
} }
// Returns the translation as HTML nodes from the given source message. // Returns the translation as HTML nodes from the given source message.
get(srcMsg: i18n.Message): html.Node[] { get(srcMsg: i18n.Message): html.Node[] {
const html = this._i18nToHtml.convert(srcMsg); const html = this._i18nToHtml.convert(srcMsg);
if (html.warnings.length) {
warn(html.warnings.join('\n'));
}
if (html.errors.length) { if (html.errors.length) {
throw new Error(html.errors.join('\n')); throw new Error(html.errors.join('\n'));
} }
@ -53,15 +66,19 @@ class I18nToHtmlVisitor implements i18n.Visitor {
private _contextStack: {msg: i18n.Message, mapper: (name: string) => string}[] = []; private _contextStack: {msg: i18n.Message, mapper: (name: string) => string}[] = [];
private _errors: I18nError[] = []; private _errors: I18nError[] = [];
private _mapper: (name: string) => string; private _mapper: (name: string) => string;
private _warnings: I18nWarning[] = [];
constructor( constructor(
private _i18nNodesByMsgId: {[msgId: string]: i18n.Node[]} = {}, private _i18nNodesByMsgId: {[msgId: string]: i18n.Node[]} = {},
private _digest: (m: i18n.Message) => string, private _digest: (m: i18n.Message) => string,
private _mapperFactory: (m: i18n.Message) => PlaceholderMapper) {} private _mapperFactory: (m: i18n.Message) => PlaceholderMapper,
private _missingTranslationStrategy: MissingTranslationStrategy) {}
convert(srcMsg: i18n.Message): {nodes: html.Node[], errors: I18nError[]} { convert(srcMsg: i18n.Message):
{nodes: html.Node[], errors: I18nError[], warnings: I18nWarning[]} {
this._contextStack.length = 0; this._contextStack.length = 0;
this._errors.length = 0; this._errors.length = 0;
// i18n to text // i18n to text
const text = this._convertToText(srcMsg); const text = this._convertToText(srcMsg);
@ -72,6 +89,7 @@ class I18nToHtmlVisitor implements i18n.Visitor {
return { return {
nodes: html.rootNodes, nodes: html.rootNodes,
errors: [...this._errors, ...html.errors], errors: [...this._errors, ...html.errors],
warnings: this._warnings
}; };
} }
@ -134,11 +152,22 @@ class I18nToHtmlVisitor implements i18n.Visitor {
return text; return text;
} }
this._addError(srcMsg.nodes[0], `Missing translation for message ${digest}`); // No valid translation found
return ''; if (this._missingTranslationStrategy === MissingTranslationStrategy.Error) {
this._addError(srcMsg.nodes[0], `Missing translation for message ${digest}`);
} else if (this._missingTranslationStrategy === MissingTranslationStrategy.Warning) {
this._addWarning(srcMsg.nodes[0], `Missing translation for message ${digest}`);
}
// In an case, Warning, Error or Ignore, return the srcMsg without translation
return serializeNodes(srcMsg.nodes).join('');
} }
private _addError(el: i18n.Node, msg: string) { private _addError(el: i18n.Node, msg: string) {
this._errors.push(new I18nError(el.sourceSpan, msg)); this._errors.push(new I18nError(el.sourceSpan, msg));
} }
}
private _addWarning(el: i18n.Node, msg: string) {
this._warnings.push(new I18nWarning(el.sourceSpan, msg));
}
}

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {COMPILER_OPTIONS, Compiler, CompilerFactory, CompilerOptions, Inject, InjectionToken, Optional, PLATFORM_INITIALIZER, PlatformRef, Provider, ReflectiveInjector, TRANSLATIONS, TRANSLATIONS_FORMAT, Type, ViewEncapsulation, createPlatformFactory, isDevMode, platformCore} from '@angular/core'; import {COMPILER_OPTIONS, Compiler, CompilerFactory, CompilerOptions, Inject, InjectionToken, MISSING_TRANSLATION_STRATEGY, MissingTranslationStrategy, Optional, PLATFORM_INITIALIZER, PlatformRef, Provider, ReflectiveInjector, TRANSLATIONS, TRANSLATIONS_FORMAT, Type, ViewEncapsulation, createPlatformFactory, isDevMode, platformCore} from '@angular/core';
import {AnimationParser} from '../animation/animation_parser'; import {AnimationParser} from '../animation/animation_parser';
import {CompilerConfig} from '../config'; import {CompilerConfig} from '../config';
@ -60,12 +60,15 @@ export const COMPILER_PROVIDERS: Array<any|Type<any>|{[k: string]: any}|any[]> =
}, },
{ {
provide: i18n.I18NHtmlParser, provide: i18n.I18NHtmlParser,
useFactory: (parser: HtmlParser, translations: string, format: string) => useFactory:
new i18n.I18NHtmlParser(parser, translations, format), (parser: HtmlParser, translations: string, format: string,
missingTranslationStrategy: MissingTranslationStrategy) =>
new i18n.I18NHtmlParser(parser, translations, format, missingTranslationStrategy),
deps: [ deps: [
baseHtmlParser, baseHtmlParser,
[new Optional(), new Inject(TRANSLATIONS)], [new Optional(), new Inject(TRANSLATIONS)],
[new Optional(), new Inject(TRANSLATIONS_FORMAT)], [new Optional(), new Inject(TRANSLATIONS_FORMAT)],
[new Optional(), new Inject(MISSING_TRANSLATION_STRATEGY)],
] ]
}, },
{ {

View File

@ -488,7 +488,7 @@ function fakeTranslate(
i18nMsgMap[id] = [new i18n.Text(`**${text}**`, null)]; i18nMsgMap[id] = [new i18n.Text(`**${text}**`, null)];
}); });
const translations = new TranslationBundle(i18nMsgMap, digest); const translations = new TranslationBundle(i18nMsgMap, digest, null);
const output = mergeTranslations( const output = mergeTranslations(
htmlNodes, translations, DEFAULT_INTERPOLATION_CONFIG, implicitTags, implicitAttrs); htmlNodes, translations, DEFAULT_INTERPOLATION_CONFIG, implicitTags, implicitAttrs);

View File

@ -6,6 +6,8 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {MissingTranslationStrategy} from '@angular/core';
import * as i18n from '../../src/i18n/i18n_ast'; import * as i18n from '../../src/i18n/i18n_ast';
import {TranslationBundle} from '../../src/i18n/translation_bundle'; import {TranslationBundle} from '../../src/i18n/translation_bundle';
import {ParseLocation, ParseSourceFile, ParseSourceSpan} from '../../src/parse_util'; import {ParseLocation, ParseSourceFile, ParseSourceSpan} from '../../src/parse_util';
@ -20,7 +22,7 @@ export function main(): void {
it('should translate a plain message', () => { it('should translate a plain message', () => {
const msgMap = {foo: [new i18n.Text('bar', null)]}; const msgMap = {foo: [new i18n.Text('bar', null)]};
const tb = new TranslationBundle(msgMap, (_) => 'foo'); const tb = new TranslationBundle(msgMap, (_) => 'foo', null);
const msg = new i18n.Message([srcNode], {}, {}, 'm', 'd', 'i'); const msg = new i18n.Message([srcNode], {}, {}, 'm', 'd', 'i');
expect(serializeNodes(tb.get(msg))).toEqual(['bar']); expect(serializeNodes(tb.get(msg))).toEqual(['bar']);
}); });
@ -35,7 +37,7 @@ export function main(): void {
const phMap = { const phMap = {
ph1: '*phContent*', ph1: '*phContent*',
}; };
const tb = new TranslationBundle(msgMap, (_) => 'foo'); const tb = new TranslationBundle(msgMap, (_) => 'foo', null);
const msg = new i18n.Message([srcNode], phMap, {}, 'm', 'd', 'i'); const msg = new i18n.Message([srcNode], phMap, {}, 'm', 'd', 'i');
expect(serializeNodes(tb.get(msg))).toEqual(['bar*phContent*']); expect(serializeNodes(tb.get(msg))).toEqual(['bar*phContent*']);
}); });
@ -55,7 +57,7 @@ export function main(): void {
const msg = new i18n.Message([srcNode], {}, {ph1: refMsg}, 'm', 'd', 'i'); const msg = new i18n.Message([srcNode], {}, {ph1: refMsg}, 'm', 'd', 'i');
let count = 0; let count = 0;
const digest = (_: any) => count++ ? 'ref' : 'foo'; const digest = (_: any) => count++ ? 'ref' : 'foo';
const tb = new TranslationBundle(msgMap, digest); const tb = new TranslationBundle(msgMap, digest, null);
expect(serializeNodes(tb.get(msg))).toEqual(['--*refMsg*++']); expect(serializeNodes(tb.get(msg))).toEqual(['--*refMsg*++']);
}); });
@ -68,17 +70,38 @@ export function main(): void {
new i18n.Placeholder('', 'ph1', span), new i18n.Placeholder('', 'ph1', span),
] ]
}; };
const tb = new TranslationBundle(msgMap, (_) => 'foo'); const tb = new TranslationBundle(msgMap, (_) => 'foo', null);
const msg = new i18n.Message([srcNode], {}, {}, 'm', 'd', 'i'); const msg = new i18n.Message([srcNode], {}, {}, 'm', 'd', 'i');
expect(() => tb.get(msg)).toThrowError(/Unknown placeholder/); expect(() => tb.get(msg)).toThrowError(/Unknown placeholder/);
}); });
it('should report missing translation', () => { it('should report missing translation', () => {
const tb = new TranslationBundle({}, (_) => 'foo'); const tb = new TranslationBundle(
{}, (_) => 'foo', null, MissingTranslationStrategy.Error);
const msg = new i18n.Message([srcNode], {}, {}, 'm', 'd', 'i'); const msg = new i18n.Message([srcNode], {}, {}, 'm', 'd', 'i');
expect(() => tb.get(msg)).toThrowError(/Missing translation for message foo/); expect(() => tb.get(msg)).toThrowError(/Missing translation for message foo/);
}); });
it('should report missing translation with MissingTranslationStrategy.Warning', () => {
const tb = new TranslationBundle(
{}, (_) => 'foo', null, MissingTranslationStrategy.Warning);
const msg = new i18n.Message([srcNode], {}, {}, 'm', 'd', 'i');
const warn = console.warn;
const consoleWarnSpy = spyOn(console, 'warn').and.callThrough();
expect(() => tb.get(msg)).not.toThrowError();
expect(consoleWarnSpy.calls.mostRecent().args[0])
.toMatch(/Missing translation for message foo/);
console.warn = warn;
});
it('should not report missing translation with MissingTranslationStrategy.Ignore', () => {
const tb = new TranslationBundle(
{}, (_) => 'foo', null, MissingTranslationStrategy.Ignore);
const msg = new i18n.Message([srcNode], {}, {}, 'm', 'd', 'i');
expect(() => tb.get(msg)).not.toThrowError();
});
it('should report missing referenced message', () => { it('should report missing referenced message', () => {
const msgMap = { const msgMap = {
foo: [new i18n.Placeholder('', 'ph1', span)], foo: [new i18n.Placeholder('', 'ph1', span)],
@ -87,7 +110,8 @@ export function main(): void {
const msg = new i18n.Message([srcNode], {}, {ph1: refMsg}, 'm', 'd', 'i'); const msg = new i18n.Message([srcNode], {}, {ph1: refMsg}, 'm', 'd', 'i');
let count = 0; let count = 0;
const digest = (_: any) => count++ ? 'ref' : 'foo'; const digest = (_: any) => count++ ? 'ref' : 'foo';
const tb = new TranslationBundle(msgMap, digest); const tb =
new TranslationBundle(msgMap, digest, null, MissingTranslationStrategy.Error);
expect(() => tb.get(msg)).toThrowError(/Missing translation for message ref/); expect(() => tb.get(msg)).toThrowError(/Missing translation for message ref/);
}); });
@ -101,7 +125,7 @@ export function main(): void {
const phMap = { const phMap = {
ph1: '</b>', ph1: '</b>',
}; };
const tb = new TranslationBundle(msgMap, (_) => 'foo'); const tb = new TranslationBundle(msgMap, (_) => 'foo', null);
const msg = new i18n.Message([srcNode], phMap, {}, 'm', 'd', 'i'); const msg = new i18n.Message([srcNode], phMap, {}, 'm', 'd', 'i');
expect(() => tb.get(msg)).toThrowError(/Unexpected closing tag "b"/); expect(() => tb.get(msg)).toThrowError(/Unexpected closing tag "b"/);
}); });

View File

@ -25,7 +25,7 @@ export {DebugElement, DebugNode, asNativeElements, getDebugNode} from './debug/d
export {GetTestability, Testability, TestabilityRegistry, setTestabilityGetter} from './testability/testability'; export {GetTestability, Testability, TestabilityRegistry, setTestabilityGetter} from './testability/testability';
export * from './change_detection'; export * from './change_detection';
export * from './platform_core_providers'; export * from './platform_core_providers';
export {TRANSLATIONS, TRANSLATIONS_FORMAT, LOCALE_ID} from './i18n/tokens'; export {TRANSLATIONS, TRANSLATIONS_FORMAT, LOCALE_ID, MISSING_TRANSLATION_STRATEGY, MissingTranslationStrategy} from './i18n/tokens';
export {ApplicationModule} from './application_module'; export {ApplicationModule} from './application_module';
export {wtfCreateScope, wtfLeave, wtfStartTimeRange, wtfEndTimeRange, WtfScopeFn} from './profile/profile'; export {wtfCreateScope, wtfLeave, wtfStartTimeRange, wtfEndTimeRange, WtfScopeFn} from './profile/profile';
export {Type} from './type'; export {Type} from './type';

View File

@ -22,3 +22,17 @@ export const TRANSLATIONS = new InjectionToken<string>('Translations');
* @experimental i18n support is experimental. * @experimental i18n support is experimental.
*/ */
export const TRANSLATIONS_FORMAT = new InjectionToken<string>('TranslationsFormat'); export const TRANSLATIONS_FORMAT = new InjectionToken<string>('TranslationsFormat');
/**
* @experimental i18n support is experimental.
*/
export const MISSING_TRANSLATION_STRATEGY = new InjectionToken('MissingTranslationStrategy');
/**
* @experimental i18n support is experimental.
*/
export enum MissingTranslationStrategy {
Error,
Warning,
Ignore,
}

View File

@ -617,6 +617,16 @@ export declare class KeyValueDiffers {
/** @experimental */ /** @experimental */
export declare const LOCALE_ID: InjectionToken<string>; export declare const LOCALE_ID: InjectionToken<string>;
/** @experimental */
export declare const MISSING_TRANSLATION_STRATEGY: OpaqueToken;
/** @experimental */
export declare enum MissingTranslationStrategy {
Error = 0,
Warning = 1,
Ignore = 2,
}
/** @experimental */ /** @experimental */
export declare class ModuleWithComponentFactories<T> { export declare class ModuleWithComponentFactories<T> {
componentFactories: ComponentFactory<any>[]; componentFactories: ComponentFactory<any>[];