@_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
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
- }