diff --git a/modules/@angular/compiler/src/i18n/i18n_html_parser.ts b/modules/@angular/compiler/src/i18n/i18n_html_parser.ts index 78317f944f..411939adb9 100644 --- a/modules/@angular/compiler/src/i18n/i18n_html_parser.ts +++ b/modules/@angular/compiler/src/i18n/i18n_html_parser.ts @@ -6,6 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ +import {MissingTranslationStrategy} from '@angular/core'; + import {HtmlParser} from '../ml_parser/html_parser'; import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../ml_parser/interpolation_config'; import {ParseTreeResult} from '../ml_parser/parser'; @@ -26,7 +28,9 @@ export class I18NHtmlParser implements HtmlParser { // TODO(vicb): remove the interpolationConfig from the Xtb serializer constructor( private _htmlParser: HtmlParser, private _translations?: string, - private _translationsFormat?: string) {} + private _translationsFormat?: string, + private _missingTranslationStrategy: + MissingTranslationStrategy = MissingTranslationStrategy.Error) {} parse( source: string, url: string, parseExpansionForms: boolean = false, @@ -46,7 +50,8 @@ export class I18NHtmlParser implements HtmlParser { } 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, [], {}); } diff --git a/modules/@angular/compiler/src/i18n/parse_util.ts b/modules/@angular/compiler/src/i18n/parse_util.ts index 37acd859ac..928cba1038 100644 --- a/modules/@angular/compiler/src/i18n/parse_util.ts +++ b/modules/@angular/compiler/src/i18n/parse_util.ts @@ -6,7 +6,7 @@ * 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. @@ -14,3 +14,7 @@ import {ParseError, ParseSourceSpan} from '../parse_util'; export class I18nError extends ParseError { constructor(span: ParseSourceSpan, msg: string) { super(span, msg); } } + +export class I18nWarning extends ParseError { + constructor(span: ParseSourceSpan, msg: string) { super(span, msg, ParseErrorLevel.WARNING); } +} diff --git a/modules/@angular/compiler/src/i18n/translation_bundle.ts b/modules/@angular/compiler/src/i18n/translation_bundle.ts index 56852395a4..f73deab9c8 100644 --- a/modules/@angular/compiler/src/i18n/translation_bundle.ts +++ b/modules/@angular/compiler/src/i18n/translation_bundle.ts @@ -6,11 +6,15 @@ * 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 {HtmlParser} from '../ml_parser/html_parser'; +import {serializeNodes} from './digest'; import * as i18n from './i18n_ast'; -import {I18nError} from './parse_util'; +import {I18nError, I18nWarning} from './parse_util'; import {PlaceholderMapper, Serializer} from './serializers/serializer'; /** @@ -22,22 +26,31 @@ export class TranslationBundle { constructor( private _i18nNodesByMsgId: {[msgId: string]: i18n.Node[]} = {}, public digest: (m: i18n.Message) => string, - public mapperFactory?: (m: i18n.Message) => PlaceholderMapper) { - this._i18nToHtml = new I18nToHtmlVisitor(_i18nNodesByMsgId, digest, mapperFactory); + public mapperFactory?: (m: i18n.Message) => PlaceholderMapper, + missingTranslationStrategy: MissingTranslationStrategy = MissingTranslationStrategy.Warning) { + this._i18nToHtml = + new I18nToHtmlVisitor(_i18nNodesByMsgId, digest, mapperFactory, missingTranslationStrategy); } // 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 digestFn = (m: i18n.Message) => serializer.digest(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. get(srcMsg: i18n.Message): html.Node[] { const html = this._i18nToHtml.convert(srcMsg); + if (html.warnings.length) { + warn(html.warnings.join('\n')); + } + if (html.errors.length) { 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 _errors: I18nError[] = []; private _mapper: (name: string) => string; + private _warnings: I18nWarning[] = []; constructor( private _i18nNodesByMsgId: {[msgId: string]: i18n.Node[]} = {}, 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._errors.length = 0; + // i18n to text const text = this._convertToText(srcMsg); @@ -72,6 +89,7 @@ class I18nToHtmlVisitor implements i18n.Visitor { return { nodes: html.rootNodes, errors: [...this._errors, ...html.errors], + warnings: this._warnings }; } @@ -134,11 +152,22 @@ class I18nToHtmlVisitor implements i18n.Visitor { return text; } - this._addError(srcMsg.nodes[0], `Missing translation for message ${digest}`); - return ''; + // No valid translation found + 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) { this._errors.push(new I18nError(el.sourceSpan, msg)); } -} \ No newline at end of file + + private _addWarning(el: i18n.Node, msg: string) { + this._warnings.push(new I18nWarning(el.sourceSpan, msg)); + } +} diff --git a/modules/@angular/compiler/src/jit/compiler_factory.ts b/modules/@angular/compiler/src/jit/compiler_factory.ts index 40a05ff5ac..74d5be6184 100644 --- a/modules/@angular/compiler/src/jit/compiler_factory.ts +++ b/modules/@angular/compiler/src/jit/compiler_factory.ts @@ -6,7 +6,7 @@ * 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 {CompilerConfig} from '../config'; @@ -60,12 +60,15 @@ export const COMPILER_PROVIDERS: Array|{[k: string]: any}|any[]> = }, { provide: i18n.I18NHtmlParser, - useFactory: (parser: HtmlParser, translations: string, format: string) => - new i18n.I18NHtmlParser(parser, translations, format), + useFactory: + (parser: HtmlParser, translations: string, format: string, + missingTranslationStrategy: MissingTranslationStrategy) => + new i18n.I18NHtmlParser(parser, translations, format, missingTranslationStrategy), deps: [ baseHtmlParser, [new Optional(), new Inject(TRANSLATIONS)], [new Optional(), new Inject(TRANSLATIONS_FORMAT)], + [new Optional(), new Inject(MISSING_TRANSLATION_STRATEGY)], ] }, { diff --git a/modules/@angular/compiler/test/i18n/extractor_merger_spec.ts b/modules/@angular/compiler/test/i18n/extractor_merger_spec.ts index e7f607c3d4..0b785645b1 100644 --- a/modules/@angular/compiler/test/i18n/extractor_merger_spec.ts +++ b/modules/@angular/compiler/test/i18n/extractor_merger_spec.ts @@ -488,7 +488,7 @@ function fakeTranslate( i18nMsgMap[id] = [new i18n.Text(`**${text}**`, null)]; }); - const translations = new TranslationBundle(i18nMsgMap, digest); + const translations = new TranslationBundle(i18nMsgMap, digest, null); const output = mergeTranslations( htmlNodes, translations, DEFAULT_INTERPOLATION_CONFIG, implicitTags, implicitAttrs); diff --git a/modules/@angular/compiler/test/i18n/translation_bundle_spec.ts b/modules/@angular/compiler/test/i18n/translation_bundle_spec.ts index 2aa45331f8..68b17d3ddc 100644 --- a/modules/@angular/compiler/test/i18n/translation_bundle_spec.ts +++ b/modules/@angular/compiler/test/i18n/translation_bundle_spec.ts @@ -6,6 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ +import {MissingTranslationStrategy} from '@angular/core'; + import * as i18n from '../../src/i18n/i18n_ast'; import {TranslationBundle} from '../../src/i18n/translation_bundle'; import {ParseLocation, ParseSourceFile, ParseSourceSpan} from '../../src/parse_util'; @@ -20,7 +22,7 @@ export function main(): void { it('should translate a plain message', () => { 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'); expect(serializeNodes(tb.get(msg))).toEqual(['bar']); }); @@ -35,7 +37,7 @@ export function main(): void { const phMap = { 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'); 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'); let count = 0; 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*++']); }); @@ -68,17 +70,38 @@ export function main(): void { 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'); expect(() => tb.get(msg)).toThrowError(/Unknown placeholder/); }); 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'); 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', () => { const msgMap = { 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'); let count = 0; 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/); }); @@ -101,7 +125,7 @@ export function main(): void { const phMap = { ph1: '', }; - const tb = new TranslationBundle(msgMap, (_) => 'foo'); + const tb = new TranslationBundle(msgMap, (_) => 'foo', null); const msg = new i18n.Message([srcNode], phMap, {}, 'm', 'd', 'i'); expect(() => tb.get(msg)).toThrowError(/Unexpected closing tag "b"/); }); diff --git a/modules/@angular/core/src/core.ts b/modules/@angular/core/src/core.ts index b033069b1a..b62f94d89b 100644 --- a/modules/@angular/core/src/core.ts +++ b/modules/@angular/core/src/core.ts @@ -25,7 +25,7 @@ export {DebugElement, DebugNode, asNativeElements, getDebugNode} from './debug/d export {GetTestability, Testability, TestabilityRegistry, setTestabilityGetter} from './testability/testability'; export * from './change_detection'; 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 {wtfCreateScope, wtfLeave, wtfStartTimeRange, wtfEndTimeRange, WtfScopeFn} from './profile/profile'; export {Type} from './type'; diff --git a/modules/@angular/core/src/i18n/tokens.ts b/modules/@angular/core/src/i18n/tokens.ts index e6735c03cb..3733ae5908 100644 --- a/modules/@angular/core/src/i18n/tokens.ts +++ b/modules/@angular/core/src/i18n/tokens.ts @@ -22,3 +22,17 @@ export const TRANSLATIONS = new InjectionToken('Translations'); * @experimental i18n support is experimental. */ export const TRANSLATIONS_FORMAT = new InjectionToken('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, +} diff --git a/tools/public_api_guard/core/index.d.ts b/tools/public_api_guard/core/index.d.ts index 4199943324..15e842cb61 100644 --- a/tools/public_api_guard/core/index.d.ts +++ b/tools/public_api_guard/core/index.d.ts @@ -617,6 +617,16 @@ export declare class KeyValueDiffers { /** @experimental */ export declare const LOCALE_ID: InjectionToken; +/** @experimental */ +export declare const MISSING_TRANSLATION_STRATEGY: OpaqueToken; + +/** @experimental */ +export declare enum MissingTranslationStrategy { + Error = 0, + Warning = 1, + Ignore = 2, +} + /** @experimental */ export declare class ModuleWithComponentFactories { componentFactories: ComponentFactory[];