/** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ import * as xmldom from '@xmldom/xmldom'; import fsExtra from 'fs-extra'; import * as pathLib from 'path'; import type {Config} from '../types/config.js'; import type {XliffConfig} from '../types/formatters.js'; import type {Locale} from '../types/locale.js'; import {Formatter} from './index.js'; import {KnownError, unreachable} from '../error.js'; import { Bundle, Message, ProgramMessage, Placeholder, makeMessageIdMap, } from '../messages.js'; import { getOneElementByTagNameOrThrow, getNonEmptyAttributeOrThrow, } from './xml-utils.js'; /** * Create an XLIFF formatter from a main config object. */ export function xliffFactory(config: Config) { return new XliffFormatter(config); } /** * Formatter for XLIFF v1.2 * https://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html */ export class XliffFormatter implements Formatter { private config: Config; private xliffConfig: XliffConfig; constructor(config: Config) { if (config.interchange.format !== 'xliff') { throw new Error( `Internal error: expected interchange.format "xliff", ` + `got ${config.interchange.format}` ); } this.config = config; this.xliffConfig = config.interchange; } /** * For each target locale, look for the file "/.xlf", and if * it exists, parse out translations. */ readTranslations(): Bundle[] { const bundles: Array = []; for (const locale of this.config.targetLocales) { const xmlStr = this.readTranslationFile(locale); if (!xmlStr) { continue; } bundles.push(this.parseXliff(xmlStr)); } return bundles; } /** * Parse the given XLIFF XML string and return its translations. */ private parseXliff(xmlStr: string): Bundle { const doc = new xmldom.DOMParser().parseFromString(xmlStr); const file = getOneElementByTagNameOrThrow(doc, 'file'); const locale = getNonEmptyAttributeOrThrow( file, 'target-language' ) as Locale; const messages: Message[] = []; const transUnits = file.getElementsByTagName('trans-unit'); for (let t = 0; t < transUnits.length; t++) { const transUnit = transUnits[t]; const name = getNonEmptyAttributeOrThrow(transUnit, 'id'); const targets = transUnit.getElementsByTagName('target'); if (targets.length === 0) { // No means it's not translated yet. continue; } if (targets.length > 1) { throw new KnownError( `Expected 0 or 1 in , got ${targets.length}` ); } const target = targets[0]; const contents: Array = []; for (let c = 0; c < target.childNodes.length; c++) { const child = target.childNodes[c]; if (child.nodeType === doc.TEXT_NODE) { contents.push(child.nodeValue || ''); } else if ( child.nodeType === doc.ELEMENT_NODE && child.nodeName === 'x' ) { const phText = getNonEmptyAttributeOrThrow( child as Element, 'equiv-text' ); const index = Number( getNonEmptyAttributeOrThrow(child as Element, 'id') ); contents.push({untranslatable: phText, index}); } else if ( child.nodeType === doc.ELEMENT_NODE && child.nodeName === 'ph' ) { const phText = child.childNodes[0]; if ( child.childNodes.length !== 1 || !phText || phText.nodeType !== doc.TEXT_NODE ) { throw new KnownError( `Expected <${child.nodeName}> to have exactly one text node` ); } const index = Number( getNonEmptyAttributeOrThrow(child as Element, 'id') ); contents.push({untranslatable: phText.nodeValue || '', index}); } else { throw new KnownError( `Unexpected node in : ${child.nodeType} ${child.nodeName}` ); } } messages.push({name, contents}); } return {locale, messages}; } /** * Write a "/.xlf" file for each target locale. If a message * has already been translated, it will have both a and a . * Otherwise, it will only have a . */ async writeOutput( sourceMessages: ProgramMessage[], translations: Map ): Promise { const xliffDir = this.config.resolve(this.xliffConfig.xliffDir); try { await fsExtra.ensureDir(xliffDir); } catch (e) { throw new KnownError( `Error creating XLIFF directory: ${xliffDir}\n` + `Do you have write permission?\n` + (e as Error).message ); } const writes: Array> = []; for (const targetLocale of this.config.targetLocales) { const existingTargetXmlStr = this.readTranslationFile(targetLocale); const xmlStr = this.encodeLocale( sourceMessages, targetLocale, translations.get(targetLocale) || [], existingTargetXmlStr ? new xmldom.DOMParser().parseFromString(existingTargetXmlStr) : undefined ); const path = pathLib.join(xliffDir, `${targetLocale}.xlf`); writes.push( fsExtra.writeFile(path, xmlStr, 'utf8').catch((e) => { throw new KnownError( `Error creating XLIFF file: ${path}\n` + `Do you have write permission?\n` + (e as Error).message ); }) ); } await Promise.all(writes); } /** * Encode the given locale in XLIFF format. */ private encodeLocale( sourceMessages: ProgramMessage[], targetLocale: Locale, targetMessages: Message[], targetDocument?: XMLDocument ): string { const translationsByName = makeMessageIdMap(targetMessages); const doc = targetDocument || this.initNewTargetDocument(targetLocale); const indent = this.createIndentFunction(doc); const existingTranslationsByName = this.makeExistingTranslationsMap(doc); const body = getOneElementByTagNameOrThrow(doc, 'body'); const names = new Set(); for (const {name, contents: sourceContents, desc} of sourceMessages) { // https://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html#trans-unit let transUnit = existingTranslationsByName.get(name); if (!transUnit) { transUnit = doc.createElement('trans-unit'); transUnit.setAttribute('id', name); this.appendChild(body, transUnit, 0, indent); } // https://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html#source let source = this.getElementByTagName(transUnit, 'source'); if (!source) { source = doc.createElement('source'); this.appendChild(transUnit, source, 1, indent); } else { this.clearElement(source); } for (const child of this.encodeContents(doc, sourceContents)) { source.appendChild(child); } const translation = translationsByName.get(name); if (translation) { // https://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html#target let target = this.getElementByTagName(transUnit, 'target'); if (!target) { target = doc.createElement('target'); this.appendChild(transUnit, target, 1, indent); } else { this.clearElement(target); } for (const child of this.encodeContents(doc, translation.contents)) { target.appendChild(child); } } if (desc) { // https://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html#note let note = this.getNoteElement(transUnit, desc); if (!note) { note = doc.createElement('note'); note.setAttribute('from', 'lit-localize'); this.appendChild(transUnit, note, 1, indent); } else { // migrate existing notes generated by lit-localize if (!note.hasAttribute('from')) { note.setAttribute('from', 'lit-localize'); } this.clearElement(note); } note.appendChild(doc.createTextNode(desc)); } names.add(name); } // clean up obsolete translations existingTranslationsByName.forEach((existingTransUnit, existingName) => { if (!names.has(existingName)) { this.removeChildAndPrecedingText(body, existingTransUnit); } }); if (!targetDocument) { indent(getOneElementByTagNameOrThrow(doc, 'file')); indent(getOneElementByTagNameOrThrow(doc, 'xliff')); } // for existing files this is a guess indent(doc); const serializer = new xmldom.XMLSerializer(); const xmlStr = serializer.serializeToString(doc); return xmlStr; } /** * Encode the given message contents in XLIFF format. */ private encodeContents(doc: Document, contents: Message['contents']): Node[] { const nodes = []; for (const content of contents) { if (typeof content === 'string') { nodes.push(doc.createTextNode(content)); } else { nodes.push(this.createPlaceholder(doc, content)); } } return nodes; } private createPlaceholder( doc: Document, {untranslatable, index}: Placeholder ): HTMLElement { const style = this.xliffConfig.placeholderStyle ?? 'x'; // We need a unique ID within each source for each placeholder. The index // will do. const id = String(index); if (style === 'x') { // https://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html#x const el = doc.createElement('x'); el.setAttribute('id', id); el.setAttribute('equiv-text', untranslatable); return el; } else if (style === 'ph') { // https://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html#ph const el = doc.createElement('ph'); el.setAttribute('id', id); el.appendChild(doc.createTextNode(untranslatable)); return el; } else { throw new Error( `Internal error: unknown xliff placeholderStyle: ${unreachable(style)}` ); } } private initNewTargetDocument(targetLocale: Locale): XMLDocument { const doc = new xmldom.DOMImplementation().createDocument('', '', null); const indent = this.createIndentFunction(doc); doc.appendChild( doc.createProcessingInstruction('xml', 'version="1.0" encoding="UTF-8"') ); indent(doc); // https://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html#xliff const xliff = doc.createElement('xliff'); xliff.setAttribute('version', '1.2'); xliff.setAttribute('xmlns', 'urn:oasis:names:tc:xliff:document:1.2'); doc.appendChild(xliff); indent(xliff); // https://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html#file const file = doc.createElement('file'); xliff.appendChild(file); file.setAttribute('target-language', targetLocale); file.setAttribute('source-language', this.config.sourceLocale); // TODO The spec requires the source filename in the "original" attribute, // but we don't currently track filenames. file.setAttribute('original', 'lit-localize-inputs'); // Plaintext seems right, as opposed to HTML, since our translatable message // text is just text, and all HTML markup is encoded into or // elements. file.setAttribute('datatype', 'plaintext'); indent(file); // https://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html#body const body = doc.createElement('body'); file.appendChild(body); return doc; } private readTranslationFile(locale: Locale): string | undefined { const path = pathLib.join( this.config.resolve(this.xliffConfig.xliffDir), locale + '.xlf' ); try { return fsExtra.readFileSync(path, 'utf8'); } catch (err) { if ((err as Error & {code: string}).code === 'ENOENT') { // It's ok if the file doesn't exist, it's probably just the first // time we're running for this locale. return undefined; } throw err; } } private makeExistingTranslationsMap( document: XMLDocument ): Map { const map = new Map(); const transUnits = document.getElementsByTagName('trans-unit'); for (let t = 0; t < transUnits.length; t++) { const transUnit = transUnits[t]; map.set(getNonEmptyAttributeOrThrow(transUnit, 'id'), transUnit); } return map; } private getElementByTagName( element: Element, tagName: string ): Element | undefined { const matches = element.getElementsByTagName(tagName); return matches.length === 1 ? matches[0] : undefined; } private getNoteElement(element: Element, note: string): Element | undefined { const matches = element.getElementsByTagName('note'); if (matches.length < 1) { return undefined; } for (let i = 0; i < matches.length; i++) { const el = matches.item(i); if (el?.getAttribute('from') === 'lit-localize') { return el; } // generated by previous version of lit-localize if (el?.textContent?.trim() === note) { return el; } } return undefined; } /** * xmldom does not implement replaceChildren. */ private clearElement(element: Element) { const children = element.childNodes; // Iterate backwards so we don't have to worry about the index changing each // time we remove. for (let i = children.length - 1; i >= 0; i--) { element.removeChild(children.item(i)); } } /** * Append childNode to parentNode, try to preserve some * whitespace / indentation for common scenarios. */ private appendChild( parentNode: Node, childNode: Node, level: number, indent: Function ) { const lastChild = parentNode.lastChild; if (lastChild) { parentNode.insertBefore(indent(parentNode, level), lastChild); parentNode.insertBefore(childNode, lastChild); } else { parentNode.appendChild(indent(parentNode, level)); parentNode.appendChild(childNode); parentNode.appendChild(indent(parentNode, level - 1)); } } private removeChildAndPrecedingText(parentNode: Node, childNode: Node) { if (childNode.previousSibling?.nodeType === childNode.TEXT_NODE) { parentNode.removeChild(childNode.previousSibling); } parentNode.removeChild(childNode); } private createIndentFunction(doc: Document): Function { return (node: Element | Document, level = 0) => node.appendChild(doc.createTextNode('\n' + Array(level + 1).join(' '))); } }