@_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,594 @@
1
+ import type { MessageFormat } from './core/messages.js';
2
+ import type {
3
+ TranslationBuildDeclaration,
4
+ TranslationDeclarationKind,
5
+ TranslationDeclarationStatus,
6
+ } from './exchange.js';
7
+
8
+ export type TranslationDeclarationSource =
9
+ | 'code'
10
+ | 'manifest'
11
+ | 'package'
12
+ | 'linked'
13
+ | 'import'
14
+ | 'runtime';
15
+
16
+ export interface TranslationDeclarationProvenance {
17
+ source: TranslationDeclarationSource;
18
+ sourceId: string;
19
+ authoritative: boolean;
20
+ file?: string;
21
+ line?: number;
22
+ ownerIri?: string;
23
+ shapeIri?: string;
24
+ propertyIri?: string;
25
+ nodeIri?: string;
26
+ fromPackage?: string;
27
+ targetClass?: string;
28
+ }
29
+
30
+ export interface TranslationDeclaration {
31
+ schemaVersion: 1;
32
+ key: string;
33
+ sourceText: string;
34
+ namespace?: string;
35
+ kind: TranslationDeclarationKind;
36
+ format: MessageFormat;
37
+ description?: string;
38
+ provenance: TranslationDeclarationProvenance;
39
+ }
40
+
41
+ export interface TranslationDiscoveryContext {
42
+ appId: string;
43
+ branchId?: string;
44
+ sourceRoot?: string;
45
+ }
46
+
47
+ export interface TranslationDiscoverySource {
48
+ readonly name: string;
49
+ discover(
50
+ context: TranslationDiscoveryContext,
51
+ ): AsyncIterable<TranslationDeclaration>;
52
+ }
53
+
54
+ export type TranslationInventoryStatus =
55
+ | 'confirmed'
56
+ | 'pending'
57
+ | 'conflict'
58
+ | 'orphaned';
59
+
60
+ export interface TranslationInventoryItem {
61
+ identity: string;
62
+ appId: string;
63
+ key: string;
64
+ sourceText: string;
65
+ namespace?: string;
66
+ kind: TranslationDeclarationKind;
67
+ format: MessageFormat;
68
+ description?: string;
69
+ status: TranslationInventoryStatus;
70
+ authoritative: boolean;
71
+ exchangeEligible: boolean;
72
+ buildEligible: boolean;
73
+ origins: TranslationDeclarationProvenance[];
74
+ }
75
+
76
+ export interface TranslationInventoryConflict {
77
+ identity: string;
78
+ declarations: TranslationDeclaration[];
79
+ }
80
+
81
+ export interface TranslationInventorySnapshot {
82
+ schemaVersion: 1;
83
+ appId: string;
84
+ branchId?: string;
85
+ items: TranslationInventoryItem[];
86
+ conflicts: TranslationInventoryConflict[];
87
+ buildDeclarations: TranslationBuildDeclaration[];
88
+ }
89
+
90
+ export interface TranslationDiscoveryManifestEntry {
91
+ key: string;
92
+ sourceText: string;
93
+ namespace?: string;
94
+ kind?: TranslationDeclarationKind;
95
+ format?: MessageFormat;
96
+ description?: string;
97
+ ownerIri?: string;
98
+ shapeIri?: string;
99
+ propertyIri?: string;
100
+ nodeIri?: string;
101
+ }
102
+
103
+ export interface TranslationDiscoveryManifest {
104
+ schemaVersion: 1;
105
+ sourceId: string;
106
+ declarations: TranslationDiscoveryManifestEntry[];
107
+ }
108
+
109
+ export interface DiscoverTranslationInventoryOptions {
110
+ previous?: TranslationInventorySnapshot;
111
+ }
112
+
113
+ const KIND_ORDER: Record<TranslationDeclarationKind, number> = {
114
+ ui: 0,
115
+ semantic: 1,
116
+ content: 2,
117
+ };
118
+
119
+ function requiredString(value: unknown, label: string): string {
120
+ if (typeof value !== 'string' || !value.trim()) {
121
+ throw new Error(`${label} is required.`);
122
+ }
123
+ return value.trim();
124
+ }
125
+
126
+ function optionalString(value: unknown, label: string): string | undefined {
127
+ if (value === undefined) return undefined;
128
+ if (typeof value !== 'string' || !value.trim()) {
129
+ throw new Error(`${label} must be a non-empty string when supplied.`);
130
+ }
131
+ return value.trim();
132
+ }
133
+
134
+ function declarationKind(
135
+ value: unknown,
136
+ label: string,
137
+ fallback: TranslationDeclarationKind = 'ui',
138
+ ): TranslationDeclarationKind {
139
+ const candidate = value ?? fallback;
140
+ if (
141
+ candidate !== 'ui' &&
142
+ candidate !== 'semantic' &&
143
+ candidate !== 'content'
144
+ ) {
145
+ throw new Error(`${label} must be "ui", "semantic", or "content".`);
146
+ }
147
+ return candidate;
148
+ }
149
+
150
+ function messageFormat(
151
+ value: unknown,
152
+ label: string,
153
+ fallback: MessageFormat = 'simple',
154
+ ): MessageFormat {
155
+ const candidate = value ?? fallback;
156
+ if (candidate !== 'simple' && candidate !== 'icu') {
157
+ throw new Error(`${label} must be "simple" or "icu".`);
158
+ }
159
+ return candidate;
160
+ }
161
+
162
+ function object(value: unknown, label: string): Record<string, unknown> {
163
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
164
+ throw new Error(`${label} must be an object.`);
165
+ }
166
+ return value as Record<string, unknown>;
167
+ }
168
+
169
+ function normalizeManifestEntry(
170
+ value: unknown,
171
+ index: number,
172
+ ): TranslationDiscoveryManifestEntry {
173
+ const entry = object(value, `declarations[${index}]`);
174
+ const kind = declarationKind(entry.kind, `declarations[${index}].kind`);
175
+ const nodeIri = optionalString(
176
+ entry.nodeIri,
177
+ `declarations[${index}].nodeIri`,
178
+ );
179
+ const propertyIri = optionalString(
180
+ entry.propertyIri,
181
+ `declarations[${index}].propertyIri`,
182
+ );
183
+ if (kind === 'content' && (!nodeIri || !propertyIri)) {
184
+ throw new Error(
185
+ `declarations[${index}] content entries require nodeIri and propertyIri.`,
186
+ );
187
+ }
188
+ if (typeof entry.sourceText !== 'string') {
189
+ throw new Error(`declarations[${index}].sourceText must be a string.`);
190
+ }
191
+ return {
192
+ key: requiredString(entry.key, `declarations[${index}].key`),
193
+ sourceText: entry.sourceText,
194
+ kind,
195
+ format: messageFormat(entry.format, `declarations[${index}].format`),
196
+ ...(optionalString(
197
+ entry.namespace,
198
+ `declarations[${index}].namespace`,
199
+ )
200
+ ? {
201
+ namespace: optionalString(
202
+ entry.namespace,
203
+ `declarations[${index}].namespace`,
204
+ ),
205
+ }
206
+ : {}),
207
+ ...(optionalString(
208
+ entry.description,
209
+ `declarations[${index}].description`,
210
+ )
211
+ ? {
212
+ description: optionalString(
213
+ entry.description,
214
+ `declarations[${index}].description`,
215
+ ),
216
+ }
217
+ : {}),
218
+ ...(optionalString(entry.ownerIri, `declarations[${index}].ownerIri`)
219
+ ? {
220
+ ownerIri: optionalString(
221
+ entry.ownerIri,
222
+ `declarations[${index}].ownerIri`,
223
+ ),
224
+ }
225
+ : {}),
226
+ ...(optionalString(entry.shapeIri, `declarations[${index}].shapeIri`)
227
+ ? {
228
+ shapeIri: optionalString(
229
+ entry.shapeIri,
230
+ `declarations[${index}].shapeIri`,
231
+ ),
232
+ }
233
+ : {}),
234
+ ...(propertyIri ? { propertyIri } : {}),
235
+ ...(nodeIri ? { nodeIri } : {}),
236
+ };
237
+ }
238
+
239
+ /** Parse the portable v1 manifest used to declare dynamic translation keys. */
240
+ export function parseTranslationDiscoveryManifest(
241
+ value: unknown,
242
+ ): TranslationDiscoveryManifest {
243
+ const manifest = object(value, 'manifest');
244
+ if (manifest.schemaVersion !== 1) {
245
+ throw new Error('Translation discovery manifest schemaVersion must be 1.');
246
+ }
247
+ if (!Array.isArray(manifest.declarations)) {
248
+ throw new Error('Translation discovery manifest declarations must be an array.');
249
+ }
250
+ return {
251
+ schemaVersion: 1,
252
+ sourceId: requiredString(manifest.sourceId, 'manifest.sourceId'),
253
+ declarations: manifest.declarations.map(normalizeManifestEntry),
254
+ };
255
+ }
256
+
257
+ /** Turn a portable manifest into an authoritative discovery source. */
258
+ export function manifestTranslationDiscoverySource(
259
+ value: unknown,
260
+ ): TranslationDiscoverySource {
261
+ const manifest = parseTranslationDiscoveryManifest(value);
262
+ return {
263
+ name: `manifest:${manifest.sourceId}`,
264
+ async *discover() {
265
+ for (const entry of manifest.declarations) {
266
+ yield {
267
+ schemaVersion: 1,
268
+ key: entry.key,
269
+ sourceText: entry.sourceText,
270
+ kind: entry.kind ?? 'ui',
271
+ format: entry.format ?? 'simple',
272
+ ...(entry.namespace ? { namespace: entry.namespace } : {}),
273
+ ...(entry.description ? { description: entry.description } : {}),
274
+ provenance: {
275
+ source: 'manifest',
276
+ sourceId: manifest.sourceId,
277
+ authoritative: true,
278
+ ...(entry.ownerIri ? { ownerIri: entry.ownerIri } : {}),
279
+ ...(entry.shapeIri ? { shapeIri: entry.shapeIri } : {}),
280
+ ...(entry.propertyIri ? { propertyIri: entry.propertyIri } : {}),
281
+ ...(entry.nodeIri ? { nodeIri: entry.nodeIri } : {}),
282
+ },
283
+ };
284
+ }
285
+ },
286
+ };
287
+ }
288
+
289
+ function normalizeDeclaration(
290
+ declaration: TranslationDeclaration,
291
+ ): TranslationDeclaration {
292
+ if (declaration?.schemaVersion !== 1) {
293
+ throw new Error('Translation declaration schemaVersion must be 1.');
294
+ }
295
+ const kind = declarationKind(declaration.kind, 'declaration.kind');
296
+ const provenance = object(
297
+ declaration.provenance,
298
+ 'declaration.provenance',
299
+ ) as unknown as TranslationDeclarationProvenance;
300
+ const nodeIri = optionalString(provenance.nodeIri, 'provenance.nodeIri');
301
+ const propertyIri = optionalString(
302
+ provenance.propertyIri,
303
+ 'provenance.propertyIri',
304
+ );
305
+ if (kind === 'content' && (!nodeIri || !propertyIri)) {
306
+ throw new Error(
307
+ 'Content translation declarations require provenance.nodeIri and provenance.propertyIri.',
308
+ );
309
+ }
310
+ if (typeof declaration.sourceText !== 'string') {
311
+ throw new Error('declaration.sourceText must be a string.');
312
+ }
313
+ const source = provenance.source;
314
+ if (
315
+ source !== 'code' &&
316
+ source !== 'manifest' &&
317
+ source !== 'package' &&
318
+ source !== 'linked' &&
319
+ source !== 'import' &&
320
+ source !== 'runtime'
321
+ ) {
322
+ throw new Error('declaration.provenance.source is invalid.');
323
+ }
324
+ if (typeof provenance.authoritative !== 'boolean') {
325
+ throw new Error('declaration.provenance.authoritative must be boolean.');
326
+ }
327
+ return {
328
+ schemaVersion: 1,
329
+ key: requiredString(declaration.key, 'declaration.key'),
330
+ sourceText: declaration.sourceText,
331
+ kind,
332
+ format: messageFormat(declaration.format, 'declaration.format'),
333
+ ...(optionalString(declaration.namespace, 'declaration.namespace')
334
+ ? { namespace: declaration.namespace!.trim() }
335
+ : {}),
336
+ ...(optionalString(declaration.description, 'declaration.description')
337
+ ? { description: declaration.description!.trim() }
338
+ : {}),
339
+ provenance: {
340
+ source,
341
+ sourceId: requiredString(provenance.sourceId, 'provenance.sourceId'),
342
+ authoritative: provenance.authoritative,
343
+ ...(optionalString(provenance.file, 'provenance.file')
344
+ ? { file: provenance.file!.trim() }
345
+ : {}),
346
+ ...(provenance.line === undefined
347
+ ? {}
348
+ : Number.isSafeInteger(provenance.line) && provenance.line > 0
349
+ ? { line: provenance.line }
350
+ : (() => {
351
+ throw new Error('provenance.line must be a positive integer.');
352
+ })()),
353
+ ...(optionalString(provenance.ownerIri, 'provenance.ownerIri')
354
+ ? { ownerIri: provenance.ownerIri!.trim() }
355
+ : {}),
356
+ ...(optionalString(provenance.shapeIri, 'provenance.shapeIri')
357
+ ? { shapeIri: provenance.shapeIri!.trim() }
358
+ : {}),
359
+ ...(propertyIri ? { propertyIri } : {}),
360
+ ...(nodeIri ? { nodeIri } : {}),
361
+ ...(optionalString(provenance.fromPackage, 'provenance.fromPackage')
362
+ ? { fromPackage: provenance.fromPackage!.trim() }
363
+ : {}),
364
+ ...(optionalString(provenance.targetClass, 'provenance.targetClass')
365
+ ? { targetClass: provenance.targetClass!.trim() }
366
+ : {}),
367
+ },
368
+ };
369
+ }
370
+
371
+ /** Stable identity: UI/semantic by namespace+key; content by node+field IRI. */
372
+ export function translationDeclarationIdentity(
373
+ appId: string,
374
+ declaration: TranslationDeclaration,
375
+ ): string {
376
+ const normalizedAppId = requiredString(appId, 'appId');
377
+ if (declaration.kind === 'content') {
378
+ return `content:${JSON.stringify([
379
+ normalizedAppId,
380
+ declaration.provenance.nodeIri,
381
+ declaration.provenance.propertyIri,
382
+ ])}`;
383
+ }
384
+ return `message:${JSON.stringify([
385
+ normalizedAppId,
386
+ declaration.namespace ?? '',
387
+ declaration.key,
388
+ ])}`;
389
+ }
390
+
391
+ function canonicalProvenance(
392
+ provenance: TranslationDeclarationProvenance,
393
+ ): string {
394
+ return JSON.stringify([
395
+ provenance.source,
396
+ provenance.sourceId,
397
+ provenance.authoritative,
398
+ provenance.file ?? '',
399
+ provenance.line ?? 0,
400
+ provenance.ownerIri ?? '',
401
+ provenance.shapeIri ?? '',
402
+ provenance.propertyIri ?? '',
403
+ provenance.nodeIri ?? '',
404
+ provenance.fromPackage ?? '',
405
+ provenance.targetClass ?? '',
406
+ ]);
407
+ }
408
+
409
+ function sortDeclarations(
410
+ declarations: TranslationDeclaration[],
411
+ ): TranslationDeclaration[] {
412
+ return [...declarations].sort((left, right) => {
413
+ const source = canonicalProvenance(left.provenance).localeCompare(
414
+ canonicalProvenance(right.provenance),
415
+ );
416
+ if (source !== 0) return source;
417
+ return JSON.stringify([
418
+ left.sourceText,
419
+ left.format,
420
+ left.kind,
421
+ left.key,
422
+ ]).localeCompare(
423
+ JSON.stringify([
424
+ right.sourceText,
425
+ right.format,
426
+ right.kind,
427
+ right.key,
428
+ ]),
429
+ );
430
+ });
431
+ }
432
+
433
+ function uniqueOrigins(
434
+ declarations: TranslationDeclaration[],
435
+ ): TranslationDeclarationProvenance[] {
436
+ return [
437
+ ...new Map(
438
+ sortDeclarations(declarations).map(({ provenance }) => [
439
+ canonicalProvenance(provenance),
440
+ provenance,
441
+ ]),
442
+ ).values(),
443
+ ];
444
+ }
445
+
446
+ function itemFromDeclarations(
447
+ appId: string,
448
+ identity: string,
449
+ declarations: TranslationDeclaration[],
450
+ ): {
451
+ item: TranslationInventoryItem;
452
+ conflict?: TranslationInventoryConflict;
453
+ } {
454
+ const ordered = sortDeclarations(declarations);
455
+ const authoritative = ordered.filter(
456
+ ({ provenance }) => provenance.authoritative,
457
+ );
458
+ const contracts = new Set(
459
+ authoritative.map(({ sourceText, format }) =>
460
+ JSON.stringify([sourceText, format]),
461
+ ),
462
+ );
463
+ const preferred = authoritative[0] ?? ordered[0];
464
+ const conflict = contracts.size > 1;
465
+ const status: TranslationInventoryStatus = conflict
466
+ ? 'conflict'
467
+ : authoritative.length
468
+ ? 'confirmed'
469
+ : 'pending';
470
+ const kind = ordered.reduce(
471
+ (current, declaration) =>
472
+ KIND_ORDER[declaration.kind] > KIND_ORDER[current]
473
+ ? declaration.kind
474
+ : current,
475
+ preferred.kind,
476
+ );
477
+ const item: TranslationInventoryItem = {
478
+ identity,
479
+ appId,
480
+ key: preferred.key,
481
+ sourceText: preferred.sourceText,
482
+ kind,
483
+ format: preferred.format,
484
+ status,
485
+ authoritative: authoritative.length > 0,
486
+ exchangeEligible:
487
+ status === 'confirmed' && kind !== 'content',
488
+ buildEligible:
489
+ status === 'confirmed' && kind !== 'content',
490
+ origins: uniqueOrigins(ordered),
491
+ ...(preferred.namespace ? { namespace: preferred.namespace } : {}),
492
+ ...(preferred.description ? { description: preferred.description } : {}),
493
+ };
494
+ return {
495
+ item,
496
+ ...(conflict ? { conflict: { identity, declarations: ordered } } : {}),
497
+ };
498
+ }
499
+
500
+ function buildDeclaration(
501
+ item: TranslationInventoryItem,
502
+ ): TranslationBuildDeclaration {
503
+ const first = item.origins[0];
504
+ return {
505
+ key: item.key,
506
+ sourceText: item.sourceText,
507
+ format: item.format,
508
+ kind: item.kind,
509
+ status: 'confirmed' satisfies TranslationDeclarationStatus,
510
+ authoritative: true,
511
+ ...(item.namespace ? { namespace: item.namespace } : {}),
512
+ ...(first?.file ? { file: first.file } : {}),
513
+ ...(first?.line ? { line: first.line } : {}),
514
+ };
515
+ }
516
+
517
+ /** Build-facing confirmed subset of a persisted or freshly discovered inventory. */
518
+ export function translationBuildDeclarationsFromInventory(
519
+ items: Iterable<TranslationInventoryItem>,
520
+ ): TranslationBuildDeclaration[] {
521
+ return [...items]
522
+ .filter(({ buildEligible, status }) => buildEligible && status === 'confirmed')
523
+ .map(buildDeclaration)
524
+ .sort((left, right) =>
525
+ JSON.stringify([left.namespace ?? '', left.key]).localeCompare(
526
+ JSON.stringify([right.namespace ?? '', right.key]),
527
+ ),
528
+ );
529
+ }
530
+
531
+ /**
532
+ * Discover and deterministically merge every source. This function is pure
533
+ * with respect to storage: hosts decide how snapshots and lifecycle changes
534
+ * are persisted.
535
+ */
536
+ export async function discoverTranslationInventory(
537
+ sources: Iterable<TranslationDiscoverySource>,
538
+ context: TranslationDiscoveryContext,
539
+ options: DiscoverTranslationInventoryOptions = {},
540
+ ): Promise<TranslationInventorySnapshot> {
541
+ const appId = requiredString(context.appId, 'context.appId');
542
+ const byIdentity = new Map<string, TranslationDeclaration[]>();
543
+ const orderedSources = [...sources].sort((left, right) =>
544
+ left.name.localeCompare(right.name),
545
+ );
546
+ for (const source of orderedSources) {
547
+ for await (const raw of source.discover({ ...context, appId })) {
548
+ const declaration = normalizeDeclaration(raw);
549
+ const identity = translationDeclarationIdentity(appId, declaration);
550
+ const values = byIdentity.get(identity) ?? [];
551
+ values.push(declaration);
552
+ byIdentity.set(identity, values);
553
+ }
554
+ }
555
+
556
+ const items: TranslationInventoryItem[] = [];
557
+ const conflicts: TranslationInventoryConflict[] = [];
558
+ for (const identity of [...byIdentity.keys()].sort()) {
559
+ const merged = itemFromDeclarations(
560
+ appId,
561
+ identity,
562
+ byIdentity.get(identity)!,
563
+ );
564
+ items.push(merged.item);
565
+ if (merged.conflict) conflicts.push(merged.conflict);
566
+ }
567
+
568
+ const currentIdentities = new Set(byIdentity.keys());
569
+ for (const previous of options.previous?.items ?? []) {
570
+ if (
571
+ previous.appId !== appId ||
572
+ currentIdentities.has(previous.identity) ||
573
+ previous.status === 'pending'
574
+ ) {
575
+ continue;
576
+ }
577
+ items.push({
578
+ ...previous,
579
+ status: 'orphaned',
580
+ buildEligible: false,
581
+ exchangeEligible: false,
582
+ });
583
+ }
584
+ items.sort((left, right) => left.identity.localeCompare(right.identity));
585
+
586
+ return {
587
+ schemaVersion: 1,
588
+ appId,
589
+ ...(context.branchId ? { branchId: context.branchId } : {}),
590
+ items,
591
+ conflicts,
592
+ buildDeclarations: translationBuildDeclarationsFromInventory(items),
593
+ };
594
+ }