fix(localize): improve matching and parsing of XLIFF 2.0 translation files (#35793)
Previously, the `Xliff2TranslationParser` 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="2.0">` which is the minimal requirement for such files. PR Close #35793
This commit is contained in:
parent
350ac11554
commit
08071e5634
|
@ -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, Node, 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_2_0_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:2.0"/;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A translation parser that can load translations from XLIFF 2 files.
|
* A translation parser that can load translations from XLIFF 2 files.
|
||||||
|
@ -26,85 +22,132 @@ const XLIFF_2_0_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:2.0"/;
|
||||||
* http://docs.oasis-open.org/xliff/xliff-core/v2.0/os/xliff-core-v2.0-os.html
|
* http://docs.oasis-open.org/xliff/xliff-core/v2.0/os/xliff-core-v2.0-os.html
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export class Xliff2TranslationParser implements TranslationParser {
|
export class Xliff2TranslationParser implements TranslationParser<XmlTranslationParserHint> {
|
||||||
canParse(filePath: string, contents: string): boolean {
|
canParse(filePath: string, contents: string): XmlTranslationParserHint|false {
|
||||||
return (extname(filePath) === '.xlf') && XLIFF_2_0_NS_REGEX.test(contents);
|
return canParseXml(filePath, contents, 'xliff', {version: '2.0'});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = Xliff2TranslationBundleVisitor.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 locale = getAttribute(element, 'trgLang');
|
||||||
|
const files = element.children.filter(isFileElement);
|
||||||
|
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, translations: {}, diagnostics};
|
||||||
|
const translationVisitor = new Xliff2TranslationVisitor();
|
||||||
|
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 2.0 format.`);
|
throw new Error(`Unable to parse "${filePath}" as XLIFF 2.0 format.`);
|
||||||
}
|
}
|
||||||
|
const bundle = this.extractBundle(hint);
|
||||||
|
if (bundle.diagnostics.hasErrors) {
|
||||||
|
const message =
|
||||||
|
bundle.diagnostics.formatDiagnostics(`Failed to parse "${filePath}" as XLIFF 2.0 format`);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
return bundle;
|
return bundle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BundleVisitorContext {
|
|
||||||
parsedLocale?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class Xliff2TranslationBundleVisitor extends BaseVisitor {
|
interface TranslationVisitorContext {
|
||||||
private bundle: ParsedTranslationBundle|undefined;
|
unit?: string;
|
||||||
|
bundle: ParsedTranslationBundle;
|
||||||
static extractBundle(xliff: Node[]): ParsedTranslationBundle|undefined {
|
|
||||||
const visitor = new this();
|
|
||||||
visitAll(visitor, xliff, {});
|
|
||||||
return visitor.bundle;
|
|
||||||
}
|
|
||||||
|
|
||||||
visitElement(element: Element, {parsedLocale}: BundleVisitorContext): any {
|
|
||||||
if (element.name === 'xliff') {
|
|
||||||
parsedLocale = getAttribute(element, 'trgLang');
|
|
||||||
return visitAll(this, element.children, {parsedLocale});
|
|
||||||
} else if (element.name === 'file') {
|
|
||||||
this.bundle = {
|
|
||||||
locale: parsedLocale,
|
|
||||||
translations: Xliff2TranslationVisitor.extractTranslations(element),
|
|
||||||
diagnostics: new Diagnostics(),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return visitAll(this, element.children, {parsedLocale});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Xliff2TranslationVisitor extends BaseVisitor {
|
class Xliff2TranslationVisitor extends BaseVisitor {
|
||||||
private translations: Record<ɵMessageId, ɵParsedTranslation> = {};
|
visitElement(element: Element, {bundle, unit}: TranslationVisitorContext): any {
|
||||||
|
|
||||||
static extractTranslations(file: Element): Record<string, ɵParsedTranslation> {
|
|
||||||
const visitor = new this();
|
|
||||||
visitAll(visitor, file.children);
|
|
||||||
return visitor.translations;
|
|
||||||
}
|
|
||||||
|
|
||||||
visitElement(element: Element, context: any): any {
|
|
||||||
if (element.name === 'unit') {
|
if (element.name === 'unit') {
|
||||||
const externalId = getAttrOrThrow(element, 'id');
|
this.visitUnitElement(element, bundle);
|
||||||
if (this.translations[externalId] !== undefined) {
|
|
||||||
throw new TranslationParseError(
|
|
||||||
element.sourceSpan, `Duplicated translations for message "${externalId}"`);
|
|
||||||
}
|
|
||||||
visitAll(this, element.children, {unit: externalId});
|
|
||||||
} else if (element.name === 'segment') {
|
} else if (element.name === 'segment') {
|
||||||
assertTranslationUnit(element, context);
|
this.visitSegmentElement(element, bundle, unit);
|
||||||
const targetMessage = element.children.find(isTargetElement);
|
|
||||||
if (targetMessage === undefined) {
|
|
||||||
throw new TranslationParseError(element.sourceSpan, 'Missing required <target> element');
|
|
||||||
}
|
|
||||||
this.translations[context.unit] = serializeTargetMessage(targetMessage);
|
|
||||||
} else {
|
} else {
|
||||||
return visitAll(this, element.children);
|
visitAll(this, element.children, {bundle, unit});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function assertTranslationUnit(segment: Element, context: any) {
|
private visitUnitElement(element: Element, bundle: ParsedTranslationBundle): void {
|
||||||
if (context === undefined || context.unit === undefined) {
|
// Error if no `id` attribute
|
||||||
throw new TranslationParseError(
|
const externalId = getAttribute(element, 'id');
|
||||||
segment.sourceSpan, 'Invalid <segment> element: should be a child of a <unit> element.');
|
if (externalId === undefined) {
|
||||||
|
addParseDiagnostic(
|
||||||
|
bundle.diagnostics, element.sourceSpan,
|
||||||
|
`Missing required "id" attribute on <trans-unit> element.`, ParseErrorLevel.ERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error if there is already a translation with the same id
|
||||||
|
if (bundle.translations[externalId] !== undefined) {
|
||||||
|
addParseDiagnostic(
|
||||||
|
bundle.diagnostics, element.sourceSpan,
|
||||||
|
`Duplicated translations for message "${externalId}"`, ParseErrorLevel.ERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visitAll(this, element.children, {bundle, unit: externalId});
|
||||||
|
}
|
||||||
|
|
||||||
|
private visitSegmentElement(
|
||||||
|
element: Element, bundle: ParsedTranslationBundle, unit: string|undefined): void {
|
||||||
|
// A `<segment>` element must be below a `<unit>` element
|
||||||
|
if (unit === undefined) {
|
||||||
|
addParseDiagnostic(
|
||||||
|
bundle.diagnostics, element.sourceSpan,
|
||||||
|
'Invalid <segment> element: should be a child of a <unit> element.',
|
||||||
|
ParseErrorLevel.ERROR);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[unit] = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,6 +161,6 @@ function serializeTargetMessage(source: Element): ɵParsedTranslation {
|
||||||
return serializer.serialize(parseInnerRange(source));
|
return serializer.serialize(parseInnerRange(source));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTargetElement(node: Node): node is Element {
|
function isFileElement(node: Node): node is Element {
|
||||||
return node instanceof Element && node.name === 'target';
|
return node instanceof Element && node.name === 'file';
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,24 +10,27 @@ import {Xliff2TranslationParser} from '../../../../src/translate/translation_fil
|
||||||
|
|
||||||
describe('Xliff2TranslationParser', () => {
|
describe('Xliff2TranslationParser', () => {
|
||||||
describe('canParse()', () => {
|
describe('canParse()', () => {
|
||||||
it('should return true if the file extension is `.xlf` and it contains the XLIFF namespace', () => {
|
it('should return true if the file contains an <xliff> element with version="2.0" attribute',
|
||||||
const parser = new Xliff2TranslationParser();
|
() => {
|
||||||
expect(
|
const parser = new Xliff2TranslationParser();
|
||||||
parser.canParse(
|
expect(parser.canParse(
|
||||||
'/some/file.xlf',
|
'/some/file.xlf',
|
||||||
'<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">'))
|
'<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0">'))
|
||||||
.toBe(true);
|
.toBeTruthy();
|
||||||
expect(
|
expect(parser.canParse(
|
||||||
parser.canParse(
|
'/some/file.json',
|
||||||
'/some/file.json',
|
'<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0">'))
|
||||||
'<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">'))
|
.toBeTruthy();
|
||||||
.toBe(false);
|
expect(parser.canParse('/some/file.xliff', '<xliff version="2.0">')).toBeTruthy();
|
||||||
expect(parser.canParse('/some/file.xlf', '')).toBe(false);
|
expect(parser.canParse('/some/file.json', '<xliff version="2.0">')).toBeTruthy();
|
||||||
expect(parser.canParse('/some/file.json', '')).toBe(false);
|
expect(parser.canParse('/some/file.xlf', '<xliff>')).toBe(false);
|
||||||
});
|
expect(parser.canParse('/some/file.xlf', '<xliff version="1.2">')).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', () => {
|
it('should extract the locale from the file contents', () => {
|
||||||
const XLIFF = `
|
const XLIFF = `
|
||||||
<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
@ -475,4 +478,550 @@ describe('Xliff2TranslationParser', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parse() [with hint]', () => {
|
||||||
|
it('should extract the locale from the file contents', () => {
|
||||||
|
const XLIFF = `
|
||||||
|
<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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 undefined locale if there is no locale in the file', () => {
|
||||||
|
const XLIFF = `
|
||||||
|
<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit id="1933478729560469763">
|
||||||
|
<notes>
|
||||||
|
<note category="location">file.ts:2</note>
|
||||||
|
</notes>
|
||||||
|
<segment>
|
||||||
|
<source>translatable attribute</source>
|
||||||
|
<target>etubirtta elbatalsnart</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit id="5057824347511785081">
|
||||||
|
<notes>
|
||||||
|
<note category="location">file.ts:3</note>
|
||||||
|
</notes>
|
||||||
|
<segment>
|
||||||
|
<source>translatable element <pc id="0" equivStart="START_BOLD_TEXT" equivEnd="CLOSE_BOLD_TEXT" type="fmt" dispStart="<b>" dispEnd="</b>">with placeholders</pc> <ph id="1" equiv="INTERPOLATION" disp="{{ interpolation}}"/></source>
|
||||||
|
<target><ph id="1" equiv="INTERPOLATION" disp="{{ interpolation}}"/> tnemele elbatalsnart <pc id="0" equivStart="START_BOLD_TEXT" equivEnd="CLOSE_BOLD_TEXT" type="fmt" dispStart="<b>" dispEnd="</b>">sredlohecalp htiw</pc></target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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 simple ICU expressions', () => {
|
||||||
|
/**
|
||||||
|
* Source HTML:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* <div i18n>{VAR_PLURAL, plural, =0 {<p>test</p>} }</div>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
const XLIFF = `
|
||||||
|
<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit id="2874455947211586270">
|
||||||
|
<notes>
|
||||||
|
<note category="location">file.ts:4</note>
|
||||||
|
</notes>
|
||||||
|
<segment>
|
||||||
|
<source>{VAR_PLURAL, plural, =0 {<pc id="0" equivStart="START_PARAGRAPH" equivEnd="CLOSE_PARAGRAPH" type="other" dispStart="<p>" dispEnd="</p>">test</pc>} }</source>
|
||||||
|
<target>{VAR_PLURAL, plural, =0 {<pc id="0" equivStart="START_PARAGRAPH" equivEnd="CLOSE_PARAGRAPH" type="other" dispStart="<p>" dispEnd="</p>">TEST</pc>} }</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit id="9205907420411818817">
|
||||||
|
<notes>
|
||||||
|
<note category="description">d</note>
|
||||||
|
<note category="meaning">m</note>
|
||||||
|
<note category="location">file.ts:5</note>
|
||||||
|
</notes>
|
||||||
|
<segment>
|
||||||
|
<source>foo</source>
|
||||||
|
<target>oof</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="i">
|
||||||
|
<notes>
|
||||||
|
<note category="description">d</note>
|
||||||
|
<note category="meaning">m</note>
|
||||||
|
<note category="location">file.ts:5</note>
|
||||||
|
</notes>
|
||||||
|
<segment>
|
||||||
|
<source>foo</source>
|
||||||
|
<target>toto</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="bar">
|
||||||
|
<notes>
|
||||||
|
<note category="description">d</note>
|
||||||
|
<note category="meaning">m</note>
|
||||||
|
<note category="location">file.ts:5</note>
|
||||||
|
</notes>
|
||||||
|
<segment>
|
||||||
|
<source>foo</source>
|
||||||
|
<target>tata</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit id="7118057989405618448">
|
||||||
|
<notes>
|
||||||
|
<note category="description">ph names</note>
|
||||||
|
<note category="location">file.ts:7</note>
|
||||||
|
</notes>
|
||||||
|
<segment>
|
||||||
|
<source><ph id="0" equiv="LINE_BREAK" type="fmt" disp="<br/>"/><ph id="1" equiv="TAG_IMG" type="image" disp="<img/>"/><ph id="2" equiv="TAG_IMG_1" type="image" disp="<img/>"/></source>
|
||||||
|
<target><ph id="2" equiv="TAG_IMG_1" type="image" disp="<img/>"/><ph id="1" equiv="TAG_IMG" type="image" disp="<img/>"/><ph id="0" equiv="LINE_BREAK" type="fmt" disp="<br/>"/></target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit id="2826198357052921524">
|
||||||
|
<notes>
|
||||||
|
<note category="description">empty element</note>
|
||||||
|
<note category="location">file.ts:8</note>
|
||||||
|
</notes>
|
||||||
|
<segment>
|
||||||
|
<source>hello <pc id="0" equivStart="START_TAG_SPAN" equivEnd="CLOSE_TAG_SPAN" type="other" dispStart="<span>" dispEnd="</span>"></pc></source>
|
||||||
|
<target></target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit id="980940425376233536">
|
||||||
|
<notes>
|
||||||
|
<note category="location">file.ts:10</note>
|
||||||
|
</notes>
|
||||||
|
<segment>
|
||||||
|
<source>Test: <ph id="0" equiv="ICU" disp="{ count, plural, =0 {...} =other {...}}"/></source>
|
||||||
|
<target>Le test: <ph id="0" equiv="ICU" disp="{ count, plural, =0 {...} =other {...}}"/></target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="5207293143089349404">
|
||||||
|
<notes>
|
||||||
|
<note category="location">file.ts:10</note>
|
||||||
|
</notes>
|
||||||
|
<segment>
|
||||||
|
<source>{VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {<pc id="0" equivStart="START_PARAGRAPH" equivEnd="CLOSE_PARAGRAPH" type="other" dispStart="<p>" dispEnd="</p>">deeply nested</pc>}}} =other {a lot}}</source>
|
||||||
|
<target>{VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {<pc id="0" equivStart="START_PARAGRAPH" equivEnd="CLOSE_PARAGRAPH" type="other" dispStart="<p>" dispEnd="</p>">profondément imbriqué</pc>}}} =other {beaucoup}}</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit id="2340165783990709777">
|
||||||
|
<notes>
|
||||||
|
<note category="location">file.ts:11,12</note>
|
||||||
|
</notes>
|
||||||
|
<segment>
|
||||||
|
<source>multi\nlines</source>
|
||||||
|
<target>multi\nlignes</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit id="mrk-test">
|
||||||
|
<segment>
|
||||||
|
<source>First sentence.</source>
|
||||||
|
<target>Translated <mrk id="m1" type="comment" ref="#n1">first sentence</mrk>.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="mrk-test2">
|
||||||
|
<segment>
|
||||||
|
<source>First sentence. Second sentence.</source>
|
||||||
|
<target>Translated <mrk id="m1" type="comment" ref="#n1"><mrk id="m2" type="comment" ref="#n1">first</mrk> sentence</mrk>.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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.']));
|
||||||
|
});
|
||||||
|
|
||||||
|
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="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit id="missingtarget">
|
||||||
|
<segment>
|
||||||
|
<source/>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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 ("
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit id="missingtarget">
|
||||||
|
[ERROR ->]<segment>
|
||||||
|
<source/>
|
||||||
|
</segment>
|
||||||
|
"): /some/file.xlf@4:12`);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
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="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit>
|
||||||
|
<segment>
|
||||||
|
<source/>
|
||||||
|
<target/>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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. ("ocument:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
[ERROR ->]<unit>
|
||||||
|
<segment>
|
||||||
|
<source/>
|
||||||
|
"): /some/file.xlf@3:10`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide a diagnostic error on duplicate trans-unit id', () => {
|
||||||
|
const XLIFF = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit id="deadbeef">
|
||||||
|
<segment>
|
||||||
|
<source/>
|
||||||
|
<target/>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="deadbeef">
|
||||||
|
<segment>
|
||||||
|
<source/>
|
||||||
|
<target/>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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" ("
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
[ERROR ->]<unit id="deadbeef">
|
||||||
|
<segment>
|
||||||
|
<source/>
|
||||||
|
"): /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="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit id="deadbeef">
|
||||||
|
<segment>
|
||||||
|
<source/>
|
||||||
|
<target><b>msg should contain only ph and pc tags</b></target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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. ("
|
||||||
|
<segment>
|
||||||
|
<source/>
|
||||||
|
<target>[ERROR ->]<b>msg should contain only ph and pc tags</b></target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
"): /some/file.xlf@6:22`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide a diagnostic error when a placeholder misses an id attribute', () => {
|
||||||
|
const XLIFF = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<xliff version="2.0" xmlns="urn:oasis:names:tc:xliff:document:2.0" srcLang="en" trgLang="fr">
|
||||||
|
<file original="ng.template" id="ngi18n">
|
||||||
|
<unit id="deadbeef">
|
||||||
|
<segment>
|
||||||
|
<source/>
|
||||||
|
<target><ph/></target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
|
||||||
|
const parser = new Xliff2TranslationParser();
|
||||||
|
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 "equiv" attribute: ("
|
||||||
|
<segment>
|
||||||
|
<source/>
|
||||||
|
<target>[ERROR ->]<ph/></target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
"): /some/file.xlf@6:22`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue