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`); + }); + }); + }); });