From 350ac1155486147562bc5156d7b6c0598052dcc3 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Sun, 1 Mar 2020 16:39:15 +0000 Subject: [PATCH] fix(localize): improve matching and parsing of XLIFF 1.2 translation files (#35793) Previously, the `Xliff1TranslationParser` only matched files that had a narrow choice of extensions (e.g. `xlf`) and also relied upon a regular expression match of an optional XML namespace directive. This commit relaxes the requirement on both of these and, instead, relies upon parsing the file into XML and identifying an element of the form `` which is the minimal requirement for such files. PR Close #35793 --- .../translation_files/translation_loader.ts | 2 +- .../translation_parsers/translation_utils.ts | 107 ++- .../xliff1_translation_parser.ts | 154 ++-- .../xliff1_translation_parser_spec.ts | 759 ++++++++++++++++-- 4 files changed, 902 insertions(+), 120 deletions(-) diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_loader.ts b/packages/localize/src/tools/src/translate/translation_files/translation_loader.ts index 120f43f910..8c37a985fa 100644 --- a/packages/localize/src/tools/src/translate/translation_files/translation_loader.ts +++ b/packages/localize/src/tools/src/translate/translation_files/translation_loader.ts @@ -42,7 +42,7 @@ export class TranslationLoader { } const {locale: parsedLocale, translations, diagnostics} = - translationParser.parse(filePath, fileContents); + translationParser.parse(filePath, fileContents, result); if (diagnostics.hasErrors) { throw new Error(diagnostics.formatDiagnostics( `The translation file "${filePath}" could not be parsed.`)); diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_parsers/translation_utils.ts b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/translation_utils.ts index 92111e458b..731bfa30b1 100644 --- a/packages/localize/src/tools/src/translate/translation_files/translation_parsers/translation_utils.ts +++ b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/translation_utils.ts @@ -5,7 +5,8 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {Element, LexerRange, Node, XmlParser} from '@angular/compiler'; +import {Element, LexerRange, Node, ParseError, ParseErrorLevel, ParseSourceSpan, XmlParser} from '@angular/compiler'; +import {Diagnostics} from '../../../diagnostics'; import {TranslationParseError} from './translation_parse_error'; export function getAttrOrThrow(element: Element, attrName: string): string { @@ -22,6 +23,14 @@ export function getAttribute(element: Element, attrName: string): string|undefin return attr !== undefined ? attr.value : undefined; } +/** + * Parse the "contents" of an XML element. + * + * This would be equivalent to parsing the `innerHTML` string of an HTML document. + * + * @param element The element whose inner range we want to parse. + * @returns a collection of XML `Node` objects that were parsed from the element's contents. + */ export function parseInnerRange(element: Element): Node[] { const xmlParser = new XmlParser(); const xml = xmlParser.parse( @@ -33,6 +42,10 @@ export function parseInnerRange(element: Element): Node[] { return xml.rootNodes; } +/** + * Compute a `LexerRange` that contains all the children of the given `element`. + * @param element The element whose inner range we want to compute. + */ function getInnerRange(element: Element): LexerRange { const start = element.startSourceSpan !.end; const end = element.endSourceSpan !.start; @@ -42,4 +55,94 @@ function getInnerRange(element: Element): LexerRange { startCol: start.col, endPos: end.offset, }; -} \ No newline at end of file +} + +/** + * This "hint" object is used to pass information from `canParse()` to `parse()` for + * `TranslationParser`s that expect XML contents. + * + * This saves the `parse()` method from having to re-parse the XML. + */ +export interface XmlTranslationParserHint { + element: Element; + errors: ParseError[]; +} + +/** + * Can this XML be parsed for translations, given the expected `rootNodeName` and expected root node + * `attributes` that should appear in the file. + * + * @param filePath The path to the file being checked. + * @param contents The contents of the file being checked. + * @param rootNodeName The expected name of an XML root node that should exist. + * @param attributes The attributes (and their values) that should appear on the root node. + * @returns The `XmlTranslationParserHint` object for use by `TranslationParser.parse()` if the XML + * document has the expected format. + */ +export function canParseXml( + filePath: string, contents: string, rootNodeName: string, + attributes: Record): XmlTranslationParserHint|false { + const xmlParser = new XmlParser(); + const xml = xmlParser.parse(contents, filePath); + + if (xml.rootNodes.length === 0 || + xml.errors.some(error => error.level === ParseErrorLevel.ERROR)) { + return false; + } + + const rootElements = xml.rootNodes.filter(isNamedElement(rootNodeName)); + const rootElement = rootElements[0]; + if (rootElement === undefined) { + return false; + } + + for (const attrKey of Object.keys(attributes)) { + const attr = rootElement.attrs.find(attr => attr.name === attrKey); + if (attr === undefined || attr.value !== attributes[attrKey]) { + return false; + } + } + + if (rootElements.length > 1) { + xml.errors.push(new ParseError( + xml.rootNodes[1].sourceSpan, + 'Unexpected root node. XLIFF 1.2 files should only have a single root node.', + ParseErrorLevel.WARNING)); + } + + return {element: rootElement, errors: xml.errors}; +} + +/** + * Create a predicate, which can be used by things like `Array.filter()`, that will match a named + * XML Element from a collection of XML Nodes. + * + * @param name The expected name of the element to match. + */ +export function isNamedElement(name: string): (node: Node) => node is Element { + function predicate(node: Node): node is Element { + return node instanceof Element && node.name === name; + } + return predicate; +} + +/** + * Add an XML parser related message to the given `diagnostics` object. + */ +export function addParseDiagnostic( + diagnostics: Diagnostics, sourceSpan: ParseSourceSpan, message: string, + level: ParseErrorLevel): void { + addParseError(diagnostics, new ParseError(sourceSpan, message, level)); +} + +/** + * Copy the formatted error message from the given `parseError` object into the given `diagnostics` + * object. + */ +export function addParseError(diagnostics: Diagnostics, parseError: ParseError): void { + if (parseError.level === ParseErrorLevel.ERROR) { + diagnostics.error(parseError.toString()); + } else { + diagnostics.warn(parseError.toString()); + } +} diff --git a/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff1_translation_parser.ts b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff1_translation_parser.ts index 3efa5ea209..0cbe064a24 100644 --- a/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff1_translation_parser.ts +++ b/packages/localize/src/tools/src/translate/translation_files/translation_parsers/xliff1_translation_parser.ts @@ -5,20 +5,16 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {Element, Node, XmlParser, visitAll} from '@angular/compiler'; -import {ɵMessageId, ɵParsedTranslation} from '@angular/localize'; -import {extname} from 'path'; +import {Element, ParseErrorLevel, visitAll} from '@angular/compiler'; +import {ɵParsedTranslation} from '@angular/localize'; import {Diagnostics} from '../../../diagnostics'; import {BaseVisitor} from '../base_visitor'; import {MessageSerializer} from '../message_serialization/message_serializer'; import {TargetMessageRenderer} from '../message_serialization/target_message_renderer'; -import {TranslationParseError} from './translation_parse_error'; import {ParsedTranslationBundle, TranslationParser} from './translation_parser'; -import {getAttrOrThrow, getAttribute, parseInnerRange} from './translation_utils'; - -const XLIFF_1_2_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:1.2"/; +import {XmlTranslationParserHint, addParseDiagnostic, addParseError, canParseXml, getAttribute, isNamedElement, parseInnerRange} from './translation_utils'; /** * A translation parser that can load XLIFF 1.2 files. @@ -27,68 +23,120 @@ const XLIFF_1_2_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:1.2"/; * http://docs.oasis-open.org/xliff/v1.2/xliff-profile-html/xliff-profile-html-1.2.html * */ -export class Xliff1TranslationParser implements TranslationParser { - canParse(filePath: string, contents: string): boolean { - return (extname(filePath) === '.xlf') && XLIFF_1_2_NS_REGEX.test(contents); +export class Xliff1TranslationParser implements TranslationParser { + canParse(filePath: string, contents: string): XmlTranslationParserHint|false { + return canParseXml(filePath, contents, 'xliff', {version: '1.2'}); } - parse(filePath: string, contents: string): ParsedTranslationBundle { - const xmlParser = new XmlParser(); - const xml = xmlParser.parse(contents, filePath); - const bundle = XliffFileElementVisitor.extractBundle(xml.rootNodes); - if (bundle === undefined) { + parse(filePath: string, contents: string, hint?: XmlTranslationParserHint): + ParsedTranslationBundle { + if (hint) { + return this.extractBundle(hint); + } else { + return this.extractBundleDeprecated(filePath, contents); + } + } + + private extractBundle({element, errors}: XmlTranslationParserHint): ParsedTranslationBundle { + const diagnostics = new Diagnostics(); + errors.forEach(e => addParseError(diagnostics, e)); + + if (element.children.length === 0) { + addParseDiagnostic( + diagnostics, element.sourceSpan, 'Missing expected element', + ParseErrorLevel.WARNING); + return {locale: undefined, translations: {}, diagnostics}; + } + + const files = element.children.filter(isNamedElement('file')); + if (files.length === 0) { + addParseDiagnostic( + diagnostics, element.sourceSpan, 'No elements found in ', + ParseErrorLevel.WARNING); + } else if (files.length > 1) { + addParseDiagnostic( + diagnostics, files[1].sourceSpan, 'More than one element found in ', + ParseErrorLevel.WARNING); + } + + const bundle = { + locale: getAttribute(files[0], 'target-language'), + translations: {}, diagnostics, + }; + const translationVisitor = new XliffTranslationVisitor(); + visitAll(translationVisitor, files[0].children, bundle); + + return bundle; + } + + private extractBundleDeprecated(filePath: string, contents: string) { + const hint = this.canParse(filePath, contents); + if (!hint) { throw new Error(`Unable to parse "${filePath}" as XLIFF 1.2 format.`); } + const bundle = this.extractBundle(hint); + if (bundle.diagnostics.hasErrors) { + const message = + bundle.diagnostics.formatDiagnostics(`Failed to parse "${filePath}" as XLIFF 1.2 format`); + throw new Error(message); + } return bundle; } } class XliffFileElementVisitor extends BaseVisitor { - private bundle: ParsedTranslationBundle|undefined; - - static extractBundle(xliff: Node[]): ParsedTranslationBundle|undefined { - const visitor = new this(); - visitAll(visitor, xliff); - return visitor.bundle; - } - - visitElement(element: Element): any { - if (element.name === 'file') { - this.bundle = { - locale: getAttribute(element, 'target-language'), - translations: XliffTranslationVisitor.extractTranslations(element), - diagnostics: new Diagnostics(), - }; - } else { - return visitAll(this, element.children); + visitElement(fileElement: Element): any { + if (fileElement.name === 'file') { + return {fileElement, locale: getAttribute(fileElement, 'target-language')}; } } } class XliffTranslationVisitor extends BaseVisitor { - private translations: Record<ɵMessageId, ɵParsedTranslation> = {}; - - static extractTranslations(file: Element): Record { - const visitor = new this(); - visitAll(visitor, file.children); - return visitor.translations; + visitElement(element: Element, bundle: ParsedTranslationBundle): void { + if (element.name === 'trans-unit') { + this.visitTransUnitElement(element, bundle); + } else { + visitAll(this, element.children, bundle); + } } - visitElement(element: Element): any { - if (element.name === 'trans-unit') { - const id = getAttrOrThrow(element, 'id'); - if (this.translations[id] !== undefined) { - throw new TranslationParseError( - element.sourceSpan, `Duplicated translations for message "${id}"`); - } + private visitTransUnitElement(element: Element, bundle: ParsedTranslationBundle): void { + // Error if no `id` attribute + const id = getAttribute(element, 'id'); + if (id === undefined) { + addParseDiagnostic( + bundle.diagnostics, element.sourceSpan, + `Missing required "id" attribute on element.`, ParseErrorLevel.ERROR); + return; + } - const targetMessage = element.children.find(isTargetElement); - if (targetMessage === undefined) { - throw new TranslationParseError(element.sourceSpan, 'Missing required element'); + // Error if there is already a translation with the same id + if (bundle.translations[id] !== undefined) { + addParseDiagnostic( + bundle.diagnostics, element.sourceSpan, `Duplicated translations for message "${id}"`, + ParseErrorLevel.ERROR); + return; + } + + // Error if there is no `` child element + const targetMessage = element.children.find(isNamedElement('target')); + if (targetMessage === undefined) { + addParseDiagnostic( + bundle.diagnostics, element.sourceSpan, 'Missing required element', + ParseErrorLevel.ERROR); + return; + } + + try { + bundle.translations[id] = serializeTargetMessage(targetMessage); + } catch (e) { + // Capture any errors from serialize the target message + if (e.span && e.msg && e.level) { + addParseDiagnostic(bundle.diagnostics, e.span, e.msg, e.level); + } else { + throw e; } - this.translations[id] = serializeTargetMessage(targetMessage); - } else { - return visitAll(this, element.children); } } } @@ -100,7 +148,3 @@ function serializeTargetMessage(source: Element): ɵParsedTranslation { }); return serializer.serialize(parseInnerRange(source)); } - -function isTargetElement(node: Node): node is Element { - return node instanceof Element && node.name === 'target'; -} diff --git a/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff1_translation_parser_spec.ts b/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff1_translation_parser_spec.ts index 594d2fabc7..29f28a97e5 100644 --- a/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff1_translation_parser_spec.ts +++ b/packages/localize/src/tools/test/translate/translation_files/translation_parsers/xliff1_translation_parser_spec.ts @@ -10,23 +10,27 @@ import {Xliff1TranslationParser} from '../../../../src/translate/translation_fil describe('Xliff1TranslationParser', () => { describe('canParse()', () => { - it('should return true if the file extension is `.xlf` and it contains the XLIFF namespace', + it('should return true only if the file contains an element with version="1.2" attribute', () => { const parser = new Xliff1TranslationParser(); expect(parser.canParse( '/some/file.xlf', '')) - .toBe(true); + .toBeTruthy(); expect(parser.canParse( '/some/file.json', '')) - .toBe(false); + .toBeTruthy(); + expect(parser.canParse('/some/file.xliff', '')).toBeTruthy(); + expect(parser.canParse('/some/file.json', '')).toBeTruthy(); + expect(parser.canParse('/some/file.xlf', '')).toBe(false); + expect(parser.canParse('/some/file.xlf', '')).toBe(false); expect(parser.canParse('/some/file.xlf', '')).toBe(false); expect(parser.canParse('/some/file.json', '')).toBe(false); }); }); - describe('parse()', () => { + describe('parse() [without hint]', () => { it('should extract the locale from the file contents', () => { const XLIFF = ` @@ -435,16 +439,17 @@ describe('Xliff1TranslationParser', () => { describe('[structure errors]', () => { it('should throw when a trans-unit has no translation', () => { - const XLIFF = ` - - - - - - - - -`; + const XLIFF = ` + + + + + + + + + + `; expect(() => { const parser = new Xliff1TranslationParser(); @@ -454,17 +459,18 @@ describe('Xliff1TranslationParser', () => { it('should throw when a trans-unit has no id attribute', () => { - const XLIFF = ` - - - - - - - - - -`; + const XLIFF = ` + + + + + + + + + + + `; expect(() => { const parser = new Xliff1TranslationParser(); @@ -473,21 +479,22 @@ describe('Xliff1TranslationParser', () => { }); it('should throw on duplicate trans-unit id', () => { - const XLIFF = ` - - - - - - - - - - - - - -`; + const XLIFF = ` + + + + + + + + + + + + + + + `; expect(() => { const parser = new Xliff1TranslationParser(); @@ -498,17 +505,18 @@ describe('Xliff1TranslationParser', () => { describe('[message errors]', () => { it('should throw on unknown message tags', () => { - const XLIFF = ` - - - - - - msg should contain only ph tags - - - -`; + const XLIFF = ` + + + + + + + msg should contain only ph tags + + + + `; expect(() => { const parser = new Xliff1TranslationParser(); @@ -517,17 +525,18 @@ describe('Xliff1TranslationParser', () => { }); it('should throw when a placeholder misses an id attribute', () => { - const XLIFF = ` - - - - - - - - - -`; + const XLIFF = ` + + + + + + + + + + + `; expect(() => { const parser = new Xliff1TranslationParser(); @@ -536,4 +545,630 @@ describe('Xliff1TranslationParser', () => { }); }); }); + + describe('parse() [with hint]', () => { + it('should extract the locale from the file contents', () => { + const XLIFF = ` + + + + + + `; + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + expect(result.locale).toEqual('fr'); + }); + + it('should return an undefined locale if there is no locale in the file', () => { + const XLIFF = ` + + + + + + `; + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + expect(result.locale).toBeUndefined(); + }); + + it('should extract basic messages', () => { + /** + * Source HTML: + * + * ``` + *
translatable attribute
+ * ``` + */ + const XLIFF = ` + + + + + translatable attribute + etubirtta elbatalsnart + + file.ts + 1 + + + + + `; + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + + expect(result.translations[ɵcomputeMsgId('translatable attribute')]) + .toEqual(ɵmakeParsedTranslation(['etubirtta elbatalsnart'])); + }); + + it('should extract translations with simple placeholders', () => { + /** + * Source HTML: + * + * ``` + *
translatable element with placeholders {{ interpolation}}
+ * ``` + */ + const XLIFF = ` + + + + + translatable element with placeholders + tnemele elbatalsnart sredlohecalp htiw + + file.ts + 2 + + + + + `; + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + + expect( + result.translations[ɵcomputeMsgId( + 'translatable element {$START_BOLD_TEXT}with placeholders{$LOSE_BOLD_TEXT} {$INTERPOLATION}')]) + .toEqual(ɵmakeParsedTranslation( + ['', ' tnemele elbatalsnart ', 'sredlohecalp htiw', ''], + ['INTERPOLATION', 'START_BOLD_TEXT', 'CLOSE_BOLD_TEXT'])); + }); + + it('should extract translations with placeholders containing hyphens', () => { + /** + * Source HTML: + * + * ``` + *
Welcome
+ * ``` + */ + const XLIFF = ` + + + + + Welcome + + src/app/app.component.html + 1 + + Translate + + + + `; + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + const id = + ɵcomputeMsgId('{$START_TAG_APP_MY_COMPONENT}{$CLOSE_TAG_APP_MY_COMPONENT} Welcome'); + expect(result.translations[id]).toEqual(ɵmakeParsedTranslation(['', '', ' Translate'], [ + 'START_TAG_APP_MY_COMPONENT', 'CLOSE_TAG_APP_MY_COMPONENT' + ])); + }); + + it('should extract translations with simple ICU expressions', () => { + /** + * Source HTML: + * + * ``` + *
{VAR_PLURAL, plural, =0 {

test

} }
+ * ``` + */ + const XLIFF = ` + + + + + {VAR_PLURAL, plural, =0 {test} } + {VAR_PLURAL, plural, =0 {TEST} } + + + + `; + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + + expect(result.translations[ɵcomputeMsgId( + '{VAR_PLURAL, plural, =0 {{START_PARAGRAPH}test{CLOSE_PARAGRAPH}}}')]) + .toEqual(ɵmakeParsedTranslation( + ['{VAR_PLURAL, plural, =0 {{START_PARAGRAPH}TEST{CLOSE_PARAGRAPH}}}'], [])); + }); + + it('should extract translations with duplicate source messages', () => { + /** + * Source HTML: + * + * ``` + *
foo
+ *
foo
+ *
foo
+ * ``` + */ + const XLIFF = ` + + + + + foo + oof + + file.ts + 3 + + d + m + + + foo + toto + + file.ts + 4 + + d + m + + + foo + tata + + file.ts + 5 + + + + + `; + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + + expect(result.translations[ɵcomputeMsgId('foo')]).toEqual(ɵmakeParsedTranslation(['oof'])); + expect(result.translations['i']).toEqual(ɵmakeParsedTranslation(['toto'])); + expect(result.translations['bar']).toEqual(ɵmakeParsedTranslation(['tata'])); + }); + + it('should extract translations with only placeholders, which are re-ordered', () => { + /** + * Source HTML: + * + * ``` + *

+ * ``` + */ + const XLIFF = ` + + + + + + + + + file.ts + 6 + + ph names + + + + `; + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + + expect(result.translations[ɵcomputeMsgId('{$LINE_BREAK}{$TAG_IMG}{$TAG_IMG_1}')]) + .toEqual( + ɵmakeParsedTranslation(['', '', '', ''], ['TAG_IMG_1', 'TAG_IMG', 'LINE_BREAK'])); + }); + + it('should extract translations with empty target', () => { + /** + * Source HTML: + * + * ``` + *
hello
+ * ``` + */ + const XLIFF = ` + + + + + hello + + + file.ts + 6 + + ph names + + + + `; + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + + expect(result.translations[ɵcomputeMsgId('hello {$START_TAG_SPAN}{$CLOSE_TAG_SPAN}')]) + .toEqual(ɵmakeParsedTranslation([''])); + }); + + it('should extract translations with deeply nested ICUs', () => { + /** + * Source HTML: + * + * ``` + * Test: { count, plural, =0 { { sex, select, other {

deeply nested

}} } =other {a lot}} + * ``` + * + * Note that the message gets split into two translation units: + * * The first one contains the outer message with an `ICU` placeholder + * * The second one is the ICU expansion itself + * + * Note that special markers `VAR_PLURAL` and `VAR_SELECT` are added, which are then replaced + * by IVY at runtime with the actual values being rendered by the ICU expansion. + */ + const XLIFF = ` + + + + + Test: + Le test: + + file.ts + 11 + + + + {VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {deeply nested}}} =other {a lot}} + {VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {profondément imbriqué}}} =other {beaucoup}} + + + + `; + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + + expect(result.translations[ɵcomputeMsgId('Test: {$ICU}')]) + .toEqual(ɵmakeParsedTranslation(['Le test: ', ''], ['ICU'])); + + expect( + result.translations[ɵcomputeMsgId( + '{VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {{START_PARAGRAPH}deeply nested{CLOSE_PARAGRAPH}}}} =other {beaucoup}}')]) + .toEqual(ɵmakeParsedTranslation([ + '{VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {{START_PARAGRAPH}profondément imbriqué{CLOSE_PARAGRAPH}}}} =other {beaucoup}}' + ])); + }); + + it('should extract translations containing multiple lines', () => { + /** + * Source HTML: + * + * ``` + *
multi + * lines
+ * ``` + */ + const XLIFF = ` + + + + + multi\nlines + multi\nlignes + + file.ts + 12 + + + + + `; + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + + expect(result.translations[ɵcomputeMsgId('multi\nlines')]) + .toEqual(ɵmakeParsedTranslation(['multi\nlignes'])); + }); + + it('should extract translations with elements', () => { + const XLIFF = ` + + + + + First sentence. + + Should not be parsed + + Translated first sentence. + + + First sentence. Second sentence. + + Should not be parsed + + Translated first sentence. + + + + `; + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + + expect(result.translations['mrk-test']) + .toEqual(ɵmakeParsedTranslation(['Translated first sentence.'])); + + expect(result.translations['mrk-test2']) + .toEqual(ɵmakeParsedTranslation(['Translated first sentence.'])); + }); + + it('should ignore alt-trans targets', () => { + const XLIFF = ` + + + + + Continue + Weiter + + src/app/auth/registration-form/registration-form.component.html + 69 + + + + Content + Content + + + + + `; + + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + expect(result.translations['registration.submit']) + .toEqual(ɵmakeParsedTranslation(['Weiter'])); + }); + + describe('[structure errors]', () => { + it('should provide a diagnostic error when a trans-unit has no translation', () => { + const XLIFF = ` + + + + + + + + + + `; + + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + expect(result.diagnostics.messages.length).toEqual(1); + expect(result.diagnostics.messages[0].message) + .toEqual( + `Missing required element ("ge="en" target-language="fr" datatype="plaintext" original="ng2.template">\n` + + ` \n` + + ` [ERROR ->]\n` + + ` \n` + + ` \n` + + `"): /some/file.xlf@5:10`); + }); + + + it('should provide a diagnostic error when a trans-unit has no id attribute', () => { + const XLIFF = ` + + + + + + + + + + + `; + + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + expect(result.diagnostics.messages.length).toEqual(1); + expect(result.diagnostics.messages[0].message) + .toEqual( + `Missing required "id" attribute on element. ("ge="en" target-language="fr" datatype="plaintext" original="ng2.template">\n` + + ` \n` + + ` [ERROR ->]\n` + + ` \n` + + ` \n` + + `"): /some/file.xlf@5:10`); + }); + + it('should provide a diagnostic error on duplicate trans-unit id', () => { + const XLIFF = ` + + + + + + + + + + + + + + + `; + + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + expect(result.diagnostics.messages.length).toEqual(1); + expect(result.diagnostics.messages[0].message) + .toEqual( + `Duplicated translations for message "deadbeef" ("\n` + + ` \n` + + ` \n` + + ` [ERROR ->]\n` + + ` \n` + + ` \n` + + `"): /some/file.xlf@9:10`); + }); + }); + + describe('[message errors]', () => { + it('should provide a diagnostic error on unknown message tags', () => { + const XLIFF = ` + + + + + + + msg should contain only ph tags + + + + `; + + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + expect(result.diagnostics.messages.length).toEqual(1); + expect(result.diagnostics.messages[0].message) + .toEqual( + `Invalid element found in message. ("\n` + + ` \n` + + ` \n` + + ` [ERROR ->]msg should contain only ph tags\n` + + ` \n` + + ` \n` + + `"): /some/file.xlf@7:20`); + }); + + it('should provide a diagnostic error when a placeholder misses an id attribute', () => { + const XLIFF = ` + + + + + + + + + + + `; + + const parser = new Xliff1TranslationParser(); + const hint = parser.canParse('/some/file.xlf', XLIFF); + if (!hint) { + return fail('expected XLIFF to be valid'); + } + const result = parser.parse('/some/file.xlf', XLIFF, hint); + expect(result.diagnostics.messages.length).toEqual(1); + expect(result.diagnostics.messages[0].message) + .toEqual( + `Missing required "id" attribute: ("\n` + + ` \n` + + ` \n` + + ` [ERROR ->]\n` + + ` \n` + + ` \n` + + `"): /some/file.xlf@7:20`); + }); + }); + }); });