fix(localize): improve matching and parsing of XTB translation files (#35793)

This commit improves the `canParse()` method to check that the file is
valid XML and has the expected root node. Previously it was relying upon
a regular expression to do this.

PR Close #35793
This commit is contained in:
Pete Bacon Darwin 2020-03-02 10:57:47 +00:00 committed by Matias Niemelä
parent 08071e5634
commit 0e2a577b42
2 changed files with 433 additions and 85 deletions

View File

@ -5,7 +5,7 @@
* 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 {Element, ParseErrorLevel, visitAll} from '@angular/compiler';
import {ɵParsedTranslation} from '@angular/localize';
import {extname} from 'path';
@ -14,87 +14,100 @@ 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, parseInnerRange} from './translation_utils';
import {XmlTranslationParserHint, addParseDiagnostic, addParseError, canParseXml, getAttribute, parseInnerRange} from './translation_utils';
/**
* A translation parser that can load XB files.
*/
export class XtbTranslationParser implements TranslationParser {
constructor(private diagnostics: Diagnostics = new Diagnostics()) {}
canParse(filePath: string, contents: string): boolean {
export class XtbTranslationParser implements TranslationParser<XmlTranslationParserHint> {
canParse(filePath: string, contents: string): XmlTranslationParserHint|false {
const extension = extname(filePath);
return (extension === '.xtb' || extension === '.xmb') &&
contents.includes('<translationbundle');
if (extension !== '.xtb' && extension !== '.xmb') {
return false;
}
return canParseXml(filePath, contents, 'translationbundle', {});
}
parse(filePath: string, contents: string): ParsedTranslationBundle {
const xmlParser = new XmlParser();
const xml = xmlParser.parse(contents, filePath);
const bundle = XtbVisitor.extractBundle(this.diagnostics, xml.rootNodes);
if (bundle === undefined) {
throw new Error(`Unable to parse "${filePath}" as XTB/XMB format.`);
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 langAttr = element.attrs.find((attr) => attr.name === 'lang');
const bundle: ParsedTranslationBundle = {
locale: langAttr && langAttr.value,
translations: {},
diagnostics: new Diagnostics()
};
errors.forEach(e => addParseError(bundle.diagnostics, e));
const bundleVisitor = new XtbVisitor();
visitAll(bundleVisitor, element.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 XMB/XTB format.`);
}
const bundle = this.extractBundle(hint);
if (bundle.diagnostics.hasErrors) {
const message =
bundle.diagnostics.formatDiagnostics(`Failed to parse "${filePath}" as XMB/XTB format`);
throw new Error(message);
}
return bundle;
}
}
class XtbVisitor extends BaseVisitor {
static extractBundle(diagnostics: Diagnostics, messageBundles: Node[]): ParsedTranslationBundle
|undefined {
const visitor = new this(diagnostics);
const bundles: ParsedTranslationBundle[] = visitAll(visitor, messageBundles, undefined);
return bundles[0];
}
constructor(private diagnostics: Diagnostics) { super(); }
visitElement(element: Element, bundle: ParsedTranslationBundle|undefined): any {
visitElement(element: Element, bundle: ParsedTranslationBundle): any {
switch (element.name) {
case 'translationbundle':
if (bundle) {
throw new TranslationParseError(
element.sourceSpan, '<translationbundle> elements can not be nested');
}
const langAttr = element.attrs.find((attr) => attr.name === 'lang');
bundle = {
locale: langAttr && langAttr.value,
translations: {},
diagnostics: this.diagnostics
};
visitAll(this, element.children, bundle);
return bundle;
case 'translation':
if (!bundle) {
throw new TranslationParseError(
element.sourceSpan, '<translation> must be inside a <translationbundle>');
// Error if no `id` attribute
const id = getAttribute(element, 'id');
if (id === undefined) {
addParseDiagnostic(
bundle.diagnostics, element.sourceSpan,
`Missing required "id" attribute on <trans-unit> element.`, ParseErrorLevel.ERROR);
return;
}
const id = getAttrOrThrow(element, 'id');
if (bundle.translations.hasOwnProperty(id)) {
throw new TranslationParseError(
element.sourceSpan, `Duplicated translations for message "${id}"`);
} else {
// 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;
}
try {
bundle.translations[id] = serializeTargetMessage(element);
} catch (error) {
if (typeof error === 'string') {
this.diagnostics.warn(
bundle.diagnostics.warn(
`Could not parse message with id "${id}" - perhaps it has an unrecognised ICU format?\n` +
error);
} else if (error.span && error.msg && error.level) {
addParseDiagnostic(bundle.diagnostics, error.span, error.msg, error.level);
} else {
throw error;
}
}
}
break;
default:
throw new TranslationParseError(element.sourceSpan, 'Unexpected tag');
addParseDiagnostic(
bundle.diagnostics, element.sourceSpan, `Unexpected <${element.name}> tag.`,
ParseErrorLevel.ERROR);
}
}
}

View File

@ -13,24 +13,24 @@ describe('XtbTranslationParser', () => {
describe('canParse()', () => {
it('should return true if the file extension is `.xtb` or `.xmb` and it contains the `<translationbundle>` tag',
() => {
const parser = new XtbTranslationParser(new Diagnostics());
expect(parser.canParse('/some/file.xtb', '<translationbundle>')).toBe(true);
expect(parser.canParse('/some/file.xmb', '<translationbundle>')).toBe(true);
expect(parser.canParse('/some/file.xtb', '<translationbundle lang="en">')).toBe(true);
expect(parser.canParse('/some/file.xmb', '<translationbundle lang="en">')).toBe(true);
const parser = new XtbTranslationParser();
expect(parser.canParse('/some/file.xtb', '<translationbundle>')).toBeTruthy();
expect(parser.canParse('/some/file.xmb', '<translationbundle>')).toBeTruthy();
expect(parser.canParse('/some/file.xtb', '<translationbundle lang="en">')).toBeTruthy();
expect(parser.canParse('/some/file.xmb', '<translationbundle lang="en">')).toBeTruthy();
expect(parser.canParse('/some/file.json', '<translationbundle>')).toBe(false);
expect(parser.canParse('/some/file.xmb', '')).toBe(false);
expect(parser.canParse('/some/file.xtb', '')).toBe(false);
});
});
describe('parse()', () => {
describe('parse() [without hint]', () => {
it('should extract the locale from the file contents', () => {
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
<translationbundle lang='fr'>
<translation id="8841459487341224498">rab</translation>
</translationbundle>`;
const parser = new XtbTranslationParser(new Diagnostics());
const parser = new XtbTranslationParser();
const result = parser.parse('/some/file.xtb', XTB);
expect(result.locale).toEqual('fr');
});
@ -49,7 +49,7 @@ describe('XtbTranslationParser', () => {
<translationbundle>
<translation id="8841459487341224498">rab</translation>
</translationbundle>`;
const parser = new XtbTranslationParser(new Diagnostics());
const parser = new XtbTranslationParser();
const result = parser.parse('/some/file.xtb', XTB);
expect(result.translations['8841459487341224498']).toEqual(ɵmakeParsedTranslation(['rab']));
@ -60,7 +60,7 @@ describe('XtbTranslationParser', () => {
<translationbundle>
<translation id="8877975308926375834"><ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/></translation>
</translationbundle>`;
const parser = new XtbTranslationParser(new Diagnostics());
const parser = new XtbTranslationParser();
const result = parser.parse('/some/file.xtb', XTB);
expect(result.translations['8877975308926375834'])
@ -73,7 +73,7 @@ describe('XtbTranslationParser', () => {
<translation id="7717087045075616176">*<ph name="ICU"/>*</translation>
<translation id="5115002811911870583">{VAR_PLURAL, plural, =1 {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}}</translation>
</translationbundle>`;
const parser = new XtbTranslationParser(new Diagnostics());
const parser = new XtbTranslationParser();
const result = parser.parse('/some/file.xtb', XTB);
expect(result.translations['7717087045075616176'])
@ -90,7 +90,7 @@ describe('XtbTranslationParser', () => {
<translation id="i">toto</translation>
<translation id="bar">tata</translation>
</translationbundle>`;
const parser = new XtbTranslationParser(new Diagnostics());
const parser = new XtbTranslationParser();
const result = parser.parse('/some/file.xtb', XTB);
expect(result.translations[ɵcomputeMsgId('foo')]).toEqual(ɵmakeParsedTranslation(['oof']));
@ -103,7 +103,7 @@ describe('XtbTranslationParser', () => {
<translationbundle>
<translation id="7118057989405618448"><ph name="TAG_IMG_1"/><ph name="TAG_IMG"/><ph name="LINE_BREAK"/></translation>
</translationbundle>`;
const parser = new XtbTranslationParser(new Diagnostics());
const parser = new XtbTranslationParser();
const result = parser.parse('/some/file.xtb', XTB);
expect(result.translations[ɵcomputeMsgId('{$LINE_BREAK}{$TAG_IMG}{$TAG_IMG_1}')])
@ -123,7 +123,7 @@ describe('XtbTranslationParser', () => {
<translationbundle>
<translation id="2826198357052921524"></translation>
</translationbundle>`;
const parser = new XtbTranslationParser(new Diagnostics());
const parser = new XtbTranslationParser();
const result = parser.parse('/some/file.xtb', XTB);
expect(result.translations[ɵcomputeMsgId('hello {$START_TAG_SPAN}{$CLOSE_TAG_SPAN}')])
@ -150,7 +150,7 @@ describe('XtbTranslationParser', () => {
<translation id="980940425376233536">Le test: <ph name="ICU" equiv-text="{ count, plural, =0 {...} =other {...}}"/></translation>
<translation id="5207293143089349404">{VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {<ph name="START_PARAGRAPH"/>profondément imbriqué<ph name="CLOSE_PARAGRAPH"/>}}} =other {beaucoup}}</translation>
</translationbundle>`;
const parser = new XtbTranslationParser(new Diagnostics());
const parser = new XtbTranslationParser();
const result = parser.parse('/some/file.xtb', XTB);
expect(result.translations[ɵcomputeMsgId('Test: {$ICU}')])
@ -177,7 +177,7 @@ describe('XtbTranslationParser', () => {
<translationbundle>
<translation id="2340165783990709777">multi\nlignes</translation>
</translationbundle>`;
const parser = new XtbTranslationParser(new Diagnostics());
const parser = new XtbTranslationParser();
const result = parser.parse('/some/file.xtb', XTB);
expect(result.translations[ɵcomputeMsgId('multi\nlines')])
@ -194,8 +194,7 @@ describe('XtbTranslationParser', () => {
</translationbundle>`;
// Parsing the file should not fail
const diagnostics = new Diagnostics();
const parser = new XtbTranslationParser(diagnostics);
const parser = new XtbTranslationParser();
const result = parser.parse('/some/file.xtb', XTB);
// We should be able to read the valid message
@ -204,7 +203,7 @@ describe('XtbTranslationParser', () => {
// Trying to access the invalid message should fail
expect(result.translations['invalid']).toBeUndefined();
expect(diagnostics.messages).toContain({
expect(result.diagnostics.messages).toContain({
type: 'warning',
message:
`Could not parse message with id "invalid" - perhaps it has an unrecognised ICU format?\n` +
@ -219,9 +218,11 @@ describe('XtbTranslationParser', () => {
'<translationbundle><translationbundle></translationbundle></translationbundle>';
expect(() => {
const parser = new XtbTranslationParser(new Diagnostics());
const parser = new XtbTranslationParser();
parser.parse('/some/file.xtb', XTB);
}).toThrowError(/<translationbundle> elements can not be nested/);
}).toThrowError(`Failed to parse "/some/file.xtb" as XMB/XTB format
ERRORS:
- Unexpected <translationbundle> tag. ("<translationbundle>[ERROR ->]<translationbundle></translationbundle></translationbundle>"): /some/file.xtb@0:19`);
});
it('should throw when a translation has no id attribute', () => {
@ -231,7 +232,7 @@ describe('XtbTranslationParser', () => {
</translationbundle>`;
expect(() => {
const parser = new XtbTranslationParser(new Diagnostics());
const parser = new XtbTranslationParser();
parser.parse('/some/file.xtb', XTB);
}).toThrowError(/Missing required "id" attribute/);
});
@ -244,7 +245,7 @@ describe('XtbTranslationParser', () => {
</translationbundle>`;
expect(() => {
const parser = new XtbTranslationParser(new Diagnostics());
const parser = new XtbTranslationParser();
parser.parse('/some/file.xtb', XTB);
}).toThrowError(/Duplicated translations for message "deadbeef"/);
});
@ -260,7 +261,7 @@ describe('XtbTranslationParser', () => {
</translationbundle>`;
expect(() => {
const parser = new XtbTranslationParser(new Diagnostics());
const parser = new XtbTranslationParser();
parser.parse('/some/file.xtb', XTB);
}).toThrowError(/Invalid element found in message/);
});
@ -272,10 +273,344 @@ describe('XtbTranslationParser', () => {
</translationbundle>`;
expect(() => {
const parser = new XtbTranslationParser(new Diagnostics());
const parser = new XtbTranslationParser();
parser.parse('/some/file.xtb', XTB);
}).toThrowError(/required "name" attribute/gi);
});
});
});
describe('parse() [with hint]', () => {
it('should extract the locale from the file contents', () => {
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
<translationbundle lang='fr'>
<translation id="8841459487341224498">rab</translation>
</translationbundle>`;
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, hint);
expect(result.locale).toEqual('fr');
});
it('should extract basic messages', () => {
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE translationbundle [<!ELEMENT translationbundle (translation)*>
<!ATTLIST translationbundle lang CDATA #REQUIRED>
<!ELEMENT translation (#PCDATA|ph)*>
<!ATTLIST translation id CDATA #REQUIRED>
<!ELEMENT ph EMPTY>
<!ATTLIST ph name CDATA #REQUIRED>
]>
<translationbundle>
<translation id="8841459487341224498">rab</translation>
</translationbundle>`;
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, hint);
expect(result.translations['8841459487341224498']).toEqual(ɵmakeParsedTranslation(['rab']));
});
it('should extract translations with simple placeholders', () => {
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
<translationbundle>
<translation id="8877975308926375834"><ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/></translation>
</translationbundle>`;
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, hint);
expect(result.translations['8877975308926375834'])
.toEqual(ɵmakeParsedTranslation(['', 'rab', ''], ['START_PARAGRAPH', 'CLOSE_PARAGRAPH']));
});
it('should extract translations with simple ICU expressions', () => {
const XTB = `<?xml version="1.0" encoding="UTF-8" ?>
<translationbundle>
<translation id="7717087045075616176">*<ph name="ICU"/>*</translation>
<translation id="5115002811911870583">{VAR_PLURAL, plural, =1 {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}}</translation>
</translationbundle>`;
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, hint);
expect(result.translations['7717087045075616176'])
.toEqual(ɵmakeParsedTranslation(['*', '*'], ['ICU']));
expect(result.translations['5115002811911870583'])
.toEqual(ɵmakeParsedTranslation(
['{VAR_PLURAL, plural, =1 {{START_PARAGRAPH}rab{CLOSE_PARAGRAPH}}}'], []));
});
it('should extract translations with duplicate source messages', () => {
const XTB = `
<translationbundle>
<translation id="9205907420411818817">oof</translation>
<translation id="i">toto</translation>
<translation id="bar">tata</translation>
</translationbundle>`;
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, 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', () => {
const XTB = `
<translationbundle>
<translation id="7118057989405618448"><ph name="TAG_IMG_1"/><ph name="TAG_IMG"/><ph name="LINE_BREAK"/></translation>
</translationbundle>`;
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, 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 XTB = `
<translationbundle>
<translation id="2826198357052921524"></translation>
</translationbundle>`;
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, 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 XTB = `
<translationbundle>
<translation id="980940425376233536">Le test: <ph name="ICU" equiv-text="{ count, plural, =0 {...} =other {...}}"/></translation>
<translation id="5207293143089349404">{VAR_PLURAL, plural, =0 {{VAR_SELECT, select, other {<ph name="START_PARAGRAPH"/>profondément imbriqué<ph name="CLOSE_PARAGRAPH"/>}}} =other {beaucoup}}</translation>
</translationbundle>`;
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, 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 XTB = `
<translationbundle>
<translation id="2340165783990709777">multi\nlignes</translation>
</translationbundle>`;
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, hint);
expect(result.translations[ɵcomputeMsgId('multi\nlines')])
.toEqual(ɵmakeParsedTranslation(['multi\nlignes']));
});
it('should warn on unrecognised ICU messages', () => {
// See https://github.com/angular/angular/issues/14046
const XTB = `
<translationbundle>
<translation id="valid">This is a valid message</translation>
<translation id="invalid">{REGION_COUNT_1, plural, =0 {unused plural form} =1 {1 region} other {{REGION_COUNT_2} regions}}</translation>
</translationbundle>`;
// Parsing the file should not fail
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, hint);
// We should be able to read the valid message
expect(result.translations['valid'])
.toEqual(ɵmakeParsedTranslation(['This is a valid message']));
// Trying to access the invalid message should fail
expect(result.translations['invalid']).toBeUndefined();
expect(result.diagnostics.messages).toContain({
type: 'warning',
message:
`Could not parse message with id "invalid" - perhaps it has an unrecognised ICU format?\n` +
`Error: Unexpected character "EOF" (Do you have an unescaped "{" in your template? Use "{{ '{' }}") to escape it.)\n` +
`Error: Invalid ICU message. Missing '}'.`
});
});
describe('[structure errors]', () => {
it('should throw when there are nested translationbundle tags', () => {
const XTB =
'<translationbundle><translationbundle></translationbundle></translationbundle>';
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, hint);
expect(result.diagnostics.messages.length).toEqual(1);
expect(result.diagnostics.messages[0].message)
.toEqual(
`Unexpected <translationbundle> tag. ("<translationbundle>[ERROR ->]<translationbundle></translationbundle></translationbundle>"): /some/file.xtb@0:19`);
});
it('should throw when a translation has no id attribute', () => {
const XTB = `
<translationbundle>
<translation></translation>
</translationbundle>`;
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, hint);
expect(result.diagnostics.messages.length).toEqual(1);
expect(result.diagnostics.messages[0].message)
.toEqual(`Missing required "id" attribute on <trans-unit> element. ("
<translationbundle>
[ERROR ->]<translation></translation>
</translationbundle>"): /some/file.xtb@2:12`);
});
it('should throw on duplicate translation id', () => {
const XTB = `
<translationbundle>
<translation id="deadbeef"></translation>
<translation id="deadbeef"></translation>
</translationbundle>`;
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, hint);
expect(result.diagnostics.messages.length).toEqual(1);
expect(result.diagnostics.messages[0].message)
.toEqual(`Duplicated translations for message "deadbeef" ("
<translationbundle>
<translation id="deadbeef"></translation>
[ERROR ->]<translation id="deadbeef"></translation>
</translationbundle>"): /some/file.xtb@3:12`);
});
});
describe('[message errors]', () => {
it('should throw on unknown message tags', () => {
const XTB = `
<translationbundle>
<translation id="deadbeef">
<source/>
</translation>
</translationbundle>`;
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, hint);
expect(result.diagnostics.messages.length).toEqual(1);
expect(result.diagnostics.messages[0].message).toEqual(`Invalid element found in message. ("
<translationbundle>
<translation id="deadbeef">
[ERROR ->]<source/>
</translation>
</translationbundle>"): /some/file.xtb@3:14`);
});
it('should throw when a placeholder misses a name attribute', () => {
const XTB = `
<translationbundle>
<translation id="deadbeef"><ph/></translation>
</translationbundle>`;
const parser = new XtbTranslationParser();
const hint = parser.canParse('/some/file.xtb', XTB);
if (!hint) {
return fail('expected XTB to be valid');
}
const result = parser.parse('/some/file.xtb', XTB, hint);
expect(result.diagnostics.messages.length).toEqual(1);
expect(result.diagnostics.messages[0].message)
.toEqual(`Missing required "name" attribute: ("
<translationbundle>
<translation id="deadbeef">[ERROR ->]<ph/></translation>
</translationbundle>"): /some/file.xtb@2:39`);
});
});
});
});