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