@_linked/translation 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -0
- package/lib/cjs/data/translation.json +8 -0
- package/lib/esm/archive.d.ts +133 -0
- package/lib/esm/archive.js +567 -0
- package/lib/esm/archive.js.map +1 -0
- package/lib/esm/authorization.d.ts +12 -0
- package/lib/esm/authorization.js +10 -0
- package/lib/esm/authorization.js.map +1 -0
- package/lib/esm/backend.d.ts +1 -0
- package/lib/esm/backend.js +3 -0
- package/lib/esm/backend.js.map +1 -0
- package/lib/esm/cdn-loader.d.ts +40 -0
- package/lib/esm/cdn-loader.js +200 -0
- package/lib/esm/cdn-loader.js.map +1 -0
- package/lib/esm/checks.d.ts +56 -0
- package/lib/esm/checks.js +288 -0
- package/lib/esm/checks.js.map +1 -0
- package/lib/esm/compile.d.ts +64 -0
- package/lib/esm/compile.js +99 -0
- package/lib/esm/compile.js.map +1 -0
- package/lib/esm/core/messages.d.ts +25 -0
- package/lib/esm/core/messages.js +76 -0
- package/lib/esm/core/messages.js.map +1 -0
- package/lib/esm/data/translation.json +8 -0
- package/lib/esm/discovery.d.ts +97 -0
- package/lib/esm/discovery.js +365 -0
- package/lib/esm/discovery.js.map +1 -0
- package/lib/esm/exchange.d.ts +100 -0
- package/lib/esm/exchange.js +223 -0
- package/lib/esm/exchange.js.map +1 -0
- package/lib/esm/export.d.ts +28 -0
- package/lib/esm/export.js +92 -0
- package/lib/esm/export.js.map +1 -0
- package/lib/esm/formats/csv.d.ts +8 -0
- package/lib/esm/formats/csv.js +397 -0
- package/lib/esm/formats/csv.js.map +1 -0
- package/lib/esm/formats/json.d.ts +30 -0
- package/lib/esm/formats/json.js +459 -0
- package/lib/esm/formats/json.js.map +1 -0
- package/lib/esm/formats/xliff.d.ts +12 -0
- package/lib/esm/formats/xliff.js +577 -0
- package/lib/esm/formats/xliff.js.map +1 -0
- package/lib/esm/import.d.ts +197 -0
- package/lib/esm/import.js +391 -0
- package/lib/esm/import.js.map +1 -0
- package/lib/esm/index.d.ts +42 -0
- package/lib/esm/index.js +34 -0
- package/lib/esm/index.js.map +1 -0
- package/lib/esm/key-sync-node.d.ts +29 -0
- package/lib/esm/key-sync-node.js +174 -0
- package/lib/esm/key-sync-node.js.map +1 -0
- package/lib/esm/key-sync.d.ts +46 -0
- package/lib/esm/key-sync.js +114 -0
- package/lib/esm/key-sync.js.map +1 -0
- package/lib/esm/key-version.d.ts +50 -0
- package/lib/esm/key-version.js +134 -0
- package/lib/esm/key-version.js.map +1 -0
- package/lib/esm/languages.d.ts +18 -0
- package/lib/esm/languages.js +74 -0
- package/lib/esm/languages.js.map +1 -0
- package/lib/esm/linked-discovery.d.ts +44 -0
- package/lib/esm/linked-discovery.js +267 -0
- package/lib/esm/linked-discovery.js.map +1 -0
- package/lib/esm/message-format.d.ts +41 -0
- package/lib/esm/message-format.js +133 -0
- package/lib/esm/message-format.js.map +1 -0
- package/lib/esm/mt.d.ts +111 -0
- package/lib/esm/mt.js +142 -0
- package/lib/esm/mt.js.map +1 -0
- package/lib/esm/ontologies/translation.d.ts +203 -0
- package/lib/esm/ontologies/translation.js +219 -0
- package/lib/esm/ontologies/translation.js.map +1 -0
- package/lib/esm/package.d.ts +4 -0
- package/lib/esm/package.js +11 -0
- package/lib/esm/package.js.map +1 -0
- package/lib/esm/react.d.ts +55 -0
- package/lib/esm/react.js +127 -0
- package/lib/esm/react.js.map +1 -0
- package/lib/esm/records.d.ts +151 -0
- package/lib/esm/records.js +2 -0
- package/lib/esm/records.js.map +1 -0
- package/lib/esm/release.d.ts +198 -0
- package/lib/esm/release.js +435 -0
- package/lib/esm/release.js.map +1 -0
- package/lib/esm/shape-catalog.d.ts +79 -0
- package/lib/esm/shape-catalog.js +118 -0
- package/lib/esm/shape-catalog.js.map +1 -0
- package/lib/esm/shapes/GlossaryTerm.d.ts +28 -0
- package/lib/esm/shapes/GlossaryTerm.js +94 -0
- package/lib/esm/shapes/GlossaryTerm.js.map +1 -0
- package/lib/esm/shapes/TranslationInventoryOrigin.d.ts +33 -0
- package/lib/esm/shapes/TranslationInventoryOrigin.js +209 -0
- package/lib/esm/shapes/TranslationInventoryOrigin.js.map +1 -0
- package/lib/esm/shapes/TranslationKey.d.ts +92 -0
- package/lib/esm/shapes/TranslationKey.js +194 -0
- package/lib/esm/shapes/TranslationKey.js.map +1 -0
- package/lib/esm/shapes/TranslationKeyVersion.d.ts +19 -0
- package/lib/esm/shapes/TranslationKeyVersion.js +125 -0
- package/lib/esm/shapes/TranslationKeyVersion.js.map +1 -0
- package/lib/esm/shapes/TranslationLanguage.d.ts +15 -0
- package/lib/esm/shapes/TranslationLanguage.js +102 -0
- package/lib/esm/shapes/TranslationLanguage.js.map +1 -0
- package/lib/esm/shapes/TranslationProvider.d.ts +409 -0
- package/lib/esm/shapes/TranslationProvider.js +1376 -0
- package/lib/esm/shapes/TranslationProvider.js.map +1 -0
- package/lib/esm/shapes/TranslationRelease.d.ts +36 -0
- package/lib/esm/shapes/TranslationRelease.js +149 -0
- package/lib/esm/shapes/TranslationRelease.js.map +1 -0
- package/lib/esm/shapes/TranslationRevision.d.ts +39 -0
- package/lib/esm/shapes/TranslationRevision.js +159 -0
- package/lib/esm/shapes/TranslationRevision.js.map +1 -0
- package/lib/esm/shapes/TranslationUnit.d.ts +26 -0
- package/lib/esm/shapes/TranslationUnit.js +111 -0
- package/lib/esm/shapes/TranslationUnit.js.map +1 -0
- package/package.json +134 -0
- package/src/archive.ts +894 -0
- package/src/authorization.ts +33 -0
- package/src/backend.ts +2 -0
- package/src/cdn-loader.ts +264 -0
- package/src/checks.ts +413 -0
- package/src/compile.ts +168 -0
- package/src/core/messages.ts +106 -0
- package/src/data/translation.json +8 -0
- package/src/discovery.ts +594 -0
- package/src/exchange.ts +428 -0
- package/src/export.ts +135 -0
- package/src/formats/csv.ts +459 -0
- package/src/formats/json.ts +587 -0
- package/src/formats/xliff.ts +708 -0
- package/src/import.ts +648 -0
- package/src/index.ts +207 -0
- package/src/key-sync-node.ts +269 -0
- package/src/key-sync.ts +168 -0
- package/src/key-version.ts +206 -0
- package/src/languages.ts +102 -0
- package/src/linked-discovery.ts +385 -0
- package/src/message-format.ts +214 -0
- package/src/mt.ts +248 -0
- package/src/ontologies/translation.ts +234 -0
- package/src/package.ts +19 -0
- package/src/react.tsx +250 -0
- package/src/records.ts +180 -0
- package/src/release.ts +726 -0
- package/src/shape-catalog.ts +198 -0
- package/src/shapes/GlossaryTerm.ts +61 -0
- package/src/shapes/TranslationInventoryOrigin.ts +131 -0
- package/src/shapes/TranslationKey.ts +174 -0
- package/src/shapes/TranslationKeyVersion.ts +77 -0
- package/src/shapes/TranslationLanguage.ts +51 -0
- package/src/shapes/TranslationProvider.ts +1755 -0
- package/src/shapes/TranslationRelease.ts +114 -0
- package/src/shapes/TranslationRevision.ts +105 -0
- package/src/shapes/TranslationUnit.ts +72 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeParsedTranslationExchangeDocument,
|
|
3
|
+
type ParsedTranslationExchangeDocument,
|
|
4
|
+
type ParsedTranslationExchangeEntry,
|
|
5
|
+
type TranslationExchangeDocument,
|
|
6
|
+
type TranslationExchangeEntry,
|
|
7
|
+
type TranslationExchangeValue,
|
|
8
|
+
type TranslationFormatAdapter,
|
|
9
|
+
} from '../exchange.js';
|
|
10
|
+
import type { TranslationState } from '../records.js';
|
|
11
|
+
|
|
12
|
+
const UTF8 = new TextEncoder();
|
|
13
|
+
const CN_NAMESPACE = 'https://create-now.app/ns/translation-exchange/1';
|
|
14
|
+
const XLIFF_12_NAMESPACE = 'urn:oasis:names:tc:xliff:document:1.2';
|
|
15
|
+
const XLIFF_20_NAMESPACE = 'urn:oasis:names:tc:xliff:document:2.0';
|
|
16
|
+
const XML_MEDIA_TYPE = 'application/xliff+xml';
|
|
17
|
+
|
|
18
|
+
const INLINE_12 = new Set([
|
|
19
|
+
'bpt',
|
|
20
|
+
'bx',
|
|
21
|
+
'ept',
|
|
22
|
+
'ex',
|
|
23
|
+
'g',
|
|
24
|
+
'it',
|
|
25
|
+
'mrk',
|
|
26
|
+
'ph',
|
|
27
|
+
'sub',
|
|
28
|
+
'x',
|
|
29
|
+
]);
|
|
30
|
+
const INLINE_20 = new Set(['ec', 'em', 'mrk', 'pc', 'ph', 'sc', 'sm']);
|
|
31
|
+
|
|
32
|
+
type XmlChild = XmlNode | string;
|
|
33
|
+
|
|
34
|
+
interface XmlNode {
|
|
35
|
+
name: string;
|
|
36
|
+
attributes: Record<string, string>;
|
|
37
|
+
children: XmlChild[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface XliffFinding {
|
|
41
|
+
code: 'unsupported-inline';
|
|
42
|
+
message: string;
|
|
43
|
+
element: string;
|
|
44
|
+
unitId?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface XliffParseOptions {
|
|
48
|
+
onFinding?: (finding: XliffFinding) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface MutableParsedEntry extends ParsedTranslationExchangeEntry {
|
|
52
|
+
translations: Record<string, TranslationExchangeValue>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function text(input: Uint8Array): string {
|
|
56
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(input);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function localName(name: string): string {
|
|
60
|
+
const separator = name.indexOf(':');
|
|
61
|
+
return separator === -1 ? name : name.slice(separator + 1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function escapeText(value: string): string {
|
|
65
|
+
return value
|
|
66
|
+
.replaceAll('&', '&')
|
|
67
|
+
.replaceAll('<', '<')
|
|
68
|
+
.replaceAll('>', '>');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function escapeAttribute(value: string): string {
|
|
72
|
+
return escapeText(value)
|
|
73
|
+
.replaceAll('"', '"')
|
|
74
|
+
.replaceAll("'", ''');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function decodeEntities(value: string): string {
|
|
78
|
+
const decoded = value.replace(
|
|
79
|
+
/&(#x[0-9a-fA-F]+|#[0-9]+|amp|lt|gt|quot|apos);/g,
|
|
80
|
+
(entity, body: string) => {
|
|
81
|
+
if (body === 'amp') return '&';
|
|
82
|
+
if (body === 'lt') return '<';
|
|
83
|
+
if (body === 'gt') return '>';
|
|
84
|
+
if (body === 'quot') return '"';
|
|
85
|
+
if (body === 'apos') return "'";
|
|
86
|
+
const point = Number.parseInt(body.slice(body[1] === 'x' ? 2 : 1), body[1] === 'x' ? 16 : 10);
|
|
87
|
+
if (!Number.isFinite(point) || point > 0x10ffff) {
|
|
88
|
+
throw new Error(`Invalid XML character reference "&${body};".`);
|
|
89
|
+
}
|
|
90
|
+
return String.fromCodePoint(point);
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
const unresolved = /&([A-Za-z_:][\w.:-]*);/.exec(decoded);
|
|
94
|
+
if (unresolved) {
|
|
95
|
+
throw new Error(`Unsupported XML entity reference "&${unresolved[1]};".`);
|
|
96
|
+
}
|
|
97
|
+
return decoded;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readTagEnd(xml: string, start: number): number {
|
|
101
|
+
let quote = '';
|
|
102
|
+
for (let index = start; index < xml.length; index += 1) {
|
|
103
|
+
const character = xml[index];
|
|
104
|
+
if (quote) {
|
|
105
|
+
if (character === quote) quote = '';
|
|
106
|
+
} else if (character === '"' || character === "'") {
|
|
107
|
+
quote = character;
|
|
108
|
+
} else if (character === '>') {
|
|
109
|
+
return index;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
throw new Error('Malformed XML: unterminated tag.');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseAttributes(input: string): {
|
|
116
|
+
name: string;
|
|
117
|
+
attributes: Record<string, string>;
|
|
118
|
+
selfClosing: boolean;
|
|
119
|
+
} {
|
|
120
|
+
let content = input.trim();
|
|
121
|
+
const selfClosing = content.endsWith('/');
|
|
122
|
+
if (selfClosing) content = content.slice(0, -1).trimEnd();
|
|
123
|
+
const nameMatch = /^([^\s/>]+)/.exec(content);
|
|
124
|
+
if (!nameMatch) throw new Error('Malformed XML: element name is missing.');
|
|
125
|
+
const name = nameMatch[1];
|
|
126
|
+
const attributes: Record<string, string> = {};
|
|
127
|
+
let remainder = content.slice(name.length);
|
|
128
|
+
const attributePattern = /^\s+([^\s=/>]+)\s*=\s*("([^"]*)"|'([^']*)')/;
|
|
129
|
+
while (remainder.trim()) {
|
|
130
|
+
const match = attributePattern.exec(remainder);
|
|
131
|
+
if (!match) {
|
|
132
|
+
throw new Error(`Malformed XML attributes on <${name}>.`);
|
|
133
|
+
}
|
|
134
|
+
if (attributes[match[1]] !== undefined) {
|
|
135
|
+
throw new Error(`Duplicate XML attribute "${match[1]}".`);
|
|
136
|
+
}
|
|
137
|
+
attributes[match[1]] = decodeEntities(match[3] ?? match[4] ?? '');
|
|
138
|
+
remainder = remainder.slice(match[0].length);
|
|
139
|
+
}
|
|
140
|
+
return { name, attributes, selfClosing };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Small, non-validating XML parser for the deliberately narrow XLIFF surface.
|
|
145
|
+
* DTDs and entity declarations are rejected before tokenization, so parsing
|
|
146
|
+
* never performs filesystem or network resolution.
|
|
147
|
+
*/
|
|
148
|
+
function parseXml(xml: string): XmlNode {
|
|
149
|
+
if (/<!DOCTYPE\b/i.test(xml) || /<!ENTITY\b/i.test(xml)) {
|
|
150
|
+
throw new Error('XLIFF containing a DTD or entity declaration is not allowed.');
|
|
151
|
+
}
|
|
152
|
+
const root: XmlNode = {
|
|
153
|
+
name: '#document',
|
|
154
|
+
attributes: {},
|
|
155
|
+
children: [],
|
|
156
|
+
};
|
|
157
|
+
const stack = [root];
|
|
158
|
+
let cursor = 0;
|
|
159
|
+
while (cursor < xml.length) {
|
|
160
|
+
const opening = xml.indexOf('<', cursor);
|
|
161
|
+
if (opening === -1) {
|
|
162
|
+
const trailing = xml.slice(cursor);
|
|
163
|
+
if (trailing) stack.at(-1)!.children.push(decodeEntities(trailing));
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
if (opening > cursor) {
|
|
167
|
+
stack.at(-1)!.children.push(decodeEntities(xml.slice(cursor, opening)));
|
|
168
|
+
}
|
|
169
|
+
if (xml.startsWith('<!--', opening)) {
|
|
170
|
+
const closing = xml.indexOf('-->', opening + 4);
|
|
171
|
+
if (closing === -1) throw new Error('Malformed XML comment.');
|
|
172
|
+
cursor = closing + 3;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (xml.startsWith('<?', opening)) {
|
|
176
|
+
const closing = xml.indexOf('?>', opening + 2);
|
|
177
|
+
if (closing === -1) throw new Error('Malformed XML processing instruction.');
|
|
178
|
+
cursor = closing + 2;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (xml.startsWith('<![CDATA[', opening)) {
|
|
182
|
+
const closing = xml.indexOf(']]>', opening + 9);
|
|
183
|
+
if (closing === -1) throw new Error('Malformed XML CDATA section.');
|
|
184
|
+
stack.at(-1)!.children.push(xml.slice(opening + 9, closing));
|
|
185
|
+
cursor = closing + 3;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (xml.startsWith('</', opening)) {
|
|
189
|
+
const closing = readTagEnd(xml, opening + 2);
|
|
190
|
+
const name = xml.slice(opening + 2, closing).trim();
|
|
191
|
+
const current = stack.pop();
|
|
192
|
+
if (!current || current === root || current.name !== name) {
|
|
193
|
+
throw new Error(`Malformed XML closing tag </${name}>.`);
|
|
194
|
+
}
|
|
195
|
+
cursor = closing + 1;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (xml.startsWith('<!', opening)) {
|
|
199
|
+
throw new Error('Unsupported XML declaration.');
|
|
200
|
+
}
|
|
201
|
+
const closing = readTagEnd(xml, opening + 1);
|
|
202
|
+
const parsed = parseAttributes(xml.slice(opening + 1, closing));
|
|
203
|
+
const node: XmlNode = {
|
|
204
|
+
name: parsed.name,
|
|
205
|
+
attributes: parsed.attributes,
|
|
206
|
+
children: [],
|
|
207
|
+
};
|
|
208
|
+
stack.at(-1)!.children.push(node);
|
|
209
|
+
if (!parsed.selfClosing) stack.push(node);
|
|
210
|
+
cursor = closing + 1;
|
|
211
|
+
}
|
|
212
|
+
if (stack.length !== 1) {
|
|
213
|
+
throw new Error(`Malformed XML: unclosed <${stack.at(-1)!.name}> element.`);
|
|
214
|
+
}
|
|
215
|
+
const documentElements = root.children.filter(
|
|
216
|
+
(child): child is XmlNode => typeof child !== 'string',
|
|
217
|
+
);
|
|
218
|
+
if (documentElements.length !== 1) {
|
|
219
|
+
throw new Error('XLIFF must contain exactly one document element.');
|
|
220
|
+
}
|
|
221
|
+
return documentElements[0];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function children(node: XmlNode, name: string): XmlNode[] {
|
|
225
|
+
return node.children.filter(
|
|
226
|
+
(child): child is XmlNode =>
|
|
227
|
+
typeof child !== 'string' && localName(child.name) === name,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function descendants(node: XmlNode, name: string): XmlNode[] {
|
|
232
|
+
const found: XmlNode[] = [];
|
|
233
|
+
for (const child of node.children) {
|
|
234
|
+
if (typeof child === 'string') continue;
|
|
235
|
+
if (localName(child.name) === name) found.push(child);
|
|
236
|
+
found.push(...descendants(child, name));
|
|
237
|
+
}
|
|
238
|
+
return found;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function first(node: XmlNode, name: string): XmlNode | undefined {
|
|
242
|
+
return children(node, name)[0];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function attribute(node: XmlNode, name: string): string | undefined {
|
|
246
|
+
const direct = node.attributes[name];
|
|
247
|
+
if (direct !== undefined) return direct;
|
|
248
|
+
const entry = Object.entries(node.attributes).find(
|
|
249
|
+
([qualified]) => localName(qualified) === name,
|
|
250
|
+
);
|
|
251
|
+
return entry?.[1];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function serializeNode(node: XmlNode): string {
|
|
255
|
+
const attributes = Object.entries(node.attributes)
|
|
256
|
+
.map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
|
|
257
|
+
.join('');
|
|
258
|
+
if (node.children.length === 0) return `<${node.name}${attributes}/>`;
|
|
259
|
+
return `<${node.name}${attributes}>${node.children
|
|
260
|
+
.map((child) =>
|
|
261
|
+
typeof child === 'string' ? escapeText(child) : serializeNode(child),
|
|
262
|
+
)
|
|
263
|
+
.join('')}</${node.name}>`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function innerXml(
|
|
267
|
+
node: XmlNode | undefined,
|
|
268
|
+
supportedInline: Set<string>,
|
|
269
|
+
options: XliffParseOptions,
|
|
270
|
+
unitId?: string,
|
|
271
|
+
): string | undefined {
|
|
272
|
+
if (!node) return undefined;
|
|
273
|
+
const visit = (parent: XmlNode): void => {
|
|
274
|
+
for (const child of parent.children) {
|
|
275
|
+
if (typeof child === 'string') continue;
|
|
276
|
+
const name = localName(child.name);
|
|
277
|
+
if (!supportedInline.has(name)) {
|
|
278
|
+
options.onFinding?.({
|
|
279
|
+
code: 'unsupported-inline',
|
|
280
|
+
message: `Unsupported inline XLIFF element <${child.name}> was preserved.`,
|
|
281
|
+
element: child.name,
|
|
282
|
+
unitId,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
visit(child);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
visit(node);
|
|
289
|
+
return node.children
|
|
290
|
+
.map((child) =>
|
|
291
|
+
typeof child === 'string' ? child : serializeNode(child),
|
|
292
|
+
)
|
|
293
|
+
.join('');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function plainText(node: XmlNode | undefined): string | undefined {
|
|
297
|
+
if (!node) return undefined;
|
|
298
|
+
return node.children
|
|
299
|
+
.map((child) => (typeof child === 'string' ? child : plainText(child) ?? ''))
|
|
300
|
+
.join('');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function metadata(node: XmlNode, name: string): string | undefined {
|
|
304
|
+
return node.attributes[`cn:${name}`] ?? attribute(node, name);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function recognizedState(value: string | undefined): TranslationState | undefined {
|
|
308
|
+
if (
|
|
309
|
+
value === 'untranslated' ||
|
|
310
|
+
value === 'machine' ||
|
|
311
|
+
value === 'reviewed' ||
|
|
312
|
+
value === 'stale'
|
|
313
|
+
) {
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
if (value === 'final' || value === 'reviewed' || value === 'signed-off') {
|
|
317
|
+
return 'reviewed';
|
|
318
|
+
}
|
|
319
|
+
if (value === 'translated') return 'machine';
|
|
320
|
+
if (value === 'needs-review-translation') return 'stale';
|
|
321
|
+
if (value === 'new' || value === 'initial') return 'untranslated';
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function commonDocument(root: XmlNode): Omit<
|
|
326
|
+
ParsedTranslationExchangeDocument,
|
|
327
|
+
'sourceLanguage' | 'targetLanguages' | 'entries'
|
|
328
|
+
> {
|
|
329
|
+
return {
|
|
330
|
+
schemaVersion: 1,
|
|
331
|
+
...(metadata(root, 'app-id') ? { appId: metadata(root, 'app-id') } : {}),
|
|
332
|
+
...(metadata(root, 'branch-id')
|
|
333
|
+
? { branchId: metadata(root, 'branch-id') }
|
|
334
|
+
: {}),
|
|
335
|
+
...(metadata(root, 'exported-at')
|
|
336
|
+
? { exportedAt: metadata(root, 'exported-at') }
|
|
337
|
+
: {}),
|
|
338
|
+
...(metadata(root, 'revision-watermark')
|
|
339
|
+
? { revisionWatermark: metadata(root, 'revision-watermark') }
|
|
340
|
+
: {}),
|
|
341
|
+
...(metadata(root, 'contract-set-hash')
|
|
342
|
+
? { contractSetHash: metadata(root, 'contract-set-hash') }
|
|
343
|
+
: {}),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function mergeUnit(
|
|
348
|
+
entries: Map<string, MutableParsedEntry>,
|
|
349
|
+
unit: XmlNode,
|
|
350
|
+
sourceText: string | undefined,
|
|
351
|
+
targetText: string | undefined,
|
|
352
|
+
targetLanguage: string | undefined,
|
|
353
|
+
targetState: TranslationState | undefined,
|
|
354
|
+
targetNote: string | undefined,
|
|
355
|
+
description: string | undefined,
|
|
356
|
+
): void {
|
|
357
|
+
const key = metadata(unit, 'key') ?? attribute(unit, 'resname') ?? attribute(unit, 'name') ?? attribute(unit, 'id');
|
|
358
|
+
if (!key) throw new Error('XLIFF unit is missing an id/key.');
|
|
359
|
+
const namespace = metadata(unit, 'namespace');
|
|
360
|
+
const identity = `${namespace ?? ''}\u0000${key}`;
|
|
361
|
+
let entry = entries.get(identity);
|
|
362
|
+
if (!entry) {
|
|
363
|
+
entry = {
|
|
364
|
+
key,
|
|
365
|
+
...(namespace ? { namespace } : {}),
|
|
366
|
+
kind: metadata(unit, 'kind') === 'semantic' ? 'semantic' : 'ui',
|
|
367
|
+
format: metadata(unit, 'format') === 'icu' ? 'icu' : 'simple',
|
|
368
|
+
...(sourceText !== undefined ? { sourceText } : {}),
|
|
369
|
+
...(metadata(unit, 'key-version-id')
|
|
370
|
+
? { keyVersionId: metadata(unit, 'key-version-id') }
|
|
371
|
+
: {}),
|
|
372
|
+
...(metadata(unit, 'source-hash')
|
|
373
|
+
? { sourceHash: metadata(unit, 'source-hash') }
|
|
374
|
+
: {}),
|
|
375
|
+
...(metadata(unit, 'contract-hash')
|
|
376
|
+
? { contractHash: metadata(unit, 'contract-hash') }
|
|
377
|
+
: {}),
|
|
378
|
+
...(metadata(unit, 'argument-signature')
|
|
379
|
+
? { argumentSignature: metadata(unit, 'argument-signature') }
|
|
380
|
+
: {}),
|
|
381
|
+
...(description ? { description } : {}),
|
|
382
|
+
translations: {},
|
|
383
|
+
};
|
|
384
|
+
entries.set(identity, entry);
|
|
385
|
+
} else if (sourceText !== undefined && entry.sourceText !== sourceText) {
|
|
386
|
+
throw new Error(`XLIFF source text conflicts for key "${key}".`);
|
|
387
|
+
}
|
|
388
|
+
if (targetLanguage && targetText !== undefined) {
|
|
389
|
+
if (entry.translations[targetLanguage]) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
`Duplicate XLIFF target for key "${key}" in ${targetLanguage}.`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
entry.translations[targetLanguage] = {
|
|
395
|
+
text: targetText,
|
|
396
|
+
...(targetState ? { state: targetState } : {}),
|
|
397
|
+
...(targetNote ? { note: targetNote } : {}),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function parse12(
|
|
403
|
+
root: XmlNode,
|
|
404
|
+
options: XliffParseOptions,
|
|
405
|
+
): ParsedTranslationExchangeDocument {
|
|
406
|
+
const entries = new Map<string, MutableParsedEntry>();
|
|
407
|
+
let sourceLanguage = attribute(root, 'source-language');
|
|
408
|
+
const targetLanguages = new Set<string>();
|
|
409
|
+
for (const file of descendants(root, 'file')) {
|
|
410
|
+
sourceLanguage ??= attribute(file, 'source-language');
|
|
411
|
+
const targetLanguage = attribute(file, 'target-language');
|
|
412
|
+
if (targetLanguage) targetLanguages.add(targetLanguage);
|
|
413
|
+
for (const unit of descendants(file, 'trans-unit')) {
|
|
414
|
+
const id = attribute(unit, 'id');
|
|
415
|
+
const sourceText = innerXml(first(unit, 'source'), INLINE_12, options, id);
|
|
416
|
+
const target = first(unit, 'target');
|
|
417
|
+
const language =
|
|
418
|
+
attribute(target ?? unit, 'lang') ?? targetLanguage;
|
|
419
|
+
if (language) targetLanguages.add(language);
|
|
420
|
+
const notes = children(unit, 'note');
|
|
421
|
+
const description = plainText(
|
|
422
|
+
notes.find((note) => attribute(note, 'from') === 'create-now-description'),
|
|
423
|
+
);
|
|
424
|
+
const targetNote = plainText(
|
|
425
|
+
notes.find(
|
|
426
|
+
(note) =>
|
|
427
|
+
attribute(note, 'from') === 'create-now-target' &&
|
|
428
|
+
(!language || !attribute(note, 'lang') || attribute(note, 'lang') === language),
|
|
429
|
+
),
|
|
430
|
+
);
|
|
431
|
+
mergeUnit(
|
|
432
|
+
entries,
|
|
433
|
+
unit,
|
|
434
|
+
sourceText,
|
|
435
|
+
innerXml(target, INLINE_12, options, id),
|
|
436
|
+
language,
|
|
437
|
+
recognizedState(metadata(target ?? unit, 'state') ?? attribute(target ?? unit, 'state')),
|
|
438
|
+
targetNote,
|
|
439
|
+
description ?? plainText(notes[0]),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (!sourceLanguage) throw new Error('XLIFF 1.2 source language is missing.');
|
|
444
|
+
return normalizeParsedTranslationExchangeDocument({
|
|
445
|
+
...commonDocument(root),
|
|
446
|
+
sourceLanguage,
|
|
447
|
+
targetLanguages: [...targetLanguages],
|
|
448
|
+
entries: [...entries.values()],
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function parse20(
|
|
453
|
+
root: XmlNode,
|
|
454
|
+
options: XliffParseOptions,
|
|
455
|
+
): ParsedTranslationExchangeDocument {
|
|
456
|
+
const entries = new Map<string, MutableParsedEntry>();
|
|
457
|
+
const sourceLanguage = attribute(root, 'srcLang');
|
|
458
|
+
if (!sourceLanguage) throw new Error('XLIFF 2.0 srcLang is missing.');
|
|
459
|
+
const targetLanguages = new Set<string>();
|
|
460
|
+
const rootTargetLanguage = attribute(root, 'trgLang');
|
|
461
|
+
if (rootTargetLanguage) targetLanguages.add(rootTargetLanguage);
|
|
462
|
+
for (const file of descendants(root, 'file')) {
|
|
463
|
+
const fileTargetLanguage =
|
|
464
|
+
attribute(file, 'trgLang') ??
|
|
465
|
+
metadata(file, 'target-language') ??
|
|
466
|
+
rootTargetLanguage;
|
|
467
|
+
if (fileTargetLanguage) targetLanguages.add(fileTargetLanguage);
|
|
468
|
+
for (const unit of descendants(file, 'unit')) {
|
|
469
|
+
const segment = first(unit, 'segment') ?? descendants(unit, 'segment')[0];
|
|
470
|
+
if (!segment) continue;
|
|
471
|
+
const target = first(segment, 'target');
|
|
472
|
+
const language = attribute(target ?? unit, 'lang') ?? fileTargetLanguage;
|
|
473
|
+
if (language) targetLanguages.add(language);
|
|
474
|
+
const notesContainer = first(unit, 'notes');
|
|
475
|
+
const notes = notesContainer ? children(notesContainer, 'note') : [];
|
|
476
|
+
const description = plainText(
|
|
477
|
+
notes.find(
|
|
478
|
+
(note) => attribute(note, 'category') === 'create-now-description',
|
|
479
|
+
),
|
|
480
|
+
);
|
|
481
|
+
const targetNote = plainText(
|
|
482
|
+
notes.find(
|
|
483
|
+
(note) =>
|
|
484
|
+
attribute(note, 'category') === 'create-now-target' &&
|
|
485
|
+
(!language || !attribute(note, 'lang') || attribute(note, 'lang') === language),
|
|
486
|
+
),
|
|
487
|
+
);
|
|
488
|
+
const id = attribute(unit, 'id');
|
|
489
|
+
mergeUnit(
|
|
490
|
+
entries,
|
|
491
|
+
unit,
|
|
492
|
+
innerXml(first(segment, 'source'), INLINE_20, options, id),
|
|
493
|
+
innerXml(target, INLINE_20, options, id),
|
|
494
|
+
language,
|
|
495
|
+
recognizedState(metadata(segment, 'state') ?? attribute(segment, 'state')),
|
|
496
|
+
targetNote,
|
|
497
|
+
description ?? plainText(notes[0]),
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return normalizeParsedTranslationExchangeDocument({
|
|
502
|
+
...commonDocument(root),
|
|
503
|
+
sourceLanguage,
|
|
504
|
+
targetLanguages: [...targetLanguages],
|
|
505
|
+
entries: [...entries.values()],
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function parseXliff(
|
|
510
|
+
input: Uint8Array,
|
|
511
|
+
expectedVersion: '1.2' | '2.0',
|
|
512
|
+
options: XliffParseOptions = {},
|
|
513
|
+
): ParsedTranslationExchangeDocument {
|
|
514
|
+
const root = parseXml(text(input));
|
|
515
|
+
if (localName(root.name) !== 'xliff') {
|
|
516
|
+
throw new Error('Document is not XLIFF.');
|
|
517
|
+
}
|
|
518
|
+
const version = attribute(root, 'version');
|
|
519
|
+
if (version !== expectedVersion) {
|
|
520
|
+
throw new Error(
|
|
521
|
+
`Expected XLIFF ${expectedVersion}, received ${version ?? 'an unknown version'}.`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
return version === '1.2' ? parse12(root, options) : parse20(root, options);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function deterministicId(entry: TranslationExchangeEntry, index: number): string {
|
|
528
|
+
const identity = `${entry.namespace ?? 'default'}:${entry.key}`;
|
|
529
|
+
return `u${index + 1}-${encodeURIComponent(identity).replaceAll('%', '_')}`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function state12(state: TranslationState | undefined): string {
|
|
533
|
+
if (state === 'reviewed') return 'final';
|
|
534
|
+
if (state === 'stale') return 'needs-review-translation';
|
|
535
|
+
if (state === 'machine') return 'translated';
|
|
536
|
+
return 'new';
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function state20(state: TranslationState | undefined): string {
|
|
540
|
+
if (state === 'reviewed') return 'final';
|
|
541
|
+
if (state === 'stale') return 'translated';
|
|
542
|
+
if (state === 'machine') return 'translated';
|
|
543
|
+
return 'initial';
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function metadataAttributes(entry: TranslationExchangeEntry): string {
|
|
547
|
+
return [
|
|
548
|
+
['cn:key', entry.key],
|
|
549
|
+
['cn:namespace', entry.namespace],
|
|
550
|
+
['cn:kind', entry.kind],
|
|
551
|
+
['cn:format', entry.format],
|
|
552
|
+
['cn:key-version-id', entry.keyVersionId],
|
|
553
|
+
['cn:source-hash', entry.sourceHash],
|
|
554
|
+
['cn:contract-hash', entry.contractHash],
|
|
555
|
+
['cn:argument-signature', entry.argumentSignature],
|
|
556
|
+
]
|
|
557
|
+
.filter((pair): pair is [string, string] => pair[1] !== undefined)
|
|
558
|
+
.map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
|
|
559
|
+
.join('');
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function rootMetadata(document: TranslationExchangeDocument): string {
|
|
563
|
+
return [
|
|
564
|
+
['cn:schema-version', String(document.schemaVersion)],
|
|
565
|
+
['cn:app-id', document.appId],
|
|
566
|
+
['cn:branch-id', document.branchId],
|
|
567
|
+
['cn:exported-at', document.exportedAt],
|
|
568
|
+
['cn:revision-watermark', document.revisionWatermark],
|
|
569
|
+
['cn:contract-set-hash', document.contractSetHash],
|
|
570
|
+
]
|
|
571
|
+
.filter((pair): pair is [string, string] => pair[1] !== undefined)
|
|
572
|
+
.map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
|
|
573
|
+
.join('');
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function validatedMixedContent(value: string, inline: Set<string>): string {
|
|
577
|
+
if (!value.includes('<')) return escapeText(value);
|
|
578
|
+
try {
|
|
579
|
+
const wrapper = parseXml(`<cn-wrapper>${value}</cn-wrapper>`);
|
|
580
|
+
const nodes = wrapper.children.filter(
|
|
581
|
+
(child): child is XmlNode => typeof child !== 'string',
|
|
582
|
+
);
|
|
583
|
+
if (
|
|
584
|
+
nodes.length === 0 ||
|
|
585
|
+
nodes.some((node) => !inline.has(localName(node.name)))
|
|
586
|
+
) {
|
|
587
|
+
return escapeText(value);
|
|
588
|
+
}
|
|
589
|
+
return wrapper.children
|
|
590
|
+
.map((child) =>
|
|
591
|
+
typeof child === 'string' ? escapeText(child) : serializeNode(child),
|
|
592
|
+
)
|
|
593
|
+
.join('');
|
|
594
|
+
} catch {
|
|
595
|
+
return escapeText(value);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function targets(document: TranslationExchangeDocument): Array<string | undefined> {
|
|
600
|
+
return document.targetLanguages.length > 0
|
|
601
|
+
? document.targetLanguages
|
|
602
|
+
: [undefined];
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function serialize12(document: TranslationExchangeDocument): string {
|
|
606
|
+
const files = targets(document)
|
|
607
|
+
.map((language, fileIndex) => {
|
|
608
|
+
const units = document.entries
|
|
609
|
+
.map((entry, index) => {
|
|
610
|
+
const target = language ? entry.translations[language] : undefined;
|
|
611
|
+
const id = deterministicId(entry, index);
|
|
612
|
+
const notes = [
|
|
613
|
+
entry.description
|
|
614
|
+
? `<note from="create-now-description">${escapeText(entry.description)}</note>`
|
|
615
|
+
: '',
|
|
616
|
+
target?.note
|
|
617
|
+
? `<note from="create-now-target" xml:lang="${escapeAttribute(language!)}">${escapeText(target.note)}</note>`
|
|
618
|
+
: '',
|
|
619
|
+
].join('');
|
|
620
|
+
return `<trans-unit id="${escapeAttribute(id)}" resname="${escapeAttribute(entry.key)}"${metadataAttributes(entry)}><source>${validatedMixedContent(entry.sourceText, INLINE_12)}</source>${
|
|
621
|
+
language && target
|
|
622
|
+
? `<target xml:lang="${escapeAttribute(language)}" state="${state12(target?.state)}" cn:state="${escapeAttribute(target?.state ?? 'untranslated')}">${validatedMixedContent(target?.text ?? '', INLINE_12)}</target>`
|
|
623
|
+
: ''
|
|
624
|
+
}${notes}</trans-unit>`;
|
|
625
|
+
})
|
|
626
|
+
.join('');
|
|
627
|
+
return `<file id="f${fileIndex + 1}" original="create-now" datatype="plaintext" source-language="${escapeAttribute(document.sourceLanguage)}"${
|
|
628
|
+
language ? ` target-language="${escapeAttribute(language)}"` : ''
|
|
629
|
+
}><body>${units}</body></file>`;
|
|
630
|
+
})
|
|
631
|
+
.join('');
|
|
632
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<xliff xmlns="${XLIFF_12_NAMESPACE}" xmlns:cn="${CN_NAMESPACE}" version="1.2"${rootMetadata(document)}>${files}</xliff>\n`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function serialize20(document: TranslationExchangeDocument): string {
|
|
636
|
+
const files = targets(document)
|
|
637
|
+
.map((language, fileIndex) => {
|
|
638
|
+
const units = document.entries
|
|
639
|
+
.map((entry, index) => {
|
|
640
|
+
const target = language ? entry.translations[language] : undefined;
|
|
641
|
+
const id = deterministicId(entry, index);
|
|
642
|
+
const notes =
|
|
643
|
+
entry.description || target?.note
|
|
644
|
+
? `<notes>${
|
|
645
|
+
entry.description
|
|
646
|
+
? `<note category="create-now-description">${escapeText(entry.description)}</note>`
|
|
647
|
+
: ''
|
|
648
|
+
}${
|
|
649
|
+
target?.note
|
|
650
|
+
? `<note category="create-now-target" xml:lang="${escapeAttribute(language!)}">${escapeText(target.note)}</note>`
|
|
651
|
+
: ''
|
|
652
|
+
}</notes>`
|
|
653
|
+
: '';
|
|
654
|
+
return `<unit id="${escapeAttribute(id)}" name="${escapeAttribute(entry.key)}"${metadataAttributes(entry)}>${notes}<segment id="s1" state="${state20(target?.state)}" cn:state="${escapeAttribute(target?.state ?? 'untranslated')}"><source>${validatedMixedContent(entry.sourceText, INLINE_20)}</source>${
|
|
655
|
+
language && target
|
|
656
|
+
? `<target xml:lang="${escapeAttribute(language)}">${validatedMixedContent(target?.text ?? '', INLINE_20)}</target>`
|
|
657
|
+
: ''
|
|
658
|
+
}</segment></unit>`;
|
|
659
|
+
})
|
|
660
|
+
.join('');
|
|
661
|
+
return `<file id="f${fileIndex + 1}"${
|
|
662
|
+
language ? ` cn:target-language="${escapeAttribute(language)}"` : ''
|
|
663
|
+
}>${units}</file>`;
|
|
664
|
+
})
|
|
665
|
+
.join('');
|
|
666
|
+
const singleTarget =
|
|
667
|
+
document.targetLanguages.length === 1
|
|
668
|
+
? ` trgLang="${escapeAttribute(document.targetLanguages[0])}"`
|
|
669
|
+
: '';
|
|
670
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<xliff xmlns="${XLIFF_20_NAMESPACE}" xmlns:cn="${CN_NAMESPACE}" version="2.0" srcLang="${escapeAttribute(document.sourceLanguage)}"${singleTarget}${rootMetadata(document)}>${files}</xliff>\n`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function sniffVersion(
|
|
674
|
+
input: Uint8Array,
|
|
675
|
+
expectedVersion: '1.2' | '2.0',
|
|
676
|
+
fileName?: string,
|
|
677
|
+
): boolean {
|
|
678
|
+
if (fileName && !/\.xlf(f)?$/i.test(fileName)) return false;
|
|
679
|
+
const prefix = text(input.slice(0, Math.min(input.length, 4096)));
|
|
680
|
+
return (
|
|
681
|
+
/<(?:(?:[\w.-]+):)?xliff\b/i.test(prefix) &&
|
|
682
|
+
new RegExp(`\\bversion\\s*=\\s*["']${expectedVersion.replace('.', '\\.')}["']`).test(prefix)
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export const xliff12Adapter: TranslationFormatAdapter = {
|
|
687
|
+
format: 'xliff-1.2',
|
|
688
|
+
sniff: (input, fileName) => sniffVersion(input, '1.2', fileName),
|
|
689
|
+
parse: async (input, options) =>
|
|
690
|
+
parseXliff(input, '1.2', (options ?? {}) as XliffParseOptions),
|
|
691
|
+
serialize: async (document) => ({
|
|
692
|
+
fileName: 'translations.xlf',
|
|
693
|
+
mediaType: XML_MEDIA_TYPE,
|
|
694
|
+
body: UTF8.encode(serialize12(document)),
|
|
695
|
+
}),
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
export const xliff20Adapter: TranslationFormatAdapter = {
|
|
699
|
+
format: 'xliff-2.0',
|
|
700
|
+
sniff: (input, fileName) => sniffVersion(input, '2.0', fileName),
|
|
701
|
+
parse: async (input, options) =>
|
|
702
|
+
parseXliff(input, '2.0', (options ?? {}) as XliffParseOptions),
|
|
703
|
+
serialize: async (document) => ({
|
|
704
|
+
fileName: 'translations.xlf',
|
|
705
|
+
mediaType: XML_MEDIA_TYPE,
|
|
706
|
+
body: UTF8.encode(serialize20(document)),
|
|
707
|
+
}),
|
|
708
|
+
};
|