@_linked/translation 0.9.4 → 0.9.5

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