fix(i18n): ICU placeholders are replaced by their translations (#10586)
They were replaced by the original message.
This commit is contained in:
parent
c7f3aa71fb
commit
43512aa5eb
|
@ -97,7 +97,7 @@ export function sha1(str: string): string {
|
||||||
hex += (b >>> 4 & 0x0f).toString(16) + (b & 0x0f).toString(16);
|
hex += (b >>> 4 & 0x0f).toString(16) + (b & 0x0f).toString(16);
|
||||||
}
|
}
|
||||||
|
|
||||||
return hex;
|
return hex.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function utf8Encode(str: string): string {
|
function utf8Encode(str: string): string {
|
||||||
|
|
|
@ -9,8 +9,17 @@
|
||||||
import {ParseSourceSpan} from '../parse_util';
|
import {ParseSourceSpan} from '../parse_util';
|
||||||
|
|
||||||
export class Message {
|
export class Message {
|
||||||
|
/**
|
||||||
|
* @param nodes message AST
|
||||||
|
* @param placeholders maps placeholder names to static content
|
||||||
|
* @param placeholderToMsgIds maps placeholder names to translatable message IDs (used for ICU
|
||||||
|
* messages)
|
||||||
|
* @param meaning
|
||||||
|
* @param description
|
||||||
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
public nodes: Node[], public placeholders: {[name: string]: string}, public meaning: string,
|
public nodes: Node[], public placeholders: {[name: string]: string},
|
||||||
|
public placeholderToMsgIds: {[name: string]: string}, public meaning: string,
|
||||||
public description: string) {}
|
public description: string) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ import * as html from '../ml_parser/ast';
|
||||||
import {getHtmlTagDefinition} from '../ml_parser/html_tags';
|
import {getHtmlTagDefinition} from '../ml_parser/html_tags';
|
||||||
import {InterpolationConfig} from '../ml_parser/interpolation_config';
|
import {InterpolationConfig} from '../ml_parser/interpolation_config';
|
||||||
import {ParseSourceSpan} from '../parse_util';
|
import {ParseSourceSpan} from '../parse_util';
|
||||||
|
import {digestMessage} from './digest';
|
||||||
|
|
||||||
import * as i18n from './i18n_ast';
|
import * as i18n from './i18n_ast';
|
||||||
import {PlaceholderRegistry} from './serializers/placeholder';
|
import {PlaceholderRegistry} from './serializers/placeholder';
|
||||||
|
@ -19,7 +20,7 @@ import {PlaceholderRegistry} from './serializers/placeholder';
|
||||||
const _expParser = new ExpressionParser(new ExpressionLexer());
|
const _expParser = new ExpressionParser(new ExpressionLexer());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a function converting html Messages to i18n Messages given an interpolationConfig
|
* Returns a function converting html nodes to an i18n Message given an interpolationConfig
|
||||||
*/
|
*/
|
||||||
export function createI18nMessageFactory(interpolationConfig: InterpolationConfig): (
|
export function createI18nMessageFactory(interpolationConfig: InterpolationConfig): (
|
||||||
nodes: html.Node[], meaning: string, description: string) => i18n.Message {
|
nodes: html.Node[], meaning: string, description: string) => i18n.Message {
|
||||||
|
@ -34,6 +35,7 @@ class _I18nVisitor implements html.Visitor {
|
||||||
private _icuDepth: number;
|
private _icuDepth: number;
|
||||||
private _placeholderRegistry: PlaceholderRegistry;
|
private _placeholderRegistry: PlaceholderRegistry;
|
||||||
private _placeholderToContent: {[name: string]: string};
|
private _placeholderToContent: {[name: string]: string};
|
||||||
|
private _placeholderToIds: {[name: string]: string};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _expressionParser: ExpressionParser,
|
private _expressionParser: ExpressionParser,
|
||||||
|
@ -44,10 +46,12 @@ class _I18nVisitor implements html.Visitor {
|
||||||
this._icuDepth = 0;
|
this._icuDepth = 0;
|
||||||
this._placeholderRegistry = new PlaceholderRegistry();
|
this._placeholderRegistry = new PlaceholderRegistry();
|
||||||
this._placeholderToContent = {};
|
this._placeholderToContent = {};
|
||||||
|
this._placeholderToIds = {};
|
||||||
|
|
||||||
const i18nodes: i18n.Node[] = html.visitAll(this, nodes, {});
|
const i18nodes: i18n.Node[] = html.visitAll(this, nodes, {});
|
||||||
|
|
||||||
return new i18n.Message(i18nodes, this._placeholderToContent, meaning, description);
|
return new i18n.Message(
|
||||||
|
i18nodes, this._placeholderToContent, this._placeholderToIds, meaning, description);
|
||||||
}
|
}
|
||||||
|
|
||||||
visitElement(el: html.Element, context: any): i18n.Node {
|
visitElement(el: html.Element, context: any): i18n.Node {
|
||||||
|
@ -99,9 +103,14 @@ class _I18nVisitor implements html.Visitor {
|
||||||
return i18nIcu;
|
return i18nIcu;
|
||||||
}
|
}
|
||||||
|
|
||||||
// else returns a placeholder
|
// Else returns a placeholder
|
||||||
|
// ICU placeholders should not be replaced with their original content but with the their
|
||||||
|
// translations. We need to create a new visitor (they are not re-entrant) to compute the
|
||||||
|
// message id.
|
||||||
|
// TODO(vicb): add a html.Node -> i18n.Message cache to avoid having to re-create the msg
|
||||||
const phName = this._placeholderRegistry.getPlaceholderName('ICU', icu.sourceSpan.toString());
|
const phName = this._placeholderRegistry.getPlaceholderName('ICU', icu.sourceSpan.toString());
|
||||||
this._placeholderToContent[phName] = icu.sourceSpan.toString();
|
const visitor = new _I18nVisitor(this._expressionParser, this._interpolationConfig);
|
||||||
|
this._placeholderToIds[phName] = digestMessage(visitor.toI18nMessage([icu], '', ''));
|
||||||
return new i18n.IcuPlaceholder(i18nIcu, phName, icu.sourceSpan);
|
return new i18n.IcuPlaceholder(i18nIcu, phName, icu.sourceSpan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -45,5 +45,7 @@ export class MessageBundle {
|
||||||
(message) => { this._messageMap[digestMessage(message)] = message; });
|
(message) => { this._messageMap[digestMessage(message)] = message; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMessageMap(): {[id: string]: Message} { return this._messageMap; }
|
||||||
|
|
||||||
write(serializer: Serializer): string { return serializer.write(this._messageMap); }
|
write(serializer: Serializer): string { return serializer.write(this._messageMap); }
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,10 +8,34 @@
|
||||||
|
|
||||||
import * as html from '../../ml_parser/ast';
|
import * as html from '../../ml_parser/ast';
|
||||||
import * as i18n from '../i18n_ast';
|
import * as i18n from '../i18n_ast';
|
||||||
|
import {MessageBundle} from '../message_bundle';
|
||||||
|
|
||||||
export interface Serializer {
|
export interface Serializer {
|
||||||
write(messageMap: {[id: string]: i18n.Message}): string;
|
write(messageMap: {[id: string]: i18n.Message}): string;
|
||||||
|
|
||||||
load(content: string, url: string, placeholders: {[id: string]: {[name: string]: string}}):
|
load(content: string, url: string, messageBundle: MessageBundle): {[id: string]: html.Node[]};
|
||||||
{[id: string]: html.Node[]};
|
}
|
||||||
|
|
||||||
|
// Generate a map of placeholder to content indexed by message ids
|
||||||
|
export function extractPlaceholders(messageBundle: MessageBundle) {
|
||||||
|
const messageMap = messageBundle.getMessageMap();
|
||||||
|
let placeholders: {[id: string]: {[name: string]: string}} = {};
|
||||||
|
|
||||||
|
Object.keys(messageMap).forEach(msgId => {
|
||||||
|
placeholders[msgId] = messageMap[msgId].placeholders;
|
||||||
|
});
|
||||||
|
|
||||||
|
return placeholders;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a map of placeholder to message ids indexed by message ids
|
||||||
|
export function extractPlaceholderToIds(messageBundle: MessageBundle) {
|
||||||
|
const messageMap = messageBundle.getMessageMap();
|
||||||
|
let placeholderToIds: {[id: string]: {[name: string]: string}} = {};
|
||||||
|
|
||||||
|
Object.keys(messageMap).forEach(msgId => {
|
||||||
|
placeholderToIds[msgId] = messageMap[msgId].placeholderToMsgIds;
|
||||||
|
});
|
||||||
|
|
||||||
|
return placeholderToIds;
|
||||||
}
|
}
|
|
@ -9,6 +9,7 @@
|
||||||
import {ListWrapper} from '../../facade/collection';
|
import {ListWrapper} from '../../facade/collection';
|
||||||
import * as html from '../../ml_parser/ast';
|
import * as html from '../../ml_parser/ast';
|
||||||
import * as i18n from '../i18n_ast';
|
import * as i18n from '../i18n_ast';
|
||||||
|
import {MessageBundle} from '../message_bundle';
|
||||||
|
|
||||||
import {Serializer} from './serializer';
|
import {Serializer} from './serializer';
|
||||||
import * as xml from './xml_helper';
|
import * as xml from './xml_helper';
|
||||||
|
@ -70,8 +71,7 @@ export class Xmb implements Serializer {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
load(content: string, url: string, placeholders: {[id: string]: {[name: string]: string}}):
|
load(content: string, url: string, messageBundle: MessageBundle): {[id: string]: html.Node[]} {
|
||||||
{[id: string]: html.Node[]} {
|
|
||||||
throw new Error('Unsupported');
|
throw new Error('Unsupported');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,9 +12,10 @@ import {InterpolationConfig} from '../../ml_parser/interpolation_config';
|
||||||
import {XmlParser} from '../../ml_parser/xml_parser';
|
import {XmlParser} from '../../ml_parser/xml_parser';
|
||||||
import {ParseError} from '../../parse_util';
|
import {ParseError} from '../../parse_util';
|
||||||
import * as i18n from '../i18n_ast';
|
import * as i18n from '../i18n_ast';
|
||||||
|
import {MessageBundle} from '../message_bundle';
|
||||||
import {I18nError} from '../parse_util';
|
import {I18nError} from '../parse_util';
|
||||||
|
|
||||||
import {Serializer} from './serializer';
|
import {Serializer, extractPlaceholderToIds, extractPlaceholders} from './serializer';
|
||||||
|
|
||||||
const _TRANSLATIONS_TAG = 'translationbundle';
|
const _TRANSLATIONS_TAG = 'translationbundle';
|
||||||
const _TRANSLATION_TAG = 'translation';
|
const _TRANSLATION_TAG = 'translation';
|
||||||
|
@ -25,8 +26,7 @@ export class Xtb implements Serializer {
|
||||||
|
|
||||||
write(messageMap: {[id: string]: i18n.Message}): string { throw new Error('Unsupported'); }
|
write(messageMap: {[id: string]: i18n.Message}): string { throw new Error('Unsupported'); }
|
||||||
|
|
||||||
load(content: string, url: string, placeholders: {[id: string]: {[name: string]: string}}):
|
load(content: string, url: string, messageBundle: MessageBundle): {[id: string]: ml.Node[]} {
|
||||||
{[id: string]: ml.Node[]} {
|
|
||||||
// Parse the xtb file into xml nodes
|
// Parse the xtb file into xml nodes
|
||||||
const result = new XmlParser().parse(content, url);
|
const result = new XmlParser().parse(content, url);
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ export class Xtb implements Serializer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace the placeholders, messages are now string
|
// Replace the placeholders, messages are now string
|
||||||
const {messages, errors} = new _Serializer().parse(result.rootNodes, placeholders);
|
const {messages, errors} = new _Serializer().parse(result.rootNodes, messageBundle);
|
||||||
|
|
||||||
if (errors.length) {
|
if (errors.length) {
|
||||||
throw new Error(`xtb parse errors:\n${errors.join('\n')}`);
|
throw new Error(`xtb parse errors:\n${errors.join('\n')}`);
|
||||||
|
@ -44,7 +44,7 @@ export class Xtb implements Serializer {
|
||||||
// Convert the string messages to html ast
|
// Convert the string messages to html ast
|
||||||
// TODO(vicb): map error message back to the original message in xtb
|
// TODO(vicb): map error message back to the original message in xtb
|
||||||
let messageMap: {[id: string]: ml.Node[]} = {};
|
let messageMap: {[id: string]: ml.Node[]} = {};
|
||||||
let parseErrors: ParseError[] = [];
|
const parseErrors: ParseError[] = [];
|
||||||
|
|
||||||
Object.keys(messages).forEach((id) => {
|
Object.keys(messages).forEach((id) => {
|
||||||
const res = this._htmlParser.parse(messages[id], url, true, this._interpolationConfig);
|
const res = this._htmlParser.parse(messages[id], url, true, this._interpolationConfig);
|
||||||
|
@ -61,24 +61,58 @@ export class Xtb implements Serializer {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Serializer implements ml.Visitor {
|
class _Serializer implements ml.Visitor {
|
||||||
private _messages: {[id: string]: string};
|
private _messageNodes: [string, ml.Node[]][];
|
||||||
|
private _translatedMessages: {[id: string]: string};
|
||||||
private _bundleDepth: number;
|
private _bundleDepth: number;
|
||||||
private _translationDepth: number;
|
private _translationDepth: number;
|
||||||
private _errors: I18nError[];
|
private _errors: I18nError[];
|
||||||
private _placeholders: {[id: string]: {[name: string]: string}};
|
private _placeholders: {[name: string]: string};
|
||||||
private _currentPlaceholders: {[name: string]: string};
|
private _placeholderToIds: {[name: string]: string};
|
||||||
|
|
||||||
parse(nodes: ml.Node[], _placeholders: {[id: string]: {[name: string]: string}}):
|
parse(nodes: ml.Node[], messageBundle: MessageBundle):
|
||||||
{messages: {[k: string]: string}, errors: I18nError[]} {
|
{messages: {[k: string]: string}, errors: I18nError[]} {
|
||||||
this._messages = {};
|
this._messageNodes = [];
|
||||||
|
this._translatedMessages = {};
|
||||||
this._bundleDepth = 0;
|
this._bundleDepth = 0;
|
||||||
this._translationDepth = 0;
|
this._translationDepth = 0;
|
||||||
this._errors = [];
|
this._errors = [];
|
||||||
this._placeholders = _placeholders;
|
|
||||||
|
|
||||||
|
// Find all messages
|
||||||
ml.visitAll(this, nodes, null);
|
ml.visitAll(this, nodes, null);
|
||||||
|
|
||||||
return {messages: this._messages, errors: this._errors};
|
const messageMap = messageBundle.getMessageMap();
|
||||||
|
const placeholders = extractPlaceholders(messageBundle);
|
||||||
|
const placeholderToIds = extractPlaceholderToIds(messageBundle);
|
||||||
|
|
||||||
|
this._messageNodes
|
||||||
|
.filter(message => {
|
||||||
|
// Remove any messages that is not present in the source message bundle.
|
||||||
|
return messageMap.hasOwnProperty(message[0]);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Because there could be no ICU placeholders inside an ICU message,
|
||||||
|
// we do not need to take into account the `placeholderToMsgIds` of the referenced
|
||||||
|
// messages, those would always be empty
|
||||||
|
// TODO(vicb): overkill - create 2 buckets and [...woDeps, ...wDeps].process()
|
||||||
|
if (Object.keys(messageMap[a[0]].placeholderToMsgIds).length == 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(messageMap[b[0]].placeholderToMsgIds).length == 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
})
|
||||||
|
.forEach(message => {
|
||||||
|
const id = message[0];
|
||||||
|
this._placeholders = placeholders[id] || {};
|
||||||
|
this._placeholderToIds = placeholderToIds[id] || {};
|
||||||
|
// TODO(vicb): make sure there is no `_TRANSLATIONS_TAG` nor `_TRANSLATION_TAG`
|
||||||
|
this._translatedMessages[id] = ml.visitAll(this, message[1]).join('');
|
||||||
|
});
|
||||||
|
|
||||||
|
return {messages: this._translatedMessages, errors: this._errors};
|
||||||
}
|
}
|
||||||
|
|
||||||
visitElement(element: ml.Element, context: any): any {
|
visitElement(element: ml.Element, context: any): any {
|
||||||
|
@ -101,8 +135,11 @@ class _Serializer implements ml.Visitor {
|
||||||
if (!idAttr) {
|
if (!idAttr) {
|
||||||
this._addError(element, `<${_TRANSLATION_TAG}> misses the "id" attribute`);
|
this._addError(element, `<${_TRANSLATION_TAG}> misses the "id" attribute`);
|
||||||
} else {
|
} else {
|
||||||
this._currentPlaceholders = this._placeholders[idAttr.value] || {};
|
// ICU placeholders are reference to other messages.
|
||||||
this._messages[idAttr.value] = ml.visitAll(this, element.children).join('');
|
// The referenced message might not have been decoded yet.
|
||||||
|
// We need to have all messages available to make sure deps are decoded first.
|
||||||
|
// TODO(vicb): report an error on duplicate id
|
||||||
|
this._messageNodes.push([idAttr.value, element.children]);
|
||||||
}
|
}
|
||||||
this._translationDepth--;
|
this._translationDepth--;
|
||||||
break;
|
break;
|
||||||
|
@ -112,11 +149,18 @@ class _Serializer implements ml.Visitor {
|
||||||
if (!nameAttr) {
|
if (!nameAttr) {
|
||||||
this._addError(element, `<${_PLACEHOLDER_TAG}> misses the "name" attribute`);
|
this._addError(element, `<${_PLACEHOLDER_TAG}> misses the "name" attribute`);
|
||||||
} else {
|
} else {
|
||||||
if (this._currentPlaceholders.hasOwnProperty(nameAttr.value)) {
|
const name = nameAttr.value;
|
||||||
return this._currentPlaceholders[nameAttr.value];
|
if (this._placeholders.hasOwnProperty(name)) {
|
||||||
|
return this._placeholders[name];
|
||||||
}
|
}
|
||||||
|
if (this._placeholderToIds.hasOwnProperty(name) &&
|
||||||
|
this._translatedMessages.hasOwnProperty(this._placeholderToIds[name])) {
|
||||||
|
return this._translatedMessages[this._placeholderToIds[name]];
|
||||||
|
}
|
||||||
|
// TODO(vicb): better error message for when
|
||||||
|
// !this._translatedMessages.hasOwnProperty(this._placeholderToIds[name])
|
||||||
this._addError(
|
this._addError(
|
||||||
element, `The placeholder "${nameAttr.value}" does not exists in the source message`);
|
element, `The placeholder "${name}" does not exists in the source message`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
|
@ -7,8 +7,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as html from '../ml_parser/ast';
|
import * as html from '../ml_parser/ast';
|
||||||
import {Serializer} from './serializers/serializer';
|
|
||||||
|
|
||||||
|
import {MessageBundle} from './message_bundle';
|
||||||
|
import {Serializer} from './serializers/serializer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A container for translated messages
|
* A container for translated messages
|
||||||
|
@ -16,10 +17,9 @@ import {Serializer} from './serializers/serializer';
|
||||||
export class TranslationBundle {
|
export class TranslationBundle {
|
||||||
constructor(private _messageMap: {[id: string]: html.Node[]} = {}) {}
|
constructor(private _messageMap: {[id: string]: html.Node[]} = {}) {}
|
||||||
|
|
||||||
static load(
|
static load(content: string, url: string, messageBundle: MessageBundle, serializer: Serializer):
|
||||||
content: string, url: string, placeholders: {[id: string]: {[name: string]: string}},
|
TranslationBundle {
|
||||||
serializer: Serializer): TranslationBundle {
|
return new TranslationBundle(serializer.load(content, url, messageBundle));
|
||||||
return new TranslationBundle(serializer.load(content, url, placeholders));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get(id: string): html.Node[] { return this._messageMap[id]; }
|
get(id: string): html.Node[] { return this._messageMap[id]; }
|
||||||
|
|
|
@ -272,12 +272,17 @@ export function main() {
|
||||||
[['{count, plural, =1 {[1]}}'], '', ''],
|
[['{count, plural, =1 {[1]}}'], '', ''],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(_humanizePlaceholders(html)).toEqual([
|
// ICU message placeholders are reference to translations.
|
||||||
'ICU={count, plural, =0 {0}}, ICU_1={count, plural, =1 {1}}',
|
// As such they have no static content but refs to message ids.
|
||||||
|
expect(_humanizePlaceholders(html)).toEqual(['', '', '', '']);
|
||||||
|
|
||||||
|
expect(_humanizePlaceholdersToIds(html)).toEqual([
|
||||||
|
'ICU=f0f76923009914f1b05f41042a5c7231b9496504, ICU_1=73693d1f78d0fc882f0bcbce4cb31a0aa1995cfe',
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -303,6 +308,17 @@ function _humanizePlaceholders(
|
||||||
// clang-format on
|
// clang-format on
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _humanizePlaceholdersToIds(
|
||||||
|
html: string, implicitTags: string[] = [],
|
||||||
|
implicitAttrs: {[k: string]: string[]} = {}): string[] {
|
||||||
|
// clang-format off
|
||||||
|
// https://github.com/angular/clang-format/issues/35
|
||||||
|
return _extractMessages(html, implicitTags, implicitAttrs).map(
|
||||||
|
msg => Object.keys(msg.placeholderToMsgIds).map(k => `${k}=${msg.placeholderToMsgIds[k]}`).join(', '));
|
||||||
|
// clang-format on
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function _extractMessages(
|
function _extractMessages(
|
||||||
html: string, implicitTags: string[] = [],
|
html: string, implicitTags: string[] = [],
|
||||||
implicitAttrs: {[k: string]: string[]} = {}): Message[] {
|
implicitAttrs: {[k: string]: string[]} = {}): Message[] {
|
||||||
|
|
|
@ -56,8 +56,8 @@ export function main(): void {
|
||||||
it('should throw when trying to load an xmb file', () => {
|
it('should throw when trying to load an xmb file', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
const serializer = new Xmb();
|
const serializer = new Xmb();
|
||||||
serializer.load(XMB, 'url', {});
|
serializer.load(XMB, 'url', null);
|
||||||
}).toThrow();
|
}).toThrowError(/Unsupported/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,10 +6,11 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Xtb} from '@angular/compiler/src/i18n/serializers/xtb';
|
|
||||||
import {escapeRegExp} from '@angular/core/src/facade/lang';
|
import {escapeRegExp} from '@angular/core/src/facade/lang';
|
||||||
import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal';
|
import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal';
|
||||||
|
|
||||||
|
import {MessageBundle} from '../../../src/i18n/message_bundle';
|
||||||
|
import {Xtb} from '../../../src/i18n/serializers/xtb';
|
||||||
import {HtmlParser} from '../../../src/ml_parser/html_parser';
|
import {HtmlParser} from '../../../src/ml_parser/html_parser';
|
||||||
import {DEFAULT_INTERPOLATION_CONFIG} from '../../../src/ml_parser/interpolation_config';
|
import {DEFAULT_INTERPOLATION_CONFIG} from '../../../src/ml_parser/interpolation_config';
|
||||||
import {serializeNodes} from '../../ml_parser/ast_serializer_spec';
|
import {serializeNodes} from '../../ml_parser/ast_serializer_spec';
|
||||||
|
@ -17,21 +18,28 @@ import {serializeNodes} from '../../ml_parser/ast_serializer_spec';
|
||||||
export function main(): void {
|
export function main(): void {
|
||||||
describe('XTB serializer', () => {
|
describe('XTB serializer', () => {
|
||||||
let serializer: Xtb;
|
let serializer: Xtb;
|
||||||
|
let htmlParser: HtmlParser;
|
||||||
|
|
||||||
function loadAsText(content: string, placeholders: {[id: string]: {[name: string]: string}}):
|
function loadAsText(template: string, xtb: string): {[id: string]: string} {
|
||||||
{[id: string]: string} {
|
let messageBundle = new MessageBundle(htmlParser, [], {});
|
||||||
const asAst = serializer.load(content, 'url', placeholders);
|
messageBundle.updateFromTemplate(template, 'url', DEFAULT_INTERPOLATION_CONFIG);
|
||||||
|
|
||||||
|
const asAst = serializer.load(xtb, 'url', messageBundle);
|
||||||
let asText: {[id: string]: string} = {};
|
let asText: {[id: string]: string} = {};
|
||||||
Object.keys(asAst).forEach(id => { asText[id] = serializeNodes(asAst[id]).join(''); });
|
Object.keys(asAst).forEach(id => { asText[id] = serializeNodes(asAst[id]).join(''); });
|
||||||
|
|
||||||
return asText;
|
return asText;
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => { serializer = new Xtb(new HtmlParser(), DEFAULT_INTERPOLATION_CONFIG); });
|
beforeEach(() => {
|
||||||
|
htmlParser = new HtmlParser();
|
||||||
|
serializer = new Xtb(htmlParser, DEFAULT_INTERPOLATION_CONFIG);
|
||||||
|
});
|
||||||
|
|
||||||
describe('load', () => {
|
describe('load', () => {
|
||||||
it('should load XTB files with a doctype', () => {
|
it('should load XTB files with a doctype', () => {
|
||||||
|
const HTML = `<div i18n>bar</div>`;
|
||||||
|
|
||||||
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
|
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE translationbundle [<!ELEMENT translationbundle (translation)*>
|
<!DOCTYPE translationbundle [<!ELEMENT translationbundle (translation)*>
|
||||||
<!ATTLIST translationbundle lang CDATA #REQUIRED>
|
<!ATTLIST translationbundle lang CDATA #REQUIRED>
|
||||||
|
@ -43,62 +51,75 @@ export function main(): void {
|
||||||
<!ATTLIST ph name CDATA #REQUIRED>
|
<!ATTLIST ph name CDATA #REQUIRED>
|
||||||
]>
|
]>
|
||||||
<translationbundle>
|
<translationbundle>
|
||||||
<translation id="foo">bar</translation>
|
<translation id="28a86c8a00ae573b2bac698d6609316dc7b4a226">rab</translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(loadAsText(XTB, {})).toEqual({foo: 'bar'});
|
expect(loadAsText(HTML, XTB)).toEqual({'28a86c8a00ae573b2bac698d6609316dc7b4a226': 'rab'});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load XTB files without placeholders', () => {
|
it('should load XTB files without placeholders', () => {
|
||||||
|
const HTML = `<div i18n>bar</div>`;
|
||||||
|
|
||||||
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
|
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<translationbundle>
|
<translationbundle>
|
||||||
<translation id="foo">bar</translation>
|
<translation id="28a86c8a00ae573b2bac698d6609316dc7b4a226">rab</translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(loadAsText(XTB, {})).toEqual({foo: 'bar'});
|
expect(loadAsText(HTML, XTB)).toEqual({'28a86c8a00ae573b2bac698d6609316dc7b4a226': 'rab'});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load XTB files with placeholders', () => {
|
it('should load XTB files with placeholders', () => {
|
||||||
|
const HTML = `<div i18n><p>bar</p></div>`;
|
||||||
|
|
||||||
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
|
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<translationbundle>
|
<translationbundle>
|
||||||
<translation id="foo">bar<ph name="PLACEHOLDER"/><ph name="PLACEHOLDER"/></translation>
|
<translation id="7de4d8ff1e42b7b31da6204074818236a9a5317f"><ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/></translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(loadAsText(XTB, {foo: {PLACEHOLDER: '!'}})).toEqual({foo: 'bar!!'});
|
expect(loadAsText(HTML, XTB)).toEqual({
|
||||||
|
'7de4d8ff1e42b7b31da6204074818236a9a5317f': '<p>rab</p>'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace ICU placeholders with their translations', () => {
|
||||||
|
const HTML = `<div i18n>-{ count, plural, =0 {<p>bar</p>}}-</div>`;
|
||||||
|
|
||||||
|
const XTB = `<? xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<translationbundle>
|
||||||
|
<translation id="eb404e202fed4846e25e7d9ac1fcb719fe4da257">*<ph name="ICU"/>*</translation>
|
||||||
|
<translation id="fc92b9b781194a02ab773129c8c5a7fc0735efd7">{ count, plural, =1 {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}}</translation>
|
||||||
|
</translationbundle>`;
|
||||||
|
|
||||||
|
expect(loadAsText(HTML, XTB)).toEqual({
|
||||||
|
'eb404e202fed4846e25e7d9ac1fcb719fe4da257': `*{ count, plural, =1 {<p>rab</p>}}*`,
|
||||||
|
'fc92b9b781194a02ab773129c8c5a7fc0735efd7': `{ count, plural, =1 {<p>rab</p>}}`,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load complex XTB files', () => {
|
it('should load complex XTB files', () => {
|
||||||
|
const HTML = `
|
||||||
|
<div i18n>foo <b>bar</b> {{ a + b }}</div>
|
||||||
|
<div i18n>{ count, plural, =0 {<p>bar</p>}}</div>
|
||||||
|
<div i18n="m|d">foo</div>
|
||||||
|
<div i18n>{ count, plural, =0 {{ sex, gender, other {<p>bar</p>}} }}</div>`;
|
||||||
|
|
||||||
const XTB = `<? xml version="1.0" encoding="UTF-8" ?>
|
const XTB = `<? xml version="1.0" encoding="UTF-8" ?>
|
||||||
<translationbundle>
|
<translationbundle>
|
||||||
<translation id="a">translatable element <ph name="START_BOLD_TEXT"><ex><b></ex></ph>with placeholders<ph name="CLOSE_BOLD_TEXT"><ex></b></ex></ph> <ph name="INTERPOLATION"/></translation>
|
<translation id="7103b4b13b616270a0044efade97d8b4f96f2ca6"><ph name="INTERPOLATION"/><ph name="START_BOLD_TEXT"/>rab<ph name="CLOSE_BOLD_TEXT"/> oof</translation>
|
||||||
<translation id="b">{ count, plural, =0 {<ph name="START_PARAGRAPH"><ex><p></ex></ph>test<ph name="CLOSE_PARAGRAPH"><ex></p></ex></ph>}}</translation>
|
<translation id="fc92b9b781194a02ab773129c8c5a7fc0735efd7">{ count, plural, =1 {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}}</translation>
|
||||||
<translation id="c" desc="d" meaning="m">foo</translation>
|
<translation id="db3e0a6a5a96481f60aec61d98c3eecddef5ac23">oof</translation>
|
||||||
<translation id="d">{ count, plural, =0 {{ sex, gender, other {<ph name="START_PARAGRAPH"><ex><p></ex></ph>deeply nested<ph name="CLOSE_PARAGRAPH"><ex></p></ex></ph>}} }}</translation>
|
<translation id="e3bf2d706c3da16ce05658e07f62f0519f7c561c">{ count, plural, =1 {{ sex, gender, male {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}} }}</translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
const PLACEHOLDERS = {
|
expect(loadAsText(HTML, XTB)).toEqual({
|
||||||
a: {
|
'7103b4b13b616270a0044efade97d8b4f96f2ca6': `{{ a + b }}<b>rab</b> oof`,
|
||||||
START_BOLD_TEXT: '<b>',
|
'fc92b9b781194a02ab773129c8c5a7fc0735efd7': `{ count, plural, =1 {<p>rab</p>}}`,
|
||||||
CLOSE_BOLD_TEXT: '</b>',
|
'db3e0a6a5a96481f60aec61d98c3eecddef5ac23': `oof`,
|
||||||
INTERPOLATION: '{{ a + b }}',
|
'e3bf2d706c3da16ce05658e07f62f0519f7c561c':
|
||||||
},
|
`{ count, plural, =1 {{ sex, gender, male {<p>rab</p>}} }}`,
|
||||||
b: {
|
|
||||||
START_PARAGRAPH: '<p translated=true>',
|
|
||||||
CLOSE_PARAGRAPH: '</p>',
|
|
||||||
},
|
|
||||||
d: {
|
|
||||||
START_PARAGRAPH: '<p>',
|
|
||||||
CLOSE_PARAGRAPH: '</p>',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(loadAsText(XTB, PLACEHOLDERS)).toEqual({
|
|
||||||
a: 'translatable element <b>with placeholders</b> {{ a + b }}',
|
|
||||||
b: '{ count, plural, =0 {<p translated="true">test</p>}}',
|
|
||||||
c: 'foo',
|
|
||||||
d: '{ count, plural, =0 {{ sex, gender, other {<p>deeply nested</p>}} }}',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('errors', () => {
|
describe('errors', () => {
|
||||||
|
@ -107,61 +128,54 @@ export function main(): void {
|
||||||
'<translationbundle><translationbundle></translationbundle></translationbundle>';
|
'<translationbundle><translationbundle></translationbundle></translationbundle>';
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
serializer.load(XTB, 'url', {});
|
loadAsText('', XTB);
|
||||||
}).toThrowError(/<translationbundle> elements can not be nested/);
|
}).toThrowError(/<translationbundle> elements can not be nested/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw on nested <translation>', () => {
|
|
||||||
const XTB = `<translationbundle>
|
|
||||||
<translation id="outer">
|
|
||||||
<translation id="inner">
|
|
||||||
</translation>
|
|
||||||
</translation>
|
|
||||||
</translationbundle>`;
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
serializer.load(XTB, 'url', {});
|
|
||||||
}).toThrowError(/<translation> elements can not be nested/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw when a <translation> has no id attribute', () => {
|
it('should throw when a <translation> has no id attribute', () => {
|
||||||
const XTB = `<translationbundle>
|
const XTB = `<translationbundle>
|
||||||
<translation></translation>
|
<translation></translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
serializer.load(XTB, 'url', {});
|
loadAsText('', XTB);
|
||||||
}).toThrowError(/<translation> misses the "id" attribute/);
|
}).toThrowError(/<translation> misses the "id" attribute/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw when a placeholder has no name attribute', () => {
|
it('should throw when a placeholder has no name attribute', () => {
|
||||||
|
const HTML = '<div i18n>give me a message</div>';
|
||||||
|
|
||||||
const XTB = `<translationbundle>
|
const XTB = `<translationbundle>
|
||||||
<translation id="fail"><ph /></translation>
|
<translation id="8de97c6a35252d9409dcaca0b8171c952740b28c"><ph /></translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(() => {
|
expect(() => { loadAsText(HTML, XTB); }).toThrowError(/<ph> misses the "name" attribute/);
|
||||||
serializer.load(XTB, 'url', {});
|
|
||||||
}).toThrowError(/<ph> misses the "name" attribute/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw when a placeholder is not present in the source message', () => {
|
it('should throw when a placeholder is not present in the source message', () => {
|
||||||
const XTB = `<translationbundle>
|
const HTML = `<div i18n>bar</div>`;
|
||||||
<translation id="fail"><ph name="UNKNOWN"/></translation>
|
|
||||||
|
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<translationbundle>
|
||||||
|
<translation id="28a86c8a00ae573b2bac698d6609316dc7b4a226"><ph name="UNKNOWN"/></translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
serializer.load(XTB, 'url', {});
|
loadAsText(HTML, XTB);
|
||||||
}).toThrowError(/The placeholder "UNKNOWN" does not exists in the source message/);
|
}).toThrowError(/The placeholder "UNKNOWN" does not exists in the source message/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw when the translation results in invalid html', () => {
|
it('should throw when the translation results in invalid html', () => {
|
||||||
const XTB = `<translationbundle>
|
const HTML = `<div i18n><p>bar</p></div>`;
|
||||||
<translation id="fail">foo<ph name="CLOSE_P"/>bar</translation>
|
|
||||||
|
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<translationbundle>
|
||||||
|
<translation id="7de4d8ff1e42b7b31da6204074818236a9a5317f">rab<ph name="CLOSE_PARAGRAPH"/></translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
serializer.load(XTB, 'url', {fail: {CLOSE_P: '</p>'}});
|
loadAsText(HTML, XTB);
|
||||||
}).toThrowError(/xtb parse errors:\nUnexpected closing tag "p"/);
|
}).toThrowError(/xtb parse errors:\nUnexpected closing tag "p"/);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -170,11 +184,11 @@ export function main(): void {
|
||||||
const XTB = `<what></what>`;
|
const XTB = `<what></what>`;
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
serializer.load(XTB, 'url', {});
|
loadAsText('', XTB);
|
||||||
}).toThrowError(new RegExp(escapeRegExp(`Unexpected tag ("[ERROR ->]<what></what>")`)));
|
}).toThrowError(new RegExp(escapeRegExp(`Unexpected tag ("[ERROR ->]<what></what>")`)));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw when trying to save an xmb file',
|
it('should throw when trying to save an xtb file',
|
||||||
() => { expect(() => { serializer.write({}); }).toThrow(); });
|
() => { expect(() => { serializer.write({}); }).toThrowError(/Unsupported/); });
|
||||||
});
|
});
|
||||||
}
|
}
|
Loading…
Reference in New Issue