@_linked/translation 0.9.4 → 0.10.0

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 (45) hide show
  1. package/README.md +30 -0
  2. package/lib/esm/shape-key-copies.d.ts +62 -0
  3. package/lib/esm/shape-key-copies.js +156 -0
  4. package/lib/esm/shape-key-copies.js.map +1 -0
  5. package/package.json +2 -3
  6. package/src/archive.ts +0 -914
  7. package/src/authorization.ts +0 -56
  8. package/src/backend.ts +0 -2
  9. package/src/cdn-loader.ts +0 -264
  10. package/src/checks.ts +0 -413
  11. package/src/compile.ts +0 -168
  12. package/src/core/messages.ts +0 -139
  13. package/src/data/translation.ttl +0 -489
  14. package/src/discovery.ts +0 -594
  15. package/src/exchange.ts +0 -437
  16. package/src/export.ts +0 -138
  17. package/src/formats/csv.ts +0 -467
  18. package/src/formats/json.ts +0 -596
  19. package/src/formats/xliff.ts +0 -733
  20. package/src/import.ts +0 -648
  21. package/src/index.ts +0 -201
  22. package/src/key-sync-node.ts +0 -269
  23. package/src/key-sync.ts +0 -208
  24. package/src/key-version.ts +0 -207
  25. package/src/languages.ts +0 -102
  26. package/src/linked-discovery.ts +0 -385
  27. package/src/message-format.ts +0 -214
  28. package/src/mt.ts +0 -253
  29. package/src/ontologies/translation.register.ts +0 -17
  30. package/src/ontologies/translation.ts +0 -239
  31. package/src/package.ts +0 -14
  32. package/src/react.tsx +0 -378
  33. package/src/records.ts +0 -181
  34. package/src/release.ts +0 -754
  35. package/src/shape-catalog.ts +0 -198
  36. package/src/shapes/GlossaryTerm.ts +0 -61
  37. package/src/shapes/TranslationInventoryOrigin.ts +0 -131
  38. package/src/shapes/TranslationKey.ts +0 -183
  39. package/src/shapes/TranslationKeyVersion.ts +0 -77
  40. package/src/shapes/TranslationLanguage.ts +0 -51
  41. package/src/shapes/TranslationProvider.ts +0 -1927
  42. package/src/shapes/TranslationRelease.ts +0 -114
  43. package/src/shapes/TranslationRevision.ts +0 -105
  44. package/src/shapes/TranslationUnit.ts +0 -72
  45. package/src/shapes/index.ts +0 -13
package/src/release.ts DELETED
@@ -1,754 +0,0 @@
1
- import type { TranslationLanguageDefinition } from './languages.js';
2
- import { runChecks, type CheckFinding } from './checks.js';
3
- import { compileLanguage } from './compile.js';
4
- import type { TranslationMessages } from './core/messages.js';
5
- import {
6
- classifyKeyVersion,
7
- createArgumentSignature,
8
- sha256Hex,
9
- } from './key-version.js';
10
- import {
11
- isConfirmedBuildDeclaration,
12
- type TranslationBuildDeclaration,
13
- } from './exchange.js';
14
- import type { GlossaryTermRecord, TranslationEntryRecord } from './records.js';
15
- import type { ExtractedTranslationKey } from './key-sync.js';
16
-
17
- export interface TranslationBuildDescriptor {
18
- schemaVersion: 2;
19
- appId: string;
20
- releaseId: string;
21
- contractSetHash: string;
22
- manifestUrl: string;
23
- }
24
-
25
- /** Serializable handoff from translation publication to an exact app build. */
26
- export interface TranslationBuildPin {
27
- schemaVersion: 1;
28
- appId: string;
29
- branchId: string;
30
- buildId: string;
31
- channel: 'preview' | 'production';
32
- releaseId: string;
33
- contractSetHash: string;
34
- manifestHash: string;
35
- manifestUrl: string;
36
- /** Sequence baked into the fallback catalogs at build time. */
37
- hotfixSequence: number;
38
- catalogs: Record<string, TranslationCatalogObject>;
39
- pinnedAt: string;
40
- /** Which declaration source supplied the contract verifier for this build. */
41
- inventorySource?: 'confirmed-inventory' | 'static-fallback';
42
- }
43
-
44
- export interface TranslationCatalogObject {
45
- hash: string;
46
- url: string;
47
- bytes: number;
48
- encoding?: 'gzip';
49
- }
50
-
51
- export interface TranslationCatalogPatchObject
52
- extends TranslationCatalogObject {
53
- sequence: number;
54
- }
55
-
56
- export interface TranslationQualitySummary {
57
- errors: number;
58
- warnings: number;
59
- rules: Record<string, number>;
60
- }
61
-
62
- export interface TranslationReleaseManifest {
63
- languageResources?: TranslationLanguageDefinition[];
64
- schemaVersion: 2;
65
- releaseId: string;
66
- contractSetHash: string;
67
- hotfixSequence: number;
68
- revisionWatermark?: string;
69
- /** Compact release selection index: logical key → exact key-version IRI. */
70
- keyVersions: Record<string, string>;
71
- quality: Record<string, TranslationQualitySummary>;
72
- languages: Record<
73
- string,
74
- {
75
- base: TranslationCatalogObject;
76
- patches: TranslationCatalogPatchObject[];
77
- }
78
- >;
79
- }
80
-
81
- export interface TranslationCatalogPatch {
82
- set: TranslationMessages;
83
- remove?: string[];
84
- }
85
-
86
- export interface CompileReleaseInput {
87
- languageResources?: TranslationLanguageDefinition[];
88
- appId: string;
89
- branchId: string;
90
- buildId?: string;
91
- releaseId?: string;
92
- publicBase?: string;
93
- channel?: 'preview' | 'production';
94
- languages: string[];
95
- defaultLanguage: string;
96
- reviewedOnlyLanguages?: Iterable<string>;
97
- previousRelease?: TranslationReleaseManifest;
98
- revisionWatermark?: string;
99
- glossary?: GlossaryTermRecord[];
100
- allowQualityErrors?: boolean;
101
- }
102
-
103
- export interface CompiledTranslationRelease {
104
- descriptor: TranslationBuildDescriptor;
105
- manifest: TranslationReleaseManifest;
106
- manifestHash: string;
107
- immutableObjects: Array<{
108
- kind: 'base' | 'patch';
109
- hash: string;
110
- body: Uint8Array;
111
- }>;
112
- }
113
-
114
- export interface TranslationReleaseReuseCandidate {
115
- releaseId: string;
116
- manifestHash: string;
117
- }
118
-
119
- export interface CompiledTranslationHotfix {
120
- key: string;
121
- language: string;
122
- sequence: number;
123
- patch: TranslationCatalogPatch;
124
- immutableObject: {
125
- kind: 'patch';
126
- hash: string;
127
- body: Uint8Array;
128
- };
129
- manifest: TranslationReleaseManifest;
130
- }
131
-
132
- export interface CompiledTranslationCompaction {
133
- language: string;
134
- immutableObject: {
135
- kind: 'base';
136
- hash: string;
137
- body: Uint8Array;
138
- };
139
- manifest: TranslationReleaseManifest;
140
- }
141
-
142
- export interface TranslationObjectInventoryEntry {
143
- /** App-relative object name, for example `objects/catalog/<hash>.json`. */
144
- name: string;
145
- lastModified?: Date | string;
146
- }
147
-
148
- export interface TranslationCompactionThresholds {
149
- maxPatches?: number;
150
- maxPatchToBaseRatio?: number;
151
- }
152
-
153
- export const DEFAULT_TRANSLATION_MAX_PATCHES = 8;
154
- export const DEFAULT_TRANSLATION_PATCH_TO_BASE_RATIO = 0.25;
155
-
156
- const encoder = new TextEncoder();
157
- const decoder = new TextDecoder();
158
-
159
- /** Recursive canonical JSON used for every content-addressed release object. */
160
- export function canonicalJson(value: unknown): string {
161
- if (value === null || typeof value !== 'object') return JSON.stringify(value);
162
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
163
- const record = value as Record<string, unknown>;
164
- return `{${Object.keys(record)
165
- .sort()
166
- .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
167
- .join(',')}}`;
168
- }
169
-
170
- function translationContracts(entries: TranslationEntryRecord[]) {
171
- return entries
172
- .filter((entry) => entry.kind === 'ui')
173
- .sort((left, right) => left.key.localeCompare(right.key))
174
- .map((entry) => ({
175
- key: entry.key,
176
- versionId: entry.currentVersion?.id ?? null,
177
- contractHash: entry.currentVersion?.contractHash ?? null,
178
- }));
179
- }
180
-
181
- /** The one canonical contract-set hash used by release compile and build pinning. */
182
- export async function translationContractSetHash(
183
- entries: TranslationEntryRecord[]
184
- ): Promise<string> {
185
- return sha256Hex(canonicalJson(translationContracts(entries)));
186
- }
187
-
188
- /**
189
- * Resolve keys extracted from one immutable build workspace to graph versions.
190
- * Missing/orphaned/conflicting/default-drifted keys fail before publication.
191
- * Explicit app-owned declaration overrides may change wording, never the
192
- * exact workspace's declared format or runtime-argument contract.
193
- */
194
- export async function buildSnapshotContractSetHash(
195
- entries: TranslationEntryRecord[],
196
- extracted: Array<ExtractedTranslationKey | TranslationBuildDeclaration>
197
- ): Promise<string> {
198
- const declarations = extracted.filter(isConfirmedBuildDeclaration);
199
- const declarationsByKey = new Map<string, TranslationBuildDeclaration[]>();
200
- for (const item of declarations) {
201
- const values = declarationsByKey.get(item.key) ?? [];
202
- values.push(item);
203
- declarationsByKey.set(item.key, values);
204
- }
205
- const selected = entries
206
- .filter((entry) => entry.kind === 'ui')
207
- .sort((left, right) => left.key.localeCompare(right.key));
208
- const graphKeys = new Set(selected.map(({ key }) => key));
209
- const missing = [...declarationsByKey.keys()]
210
- .filter((key) => !graphKeys.has(key))
211
- .sort();
212
- const orphaned = selected
213
- .map(({ key }) => key)
214
- .filter((key) => !declarationsByKey.has(key))
215
- .sort();
216
- if (missing.length || orphaned.length) {
217
- throw new Error(
218
- `Build translation contract differs from the release: ${
219
- missing.length ? `missing in graph [${missing.join(', ')}]` : ''
220
- }${missing.length && orphaned.length ? '; ' : ''}${
221
- orphaned.length ? `missing in build [${orphaned.join(', ')}]` : ''
222
- }.`
223
- );
224
- }
225
-
226
- for (const entry of selected) {
227
- const current = entry.currentVersion;
228
- if (!current?.id) {
229
- throw new Error(
230
- `Translation key "${entry.key}" has no current contract version.`
231
- );
232
- }
233
- const contracts = (declarationsByKey.get(entry.key) ?? []).map(
234
- (declaration) => ({
235
- sourceText: declaration.sourceText,
236
- // Legacy static extraction has no format. Preserve its shipped behavior
237
- // by resolving against the current graph contract.
238
- format: declaration.format ?? current.format ?? entry.format,
239
- })
240
- );
241
- const distinct = new Map(
242
- contracts.map((contract) => [
243
- canonicalJson([contract.sourceText, contract.format]),
244
- contract,
245
- ])
246
- );
247
- if (distinct.size > 1) {
248
- throw new Error(
249
- `Build translation key "${entry.key}" has conflicting source defaults or formats.`
250
- );
251
- }
252
- const extractedContract = [...distinct.values()][0];
253
- const decision = classifyKeyVersion(current, extractedContract);
254
- const explicitDeclarationOverride =
255
- entry.overridden === true &&
256
- !!(
257
- entry.ofShape ||
258
- entry.ofProperty ||
259
- entry.ofResource ||
260
- entry.fromPackage
261
- );
262
- // source-changed is reached only AFTER the existing classifier has checked
263
- // identical format and argument signature. Do not infer the missing format
264
- // of a legacy extraction from the very app override being verified.
265
- const compatibleAppWording =
266
- explicitDeclarationOverride &&
267
- decision.reason === 'source-changed' &&
268
- (declarationsByKey.get(entry.key) ?? []).every(
269
- (item) =>
270
- item.format !== undefined && item.namespace === entry.namespace
271
- ) &&
272
- entry.sourceText === current.sourceText &&
273
- entry.format === current.format &&
274
- createArgumentSignature(current.sourceText, current.format) ===
275
- current.argumentSignature;
276
- if (decision.action !== 'unchanged' && !compatibleAppWording) {
277
- throw new Error(
278
- `Build translation source for "${entry.key}" does not match its current key version (${decision.reason}).`
279
- );
280
- }
281
- }
282
- return translationContractSetHash(selected);
283
- }
284
-
285
- export function assertTranslationContractSet(
286
- buildContractSetHash: string,
287
- releaseContractSetHash: string
288
- ): void {
289
- if (buildContractSetHash !== releaseContractSetHash) {
290
- throw new Error(
291
- `Build translation contract ${buildContractSetHash} does not match release contract ${releaseContractSetHash}.`
292
- );
293
- }
294
- }
295
-
296
- /** Verify immutable catalog bytes before a build adapter writes them. */
297
- export async function assertTranslationObjectHash(
298
- value: string | Uint8Array,
299
- expectedHash: string
300
- ): Promise<void> {
301
- const text = typeof value === 'string' ? value : decoder.decode(value);
302
- const actual = await sha256Hex(text);
303
- if (actual !== expectedHash) {
304
- throw new Error(
305
- `Translation object hash mismatch: expected ${expectedHash}, received ${actual}.`
306
- );
307
- }
308
- }
309
-
310
- export function createTranslationBuildPin(input: {
311
- descriptor: TranslationBuildDescriptor;
312
- manifest: TranslationReleaseManifest;
313
- manifestHash: string;
314
- branchId: string;
315
- buildId: string;
316
- channel: 'preview' | 'production';
317
- pinnedAt?: string;
318
- inventorySource?: 'confirmed-inventory' | 'static-fallback';
319
- }): TranslationBuildPin {
320
- if (
321
- input.manifest.releaseId !== input.descriptor.releaseId ||
322
- input.manifest.contractSetHash !== input.descriptor.contractSetHash
323
- ) {
324
- throw new Error(
325
- 'Translation release descriptor and manifest are incompatible.'
326
- );
327
- }
328
- return {
329
- schemaVersion: 1,
330
- appId: input.descriptor.appId,
331
- branchId: input.branchId,
332
- buildId: input.buildId,
333
- channel: input.channel,
334
- releaseId: input.descriptor.releaseId,
335
- contractSetHash: input.descriptor.contractSetHash,
336
- manifestHash: input.manifestHash,
337
- manifestUrl: input.descriptor.manifestUrl,
338
- hotfixSequence: input.manifest.hotfixSequence,
339
- catalogs: Object.fromEntries(
340
- Object.entries(input.manifest.languages).map(([language, lane]) => [
341
- language,
342
- lane.base,
343
- ])
344
- ),
345
- pinnedAt: input.pinnedAt ?? new Date().toISOString(),
346
- ...(input.inventorySource
347
- ? { inventorySource: input.inventorySource }
348
- : {}),
349
- };
350
- }
351
-
352
- export function decodeReleaseObject(body: Uint8Array): unknown {
353
- return JSON.parse(decoder.decode(body));
354
- }
355
-
356
- export function applyCatalogPatches(
357
- base: TranslationMessages,
358
- patches: TranslationCatalogPatch[]
359
- ): TranslationMessages {
360
- const result: TranslationMessages = { ...base };
361
- for (const patch of patches) {
362
- for (const key of patch.remove ?? []) delete result[key];
363
- Object.assign(result, patch.set);
364
- }
365
- return result;
366
- }
367
-
368
- export function releaseReferencesKeyVersion(
369
- manifest: TranslationReleaseManifest,
370
- keyVersionId: string
371
- ): boolean {
372
- return Object.values(manifest.keyVersions).includes(keyVersionId);
373
- }
374
-
375
- export function keyForVersion(
376
- manifest: TranslationReleaseManifest,
377
- keyVersionId: string
378
- ): string | undefined {
379
- return Object.entries(manifest.keyVersions).find(
380
- ([, version]) => version === keyVersionId
381
- )?.[0];
382
- }
383
-
384
- export function shouldCompactTranslationLanguage(
385
- lane: TranslationReleaseManifest['languages'][string],
386
- thresholds: TranslationCompactionThresholds = {}
387
- ): boolean {
388
- const maxPatches = thresholds.maxPatches ?? DEFAULT_TRANSLATION_MAX_PATCHES;
389
- const maxPatchToBaseRatio =
390
- thresholds.maxPatchToBaseRatio ?? DEFAULT_TRANSLATION_PATCH_TO_BASE_RATIO;
391
- if (lane.patches.length > maxPatches) return true;
392
- if (lane.base.bytes <= 0) return lane.patches.length > 0;
393
- const patchBytes = lane.patches.reduce(
394
- (total, patch) => total + patch.bytes,
395
- 0
396
- );
397
- return patchBytes > lane.base.bytes * maxPatchToBaseRatio;
398
- }
399
-
400
- function publicBaseFromObjectUrl(url: string): string | undefined {
401
- const marker = '/objects/';
402
- const index = url.indexOf(marker);
403
- return index < 0 ? undefined : url.slice(0, index);
404
- }
405
-
406
- /**
407
- * Replace one eligible patch chain with an equivalent immutable base. The
408
- * release identity, contract set, and hotfix sequence deliberately do not
409
- * change because compaction changes representation, not translation content.
410
- */
411
- export async function compileTranslationCompaction(input: {
412
- manifest: TranslationReleaseManifest;
413
- language: string;
414
- currentMessages: TranslationMessages;
415
- force?: boolean;
416
- thresholds?: TranslationCompactionThresholds;
417
- }): Promise<CompiledTranslationCompaction | null> {
418
- const lane = input.manifest.languages[input.language];
419
- if (!lane) {
420
- throw new Error(
421
- `Release ${input.manifest.releaseId} does not publish ${input.language}.`
422
- );
423
- }
424
- if (
425
- !input.force &&
426
- !shouldCompactTranslationLanguage(lane, input.thresholds)
427
- ) {
428
- return null;
429
- }
430
- if (lane.patches.length === 0) return null;
431
-
432
- const body = encoder.encode(canonicalJson(input.currentMessages));
433
- const hash = await sha256Hex(decoder.decode(body));
434
- const base: TranslationCatalogObject = {
435
- hash,
436
- url: objectUrl(
437
- publicBaseFromObjectUrl(lane.base.url),
438
- `objects/catalog/${hash}.json`
439
- ),
440
- bytes: body.byteLength,
441
- };
442
- return {
443
- language: input.language,
444
- immutableObject: { kind: 'base', hash, body },
445
- manifest: {
446
- ...input.manifest,
447
- languages: {
448
- ...input.manifest.languages,
449
- [input.language]: {
450
- base,
451
- patches: [],
452
- },
453
- },
454
- },
455
- };
456
- }
457
-
458
- function objectNameFromUrl(url: string, fallback: string): string {
459
- const clean = url.split(/[?#]/, 1)[0].replace(/^\/+/, '');
460
- const marker = 'objects/';
461
- const index = clean.indexOf(marker);
462
- return index >= 0 ? clean.slice(index) : fallback;
463
- }
464
-
465
- /** Every immutable object directly reachable from the supplied manifests. */
466
- export function collectReferencedTranslationObjects(
467
- manifests: Iterable<TranslationReleaseManifest>
468
- ): Set<string> {
469
- const referenced = new Set<string>();
470
- for (const manifest of manifests) {
471
- for (const lane of Object.values(manifest.languages)) {
472
- referenced.add(
473
- objectNameFromUrl(
474
- lane.base.url,
475
- `objects/catalog/${lane.base.hash}.json${
476
- lane.base.encoding === 'gzip' ? '.gz' : ''
477
- }`
478
- )
479
- );
480
- for (const patch of lane.patches) {
481
- referenced.add(
482
- objectNameFromUrl(
483
- patch.url,
484
- `objects/patch/${patch.hash}.json${
485
- patch.encoding === 'gzip' ? '.gz' : ''
486
- }`
487
- )
488
- );
489
- }
490
- }
491
- }
492
- return referenced;
493
- }
494
-
495
- /**
496
- * Mark only old, content-addressed objects that are absent from every retained
497
- * manifest. Unknown timestamps fail closed and remain retained.
498
- */
499
- export function selectPrunableTranslationObjects(input: {
500
- objects: Iterable<TranslationObjectInventoryEntry>;
501
- manifests: Iterable<TranslationReleaseManifest>;
502
- now?: Date | number;
503
- gracePeriodMs: number;
504
- }): string[] {
505
- if (!Number.isFinite(input.gracePeriodMs) || input.gracePeriodMs < 0) {
506
- throw new Error('gracePeriodMs must be a non-negative finite number.');
507
- }
508
- const now =
509
- input.now instanceof Date ? input.now.getTime() : input.now ?? Date.now();
510
- const referenced = collectReferencedTranslationObjects(input.manifests);
511
- const candidates = new Set<string>();
512
- for (const object of input.objects) {
513
- const name = object.name.replace(/^\/+/, '');
514
- if (
515
- !/^objects\/(?:catalog|patch)\/[^/]+\.json(?:\.gz)?$/.test(name) ||
516
- referenced.has(name) ||
517
- !object.lastModified
518
- ) {
519
- continue;
520
- }
521
- const modifiedAt =
522
- object.lastModified instanceof Date
523
- ? object.lastModified.getTime()
524
- : new Date(object.lastModified).getTime();
525
- if (
526
- Number.isFinite(modifiedAt) &&
527
- now - modifiedAt >= input.gracePeriodMs
528
- ) {
529
- candidates.add(name);
530
- }
531
- }
532
- return [...candidates].sort();
533
- }
534
-
535
- /**
536
- * Create one content-addressed language patch and advance a compatible
537
- * manifest. Returns null for an idempotent re-approval of the current value.
538
- */
539
- export async function compileTranslationHotfix(input: {
540
- manifest: TranslationReleaseManifest;
541
- keyVersionId: string;
542
- language: string;
543
- text: string;
544
- currentMessages: TranslationMessages;
545
- }): Promise<CompiledTranslationHotfix | null> {
546
- const key = keyForVersion(input.manifest, input.keyVersionId);
547
- if (!key) {
548
- throw new Error(
549
- `Release ${input.manifest.releaseId} does not reference key version ${input.keyVersionId}.`
550
- );
551
- }
552
- const lane = input.manifest.languages[input.language];
553
- if (!lane) {
554
- throw new Error(
555
- `Release ${input.manifest.releaseId} does not publish ${input.language}.`
556
- );
557
- }
558
- const current = input.currentMessages[key];
559
- const next =
560
- typeof current === 'object' && current?.format === 'icu'
561
- ? { message: input.text, format: 'icu' as const }
562
- : input.text;
563
- if (canonicalJson(current) === canonicalJson(next)) return null;
564
-
565
- const patch: TranslationCatalogPatch = { set: { [key]: next } };
566
- const body = encoder.encode(canonicalJson(patch));
567
- const hash = await sha256Hex(decoder.decode(body));
568
- const sequence = input.manifest.hotfixSequence + 1;
569
- const publicBase = publicBaseFromObjectUrl(lane.base.url);
570
- const patchObject: TranslationCatalogPatchObject = {
571
- hash,
572
- url: objectUrl(publicBase, `objects/patch/${hash}.json`),
573
- bytes: body.byteLength,
574
- sequence,
575
- };
576
- return {
577
- key,
578
- language: input.language,
579
- sequence,
580
- patch,
581
- immutableObject: { kind: 'patch', hash, body },
582
- manifest: {
583
- ...input.manifest,
584
- hotfixSequence: sequence,
585
- languages: {
586
- ...input.manifest.languages,
587
- [input.language]: {
588
- ...lane,
589
- patches: [...lane.patches, patchObject],
590
- },
591
- },
592
- },
593
- };
594
- }
595
-
596
- function qualitySummary(findings: CheckFinding[]): TranslationQualitySummary {
597
- const rules: Record<string, number> = {};
598
- let errors = 0;
599
- let warnings = 0;
600
- for (const finding of findings) {
601
- finding.severity === 'error' ? errors++ : warnings++;
602
- rules[finding.rule] = (rules[finding.rule] ?? 0) + 1;
603
- }
604
- return { errors, warnings, rules };
605
- }
606
-
607
- function objectUrl(publicBase: string | undefined, path: string): string {
608
- const base = publicBase?.replace(/\/+$/, '');
609
- return base ? `${base}/${path}` : path;
610
- }
611
-
612
- /**
613
- * Compile one immutable schema-v2 release. The input entries are the captured
614
- * graph snapshot; callers are responsible for supplying its revision watermark.
615
- */
616
- export async function compileRelease(
617
- entries: TranslationEntryRecord[],
618
- input: CompileReleaseInput
619
- ): Promise<CompiledTranslationRelease> {
620
- const reviewedOnly = new Set(input.reviewedOnlyLanguages ?? []);
621
- const selected = entries
622
- .filter((entry) => entry.kind === 'ui')
623
- .sort((left, right) => left.key.localeCompare(right.key));
624
- const missingVersion = selected.find((entry) => !entry.currentVersion?.id);
625
- if (missingVersion) {
626
- throw new Error(
627
- `Translation key "${missingVersion.key}" has no current contract version.`
628
- );
629
- }
630
- const keyVersions = Object.fromEntries(
631
- selected.map((entry) => [entry.key, entry.currentVersion!.id])
632
- );
633
- const contractSetHash = await translationContractSetHash(selected);
634
- const releaseSeed = canonicalJson([
635
- input.appId,
636
- input.branchId,
637
- input.buildId ?? '',
638
- contractSetHash,
639
- ]);
640
- const releaseId =
641
- input.releaseId?.trim() ||
642
- `release-${(await sha256Hex(releaseSeed)).slice(0, 24)}`;
643
- const immutableObjects: CompiledTranslationRelease['immutableObjects'] = [];
644
- const languages: TranslationReleaseManifest['languages'] = {};
645
- const quality: TranslationReleaseManifest['quality'] = {};
646
-
647
- for (const language of [...new Set(input.languages)].sort()) {
648
- const payload = compileLanguage(selected, language, {
649
- defaultLanguage: input.defaultLanguage,
650
- reviewedOnly: reviewedOnly.has(language),
651
- languageResources: input.languageResources,
652
- });
653
- const body = encoder.encode(canonicalJson(payload));
654
- const hash = await sha256Hex(decoder.decode(body));
655
- immutableObjects.push({ kind: 'base', hash, body });
656
- languages[language] = {
657
- base: {
658
- hash,
659
- url: objectUrl(input.publicBase, `objects/catalog/${hash}.json`),
660
- bytes: body.byteLength,
661
- },
662
- patches: [],
663
- };
664
- const checkedEntries = selected.map((entry) => {
665
- const message = payload[entry.key];
666
- const text =
667
- typeof message === 'string' ? message : message?.message ?? '';
668
- return {
669
- ...entry,
670
- units: {
671
- ...entry.units,
672
- [language]: {
673
- language,
674
- text,
675
- state: entry.units[language]?.state ?? 'untranslated',
676
- },
677
- },
678
- };
679
- });
680
- quality[language] = qualitySummary(
681
- language === input.defaultLanguage
682
- ? []
683
- : runChecks(checkedEntries, language, input.glossary ?? [])
684
- );
685
- }
686
-
687
- const errorCount = Object.values(quality).reduce(
688
- (total, summary) => total + summary.errors,
689
- 0
690
- );
691
- if (
692
- input.channel === 'production' &&
693
- errorCount > 0 &&
694
- !input.allowQualityErrors
695
- ) {
696
- throw new Error(
697
- `Production translation release blocked by ${errorCount} quality error${
698
- errorCount === 1 ? '' : 's'
699
- }.`
700
- );
701
- }
702
-
703
- const manifest: TranslationReleaseManifest = {
704
- schemaVersion: 2,
705
- releaseId,
706
- contractSetHash,
707
- hotfixSequence: 0,
708
- ...(input.revisionWatermark
709
- ? { revisionWatermark: input.revisionWatermark }
710
- : {}),
711
- keyVersions,
712
- quality,
713
- languages,
714
- ...(input.languageResources
715
- ? { languageResources: input.languageResources }
716
- : {}),
717
- };
718
- const manifestHash = await sha256Hex(canonicalJson(manifest));
719
- return {
720
- descriptor: {
721
- schemaVersion: 2,
722
- appId: input.appId,
723
- releaseId,
724
- contractSetHash,
725
- manifestUrl: objectUrl(
726
- input.publicBase,
727
- `releases/${releaseId}/manifest.json`
728
- ),
729
- },
730
- manifest,
731
- manifestHash,
732
- immutableObjects,
733
- };
734
- }
735
-
736
- /**
737
- * Recompile content beneath existing release identities and reuse only an
738
- * exact manifest match. Contract equality alone is insufficient because
739
- * selected translations, language policy, quality, or delivery URLs may differ.
740
- */
741
- export async function findReusableCompiledRelease(
742
- entries: TranslationEntryRecord[],
743
- input: Omit<CompileReleaseInput, 'releaseId' | 'buildId'>,
744
- candidates: TranslationReleaseReuseCandidate[]
745
- ): Promise<CompiledTranslationRelease | null> {
746
- for (const candidate of candidates) {
747
- const compiled = await compileRelease(entries, {
748
- ...input,
749
- releaseId: candidate.releaseId,
750
- });
751
- if (compiled.manifestHash === candidate.manifestHash) return compiled;
752
- }
753
- return null;
754
- }