@_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.
Files changed (153) hide show
  1. package/README.md +102 -0
  2. package/lib/cjs/data/translation.json +8 -0
  3. package/lib/esm/archive.d.ts +133 -0
  4. package/lib/esm/archive.js +567 -0
  5. package/lib/esm/archive.js.map +1 -0
  6. package/lib/esm/authorization.d.ts +12 -0
  7. package/lib/esm/authorization.js +10 -0
  8. package/lib/esm/authorization.js.map +1 -0
  9. package/lib/esm/backend.d.ts +1 -0
  10. package/lib/esm/backend.js +3 -0
  11. package/lib/esm/backend.js.map +1 -0
  12. package/lib/esm/cdn-loader.d.ts +40 -0
  13. package/lib/esm/cdn-loader.js +200 -0
  14. package/lib/esm/cdn-loader.js.map +1 -0
  15. package/lib/esm/checks.d.ts +56 -0
  16. package/lib/esm/checks.js +288 -0
  17. package/lib/esm/checks.js.map +1 -0
  18. package/lib/esm/compile.d.ts +64 -0
  19. package/lib/esm/compile.js +99 -0
  20. package/lib/esm/compile.js.map +1 -0
  21. package/lib/esm/core/messages.d.ts +25 -0
  22. package/lib/esm/core/messages.js +76 -0
  23. package/lib/esm/core/messages.js.map +1 -0
  24. package/lib/esm/data/translation.json +8 -0
  25. package/lib/esm/discovery.d.ts +97 -0
  26. package/lib/esm/discovery.js +365 -0
  27. package/lib/esm/discovery.js.map +1 -0
  28. package/lib/esm/exchange.d.ts +100 -0
  29. package/lib/esm/exchange.js +223 -0
  30. package/lib/esm/exchange.js.map +1 -0
  31. package/lib/esm/export.d.ts +28 -0
  32. package/lib/esm/export.js +92 -0
  33. package/lib/esm/export.js.map +1 -0
  34. package/lib/esm/formats/csv.d.ts +8 -0
  35. package/lib/esm/formats/csv.js +397 -0
  36. package/lib/esm/formats/csv.js.map +1 -0
  37. package/lib/esm/formats/json.d.ts +30 -0
  38. package/lib/esm/formats/json.js +459 -0
  39. package/lib/esm/formats/json.js.map +1 -0
  40. package/lib/esm/formats/xliff.d.ts +12 -0
  41. package/lib/esm/formats/xliff.js +577 -0
  42. package/lib/esm/formats/xliff.js.map +1 -0
  43. package/lib/esm/import.d.ts +197 -0
  44. package/lib/esm/import.js +391 -0
  45. package/lib/esm/import.js.map +1 -0
  46. package/lib/esm/index.d.ts +42 -0
  47. package/lib/esm/index.js +34 -0
  48. package/lib/esm/index.js.map +1 -0
  49. package/lib/esm/key-sync-node.d.ts +29 -0
  50. package/lib/esm/key-sync-node.js +174 -0
  51. package/lib/esm/key-sync-node.js.map +1 -0
  52. package/lib/esm/key-sync.d.ts +46 -0
  53. package/lib/esm/key-sync.js +114 -0
  54. package/lib/esm/key-sync.js.map +1 -0
  55. package/lib/esm/key-version.d.ts +50 -0
  56. package/lib/esm/key-version.js +134 -0
  57. package/lib/esm/key-version.js.map +1 -0
  58. package/lib/esm/languages.d.ts +18 -0
  59. package/lib/esm/languages.js +74 -0
  60. package/lib/esm/languages.js.map +1 -0
  61. package/lib/esm/linked-discovery.d.ts +44 -0
  62. package/lib/esm/linked-discovery.js +267 -0
  63. package/lib/esm/linked-discovery.js.map +1 -0
  64. package/lib/esm/message-format.d.ts +41 -0
  65. package/lib/esm/message-format.js +133 -0
  66. package/lib/esm/message-format.js.map +1 -0
  67. package/lib/esm/mt.d.ts +111 -0
  68. package/lib/esm/mt.js +142 -0
  69. package/lib/esm/mt.js.map +1 -0
  70. package/lib/esm/ontologies/translation.d.ts +203 -0
  71. package/lib/esm/ontologies/translation.js +219 -0
  72. package/lib/esm/ontologies/translation.js.map +1 -0
  73. package/lib/esm/package.d.ts +4 -0
  74. package/lib/esm/package.js +11 -0
  75. package/lib/esm/package.js.map +1 -0
  76. package/lib/esm/react.d.ts +55 -0
  77. package/lib/esm/react.js +127 -0
  78. package/lib/esm/react.js.map +1 -0
  79. package/lib/esm/records.d.ts +151 -0
  80. package/lib/esm/records.js +2 -0
  81. package/lib/esm/records.js.map +1 -0
  82. package/lib/esm/release.d.ts +198 -0
  83. package/lib/esm/release.js +435 -0
  84. package/lib/esm/release.js.map +1 -0
  85. package/lib/esm/shape-catalog.d.ts +79 -0
  86. package/lib/esm/shape-catalog.js +118 -0
  87. package/lib/esm/shape-catalog.js.map +1 -0
  88. package/lib/esm/shapes/GlossaryTerm.d.ts +28 -0
  89. package/lib/esm/shapes/GlossaryTerm.js +94 -0
  90. package/lib/esm/shapes/GlossaryTerm.js.map +1 -0
  91. package/lib/esm/shapes/TranslationInventoryOrigin.d.ts +33 -0
  92. package/lib/esm/shapes/TranslationInventoryOrigin.js +209 -0
  93. package/lib/esm/shapes/TranslationInventoryOrigin.js.map +1 -0
  94. package/lib/esm/shapes/TranslationKey.d.ts +92 -0
  95. package/lib/esm/shapes/TranslationKey.js +194 -0
  96. package/lib/esm/shapes/TranslationKey.js.map +1 -0
  97. package/lib/esm/shapes/TranslationKeyVersion.d.ts +19 -0
  98. package/lib/esm/shapes/TranslationKeyVersion.js +125 -0
  99. package/lib/esm/shapes/TranslationKeyVersion.js.map +1 -0
  100. package/lib/esm/shapes/TranslationLanguage.d.ts +15 -0
  101. package/lib/esm/shapes/TranslationLanguage.js +102 -0
  102. package/lib/esm/shapes/TranslationLanguage.js.map +1 -0
  103. package/lib/esm/shapes/TranslationProvider.d.ts +409 -0
  104. package/lib/esm/shapes/TranslationProvider.js +1376 -0
  105. package/lib/esm/shapes/TranslationProvider.js.map +1 -0
  106. package/lib/esm/shapes/TranslationRelease.d.ts +36 -0
  107. package/lib/esm/shapes/TranslationRelease.js +149 -0
  108. package/lib/esm/shapes/TranslationRelease.js.map +1 -0
  109. package/lib/esm/shapes/TranslationRevision.d.ts +39 -0
  110. package/lib/esm/shapes/TranslationRevision.js +159 -0
  111. package/lib/esm/shapes/TranslationRevision.js.map +1 -0
  112. package/lib/esm/shapes/TranslationUnit.d.ts +26 -0
  113. package/lib/esm/shapes/TranslationUnit.js +111 -0
  114. package/lib/esm/shapes/TranslationUnit.js.map +1 -0
  115. package/package.json +134 -0
  116. package/src/archive.ts +894 -0
  117. package/src/authorization.ts +33 -0
  118. package/src/backend.ts +2 -0
  119. package/src/cdn-loader.ts +264 -0
  120. package/src/checks.ts +413 -0
  121. package/src/compile.ts +168 -0
  122. package/src/core/messages.ts +106 -0
  123. package/src/data/translation.json +8 -0
  124. package/src/discovery.ts +594 -0
  125. package/src/exchange.ts +428 -0
  126. package/src/export.ts +135 -0
  127. package/src/formats/csv.ts +459 -0
  128. package/src/formats/json.ts +587 -0
  129. package/src/formats/xliff.ts +708 -0
  130. package/src/import.ts +648 -0
  131. package/src/index.ts +207 -0
  132. package/src/key-sync-node.ts +269 -0
  133. package/src/key-sync.ts +168 -0
  134. package/src/key-version.ts +206 -0
  135. package/src/languages.ts +102 -0
  136. package/src/linked-discovery.ts +385 -0
  137. package/src/message-format.ts +214 -0
  138. package/src/mt.ts +248 -0
  139. package/src/ontologies/translation.ts +234 -0
  140. package/src/package.ts +19 -0
  141. package/src/react.tsx +250 -0
  142. package/src/records.ts +180 -0
  143. package/src/release.ts +726 -0
  144. package/src/shape-catalog.ts +198 -0
  145. package/src/shapes/GlossaryTerm.ts +61 -0
  146. package/src/shapes/TranslationInventoryOrigin.ts +131 -0
  147. package/src/shapes/TranslationKey.ts +174 -0
  148. package/src/shapes/TranslationKeyVersion.ts +77 -0
  149. package/src/shapes/TranslationLanguage.ts +51 -0
  150. package/src/shapes/TranslationProvider.ts +1755 -0
  151. package/src/shapes/TranslationRelease.ts +114 -0
  152. package/src/shapes/TranslationRevision.ts +105 -0
  153. package/src/shapes/TranslationUnit.ts +72 -0
@@ -0,0 +1,428 @@
1
+ import type { MessageFormat } from './core/messages.js';
2
+ import { canonicalLanguageTag } from './key-version.js';
3
+ import type { TranslationState } from './records.js';
4
+
5
+ export type TranslationExchangeKind = 'ui' | 'semantic';
6
+ export type TranslationDeclarationKind =
7
+ | TranslationExchangeKind
8
+ | 'content';
9
+ export type TranslationDeclarationStatus = 'confirmed' | 'pending';
10
+
11
+ /**
12
+ * The build-facing subset of the later multi-source discovery contract.
13
+ * Static extraction omits the optional fields and retains its existing
14
+ * confirmed/simple behavior.
15
+ */
16
+ export interface TranslationBuildDeclaration {
17
+ key: string;
18
+ sourceText: string;
19
+ format?: MessageFormat;
20
+ kind?: TranslationDeclarationKind;
21
+ status?: TranslationDeclarationStatus;
22
+ authoritative?: boolean;
23
+ namespace?: string;
24
+ file?: string;
25
+ line?: number;
26
+ }
27
+
28
+ export interface TranslationExchangeValue {
29
+ text: string;
30
+ state?: TranslationState;
31
+ note?: string;
32
+ }
33
+
34
+ export interface TranslationExchangeEntry {
35
+ key: string;
36
+ namespace?: string;
37
+ kind: TranslationExchangeKind;
38
+ sourceText: string;
39
+ keyVersionId: string;
40
+ sourceHash: string;
41
+ contractHash: string;
42
+ argumentSignature: string;
43
+ description?: string;
44
+ format: MessageFormat;
45
+ translations: Record<string, TranslationExchangeValue>;
46
+ }
47
+
48
+ export interface TranslationExchangeDocument {
49
+ schemaVersion: 1;
50
+ appId?: string;
51
+ branchId?: string;
52
+ sourceLanguage: string;
53
+ targetLanguages: string[];
54
+ exportedAt?: string;
55
+ revisionWatermark?: string;
56
+ contractSetHash?: string;
57
+ entries: TranslationExchangeEntry[];
58
+ }
59
+
60
+ export type ParsedTranslationExchangeEntry = Omit<
61
+ TranslationExchangeEntry,
62
+ | 'sourceText'
63
+ | 'keyVersionId'
64
+ | 'sourceHash'
65
+ | 'contractHash'
66
+ | 'argumentSignature'
67
+ > & {
68
+ sourceText?: string;
69
+ keyVersionId?: string;
70
+ sourceHash?: string;
71
+ contractHash?: string;
72
+ argumentSignature?: string;
73
+ };
74
+
75
+ export interface ParsedTranslationExchangeDocument
76
+ extends Omit<TranslationExchangeDocument, 'entries'> {
77
+ entries: ParsedTranslationExchangeEntry[];
78
+ }
79
+
80
+ export type TranslationFormat =
81
+ | 'xliff-1.2'
82
+ | 'xliff-2.0'
83
+ | 'csv'
84
+ | 'json';
85
+
86
+ export interface SerializedTranslationDocument {
87
+ fileName: string;
88
+ mediaType: string;
89
+ body: Uint8Array;
90
+ }
91
+
92
+ export interface TranslationFormatAdapter {
93
+ readonly format: TranslationFormat;
94
+ sniff(input: Uint8Array, fileName?: string): boolean;
95
+ parse(
96
+ input: Uint8Array,
97
+ options?: unknown,
98
+ ): Promise<ParsedTranslationExchangeDocument>;
99
+ serialize(
100
+ document: TranslationExchangeDocument,
101
+ options?: unknown,
102
+ ): Promise<SerializedTranslationDocument>;
103
+ }
104
+
105
+ export type TranslationImportDisposition =
106
+ | 'import'
107
+ | 'carry-forward'
108
+ | 'unverified-source'
109
+ | 'conflict'
110
+ | 'skip';
111
+
112
+ export interface TranslationImportDryRunEntry {
113
+ key: string;
114
+ language: string;
115
+ disposition: TranslationImportDisposition;
116
+ expectedKeyVersionId?: string;
117
+ expectedUnitContentHash?: string;
118
+ }
119
+
120
+ export interface TranslationImportDryRun {
121
+ sessionId: string;
122
+ documentHash: string;
123
+ revisionWatermark: string;
124
+ expiresAt: string;
125
+ entries: TranslationImportDryRunEntry[];
126
+ }
127
+
128
+ const EXCHANGE_STATES = new Set<TranslationState>([
129
+ 'untranslated',
130
+ 'machine',
131
+ 'reviewed',
132
+ 'stale',
133
+ ]);
134
+ const adapters = new Map<TranslationFormat, TranslationFormatAdapter>();
135
+
136
+ function record(value: unknown, label: string): Record<string, unknown> {
137
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
138
+ throw new Error(`${label} must be an object.`);
139
+ }
140
+ return value as Record<string, unknown>;
141
+ }
142
+
143
+ function requiredString(value: unknown, label: string): string {
144
+ if (typeof value !== 'string' || !value.trim()) {
145
+ throw new Error(`${label} is required.`);
146
+ }
147
+ return value.trim();
148
+ }
149
+
150
+ function optionalString(value: unknown, label: string): string | undefined {
151
+ if (value === undefined) return undefined;
152
+ if (typeof value !== 'string' || !value.trim()) {
153
+ throw new Error(`${label} must be a non-empty string when supplied.`);
154
+ }
155
+ return value.trim();
156
+ }
157
+
158
+ function messageFormat(value: unknown, label: string): MessageFormat {
159
+ if (value !== 'simple' && value !== 'icu') {
160
+ throw new Error(`${label} must be "simple" or "icu".`);
161
+ }
162
+ return value;
163
+ }
164
+
165
+ function exchangeKind(value: unknown, label: string): TranslationExchangeKind {
166
+ if (value !== 'ui' && value !== 'semantic') {
167
+ throw new Error(`${label} must be "ui" or "semantic".`);
168
+ }
169
+ return value;
170
+ }
171
+
172
+ function canonicalLanguages(value: unknown, label: string): string[] {
173
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
174
+ const canonical: string[] = [];
175
+ const seen = new Set<string>();
176
+ for (const item of value) {
177
+ if (typeof item !== 'string') {
178
+ throw new Error(`${label} must contain only BCP-47 strings.`);
179
+ }
180
+ const language = canonicalLanguageTag(item);
181
+ if (!seen.has(language)) {
182
+ seen.add(language);
183
+ canonical.push(language);
184
+ }
185
+ }
186
+ return canonical;
187
+ }
188
+
189
+ function normalizeTranslations(
190
+ value: unknown,
191
+ label: string,
192
+ ): Record<string, TranslationExchangeValue> {
193
+ const input = record(value, label);
194
+ const translations: Record<string, TranslationExchangeValue> = {};
195
+ for (const [rawLanguage, rawValue] of Object.entries(input)) {
196
+ const language = canonicalLanguageTag(rawLanguage);
197
+ if (translations[language]) {
198
+ throw new Error(
199
+ `${label} contains duplicate canonical language "${language}".`,
200
+ );
201
+ }
202
+ const item = record(rawValue, `${label}.${rawLanguage}`);
203
+ if (typeof item.text !== 'string') {
204
+ throw new Error(`${label}.${rawLanguage}.text must be a string.`);
205
+ }
206
+ const state = item.state;
207
+ if (state !== undefined && !EXCHANGE_STATES.has(state as TranslationState)) {
208
+ throw new Error(`${label}.${rawLanguage}.state is invalid.`);
209
+ }
210
+ translations[language] = {
211
+ text: item.text,
212
+ ...(state ? { state: state as TranslationState } : {}),
213
+ ...(item.note === undefined
214
+ ? {}
215
+ : {
216
+ note: requiredString(
217
+ item.note,
218
+ `${label}.${rawLanguage}.note`,
219
+ ),
220
+ }),
221
+ };
222
+ }
223
+ return translations;
224
+ }
225
+
226
+ function normalizeEntry(
227
+ value: unknown,
228
+ index: number,
229
+ ): ParsedTranslationExchangeEntry {
230
+ const item = record(value, `entries[${index}]`);
231
+ const key = requiredString(item.key, `entries[${index}].key`);
232
+ return {
233
+ key,
234
+ ...(item.namespace === undefined
235
+ ? {}
236
+ : {
237
+ namespace: requiredString(
238
+ item.namespace,
239
+ `entries[${index}].namespace`,
240
+ ),
241
+ }),
242
+ kind: exchangeKind(item.kind, `entries[${index}].kind`),
243
+ format: messageFormat(item.format, `entries[${index}].format`),
244
+ ...(item.sourceText === undefined
245
+ ? {}
246
+ : {
247
+ sourceText:
248
+ typeof item.sourceText === 'string'
249
+ ? item.sourceText
250
+ : requiredString(item.sourceText, `entries[${index}].sourceText`),
251
+ }),
252
+ ...(item.keyVersionId === undefined
253
+ ? {}
254
+ : {
255
+ keyVersionId: requiredString(
256
+ item.keyVersionId,
257
+ `entries[${index}].keyVersionId`,
258
+ ),
259
+ }),
260
+ ...(item.sourceHash === undefined
261
+ ? {}
262
+ : {
263
+ sourceHash: requiredString(
264
+ item.sourceHash,
265
+ `entries[${index}].sourceHash`,
266
+ ),
267
+ }),
268
+ ...(item.contractHash === undefined
269
+ ? {}
270
+ : {
271
+ contractHash: requiredString(
272
+ item.contractHash,
273
+ `entries[${index}].contractHash`,
274
+ ),
275
+ }),
276
+ ...(item.argumentSignature === undefined
277
+ ? {}
278
+ : {
279
+ argumentSignature: requiredString(
280
+ item.argumentSignature,
281
+ `entries[${index}].argumentSignature`,
282
+ ),
283
+ }),
284
+ ...(item.description === undefined
285
+ ? {}
286
+ : {
287
+ description: requiredString(
288
+ item.description,
289
+ `entries[${index}].description`,
290
+ ),
291
+ }),
292
+ translations: normalizeTranslations(
293
+ item.translations,
294
+ `entries[${index}].translations`,
295
+ ),
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Normalize adapter output without inventing evidence missing from a foreign
301
+ * file. Import disposition handles those entries as unverified later.
302
+ */
303
+ export function normalizeParsedTranslationExchangeDocument(
304
+ value: unknown,
305
+ ): ParsedTranslationExchangeDocument {
306
+ const input = record(value, 'Translation exchange document');
307
+ if (input.schemaVersion !== 1) {
308
+ throw new Error('Translation exchange schemaVersion must be 1.');
309
+ }
310
+ const sourceLanguage = canonicalLanguageTag(
311
+ requiredString(input.sourceLanguage, 'sourceLanguage'),
312
+ );
313
+ const targetLanguages = canonicalLanguages(
314
+ input.targetLanguages,
315
+ 'targetLanguages',
316
+ );
317
+ if (!Array.isArray(input.entries)) {
318
+ throw new Error('entries must be an array.');
319
+ }
320
+ const entries = input.entries.map(normalizeEntry);
321
+ const identities = new Set<string>();
322
+ for (const entry of entries) {
323
+ for (const language of Object.keys(entry.translations)) {
324
+ const identity = `${entry.namespace ?? ''}\u0000${entry.key}\u0000${language}`;
325
+ if (identities.has(identity)) {
326
+ throw new Error(
327
+ `Duplicate translation entry for "${entry.key}" in ${language}.`,
328
+ );
329
+ }
330
+ identities.add(identity);
331
+ }
332
+ }
333
+ return {
334
+ schemaVersion: 1,
335
+ ...(input.appId === undefined
336
+ ? {}
337
+ : { appId: requiredString(input.appId, 'appId') }),
338
+ ...(input.branchId === undefined
339
+ ? {}
340
+ : { branchId: requiredString(input.branchId, 'branchId') }),
341
+ sourceLanguage,
342
+ targetLanguages,
343
+ ...(input.exportedAt === undefined
344
+ ? {}
345
+ : { exportedAt: requiredString(input.exportedAt, 'exportedAt') }),
346
+ ...(input.revisionWatermark === undefined
347
+ ? {}
348
+ : {
349
+ revisionWatermark: requiredString(
350
+ input.revisionWatermark,
351
+ 'revisionWatermark',
352
+ ),
353
+ }),
354
+ ...(input.contractSetHash === undefined
355
+ ? {}
356
+ : {
357
+ contractSetHash: requiredString(
358
+ input.contractSetHash,
359
+ 'contractSetHash',
360
+ ),
361
+ }),
362
+ entries,
363
+ };
364
+ }
365
+
366
+ /** Validate the stronger, version-evidenced document required for CN exports. */
367
+ export function validateTranslationExchangeDocument(
368
+ value: unknown,
369
+ ): TranslationExchangeDocument {
370
+ const parsed = normalizeParsedTranslationExchangeDocument(value);
371
+ const entries = parsed.entries.map((entry, index): TranslationExchangeEntry => ({
372
+ ...entry,
373
+ sourceText:
374
+ entry.sourceText ??
375
+ requiredString(undefined, `entries[${index}].sourceText`),
376
+ keyVersionId:
377
+ entry.keyVersionId ??
378
+ requiredString(undefined, `entries[${index}].keyVersionId`),
379
+ sourceHash:
380
+ entry.sourceHash ??
381
+ requiredString(undefined, `entries[${index}].sourceHash`),
382
+ contractHash:
383
+ entry.contractHash ??
384
+ requiredString(undefined, `entries[${index}].contractHash`),
385
+ argumentSignature:
386
+ entry.argumentSignature ??
387
+ requiredString(undefined, `entries[${index}].argumentSignature`),
388
+ }));
389
+ return { ...parsed, entries };
390
+ }
391
+
392
+ export function isConfirmedBuildDeclaration(
393
+ declaration: TranslationBuildDeclaration,
394
+ ): boolean {
395
+ return (
396
+ (declaration.kind ?? 'ui') !== 'content' &&
397
+ (declaration.status ?? 'confirmed') === 'confirmed' &&
398
+ declaration.authoritative !== false
399
+ );
400
+ }
401
+
402
+ export function registerTranslationFormatAdapter(
403
+ adapter: TranslationFormatAdapter,
404
+ ): void {
405
+ if (adapters.has(adapter.format)) {
406
+ throw new Error(
407
+ `Translation format adapter "${adapter.format}" is already registered.`,
408
+ );
409
+ }
410
+ adapters.set(adapter.format, adapter);
411
+ }
412
+
413
+ export function getTranslationFormatAdapter(
414
+ format: TranslationFormat,
415
+ ): TranslationFormatAdapter | undefined {
416
+ return adapters.get(format);
417
+ }
418
+
419
+ export function listTranslationFormatAdapters(): TranslationFormatAdapter[] {
420
+ return [...adapters.values()].sort((left, right) =>
421
+ left.format.localeCompare(right.format),
422
+ );
423
+ }
424
+
425
+ /** Test/host reset hook; adapters are registered explicitly by the host. */
426
+ export function clearTranslationFormatAdapters(): void {
427
+ adapters.clear();
428
+ }
package/src/export.ts ADDED
@@ -0,0 +1,135 @@
1
+ import {
2
+ validateTranslationExchangeDocument,
3
+ type TranslationExchangeDocument,
4
+ type TranslationExchangeEntry,
5
+ } from './exchange.js';
6
+ import { canonicalLanguageTag, sha256Hex } from './key-version.js';
7
+ import { canonicalJson, translationContractSetHash } from './release.js';
8
+ import type {
9
+ TranslationEntryRecord,
10
+ TranslationState,
11
+ } from './records.js';
12
+
13
+ export interface TranslationExportOptions {
14
+ appId?: string;
15
+ branchId?: string;
16
+ sourceLanguage: string;
17
+ revisionWatermark: string;
18
+ exportedAt?: string;
19
+ languages?: readonly string[];
20
+ namespaces?: readonly string[];
21
+ keys?: readonly string[];
22
+ states?: readonly TranslationState[];
23
+ }
24
+
25
+ export interface TranslationExportFinding {
26
+ key: string;
27
+ code: 'content-key-unsupported' | 'missing-current-version';
28
+ message: string;
29
+ }
30
+
31
+ export interface TranslationExportResult {
32
+ document: TranslationExchangeDocument;
33
+ documentHash: string;
34
+ findings: TranslationExportFinding[];
35
+ }
36
+
37
+ /**
38
+ * Build one deterministic, version-evidenced exchange snapshot. Content keys
39
+ * and versionless keys are reported rather than silently losing metadata.
40
+ */
41
+ export async function createTranslationExchangeDocument(
42
+ entries: readonly TranslationEntryRecord[],
43
+ options: TranslationExportOptions,
44
+ ): Promise<TranslationExportResult> {
45
+ const sourceLanguage = canonicalLanguageTag(options.sourceLanguage);
46
+ const languageFilter = options.languages
47
+ ? new Set(options.languages.map(canonicalLanguageTag))
48
+ : undefined;
49
+ const namespaceFilter = options.namespaces
50
+ ? new Set(options.namespaces)
51
+ : undefined;
52
+ const keyFilter = options.keys ? new Set(options.keys) : undefined;
53
+ const stateFilter = options.states ? new Set(options.states) : undefined;
54
+ const findings: TranslationExportFinding[] = [];
55
+ const exportEntries: TranslationExchangeEntry[] = [];
56
+
57
+ for (const entry of [...entries].sort((left, right) =>
58
+ `${left.namespace}\u0000${left.key}`.localeCompare(
59
+ `${right.namespace}\u0000${right.key}`,
60
+ ),
61
+ )) {
62
+ if (namespaceFilter && !namespaceFilter.has(entry.namespace)) continue;
63
+ if (keyFilter && !keyFilter.has(entry.key)) continue;
64
+ if (entry.kind === 'content') {
65
+ findings.push({
66
+ key: entry.key,
67
+ code: 'content-key-unsupported',
68
+ message: 'Content translations are not part of the UI exchange format.',
69
+ });
70
+ continue;
71
+ }
72
+ const version = entry.currentVersion;
73
+ if (!version) {
74
+ findings.push({
75
+ key: entry.key,
76
+ code: 'missing-current-version',
77
+ message: 'The key has no current immutable version.',
78
+ });
79
+ continue;
80
+ }
81
+ const translations: TranslationExchangeEntry['translations'] = {};
82
+ for (const [rawLanguage, unit] of Object.entries(entry.units).sort(
83
+ ([left], [right]) => left.localeCompare(right),
84
+ )) {
85
+ const language = canonicalLanguageTag(rawLanguage);
86
+ if (language === sourceLanguage) continue;
87
+ if (languageFilter && !languageFilter.has(language)) continue;
88
+ if (stateFilter && !stateFilter.has(unit.state)) continue;
89
+ translations[language] = {
90
+ text: unit.text,
91
+ state: unit.state,
92
+ };
93
+ }
94
+ exportEntries.push({
95
+ key: entry.key,
96
+ namespace: entry.namespace,
97
+ kind:
98
+ entry.ofShape || entry.ofProperty || entry.fromPackage
99
+ ? 'semantic'
100
+ : 'ui',
101
+ sourceText: version.sourceText,
102
+ keyVersionId: version.id,
103
+ sourceHash: version.sourceHash,
104
+ contractHash: version.contractHash,
105
+ argumentSignature: version.argumentSignature,
106
+ description: entry.description,
107
+ format: version.format,
108
+ translations,
109
+ });
110
+ }
111
+
112
+ const targetLanguages = [
113
+ ...new Set(
114
+ exportEntries.flatMap((entry) => Object.keys(entry.translations)),
115
+ ),
116
+ ].sort();
117
+ const document = validateTranslationExchangeDocument({
118
+ schemaVersion: 1,
119
+ appId: options.appId,
120
+ branchId: options.branchId,
121
+ sourceLanguage,
122
+ targetLanguages,
123
+ exportedAt: options.exportedAt,
124
+ revisionWatermark: options.revisionWatermark,
125
+ contractSetHash: await translationContractSetHash(
126
+ entries.filter((entry) => entry.kind === 'ui') as TranslationEntryRecord[],
127
+ ),
128
+ entries: exportEntries,
129
+ });
130
+ return {
131
+ document,
132
+ documentHash: await sha256Hex(canonicalJson(document)),
133
+ findings,
134
+ };
135
+ }