fix(core): fix placeholders handling in i18n.
Prior to this commit, translations were built in the serializers. This could not work as a single translation can be used for different source messages having different placeholder content. Serializers do not try to replace the placeholders any more. Placeholders are replaced by the translation bundle and the source message is given as parameter so that the content of the placeholders is taken into account. Also XMB ids are now independent of the expression which is replaced by a placeholder in the extracted file. fixes #12512
This commit is contained in:
parent
ed5e98d0df
commit
76e4911e8b
|
@ -51,9 +51,8 @@ function extract(
|
||||||
case 'xliff':
|
case 'xliff':
|
||||||
case 'xlf':
|
case 'xlf':
|
||||||
default:
|
default:
|
||||||
const htmlParser = new compiler.I18NHtmlParser(new compiler.HtmlParser());
|
|
||||||
ext = 'xlf';
|
ext = 'xlf';
|
||||||
serializer = new compiler.Xliff(htmlParser, compiler.DEFAULT_INTERPOLATION_CONFIG);
|
serializer = new compiler.Xliff();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,9 @@ export function digest(message: i18n.Message): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decimalDigest(message: i18n.Message): string {
|
export function decimalDigest(message: i18n.Message): string {
|
||||||
return fingerprint(serializeNodes(message.nodes).join('') + `[${message.meaning}]`);
|
const visitor = new _SerializerIgnoreIcuExpVisitor();
|
||||||
|
const parts = message.nodes.map(a => a.visit(visitor, null));
|
||||||
|
return fingerprint(parts.join('') + `[${message.meaning}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -43,7 +45,7 @@ class _SerializerVisitor implements i18n.Visitor {
|
||||||
}
|
}
|
||||||
|
|
||||||
visitPlaceholder(ph: i18n.Placeholder, context: any): any {
|
visitPlaceholder(ph: i18n.Placeholder, context: any): any {
|
||||||
return `<ph name="${ph.name}">${ph.value}</ph>`;
|
return ph.value ? `<ph name="${ph.name}">${ph.value}</ph>` : `<ph name="${ph.name}"/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
visitIcuPlaceholder(ph: i18n.IcuPlaceholder, context?: any): any {
|
visitIcuPlaceholder(ph: i18n.IcuPlaceholder, context?: any): any {
|
||||||
|
@ -57,6 +59,21 @@ export function serializeNodes(nodes: i18n.Node[]): string[] {
|
||||||
return nodes.map(a => a.visit(serializerVisitor, null));
|
return nodes.map(a => a.visit(serializerVisitor, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the i18n ast to something xml-like in order to generate an UID.
|
||||||
|
*
|
||||||
|
* Ignore the ICU expressions so that message IDs stays identical if only the expression changes.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class _SerializerIgnoreIcuExpVisitor extends _SerializerVisitor {
|
||||||
|
visitIcu(icu: i18n.Icu, context: any): any {
|
||||||
|
let strCases = Object.keys(icu.cases).map((k: string) => `${k} {${icu.cases[k].visit(this)}}`);
|
||||||
|
// Do not take the expression into account
|
||||||
|
return `{${icu.type}, ${strCases.join(', ')}}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the SHA1 of the given string
|
* Compute the SHA1 of the given string
|
||||||
*
|
*
|
||||||
|
|
|
@ -10,7 +10,6 @@ import * as html from '../ml_parser/ast';
|
||||||
import {InterpolationConfig} from '../ml_parser/interpolation_config';
|
import {InterpolationConfig} from '../ml_parser/interpolation_config';
|
||||||
import {ParseTreeResult} from '../ml_parser/parser';
|
import {ParseTreeResult} from '../ml_parser/parser';
|
||||||
|
|
||||||
import {digest} from './digest';
|
|
||||||
import * as i18n from './i18n_ast';
|
import * as i18n from './i18n_ast';
|
||||||
import {createI18nMessageFactory} from './i18n_parser';
|
import {createI18nMessageFactory} from './i18n_parser';
|
||||||
import {I18nError} from './parse_util';
|
import {I18nError} from './parse_util';
|
||||||
|
|
|
@ -22,7 +22,10 @@ export class Message {
|
||||||
public description: string) {}
|
public description: string) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Node { visit(visitor: Visitor, context?: any): any; }
|
export interface Node {
|
||||||
|
sourceSpan: ParseSourceSpan;
|
||||||
|
visit(visitor: Visitor, context?: any): any;
|
||||||
|
}
|
||||||
|
|
||||||
export class Text implements Node {
|
export class Text implements Node {
|
||||||
constructor(public value: string, public sourceSpan: ParseSourceSpan) {}
|
constructor(public value: string, public sourceSpan: ParseSourceSpan) {}
|
||||||
|
@ -30,6 +33,7 @@ export class Text implements Node {
|
||||||
visit(visitor: Visitor, context?: any): any { return visitor.visitText(this, context); }
|
visit(visitor: Visitor, context?: any): any { return visitor.visitText(this, context); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(vicb): do we really need this node (vs an array) ?
|
||||||
export class Container implements Node {
|
export class Container implements Node {
|
||||||
constructor(public children: Node[], public sourceSpan: ParseSourceSpan) {}
|
constructor(public children: Node[], public sourceSpan: ParseSourceSpan) {}
|
||||||
|
|
||||||
|
@ -37,6 +41,7 @@ export class Container implements Node {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Icu implements Node {
|
export class Icu implements Node {
|
||||||
|
public expressionPlaceholder: string;
|
||||||
constructor(
|
constructor(
|
||||||
public expression: string, public type: string, public cases: {[k: string]: Node},
|
public expression: string, public type: string, public cases: {[k: string]: Node},
|
||||||
public sourceSpan: ParseSourceSpan) {}
|
public sourceSpan: ParseSourceSpan) {}
|
||||||
|
@ -54,13 +59,13 @@ export class TagPlaceholder implements Node {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Placeholder implements Node {
|
export class Placeholder implements Node {
|
||||||
constructor(public value: string, public name: string = '', public sourceSpan: ParseSourceSpan) {}
|
constructor(public value: string, public name: string, public sourceSpan: ParseSourceSpan) {}
|
||||||
|
|
||||||
visit(visitor: Visitor, context?: any): any { return visitor.visitPlaceholder(this, context); }
|
visit(visitor: Visitor, context?: any): any { return visitor.visitPlaceholder(this, context); }
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IcuPlaceholder implements Node {
|
export class IcuPlaceholder implements Node {
|
||||||
constructor(public value: Icu, public name: string = '', public sourceSpan: ParseSourceSpan) {}
|
constructor(public value: Icu, public name: string, public sourceSpan: ParseSourceSpan) {}
|
||||||
|
|
||||||
visit(visitor: Visitor, context?: any): any { return visitor.visitIcuPlaceholder(this, context); }
|
visit(visitor: Visitor, context?: any): any { return visitor.visitIcuPlaceholder(this, context); }
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@ import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../ml_parser/in
|
||||||
import {ParseTreeResult} from '../ml_parser/parser';
|
import {ParseTreeResult} from '../ml_parser/parser';
|
||||||
|
|
||||||
import {mergeTranslations} from './extractor_merger';
|
import {mergeTranslations} from './extractor_merger';
|
||||||
import {MessageBundle} from './message_bundle';
|
|
||||||
import {Serializer} from './serializers/serializer';
|
import {Serializer} from './serializers/serializer';
|
||||||
import {Xliff} from './serializers/xliff';
|
import {Xliff} from './serializers/xliff';
|
||||||
import {Xmb} from './serializers/xmb';
|
import {Xmb} from './serializers/xmb';
|
||||||
|
@ -41,32 +40,29 @@ export class I18NHtmlParser implements HtmlParser {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(vicb): add support for implicit tags / attributes
|
// TODO(vicb): add support for implicit tags / attributes
|
||||||
const messageBundle = new MessageBundle(this._htmlParser, [], {});
|
|
||||||
const errors = messageBundle.updateFromTemplate(source, url, interpolationConfig);
|
|
||||||
|
|
||||||
if (errors && errors.length) {
|
if (parseResult.errors.length) {
|
||||||
return new ParseTreeResult(parseResult.rootNodes, parseResult.errors.concat(errors));
|
return new ParseTreeResult(parseResult.rootNodes, parseResult.errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
const serializer = this._createSerializer(interpolationConfig);
|
const serializer = this._createSerializer();
|
||||||
const translationBundle =
|
const translationBundle = TranslationBundle.load(this._translations, url, serializer);
|
||||||
TranslationBundle.load(this._translations, url, messageBundle, serializer);
|
|
||||||
|
|
||||||
return mergeTranslations(parseResult.rootNodes, translationBundle, interpolationConfig, [], {});
|
return mergeTranslations(parseResult.rootNodes, translationBundle, interpolationConfig, [], {});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createSerializer(interpolationConfig: InterpolationConfig): Serializer {
|
private _createSerializer(): Serializer {
|
||||||
const format = (this._translationsFormat || 'xlf').toLowerCase();
|
const format = (this._translationsFormat || 'xlf').toLowerCase();
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'xmb':
|
case 'xmb':
|
||||||
return new Xmb();
|
return new Xmb();
|
||||||
case 'xtb':
|
case 'xtb':
|
||||||
return new Xtb(this._htmlParser, interpolationConfig);
|
return new Xtb();
|
||||||
case 'xliff':
|
case 'xliff':
|
||||||
case 'xlf':
|
case 'xlf':
|
||||||
default:
|
default:
|
||||||
return new Xliff(this._htmlParser, interpolationConfig);
|
return new Xliff();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -98,7 +98,13 @@ class _I18nVisitor implements html.Visitor {
|
||||||
this._icuDepth--;
|
this._icuDepth--;
|
||||||
|
|
||||||
if (this._isIcu || this._icuDepth > 0) {
|
if (this._isIcu || this._icuDepth > 0) {
|
||||||
// If the message (vs a part of the message) is an ICU message returns it
|
// Returns an ICU node when:
|
||||||
|
// - the message (vs a part of the message) is an ICU message, or
|
||||||
|
// - the ICU message is nested.
|
||||||
|
const expPh = this._placeholderRegistry.getUniquePlaceholder(`VAR_${icu.type}`);
|
||||||
|
i18nIcu.expressionPlaceholder = expPh;
|
||||||
|
this._placeholderToContent[expPh] = icu.switchValue;
|
||||||
|
|
||||||
return i18nIcu;
|
return i18nIcu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -95,6 +95,10 @@ export class PlaceholderRegistry {
|
||||||
return uniqueName;
|
return uniqueName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUniquePlaceholder(name: string): string {
|
||||||
|
return this._generateUniqueName(name.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
// Generate a hash for a tag - does not take attribute order into account
|
// Generate a hash for a tag - does not take attribute order into account
|
||||||
private _hashTag(tag: string, attrs: {[k: string]: string}, isVoid: boolean): string {
|
private _hashTag(tag: string, attrs: {[k: string]: string}, isVoid: boolean): string {
|
||||||
const start = `<${tag}`;
|
const start = `<${tag}`;
|
||||||
|
|
|
@ -6,14 +6,12 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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(messages: i18n.Message[]): string;
|
write(messages: i18n.Message[]): string;
|
||||||
|
|
||||||
load(content: string, url: string, messageBundle: MessageBundle): {[id: string]: html.Node[]};
|
load(content: string, url: string): {[msgId: string]: i18n.Node[]};
|
||||||
|
|
||||||
digest(message: i18n.Message): string;
|
digest(message: i18n.Message): string;
|
||||||
}
|
}
|
|
@ -7,13 +7,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as ml from '../../ml_parser/ast';
|
import * as ml from '../../ml_parser/ast';
|
||||||
import {HtmlParser} from '../../ml_parser/html_parser';
|
|
||||||
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 {digest} from '../digest';
|
import {digest} from '../digest';
|
||||||
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} from './serializer';
|
||||||
|
@ -24,6 +20,7 @@ const _XMLNS = 'urn:oasis:names:tc:xliff:document:1.2';
|
||||||
// TODO(vicb): make this a param (s/_/-/)
|
// TODO(vicb): make this a param (s/_/-/)
|
||||||
const _SOURCE_LANG = 'en';
|
const _SOURCE_LANG = 'en';
|
||||||
const _PLACEHOLDER_TAG = 'x';
|
const _PLACEHOLDER_TAG = 'x';
|
||||||
|
|
||||||
const _SOURCE_TAG = 'source';
|
const _SOURCE_TAG = 'source';
|
||||||
const _TARGET_TAG = 'target';
|
const _TARGET_TAG = 'target';
|
||||||
const _UNIT_TAG = 'trans-unit';
|
const _UNIT_TAG = 'trans-unit';
|
||||||
|
@ -31,8 +28,6 @@ const _UNIT_TAG = 'trans-unit';
|
||||||
// http://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html
|
// http://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html
|
||||||
// http://docs.oasis-open.org/xliff/v1.2/xliff-profile-html/xliff-profile-html-1.2.html
|
// http://docs.oasis-open.org/xliff/v1.2/xliff-profile-html/xliff-profile-html-1.2.html
|
||||||
export class Xliff implements Serializer {
|
export class Xliff implements Serializer {
|
||||||
constructor(private _htmlParser: HtmlParser, private _interpolationConfig: InterpolationConfig) {}
|
|
||||||
|
|
||||||
write(messages: i18n.Message[]): string {
|
write(messages: i18n.Message[]): string {
|
||||||
const visitor = new _WriteVisitor();
|
const visitor = new _WriteVisitor();
|
||||||
const visited: {[id: string]: boolean} = {};
|
const visited: {[id: string]: boolean} = {};
|
||||||
|
@ -80,37 +75,25 @@ export class Xliff implements Serializer {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
load(content: string, url: string, messageBundle: MessageBundle): {[id: string]: ml.Node[]} {
|
load(content: string, url: string): {[msgId: string]: i18n.Node[]} {
|
||||||
// Parse the xtb file into xml nodes
|
// xliff to xml nodes
|
||||||
const result = new XmlParser().parse(content, url);
|
const xliffParser = new XliffParser();
|
||||||
|
const {mlNodesByMsgId, errors} = xliffParser.parse(content, url);
|
||||||
|
|
||||||
if (result.errors.length) {
|
// xml nodes to i18n nodes
|
||||||
throw new Error(`xtb parse errors:\n${result.errors.join('\n')}`);
|
const i18nNodesByMsgId: {[msgId: string]: i18n.Node[]} = {};
|
||||||
}
|
const converter = new XmlToI18n();
|
||||||
|
Object.keys(mlNodesByMsgId).forEach(msgId => {
|
||||||
// Replace the placeholders, messages are now string
|
const {i18nNodes, errors: e} = converter.convert(mlNodesByMsgId[msgId]);
|
||||||
const {messages, errors} = new _LoadVisitor(this).parse(result.rootNodes, messageBundle);
|
errors.push(...e);
|
||||||
|
i18nNodesByMsgId[msgId] = i18nNodes;
|
||||||
if (errors.length) {
|
|
||||||
throw new Error(`xtb parse errors:\n${errors.join('\n')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert the string messages to html ast
|
|
||||||
// TODO(vicb): map error message back to the original message in xtb
|
|
||||||
const messageMap: {[id: string]: ml.Node[]} = {};
|
|
||||||
const parseErrors: ParseError[] = [];
|
|
||||||
|
|
||||||
Object.keys(messages).forEach((id) => {
|
|
||||||
const res = this._htmlParser.parse(messages[id], url, true, this._interpolationConfig);
|
|
||||||
parseErrors.push(...res.errors);
|
|
||||||
messageMap[id] = res.rootNodes;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (parseErrors.length) {
|
if (errors.length) {
|
||||||
throw new Error(`xtb parse errors:\n${parseErrors.join('\n')}`);
|
throw new Error(`xliff parse errors:\n${errors.join('\n')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return messageMap;
|
return i18nNodesByMsgId;
|
||||||
}
|
}
|
||||||
|
|
||||||
digest(message: i18n.Message): string { return digest(message); }
|
digest(message: i18n.Message): string { return digest(message); }
|
||||||
|
@ -173,74 +156,42 @@ class _WriteVisitor implements i18n.Visitor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(vicb): add error management (structure)
|
// TODO(vicb): add error management (structure)
|
||||||
// TODO(vicb): factorize (xtb) ?
|
// Extract messages as xml nodes from the xliff file
|
||||||
class _LoadVisitor implements ml.Visitor {
|
class XliffParser implements ml.Visitor {
|
||||||
private _messageNodes: [string, ml.Node[]][];
|
private _unitMlNodes: ml.Node[];
|
||||||
private _translatedMessages: {[id: string]: string};
|
|
||||||
private _msgId: string;
|
|
||||||
private _target: ml.Node[];
|
|
||||||
private _errors: I18nError[];
|
private _errors: I18nError[];
|
||||||
private _sourceMessage: i18n.Message;
|
private _mlNodesByMsgId: {[msgId: string]: ml.Node[]};
|
||||||
|
|
||||||
constructor(private _serializer: Serializer) {}
|
parse(xliff: string, url: string) {
|
||||||
|
this._unitMlNodes = [];
|
||||||
|
this._mlNodesByMsgId = {};
|
||||||
|
|
||||||
parse(nodes: ml.Node[], messageBundle: MessageBundle):
|
const xml = new XmlParser().parse(xliff, url, false);
|
||||||
{messages: {[k: string]: string}, errors: I18nError[]} {
|
|
||||||
this._messageNodes = [];
|
|
||||||
this._translatedMessages = {};
|
|
||||||
this._msgId = '';
|
|
||||||
this._target = [];
|
|
||||||
this._errors = [];
|
|
||||||
|
|
||||||
// Find all messages
|
this._errors = xml.errors;
|
||||||
ml.visitAll(this, nodes, null);
|
ml.visitAll(this, xml.rootNodes, null);
|
||||||
|
|
||||||
const messageMap: {[msgId: string]: i18n.Message} = {};
|
return {
|
||||||
messageBundle.getMessages().forEach(m => messageMap[this._serializer.digest(m)] = m);
|
mlNodesByMsgId: this._mlNodesByMsgId,
|
||||||
|
errors: this._errors,
|
||||||
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 placeholdersByMsgId 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]].placeholderToMessage).length == 0) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(messageMap[b[0]].placeholderToMessage).length == 0) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
})
|
|
||||||
.forEach(message => {
|
|
||||||
const msgId = message[0];
|
|
||||||
this._sourceMessage = messageMap[msgId];
|
|
||||||
// TODO(vicb): make sure there is no `_TRANSLATIONS_TAG` nor `_TRANSLATION_TAG`
|
|
||||||
this._translatedMessages[msgId] = 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 {
|
||||||
switch (element.name) {
|
switch (element.name) {
|
||||||
case _UNIT_TAG:
|
case _UNIT_TAG:
|
||||||
this._target = null;
|
this._unitMlNodes = null;
|
||||||
const msgId = element.attrs.find((attr) => attr.name === 'id');
|
const idAttr = element.attrs.find((attr) => attr.name === 'id');
|
||||||
if (!msgId) {
|
if (!idAttr) {
|
||||||
this._addError(element, `<${_UNIT_TAG}> misses the "id" attribute`);
|
this._addError(element, `<${_UNIT_TAG}> misses the "id" attribute`);
|
||||||
} else {
|
} else {
|
||||||
this._msgId = msgId.value;
|
const id = idAttr.value;
|
||||||
}
|
if (this._mlNodesByMsgId.hasOwnProperty(id)) {
|
||||||
ml.visitAll(this, element.children, null);
|
this._addError(element, `Duplicated translations for msg ${id}`);
|
||||||
if (this._msgId !== null) {
|
} else {
|
||||||
this._messageNodes.push([this._msgId, this._target]);
|
ml.visitAll(this, element.children, null);
|
||||||
|
this._mlNodesByMsgId[id] = this._unitMlNodes;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
@ -249,52 +200,65 @@ class _LoadVisitor implements ml.Visitor {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case _TARGET_TAG:
|
case _TARGET_TAG:
|
||||||
this._target = element.children;
|
this._unitMlNodes = element.children;
|
||||||
break;
|
|
||||||
|
|
||||||
case _PLACEHOLDER_TAG:
|
|
||||||
const idAttr = element.attrs.find((attr) => attr.name === 'id');
|
|
||||||
if (!idAttr) {
|
|
||||||
this._addError(element, `<${_PLACEHOLDER_TAG}> misses the "id" attribute`);
|
|
||||||
} else {
|
|
||||||
const phName = idAttr.value;
|
|
||||||
if (this._sourceMessage.placeholders.hasOwnProperty(phName)) {
|
|
||||||
return this._sourceMessage.placeholders[phName];
|
|
||||||
}
|
|
||||||
if (this._sourceMessage.placeholderToMessage.hasOwnProperty(phName)) {
|
|
||||||
const refMsg = this._sourceMessage.placeholderToMessage[phName];
|
|
||||||
const refMsgId = this._serializer.digest(refMsg);
|
|
||||||
if (this._translatedMessages.hasOwnProperty(refMsgId)) {
|
|
||||||
return this._translatedMessages[refMsgId];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO(vicb): better error message for when
|
|
||||||
// !this._translatedMessages.hasOwnProperty(this._placeholderToIds[id])
|
|
||||||
this._addError(
|
|
||||||
element, `The placeholder "${phName}" does not exists in the source message`);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
// TODO(vicb): assert file structure, xliff version
|
||||||
|
// For now only recurse on unhandled nodes
|
||||||
ml.visitAll(this, element.children, null);
|
ml.visitAll(this, element.children, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
visitAttribute(attribute: ml.Attribute, context: any): any {
|
visitAttribute(attribute: ml.Attribute, context: any): any {}
|
||||||
throw new Error('unreachable code');
|
|
||||||
|
visitText(text: ml.Text, context: any): any {}
|
||||||
|
|
||||||
|
visitComment(comment: ml.Comment, context: any): any {}
|
||||||
|
|
||||||
|
visitExpansion(expansion: ml.Expansion, context: any): any {}
|
||||||
|
|
||||||
|
visitExpansionCase(expansionCase: ml.ExpansionCase, context: any): any {}
|
||||||
|
|
||||||
|
private _addError(node: ml.Node, message: string): void {
|
||||||
|
this._errors.push(new I18nError(node.sourceSpan, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert ml nodes (xliff syntax) to i18n nodes
|
||||||
|
class XmlToI18n implements ml.Visitor {
|
||||||
|
private _errors: I18nError[];
|
||||||
|
|
||||||
|
convert(nodes: ml.Node[]) {
|
||||||
|
this._errors = [];
|
||||||
|
return {
|
||||||
|
i18nNodes: ml.visitAll(this, nodes),
|
||||||
|
errors: this._errors,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
visitText(text: ml.Text, context: any): any { return text.value; }
|
visitText(text: ml.Text, context: any) { return new i18n.Text(text.value, text.sourceSpan); }
|
||||||
|
|
||||||
visitComment(comment: ml.Comment, context: any): any { return ''; }
|
visitElement(el: ml.Element, context: any): i18n.Placeholder {
|
||||||
|
if (el.name === _PLACEHOLDER_TAG) {
|
||||||
|
const nameAttr = el.attrs.find((attr) => attr.name === 'id');
|
||||||
|
if (nameAttr) {
|
||||||
|
return new i18n.Placeholder('', nameAttr.value, el.sourceSpan);
|
||||||
|
}
|
||||||
|
|
||||||
visitExpansion(expansion: ml.Expansion, context: any): any {
|
this._addError(el, `<${_PLACEHOLDER_TAG}> misses the "id" attribute`);
|
||||||
throw new Error('unreachable code');
|
} else {
|
||||||
|
this._addError(el, `Unexpected tag`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
visitExpansionCase(expansionCase: ml.ExpansionCase, context: any): any {
|
visitExpansion(icu: ml.Expansion, context: any) {}
|
||||||
throw new Error('unreachable code');
|
|
||||||
}
|
visitExpansionCase(icuCase: ml.ExpansionCase, context: any): any {}
|
||||||
|
|
||||||
|
visitComment(comment: ml.Comment, context: any) {}
|
||||||
|
|
||||||
|
visitAttribute(attribute: ml.Attribute, context: any) {}
|
||||||
|
|
||||||
private _addError(node: ml.Node, message: string): void {
|
private _addError(node: ml.Node, message: string): void {
|
||||||
this._errors.push(new I18nError(node.sourceSpan, message));
|
this._errors.push(new I18nError(node.sourceSpan, message));
|
||||||
|
|
|
@ -6,10 +6,8 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as html from '../../ml_parser/ast';
|
|
||||||
import {decimalDigest} from '../digest';
|
import {decimalDigest} from '../digest';
|
||||||
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';
|
||||||
|
@ -78,7 +76,7 @@ export class Xmb implements Serializer {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
load(content: string, url: string, messageBundle: MessageBundle): {[id: string]: html.Node[]} {
|
load(content: string, url: string): {[msgId: string]: i18n.Node[]} {
|
||||||
throw new Error('Unsupported');
|
throw new Error('Unsupported');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,7 +93,7 @@ class _Visitor implements i18n.Visitor {
|
||||||
}
|
}
|
||||||
|
|
||||||
visitIcu(icu: i18n.Icu, context?: any): xml.Node[] {
|
visitIcu(icu: i18n.Icu, context?: any): xml.Node[] {
|
||||||
const nodes = [new xml.Text(`{${icu.expression}, ${icu.type}, `)];
|
const nodes = [new xml.Text(`{${icu.expressionPlaceholder}, ${icu.type}, `)];
|
||||||
|
|
||||||
Object.keys(icu.cases).forEach((c: string) => {
|
Object.keys(icu.cases).forEach((c: string) => {
|
||||||
nodes.push(new xml.Text(`${c} {`), ...icu.cases[c].visit(this), new xml.Text(`} `));
|
nodes.push(new xml.Text(`${c} {`), ...icu.cases[c].visit(this), new xml.Text(`} `));
|
||||||
|
|
|
@ -7,12 +7,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as ml from '../../ml_parser/ast';
|
import * as ml from '../../ml_parser/ast';
|
||||||
import {HtmlParser} from '../../ml_parser/html_parser';
|
|
||||||
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 * 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} from './serializer';
|
||||||
|
@ -23,102 +19,51 @@ const _TRANSLATION_TAG = 'translation';
|
||||||
const _PLACEHOLDER_TAG = 'ph';
|
const _PLACEHOLDER_TAG = 'ph';
|
||||||
|
|
||||||
export class Xtb implements Serializer {
|
export class Xtb implements Serializer {
|
||||||
constructor(private _htmlParser: HtmlParser, private _interpolationConfig: InterpolationConfig) {}
|
|
||||||
|
|
||||||
write(messages: i18n.Message[]): string { throw new Error('Unsupported'); }
|
write(messages: i18n.Message[]): string { throw new Error('Unsupported'); }
|
||||||
|
|
||||||
load(content: string, url: string, messageBundle: MessageBundle): {[id: string]: ml.Node[]} {
|
load(content: string, url: string): {[msgId: string]: i18n.Node[]} {
|
||||||
// Parse the xtb file into xml nodes
|
// xtb to xml nodes
|
||||||
const result = new XmlParser().parse(content, url, true);
|
const xtbParser = new XtbParser();
|
||||||
|
const {mlNodesByMsgId, errors} = xtbParser.parse(content, url);
|
||||||
|
|
||||||
if (result.errors.length) {
|
// xml nodes to i18n nodes
|
||||||
throw new Error(`xtb parse errors:\n${result.errors.join('\n')}`);
|
const i18nNodesByMsgId: {[msgId: string]: i18n.Node[]} = {};
|
||||||
}
|
const converter = new XmlToI18n();
|
||||||
|
Object.keys(mlNodesByMsgId).forEach(msgId => {
|
||||||
// Replace the placeholders, messages are now string
|
const {i18nNodes, errors: e} = converter.convert(mlNodesByMsgId[msgId]);
|
||||||
const {messages, errors} = new _Visitor(this).parse(result.rootNodes, messageBundle);
|
errors.push(...e);
|
||||||
|
i18nNodesByMsgId[msgId] = i18nNodes;
|
||||||
|
});
|
||||||
|
|
||||||
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')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the string messages to html ast
|
return i18nNodesByMsgId;
|
||||||
// TODO(vicb): map error message back to the original message in xtb
|
|
||||||
const messageMap: {[id: string]: ml.Node[]} = {};
|
|
||||||
const parseErrors: ParseError[] = [];
|
|
||||||
|
|
||||||
Object.keys(messages).forEach((msgId) => {
|
|
||||||
const res = this._htmlParser.parse(messages[msgId], url, true, this._interpolationConfig);
|
|
||||||
parseErrors.push(...res.errors);
|
|
||||||
messageMap[msgId] = res.rootNodes;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (parseErrors.length) {
|
|
||||||
throw new Error(`xtb parse errors:\n${parseErrors.join('\n')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return messageMap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
digest(message: i18n.Message): string {
|
digest(message: i18n.Message): string { return digest(message); }
|
||||||
// we must use the same digest as xmb
|
|
||||||
return digest(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Visitor implements ml.Visitor {
|
// Extract messages as xml nodes from the xtb file
|
||||||
private _messageNodes: [string, ml.Node[]][];
|
class XtbParser implements ml.Visitor {
|
||||||
private _translatedMessages: {[id: string]: string};
|
|
||||||
private _bundleDepth: number;
|
private _bundleDepth: number;
|
||||||
private _translationDepth: number;
|
|
||||||
private _errors: I18nError[];
|
private _errors: I18nError[];
|
||||||
private _sourceMessage: i18n.Message;
|
private _mlNodesByMsgId: {[msgId: string]: ml.Node[]};
|
||||||
|
|
||||||
constructor(private _serializer: Serializer) {}
|
parse(xtb: string, url: string) {
|
||||||
|
|
||||||
parse(nodes: ml.Node[], messageBundle: MessageBundle):
|
|
||||||
{messages: {[k: string]: string}, errors: I18nError[]} {
|
|
||||||
// Tuple [<message id>, [ml nodes]]
|
|
||||||
this._messageNodes = [];
|
|
||||||
this._translatedMessages = {};
|
|
||||||
this._bundleDepth = 0;
|
this._bundleDepth = 0;
|
||||||
this._translationDepth = 0;
|
this._mlNodesByMsgId = {};
|
||||||
this._errors = [];
|
|
||||||
|
|
||||||
// load all translations
|
const xml = new XmlParser().parse(xtb, url, true);
|
||||||
ml.visitAll(this, nodes, null);
|
|
||||||
|
|
||||||
const messageMap: {[msgId: string]: i18n.Message} = {};
|
this._errors = xml.errors;
|
||||||
messageBundle.getMessages().forEach(m => messageMap[this._serializer.digest(m)] = m);
|
ml.visitAll(this, xml.rootNodes);
|
||||||
|
|
||||||
this._messageNodes
|
return {
|
||||||
.filter(message => {
|
mlNodesByMsgId: this._mlNodesByMsgId,
|
||||||
// Remove any messages that is not present in the source message bundle.
|
errors: this._errors,
|
||||||
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]].placeholderToMessage).length == 0) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(messageMap[b[0]].placeholderToMessage).length == 0) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
})
|
|
||||||
.forEach(message => {
|
|
||||||
const msgId = message[0];
|
|
||||||
this._sourceMessage = messageMap[msgId];
|
|
||||||
// TODO(vicb): make sure there is no `_TRANSLATIONS_TAG` nor `_TRANSLATION_TAG`
|
|
||||||
this._translatedMessages[msgId] = 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 {
|
||||||
|
@ -133,43 +78,16 @@ class _Visitor implements ml.Visitor {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case _TRANSLATION_TAG:
|
case _TRANSLATION_TAG:
|
||||||
this._translationDepth++;
|
|
||||||
if (this._translationDepth > 1) {
|
|
||||||
this._addError(element, `<${_TRANSLATION_TAG}> elements can not be nested`);
|
|
||||||
}
|
|
||||||
const idAttr = element.attrs.find((attr) => attr.name === 'id');
|
const idAttr = element.attrs.find((attr) => attr.name === 'id');
|
||||||
if (!idAttr) {
|
if (!idAttr) {
|
||||||
this._addError(element, `<${_TRANSLATION_TAG}> misses the "id" attribute`);
|
this._addError(element, `<${_TRANSLATION_TAG}> misses the "id" attribute`);
|
||||||
} else {
|
} else {
|
||||||
// ICU placeholders are reference to other messages.
|
const id = idAttr.value;
|
||||||
// The referenced message might not have been decoded yet.
|
if (this._mlNodesByMsgId.hasOwnProperty(id)) {
|
||||||
// We need to have all messages available to make sure deps are decoded first.
|
this._addError(element, `Duplicated translations for msg ${id}`);
|
||||||
// TODO(vicb): report an error on duplicate id
|
} else {
|
||||||
this._messageNodes.push([idAttr.value, element.children]);
|
this._mlNodesByMsgId[id] = element.children;
|
||||||
}
|
|
||||||
this._translationDepth--;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case _PLACEHOLDER_TAG:
|
|
||||||
const nameAttr = element.attrs.find((attr) => attr.name === 'name');
|
|
||||||
if (!nameAttr) {
|
|
||||||
this._addError(element, `<${_PLACEHOLDER_TAG}> misses the "name" attribute`);
|
|
||||||
} else {
|
|
||||||
const phName = nameAttr.value;
|
|
||||||
if (this._sourceMessage.placeholders.hasOwnProperty(phName)) {
|
|
||||||
return this._sourceMessage.placeholders[phName];
|
|
||||||
}
|
}
|
||||||
if (this._sourceMessage.placeholderToMessage.hasOwnProperty(phName)) {
|
|
||||||
const refMsg = this._sourceMessage.placeholderToMessage[phName];
|
|
||||||
const refMsgId = this._serializer.digest(refMsg);
|
|
||||||
if (this._translatedMessages.hasOwnProperty(refMsgId)) {
|
|
||||||
return this._translatedMessages[refMsgId];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO(vicb): better error message for when
|
|
||||||
// !this._translatedMessages.hasOwnProperty(refMessageId)
|
|
||||||
this._addError(
|
|
||||||
element, `The placeholder "${phName}" does not exists in the source message`);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
@ -178,22 +96,68 @@ class _Visitor implements ml.Visitor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
visitAttribute(attribute: ml.Attribute, context: any): any {
|
visitAttribute(attribute: ml.Attribute, context: any): any {}
|
||||||
throw new Error('unreachable code');
|
|
||||||
}
|
|
||||||
|
|
||||||
visitText(text: ml.Text, context: any): any { return text.value; }
|
visitText(text: ml.Text, context: any): any {}
|
||||||
|
|
||||||
visitComment(comment: ml.Comment, context: any): any { return ''; }
|
visitComment(comment: ml.Comment, context: any): any {}
|
||||||
|
|
||||||
visitExpansion(expansion: ml.Expansion, context: any): any {
|
visitExpansion(expansion: ml.Expansion, context: any): any {}
|
||||||
const strCases = expansion.cases.map(c => c.visit(this, null));
|
|
||||||
return `{${expansion.switchValue}, ${expansion.type}, ${strCases.join(' ')}}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
visitExpansionCase(expansionCase: ml.ExpansionCase, context: any): any {
|
visitExpansionCase(expansionCase: ml.ExpansionCase, context: any): any {}
|
||||||
return `${expansionCase.value} {${ml.visitAll(this, expansionCase.expression, null).join('')}}`;
|
|
||||||
}
|
private _addError(node: ml.Node, message: string): void {
|
||||||
|
this._errors.push(new I18nError(node.sourceSpan, message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert ml nodes (xtb syntax) to i18n nodes
|
||||||
|
class XmlToI18n implements ml.Visitor {
|
||||||
|
private _errors: I18nError[];
|
||||||
|
|
||||||
|
convert(nodes: ml.Node[]) {
|
||||||
|
this._errors = [];
|
||||||
|
return {
|
||||||
|
i18nNodes: ml.visitAll(this, nodes),
|
||||||
|
errors: this._errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
visitText(text: ml.Text, context: any) { return new i18n.Text(text.value, text.sourceSpan); }
|
||||||
|
|
||||||
|
visitExpansion(icu: ml.Expansion, context: any) {
|
||||||
|
const caseMap: {[value: string]: i18n.Node} = {};
|
||||||
|
|
||||||
|
ml.visitAll(this, icu.cases).forEach(c => {
|
||||||
|
caseMap[c.value] = new i18n.Container(c.nodes, icu.sourceSpan);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new i18n.Icu(icu.switchValue, icu.type, caseMap, icu.sourceSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
visitExpansionCase(icuCase: ml.ExpansionCase, context: any): any {
|
||||||
|
return {
|
||||||
|
value: icuCase.value,
|
||||||
|
nodes: ml.visitAll(this, icuCase.expression),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
visitElement(el: ml.Element, context: any): i18n.Placeholder {
|
||||||
|
if (el.name === _PLACEHOLDER_TAG) {
|
||||||
|
const nameAttr = el.attrs.find((attr) => attr.name === 'name');
|
||||||
|
if (nameAttr) {
|
||||||
|
return new i18n.Placeholder('', nameAttr.value, el.sourceSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._addError(el, `<${_PLACEHOLDER_TAG}> misses the "name" attribute`);
|
||||||
|
} else {
|
||||||
|
this._addError(el, `Unexpected tag`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visitComment(comment: ml.Comment, context: any) {}
|
||||||
|
|
||||||
|
visitAttribute(attribute: ml.Attribute, context: any) {}
|
||||||
|
|
||||||
private _addError(node: ml.Node, message: string): void {
|
private _addError(node: ml.Node, message: string): void {
|
||||||
this._errors.push(new I18nError(node.sourceSpan, message));
|
this._errors.push(new I18nError(node.sourceSpan, message));
|
||||||
|
|
|
@ -7,27 +7,120 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as html from '../ml_parser/ast';
|
import * as html from '../ml_parser/ast';
|
||||||
|
import {HtmlParser} from '../ml_parser/html_parser';
|
||||||
|
|
||||||
import {Message} from './i18n_ast';
|
import * as i18n from './i18n_ast';
|
||||||
import {MessageBundle} from './message_bundle';
|
import {I18nError} from './parse_util';
|
||||||
import {Serializer} from './serializers/serializer';
|
import {Serializer} from './serializers/serializer';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A container for translated messages
|
* A container for translated messages
|
||||||
*/
|
*/
|
||||||
export class TranslationBundle {
|
export class TranslationBundle {
|
||||||
constructor(
|
private _i18nToHtml: I18nToHtmlVisitor;
|
||||||
private _messageMap: {[id: string]: html.Node[]} = {},
|
|
||||||
public digest: (m: Message) => string) {}
|
|
||||||
|
|
||||||
static load(content: string, url: string, messageBundle: MessageBundle, serializer: Serializer):
|
constructor(
|
||||||
TranslationBundle {
|
private _i18nNodesByMsgId: {[msgId: string]: i18n.Node[]} = {},
|
||||||
return new TranslationBundle(
|
public digest: (m: i18n.Message) => string) {
|
||||||
serializer.load(content, url, messageBundle), (m: Message) => serializer.digest(m));
|
this._i18nToHtml = new I18nToHtmlVisitor(_i18nNodesByMsgId, digest);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(message: Message): html.Node[] { return this._messageMap[this.digest(message)]; }
|
static load(content: string, url: string, serializer: Serializer): TranslationBundle {
|
||||||
|
const i18nNodesByMsgId = serializer.load(content, url);
|
||||||
|
const digestFn = (m: i18n.Message) => serializer.digest(m);
|
||||||
|
return new TranslationBundle(i18nNodesByMsgId, digestFn);
|
||||||
|
}
|
||||||
|
|
||||||
has(message: Message): boolean { return this.digest(message) in this._messageMap; }
|
get(srcMsg: i18n.Message): html.Node[] {
|
||||||
|
const html = this._i18nToHtml.convert(srcMsg);
|
||||||
|
|
||||||
|
if (html.errors.length) {
|
||||||
|
throw new Error(html.errors.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return html.nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(srcMsg: i18n.Message): boolean { return this.digest(srcMsg) in this._i18nNodesByMsgId; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class I18nToHtmlVisitor implements i18n.Visitor {
|
||||||
|
private _srcMsg: i18n.Message;
|
||||||
|
private _srcMsgStack: i18n.Message[] = [];
|
||||||
|
private _errors: I18nError[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private _i18nNodesByMsgId: {[msgId: string]: i18n.Node[]} = {},
|
||||||
|
private _digest: (m: i18n.Message) => string) {}
|
||||||
|
|
||||||
|
convert(srcMsg: i18n.Message): {nodes: html.Node[], errors: I18nError[]} {
|
||||||
|
this._srcMsgStack.length = 0;
|
||||||
|
this._errors.length = 0;
|
||||||
|
// i18n to text
|
||||||
|
const text = this._convertToText(srcMsg);
|
||||||
|
|
||||||
|
// text to html
|
||||||
|
const url = srcMsg.nodes[0].sourceSpan.start.file.url;
|
||||||
|
const html = new HtmlParser().parse(text, url, true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: html.rootNodes,
|
||||||
|
errors: [...this._errors, ...html.errors],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
visitText(text: i18n.Text, context?: any): string { return text.value; }
|
||||||
|
|
||||||
|
visitContainer(container: i18n.Container, context?: any): any {
|
||||||
|
return container.children.map(n => n.visit(this)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
visitIcu(icu: i18n.Icu, context?: any): any {
|
||||||
|
const cases = Object.keys(icu.cases).map(k => `${k} {${icu.cases[k].visit(this)}}`);
|
||||||
|
|
||||||
|
// TODO(vicb): Once all format switch to using expression placeholders
|
||||||
|
// we should throw when the placeholder is not in the source message
|
||||||
|
const exp = this._srcMsg.placeholders.hasOwnProperty(icu.expression) ?
|
||||||
|
this._srcMsg.placeholders[icu.expression] :
|
||||||
|
icu.expression;
|
||||||
|
|
||||||
|
return `{${exp}, ${icu.type}, ${cases.join(' ')}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
visitPlaceholder(ph: i18n.Placeholder, context?: any): string {
|
||||||
|
const phName = ph.name;
|
||||||
|
if (this._srcMsg.placeholders.hasOwnProperty(phName)) {
|
||||||
|
return this._srcMsg.placeholders[phName];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._srcMsg.placeholderToMessage.hasOwnProperty(phName)) {
|
||||||
|
return this._convertToText(this._srcMsg.placeholderToMessage[phName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._addError(ph, `Unknown placeholder`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
visitTagPlaceholder(ph: i18n.TagPlaceholder, context?: any): any { throw 'unreachable code'; }
|
||||||
|
|
||||||
|
visitIcuPlaceholder(ph: i18n.IcuPlaceholder, context?: any): any { throw 'unreachable code'; }
|
||||||
|
|
||||||
|
private _convertToText(srcMsg: i18n.Message): string {
|
||||||
|
const digest = this._digest(srcMsg);
|
||||||
|
if (this._i18nNodesByMsgId.hasOwnProperty(digest)) {
|
||||||
|
this._srcMsgStack.push(this._srcMsg);
|
||||||
|
this._srcMsg = srcMsg;
|
||||||
|
const nodes = this._i18nNodesByMsgId[digest];
|
||||||
|
const text = nodes.map(node => node.visit(this)).join('');
|
||||||
|
this._srcMsg = this._srcMsgStack.pop();
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._addError(srcMsg.nodes[0], `Missing translation for message ${digest}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private _addError(el: i18n.Node, msg: string) {
|
||||||
|
this._errors.push(new I18nError(el.sourceSpan, msg));
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -6,7 +6,7 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {isBlank, isPrimitive, isStrictStringMap} from './facade/lang';
|
import {isPrimitive, isStrictStringMap} from './facade/lang';
|
||||||
|
|
||||||
export const MODULE_SUFFIX = '';
|
export const MODULE_SUFFIX = '';
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@ export function visitValue(value: any, visitor: ValueVisitor, context: any): any
|
||||||
return visitor.visitStringMap(<{[key: string]: any}>value, context);
|
return visitor.visitStringMap(<{[key: string]: any}>value, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBlank(value) || isPrimitive(value)) {
|
if (value == null || isPrimitive(value)) {
|
||||||
return visitor.visitPrimitive(value, context);
|
return visitor.visitPrimitive(value, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,8 +6,6 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {describe, expect, it} from '@angular/core/testing/testing_internal';
|
|
||||||
|
|
||||||
import {fingerprint, sha1} from '../../src/i18n/digest';
|
import {fingerprint, sha1} from '../../src/i18n/digest';
|
||||||
|
|
||||||
export function main(): void {
|
export function main(): void {
|
||||||
|
|
|
@ -7,7 +7,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {DEFAULT_INTERPOLATION_CONFIG, HtmlParser} from '@angular/compiler';
|
import {DEFAULT_INTERPOLATION_CONFIG, HtmlParser} from '@angular/compiler';
|
||||||
import {describe, expect, it} from '@angular/core/testing/testing_internal';
|
|
||||||
|
|
||||||
import {digest, serializeNodes as serializeI18nNodes} from '../../src/i18n/digest';
|
import {digest, serializeNodes as serializeI18nNodes} from '../../src/i18n/digest';
|
||||||
import {extractMessages, mergeTranslations} from '../../src/i18n/extractor_merger';
|
import {extractMessages, mergeTranslations} from '../../src/i18n/extractor_merger';
|
||||||
|
@ -93,9 +92,10 @@ export function main() {
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
'text',
|
'text', '<ph tag name="START_PARAGRAPH">html, <ph tag' +
|
||||||
'<ph tag name="START_PARAGRAPH">html, <ph tag name="START_BOLD_TEXT">nested</ph name="CLOSE_BOLD_TEXT"></ph name="CLOSE_PARAGRAPH">',
|
' name="START_BOLD_TEXT">nested</ph name="CLOSE_BOLD_TEXT"></ph name="CLOSE_PARAGRAPH">',
|
||||||
'<ph icu name="ICU">{count, plural, =0 {[<ph tag name="START_TAG_SPAN">html</ph name="CLOSE_TAG_SPAN">]}}</ph>',
|
'<ph icu name="ICU">{count, plural, =0 {[<ph tag' +
|
||||||
|
' name="START_TAG_SPAN">html</ph name="CLOSE_TAG_SPAN">]}}</ph>',
|
||||||
'[<ph name="INTERPOLATION">interp</ph>]'
|
'[<ph name="INTERPOLATION">interp</ph>]'
|
||||||
],
|
],
|
||||||
'', ''
|
'', ''
|
||||||
|
@ -189,9 +189,8 @@ export function main() {
|
||||||
it('should extract from attributes in translatable elements', () => {
|
it('should extract from attributes in translatable elements', () => {
|
||||||
expect(extract('<div i18n><p><b i18n-title="m|d" title="msg"></b></p></div>')).toEqual([
|
expect(extract('<div i18n><p><b i18n-title="m|d" title="msg"></b></p></div>')).toEqual([
|
||||||
[
|
[
|
||||||
[
|
['<ph tag name="START_PARAGRAPH"><ph tag name="START_BOLD_TEXT"></ph' +
|
||||||
'<ph tag name="START_PARAGRAPH"><ph tag name="START_BOLD_TEXT"></ph name="CLOSE_BOLD_TEXT"></ph name="CLOSE_PARAGRAPH">'
|
' name="CLOSE_BOLD_TEXT"></ph name="CLOSE_PARAGRAPH">'],
|
||||||
],
|
|
||||||
'', ''
|
'', ''
|
||||||
],
|
],
|
||||||
[['msg'], 'm', 'd'],
|
[['msg'], 'm', 'd'],
|
||||||
|
@ -203,9 +202,8 @@ export function main() {
|
||||||
.toEqual([
|
.toEqual([
|
||||||
[['msg'], 'm', 'd'],
|
[['msg'], 'm', 'd'],
|
||||||
[
|
[
|
||||||
[
|
['<ph tag name="START_PARAGRAPH"><ph tag name="START_BOLD_TEXT"></ph' +
|
||||||
'<ph tag name="START_PARAGRAPH"><ph tag name="START_BOLD_TEXT"></ph name="CLOSE_BOLD_TEXT"></ph name="CLOSE_PARAGRAPH">'
|
' name="CLOSE_BOLD_TEXT"></ph name="CLOSE_PARAGRAPH">'],
|
||||||
],
|
|
||||||
'', ''
|
'', ''
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
@ -219,7 +217,8 @@ export function main() {
|
||||||
[['msg'], 'm', 'd'],
|
[['msg'], 'm', 'd'],
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
'{count, plural, =0 {[<ph tag name="START_PARAGRAPH"><ph tag name="START_BOLD_TEXT"></ph name="CLOSE_BOLD_TEXT"></ph name="CLOSE_PARAGRAPH">]}}'
|
'{count, plural, =0 {[<ph tag name="START_PARAGRAPH"><ph tag' +
|
||||||
|
' name="START_BOLD_TEXT"></ph name="CLOSE_BOLD_TEXT"></ph name="CLOSE_PARAGRAPH">]}}'
|
||||||
],
|
],
|
||||||
'', ''
|
'', ''
|
||||||
],
|
],
|
||||||
|
@ -350,7 +349,9 @@ export function main() {
|
||||||
const HTML = `before<!-- i18n --><p>foo</p><span><i>bar</i></span><!-- /i18n -->after`;
|
const HTML = `before<!-- i18n --><p>foo</p><span><i>bar</i></span><!-- /i18n -->after`;
|
||||||
expect(fakeTranslate(HTML))
|
expect(fakeTranslate(HTML))
|
||||||
.toEqual(
|
.toEqual(
|
||||||
'before**<ph tag name="START_PARAGRAPH">foo</ph name="CLOSE_PARAGRAPH"><ph tag name="START_TAG_SPAN"><ph tag name="START_ITALIC_TEXT">bar</ph name="CLOSE_ITALIC_TEXT"></ph name="CLOSE_TAG_SPAN">**after');
|
'before**[ph tag name="START_PARAGRAPH">foo[/ph name="CLOSE_PARAGRAPH">[ph tag' +
|
||||||
|
' name="START_TAG_SPAN">[ph tag name="START_ITALIC_TEXT">bar[/ph' +
|
||||||
|
' name="CLOSE_ITALIC_TEXT">[/ph name="CLOSE_TAG_SPAN">**after');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should merge nested blocks', () => {
|
it('should merge nested blocks', () => {
|
||||||
|
@ -358,7 +359,9 @@ export function main() {
|
||||||
`<div>before<!-- i18n --><p>foo</p><span><i>bar</i></span><!-- /i18n -->after</div>`;
|
`<div>before<!-- i18n --><p>foo</p><span><i>bar</i></span><!-- /i18n -->after</div>`;
|
||||||
expect(fakeTranslate(HTML))
|
expect(fakeTranslate(HTML))
|
||||||
.toEqual(
|
.toEqual(
|
||||||
'<div>before**<ph tag name="START_PARAGRAPH">foo</ph name="CLOSE_PARAGRAPH"><ph tag name="START_TAG_SPAN"><ph tag name="START_ITALIC_TEXT">bar</ph name="CLOSE_ITALIC_TEXT"></ph name="CLOSE_TAG_SPAN">**after</div>');
|
'<div>before**[ph tag name="START_PARAGRAPH">foo[/ph name="CLOSE_PARAGRAPH">[ph' +
|
||||||
|
' tag name="START_TAG_SPAN">[ph tag name="START_ITALIC_TEXT">bar[/ph' +
|
||||||
|
' name="CLOSE_ITALIC_TEXT">[/ph name="CLOSE_TAG_SPAN">**after</div>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -399,12 +402,12 @@ function fakeTranslate(
|
||||||
extractMessages(htmlNodes, DEFAULT_INTERPOLATION_CONFIG, implicitTags, implicitAttrs)
|
extractMessages(htmlNodes, DEFAULT_INTERPOLATION_CONFIG, implicitTags, implicitAttrs)
|
||||||
.messages;
|
.messages;
|
||||||
|
|
||||||
const i18nMsgMap: {[id: string]: html.Node[]} = {};
|
const i18nMsgMap: {[id: string]: i18n.Node[]} = {};
|
||||||
|
|
||||||
messages.forEach(message => {
|
messages.forEach(message => {
|
||||||
const id = digest(message);
|
const id = digest(message);
|
||||||
const text = serializeI18nNodes(message.nodes).join('');
|
const text = serializeI18nNodes(message.nodes).join('').replace(/</g, '[');
|
||||||
i18nMsgMap[id] = [new html.Text(`**${text}**`, null)];
|
i18nMsgMap[id] = [new i18n.Text(`**${text}**`, null)];
|
||||||
});
|
});
|
||||||
|
|
||||||
const translations = new TranslationBundle(i18nMsgMap, digest);
|
const translations = new TranslationBundle(i18nMsgMap, digest);
|
||||||
|
|
|
@ -9,7 +9,6 @@
|
||||||
import {digest} from '@angular/compiler/src/i18n/digest';
|
import {digest} from '@angular/compiler/src/i18n/digest';
|
||||||
import {extractMessages} from '@angular/compiler/src/i18n/extractor_merger';
|
import {extractMessages} from '@angular/compiler/src/i18n/extractor_merger';
|
||||||
import {Message} from '@angular/compiler/src/i18n/i18n_ast';
|
import {Message} from '@angular/compiler/src/i18n/i18n_ast';
|
||||||
import {describe, expect, it} from '@angular/core/testing/testing_internal';
|
|
||||||
|
|
||||||
import {serializeNodes} from '../../src/i18n/digest';
|
import {serializeNodes} from '../../src/i18n/digest';
|
||||||
import {HtmlParser} from '../../src/ml_parser/html_parser';
|
import {HtmlParser} from '../../src/ml_parser/html_parser';
|
||||||
|
@ -273,9 +272,12 @@ export function main() {
|
||||||
[['{count, plural, =1 {[1]}}'], '', ''],
|
[['{count, plural, =1 {[1]}}'], '', ''],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ICU message placeholders are reference to translations.
|
expect(_humanizePlaceholders(html)).toEqual([
|
||||||
// As such they have no static content but refs to message ids.
|
'',
|
||||||
expect(_humanizePlaceholders(html)).toEqual(['', '', '', '']);
|
'VAR_PLURAL=count',
|
||||||
|
'VAR_PLURAL=count',
|
||||||
|
'VAR_PLURAL=count',
|
||||||
|
]);
|
||||||
|
|
||||||
expect(_humanizePlaceholdersToMessage(html)).toEqual([
|
expect(_humanizePlaceholdersToMessage(html)).toEqual([
|
||||||
'ICU=f0f76923009914f1b05f41042a5c7231b9496504, ICU_1=73693d1f78d0fc882f0bcbce4cb31a0aa1995cfe',
|
'ICU=f0f76923009914f1b05f41042a5c7231b9496504, ICU_1=73693d1f78d0fc882f0bcbce4cb31a0aa1995cfe',
|
||||||
|
|
|
@ -43,6 +43,9 @@ export function main() {
|
||||||
expectHtml(el, '#i18n-2').toBe('<div id="i18n-2"><p>imbriqué</p></div>');
|
expectHtml(el, '#i18n-2').toBe('<div id="i18n-2"><p>imbriqué</p></div>');
|
||||||
expectHtml(el, '#i18n-3')
|
expectHtml(el, '#i18n-3')
|
||||||
.toBe('<div id="i18n-3"><p><i>avec des espaces réservés</i></p></div>');
|
.toBe('<div id="i18n-3"><p><i>avec des espaces réservés</i></p></div>');
|
||||||
|
expectHtml(el, '#i18n-3b')
|
||||||
|
.toBe(
|
||||||
|
'<div id="i18n-3b"><p><i class="preserved-on-placeholders">avec des espaces réservés</i></p></div>');
|
||||||
expectHtml(el, '#i18n-4')
|
expectHtml(el, '#i18n-4')
|
||||||
.toBe('<p id="i18n-4" title="sur des balises non traductibles"></p>');
|
.toBe('<p id="i18n-4" title="sur des balises non traductibles"></p>');
|
||||||
expectHtml(el, '#i18n-5').toBe('<p id="i18n-5" title="sur des balises traductibles"></p>');
|
expectHtml(el, '#i18n-5').toBe('<p id="i18n-5" title="sur des balises traductibles"></p>');
|
||||||
|
@ -66,8 +69,10 @@ export function main() {
|
||||||
expect(el.query(By.css('#i18n-14')).nativeElement).toHaveText('beaucoup');
|
expect(el.query(By.css('#i18n-14')).nativeElement).toHaveText('beaucoup');
|
||||||
|
|
||||||
cmp.sex = 'm';
|
cmp.sex = 'm';
|
||||||
|
cmp.sexB = 'f';
|
||||||
tb.detectChanges();
|
tb.detectChanges();
|
||||||
expect(el.query(By.css('#i18n-8')).nativeElement).toHaveText('homme');
|
expect(el.query(By.css('#i18n-8')).nativeElement).toHaveText('homme');
|
||||||
|
expect(el.query(By.css('#i18n-8b')).nativeElement).toHaveText('femme');
|
||||||
cmp.sex = 'f';
|
cmp.sex = 'f';
|
||||||
tb.detectChanges();
|
tb.detectChanges();
|
||||||
expect(el.query(By.css('#i18n-8')).nativeElement).toHaveText('femme');
|
expect(el.query(By.css('#i18n-8')).nativeElement).toHaveText('femme');
|
||||||
|
@ -106,6 +111,7 @@ function expectHtml(el: DebugElement, cssSelector: string): any {
|
||||||
<div id="i18n-2"><p i18n="different meaning|">nested</p></div>
|
<div id="i18n-2"><p i18n="different meaning|">nested</p></div>
|
||||||
|
|
||||||
<div id="i18n-3"><p i18n><i>with placeholders</i></p></div>
|
<div id="i18n-3"><p i18n><i>with placeholders</i></p></div>
|
||||||
|
<div id="i18n-3b"><p i18n><i class="preserved-on-placeholders">with placeholders</i></p></div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p id="i18n-4" i18n-title title="on not translatable node"></p>
|
<p id="i18n-4" i18n-title title="on not translatable node"></p>
|
||||||
|
@ -119,6 +125,9 @@ function expectHtml(el: DebugElement, cssSelector: string): any {
|
||||||
<div i18n id="i18n-8">
|
<div i18n id="i18n-8">
|
||||||
{sex, select, m {male} f {female}}
|
{sex, select, m {male} f {female}}
|
||||||
</div>
|
</div>
|
||||||
|
<div i18n id="i18n-8b">
|
||||||
|
{sexB, select, m {male} f {female}}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div i18n id="i18n-9">{{ "count = " + count }}</div>
|
<div i18n id="i18n-9">{{ "count = " + count }}</div>
|
||||||
<div i18n id="i18n-10">sex = {{ sex }}</div>
|
<div i18n id="i18n-10">sex = {{ sex }}</div>
|
||||||
|
@ -135,8 +144,9 @@ function expectHtml(el: DebugElement, cssSelector: string): any {
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
class I18nComponent {
|
class I18nComponent {
|
||||||
count: number = 0;
|
count: number;
|
||||||
sex: string = 'm';
|
sex: string;
|
||||||
|
sexB: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class FrLocalization extends NgLocalization {
|
class FrLocalization extends NgLocalization {
|
||||||
|
@ -159,14 +169,14 @@ const XTB = `
|
||||||
<translation id="7210334813789040330"><ph name="START_ITALIC_TEXT"/>avec des espaces réservés<ph name="CLOSE_ITALIC_TEXT"/></translation>
|
<translation id="7210334813789040330"><ph name="START_ITALIC_TEXT"/>avec des espaces réservés<ph name="CLOSE_ITALIC_TEXT"/></translation>
|
||||||
<translation id="4769680004784140786">sur des balises non traductibles</translation>
|
<translation id="4769680004784140786">sur des balises non traductibles</translation>
|
||||||
<translation id="4033143013932333681">sur des balises traductibles</translation>
|
<translation id="4033143013932333681">sur des balises traductibles</translation>
|
||||||
<translation id="6304278477201429103">{count, plural, =0 {zero} =1 {un} =2 {deux} other {<ph name="START_BOLD_TEXT"/>beaucoup<ph name="CLOSE_BOLD_TEXT"/>}}</translation>
|
<translation id="6162642997206060264">{VAR_PLURAL, plural, =0 {zero} =1 {un} =2 {deux} other {<ph name="START_BOLD_TEXT"/>beaucoup<ph name="CLOSE_BOLD_TEXT"/>}}</translation>
|
||||||
<translation id="7235359853951837339"><ph name="ICU"/></translation>
|
<translation id="1882489820012923152"><ph name="ICU"/></translation>
|
||||||
<translation id="3159329131322704158">{sex, select, m {homme} f {femme}}</translation>
|
<translation id="4822972059757846302">{VAR_SELECT, select, m {homme} f {femme}}</translation>
|
||||||
<translation id="5917557396782931034"><ph name="INTERPOLATION"/></translation>
|
<translation id="5917557396782931034"><ph name="INTERPOLATION"/></translation>
|
||||||
<translation id="4687596778889597732">sexe = <ph name="INTERPOLATION"/></translation>
|
<translation id="4687596778889597732">sexe = <ph name="INTERPOLATION"/></translation>
|
||||||
<translation id="2505882222003102347"><ph name="CUSTOM_NAME"/></translation>
|
<translation id="2505882222003102347"><ph name="CUSTOM_NAME"/></translation>
|
||||||
<translation id="5340176214595489533">dans une section traductible</translation>
|
<translation id="5340176214595489533">dans une section traductible</translation>
|
||||||
<translation id="8173674801943621225">
|
<translation id="4120782520649528473">
|
||||||
<ph name="START_HEADING_LEVEL1"/>Balises dans les commentaires html<ph name="CLOSE_HEADING_LEVEL1"/>
|
<ph name="START_HEADING_LEVEL1"/>Balises dans les commentaires html<ph name="CLOSE_HEADING_LEVEL1"/>
|
||||||
<ph name="START_TAG_DIV"/><ph name="CLOSE_TAG_DIV"/>
|
<ph name="START_TAG_DIV"/><ph name="CLOSE_TAG_DIV"/>
|
||||||
<ph name="START_TAG_DIV_1"/><ph name="ICU"/><ph name="CLOSE_TAG_DIV"></ph>
|
<ph name="START_TAG_DIV_1"/><ph name="ICU"/><ph name="CLOSE_TAG_DIV"></ph>
|
||||||
|
@ -185,16 +195,16 @@ const XMB = `
|
||||||
<msg id="7210334813789040330"><ph name="START_ITALIC_TEXT"><ex><i></ex></ph>with placeholders<ph name="CLOSE_ITALIC_TEXT"><ex></i></ex></ph></msg>
|
<msg id="7210334813789040330"><ph name="START_ITALIC_TEXT"><ex><i></ex></ph>with placeholders<ph name="CLOSE_ITALIC_TEXT"><ex></i></ex></ph></msg>
|
||||||
<msg id="4769680004784140786">on not translatable node</msg>
|
<msg id="4769680004784140786">on not translatable node</msg>
|
||||||
<msg id="4033143013932333681">on translatable node</msg>
|
<msg id="4033143013932333681">on translatable node</msg>
|
||||||
<msg id="6304278477201429103">{count, plural, =0 {zero} =1 {one} =2 {two} other {<ph name="START_BOLD_TEXT"><ex><b></ex></ph>many<ph name="CLOSE_BOLD_TEXT"><ex></b></ex></ph>} }</msg>
|
<msg id="6162642997206060264">{VAR_PLURAL, plural, =0 {zero} =1 {one} =2 {two} other {<ph name="START_BOLD_TEXT"><ex><b></ex></ph>many<ph name="CLOSE_BOLD_TEXT"><ex></b></ex></ph>} }</msg>
|
||||||
<msg id="7235359853951837339">
|
<msg id="1882489820012923152">
|
||||||
<ph name="ICU"/>
|
<ph name="ICU"/>
|
||||||
</msg>
|
</msg>
|
||||||
<msg id="3159329131322704158">{sex, select, m {male} f {female} }</msg>
|
<msg id="4822972059757846302">{VAR_SELECT, select, m {male} f {female} }</msg>
|
||||||
<msg id="5917557396782931034"><ph name="INTERPOLATION"/></msg>
|
<msg id="5917557396782931034"><ph name="INTERPOLATION"/></msg>
|
||||||
<msg id="4687596778889597732">sex = <ph name="INTERPOLATION"/></msg>
|
<msg id="4687596778889597732">sex = <ph name="INTERPOLATION"/></msg>
|
||||||
<msg id="2505882222003102347"><ph name="CUSTOM_NAME"/></msg>
|
<msg id="2505882222003102347"><ph name="CUSTOM_NAME"/></msg>
|
||||||
<msg id="5340176214595489533">in a translatable section</msg>
|
<msg id="5340176214595489533">in a translatable section</msg>
|
||||||
<msg id="8173674801943621225">
|
<msg id="4120782520649528473">
|
||||||
<ph name="START_HEADING_LEVEL1"><ex><h1></ex></ph>Markers in html comments<ph name="CLOSE_HEADING_LEVEL1"><ex></h1></ex></ph>
|
<ph name="START_HEADING_LEVEL1"><ex><h1></ex></ph>Markers in html comments<ph name="CLOSE_HEADING_LEVEL1"><ex></h1></ex></ph>
|
||||||
<ph name="START_TAG_DIV"><ex><div></ex></ph><ph name="CLOSE_TAG_DIV"><ex></div></ex></ph>
|
<ph name="START_TAG_DIV"><ex><div></ex></ph><ph name="CLOSE_TAG_DIV"><ex></div></ex></ph>
|
||||||
<ph name="START_TAG_DIV_1"><ex><div></ex></ph><ph name="ICU"/><ph name="CLOSE_TAG_DIV"><ex></div></ex></ph>
|
<ph name="START_TAG_DIV_1"><ex><div></ex></ph><ph name="ICU"/><ph name="CLOSE_TAG_DIV"><ex></div></ex></ph>
|
||||||
|
|
|
@ -6,12 +6,10 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as i18n from '@angular/compiler/src/i18n/i18n_ast';
|
|
||||||
import {Serializer} from '@angular/compiler/src/i18n/serializers/serializer';
|
|
||||||
import {beforeEach, describe, expect, it} from '@angular/core/testing/testing_internal';
|
|
||||||
|
|
||||||
import {serializeNodes} from '../../src/i18n/digest';
|
import {serializeNodes} from '../../src/i18n/digest';
|
||||||
|
import * as i18n from '../../src/i18n/i18n_ast';
|
||||||
import {MessageBundle} from '../../src/i18n/message_bundle';
|
import {MessageBundle} from '../../src/i18n/message_bundle';
|
||||||
|
import {Serializer} from '../../src/i18n/serializers/serializer';
|
||||||
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';
|
||||||
|
|
||||||
|
@ -50,7 +48,7 @@ class _TestSerializer implements Serializer {
|
||||||
.join('//');
|
.join('//');
|
||||||
}
|
}
|
||||||
|
|
||||||
load(content: string, url: string, placeholders: {}): {} { return null; }
|
load(content: string, url: string): {} { return null; }
|
||||||
|
|
||||||
digest(msg: i18n.Message): string { return 'unused'; }
|
digest(msg: i18n.Message): string { return 'unused'; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,6 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {beforeEach, describe, expect, it} from '@angular/core/testing/testing_internal';
|
|
||||||
|
|
||||||
import {PlaceholderRegistry} from '../../../src/i18n/serializers/placeholder';
|
import {PlaceholderRegistry} from '../../../src/i18n/serializers/placeholder';
|
||||||
|
|
||||||
export function main(): void {
|
export function main(): void {
|
||||||
|
|
|
@ -6,12 +6,13 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Xliff} from '@angular/compiler/src/i18n/serializers/xliff';
|
import {escapeRegExp} from '@angular/core/src/facade/lang';
|
||||||
import {beforeEach, describe, expect, it} from '@angular/core/testing/testing_internal';
|
|
||||||
|
import {serializeNodes} from '../../../src/i18n/digest';
|
||||||
import {MessageBundle} from '../../../src/i18n/message_bundle';
|
import {MessageBundle} from '../../../src/i18n/message_bundle';
|
||||||
|
import {Xliff} from '../../../src/i18n/serializers/xliff';
|
||||||
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';
|
|
||||||
|
|
||||||
const HTML = `
|
const HTML = `
|
||||||
<p i18n-title title="translatable attribute">not translatable</p>
|
<p i18n-title title="translatable attribute">not translatable</p>
|
||||||
|
@ -77,8 +78,7 @@ const LOAD_XLIFF = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function main(): void {
|
export function main(): void {
|
||||||
let serializer: Xliff;
|
const serializer = new Xliff();
|
||||||
let htmlParser: HtmlParser;
|
|
||||||
|
|
||||||
function toXliff(html: string): string {
|
function toXliff(html: string): string {
|
||||||
const catalog = new MessageBundle(new HtmlParser, [], {});
|
const catalog = new MessageBundle(new HtmlParser, [], {});
|
||||||
|
@ -86,37 +86,89 @@ export function main(): void {
|
||||||
return catalog.write(serializer);
|
return catalog.write(serializer);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadAsText(template: string, xliff: string): {[id: string]: string} {
|
function loadAsMap(xliff: string): {[id: string]: string} {
|
||||||
const messageBundle = new MessageBundle(htmlParser, [], {});
|
const i18nNodesByMsgId = serializer.load(xliff, 'url');
|
||||||
messageBundle.updateFromTemplate(template, 'url', DEFAULT_INTERPOLATION_CONFIG);
|
const msgMap: {[id: string]: string} = {};
|
||||||
|
Object.keys(i18nNodesByMsgId)
|
||||||
|
.forEach(id => msgMap[id] = serializeNodes(i18nNodesByMsgId[id]).join(''));
|
||||||
|
|
||||||
const asAst = serializer.load(xliff, 'url', messageBundle);
|
return msgMap;
|
||||||
const asText: {[id: string]: string} = {};
|
|
||||||
Object.keys(asAst).forEach(id => { asText[id] = serializeNodes(asAst[id]).join(''); });
|
|
||||||
|
|
||||||
return asText;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('XLIFF serializer', () => {
|
describe('XLIFF serializer', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
htmlParser = new HtmlParser();
|
|
||||||
serializer = new Xliff(htmlParser, DEFAULT_INTERPOLATION_CONFIG);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
describe('write', () => {
|
describe('write', () => {
|
||||||
it('should write a valid xliff file', () => { expect(toXliff(HTML)).toEqual(WRITE_XLIFF); });
|
it('should write a valid xliff file', () => { expect(toXliff(HTML)).toEqual(WRITE_XLIFF); });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('load', () => {
|
describe('load', () => {
|
||||||
it('should load XLIFF files', () => {
|
it('should load XLIFF files', () => {
|
||||||
expect(loadAsText(HTML, LOAD_XLIFF)).toEqual({
|
expect(loadAsMap(LOAD_XLIFF)).toEqual({
|
||||||
'983775b9a51ce14b036be72d4cfd65d68d64e231': 'etubirtta elbatalsnart',
|
'983775b9a51ce14b036be72d4cfd65d68d64e231': 'etubirtta elbatalsnart',
|
||||||
'ec1d033f2436133c14ab038286c4f5df4697484a':
|
'ec1d033f2436133c14ab038286c4f5df4697484a':
|
||||||
'{{ interpolation}} footnemele elbatalsnart <b>sredlohecalp htiw</b>',
|
'<ph name="INTERPOLATION"/> footnemele elbatalsnart <ph name="START_BOLD_TEXT"/>sredlohecalp htiw<ph name="CLOSE_BOLD_TEXT"/>',
|
||||||
'db3e0a6a5a96481f60aec61d98c3eecddef5ac23': 'oof',
|
'db3e0a6a5a96481f60aec61d98c3eecddef5ac23': 'oof',
|
||||||
'd7fa2d59aaedcaa5309f13028c59af8c85b8c49d': '<div></div><img/><br/>',
|
'd7fa2d59aaedcaa5309f13028c59af8c85b8c49d':
|
||||||
|
'<ph name="START_TAG_DIV"/><ph name="CLOSE_TAG_DIV"/><ph name="TAG_IMG"/><ph name="LINE_BREAK"/>',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('errors', () => {
|
||||||
|
it('should throw when a placeholder has no id attribute', () => {
|
||||||
|
const XLIFF = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<file source-language="en" datatype="plaintext" original="ng2.template">
|
||||||
|
<body>
|
||||||
|
<trans-unit datatype="html">
|
||||||
|
<source/>
|
||||||
|
<target/>
|
||||||
|
</trans-unit>
|
||||||
|
</body>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
loadAsMap(XLIFF);
|
||||||
|
}).toThrowError(/<trans-unit> misses the "id" attribute/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on unknown message tags', () => {
|
||||||
|
const XLIFF = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<file source-language="en" datatype="plaintext" original="ng2.template">
|
||||||
|
<body>
|
||||||
|
<trans-unit id="deadbeef" datatype="html">
|
||||||
|
<source/>
|
||||||
|
<target><b>msg should contain only ph tags</b></target>
|
||||||
|
</trans-unit>
|
||||||
|
</body>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
|
||||||
|
expect(() => { loadAsMap(XLIFF); })
|
||||||
|
.toThrowError(
|
||||||
|
new RegExp(escapeRegExp(`[ERROR ->]<b>msg should contain only ph tags</b>`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when a placeholder has no name attribute', () => {
|
||||||
|
const XLIFF = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<file source-language="en" datatype="plaintext" original="ng2.template">
|
||||||
|
<body>
|
||||||
|
<trans-unit id="deadbeef">
|
||||||
|
<source/>
|
||||||
|
<target/>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="deadbeef">
|
||||||
|
<source/>
|
||||||
|
<target/>
|
||||||
|
</trans-unit>
|
||||||
|
</body>
|
||||||
|
</file>
|
||||||
|
</xliff>`;
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
loadAsMap(XLIFF);
|
||||||
|
}).toThrowError(/Duplicated translations for msg deadbeef/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -44,9 +44,9 @@ export function main(): void {
|
||||||
]>
|
]>
|
||||||
<messagebundle>
|
<messagebundle>
|
||||||
<msg id="2348600990161399314">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"/></msg>
|
<msg id="2348600990161399314">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"/></msg>
|
||||||
<msg id="8332678508949127113">{ count, plural, =0 {<ph name="START_PARAGRAPH"><ex><p></ex></ph>test<ph name="CLOSE_PARAGRAPH"><ex></p></ex></ph>} }</msg>
|
<msg id="5525949440406338075">{VAR_PLURAL, plural, =0 {<ph name="START_PARAGRAPH"><ex><p></ex></ph>test<ph name="CLOSE_PARAGRAPH"><ex></p></ex></ph>} }</msg>
|
||||||
<msg id="130772889486467622" desc="d" meaning="m">foo</msg>
|
<msg id="130772889486467622" desc="d" meaning="m">foo</msg>
|
||||||
<msg id="5848862331224404557">{ count, plural, =0 {{ sex, gender, other {<ph name="START_PARAGRAPH"><ex><p></ex></ph>deeply nested<ph name="CLOSE_PARAGRAPH"><ex></p></ex></ph>} } } }</msg>
|
<msg id="9095788995532341072">{VAR_PLURAL, plural, =0 {{VAR_GENDER, gender, other {<ph name="START_PARAGRAPH"><ex><p></ex></ph>deeply nested<ph name="CLOSE_PARAGRAPH"><ex></p></ex></ph>} } } }</msg>
|
||||||
</messagebundle>
|
</messagebundle>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -55,7 +55,7 @@ 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', null);
|
serializer.load(XMB, 'url');
|
||||||
}).toThrowError(/Unsupported/);
|
}).toThrowError(/Unsupported/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,8 +6,6 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {describe, expect, it} from '@angular/core/testing/testing_internal';
|
|
||||||
|
|
||||||
import * as xml from '../../../src/i18n/serializers/xml_helper';
|
import * as xml from '../../../src/i18n/serializers/xml_helper';
|
||||||
|
|
||||||
export function main(): void {
|
export function main(): void {
|
||||||
|
|
|
@ -8,37 +8,24 @@
|
||||||
|
|
||||||
import {escapeRegExp} from '@angular/core/src/facade/lang';
|
import {escapeRegExp} from '@angular/core/src/facade/lang';
|
||||||
|
|
||||||
import {MessageBundle} from '../../../src/i18n/message_bundle';
|
import {serializeNodes} from '../../../src/i18n/digest';
|
||||||
import {Xtb} from '../../../src/i18n/serializers/xtb';
|
import {Xtb} from '../../../src/i18n/serializers/xtb';
|
||||||
import {HtmlParser} from '../../../src/ml_parser/html_parser';
|
|
||||||
import {DEFAULT_INTERPOLATION_CONFIG} from '../../../src/ml_parser/interpolation_config';
|
|
||||||
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;
|
const serializer = new Xtb();
|
||||||
let htmlParser: HtmlParser;
|
|
||||||
|
|
||||||
function loadAsText(template: string, xtb: string): {[id: string]: string} {
|
function loadAsMap(xtb: string): {[id: string]: string} {
|
||||||
const messageBundle = new MessageBundle(htmlParser, [], {});
|
const i18nNodesByMsgId = serializer.load(xtb, 'url');
|
||||||
messageBundle.updateFromTemplate(template, 'url', DEFAULT_INTERPOLATION_CONFIG);
|
const msgMap: {[id: string]: string} = {};
|
||||||
|
Object.keys(i18nNodesByMsgId).forEach(id => {
|
||||||
const asAst = serializer.load(xtb, 'url', messageBundle);
|
msgMap[id] = serializeNodes(i18nNodesByMsgId[id]).join('');
|
||||||
const asText: {[id: string]: string} = {};
|
});
|
||||||
Object.keys(asAst).forEach(id => { asText[id] = serializeNodes(asAst[id]).join(''); });
|
return msgMap;
|
||||||
|
|
||||||
return asText;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
@ -53,67 +40,61 @@ export function main(): void {
|
||||||
<translation id="8841459487341224498">rab</translation>
|
<translation id="8841459487341224498">rab</translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(loadAsText(HTML, XTB)).toEqual({'8841459487341224498': 'rab'});
|
expect(loadAsMap(XTB)).toEqual({'8841459487341224498': '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="8841459487341224498">rab</translation>
|
<translation id="8841459487341224498">rab</translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(loadAsText(HTML, XTB)).toEqual({'8841459487341224498': 'rab'});
|
expect(loadAsMap(XTB)).toEqual({'8841459487341224498': '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="8877975308926375834"><ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/></translation>
|
<translation id="8877975308926375834"><ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/></translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(loadAsText(HTML, XTB)).toEqual({'8877975308926375834': '<p>rab</p>'});
|
expect(loadAsMap(XTB)).toEqual({
|
||||||
|
'8877975308926375834': '<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should replace ICU placeholders with their translations', () => {
|
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" ?>
|
const XTB = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
<translationbundle>
|
<translationbundle>
|
||||||
<translation id="1430521728694081603">*<ph name="ICU"/>*</translation>
|
<translation id="7717087045075616176">*<ph name="ICU"/>*</translation>
|
||||||
<translation id="4004755025589356097">{ count, plural, =1 {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}}</translation>
|
<translation id="5115002811911870583">{VAR_PLURAL, plural, =1 {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}}</translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(loadAsText(HTML, XTB)).toEqual({
|
expect(loadAsMap(XTB)).toEqual({
|
||||||
'1430521728694081603': `*{ count, plural, =1 {<p>rab</p>}}*`,
|
'7717087045075616176': `*<ph name="ICU"/>*`,
|
||||||
'4004755025589356097': `{ count, plural, =1 {<p>rab</p>}}`,
|
'5115002811911870583':
|
||||||
|
`{VAR_PLURAL, plural, =1 {[<ph name="START_PARAGRAPH"/>, rab, <ph name="CLOSE_PARAGRAPH"/>]}}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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, select, 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="8281795707202401639"><ph name="INTERPOLATION"/><ph name="START_BOLD_TEXT"/>rab<ph name="CLOSE_BOLD_TEXT"/> oof</translation>
|
<translation id="8281795707202401639"><ph name="INTERPOLATION"/><ph name="START_BOLD_TEXT"/>rab<ph name="CLOSE_BOLD_TEXT"/> oof</translation>
|
||||||
<translation id="4004755025589356097">{ count, plural, =1 {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}}</translation>
|
<translation id="5115002811911870583">{VAR_PLURAL, plural, =1 {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}}</translation>
|
||||||
<translation id="130772889486467622">oof</translation>
|
<translation id="130772889486467622">oof</translation>
|
||||||
<translation id="4244993204427636474">{ count, plural, =1 {{ sex, gender, male {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}} }}</translation>
|
<translation id="4739316421648347533">{VAR_PLURAL, plural, =1 {{VAR_GENDER, gender, male {<ph name="START_PARAGRAPH"/>rab<ph name="CLOSE_PARAGRAPH"/>}} }}</translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(loadAsText(HTML, XTB)).toEqual({
|
expect(loadAsMap(XTB)).toEqual({
|
||||||
'8281795707202401639': `{{ a + b }}<b>rab</b> oof`,
|
'8281795707202401639':
|
||||||
'4004755025589356097': `{ count, plural, =1 {<p>rab</p>}}`,
|
`<ph name="INTERPOLATION"/><ph name="START_BOLD_TEXT"/>rab<ph name="CLOSE_BOLD_TEXT"/> oof`,
|
||||||
|
'5115002811911870583':
|
||||||
|
`{VAR_PLURAL, plural, =1 {[<ph name="START_PARAGRAPH"/>, rab, <ph name="CLOSE_PARAGRAPH"/>]}}`,
|
||||||
'130772889486467622': `oof`,
|
'130772889486467622': `oof`,
|
||||||
'4244993204427636474': `{ count, plural, =1 {{ sex, gender, male {<p>rab</p>}} }}`,
|
'4739316421648347533':
|
||||||
|
`{VAR_PLURAL, plural, =1 {[{VAR_GENDER, gender, male {[<ph name="START_PARAGRAPH"/>, rab, <ph name="CLOSE_PARAGRAPH"/>]}}, ]}}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -124,7 +105,7 @@ export function main(): void {
|
||||||
'<translationbundle><translationbundle></translationbundle></translationbundle>';
|
'<translationbundle><translationbundle></translationbundle></translationbundle>';
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
loadAsText('', XTB);
|
loadAsMap(XTB);
|
||||||
}).toThrowError(/<translationbundle> elements can not be nested/);
|
}).toThrowError(/<translationbundle> elements can not be nested/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -133,58 +114,49 @@ export function main(): void {
|
||||||
<translation></translation>
|
<translation></translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(() => {
|
expect(() => { loadAsMap(XTB); }).toThrowError(/<translation> misses the "id" attribute/);
|
||||||
loadAsText('', XTB);
|
|
||||||
}).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="1186013544048295927"><ph /></translation>
|
<translation id="1186013544048295927"><ph /></translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(() => { loadAsText(HTML, XTB); }).toThrowError(/<ph> misses the "name" attribute/);
|
expect(() => { loadAsMap(XTB); }).toThrowError(/<ph> misses the "name" attribute/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw when a placeholder is not present in the source message', () => {
|
it('should throw on unknown xtb tags', () => {
|
||||||
const HTML = `<div i18n>bar</div>`;
|
const XTB = `<what></what>`;
|
||||||
|
|
||||||
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
|
expect(() => {
|
||||||
<translationbundle>
|
loadAsMap(XTB);
|
||||||
<translation id="8841459487341224498"><ph name="UNKNOWN"/></translation>
|
}).toThrowError(new RegExp(escapeRegExp(`Unexpected tag ("[ERROR ->]<what></what>")`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on unknown message tags', () => {
|
||||||
|
const XTB = `<translationbundle>
|
||||||
|
<translation id="1186013544048295927"><b>msg should contain only ph tags</b></translation>
|
||||||
|
</translationbundle>`;
|
||||||
|
|
||||||
|
expect(() => { loadAsMap(XTB); })
|
||||||
|
.toThrowError(
|
||||||
|
new RegExp(escapeRegExp(`[ERROR ->]<b>msg should contain only ph tags</b>`)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on duplicate message id', () => {
|
||||||
|
const XTB = `<translationbundle>
|
||||||
|
<translation id="1186013544048295927">msg1</translation>
|
||||||
|
<translation id="1186013544048295927">msg2</translation>
|
||||||
</translationbundle>`;
|
</translationbundle>`;
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
loadAsText(HTML, XTB);
|
loadAsMap(XTB);
|
||||||
}).toThrowError(/The placeholder "UNKNOWN" does not exists in the source message/);
|
}).toThrowError(/Duplicated translations for msg 1186013544048295927/);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw when the translation results in invalid html', () => {
|
it('should throw when trying to save an xtb file',
|
||||||
const HTML = `<div i18n><p>bar</p></div>`;
|
() => { expect(() => { serializer.write([]); }).toThrowError(/Unsupported/); });
|
||||||
|
|
||||||
const XTB = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<translationbundle>
|
|
||||||
<translation id="8877975308926375834">rab<ph name="CLOSE_PARAGRAPH"/></translation>
|
|
||||||
</translationbundle>`;
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
loadAsText(HTML, XTB);
|
|
||||||
}).toThrowError(/xtb parse errors:\nUnexpected closing tag "p"/);
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw on unknown tags', () => {
|
|
||||||
const XTB = `<what></what>`;
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
loadAsText('', XTB);
|
|
||||||
}).toThrowError(new RegExp(escapeRegExp(`Unexpected tag ("[ERROR ->]<what></what>")`)));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw when trying to save an xtb file',
|
|
||||||
() => { expect(() => { serializer.write([]); }).toThrowError(/Unsupported/); });
|
|
||||||
});
|
});
|
||||||
}
|
}
|
|
@ -0,0 +1,110 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright Google Inc. All Rights Reserved.
|
||||||
|
*
|
||||||
|
* 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 * as i18n from '../../src/i18n/i18n_ast';
|
||||||
|
import {TranslationBundle} from '../../src/i18n/translation_bundle';
|
||||||
|
import {ParseLocation, ParseSourceFile, ParseSourceSpan} from '../../src/parse_util';
|
||||||
|
import {serializeNodes} from '../ml_parser/ast_serializer_spec';
|
||||||
|
|
||||||
|
export function main(): void {
|
||||||
|
describe('TranslationBundle', () => {
|
||||||
|
const file = new ParseSourceFile('content', 'url');
|
||||||
|
const location = new ParseLocation(file, 0, 0, 0);
|
||||||
|
const span = new ParseSourceSpan(location, null);
|
||||||
|
const srcNode = new i18n.Text('src', span);
|
||||||
|
|
||||||
|
it('should translate a plain message', () => {
|
||||||
|
const msgMap = {foo: [new i18n.Text('bar', null)]};
|
||||||
|
const tb = new TranslationBundle(msgMap, (_) => 'foo');
|
||||||
|
const msg = new i18n.Message([srcNode], {}, {}, 'm', 'd');
|
||||||
|
expect(serializeNodes(tb.get(msg))).toEqual(['bar']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should translate a message with placeholder', () => {
|
||||||
|
const msgMap = {
|
||||||
|
foo: [
|
||||||
|
new i18n.Text('bar', null),
|
||||||
|
new i18n.Placeholder('', 'ph1', null),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const phMap = {
|
||||||
|
ph1: '*phContent*',
|
||||||
|
};
|
||||||
|
const tb = new TranslationBundle(msgMap, (_) => 'foo');
|
||||||
|
const msg = new i18n.Message([srcNode], phMap, {}, 'm', 'd');
|
||||||
|
expect(serializeNodes(tb.get(msg))).toEqual(['bar*phContent*']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should translate a message with placeholder referencing messages', () => {
|
||||||
|
const msgMap = {
|
||||||
|
foo: [
|
||||||
|
new i18n.Text('--', null),
|
||||||
|
new i18n.Placeholder('', 'ph1', null),
|
||||||
|
new i18n.Text('++', null),
|
||||||
|
],
|
||||||
|
ref: [
|
||||||
|
new i18n.Text('*refMsg*', null),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const refMsg = new i18n.Message([srcNode], {}, {}, 'm', 'd');
|
||||||
|
const msg = new i18n.Message([srcNode], {}, {ph1: refMsg}, 'm', 'd');
|
||||||
|
let count = 0;
|
||||||
|
const digest = (_: any) => count++ ? 'ref' : 'foo';
|
||||||
|
const tb = new TranslationBundle(msgMap, digest);
|
||||||
|
|
||||||
|
expect(serializeNodes(tb.get(msg))).toEqual(['--*refMsg*++']);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('errors', () => {
|
||||||
|
it('should report unknown placeholders', () => {
|
||||||
|
const msgMap = {
|
||||||
|
foo: [
|
||||||
|
new i18n.Text('bar', null),
|
||||||
|
new i18n.Placeholder('', 'ph1', span),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const tb = new TranslationBundle(msgMap, (_) => 'foo');
|
||||||
|
const msg = new i18n.Message([srcNode], {}, {}, 'm', 'd');
|
||||||
|
expect(() => tb.get(msg)).toThrowError(/Unknown placeholder/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report missing translation', () => {
|
||||||
|
const tb = new TranslationBundle({}, (_) => 'foo');
|
||||||
|
const msg = new i18n.Message([srcNode], {}, {}, 'm', 'd');
|
||||||
|
expect(() => tb.get(msg)).toThrowError(/Missing translation for message foo/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report missing referenced message', () => {
|
||||||
|
const msgMap = {
|
||||||
|
foo: [new i18n.Placeholder('', 'ph1', span)],
|
||||||
|
};
|
||||||
|
const refMsg = new i18n.Message([srcNode], {}, {}, 'm', 'd');
|
||||||
|
const msg = new i18n.Message([srcNode], {}, {ph1: refMsg}, 'm', 'd');
|
||||||
|
let count = 0;
|
||||||
|
const digest = (_: any) => count++ ? 'ref' : 'foo';
|
||||||
|
const tb = new TranslationBundle(msgMap, digest);
|
||||||
|
expect(() => tb.get(msg)).toThrowError(/Missing translation for message ref/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report invalid translated html', () => {
|
||||||
|
const msgMap = {
|
||||||
|
foo: [
|
||||||
|
new i18n.Text('text', null),
|
||||||
|
new i18n.Placeholder('', 'ph1', null),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const phMap = {
|
||||||
|
ph1: '</b>',
|
||||||
|
};
|
||||||
|
const tb = new TranslationBundle(msgMap, (_) => 'foo');
|
||||||
|
const msg = new i18n.Message([srcNode], phMap, {}, 'm', 'd');
|
||||||
|
expect(() => tb.get(msg)).toThrowError(/Unexpected closing tag "b"/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue