Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

i18n: support xmb formatted translation files in compile time inlining #33444

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/localize/src/tools/src/translate/main.ts
Expand Up @@ -15,9 +15,10 @@ import {getOutputPathFn, OutputPathFn} from './output_path';
import {SourceFileTranslationHandler} from './source_files/source_file_translation_handler';
import {MissingTranslationStrategy} from './source_files/source_file_utils';
import {TranslationLoader} from './translation_files/translation_loader';
import {SimpleJsonTranslationParser} from './translation_files/translation_parsers/simple_json/simple_json_translation_parser';
import {Xliff1TranslationParser} from './translation_files/translation_parsers/xliff1/xliff1_translation_parser';
import {Xliff2TranslationParser} from './translation_files/translation_parsers/xliff2/xliff2_translation_parser';
import {SimpleJsonTranslationParser} from './translation_files/translation_parsers/simple_json_translation_parser';
import {Xliff1TranslationParser} from './translation_files/translation_parsers/xliff1_translation_parser';
import {Xliff2TranslationParser} from './translation_files/translation_parsers/xliff2_translation_parser';
import {XtbTranslationParser} from './translation_files/translation_parsers/xtb_translation_parser';
import {Translator} from './translator';
import {Diagnostics} from '../diagnostics';

Expand Down Expand Up @@ -141,6 +142,7 @@ export function translateFiles({sourceRootPath, sourceFilePaths, translationFile
[
new Xliff2TranslationParser(),
new Xliff1TranslationParser(),
new XtbTranslationParser(diagnostics),
new SimpleJsonTranslationParser(),
],
diagnostics);
Expand Down
Expand Up @@ -6,15 +6,28 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Element, Expansion, ExpansionCase, Node, Text, visitAll} from '@angular/compiler';
import {MessageRenderer} from '../../../message_renderers/message_renderer';

import {BaseVisitor} from '../base_visitor';
import {TranslationParseError} from '../translation_parse_error';
import {getAttrOrThrow, getAttribute} from '../translation_utils';
import {TranslationParseError} from '../translation_parsers/translation_parse_error';
import {getAttrOrThrow, getAttribute} from '../translation_parsers/translation_utils';

import {MessageRenderer} from './message_renderer';

const INLINE_ELEMENTS = ['cp', 'sc', 'ec', 'mrk', 'sm', 'em'];
interface MessageSerializerConfig {
inlineElements: string[];
placeholder?: {elementName: string; nameAttribute: string; bodyAttribute?: string;};
placeholderContainer?: {elementName: string; startAttribute: string; endAttribute: string;};
}

export class Xliff2MessageSerializer<T> extends BaseVisitor {
constructor(private renderer: MessageRenderer<T>) { super(); }
/**
* This visitor will walk over a set of XML nodes, which represent an i18n message, and serialize
* them into a message object of type `T`.
* The type of the serialized message is controlled by the
*/
export class MessageSerializer<T> extends BaseVisitor {
constructor(private renderer: MessageRenderer<T>, private config: MessageSerializerConfig) {
super();
}

serialize(nodes: Node[]): T {
this.renderer.startRender();
Expand All @@ -24,13 +37,18 @@ export class Xliff2MessageSerializer<T> extends BaseVisitor {
}

visitElement(element: Element): void {
if (element.name === 'ph') {
this.visitPlaceholder(getAttrOrThrow(element, 'equiv'), getAttribute(element, 'disp'));
} else if (element.name === 'pc') {
this.visitPlaceholderContainer(
getAttrOrThrow(element, 'equivStart'), element.children,
getAttrOrThrow(element, 'equivEnd'));
} else if (INLINE_ELEMENTS.indexOf(element.name) !== -1) {
if (this.config.placeholder && element.name === this.config.placeholder.elementName) {
const name = getAttrOrThrow(element, this.config.placeholder.nameAttribute);
const body = this.config.placeholder.bodyAttribute &&
getAttribute(element, this.config.placeholder.bodyAttribute);
this.visitPlaceholder(name, body);
} else if (
this.config.placeholderContainer &&
element.name === this.config.placeholderContainer.elementName) {
const start = getAttrOrThrow(element, this.config.placeholderContainer.startAttribute);
const end = getAttrOrThrow(element, this.config.placeholderContainer.endAttribute);
this.visitPlaceholderContainer(start, element.children, end);
} else if (this.config.inlineElements.indexOf(element.name) !== -1) {
visitAll(this, element.children);
} else {
throw new TranslationParseError(element.sourceSpan, `Invalid element found in message.`);
Expand Down Expand Up @@ -58,11 +76,11 @@ export class Xliff2MessageSerializer<T> extends BaseVisitor {
const length = nodes.length;
let index = 0;
while (index < length) {
if (!isPlaceholderContainer(nodes[index])) {
if (!this.isPlaceholderContainer(nodes[index])) {
const startOfContainedNodes = index;
while (index < length - 1) {
index++;
if (isPlaceholderContainer(nodes[index])) {
if (this.isPlaceholderContainer(nodes[index])) {
break;
}
}
Expand All @@ -89,8 +107,8 @@ export class Xliff2MessageSerializer<T> extends BaseVisitor {
this.visitContainedNodes(children);
this.renderer.closePlaceholder(closeName);
}
}

function isPlaceholderContainer(node: Node): boolean {
return node instanceof Element && node.name === 'pc';
private isPlaceholderContainer(node: Node): boolean {
return node instanceof Element && node.name === this.config.placeholderContainer !.elementName;
}
}
Expand Up @@ -7,7 +7,7 @@
*/
import {ɵMessageId, ɵParsedTranslation, ɵparseTranslation} from '@angular/localize';
import {extname} from 'path';
import {ParsedTranslationBundle, TranslationParser} from '../translation_parser';
import {ParsedTranslationBundle, TranslationParser} from './translation_parser';

/**
* A translation parser that can parse JSON that has the form:
Expand Down
Expand Up @@ -27,6 +27,9 @@ export function parseInnerRange(element: Element): Node[] {
const xml = xmlParser.parse(
element.sourceSpan.start.file.content, element.sourceSpan.start.file.url,
{tokenizeExpansionForms: true, range: getInnerRange(element)});
if (xml.errors.length) {
throw xml.errors.map(e => new TranslationParseError(e.span, e.msg).toString()).join('\n');
}
return xml.rootNodes;
}

Expand Down

This file was deleted.

Expand Up @@ -8,12 +8,14 @@
import {Element, Node, XmlParser, visitAll} from '@angular/compiler';
import {ɵMessageId, ɵParsedTranslation} from '@angular/localize';
import {extname} from 'path';
import {TargetMessageRenderer} from '../../../message_renderers/target_message_renderer';

import {BaseVisitor} from '../base_visitor';
import {TranslationParseError} from '../translation_parse_error';
import {ParsedTranslationBundle, TranslationParser} from '../translation_parser';
import {getAttrOrThrow, getAttribute, parseInnerRange} from '../translation_utils';
import {Xliff1MessageSerializer} from './xliff1_message_serializer';
import {MessageSerializer} from '../message_serialization/message_serializer';
import {TargetMessageRenderer} from '../message_serialization/target_message_renderer';

import {TranslationParseError} from './translation_parse_error';
import {ParsedTranslationBundle, TranslationParser} from './translation_parser';
import {getAttrOrThrow, getAttribute, parseInnerRange} from './translation_utils';

const XLIFF_1_2_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:1.2"/;

Expand Down Expand Up @@ -90,7 +92,10 @@ class XliffTranslationVisitor extends BaseVisitor {
}

function serializeTargetMessage(source: Element): ɵParsedTranslation {
const serializer = new Xliff1MessageSerializer(new TargetMessageRenderer());
const serializer = new MessageSerializer(new TargetMessageRenderer(), {
inlineElements: ['g', 'bx', 'ex', 'bpt', 'ept', 'ph', 'it', 'mrk'],
placeholder: {elementName: 'x', nameAttribute: 'id'}
});
return serializer.serialize(parseInnerRange(source));
}

Expand Down
Expand Up @@ -8,12 +8,14 @@
import {Element, Node, XmlParser, visitAll} from '@angular/compiler';
import {ɵMessageId, ɵParsedTranslation} from '@angular/localize';
import {extname} from 'path';
import {TargetMessageRenderer} from '../../../message_renderers/target_message_renderer';

import {BaseVisitor} from '../base_visitor';
import {TranslationParseError} from '../translation_parse_error';
import {ParsedTranslationBundle, TranslationParser} from '../translation_parser';
import {getAttrOrThrow, getAttribute, parseInnerRange} from '../translation_utils';
import {Xliff2MessageSerializer} from './xliff2_message_serializer';
import {MessageSerializer} from '../message_serialization/message_serializer';
import {TargetMessageRenderer} from '../message_serialization/target_message_renderer';

import {TranslationParseError} from './translation_parse_error';
import {ParsedTranslationBundle, TranslationParser} from './translation_parser';
import {getAttrOrThrow, getAttribute, parseInnerRange} from './translation_utils';

const XLIFF_2_0_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:2.0"/;

Expand Down Expand Up @@ -105,7 +107,12 @@ function assertTranslationUnit(segment: Element, context: any) {
}

function serializeTargetMessage(source: Element): ɵParsedTranslation {
const serializer = new Xliff2MessageSerializer(new TargetMessageRenderer());
const serializer = new MessageSerializer(new TargetMessageRenderer(), {
inlineElements: ['cp', 'sc', 'ec', 'mrk', 'sm', 'em'],
placeholder: {elementName: 'ph', nameAttribute: 'equiv', bodyAttribute: 'disp'},
placeholderContainer:
{elementName: 'pc', startAttribute: 'equivStart', endAttribute: 'equivEnd'}
});
return serializer.serialize(parseInnerRange(source));
}

Expand Down
@@ -0,0 +1,103 @@
/**
* @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 {Element, Node, XmlParser, visitAll} from '@angular/compiler';
import {ɵParsedTranslation} from '@angular/localize';
import {extname} from 'path';

import {Diagnostics} from '../../../diagnostics';
import {BaseVisitor} from '../base_visitor';
import {MessageSerializer} from '../message_serialization/message_serializer';
import {TargetMessageRenderer} from '../message_serialization/target_message_renderer';

import {TranslationParseError} from './translation_parse_error';
import {ParsedTranslationBundle, TranslationParser} from './translation_parser';
import {getAttrOrThrow, parseInnerRange} from './translation_utils';



/**
* A translation parser that can load XB files.
*/
export class XtbTranslationParser implements TranslationParser {
constructor(private diagnostics: Diagnostics) {}

canParse(filePath: string, contents: string): boolean {
const extension = extname(filePath);
return (extension === '.xtb' || extension === '.xmb') &&
contents.includes('<translationbundle');
}

parse(filePath: string, contents: string): ParsedTranslationBundle {
const xmlParser = new XmlParser();
const xml = xmlParser.parse(contents, filePath);
const bundle = XtbVisitor.extractBundle(this.diagnostics, xml.rootNodes);
if (bundle === undefined) {
throw new Error(`Unable to parse "${filePath}" as XTB/XMB format.`);
}
return bundle;
}
}

class XtbVisitor extends BaseVisitor {
static extractBundle(diagnostics: Diagnostics, messageBundles: Node[]): ParsedTranslationBundle
|undefined {
const visitor = new this(diagnostics);
const bundles: ParsedTranslationBundle[] = visitAll(visitor, messageBundles, undefined);
return bundles[0];
}

constructor(private diagnostics: Diagnostics) { super(); }

visitElement(element: Element, bundle: ParsedTranslationBundle|undefined): any {
switch (element.name) {
case 'translationbundle':
if (bundle) {
throw new TranslationParseError(
element.sourceSpan, '<translationbundle> elements can not be nested');
}
const langAttr = element.attrs.find((attr) => attr.name === 'lang');
bundle = {locale: langAttr && langAttr.value, translations: {}};
visitAll(this, element.children, bundle);
return bundle;

case 'translation':
if (!bundle) {
throw new TranslationParseError(
element.sourceSpan, '<translation> must be inside a <translationbundle>');
}
const id = getAttrOrThrow(element, 'id');
if (bundle.translations.hasOwnProperty(id)) {
throw new TranslationParseError(
element.sourceSpan, `Duplicated translations for message "${id}"`);
} else {
try {
bundle.translations[id] = serializeTargetMessage(element);
} catch (error) {
if (typeof error === 'string') {
this.diagnostics.warn(
`Could not parse message with id "${id}" - perhaps it has an unrecognised ICU format?\n` +
error);
} else {
throw error;
}
}
}
break;

default:
throw new TranslationParseError(element.sourceSpan, 'Unexpected tag');
}
}
}

function serializeTargetMessage(source: Element): ɵParsedTranslation {
const serializer = new MessageSerializer(
new TargetMessageRenderer(),
{inlineElements: [], placeholder: {elementName: 'ph', nameAttribute: 'name'}});
return serializer.serialize(parseInnerRange(source));
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE translationbundle [<!ELEMENT translationbundle (translation)*>
<!ATTLIST translationbundle lang CDATA #REQUIRED>

<!ELEMENT translation (#PCDATA|ph)*>
<!ATTLIST translation id CDATA #REQUIRED>

<!ELEMENT ph EMPTY>
<!ATTLIST ph name CDATA #REQUIRED>
]>
<translationbundle lang="it">
<translation id="3291030485717846467">Ciao, <ph name="PH"/>!</translation>
</translationbundle>