2016-08-12 20:14:52 -07:00
|
|
|
/**
|
|
|
|
* @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 ml from '../../ml_parser/ast';
|
|
|
|
import {XmlParser} from '../../ml_parser/xml_parser';
|
2016-10-28 19:53:42 -07:00
|
|
|
import {digest} from '../digest';
|
2016-08-12 20:14:52 -07:00
|
|
|
import * as i18n from '../i18n_ast';
|
|
|
|
import {I18nError} from '../parse_util';
|
|
|
|
|
2016-10-31 18:22:11 -07:00
|
|
|
import {Serializer} from './serializer';
|
2016-08-12 20:14:52 -07:00
|
|
|
import * as xml from './xml_helper';
|
|
|
|
|
|
|
|
const _VERSION = '1.2';
|
|
|
|
const _XMLNS = 'urn:oasis:names:tc:xliff:document:1.2';
|
|
|
|
// TODO(vicb): make this a param (s/_/-/)
|
2017-02-16 17:03:18 +01:00
|
|
|
const _DEFAULT_SOURCE_LANG = 'en';
|
2016-08-12 20:14:52 -07:00
|
|
|
const _PLACEHOLDER_TAG = 'x';
|
2018-01-02 11:19:16 +01:00
|
|
|
const _MARKER_TAG = 'mrk';
|
2016-11-02 17:40:15 -07:00
|
|
|
|
2017-02-03 14:29:28 -08:00
|
|
|
const _FILE_TAG = 'file';
|
2016-08-12 20:14:52 -07:00
|
|
|
const _SOURCE_TAG = 'source';
|
2018-01-02 11:19:16 +01:00
|
|
|
const _SEGMENT_SOURCE_TAG = 'seg-source';
|
2016-08-12 20:14:52 -07:00
|
|
|
const _TARGET_TAG = 'target';
|
|
|
|
const _UNIT_TAG = 'trans-unit';
|
2017-04-14 18:06:25 +02:00
|
|
|
const _CONTEXT_GROUP_TAG = 'context-group';
|
|
|
|
const _CONTEXT_TAG = 'context';
|
2016-08-12 20:14:52 -07:00
|
|
|
|
|
|
|
// 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
|
2017-01-17 17:36:16 -08:00
|
|
|
export class Xliff extends Serializer {
|
2017-02-16 17:03:18 +01:00
|
|
|
write(messages: i18n.Message[], locale: string|null): string {
|
2016-08-12 20:14:52 -07:00
|
|
|
const visitor = new _WriteVisitor();
|
|
|
|
const transUnits: xml.Node[] = [];
|
|
|
|
|
2016-10-28 19:53:42 -07:00
|
|
|
messages.forEach(message => {
|
2017-04-14 18:06:25 +02:00
|
|
|
let contextTags: xml.Node[] = [];
|
|
|
|
message.sources.forEach((source: i18n.MessageSpan) => {
|
|
|
|
let contextGroupTag = new xml.Tag(_CONTEXT_GROUP_TAG, {purpose: 'location'});
|
|
|
|
contextGroupTag.children.push(
|
|
|
|
new xml.CR(10),
|
|
|
|
new xml.Tag(
|
|
|
|
_CONTEXT_TAG, {'context-type': 'sourcefile'}, [new xml.Text(source.filePath)]),
|
|
|
|
new xml.CR(10), new xml.Tag(
|
|
|
|
_CONTEXT_TAG, {'context-type': 'linenumber'},
|
|
|
|
[new xml.Text(`${source.startLine}`)]),
|
|
|
|
new xml.CR(8));
|
|
|
|
contextTags.push(new xml.CR(8), contextGroupTag);
|
|
|
|
});
|
|
|
|
|
2017-01-19 14:42:25 -08:00
|
|
|
const transUnit = new xml.Tag(_UNIT_TAG, {id: message.id, datatype: 'html'});
|
2016-08-12 20:14:52 -07:00
|
|
|
transUnit.children.push(
|
2016-09-30 14:52:12 -07:00
|
|
|
new xml.CR(8), new xml.Tag(_SOURCE_TAG, {}, visitor.serialize(message.nodes)),
|
2017-07-12 14:38:06 +02:00
|
|
|
...contextTags);
|
2016-08-12 20:14:52 -07:00
|
|
|
|
|
|
|
if (message.description) {
|
|
|
|
transUnit.children.push(
|
2016-09-30 14:52:12 -07:00
|
|
|
new xml.CR(8),
|
2016-08-12 20:14:52 -07:00
|
|
|
new xml.Tag(
|
|
|
|
'note', {priority: '1', from: 'description'}, [new xml.Text(message.description)]));
|
|
|
|
}
|
|
|
|
|
|
|
|
if (message.meaning) {
|
|
|
|
transUnit.children.push(
|
2016-09-30 14:52:12 -07:00
|
|
|
new xml.CR(8),
|
2016-08-12 20:14:52 -07:00
|
|
|
new xml.Tag('note', {priority: '1', from: 'meaning'}, [new xml.Text(message.meaning)]));
|
|
|
|
}
|
|
|
|
|
2016-09-30 14:52:12 -07:00
|
|
|
transUnit.children.push(new xml.CR(6));
|
2016-08-12 20:14:52 -07:00
|
|
|
|
2016-09-30 14:52:12 -07:00
|
|
|
transUnits.push(new xml.CR(6), transUnit);
|
2016-08-12 20:14:52 -07:00
|
|
|
});
|
|
|
|
|
2016-09-30 14:52:12 -07:00
|
|
|
const body = new xml.Tag('body', {}, [...transUnits, new xml.CR(4)]);
|
2016-08-12 20:14:52 -07:00
|
|
|
const file = new xml.Tag(
|
2017-02-16 17:03:18 +01:00
|
|
|
'file', {
|
|
|
|
'source-language': locale || _DEFAULT_SOURCE_LANG,
|
|
|
|
datatype: 'plaintext',
|
|
|
|
original: 'ng2.template',
|
|
|
|
},
|
2016-09-30 14:52:12 -07:00
|
|
|
[new xml.CR(4), body, new xml.CR(2)]);
|
|
|
|
const xliff = new xml.Tag(
|
|
|
|
'xliff', {version: _VERSION, xmlns: _XMLNS}, [new xml.CR(2), file, new xml.CR()]);
|
2016-08-12 20:14:52 -07:00
|
|
|
|
2016-09-30 14:52:12 -07:00
|
|
|
return xml.serialize([
|
|
|
|
new xml.Declaration({version: '1.0', encoding: 'UTF-8'}), new xml.CR(), xliff, new xml.CR()
|
|
|
|
]);
|
2016-08-12 20:14:52 -07:00
|
|
|
}
|
|
|
|
|
2017-02-03 14:29:28 -08:00
|
|
|
load(content: string, url: string):
|
|
|
|
{locale: string, i18nNodesByMsgId: {[msgId: string]: i18n.Node[]}} {
|
2016-11-02 17:40:15 -07:00
|
|
|
// xliff to xml nodes
|
|
|
|
const xliffParser = new XliffParser();
|
2017-03-13 11:40:44 +01:00
|
|
|
const {locale, msgIdToHtml, errors} = xliffParser.parse(content, url);
|
2016-11-02 17:40:15 -07:00
|
|
|
|
|
|
|
// xml nodes to i18n nodes
|
|
|
|
const i18nNodesByMsgId: {[msgId: string]: i18n.Node[]} = {};
|
|
|
|
const converter = new XmlToI18n();
|
2017-03-13 11:40:44 +01:00
|
|
|
|
|
|
|
Object.keys(msgIdToHtml).forEach(msgId => {
|
|
|
|
const {i18nNodes, errors: e} = converter.convert(msgIdToHtml[msgId], url);
|
2016-11-02 17:40:15 -07:00
|
|
|
errors.push(...e);
|
|
|
|
i18nNodesByMsgId[msgId] = i18nNodes;
|
2016-08-12 20:14:52 -07:00
|
|
|
});
|
|
|
|
|
2016-11-02 17:40:15 -07:00
|
|
|
if (errors.length) {
|
|
|
|
throw new Error(`xliff parse errors:\n${errors.join('\n')}`);
|
2016-08-12 20:14:52 -07:00
|
|
|
}
|
|
|
|
|
2017-03-24 09:59:58 -07:00
|
|
|
return {locale: locale !, i18nNodesByMsgId};
|
2016-08-12 20:14:52 -07:00
|
|
|
}
|
2016-10-28 19:53:42 -07:00
|
|
|
|
|
|
|
digest(message: i18n.Message): string { return digest(message); }
|
2016-08-12 20:14:52 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
class _WriteVisitor implements i18n.Visitor {
|
|
|
|
visitText(text: i18n.Text, context?: any): xml.Node[] { return [new xml.Text(text.value)]; }
|
|
|
|
|
|
|
|
visitContainer(container: i18n.Container, context?: any): xml.Node[] {
|
|
|
|
const nodes: xml.Node[] = [];
|
|
|
|
container.children.forEach((node: i18n.Node) => nodes.push(...node.visit(this)));
|
|
|
|
return nodes;
|
|
|
|
}
|
|
|
|
|
|
|
|
visitIcu(icu: i18n.Icu, context?: any): xml.Node[] {
|
2017-03-13 11:40:44 +01:00
|
|
|
const nodes = [new xml.Text(`{${icu.expressionPlaceholder}, ${icu.type}, `)];
|
2016-08-12 20:14:52 -07:00
|
|
|
|
2017-03-13 11:40:44 +01:00
|
|
|
Object.keys(icu.cases).forEach((c: string) => {
|
|
|
|
nodes.push(new xml.Text(`${c} {`), ...icu.cases[c].visit(this), new xml.Text(`} `));
|
|
|
|
});
|
2016-08-12 20:14:52 -07:00
|
|
|
|
2017-03-13 11:40:44 +01:00
|
|
|
nodes.push(new xml.Text(`}`));
|
2016-08-12 20:14:52 -07:00
|
|
|
|
|
|
|
return nodes;
|
|
|
|
}
|
|
|
|
|
|
|
|
visitTagPlaceholder(ph: i18n.TagPlaceholder, context?: any): xml.Node[] {
|
2016-09-30 15:39:46 -07:00
|
|
|
const ctype = getCtypeForTag(ph.tag);
|
|
|
|
|
2016-08-12 20:14:52 -07:00
|
|
|
if (ph.isVoid) {
|
|
|
|
// void tags have no children nor closing tags
|
2017-07-12 16:27:53 +02:00
|
|
|
return [new xml.Tag(
|
|
|
|
_PLACEHOLDER_TAG, {id: ph.startName, ctype, 'equiv-text': `<${ph.tag}/>`})];
|
2016-08-12 20:14:52 -07:00
|
|
|
}
|
|
|
|
|
2017-07-12 16:27:53 +02:00
|
|
|
const startTagPh =
|
|
|
|
new xml.Tag(_PLACEHOLDER_TAG, {id: ph.startName, ctype, 'equiv-text': `<${ph.tag}>`});
|
|
|
|
const closeTagPh =
|
|
|
|
new xml.Tag(_PLACEHOLDER_TAG, {id: ph.closeName, ctype, 'equiv-text': `</${ph.tag}>`});
|
2016-08-12 20:14:52 -07:00
|
|
|
|
|
|
|
return [startTagPh, ...this.serialize(ph.children), closeTagPh];
|
|
|
|
}
|
|
|
|
|
|
|
|
visitPlaceholder(ph: i18n.Placeholder, context?: any): xml.Node[] {
|
2017-07-12 16:27:53 +02:00
|
|
|
return [new xml.Tag(_PLACEHOLDER_TAG, {id: ph.name, 'equiv-text': `{{${ph.value}}}`})];
|
2016-08-12 20:14:52 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
visitIcuPlaceholder(ph: i18n.IcuPlaceholder, context?: any): xml.Node[] {
|
2017-07-12 16:27:53 +02:00
|
|
|
const equivText =
|
|
|
|
`{${ph.value.expression}, ${ph.value.type}, ${Object.keys(ph.value.cases).map((value: string) => value + ' {...}').join(' ')}}`;
|
|
|
|
return [new xml.Tag(_PLACEHOLDER_TAG, {id: ph.name, 'equiv-text': equivText})];
|
2016-08-12 20:14:52 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
serialize(nodes: i18n.Node[]): xml.Node[] {
|
2016-10-31 17:26:54 -07:00
|
|
|
return [].concat(...nodes.map(node => node.visit(this)));
|
2016-08-12 20:14:52 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO(vicb): add error management (structure)
|
2016-11-02 17:40:15 -07:00
|
|
|
// Extract messages as xml nodes from the xliff file
|
|
|
|
class XliffParser implements ml.Visitor {
|
2018-06-18 16:38:33 -07:00
|
|
|
// TODO(issue/24571): remove '!'.
|
|
|
|
private _unitMlString !: string | null;
|
|
|
|
// TODO(issue/24571): remove '!'.
|
|
|
|
private _errors !: I18nError[];
|
|
|
|
// TODO(issue/24571): remove '!'.
|
|
|
|
private _msgIdToHtml !: {[msgId: string]: string};
|
2017-02-03 14:29:28 -08:00
|
|
|
private _locale: string|null = null;
|
2016-08-12 20:14:52 -07:00
|
|
|
|
2016-11-02 17:40:15 -07:00
|
|
|
parse(xliff: string, url: string) {
|
2017-03-13 11:40:44 +01:00
|
|
|
this._unitMlString = null;
|
|
|
|
this._msgIdToHtml = {};
|
2016-08-12 20:14:52 -07:00
|
|
|
|
2019-02-08 22:10:19 +00:00
|
|
|
const xml = new XmlParser().parse(xliff, url);
|
2016-08-12 20:14:52 -07:00
|
|
|
|
2016-11-02 17:40:15 -07:00
|
|
|
this._errors = xml.errors;
|
|
|
|
ml.visitAll(this, xml.rootNodes, null);
|
2016-08-12 20:14:52 -07:00
|
|
|
|
2016-11-02 17:40:15 -07:00
|
|
|
return {
|
2017-03-13 11:40:44 +01:00
|
|
|
msgIdToHtml: this._msgIdToHtml,
|
2016-11-02 17:40:15 -07:00
|
|
|
errors: this._errors,
|
2017-02-03 14:29:28 -08:00
|
|
|
locale: this._locale,
|
2016-11-02 17:40:15 -07:00
|
|
|
};
|
2016-08-12 20:14:52 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
visitElement(element: ml.Element, context: any): any {
|
|
|
|
switch (element.name) {
|
|
|
|
case _UNIT_TAG:
|
2017-03-24 09:59:58 -07:00
|
|
|
this._unitMlString = null !;
|
2016-11-02 17:40:15 -07:00
|
|
|
const idAttr = element.attrs.find((attr) => attr.name === 'id');
|
|
|
|
if (!idAttr) {
|
2016-08-12 20:14:52 -07:00
|
|
|
this._addError(element, `<${_UNIT_TAG}> misses the "id" attribute`);
|
|
|
|
} else {
|
2016-11-02 17:40:15 -07:00
|
|
|
const id = idAttr.value;
|
2017-03-13 11:40:44 +01:00
|
|
|
if (this._msgIdToHtml.hasOwnProperty(id)) {
|
2016-11-02 17:40:15 -07:00
|
|
|
this._addError(element, `Duplicated translations for msg ${id}`);
|
|
|
|
} else {
|
|
|
|
ml.visitAll(this, element.children, null);
|
2017-03-13 11:40:44 +01:00
|
|
|
if (typeof this._unitMlString === 'string') {
|
|
|
|
this._msgIdToHtml[id] = this._unitMlString;
|
2016-11-14 11:22:58 -08:00
|
|
|
} else {
|
|
|
|
this._addError(element, `Message ${id} misses a translation`);
|
|
|
|
}
|
2016-11-02 17:40:15 -07:00
|
|
|
}
|
2016-08-12 20:14:52 -07:00
|
|
|
}
|
|
|
|
break;
|
|
|
|
|
2018-01-02 11:19:16 +01:00
|
|
|
// ignore those tags
|
2016-08-12 20:14:52 -07:00
|
|
|
case _SOURCE_TAG:
|
2018-01-02 11:19:16 +01:00
|
|
|
case _SEGMENT_SOURCE_TAG:
|
2016-08-12 20:14:52 -07:00
|
|
|
break;
|
|
|
|
|
|
|
|
case _TARGET_TAG:
|
2017-03-24 09:59:58 -07:00
|
|
|
const innerTextStart = element.startSourceSpan !.end.offset;
|
|
|
|
const innerTextEnd = element.endSourceSpan !.start.offset;
|
|
|
|
const content = element.startSourceSpan !.start.file.content;
|
2017-03-13 11:40:44 +01:00
|
|
|
const innerText = content.slice(innerTextStart, innerTextEnd);
|
|
|
|
this._unitMlString = innerText;
|
2016-08-12 20:14:52 -07:00
|
|
|
break;
|
|
|
|
|
2017-02-03 14:29:28 -08:00
|
|
|
case _FILE_TAG:
|
|
|
|
const localeAttr = element.attrs.find((attr) => attr.name === 'target-language');
|
|
|
|
if (localeAttr) {
|
|
|
|
this._locale = localeAttr.value;
|
|
|
|
}
|
|
|
|
ml.visitAll(this, element.children, null);
|
|
|
|
break;
|
|
|
|
|
2016-08-12 20:14:52 -07:00
|
|
|
default:
|
2016-11-02 17:40:15 -07:00
|
|
|
// TODO(vicb): assert file structure, xliff version
|
|
|
|
// For now only recurse on unhandled nodes
|
2016-08-12 20:14:52 -07:00
|
|
|
ml.visitAll(this, element.children, null);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-11-02 17:40:15 -07:00
|
|
|
visitAttribute(attribute: ml.Attribute, context: any): any {}
|
2016-08-12 20:14:52 -07:00
|
|
|
|
2016-11-02 17:40:15 -07:00
|
|
|
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 {
|
2017-03-24 09:59:58 -07:00
|
|
|
this._errors.push(new I18nError(node.sourceSpan !, message));
|
2016-11-02 17:40:15 -07:00
|
|
|
}
|
|
|
|
}
|
2016-08-12 20:14:52 -07:00
|
|
|
|
2016-11-02 17:40:15 -07:00
|
|
|
// Convert ml nodes (xliff syntax) to i18n nodes
|
|
|
|
class XmlToI18n implements ml.Visitor {
|
2018-06-18 16:38:33 -07:00
|
|
|
// TODO(issue/24571): remove '!'.
|
|
|
|
private _errors !: I18nError[];
|
2016-08-12 20:14:52 -07:00
|
|
|
|
2017-03-13 11:40:44 +01:00
|
|
|
convert(message: string, url: string) {
|
2019-02-08 22:10:19 +00:00
|
|
|
const xmlIcu = new XmlParser().parse(message, url, {tokenizeExpansionForms: true});
|
2017-03-13 11:40:44 +01:00
|
|
|
this._errors = xmlIcu.errors;
|
|
|
|
|
|
|
|
const i18nNodes = this._errors.length > 0 || xmlIcu.rootNodes.length == 0 ?
|
|
|
|
[] :
|
2018-01-02 11:19:16 +01:00
|
|
|
[].concat(...ml.visitAll(this, xmlIcu.rootNodes));
|
2017-03-13 11:40:44 +01:00
|
|
|
|
2016-11-02 17:40:15 -07:00
|
|
|
return {
|
2017-03-13 11:40:44 +01:00
|
|
|
i18nNodes: i18nNodes,
|
2016-11-02 17:40:15 -07:00
|
|
|
errors: this._errors,
|
|
|
|
};
|
2016-08-12 20:14:52 -07:00
|
|
|
}
|
|
|
|
|
2017-03-24 09:59:58 -07:00
|
|
|
visitText(text: ml.Text, context: any) { return new i18n.Text(text.value, text.sourceSpan !); }
|
2016-11-02 17:40:15 -07:00
|
|
|
|
2018-01-02 11:19:16 +01:00
|
|
|
visitElement(el: ml.Element, context: any): i18n.Placeholder|ml.Node[]|null {
|
2016-11-02 17:40:15 -07:00
|
|
|
if (el.name === _PLACEHOLDER_TAG) {
|
|
|
|
const nameAttr = el.attrs.find((attr) => attr.name === 'id');
|
|
|
|
if (nameAttr) {
|
2017-03-24 09:59:58 -07:00
|
|
|
return new i18n.Placeholder('', nameAttr.value, el.sourceSpan !);
|
2016-11-02 17:40:15 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
this._addError(el, `<${_PLACEHOLDER_TAG}> misses the "id" attribute`);
|
2018-01-02 11:19:16 +01:00
|
|
|
return null;
|
2016-11-02 17:40:15 -07:00
|
|
|
}
|
2018-01-02 11:19:16 +01:00
|
|
|
|
|
|
|
if (el.name === _MARKER_TAG) {
|
|
|
|
return [].concat(...ml.visitAll(this, el.children));
|
|
|
|
}
|
|
|
|
|
|
|
|
this._addError(el, `Unexpected tag`);
|
2017-03-24 09:59:58 -07:00
|
|
|
return null;
|
2016-08-12 20:14:52 -07:00
|
|
|
}
|
|
|
|
|
2017-03-13 11:40:44 +01:00
|
|
|
visitExpansion(icu: ml.Expansion, context: any) {
|
|
|
|
const caseMap: {[value: string]: i18n.Node} = {};
|
2016-11-02 17:40:15 -07:00
|
|
|
|
2017-03-13 11:40:44 +01:00
|
|
|
ml.visitAll(this, icu.cases).forEach((c: any) => {
|
|
|
|
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),
|
|
|
|
};
|
|
|
|
}
|
2016-11-02 17:40:15 -07:00
|
|
|
|
|
|
|
visitComment(comment: ml.Comment, context: any) {}
|
|
|
|
|
|
|
|
visitAttribute(attribute: ml.Attribute, context: any) {}
|
|
|
|
|
2016-08-12 20:14:52 -07:00
|
|
|
private _addError(node: ml.Node, message: string): void {
|
2017-03-24 09:59:58 -07:00
|
|
|
this._errors.push(new I18nError(node.sourceSpan !, message));
|
2016-08-12 20:14:52 -07:00
|
|
|
}
|
|
|
|
}
|
2016-09-30 15:39:46 -07:00
|
|
|
|
|
|
|
function getCtypeForTag(tag: string): string {
|
|
|
|
switch (tag.toLowerCase()) {
|
|
|
|
case 'br':
|
|
|
|
return 'lb';
|
|
|
|
case 'img':
|
|
|
|
return 'image';
|
|
|
|
default:
|
|
|
|
return `x-${tag}`;
|
|
|
|
}
|
2017-02-16 17:03:18 +01:00
|
|
|
}
|