@_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,587 @@
1
+ import { unzipSync, zipSync, type Zippable } from 'fflate';
2
+
3
+ import {
4
+ normalizeParsedTranslationExchangeDocument,
5
+ validateTranslationExchangeDocument,
6
+ type ParsedTranslationExchangeDocument,
7
+ type SerializedTranslationDocument,
8
+ type TranslationExchangeDocument,
9
+ type TranslationExchangeEntry,
10
+ type TranslationFormatAdapter,
11
+ } from '../exchange.js';
12
+ import { canonicalLanguageTag } from '../key-version.js';
13
+
14
+ const textDecoder = new TextDecoder('utf-8', { fatal: true });
15
+ const textEncoder = new TextEncoder();
16
+ const SIDECAR_PATH = 'create-now.exchange.json';
17
+ const ZIP_LOCAL_FILE_SIGNATURE = 0x04034b50;
18
+ const ZIP_EMPTY_SIGNATURE = 0x06054b50;
19
+ const ZIP_SPANNED_SIGNATURE = 0x08074b50;
20
+
21
+ export type I18nextJsonLayout = 'flat' | 'nested';
22
+
23
+ export interface TranslationJsonArchiveLimits {
24
+ maxEntries: number;
25
+ maxArchiveBytes: number;
26
+ maxEntryCompressedBytes: number;
27
+ maxEntryUncompressedBytes: number;
28
+ maxTotalUncompressedBytes: number;
29
+ maxCompressionRatio: number;
30
+ }
31
+
32
+ export const DEFAULT_TRANSLATION_JSON_ARCHIVE_LIMITS:
33
+ TranslationJsonArchiveLimits = Object.freeze({
34
+ maxEntries: 256,
35
+ maxArchiveBytes: 16 * 1024 * 1024,
36
+ maxEntryCompressedBytes: 8 * 1024 * 1024,
37
+ maxEntryUncompressedBytes: 16 * 1024 * 1024,
38
+ maxTotalUncompressedBytes: 64 * 1024 * 1024,
39
+ maxCompressionRatio: 100,
40
+ });
41
+
42
+ export interface TranslationJsonParseOptions {
43
+ /** Required when a standalone filename does not begin with a BCP-47 tag. */
44
+ language?: string;
45
+ /** Standalone JSON has no source evidence, so this defaults to `und`. */
46
+ sourceLanguage?: string;
47
+ /** Tolgee's default namespace is exposed as unprefixed runtime keys. */
48
+ defaultNamespace?: string;
49
+ fileName?: string;
50
+ limits?: Partial<TranslationJsonArchiveLimits>;
51
+ }
52
+
53
+ export interface TranslationJsonSerializeOptions {
54
+ layout?: I18nextJsonLayout;
55
+ limits?: Partial<TranslationJsonArchiveLimits>;
56
+ }
57
+
58
+ interface CreateNowJsonSidecar {
59
+ schemaVersion: 1;
60
+ format: 'create-now-i18next-zip';
61
+ exchange: TranslationExchangeDocument;
62
+ files: Record<string, string>;
63
+ }
64
+
65
+ interface JsonObject {
66
+ [key: string]: JsonValue;
67
+ }
68
+ type JsonValue = string | JsonObject;
69
+
70
+ function decodeUtf8(input: Uint8Array, label: string): string {
71
+ try {
72
+ return textDecoder.decode(input);
73
+ } catch {
74
+ throw new Error(`${label} is not valid UTF-8.`);
75
+ }
76
+ }
77
+
78
+ function parseJsonObject(input: Uint8Array, label: string): JsonObject {
79
+ let value: unknown;
80
+ try {
81
+ value = JSON.parse(decodeUtf8(input, label));
82
+ } catch (error) {
83
+ if (error instanceof Error && error.message.endsWith('is not valid UTF-8.')) {
84
+ throw error;
85
+ }
86
+ throw new Error(`${label} is not valid JSON.`);
87
+ }
88
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
89
+ throw new Error(`${label} must contain a JSON object.`);
90
+ }
91
+ return value as JsonObject;
92
+ }
93
+
94
+ function flattenJsonObject(
95
+ input: JsonObject,
96
+ prefix = '',
97
+ output: Record<string, string> = Object.create(null),
98
+ ): Record<string, string> {
99
+ for (const key of Object.keys(input).sort()) {
100
+ if (!key) throw new Error('i18next JSON keys must not be empty.');
101
+ const value = input[key] as unknown;
102
+ const path = prefix ? `${prefix}.${key}` : key;
103
+ if (typeof value === 'string') {
104
+ if (Object.hasOwn(output, path)) {
105
+ throw new Error(`Duplicate i18next key "${path}".`);
106
+ }
107
+ output[path] = value;
108
+ continue;
109
+ }
110
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
111
+ throw new Error(
112
+ `i18next JSON value for "${path}" must be a string or object.`,
113
+ );
114
+ }
115
+ flattenJsonObject(value as JsonObject, path, output);
116
+ }
117
+ return output;
118
+ }
119
+
120
+ function nestedJsonObject(values: Record<string, string>): JsonObject {
121
+ const root: JsonObject = Object.create(null);
122
+ for (const key of Object.keys(values).sort()) {
123
+ const segments = key.split('.');
124
+ if (segments.some((segment) => !segment)) {
125
+ throw new Error(`Cannot nest i18next key "${key}" with an empty segment.`);
126
+ }
127
+ let cursor = root;
128
+ for (let index = 0; index < segments.length; index += 1) {
129
+ const segment = segments[index];
130
+ const path = segments.slice(0, index + 1).join('.');
131
+ const last = index === segments.length - 1;
132
+ const existing = cursor[segment];
133
+ if (last) {
134
+ if (existing !== undefined) {
135
+ throw new Error(
136
+ `Cannot nest i18next keys because "${path}" is both a value and a namespace.`,
137
+ );
138
+ }
139
+ cursor[segment] = values[key];
140
+ } else {
141
+ if (typeof existing === 'string') {
142
+ throw new Error(
143
+ `Cannot nest i18next keys because "${path}" is both a value and a namespace.`,
144
+ );
145
+ }
146
+ if (!existing) cursor[segment] = Object.create(null) as JsonObject;
147
+ cursor = cursor[segment] as JsonObject;
148
+ }
149
+ }
150
+ }
151
+ return root;
152
+ }
153
+
154
+ function orderedFlatJsonObject(values: Record<string, string>): JsonObject {
155
+ const output: JsonObject = Object.create(null);
156
+ for (const key of Object.keys(values).sort()) output[key] = values[key];
157
+ return output;
158
+ }
159
+
160
+ function stableJson(value: unknown): string {
161
+ const normalize = (item: unknown): unknown => {
162
+ if (Array.isArray(item)) return item.map(normalize);
163
+ if (item && typeof item === 'object') {
164
+ const result: Record<string, unknown> = {};
165
+ for (const key of Object.keys(item as Record<string, unknown>).sort()) {
166
+ result[key] = normalize((item as Record<string, unknown>)[key]);
167
+ }
168
+ return result;
169
+ }
170
+ return item;
171
+ };
172
+ return `${JSON.stringify(normalize(value), null, 2)}\n`;
173
+ }
174
+
175
+ function storageKey(entry: Pick<TranslationExchangeEntry, 'key' | 'namespace'>):
176
+ string {
177
+ return entry.namespace ? `${entry.namespace}:${entry.key}` : entry.key;
178
+ }
179
+
180
+ function languageValues(
181
+ document: TranslationExchangeDocument,
182
+ language: string,
183
+ ): Record<string, string> {
184
+ const values: Record<string, string> = Object.create(null);
185
+ for (const entry of document.entries) {
186
+ const key = storageKey(entry);
187
+ if (Object.hasOwn(values, key)) {
188
+ throw new Error(
189
+ `i18next JSON key "${key}" is ambiguous after namespace mapping.`,
190
+ );
191
+ }
192
+ if (language === document.sourceLanguage) {
193
+ values[key] = entry.sourceText;
194
+ } else {
195
+ const translation = entry.translations[language];
196
+ if (translation) values[key] = translation.text;
197
+ }
198
+ }
199
+ return values;
200
+ }
201
+
202
+ function serializeLanguage(
203
+ document: TranslationExchangeDocument,
204
+ language: string,
205
+ layout: I18nextJsonLayout,
206
+ ): Uint8Array {
207
+ const values = languageValues(document, language);
208
+ const json =
209
+ layout === 'nested'
210
+ ? nestedJsonObject(values)
211
+ : orderedFlatJsonObject(values);
212
+ return textEncoder.encode(stableJson(json));
213
+ }
214
+
215
+ function isZip(input: Uint8Array): boolean {
216
+ if (input.byteLength < 4) return false;
217
+ const signature = new DataView(
218
+ input.buffer,
219
+ input.byteOffset,
220
+ input.byteLength,
221
+ ).getUint32(0, true);
222
+ return (
223
+ signature === ZIP_LOCAL_FILE_SIGNATURE ||
224
+ signature === ZIP_EMPTY_SIGNATURE ||
225
+ signature === ZIP_SPANNED_SIGNATURE
226
+ );
227
+ }
228
+
229
+ export function translationZipLimits(
230
+ overrides?: Partial<TranslationJsonArchiveLimits>,
231
+ ): TranslationJsonArchiveLimits {
232
+ const limits = {
233
+ ...DEFAULT_TRANSLATION_JSON_ARCHIVE_LIMITS,
234
+ ...overrides,
235
+ };
236
+ for (const [name, value] of Object.entries(limits)) {
237
+ if (!Number.isFinite(value) || value <= 0) {
238
+ throw new Error(`ZIP limit "${name}" must be a positive number.`);
239
+ }
240
+ }
241
+ return limits;
242
+ }
243
+
244
+ function assertSafeArchivePath(path: string): void {
245
+ if (
246
+ !path ||
247
+ path.includes('\0') ||
248
+ path.startsWith('/') ||
249
+ path.startsWith('\\') ||
250
+ /^[A-Za-z]:/.test(path)
251
+ ) {
252
+ throw new Error(`Unsafe ZIP entry path "${path}".`);
253
+ }
254
+ const segments = path.replaceAll('\\', '/').split('/');
255
+ if (segments.some((segment) => segment === '..' || segment === '.')) {
256
+ throw new Error(`Unsafe ZIP entry path "${path}".`);
257
+ }
258
+ }
259
+
260
+ export function safeUnzipTranslationZip(
261
+ input: Uint8Array,
262
+ overrides?: Partial<TranslationJsonArchiveLimits>,
263
+ ): Record<string, Uint8Array> {
264
+ const limits = translationZipLimits(overrides);
265
+ if (input.byteLength > limits.maxArchiveBytes) {
266
+ throw new Error('ZIP archive exceeds the compressed size limit.');
267
+ }
268
+ const names = new Set<string>();
269
+ let entryCount = 0;
270
+ let totalUncompressed = 0;
271
+ try {
272
+ // fflate calls the filter before inflating each entry. A validation-only
273
+ // pass that rejects every entry therefore inspects the complete central
274
+ // directory before a second pass allocates or decompresses file bodies.
275
+ unzipSync(input, {
276
+ filter: (entry) => {
277
+ assertSafeArchivePath(entry.name);
278
+ if (names.has(entry.name)) {
279
+ throw new Error(`ZIP archive contains duplicate entry "${entry.name}".`);
280
+ }
281
+ names.add(entry.name);
282
+ entryCount += 1;
283
+ if (entryCount > limits.maxEntries) {
284
+ throw new Error('ZIP archive exceeds the entry-count limit.');
285
+ }
286
+ if (entry.size > limits.maxEntryCompressedBytes) {
287
+ throw new Error(
288
+ `ZIP entry "${entry.name}" exceeds the compressed size limit.`,
289
+ );
290
+ }
291
+ if (entry.originalSize > limits.maxEntryUncompressedBytes) {
292
+ throw new Error(
293
+ `ZIP entry "${entry.name}" exceeds the uncompressed size limit.`,
294
+ );
295
+ }
296
+ totalUncompressed += entry.originalSize;
297
+ if (totalUncompressed > limits.maxTotalUncompressedBytes) {
298
+ throw new Error('ZIP archive exceeds the total uncompressed size limit.');
299
+ }
300
+ const ratio = entry.originalSize / Math.max(1, entry.size);
301
+ if (ratio > limits.maxCompressionRatio) {
302
+ throw new Error(
303
+ `ZIP entry "${entry.name}" exceeds the compression-ratio limit.`,
304
+ );
305
+ }
306
+ return false;
307
+ },
308
+ });
309
+ return unzipSync(input, {
310
+ filter: (entry) => !entry.name.endsWith('/'),
311
+ });
312
+ } catch (error) {
313
+ if (error instanceof Error && error.message.startsWith('ZIP ')) throw error;
314
+ throw new Error(
315
+ `Invalid or unsupported ZIP archive: ${
316
+ error instanceof Error ? error.message : String(error)
317
+ }`,
318
+ );
319
+ }
320
+ }
321
+
322
+ function languageFromFileName(fileName?: string): string | undefined {
323
+ if (!fileName) return undefined;
324
+ const name = fileName.replaceAll('\\', '/').split('/').pop() ?? '';
325
+ const candidate = name.replace(/\.json$/i, '');
326
+ try {
327
+ return canonicalLanguageTag(candidate);
328
+ } catch {
329
+ return undefined;
330
+ }
331
+ }
332
+
333
+ function parseStandalone(
334
+ input: Uint8Array,
335
+ options: TranslationJsonParseOptions,
336
+ ): ParsedTranslationExchangeDocument {
337
+ const language = canonicalLanguageTag(
338
+ options.language ?? languageFromFileName(options.fileName) ?? 'und',
339
+ );
340
+ const sourceLanguage = canonicalLanguageTag(options.sourceLanguage ?? 'und');
341
+ const values = flattenJsonObject(
342
+ parseJsonObject(input, options.fileName ?? 'Standalone i18next JSON'),
343
+ );
344
+ return normalizeParsedTranslationExchangeDocument({
345
+ schemaVersion: 1,
346
+ sourceLanguage,
347
+ targetLanguages: [language],
348
+ entries: Object.keys(values)
349
+ .sort()
350
+ .map((key) => ({
351
+ key,
352
+ kind: 'ui',
353
+ format: 'simple',
354
+ translations: { [language]: { text: values[key] } },
355
+ })),
356
+ });
357
+ }
358
+
359
+ function sidecarFromBytes(input: Uint8Array): CreateNowJsonSidecar {
360
+ const value = parseJsonObject(input, SIDECAR_PATH) as unknown as Partial<
361
+ CreateNowJsonSidecar
362
+ >;
363
+ if (
364
+ value.schemaVersion !== 1 ||
365
+ value.format !== 'create-now-i18next-zip' ||
366
+ !value.exchange ||
367
+ !value.files ||
368
+ typeof value.files !== 'object' ||
369
+ Array.isArray(value.files)
370
+ ) {
371
+ throw new Error('Create Now JSON ZIP sidecar is invalid.');
372
+ }
373
+ return value as CreateNowJsonSidecar;
374
+ }
375
+
376
+ function assertMapsEqual(
377
+ actual: Record<string, string>,
378
+ expected: Record<string, string>,
379
+ label: string,
380
+ ): void {
381
+ const actualKeys = Object.keys(actual).sort();
382
+ const expectedKeys = Object.keys(expected).sort();
383
+ if (
384
+ actualKeys.length !== expectedKeys.length ||
385
+ actualKeys.some((key, index) => key !== expectedKeys[index])
386
+ ) {
387
+ throw new Error(`${label} keys do not match the Create Now sidecar.`);
388
+ }
389
+ for (const key of expectedKeys) {
390
+ if (actual[key] !== expected[key]) {
391
+ throw new Error(
392
+ `${label} value for "${key}" does not match the Create Now sidecar.`,
393
+ );
394
+ }
395
+ }
396
+ }
397
+
398
+ function parseArchive(
399
+ input: Uint8Array,
400
+ options: TranslationJsonParseOptions,
401
+ ): ParsedTranslationExchangeDocument {
402
+ const files = safeUnzipTranslationZip(input, options.limits);
403
+ const sidecarBytes = files[SIDECAR_PATH];
404
+ if (!sidecarBytes) {
405
+ return parseTolgeeArchive(files, options);
406
+ }
407
+ const sidecar = sidecarFromBytes(sidecarBytes);
408
+ const exchange = validateTranslationExchangeDocument(sidecar.exchange);
409
+ const expectedLanguages = [
410
+ exchange.sourceLanguage,
411
+ ...exchange.targetLanguages,
412
+ ];
413
+ const fileLanguages = Object.keys(sidecar.files).sort();
414
+ if (
415
+ fileLanguages.length !== expectedLanguages.length ||
416
+ expectedLanguages
417
+ .slice()
418
+ .sort()
419
+ .some((language, index) => language !== fileLanguages[index])
420
+ ) {
421
+ throw new Error('Create Now JSON ZIP language manifest is incomplete.');
422
+ }
423
+ const usedPaths = new Set<string>();
424
+ for (const language of expectedLanguages) {
425
+ const path = sidecar.files[language];
426
+ if (typeof path !== 'string') {
427
+ throw new Error(`Create Now JSON ZIP has no file for ${language}.`);
428
+ }
429
+ assertSafeArchivePath(path);
430
+ if (usedPaths.has(path)) {
431
+ throw new Error(`Create Now JSON ZIP reuses language file "${path}".`);
432
+ }
433
+ usedPaths.add(path);
434
+ const bytes = files[path];
435
+ if (!bytes) {
436
+ throw new Error(`Create Now JSON ZIP is missing language file "${path}".`);
437
+ }
438
+ const actual = flattenJsonObject(parseJsonObject(bytes, path));
439
+ const expected = languageValues(exchange, language);
440
+ assertMapsEqual(actual, expected, path);
441
+ }
442
+ return normalizeParsedTranslationExchangeDocument(exchange);
443
+ }
444
+
445
+ /** Tolgee JSON exports carry source text, but no immutable key-version evidence.
446
+ * Parsing never writes or invents version IDs; a manager must approve the import.
447
+ * The same bounded ZIP reader runs before either archive format is considered.
448
+ */
449
+ function parseTolgeeArchive(
450
+ files: Record<string, Uint8Array>,
451
+ options: TranslationJsonParseOptions,
452
+ ): ParsedTranslationExchangeDocument {
453
+ const sourceLanguage = canonicalLanguageTag(options.sourceLanguage ?? 'en');
454
+ const defaultNamespace = options.defaultNamespace ?? 'common';
455
+ const namespaces = new Map<string, Map<string, Record<string, string>>>();
456
+ for (const [path, bytes] of Object.entries(files)) {
457
+ const match = /^(?:([^/\\]+)\/)?([^/\\]+)\.json$/.exec(path);
458
+ if (!match) {
459
+ throw new Error(`Tolgee ZIP entry "${path}" must use namespace/language.json or language.json.`);
460
+ }
461
+ const [, namespace = '', languageName] = match;
462
+ const language = canonicalLanguageTag(languageName);
463
+ if (language === 'und') throw new Error(`Tolgee ZIP entry "${path}" needs a language tag.`);
464
+ let languages = namespaces.get(namespace);
465
+ if (!languages) namespaces.set(namespace, (languages = new Map()));
466
+ if (languages.has(language)) {
467
+ throw new Error(`Tolgee ZIP contains duplicate language ${language} in ${namespace}.`);
468
+ }
469
+ languages.set(language, flattenJsonObject(parseJsonObject(bytes, path)));
470
+ }
471
+ if (!namespaces.size) throw new Error('Tolgee ZIP contains no language files.');
472
+ const targetLanguages = new Set<string>();
473
+ const entries: ParsedTranslationExchangeDocument['entries'] = [];
474
+ for (const [namespace, languages] of namespaces) {
475
+ const sources = languages.get(sourceLanguage);
476
+ if (!sources) {
477
+ throw new Error(`Tolgee ZIP is missing ${namespace}/${sourceLanguage}.json source text.`);
478
+ }
479
+ for (const [language, values] of languages) {
480
+ if (language === sourceLanguage) continue;
481
+ targetLanguages.add(language);
482
+ for (const key of Object.keys(values)) {
483
+ if (!Object.hasOwn(sources, key)) {
484
+ throw new Error(`Tolgee key "${namespace}:${key}" in ${language} has no source text.`);
485
+ }
486
+ }
487
+ }
488
+ for (const key of Object.keys(sources).sort()) {
489
+ const translations: TranslationExchangeEntry['translations'] = Object.create(null);
490
+ for (const [language, values] of languages) {
491
+ if (language !== sourceLanguage && Object.hasOwn(values, key) && values[key] !== '') {
492
+ translations[language] = { text: values[key] };
493
+ }
494
+ }
495
+ entries.push({
496
+ key,
497
+ ...(!namespace || namespace === defaultNamespace ? {} : { namespace }),
498
+ kind: 'ui',
499
+ format: 'simple',
500
+ sourceText: sources[key],
501
+ translations,
502
+ });
503
+ }
504
+ }
505
+ return normalizeParsedTranslationExchangeDocument({
506
+ schemaVersion: 1,
507
+ sourceLanguage,
508
+ targetLanguages: [...targetLanguages].sort(),
509
+ entries,
510
+ });
511
+ }
512
+
513
+ export function parseI18nextJson(
514
+ input: Uint8Array,
515
+ options: TranslationJsonParseOptions = {},
516
+ ): ParsedTranslationExchangeDocument {
517
+ return isZip(input)
518
+ ? parseArchive(input, options)
519
+ : parseStandalone(input, options);
520
+ }
521
+
522
+ export function serializeI18nextJsonZip(
523
+ document: TranslationExchangeDocument,
524
+ options: TranslationJsonSerializeOptions = {},
525
+ ): SerializedTranslationDocument {
526
+ const exchange = validateTranslationExchangeDocument(document);
527
+ const limits = translationZipLimits(options.limits);
528
+ const layout = options.layout ?? 'flat';
529
+ const files: Zippable = Object.create(null);
530
+ const languageFiles: Record<string, string> = Object.create(null);
531
+ const languages = [exchange.sourceLanguage, ...exchange.targetLanguages];
532
+ for (const language of languages) {
533
+ const path = `locales/${language}.json`;
534
+ languageFiles[language] = path;
535
+ files[path] = serializeLanguage(exchange, language, layout);
536
+ }
537
+ const sidecar: CreateNowJsonSidecar = {
538
+ schemaVersion: 1,
539
+ format: 'create-now-i18next-zip',
540
+ exchange,
541
+ files: languageFiles,
542
+ };
543
+ files[SIDECAR_PATH] = textEncoder.encode(stableJson(sidecar));
544
+ if (Object.keys(files).length > limits.maxEntries) {
545
+ throw new Error('ZIP archive exceeds the entry-count limit.');
546
+ }
547
+ for (const [path, body] of Object.entries(files)) {
548
+ const bytes = Array.isArray(body) ? body[0] : body;
549
+ if (bytes instanceof Uint8Array && bytes.byteLength > limits.maxEntryUncompressedBytes) {
550
+ throw new Error(`ZIP entry "${path}" exceeds the uncompressed size limit.`);
551
+ }
552
+ }
553
+ const body = zipSync(files, {
554
+ level: 6,
555
+ mtime: new Date('1980-01-01T00:00:00.000Z'),
556
+ });
557
+ if (body.byteLength > limits.maxArchiveBytes) {
558
+ throw new Error('ZIP archive exceeds the compressed size limit.');
559
+ }
560
+ // Guarantee that an archive produced with custom limits is accepted by the
561
+ // same guarded import path (including total-size and ratio checks).
562
+ safeUnzipTranslationZip(body, limits);
563
+ return {
564
+ fileName: 'translations.i18next.zip',
565
+ mediaType: 'application/zip',
566
+ body,
567
+ };
568
+ }
569
+
570
+ export const i18nextJsonFormatAdapter: TranslationFormatAdapter = {
571
+ format: 'json',
572
+ sniff(input, fileName) {
573
+ if (isZip(input)) return true;
574
+ if (fileName?.toLowerCase().endsWith('.json')) return true;
575
+ const first = decodeUtf8(input.slice(0, 64), 'JSON input').trimStart()[0];
576
+ return first === '{';
577
+ },
578
+ async parse(input, options) {
579
+ return parseI18nextJson(input, options as TranslationJsonParseOptions);
580
+ },
581
+ async serialize(document, options) {
582
+ return serializeI18nextJsonZip(
583
+ document,
584
+ options as TranslationJsonSerializeOptions,
585
+ );
586
+ },
587
+ };