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
`<xliff version="1.2">` which is the minimal requirement for such files.

PR Close #35793
This commit is contained in:
Pete Bacon Darwin 2020-03-01 16:39:15 +00:00 committed by Matias Niemelä
parent cd32085a75
commit 350ac11554
4 changed files with 902 additions and 120 deletions

View File

@ -42,7 +42,7 @@ export class TranslationLoader {
} }
const {locale: parsedLocale, translations, diagnostics} = const {locale: parsedLocale, translations, diagnostics} =
translationParser.parse(filePath, fileContents); translationParser.parse(filePath, fileContents, result);
if (diagnostics.hasErrors) { if (diagnostics.hasErrors) {
throw new Error(diagnostics.formatDiagnostics( throw new Error(diagnostics.formatDiagnostics(
`The translation file "${filePath}" could not be parsed.`)); `The translation file "${filePath}" could not be parsed.`));

View File

@ -5,7 +5,8 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * 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'; import {TranslationParseError} from './translation_parse_error';
export function getAttrOrThrow(element: Element, attrName: string): string { 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; 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[] { export function parseInnerRange(element: Element): Node[] {
const xmlParser = new XmlParser(); const xmlParser = new XmlParser();
const xml = xmlParser.parse( const xml = xmlParser.parse(
@ -33,6 +42,10 @@ export function parseInnerRange(element: Element): Node[] {
return xml.rootNodes; 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 { function getInnerRange(element: Element): LexerRange {
const start = element.startSourceSpan !.end; const start = element.startSourceSpan !.end;
const end = element.endSourceSpan !.start; const end = element.endSourceSpan !.start;
@ -42,4 +55,94 @@ function getInnerRange(element: Element): LexerRange {
startCol: start.col, startCol: start.col,
endPos: end.offset, endPos: end.offset,
}; };
} }
/**
* 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<string, string>): 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 <xliff> 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());
}
}

View File

@ -5,20 +5,16 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * found in the LICENSE file at https://angular.io/license
*/ */
import {Element, Node, XmlParser, visitAll} from '@angular/compiler'; import {Element, ParseErrorLevel, visitAll} from '@angular/compiler';
import {ɵMessageId, ɵParsedTranslation} from '@angular/localize'; import {ɵParsedTranslation} from '@angular/localize';
import {extname} from 'path';
import {Diagnostics} from '../../../diagnostics'; import {Diagnostics} from '../../../diagnostics';
import {BaseVisitor} from '../base_visitor'; import {BaseVisitor} from '../base_visitor';
import {MessageSerializer} from '../message_serialization/message_serializer'; import {MessageSerializer} from '../message_serialization/message_serializer';
import {TargetMessageRenderer} from '../message_serialization/target_message_renderer'; import {TargetMessageRenderer} from '../message_serialization/target_message_renderer';
import {TranslationParseError} from './translation_parse_error';
import {ParsedTranslationBundle, TranslationParser} from './translation_parser'; import {ParsedTranslationBundle, TranslationParser} from './translation_parser';
import {getAttrOrThrow, getAttribute, parseInnerRange} from './translation_utils'; import {XmlTranslationParserHint, addParseDiagnostic, addParseError, canParseXml, getAttribute, isNamedElement, parseInnerRange} from './translation_utils';
const XLIFF_1_2_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:1.2"/;
/** /**
* A translation parser that can load XLIFF 1.2 files. * 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 * http://docs.oasis-open.org/xliff/v1.2/xliff-profile-html/xliff-profile-html-1.2.html
* *
*/ */
export class Xliff1TranslationParser implements TranslationParser { export class Xliff1TranslationParser implements TranslationParser<XmlTranslationParserHint> {
canParse(filePath: string, contents: string): boolean { canParse(filePath: string, contents: string): XmlTranslationParserHint|false {
return (extname(filePath) === '.xlf') && XLIFF_1_2_NS_REGEX.test(contents); return canParseXml(filePath, contents, 'xliff', {version: '1.2'});
} }
parse(filePath: string, contents: string): ParsedTranslationBundle { parse(filePath: string, contents: string, hint?: XmlTranslationParserHint):
const xmlParser = new XmlParser(); ParsedTranslationBundle {
const xml = xmlParser.parse(contents, filePath); if (hint) {
const bundle = XliffFileElementVisitor.extractBundle(xml.rootNodes); return this.extractBundle(hint);
if (bundle === undefined) { } 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 <file> element',
ParseErrorLevel.WARNING);
return {locale: undefined, translations: {}, diagnostics};
}
const files = element.children.filter(isNamedElement('file'));
if (files.length === 0) {
addParseDiagnostic(
diagnostics, element.sourceSpan, 'No <file> elements found in <xliff>',
ParseErrorLevel.WARNING);
} else if (files.length > 1) {
addParseDiagnostic(
diagnostics, files[1].sourceSpan, 'More than one <file> element found in <xliff>',
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.`); 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; return bundle;
} }
} }
class XliffFileElementVisitor extends BaseVisitor { class XliffFileElementVisitor extends BaseVisitor {
private bundle: ParsedTranslationBundle|undefined; visitElement(fileElement: Element): any {
if (fileElement.name === 'file') {
static extractBundle(xliff: Node[]): ParsedTranslationBundle|undefined { return {fileElement, locale: getAttribute(fileElement, 'target-language')};
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);
} }
} }
} }
class XliffTranslationVisitor extends BaseVisitor { class XliffTranslationVisitor extends BaseVisitor {
private translations: Record<ɵMessageId, ɵParsedTranslation> = {}; visitElement(element: Element, bundle: ParsedTranslationBundle): void {
if (element.name === 'trans-unit') {
static extractTranslations(file: Element): Record<string, ɵParsedTranslation> { this.visitTransUnitElement(element, bundle);
const visitor = new this(); } else {
visitAll(visitor, file.children); visitAll(this, element.children, bundle);
return visitor.translations; }
} }
visitElement(element: Element): any { private visitTransUnitElement(element: Element, bundle: ParsedTranslationBundle): void {
if (element.name === 'trans-unit') { // Error if no `id` attribute
const id = getAttrOrThrow(element, 'id'); const id = getAttribute(element, 'id');
if (this.translations[id] !== undefined) { if (id === undefined) {
throw new TranslationParseError( addParseDiagnostic(
element.sourceSpan, `Duplicated translations for message "${id}"`); bundle.diagnostics, element.sourceSpan,
} `Missing required "id" attribute on <trans-unit> element.`, ParseErrorLevel.ERROR);
return;
}
const targetMessage = element.children.find(isTargetElement); // Error if there is already a translation with the same id
if (targetMessage === undefined) { if (bundle.translations[id] !== undefined) {
throw new TranslationParseError(element.sourceSpan, 'Missing required <target> element'); addParseDiagnostic(
bundle.diagnostics, element.sourceSpan, `Duplicated translations for message "${id}"`,
ParseErrorLevel.ERROR);
return;
}
// Error if there is no `<target>` child element
const targetMessage = element.children.find(isNamedElement('target'));
if (targetMessage === undefined) {
addParseDiagnostic(
bundle.diagnostics, element.sourceSpan, 'Missing required <target> 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)); return serializer.serialize(parseInnerRange(source));
} }
function isTargetElement(node: Node): node is Element {
return node instanceof Element && node.name === 'target';
}

View File

@ -10,23 +10,27 @@ import {Xliff1TranslationParser} from '../../../../src/translate/translation_fil
describe('Xliff1TranslationParser', () => { describe('Xliff1TranslationParser', () => {
describe('canParse()', () => { 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 <xliff> element with version="1.2" attribute',
() => { () => {
const parser = new Xliff1TranslationParser(); const parser = new Xliff1TranslationParser();
expect(parser.canParse( expect(parser.canParse(
'/some/file.xlf', '/some/file.xlf',
'<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">')) '<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">'))
.toBe(true); .toBeTruthy();
expect(parser.canParse( expect(parser.canParse(
'/some/file.json', '/some/file.json',
'<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">')) '<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">'))
.toBe(false); .toBeTruthy();
expect(parser.canParse('/some/file.xliff', '<xliff version="1.2">')).toBeTruthy();
expect(parser.canParse('/some/file.json', '<xliff version="1.2">')).toBeTruthy();
expect(parser.canParse('/some/file.xlf', '<xliff>')).toBe(false);
expect(parser.canParse('/some/file.xlf', '<xliff version="2.0">')).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); expect(parser.canParse('/some/file.json', '')).toBe(false);
}); });
}); });
describe('parse()', () => { describe('parse() [without hint]', () => {
it('should extract the locale from the file contents', () => { it('should extract the locale from the file contents', () => {
const XLIFF = ` const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
@ -435,16 +439,17 @@ describe('Xliff1TranslationParser', () => {
describe('[structure errors]', () => { describe('[structure errors]', () => {
it('should throw when a trans-unit has no translation', () => { it('should throw when a trans-unit has no translation', () => {
const XLIFF = `<?xml version="1.0" encoding="UTF-8" ?> const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> <?xml version="1.0" encoding="UTF-8" ?>
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template"> <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<body> <file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<trans-unit id="missingtarget"> <body>
<source/> <trans-unit id="missingtarget">
</trans-unit> <source/>
</body> </trans-unit>
</file> </body>
</xliff>`; </file>
</xliff>`;
expect(() => { expect(() => {
const parser = new Xliff1TranslationParser(); const parser = new Xliff1TranslationParser();
@ -454,17 +459,18 @@ describe('Xliff1TranslationParser', () => {
it('should throw when a trans-unit has no id attribute', () => { it('should throw when a trans-unit has no id attribute', () => {
const XLIFF = `<?xml version="1.0" encoding="UTF-8" ?> const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> <?xml version="1.0" encoding="UTF-8" ?>
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template"> <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<body> <file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<trans-unit datatype="html"> <body>
<source/> <trans-unit datatype="html">
<target/> <source/>
</trans-unit> <target/>
</body> </trans-unit>
</file> </body>
</xliff>`; </file>
</xliff>`;
expect(() => { expect(() => {
const parser = new Xliff1TranslationParser(); const parser = new Xliff1TranslationParser();
@ -473,21 +479,22 @@ describe('Xliff1TranslationParser', () => {
}); });
it('should throw on duplicate trans-unit id', () => { it('should throw on duplicate trans-unit id', () => {
const XLIFF = `<?xml version="1.0" encoding="UTF-8" ?> const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> <?xml version="1.0" encoding="UTF-8" ?>
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template"> <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<body> <file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<trans-unit id="deadbeef"> <body>
<source/> <trans-unit id="deadbeef">
<target/> <source/>
</trans-unit> <target/>
<trans-unit id="deadbeef"> </trans-unit>
<source/> <trans-unit id="deadbeef">
<target/> <source/>
</trans-unit> <target/>
</body> </trans-unit>
</file> </body>
</xliff>`; </file>
</xliff>`;
expect(() => { expect(() => {
const parser = new Xliff1TranslationParser(); const parser = new Xliff1TranslationParser();
@ -498,17 +505,18 @@ describe('Xliff1TranslationParser', () => {
describe('[message errors]', () => { describe('[message errors]', () => {
it('should throw on unknown message tags', () => { it('should throw on unknown message tags', () => {
const XLIFF = `<?xml version="1.0" encoding="UTF-8" ?> const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> <?xml version="1.0" encoding="UTF-8" ?>
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template"> <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<body> <file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<trans-unit id="deadbeef" datatype="html"> <body>
<source/> <trans-unit id="deadbeef" datatype="html">
<target><b>msg should contain only ph tags</b></target> <source/>
</trans-unit> <target><b>msg should contain only ph tags</b></target>
</body> </trans-unit>
</file> </body>
</xliff>`; </file>
</xliff>`;
expect(() => { expect(() => {
const parser = new Xliff1TranslationParser(); const parser = new Xliff1TranslationParser();
@ -517,17 +525,18 @@ describe('Xliff1TranslationParser', () => {
}); });
it('should throw when a placeholder misses an id attribute', () => { it('should throw when a placeholder misses an id attribute', () => {
const XLIFF = `<?xml version="1.0" encoding="UTF-8" ?> const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2"> <?xml version="1.0" encoding="UTF-8" ?>
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template"> <xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<body> <file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<trans-unit id="deadbeef" datatype="html"> <body>
<source/> <trans-unit id="deadbeef" datatype="html">
<target><x/></target> <source/>
</trans-unit> <target><x/></target>
</body> </trans-unit>
</file> </body>
</xliff>`; </file>
</xliff>`;
expect(() => { expect(() => {
const parser = new Xliff1TranslationParser(); 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 = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
</body>
</file>
</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 = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" datatype="plaintext" original="ng2.template">
<body>
</body>
</file>
</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:
*
* ```
* <div i18n>translatable attribute</div>
* ```
*/
const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="1933478729560469763" datatype="html">
<source>translatable attribute</source>
<target>etubirtta elbatalsnart</target>
<context-group purpose="location">
<context context-type="sourcefile">file.ts</context>
<context context-type="linenumber">1</context>
</context-group>
</trans-unit>
</body>
</file>
</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.translations[ɵcomputeMsgId('translatable attribute')])
.toEqual(ɵmakeParsedTranslation(['etubirtta elbatalsnart']));
});
it('should extract translations with simple placeholders', () => {
/**
* Source HTML:
*
* ```
* <div i18n>translatable element <b>with placeholders</b> {{ interpolation}}</div>
* ```
*/
const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="5057824347511785081" datatype="html">
<source>translatable element <x id="START_BOLD_TEXT" ctype="b"/>with placeholders<x id="CLOSE_BOLD_TEXT" ctype="b"/> <x id="INTERPOLATION"/></source>
<target><x id="INTERPOLATION"/> tnemele elbatalsnart <x id="START_BOLD_TEXT" ctype="x-b"/>sredlohecalp htiw<x id="CLOSE_BOLD_TEXT" ctype="x-b"/></target>
<context-group purpose="location">
<context context-type="sourcefile">file.ts</context>
<context context-type="linenumber">2</context>
</context-group>
</trans-unit>
</body>
</file>
</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.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:
*
* ```
* <div i18n><app-my-component></app-my-component> Welcome</div>
* ```
*/
const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="2877147807876214810" datatype="html">
<source><x id="START_TAG_APP-MY-COMPONENT" ctype="x-app-my-component" equiv-text="&lt;app-my-component&gt;"/><x id="CLOSE_TAG_APP-MY-COMPONENT" ctype="x-app-my-component" equiv-text="&lt;/app-my-component&gt;"/> Welcome</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.html</context>
<context context-type="linenumber">1</context>
</context-group>
<target><x id="START_TAG_APP-MY-COMPONENT" ctype="x-app-my-component" equiv-text="&lt;app-my-component&gt;"/><x id="CLOSE_TAG_APP-MY-COMPONENT" ctype="x-app-my-component" equiv-text="&lt;/app-my-component&gt;"/> Translate</target>
</trans-unit>
</body>
</file>
</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);
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:
*
* ```
* <div i18n>{VAR_PLURAL, plural, =0 {<p>test</p>} }</div>
* ```
*/
const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="2874455947211586270" datatype="html">
<source>{VAR_PLURAL, plural, =0 {<x id="START_PARAGRAPH" ctype="x-p"/>test<x id="CLOSE_PARAGRAPH" ctype="x-p"/>} }</source>
<target>{VAR_PLURAL, plural, =0 {<x id="START_PARAGRAPH" ctype="x-p"/>TEST<x id="CLOSE_PARAGRAPH" ctype="x-p"/>} }</target>
</trans-unit>
</body>
</file>
</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.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:
*
* ```
* <div i18n>foo</div>
* <div i18n="m|d@@i">foo</div>
* <div i18=""m|d@@bar>foo</div>
* ```
*/
const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="9205907420411818817" datatype="html">
<source>foo</source>
<target>oof</target>
<context-group purpose="location">
<context context-type="sourcefile">file.ts</context>
<context context-type="linenumber">3</context>
</context-group>
<note priority="1" from="description">d</note>
<note priority="1" from="meaning">m</note>
</trans-unit>
<trans-unit id="i" datatype="html">
<source>foo</source>
<target>toto</target>
<context-group purpose="location">
<context context-type="sourcefile">file.ts</context>
<context context-type="linenumber">4</context>
</context-group>
<note priority="1" from="description">d</note>
<note priority="1" from="meaning">m</note>
</trans-unit>
<trans-unit id="bar" datatype="html">
<source>foo</source>
<target>tata</target>
<context-group purpose="location">
<context context-type="sourcefile">file.ts</context>
<context context-type="linenumber">5</context>
</context-group>
</trans-unit>
</body>
</file>
</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.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:
*
* ```
* <div i18n><br><img/><img/></div>
* ```
*/
const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="7118057989405618448" datatype="html">
<ph id="1" equiv="TAG_IMG" type="image" disp="&lt;img/&gt;"/><ph id="2" equiv="TAG_IMG_1" type="image" disp="&lt;img/&gt;"/>
<source><x id="LINE_BREAK" ctype="lb"/><x id="TAG_IMG" ctype="image"/><x id="TAG_IMG_1" ctype="image"/></source>
<target><x id="TAG_IMG_1" ctype="image"/><x id="TAG_IMG" ctype="image"/><x id="LINE_BREAK" ctype="lb"/></target>
<context-group purpose="location">
<context context-type="sourcefile">file.ts</context>
<context context-type="linenumber">6</context>
</context-group>
<note priority="1" from="description">ph names</note>
</trans-unit>
</body>
</file>
</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.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:
*
* ```
* <div i18n>hello <span></span></div>
* ```
*/
const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="2826198357052921524" datatype="html">
<source>hello <x id="START_TAG_SPAN" ctype="x-span"/><x id="CLOSE_TAG_SPAN" ctype="x-span"/></source>
<target/>
<context-group purpose="location">
<context context-type="sourcefile">file.ts</context>
<context context-type="linenumber">6</context>
</context-group>
<note priority="1" from="description">ph names</note>
</trans-unit>
</body>
</file>
</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.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 {<p>deeply nested</p>}} } =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 = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="980940425376233536" datatype="html">
<source>Test: <x id="ICU" equiv-text="{ count, plural, =0 {...} =other {...}}"/></source>
<target>Le test: <x id="ICU" equiv-text="{ count, plural, =0 {...} =other {...}}"/></target>
<context-group purpose="location">
<context context-type="sourcefile">file.ts</context>
<context context-type="linenumber">11</context>
</context-group>
</trans-unit>
<trans-unit id="5207293143089349404" datatype="html">
<source>{VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {<x id="START_PARAGRAPH" ctype="x-p"/>deeply nested<x id="CLOSE_PARAGRAPH" ctype="x-p"/>}}} =other {a lot}}</source>
<target>{VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {<x id="START_PARAGRAPH" ctype="x-p"/>profondément imbriqué<x id="CLOSE_PARAGRAPH" ctype="x-p"/>}}} =other {beaucoup}}</target>
</trans-unit>
</body>
</file>
</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.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:
*
* ```
* <div i18n>multi
* lines</div>
* ```
*/
const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="2340165783990709777" datatype="html">
<source>multi\nlines</source>
<target>multi\nlignes</target>
<context-group purpose="location">
<context context-type="sourcefile">file.ts</context>
<context context-type="linenumber">12</context>
</context-group>
</trans-unit>
</body>
</file>
</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.translations[ɵcomputeMsgId('multi\nlines')])
.toEqual(ɵmakeParsedTranslation(['multi\nlignes']));
});
it('should extract translations with <mrk> elements', () => {
const XLIFF = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="mrk-test">
<source>First sentence.</source>
<seg-source>
<invalid-tag>Should not be parsed</invalid-tag>
</seg-source>
<target>Translated <mrk mtype="seg" mid="1">first sentence</mrk>.</target>
</trans-unit>
<trans-unit id="mrk-test2">
<source>First sentence. Second sentence.</source>
<seg-source>
<invalid-tag>Should not be parsed</invalid-tag>
</seg-source>
<target>Translated <mrk mtype="seg" mid="1"><mrk mtype="seg" mid="2">first</mrk> sentence</mrk>.</target>
</trans-unit>
</body>
</file>
</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.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 = `
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit datatype="html" approved="no" id="registration.submit">
<source>Continue</source>
<target state="translated" xml:lang="de">Weiter</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/registration-form/registration-form.component.html</context>
<context context-type="linenumber">69</context>
</context-group>
<?sid 1110954287-0?>
<alt-trans origin="autoFuzzy" tool="Swordfish" match-quality="71" ts="63">
<source xml:lang="en">Content</source>
<target state="translated" xml:lang="de">Content</target>
</alt-trans>
</trans-unit>
</body>
</file>
</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.translations['registration.submit'])
.toEqual(ɵmakeParsedTranslation(['Weiter']));
});
describe('[structure errors]', () => {
it('should provide a diagnostic error when a trans-unit has no translation', () => {
const XLIFF = `
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="missingtarget">
<source/>
</trans-unit>
</body>
</file>
</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 <target> element ("ge="en" target-language="fr" datatype="plaintext" original="ng2.template">\n` +
` <body>\n` +
` [ERROR ->]<trans-unit id="missingtarget">\n` +
` <source/>\n` +
` </trans-unit>\n` +
`"): /some/file.xlf@5:10`);
});
it('should provide a diagnostic error when a trans-unit has no id attribute', () => {
const XLIFF = `
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit datatype="html">
<source/>
<target/>
</trans-unit>
</body>
</file>
</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 <trans-unit> element. ("ge="en" target-language="fr" datatype="plaintext" original="ng2.template">\n` +
` <body>\n` +
` [ERROR ->]<trans-unit datatype="html">\n` +
` <source/>\n` +
` <target/>\n` +
`"): /some/file.xlf@5:10`);
});
it('should provide a diagnostic error on duplicate trans-unit id', () => {
const XLIFF = `
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="deadbeef">
<source/>
<target/>
</trans-unit>
<trans-unit id="deadbeef">
<source/>
<target/>
</trans-unit>
</body>
</file>
</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` +
` <target/>\n` +
` </trans-unit>\n` +
` [ERROR ->]<trans-unit id="deadbeef">\n` +
` <source/>\n` +
` <target/>\n` +
`"): /some/file.xlf@9:10`);
});
});
describe('[message errors]', () => {
it('should provide a diagnostic error on unknown message tags', () => {
const XLIFF = `
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="deadbeef" datatype="html">
<source/>
<target><b>msg should contain only ph tags</b></target>
</trans-unit>
</body>
</file>
</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(
`Invalid element found in message. ("\n` +
` <trans-unit id="deadbeef" datatype="html">\n` +
` <source/>\n` +
` <target>[ERROR ->]<b>msg should contain only ph tags</b></target>\n` +
` </trans-unit>\n` +
` </body>\n` +
`"): /some/file.xlf@7:20`);
});
it('should provide a diagnostic error when a placeholder misses an id attribute', () => {
const XLIFF = `
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="deadbeef" datatype="html">
<source/>
<target><x/></target>
</trans-unit>
</body>
</file>
</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` +
` <trans-unit id="deadbeef" datatype="html">\n` +
` <source/>\n` +
` <target>[ERROR ->]<x/></target>\n` +
` </trans-unit>\n` +
` </body>\n` +
`"): /some/file.xlf@7:20`);
});
});
});
}); });