From 1ed2ce1a0e6096d6931853ea7df0df41397cb780 Mon Sep 17 00:00:00 2001 From: Olivier Combe Date: Fri, 24 Feb 2017 17:08:54 +0100 Subject: [PATCH] feat(compiler): add source files to xmb/xliff translations Fixes #14190 --- .gitignore | 1 + .../integrationtest/test/i18n_spec.ts | 18 +++- .../@angular/compiler-cli/src/extractor.ts | 3 +- .../@angular/compiler/src/i18n/i18n_ast.ts | 21 ++++- .../compiler/src/i18n/message_bundle.ts | 15 +++- .../compiler/src/i18n/serializers/xliff.ts | 17 +++- .../compiler/src/i18n/serializers/xmb.ts | 11 ++- .../compiler/src/i18n/serializers/xtb.ts | 3 +- .../compiler/test/i18n/digest_spec.ts | 1 + .../compiler/test/i18n/integration_spec.ts | 82 +++++++++---------- .../compiler/test/i18n/message_bundle_spec.ts | 11 ++- .../test/i18n/serializers/xliff_spec.ts | 54 +++++++++++- .../test/i18n/serializers/xmb_spec.ts | 28 ++++--- .../test/i18n/translation_bundle_spec.ts | 5 +- 14 files changed, 198 insertions(+), 72 deletions(-) diff --git a/.gitignore b/.gitignore index ec7639029dde6d..27af014bf83675 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /dist/ node_modules bower_components +e2e_test.* # Include when developing application packages. pubspec.lock diff --git a/modules/@angular/compiler-cli/integrationtest/test/i18n_spec.ts b/modules/@angular/compiler-cli/integrationtest/test/i18n_spec.ts index 019b354ce9c03a..c424459318f0a7 100644 --- a/modules/@angular/compiler-cli/integrationtest/test/i18n_spec.ts +++ b/modules/@angular/compiler-cli/integrationtest/test/i18n_spec.ts @@ -34,9 +34,9 @@ const EXPECTED_XMB = ` ]> - translate me - Welcome - other-3rdP-component + /src/basic.ts:0:0translate me + /src/basic.ts:4:4Welcome + /node_modules/third_party/other_comp.d.ts:0:0other-3rdP-component `; @@ -47,16 +47,28 @@ const EXPECTED_XLIFF = ` translate me + + /src/basic.ts + 0 + desc meaning Welcome + + /src/basic.ts + 4 + other-3rdP-component + + /node_modules/third_party/other_comp.d.ts + 0 + diff --git a/modules/@angular/compiler-cli/src/extractor.ts b/modules/@angular/compiler-cli/src/extractor.ts index 1ff09b6416c435..0d77319b3d95e7 100644 --- a/modules/@angular/compiler-cli/src/extractor.ts +++ b/modules/@angular/compiler-cli/src/extractor.ts @@ -59,8 +59,7 @@ export class Extractor { default: serializer = new compiler.Xliff(); } - - return bundle.write(serializer); + return bundle.write(serializer, this.options.basePath); } getExtension(formatName: string): string { diff --git a/modules/@angular/compiler/src/i18n/i18n_ast.ts b/modules/@angular/compiler/src/i18n/i18n_ast.ts index 1e94ea416f7d55..662a7f6890f3fb 100644 --- a/modules/@angular/compiler/src/i18n/i18n_ast.ts +++ b/modules/@angular/compiler/src/i18n/i18n_ast.ts @@ -9,6 +9,8 @@ import {ParseSourceSpan} from '../parse_util'; export class Message { + sources: MessageSource[] = []; + /** * @param nodes message AST * @param placeholders maps placeholder names to static content @@ -20,7 +22,22 @@ export class Message { constructor( public nodes: Node[], public placeholders: {[phName: string]: string}, public placeholderToMessage: {[phName: string]: Message}, public meaning: string, - public description: string, public id: string) {} + public description: string, public id: string) { + if (nodes.length) { + this.sources.push({ + filePath: nodes[0].sourceSpan.start.file.url, + startLine: nodes[0].sourceSpan.start.line, + endLine: nodes[nodes.length - 1].sourceSpan.end.line + }); + } + } +} + +export interface MessageSource { + filePath: string; + // startLine / endline are index-0 based (first line has a value of 0, not 1) + startLine: number; + endLine: number; } export interface Node { @@ -131,4 +148,4 @@ export class RecurseVisitor implements Visitor { visitPlaceholder(ph: Placeholder, context?: any): any{}; visitIcuPlaceholder(ph: IcuPlaceholder, context?: any): any{}; -} \ No newline at end of file +} diff --git a/modules/@angular/compiler/src/i18n/message_bundle.ts b/modules/@angular/compiler/src/i18n/message_bundle.ts index 7b42b4fa213a4f..4ab396c9621150 100644 --- a/modules/@angular/compiler/src/i18n/message_bundle.ts +++ b/modules/@angular/compiler/src/i18n/message_bundle.ts @@ -47,7 +47,7 @@ export class MessageBundle { // The public (serialized) format might be different, see the `write` method. getMessages(): i18n.Message[] { return this._messages; } - write(serializer: Serializer): string { + write(serializer: Serializer, sourcesFilter: string = ''): string { const messages: {[id: string]: i18n.Message} = {}; const mapperVisitor = new MapPlaceholderNames(); @@ -56,6 +56,8 @@ export class MessageBundle { const id = serializer.digest(message); if (!messages.hasOwnProperty(id)) { messages[id] = message; + } else { + messages[id].sources.push(...message.sources); } }); @@ -64,11 +66,20 @@ export class MessageBundle { const mapper = serializer.createNameMapper(messages[id]); const src = messages[id]; const nodes = mapper ? mapperVisitor.convert(src.nodes, mapper) : src.nodes; - return new i18n.Message(nodes, {}, {}, src.meaning, src.description, id); + let transformedMessage = new i18n.Message(nodes, {}, {}, src.meaning, src.description, id); + transformedMessage.sources = this.filterMessageSourcePaths(src.sources, sourcesFilter); + return transformedMessage; }); return serializer.write(msgList, this._locale); } + + // filter the message source paths relative to another path + filterMessageSourcePaths(sources: i18n.MessageSource[], filter: string) { + sources.forEach( + (source: i18n.MessageSource) => { source.filePath = source.filePath.replace(filter, ''); }); + return sources; + } } // Transform an i18n AST by renaming the placeholder nodes with the given mapper diff --git a/modules/@angular/compiler/src/i18n/serializers/xliff.ts b/modules/@angular/compiler/src/i18n/serializers/xliff.ts index dd9549409c069b..bcb108b302ca2d 100644 --- a/modules/@angular/compiler/src/i18n/serializers/xliff.ts +++ b/modules/@angular/compiler/src/i18n/serializers/xliff.ts @@ -25,6 +25,8 @@ const _FILE_TAG = 'file'; const _SOURCE_TAG = 'source'; const _TARGET_TAG = 'target'; const _UNIT_TAG = 'trans-unit'; +const _CONTEXT_GROUP_TAG = 'context-group'; +const _CONTEXT_TAG = 'context'; // http://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html // http://docs.oasis-open.org/xliff/v1.2/xliff-profile-html/xliff-profile-html-1.2.html @@ -34,10 +36,23 @@ export class Xliff extends Serializer { const transUnits: xml.Node[] = []; messages.forEach(message => { + let contextTags: xml.Node[] = []; + message.sources.forEach((source: i18n.MessageSource) => { + contextTags.push( + new xml.CR(10), + new xml.Tag( + _CONTEXT_TAG, {'context-type': 'sourcefile'}, [new xml.Text(source.filePath)]), + new xml.CR(10), new xml.Tag( + _CONTEXT_TAG, {'context-type': 'linenumber'}, + [new xml.Text(`${source.startLine}`)]), + new xml.CR(8)); + }); + const transUnit = new xml.Tag(_UNIT_TAG, {id: message.id, datatype: 'html'}); transUnit.children.push( new xml.CR(8), new xml.Tag(_SOURCE_TAG, {}, visitor.serialize(message.nodes)), - new xml.CR(8), new xml.Tag(_TARGET_TAG)); + new xml.CR(8), new xml.Tag(_TARGET_TAG), new xml.CR(8), + new xml.Tag(_CONTEXT_GROUP_TAG, {purpose: 'location'}, contextTags)); if (message.description) { transUnit.children.push( diff --git a/modules/@angular/compiler/src/i18n/serializers/xmb.ts b/modules/@angular/compiler/src/i18n/serializers/xmb.ts index 9110b27221dc94..64e7d0803ba4a6 100644 --- a/modules/@angular/compiler/src/i18n/serializers/xmb.ts +++ b/modules/@angular/compiler/src/i18n/serializers/xmb.ts @@ -16,6 +16,7 @@ const _MESSAGES_TAG = 'messagebundle'; const _MESSAGE_TAG = 'msg'; const _PLACEHOLDER_TAG = 'ph'; const _EXEMPLE_TAG = 'ex'; +const _SOURCE_TAG = 'source'; const _DOCTYPE = ` @@ -54,8 +55,16 @@ export class Xmb extends Serializer { attrs['meaning'] = message.meaning; } + let sourceTags: xml.Tag[] = []; + message.sources.forEach((source: i18n.MessageSource) => { + sourceTags.push(new xml.Tag( + _SOURCE_TAG, {}, + [new xml.Text(`${source.filePath}:${source.startLine}:${source.endLine}`)])); + }); + rootNode.children.push( - new xml.CR(2), new xml.Tag(_MESSAGE_TAG, attrs, visitor.serialize(message.nodes))); + new xml.CR(2), + new xml.Tag(_MESSAGE_TAG, attrs, [...sourceTags, ...visitor.serialize(message.nodes)])); }); rootNode.children.push(new xml.CR()); diff --git a/modules/@angular/compiler/src/i18n/serializers/xtb.ts b/modules/@angular/compiler/src/i18n/serializers/xtb.ts index d8848fa970bb6d..c027539e8ad6a5 100644 --- a/modules/@angular/compiler/src/i18n/serializers/xtb.ts +++ b/modules/@angular/compiler/src/i18n/serializers/xtb.ts @@ -17,6 +17,7 @@ import {digest, toPublicName} from './xmb'; const _TRANSLATIONS_TAG = 'translationbundle'; const _TRANSLATION_TAG = 'translation'; const _PLACEHOLDER_TAG = 'ph'; +const _SOURCE_TAG = 'source'; export class Xtb extends Serializer { write(messages: i18n.Message[], locale: string|null): string { throw new Error('Unsupported'); } @@ -195,7 +196,7 @@ class XmlToI18n implements ml.Visitor { } this._addError(el, `<${_PLACEHOLDER_TAG}> misses the "name" attribute`); - } else { + } else if (el.name !== _SOURCE_TAG) { this._addError(el, `Unexpected tag`); } } diff --git a/modules/@angular/compiler/test/i18n/digest_spec.ts b/modules/@angular/compiler/test/i18n/digest_spec.ts index 7297f6cc76130d..a2f857087b1214 100644 --- a/modules/@angular/compiler/test/i18n/digest_spec.ts +++ b/modules/@angular/compiler/test/i18n/digest_spec.ts @@ -19,6 +19,7 @@ export function main(): void { placeholderToMessage: {}, meaning: '', description: '', + sources: [] })).toEqual('i'); }); }); diff --git a/modules/@angular/compiler/test/i18n/integration_spec.ts b/modules/@angular/compiler/test/i18n/integration_spec.ts index b7a36dd3714831..621b7f1935dec8 100644 --- a/modules/@angular/compiler/test/i18n/integration_spec.ts +++ b/modules/@angular/compiler/test/i18n/integration_spec.ts @@ -39,7 +39,7 @@ export function main() { it('should extract from templates', () => { const catalog = new MessageBundle(new HtmlParser, [], {}); const serializer = new Xmb(); - catalog.updateFromTemplate(HTML, '', DEFAULT_INTERPOLATION_CONFIG); + catalog.updateFromTemplate(HTML, 'file.ts', DEFAULT_INTERPOLATION_CONFIG); expect(catalog.write(serializer)).toContain(XMB); }); @@ -141,59 +141,59 @@ class FrLocalization extends NgLocalization { const XTB = ` - attributs i18n sur les balises - imbriqué - imbriqué - avec des espaces réservés - sur des balises non traductibles - sur des balises traductibles - {VAR_PLURAL, plural, =0 {zero} =1 {un} =2 {deux} other {beaucoup}} - - {VAR_SELECT, select, m {homme} f {femme}} - - sexe = - - dans une section traductible - + file.ts:2:2attributs i18n sur les balises + file.ts:4:4imbriqué + file.ts:6:6imbriqué + file.ts:8:8file.ts:9:9avec des espaces réservés + file.ts:12:12sur des balises non traductibles + file.ts:13:13sur des balises traductibles + file.ts:18:18file.ts:35:35{VAR_PLURAL, plural, =0 {zero} =1 {un} =2 {deux} other {beaucoup}} + file.ts:20:22file.ts:23:25 + file.ts:21:21file.ts:24:24{VAR_SELECT, select, m {homme} f {femme}} + file.ts:27:27 + file.ts:28:28sexe = + file.ts:29:29 + file.ts:34:34file.ts:52:52dans une section traductible + file.ts:32:36 Balises dans les commentaires html - ca devrait marcher - avec un ID explicite - {VAR_PLURAL, plural, =0 {zero} =1 {un} =2 {deux} other {file.ts:38:38ca devrait marcher + file.ts:40:40avec un ID explicite + file.ts:41:41{VAR_PLURAL, plural, =0 {zero} =1 {un} =2 {deux} other {<b>beaucoup</b>} } - {VAR_PLURAL, plural, =0 {Pas de réponse} =1 {une réponse} other {INTERPOLATION réponse} } - FOO<a>BAR</a> - MAP_NAME + file.ts:44:50{VAR_PLURAL, plural, =0 {Pas de réponse} =1 {une réponse} other {INTERPOLATION réponse} } + file.ts:52:52FOO<a>BAR</a> + file.ts:54:54MAP_NAME `; -const XMB = ` i18n attribute on tags - nested - nested - <i>with placeholders</i> - on not translatable node - on translatable node - {VAR_PLURAL, plural, =0 {zero} =1 {one} =2 {two} other {<b>many</b>} } - +const XMB = ` file.ts:2:2i18n attribute on tags + file.ts:4:4nested + file.ts:6:6nested + file.ts:8:8file.ts:9:9<i>with placeholders</i> + file.ts:12:12on not translatable node + file.ts:13:13on translatable node + file.ts:18:18file.ts:35:35{VAR_PLURAL, plural, =0 {zero} =1 {one} =2 {two} other {<b>many</b>} } + file.ts:20:22file.ts:23:25 ICU - {VAR_SELECT, select, m {male} f {female} } - INTERPOLATION - sex = INTERPOLATION - CUSTOM_NAME - in a translatable section - + file.ts:21:21file.ts:24:24{VAR_SELECT, select, m {male} f {female} } + file.ts:27:27INTERPOLATION + file.ts:28:28sex = INTERPOLATION + file.ts:29:29CUSTOM_NAME + file.ts:34:34file.ts:52:52in a translatable section + file.ts:32:36 <h1>Markers in html comments</h1> <div></div> <div>ICU</div> - it <b>should</b> work - with an explicit ID - {VAR_PLURAL, plural, =0 {zero} =1 {one} =2 {two} other {<b>many</b>} } - {VAR_PLURAL, plural, =0 {Found no results} =1 {Found one result} other {Found INTERPOLATION results} } - foo<a>bar</a> - MAP_NAME`; + file.ts:38:38it <b>should</b> work + file.ts:40:40with an explicit ID + file.ts:41:41{VAR_PLURAL, plural, =0 {zero} =1 {one} =2 {two} other {<b>many</b>} } + file.ts:44:50{VAR_PLURAL, plural, =0 {Found no results} =1 {Found one result} other {Found INTERPOLATION results} } + file.ts:52:52foo<a>bar</a> + file.ts:54:54MAP_NAME`; const HTML = `
diff --git a/modules/@angular/compiler/test/i18n/message_bundle_spec.ts b/modules/@angular/compiler/test/i18n/message_bundle_spec.ts index cae03595bdaa88..5daf3ce824df49 100644 --- a/modules/@angular/compiler/test/i18n/message_bundle_spec.ts +++ b/modules/@angular/compiler/test/i18n/message_bundle_spec.ts @@ -24,7 +24,7 @@ export function main(): void { messages.updateFromTemplate( '

Translate Me

', 'url', DEFAULT_INTERPOLATION_CONFIG); expect(humanizeMessages(messages)).toEqual([ - 'Translate Me (m|d)', + 'Translate Me (m|d|1)', ]); }); @@ -33,8 +33,8 @@ export function main(): void { '

Translate Me

Translate Me

Translate Me

', 'url', DEFAULT_INTERPOLATION_CONFIG); expect(humanizeMessages(messages)).toEqual([ - 'Translate Me (m|d)', - 'Translate Me (|)', + 'Translate Me (m|d|1)', + 'Translate Me (||2)', ]); }); }); @@ -43,7 +43,10 @@ export function main(): void { class _TestSerializer extends Serializer { write(messages: i18n.Message[]): string { - return messages.map(msg => `${serializeNodes(msg.nodes)} (${msg.meaning}|${msg.description})`) + return messages + .map( + msg => + `${serializeNodes(msg.nodes)} (${msg.meaning}|${msg.description}|${msg.sources.length})`) .join('//'); } diff --git a/modules/@angular/compiler/test/i18n/serializers/xliff_spec.ts b/modules/@angular/compiler/test/i18n/serializers/xliff_spec.ts index e8a81b66117ac3..48690e52e8d9a0 100644 --- a/modules/@angular/compiler/test/i18n/serializers/xliff_spec.ts +++ b/modules/@angular/compiler/test/i18n/serializers/xliff_spec.ts @@ -30,30 +30,54 @@ const WRITE_XLIFF = ` translatable attribute + + file.ts + 1 + translatable element with placeholders + + file.ts + 2 + foo + + file.ts + 3 + d m foo + + file.ts + 4 + d m foo + + file.ts + 5 + + + file.ts + 6 + ph names @@ -68,35 +92,63 @@ const LOAD_XLIFF = ` translatable attribute etubirtta elbatalsnart + + file.ts + 1 + translatable element with placeholders footnemele elbatalsnart sredlohecalp htiw + + file.ts + 2 + foo oof + + file.ts + 3 + d m foo toto + + file.ts + 4 + d m foo tata + + file.ts + 5 + + + file.ts + 6 + ph names + + file.ts + 6 + ph names @@ -109,7 +161,7 @@ export function main(): void { function toXliff(html: string, locale: string | null = null): string { const catalog = new MessageBundle(new HtmlParser, [], {}, locale); - catalog.updateFromTemplate(html, '', DEFAULT_INTERPOLATION_CONFIG); + catalog.updateFromTemplate(html, 'file.ts', DEFAULT_INTERPOLATION_CONFIG); return catalog.write(serializer); } diff --git a/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts b/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts index cb8c535fd52c6d..c85dbf32cf5389 100644 --- a/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts +++ b/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts @@ -21,7 +21,9 @@ export function main(): void {

foo

foo

{ count, plural, =0 { { sex, select, other {

deeply nested

}} }}

-

{ count, plural, =0 { { sex, select, other {

deeply nested

}} }}

`; +

{ count, plural, =0 { { sex, select, other {

deeply nested

}} }}

+

multi +lines

`; const XMB = ` ]> - translatable element <b>with placeholders</b> INTERPOLATION - {VAR_PLURAL, plural, =0 {<p>test</p>} } - foo - foo - foo - {VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {<p>deeply nested</p>} } } } - {VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {<p>deeply nested</p>} } } } + file.ts:2:2translatable element <b>with placeholders</b> INTERPOLATION + file.ts:3:3{VAR_PLURAL, plural, =0 {<p>test</p>} } + file.ts:4:4foo + file.ts:5:5foo + file.ts:6:6foo + file.ts:7:7{VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {<p>deeply nested</p>} } } } + file.ts:8:8{VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {<p>deeply nested</p>} } } } + file.ts:9:10multi +lines `; it('should write a valid xmb file', () => { - expect(toXmb(HTML)).toEqual(XMB); + expect(toXmb(HTML, 'file.ts')).toEqual(XMB); // the locale is not specified in the xmb file - expect(toXmb(HTML, 'fr')).toEqual(XMB); + expect(toXmb(HTML, 'file.ts', 'fr')).toEqual(XMB); }); it('should throw when trying to load an xmb file', () => { @@ -71,11 +75,11 @@ export function main(): void { }); } -function toXmb(html: string, locale: string | null = null): string { +function toXmb(html: string, url: string, locale: string | null = null): string { const catalog = new MessageBundle(new HtmlParser, [], {}, locale); const serializer = new Xmb(); - catalog.updateFromTemplate(html, '', DEFAULT_INTERPOLATION_CONFIG); + catalog.updateFromTemplate(html, url, DEFAULT_INTERPOLATION_CONFIG); return catalog.write(serializer); } diff --git a/modules/@angular/compiler/test/i18n/translation_bundle_spec.ts b/modules/@angular/compiler/test/i18n/translation_bundle_spec.ts index 2d6dccd973c2ce..81a1648d0a08e7 100644 --- a/modules/@angular/compiler/test/i18n/translation_bundle_spec.ts +++ b/modules/@angular/compiler/test/i18n/translation_bundle_spec.ts @@ -17,8 +17,9 @@ import {_extractMessages} from './i18n_parser_spec'; export function main(): void { describe('TranslationBundle', () => { const file = new ParseSourceFile('content', 'url'); - const location = new ParseLocation(file, 0, 0, 0); - const span = new ParseSourceSpan(location, null); + const startLocation = new ParseLocation(file, 0, 0, 0); + const endLocation = new ParseLocation(file, 0, 0, 7); + const span = new ParseSourceSpan(startLocation, endLocation); const srcNode = new i18n.Text('src', span); it('should translate a plain message', () => {