@menapo/vi18n 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +49 -0
  2. package/README.md +110 -2
  3. package/dist/apply-Bre-lY9J.js +228 -0
  4. package/dist/captured-Bj8bO6vd.js +1034 -0
  5. package/dist/check-D34P-S--.js +1 -0
  6. package/dist/check-DyrAbv73.js +201 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +41 -0
  9. package/dist/codec-DuvU2VQy.js +207 -0
  10. package/dist/collect-Cx4_SKOe.js +13 -0
  11. package/dist/collisions-CfpZU8e4.js +217 -0
  12. package/dist/compile-B5Mz6WJm.js +245 -0
  13. package/dist/compile-Cc97JaW2.js +2502 -0
  14. package/dist/concurrency-C9UUWuDC.js +96 -0
  15. package/dist/concurrency-DONL7t4V.js +1 -0
  16. package/dist/config.d.ts +21 -0
  17. package/dist/config.js +39 -0
  18. package/dist/dir-zw6bGMKQ.js +290 -0
  19. package/dist/duplicates-yuRljQ6h.js +185 -0
  20. package/dist/engine-B7p40M__.js +531 -0
  21. package/dist/engine.d.ts +43 -0
  22. package/dist/engine.js +1241 -0
  23. package/dist/exclude-Du-Zd_qI.js +26 -0
  24. package/dist/export-DGsMbcTZ.js +103 -0
  25. package/dist/extract-Dujlsgnk.js +298 -0
  26. package/dist/formats-KPIg-08V.js +781 -0
  27. package/dist/i18next-CrUnKFfh.js +1596 -0
  28. package/dist/i18next-Rgo-v9l0.js +1 -0
  29. package/dist/import-Cwdhcywt.js +225 -0
  30. package/dist/index.d.ts +156 -0
  31. package/dist/index.js +239 -0
  32. package/dist/init-BecCTvKi.js +792 -0
  33. package/dist/library-D5CWvTQs.js +1882 -0
  34. package/dist/lingui--PcOOYry.js +833 -0
  35. package/dist/lint.d.ts +105 -0
  36. package/dist/lint.js +971 -0
  37. package/dist/load-CQRz4PpM.js +2950 -0
  38. package/dist/load-gW-oJt1Q.js +747 -0
  39. package/dist/locales-DX4MOBFE.js +827 -0
  40. package/dist/lsp-Cq1Nuu-Q.js +31 -0
  41. package/dist/match-BGr8EXB3.js +89 -0
  42. package/dist/mcp-Cf0T7qPW.js +28 -0
  43. package/dist/merge-driver-CpiiSVIc.js +422 -0
  44. package/dist/messages-Cxa-5tR0.js +166 -0
  45. package/dist/migrate-CntqvrW4.js +1 -0
  46. package/dist/migrate-dPgq9uzy.js +1484 -0
  47. package/dist/openai-CTaMDcl-.js +346 -0
  48. package/dist/output-DsaHKHEt.js +164 -0
  49. package/dist/parse.d.ts +62 -0
  50. package/dist/parse.js +156 -0
  51. package/dist/pipeline-C7g11THL.js +2031 -0
  52. package/dist/pipeline-wxS0SApG.js +1 -0
  53. package/dist/project-Dwx7iPm6.js +381 -0
  54. package/dist/project-pXipT8iG.js +1046 -0
  55. package/dist/pseudo-DRv30iN5.js +145 -0
  56. package/dist/raw-C4Y6vyGY.js +4708 -0
  57. package/dist/react-intl-CaBVwJ9u.js +519 -0
  58. package/dist/restore-Btq66Tid.js +175 -0
  59. package/dist/rewrite-CiTRAICl.js +803 -0
  60. package/dist/rich-DCUKl5HM.js +97 -0
  61. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  62. package/dist/run-DYD_VXKy.js +694 -0
  63. package/dist/runtime-DTMmliT1.js +64 -0
  64. package/dist/scan-worker.d.ts +62 -0
  65. package/dist/scan-worker.js +1 -0
  66. package/dist/scoped-BMYpSthj.js +44 -0
  67. package/dist/server-BDkykMjb.js +11823 -0
  68. package/dist/server-qv3mz70d.js +10485 -0
  69. package/dist/server.d.ts +169 -0
  70. package/dist/server.js +1234 -0
  71. package/dist/sitemap-DV3o8QD7.js +159 -0
  72. package/dist/sources-D19czDSA.js +2242 -0
  73. package/dist/status-DZArW5aE.js +149 -0
  74. package/dist/stdio-CVaJfxA7.js +29 -0
  75. package/dist/stdio-FPOKQAgu.js +95 -0
  76. package/dist/sync-CcVzger_.js +658 -0
  77. package/dist/t-DC-c9zE5.js +454 -0
  78. package/dist/test.d.ts +116 -0
  79. package/dist/test.js +319 -0
  80. package/dist/translate-DZR3MkSA.js +299 -0
  81. package/dist/ts-BaU5yOf0.js +4863 -0
  82. package/dist/types-CgUCkFU1.d.ts +707 -0
  83. package/dist/types-D3QGM8XO.js +146 -0
  84. package/dist/types-DsdWBG0b.d.ts +558 -0
  85. package/dist/types-DtvQ7UdD.js +1948 -0
  86. package/dist/types-wSh9fCBY.js +11 -0
  87. package/dist/units-CQ5Q99RC.js +150 -0
  88. package/dist/url-CRfCOhUI.js +172 -0
  89. package/dist/url-CsBkRaJy.js +225 -0
  90. package/dist/validate-BxpupdAf.js +2615 -0
  91. package/dist/validate-D6DJepZv.js +365 -0
  92. package/dist/version-BlyLGlOa.js +28 -0
  93. package/dist/vite-scan-B_YW4X_a.js +242 -0
  94. package/dist/vite.d.ts +34 -0
  95. package/dist/vite.js +7558 -0
  96. package/dist/vue-i18n-CEMnpv7k.js +651 -0
  97. package/dist/which-C6PFI1Dm.js +116 -0
  98. package/dist/worker-84U6uYB3.js +1656 -0
  99. package/package.json +97 -3
  100. package/schemas/catalog.json +57 -0
@@ -0,0 +1,2950 @@
1
+ import { a as printICU, d as diagnosticError, f as isDiagnosticError, i as normalizeSource, r as isPlainText, s as parseSource, t as compileMessage } from "./compile-Cc97JaW2.js";
2
+ import { n as validateTranslation } from "./validate-D6DJepZv.js";
3
+ import { t as pseudoLocalize } from "./pseudo-DRv30iN5.js";
4
+ import { hash } from "node:crypto";
5
+ import { readFile, stat } from "node:fs/promises";
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
8
+ import { pathToFileURL } from "node:url";
9
+ import { z } from "zod";
10
+ //#region src/core/catalog/validity.ts
11
+ function sourceMatches(entry, layout, message, codec) {
12
+ if (layout === "id") return entry.label !== null && entry.label === message.label;
13
+ const source = entry.source;
14
+ if (source === null) return false;
15
+ if (source === message.canonical) return true;
16
+ return entryCanonical(entry, codec) === message.canonical;
17
+ }
18
+ //#endregion
19
+ //#region src/core/catalog/model.ts
20
+ /**
21
+ * The catalog model shared by every reader, writer and consumer (INTERNALS
22
+ * §2.6.2, §2.6.5, §2.6.7): how an entry is keyed, how entries sort, and when
23
+ * a translation may ship. Formats (`po.ts`, `json.ts`) and files (`io.ts`)
24
+ * build on these; nothing here touches the file system.
25
+ */
26
+ const canonicals = /* @__PURE__ */ new WeakMap();
27
+ /**
28
+ * `codec.canonicalize(entry.source)`, memoised on the entry (catalog load,
29
+ * the store's index, duplicate resolution and compilation each need it for
30
+ * every entry); null for an entry without a source or whose source is not
31
+ * valid ICU. Canonical forms do not depend on the codec's id length.
32
+ */
33
+ function entryCanonical(entry, codec) {
34
+ const source = entry.source;
35
+ if (source === null) return null;
36
+ const memo = canonicals.get(entry);
37
+ if (memo !== void 0 && memo.source === source) return memo.canonical;
38
+ let canonical;
39
+ try {
40
+ canonical = codec.canonicalize(source);
41
+ } catch (error) {
42
+ if (!isDiagnosticError(error)) throw error;
43
+ canonical = null;
44
+ }
45
+ canonicals.set(entry, {
46
+ source,
47
+ canonical
48
+ });
49
+ return canonical;
50
+ }
51
+ /**
52
+ * The key that joins an entry to code. Null for foreign contexts (never
53
+ * matched) and for `"source"`-layout entries whose source does not parse,
54
+ * which consumers report on the source instead.
55
+ */
56
+ function entryKey(entry, layout, codec) {
57
+ if (entry.foreignContext !== null) return null;
58
+ if (entry.manualId !== null) return entry.manualId;
59
+ if (layout !== "source") return entry.label;
60
+ const canonical = entryCanonical(entry, codec);
61
+ return canonical === null ? null : codec.label(canonical);
62
+ }
63
+ /** Splits a JSON `context` (notes joined with `"\n"`) back into single-line notes. */
64
+ function splitContexts(context) {
65
+ if (context === "") return [];
66
+ return context.split("\n");
67
+ }
68
+ /**
69
+ * The one definition of "this entry's translation may ship" (INTERNALS
70
+ * §2.6.7), shared by compilation, AI planning, `check` and `status`. Steps
71
+ * run in contract order and the first failure names the reason.
72
+ * `scheme-mismatch` and `no-entry` are decided by callers.
73
+ */
74
+ function usableTranslation(entry, layout, message, codec, locale) {
75
+ if (!sourceMatches(entry, layout, message, codec)) return {
76
+ ok: false,
77
+ reason: "source-mismatch",
78
+ diagnostics: []
79
+ };
80
+ if (entry.translation === "") return {
81
+ ok: false,
82
+ reason: "untranslated",
83
+ diagnostics: []
84
+ };
85
+ if (entry.fuzzy) return {
86
+ ok: false,
87
+ reason: "fuzzy",
88
+ diagnostics: []
89
+ };
90
+ let parsed;
91
+ try {
92
+ parsed = codec.parse(entry.translation);
93
+ } catch (error) {
94
+ if (!isDiagnosticError(error)) throw error;
95
+ return {
96
+ ok: false,
97
+ reason: "invalid",
98
+ diagnostics: [withSubject(error.diagnostic, locale, message.id, entry.line)]
99
+ };
100
+ }
101
+ const found = validateTranslation(parsed, codec.parse(message.canonical), {
102
+ locale,
103
+ text: entry.translation
104
+ });
105
+ let errors = null;
106
+ for (const diagnostic of found) if (diagnostic.severity === "error") (errors ??= []).push(withSubject(diagnostic, locale, message.id, entry.line));
107
+ return errors === null ? {
108
+ ok: true,
109
+ parsed
110
+ } : {
111
+ ok: false,
112
+ reason: "invalid",
113
+ diagnostics: errors
114
+ };
115
+ }
116
+ /**
117
+ * ICU diagnostics locate problems inside the message text; for a catalog the
118
+ * useful position is the entry's line, and the locale and id say which entry.
119
+ */
120
+ function withSubject(diagnostic, locale, id, line) {
121
+ const located = {
122
+ ...diagnostic,
123
+ locale: diagnostic.locale ?? locale,
124
+ id: diagnostic.id ?? id
125
+ };
126
+ delete located.line;
127
+ delete located.column;
128
+ if (line > 0) located.line = line;
129
+ return located;
130
+ }
131
+ //#endregion
132
+ //#region src/core/catalog/json.ts
133
+ /** Top-level keys the reader maps onto the header; anything else is preserved in `extra`. */
134
+ const OWNED_KEYS = /* @__PURE__ */ new Set([
135
+ "$schema",
136
+ "locale",
137
+ "sourceLocale",
138
+ "idScheme",
139
+ "aiModel",
140
+ "messages"
141
+ ]);
142
+ /** Per-message fields with a model field; anything else is preserved in `extra`. */
143
+ const MESSAGE_FIELDS = /* @__PURE__ */ new Set([
144
+ "context",
145
+ "fuzzy",
146
+ "maxLength",
147
+ "flags",
148
+ "refs",
149
+ "state",
150
+ "source",
151
+ "translation"
152
+ ]);
153
+ const STATES = /* @__PURE__ */ new Set([
154
+ "untranslated",
155
+ "ai",
156
+ "translated"
157
+ ]);
158
+ function skipBlank(text, i) {
159
+ while (i < text.length) {
160
+ const code = text.charCodeAt(i);
161
+ if (code !== 32 && code !== 10 && code !== 13 && code !== 9) break;
162
+ i++;
163
+ }
164
+ return i;
165
+ }
166
+ /** Index just past the string starting at `i` (which is `"`). */
167
+ function skipString(text, i) {
168
+ i++;
169
+ while (i < text.length) {
170
+ const code = text.charCodeAt(i);
171
+ if (code === 92) i += 2;
172
+ else if (code === 34) return i + 1;
173
+ else i++;
174
+ }
175
+ return i;
176
+ }
177
+ /** Index just past the value starting at `i`. The text is known to be valid JSON. */
178
+ function skipValue(text, i) {
179
+ const code = text.charCodeAt(i);
180
+ if (code === 34) return skipString(text, i);
181
+ if (code === 123 || code === 91) {
182
+ let depth = 0;
183
+ while (i < text.length) {
184
+ const c = text.charCodeAt(i);
185
+ if (c === 34) {
186
+ i = skipString(text, i);
187
+ continue;
188
+ }
189
+ if (c === 123 || c === 91) depth++;
190
+ else if (c === 125 || c === 93) {
191
+ depth--;
192
+ if (depth === 0) return i + 1;
193
+ }
194
+ i++;
195
+ }
196
+ return i;
197
+ }
198
+ while (i < text.length) {
199
+ const c = text.charCodeAt(i);
200
+ if (c === 44 || c === 125 || c === 93 || c === 32 || c === 10 || c === 13 || c === 9) break;
201
+ i++;
202
+ }
203
+ return i;
204
+ }
205
+ /**
206
+ * The members of the top-level `messages` object in text order, duplicates
207
+ * included. Null when there is no `messages` object.
208
+ */
209
+ function scanMessages(text) {
210
+ let line = 1;
211
+ let counted = 0;
212
+ const lineAt = (offset) => {
213
+ for (let i = counted; i < offset; i++) if (text.charCodeAt(i) === 10) line++;
214
+ counted = offset;
215
+ return line;
216
+ };
217
+ let i = skipBlank(text, text.charCodeAt(0) === 65279 ? 1 : 0);
218
+ if (text.charCodeAt(i) !== 123) return null;
219
+ i = skipBlank(text, i + 1);
220
+ while (i < text.length && text.charCodeAt(i) === 34) {
221
+ const keyEnd = skipString(text, i);
222
+ const key = JSON.parse(text.slice(i, keyEnd));
223
+ i = skipBlank(text, keyEnd);
224
+ i = skipBlank(text, i + 1);
225
+ const valueEnd = skipValue(text, i);
226
+ if (key === "messages" && text.charCodeAt(i) === 123) {
227
+ const members = [];
228
+ let j = skipBlank(text, i + 1);
229
+ while (j < valueEnd && text.charCodeAt(j) === 34) {
230
+ const memberKeyEnd = skipString(text, j);
231
+ const memberLine = lineAt(j);
232
+ const memberKey = JSON.parse(text.slice(j, memberKeyEnd));
233
+ j = skipBlank(text, memberKeyEnd);
234
+ j = skipBlank(text, j + 1);
235
+ const end = skipValue(text, j);
236
+ members.push({
237
+ key: memberKey,
238
+ line: memberLine,
239
+ start: j,
240
+ end
241
+ });
242
+ j = skipBlank(text, end);
243
+ if (text.charCodeAt(j) === 44) j = skipBlank(text, j + 1);
244
+ }
245
+ return members;
246
+ }
247
+ i = skipBlank(text, valueEnd);
248
+ if (text.charCodeAt(i) === 44) i = skipBlank(text, i + 1);
249
+ }
250
+ return null;
251
+ }
252
+ function isRecord(value) {
253
+ return typeof value === "object" && value !== null && !Array.isArray(value);
254
+ }
255
+ function isStringArray(value) {
256
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
257
+ }
258
+ function sortedUnique$1(values) {
259
+ return [...new Set(values)].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
260
+ }
261
+ /** Line and column of a `JSON.parse` error, when V8 names a position. */
262
+ function errorPosition(text, error) {
263
+ const match = /position (\d+)/.exec(error instanceof Error ? error.message : "");
264
+ if (match === null) return {};
265
+ const offset = Number(match[1]);
266
+ let line = 1;
267
+ let lineStart = 0;
268
+ for (let i = 0; i < offset && i < text.length; i++) if (text.charCodeAt(i) === 10) {
269
+ line++;
270
+ lineStart = i + 1;
271
+ }
272
+ return {
273
+ line,
274
+ column: offset - lineStart + 1
275
+ };
276
+ }
277
+ function readEntry(key, value, line, isSourceLocale) {
278
+ if (!isRecord(value)) return { error: `message ${JSON.stringify(key)} must be an object` };
279
+ const { context, fuzzy, maxLength, flags, refs, state, source, translation } = value;
280
+ if (context !== void 0 && typeof context !== "string") return { error: `${key}: context must be a string` };
281
+ if (fuzzy !== void 0 && typeof fuzzy !== "boolean") return { error: `${key}: fuzzy must be true or absent` };
282
+ if (maxLength !== void 0 && !(Number.isInteger(maxLength) && maxLength > 0)) return { error: `${key}: maxLength must be a positive integer` };
283
+ if (flags !== void 0 && !isStringArray(flags)) return { error: `${key}: flags must be an array of strings` };
284
+ if (refs !== void 0 && !isStringArray(refs)) return { error: `${key}: refs must be an array of strings` };
285
+ if (state !== void 0 && (typeof state !== "string" || !STATES.has(state))) return { error: `${key}: state must be "untranslated", "ai" or "translated"` };
286
+ if (source !== void 0 && source !== null && typeof source !== "string") return { error: `${key}: source must be a string` };
287
+ if (translation !== void 0 && typeof translation !== "string") return { error: `${key}: translation must be a string` };
288
+ const text = isSourceLocale ? "" : translation ?? "";
289
+ const entryState = text === "" ? "untranslated" : state === "ai" ? "ai" : "translated";
290
+ let extra;
291
+ for (const field of Object.keys(value)) if (!MESSAGE_FIELDS.has(field)) Object.defineProperty(extra ??= {}, field, {
292
+ value: value[field],
293
+ enumerable: true,
294
+ writable: true,
295
+ configurable: true
296
+ });
297
+ const manual = key.includes(".");
298
+ const entry = {
299
+ manualId: manual ? key : null,
300
+ foreignContext: null,
301
+ label: manual ? null : key,
302
+ source: typeof source === "string" ? source : null,
303
+ translation: text,
304
+ state: entryState,
305
+ fuzzy: fuzzy === true,
306
+ contexts: typeof context === "string" ? splitContexts(context) : [],
307
+ comments: [],
308
+ refs: refs === void 0 ? [] : sortedUnique$1(refs),
309
+ maxLength: maxLength === void 0 ? null : maxLength,
310
+ flags: flags === void 0 ? [] : [...new Set(flags)].map((name) => ({
311
+ name,
312
+ line: "#,"
313
+ })),
314
+ line,
315
+ wrapped: false
316
+ };
317
+ if (extra !== void 0) entry.extra = extra;
318
+ return { entry };
319
+ }
320
+ function parseJsonCatalog(text, options) {
321
+ const { locale, isSourceLocale } = options;
322
+ const diagnostics = [];
323
+ const header = {
324
+ language: locale,
325
+ idScheme: null,
326
+ sourceLocale: null,
327
+ aiModel: null,
328
+ extra: []
329
+ };
330
+ const catalog = {
331
+ locale,
332
+ layout: "json",
333
+ header,
334
+ entries: [],
335
+ obsolete: [],
336
+ droppedObsolete: 0
337
+ };
338
+ const fail = (message, line) => {
339
+ const diagnostic = {
340
+ code: "catalog-parse",
341
+ severity: "error",
342
+ message
343
+ };
344
+ if (line !== void 0) diagnostic.line = line;
345
+ diagnostics.push(diagnostic);
346
+ };
347
+ let root;
348
+ try {
349
+ root = JSON.parse(text.charCodeAt(0) === 65279 ? text.slice(1) : text);
350
+ } catch (error) {
351
+ const message = error instanceof Error ? error.message : String(error);
352
+ diagnostics.push({
353
+ code: "catalog-parse",
354
+ severity: "error",
355
+ message,
356
+ ...errorPosition(text, error)
357
+ });
358
+ return {
359
+ catalog,
360
+ diagnostics
361
+ };
362
+ }
363
+ if (!isRecord(root)) {
364
+ fail("a JSON catalog must be an object", 1);
365
+ return {
366
+ catalog,
367
+ diagnostics
368
+ };
369
+ }
370
+ for (const [key, value] of Object.entries(root)) switch (key) {
371
+ case "$schema":
372
+ case "messages": break;
373
+ case "locale":
374
+ if (typeof value === "string" && value !== "") header.language = value;
375
+ else fail("locale must be a string");
376
+ break;
377
+ case "sourceLocale":
378
+ case "idScheme":
379
+ case "aiModel":
380
+ if (typeof value === "string") header[key] = value;
381
+ else if (value !== null) fail(`${key} must be a string`);
382
+ break;
383
+ default: if (!OWNED_KEYS.has(key)) header.extra.push([key, value]);
384
+ }
385
+ const messages = root.messages;
386
+ if (messages === void 0) return {
387
+ catalog,
388
+ diagnostics
389
+ };
390
+ if (!isRecord(messages)) {
391
+ fail("messages must be an object");
392
+ return {
393
+ catalog,
394
+ diagnostics
395
+ };
396
+ }
397
+ const members = scanMessages(text) ?? [];
398
+ const seen = /* @__PURE__ */ new Set();
399
+ let duplicates = null;
400
+ for (const member of members) if (seen.has(member.key)) (duplicates ??= /* @__PURE__ */ new Set()).add(member.key);
401
+ else seen.add(member.key);
402
+ for (const member of members) {
403
+ const value = duplicates !== null && duplicates.has(member.key) ? JSON.parse(text.slice(member.start, member.end)) : messages[member.key];
404
+ const read = readEntry(member.key, value, member.line, isSourceLocale);
405
+ if ("error" in read) fail(read.error, member.line);
406
+ else catalog.entries.push(read.entry);
407
+ }
408
+ return {
409
+ catalog,
410
+ diagnostics
411
+ };
412
+ }
413
+ //#endregion
414
+ //#region src/core/extract/dependencies.ts
415
+ /**
416
+ * `extraction.dependencies` (PLAN §4.5, §5.24; INTERNALS §2.5.7): published
417
+ * packages the app opts in. Each name resolves from the config root the way
418
+ * Node resolves a package directory (`node_modules/<name>` in the root or a
419
+ * directory above it), by realpath, which is what Vite hands the plugin under
420
+ * pnpm's links. Their files are extracted and rewritten like the app's,
421
+ * except the package's own `node_modules`, tests and declarations, and their
422
+ * catalog references read `<name>/<path in the package>`, the same on every
423
+ * machine and package manager and across the package's versions.
424
+ */
425
+ /** As `files.ts` has it (which imports this module). */
426
+ const toPosix$1 = (path) => sep === "/" ? path : path.replaceAll("\\", "/");
427
+ const resolved = /* @__PURE__ */ new WeakMap();
428
+ /**
429
+ * Every `extraction.dependencies` package, resolved once per config. A
430
+ * name that does not resolve is a `config-invalid` error: its strings would
431
+ * otherwise ship untranslated and unreported.
432
+ */
433
+ function dependenciesOf(config) {
434
+ let list = resolved.get(config.extraction);
435
+ if (list !== void 0) return list;
436
+ list = [];
437
+ for (const name of config.extraction.dependencies) {
438
+ const dir = packageDir(config.root, name);
439
+ if (dir === null) throw diagnosticError({
440
+ code: "config-invalid",
441
+ severity: "error",
442
+ message: `extraction.dependencies: ${name} is not installed (no node_modules/${name} from ${toPosix$1(config.root)} up); add it to the app's dependencies`
443
+ });
444
+ list.push({
445
+ name,
446
+ dir,
447
+ messages: messagesFile(dir)
448
+ });
449
+ }
450
+ resolved.set(config.extraction, list);
451
+ return list;
452
+ }
453
+ /** The package directory as Node finds it from `root`, by realpath; null when not installed. */
454
+ function packageDir(root, name) {
455
+ let dir = resolve(root);
456
+ for (;;) {
457
+ const candidate = join(dir, "node_modules", name);
458
+ if (existsSync(join(candidate, "package.json"))) return toPosix$1(realpathSync(candidate)).replace(/\/+$/, "");
459
+ const parent = dirname(dir);
460
+ if (parent === dir) return null;
461
+ dir = parent;
462
+ }
463
+ }
464
+ /** `package.json#vi18n.messages`, absolute; null without one (or an unreadable manifest). */
465
+ function messagesFile(dir) {
466
+ try {
467
+ const path = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).vi18n?.messages;
468
+ return typeof path === "string" && path !== "" ? toPosix$1(resolve(dir, path)) : null;
469
+ } catch {
470
+ return null;
471
+ }
472
+ }
473
+ //#endregion
474
+ //#region src/core/catalog/library.ts
475
+ /**
476
+ * Library mode, the app's side (PLAN §4.7, §5.24; INTERNALS §2.6.11): what an
477
+ * opted-in package ships next to its code. Its `vi18n.messages.json` (written
478
+ * by its own `build.lib` build) lists its messages and its catalogs, and
479
+ * `package.json#vi18n.messages` points at it. Each catalog loads under the
480
+ * app's catalog for the same locale (`LoadedCatalog.libraries`), its entries
481
+ * joined to the app's ids by canonical source or manual id, never by label:
482
+ * a library built with another `ids.length`, or another msgid layout, still
483
+ * matches. Precedence is app catalog, then library catalogs in
484
+ * `extraction.dependencies` order, then the source, at each step of a
485
+ * fallback chain.
486
+ */
487
+ const manifests = /* @__PURE__ */ new WeakMap();
488
+ /** Every opted-in package's manifest (those with `package.json#vi18n.messages`), read once per config. */
489
+ function libraryManifests(config) {
490
+ let list = manifests.get(config.extraction);
491
+ if (list !== void 0) return list;
492
+ list = [];
493
+ for (const dependency of dependenciesOf(config)) {
494
+ if (dependency.messages === null) continue;
495
+ list.push({
496
+ name: dependency.name,
497
+ file: dependency.messages,
498
+ ...readManifest(dependency.messages, config.root)
499
+ });
500
+ }
501
+ manifests.set(config.extraction, list);
502
+ return list;
503
+ }
504
+ function readManifest(file, root) {
505
+ const shown = displayPath$2(root, file);
506
+ let data;
507
+ try {
508
+ data = JSON.parse(readFileSync(file, "utf8"));
509
+ } catch (error) {
510
+ return {
511
+ manifest: null,
512
+ diagnostic: {
513
+ code: "catalog-parse",
514
+ severity: "warning",
515
+ message: `${shown} cannot be read (${error.message}); the package's translations are left out`,
516
+ file: shown
517
+ }
518
+ };
519
+ }
520
+ const manifest = data;
521
+ if (manifest === null || typeof manifest !== "object" || manifest.vi18n !== 1 || !Array.isArray(manifest.messages)) return {
522
+ manifest: null,
523
+ diagnostic: {
524
+ code: "catalog-parse",
525
+ severity: "warning",
526
+ message: `${shown} is not a vi18n.messages.json of version 1; the package's translations are left out`,
527
+ file: shown
528
+ }
529
+ };
530
+ return {
531
+ manifest,
532
+ diagnostic: null
533
+ };
534
+ }
535
+ /**
536
+ * Loads the opted-in packages' catalogs for `locales` under the app's
537
+ * (`LoadedCatalog.libraries`), normalised for joining (below). The source
538
+ * locale compiles from the code, so its library catalogs are read only for a
539
+ * package written in another language: its texts are not the app's source
540
+ * language, and its catalog for that language is what those pages show.
541
+ */
542
+ async function attachLibraryCatalogs(set, config, codec, locales) {
543
+ const diagnostics = [];
544
+ if (config.extraction.dependencies.length === 0) return diagnostics;
545
+ const layout = catalogLayout(config);
546
+ for (const loaded of libraryManifests(config)) {
547
+ if (loaded.diagnostic !== null) diagnostics.push(loaded.diagnostic);
548
+ const catalogs = loaded.manifest?.catalogs;
549
+ if (catalogs == null || typeof catalogs.files !== "object") continue;
550
+ const from = catalogs.format === "json" ? "json" : catalogs.msgid === "id" ? "id" : "source";
551
+ const foreign = typeof loaded.manifest?.sourceLocale === "string" && loaded.manifest.sourceLocale !== config.sourceLocale;
552
+ for (const locale of locales) {
553
+ if (locale === config.sourceLocale && !foreign) continue;
554
+ const own = set.get(locale);
555
+ const relativePath = Object.hasOwn(catalogs.files, locale) ? catalogs.files[locale] : void 0;
556
+ if (locale === config.sourceLocale && typeof relativePath !== "string") {
557
+ diagnostics.push({
558
+ code: "catalog-parse",
559
+ severity: "warning",
560
+ message: `${loaded.name} is written in ${loaded.manifest?.sourceLocale} and ships no ${locale} catalog; its messages show in ${loaded.manifest?.sourceLocale} on ${locale} pages`,
561
+ file: displayPath$2(config.root, loaded.file),
562
+ locale
563
+ });
564
+ continue;
565
+ }
566
+ if (own === void 0 || typeof relativePath !== "string") continue;
567
+ const path = resolve(dirname(loaded.file), relativePath);
568
+ let text;
569
+ try {
570
+ text = await readFile(path, "utf8");
571
+ } catch {
572
+ diagnostics.push({
573
+ code: "catalog-parse",
574
+ severity: "warning",
575
+ message: `${loaded.name} lists a ${locale} catalog it does not ship (${relativePath}); its ${locale} translations are left out`,
576
+ file: displayPath$2(config.root, loaded.file),
577
+ locale
578
+ });
579
+ continue;
580
+ }
581
+ const read = parseCatalog(text, {
582
+ locale,
583
+ layout: from,
584
+ isSourceLocale: false
585
+ });
586
+ for (const diagnostic of read.diagnostics) diagnostics.push({
587
+ ...diagnostic,
588
+ severity: diagnostic.severity === "error" ? "warning" : diagnostic.severity,
589
+ file: diagnostic.file ?? displayPath$2(config.root, path),
590
+ locale
591
+ });
592
+ const library = {
593
+ locale,
594
+ path,
595
+ catalog: joined(read.catalog, layout, codec),
596
+ hash: contentHash(text),
597
+ library: true,
598
+ ...foreign ? { librarySource: loaded.manifest?.sourceLocale } : {}
599
+ };
600
+ set.set(locale, {
601
+ ...own,
602
+ libraries: [...own.libraries ?? [], library]
603
+ });
604
+ }
605
+ }
606
+ return diagnostics;
607
+ }
608
+ /**
609
+ * A library catalog in the app's terms: the app's layout, no recorded
610
+ * scheme, and every entry's `label` computed from its source by the app's
611
+ * codec, so `entryKey`, `sourceMatches` and duplicates join by canonical
612
+ * source (generated ids) or manual id whatever the library's `ids.length`
613
+ * and layout. Entries without a source that parses cannot join and are
614
+ * left out.
615
+ */
616
+ function joined(catalog, layout, codec) {
617
+ const entries = [];
618
+ for (const entry of catalog.entries) {
619
+ if (entry.foreignContext !== null) continue;
620
+ const canonical = entryCanonical(entry, codec);
621
+ if (canonical === null) continue;
622
+ entries.push({
623
+ ...entry,
624
+ source: canonical,
625
+ label: codec.label(canonical)
626
+ });
627
+ }
628
+ return {
629
+ ...catalog,
630
+ layout,
631
+ header: {
632
+ ...catalog.header,
633
+ idScheme: null
634
+ },
635
+ entries,
636
+ obsolete: [],
637
+ droppedObsolete: 0
638
+ };
639
+ }
640
+ function displayPath$2(root, file) {
641
+ const rel = relative(root, file);
642
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return file.split(sep).join("/");
643
+ return rel.split(sep).join("/");
644
+ }
645
+ //#endregion
646
+ //#region src/core/catalog/plural-forms.ts
647
+ /**
648
+ * The PO `Plural-Forms` header (PLAN §4.4 P1, INTERNALS §2.6.3). vi18n never
649
+ * evaluates it: an ICU message carries its own plural cases, and vi18n never
650
+ * writes gettext plurals. PO editors and `msgfmt -c` expect a real one, so the
651
+ * writer emits the GNU gettext manual's formula for the file's language (the
652
+ * "Plural forms" table of gettext.texi, plus Chinese), and keeps a formula a
653
+ * tool already wrote, so vi18n and Poedit or Weblate never rewrite each
654
+ * other's spelling of the same rule.
655
+ */
656
+ const ONE = "nplurals=1; plural=0;";
657
+ const NOT_ONE = "nplurals=2; plural=n != 1;";
658
+ const OVER_ONE = "nplurals=2; plural=n>1;";
659
+ const EAST_SLAVIC = "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;";
660
+ /** Lower-case BCP 47 tags; a region-specific row wins over its language's. */
661
+ const FORMULAS = {
662
+ ja: ONE,
663
+ ko: ONE,
664
+ th: ONE,
665
+ vi: ONE,
666
+ zh: ONE,
667
+ bg: NOT_ONE,
668
+ da: NOT_ONE,
669
+ de: NOT_ONE,
670
+ el: NOT_ONE,
671
+ en: NOT_ONE,
672
+ eo: NOT_ONE,
673
+ es: NOT_ONE,
674
+ et: NOT_ONE,
675
+ fi: NOT_ONE,
676
+ fo: NOT_ONE,
677
+ he: NOT_ONE,
678
+ hu: NOT_ONE,
679
+ id: NOT_ONE,
680
+ it: NOT_ONE,
681
+ nb: NOT_ONE,
682
+ nl: NOT_ONE,
683
+ nn: NOT_ONE,
684
+ no: NOT_ONE,
685
+ pt: NOT_ONE,
686
+ sv: NOT_ONE,
687
+ tr: NOT_ONE,
688
+ fr: OVER_ONE,
689
+ "pt-br": OVER_ONE,
690
+ lv: "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2;",
691
+ ga: "nplurals=3; plural=n==1 ? 0 : n==2 ? 1 : 2;",
692
+ ro: "nplurals=3; plural=n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2;",
693
+ lt: "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2;",
694
+ be: EAST_SLAVIC,
695
+ hr: EAST_SLAVIC,
696
+ ru: EAST_SLAVIC,
697
+ sr: EAST_SLAVIC,
698
+ uk: EAST_SLAVIC,
699
+ cs: "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;",
700
+ sk: "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;",
701
+ pl: "nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;",
702
+ sl: "nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;",
703
+ ar: "nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5;"
704
+ };
705
+ /** The formula for a language tag (`pt-BR`, `pt_BR`, `de`), or null when the table has none. */
706
+ function pluralFormsFor(language) {
707
+ const subtags = language.trim().toLowerCase().replaceAll("_", "-").split("-");
708
+ for (let length = subtags.length; length > 0; length--) {
709
+ const formula = FORMULAS[subtags.slice(0, length).join("-")];
710
+ if (formula !== void 0) return formula;
711
+ }
712
+ return null;
713
+ }
714
+ //#endregion
715
+ //#region src/core/catalog/po.ts
716
+ /**
717
+ * Header fields the writer always emits itself; keeping them in `extra` would
718
+ * write them twice.
719
+ */
720
+ const OWNED_HEADER_FIELDS = /* @__PURE__ */ new Set([
721
+ "language",
722
+ "content-type",
723
+ "x-generator",
724
+ "x-vi18n-id",
725
+ "x-vi18n-ai-model"
726
+ ]);
727
+ const FSI = "⁨";
728
+ const PDI = "⁩";
729
+ const ESCAPED_SOURCE_LIKE = /^\\+source:/;
730
+ const SIMPLE_ESCAPES = {
731
+ "\\": "\\",
732
+ "\"": "\"",
733
+ n: "\n",
734
+ t: " ",
735
+ r: "\r",
736
+ a: "\x07",
737
+ b: "\b",
738
+ f: "\f",
739
+ v: "\v",
740
+ "'": "'",
741
+ "?": "?"
742
+ };
743
+ function isOctal(code) {
744
+ return code >= 48 && code <= 55;
745
+ }
746
+ function hexValue(code) {
747
+ if (code >= 48 && code <= 57) return code - 48;
748
+ if (code >= 97 && code <= 102) return code - 87;
749
+ if (code >= 65 && code <= 70) return code - 55;
750
+ return -1;
751
+ }
752
+ /**
753
+ * Decodes a PO string body (C escapes). Octal and hex escapes are bytes, so
754
+ * runs of them are decoded together as UTF-8 (`\303\251` is `é`). Returns
755
+ * null on an invalid escape.
756
+ */
757
+ function unescapePo(body) {
758
+ if (!body.includes("\\")) return body;
759
+ let out = "";
760
+ let bytes = null;
761
+ const flush = () => {
762
+ if (bytes !== null) {
763
+ out += Buffer.from(bytes).toString("utf8");
764
+ bytes = null;
765
+ }
766
+ };
767
+ let i = 0;
768
+ while (i < body.length) {
769
+ if (body[i] !== "\\") {
770
+ flush();
771
+ const next = body.indexOf("\\", i);
772
+ const end = next === -1 ? body.length : next;
773
+ out += body.slice(i, end);
774
+ i = end;
775
+ continue;
776
+ }
777
+ const escape = body[i + 1];
778
+ if (escape === void 0) return null;
779
+ if (Object.hasOwn(SIMPLE_ESCAPES, escape)) {
780
+ flush();
781
+ out += SIMPLE_ESCAPES[escape];
782
+ i += 2;
783
+ continue;
784
+ }
785
+ if (isOctal(escape.charCodeAt(0))) {
786
+ let value = 0;
787
+ let j = i + 1;
788
+ while (j < body.length && j < i + 4 && isOctal(body.charCodeAt(j))) value = value * 8 + body.charCodeAt(j++) - 48;
789
+ (bytes ??= []).push(value & 255);
790
+ i = j;
791
+ continue;
792
+ }
793
+ if (escape === "x") {
794
+ let value = 0;
795
+ let j = i + 2;
796
+ while (j < body.length && j < i + 4 && hexValue(body.charCodeAt(j)) !== -1) value = value * 16 + hexValue(body.charCodeAt(j++));
797
+ if (j === i + 2) return null;
798
+ (bytes ??= []).push(value);
799
+ i = j;
800
+ continue;
801
+ }
802
+ return null;
803
+ }
804
+ flush();
805
+ return out;
806
+ }
807
+ /** Parses `"…"` (trailing blanks allowed); null when the token is not exactly one string. */
808
+ function readString(text, from) {
809
+ let start = from;
810
+ while (start < text.length && (text[start] === " " || text[start] === " ")) start++;
811
+ if (text[start] !== "\"") return null;
812
+ let i = start + 1;
813
+ while (i < text.length) {
814
+ const ch = text[i];
815
+ if (ch === "\\") i += 2;
816
+ else if (ch === "\"") break;
817
+ else i++;
818
+ }
819
+ if (i >= text.length) return null;
820
+ for (let j = i + 1; j < text.length; j++) if (text[j] !== " " && text[j] !== " ") return null;
821
+ return unescapePo(text.slice(start + 1, i));
822
+ }
823
+ function newDraft() {
824
+ return {
825
+ raw: [],
826
+ comments: [],
827
+ contexts: [],
828
+ sourceNote: null,
829
+ refs: [],
830
+ flags: [],
831
+ flagKeys: null,
832
+ fuzzy: false,
833
+ ai: false,
834
+ maxLength: null,
835
+ msgctxt: null,
836
+ msgid: null,
837
+ plural: false,
838
+ msgstr: null,
839
+ field: null,
840
+ wrapped: false,
841
+ line: 0,
842
+ firstLine: 0,
843
+ error: null
844
+ };
845
+ }
846
+ function hasKeywords(draft) {
847
+ return draft.field !== null;
848
+ }
849
+ function parseError(line, message) {
850
+ return {
851
+ code: "catalog-parse",
852
+ severity: "error",
853
+ message,
854
+ line
855
+ };
856
+ }
857
+ /**
858
+ * `#:` references: whitespace-separated, GNU's FSI…PDI isolation for names
859
+ * with spaces, and `:line` suffixes dropped (vi18n references files only).
860
+ */
861
+ function readRefs(text, into) {
862
+ let i = 0;
863
+ while (i < text.length) {
864
+ const ch = text[i];
865
+ if (ch === " " || ch === " ") {
866
+ i++;
867
+ continue;
868
+ }
869
+ let token;
870
+ if (ch === FSI) {
871
+ const end = text.indexOf(PDI, i + 1);
872
+ const stop = end === -1 ? text.length : end;
873
+ token = text.slice(i + 1, stop);
874
+ i = stop + 1;
875
+ while (i < text.length && text[i] !== " " && text[i] !== " ") i++;
876
+ } else {
877
+ let end = i;
878
+ while (end < text.length && text[end] !== " " && text[end] !== " ") end++;
879
+ token = text.slice(i, end).replace(/:\d+$/, "");
880
+ i = end;
881
+ }
882
+ if (token !== "") into.push(token);
883
+ }
884
+ }
885
+ function readFlags(text, line, draft) {
886
+ for (const part of text.split(",")) {
887
+ const name = part.trim();
888
+ if (name === "") continue;
889
+ if (name === "fuzzy") draft.fuzzy = true;
890
+ else if (name === "ai") draft.ai = true;
891
+ else if (name.startsWith("max-length:") && /^max-length:[1-9]\d*$/.test(name)) {
892
+ const value = Number(name.slice(11));
893
+ draft.maxLength = draft.maxLength === null ? value : Math.min(draft.maxLength, value);
894
+ } else {
895
+ const key = line + name;
896
+ const keys = draft.flagKeys ??= /* @__PURE__ */ new Set();
897
+ if (!keys.has(key)) {
898
+ keys.add(key);
899
+ draft.flags.push({
900
+ name,
901
+ line
902
+ });
903
+ }
904
+ }
905
+ }
906
+ }
907
+ function sortedUnique(values) {
908
+ if (values.length < 2) return values;
909
+ const sorted = values.slice().sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
910
+ let w = 1;
911
+ for (let r = 1; r < sorted.length; r++) if (sorted[r] !== sorted[w - 1]) sorted[w++] = sorted[r];
912
+ sorted.length = w;
913
+ return sorted;
914
+ }
915
+ function readHeader(msgstr, locale) {
916
+ const header = {
917
+ language: locale,
918
+ idScheme: null,
919
+ sourceLocale: null,
920
+ aiModel: null,
921
+ extra: []
922
+ };
923
+ for (const field of msgstr.split("\n")) {
924
+ const colon = field.indexOf(":");
925
+ if (colon <= 0) continue;
926
+ const name = field.slice(0, colon).trim();
927
+ const value = field.slice(colon + 1).trim();
928
+ const lower = name.toLowerCase();
929
+ if (lower === "language") {
930
+ if (value !== "") header.language = value;
931
+ } else if (lower === "x-vi18n-id") header.idScheme = value === "" ? null : value;
932
+ else if (lower === "x-vi18n-ai-model") header.aiModel = value === "" ? null : value;
933
+ else if (!OWNED_HEADER_FIELDS.has(lower)) header.extra.push([name, value]);
934
+ }
935
+ const derived = pluralFormsFor(header.language);
936
+ if (derived !== null) header.extra = header.extra.filter(([name, value]) => name.toLowerCase() !== "plural-forms" || value !== derived);
937
+ return header;
938
+ }
939
+ function parsePo(text, options) {
940
+ const { locale, layout, isSourceLocale } = options;
941
+ const diagnostics = [];
942
+ const entries = [];
943
+ const obsolete = [];
944
+ let header = null;
945
+ let draft = newDraft();
946
+ let block = null;
947
+ let blockHasMsgstr = false;
948
+ const closeBlock = () => {
949
+ if (block !== null) {
950
+ obsolete.push(block.join("\n"));
951
+ block = null;
952
+ blockHasMsgstr = false;
953
+ }
954
+ };
955
+ const finish = () => {
956
+ const done = draft;
957
+ if (!hasKeywords(done)) return;
958
+ draft = newDraft();
959
+ if (done.error !== null) {
960
+ diagnostics.push(done.error);
961
+ return;
962
+ }
963
+ if (done.msgid === null) {
964
+ diagnostics.push(parseError(done.firstLine, "entry without msgid"));
965
+ return;
966
+ }
967
+ if (done.plural) {
968
+ diagnostics.push(parseError(done.line, "gettext plural entries (msgid_plural) are not supported: write the plural as ICU in the source text"));
969
+ return;
970
+ }
971
+ if (done.msgstr === null) {
972
+ diagnostics.push(parseError(done.line, "msgid without msgstr"));
973
+ return;
974
+ }
975
+ if (done.msgid === "" && done.msgctxt === null && header === null && entries.length === 0) {
976
+ header = readHeader(done.msgstr, locale);
977
+ return;
978
+ }
979
+ const manual = done.msgctxt !== null && done.msgctxt.includes(".");
980
+ const translation = isSourceLocale ? "" : done.msgstr;
981
+ let source;
982
+ let label = null;
983
+ if (layout === "source") source = done.msgid;
984
+ else {
985
+ label = done.msgid;
986
+ source = isSourceLocale ? done.msgstr : done.sourceNote;
987
+ }
988
+ const state = translation === "" ? "untranslated" : done.ai ? "ai" : "translated";
989
+ entries.push({
990
+ manualId: manual ? done.msgctxt : null,
991
+ foreignContext: manual ? null : done.msgctxt,
992
+ label,
993
+ source,
994
+ translation,
995
+ state,
996
+ fuzzy: done.fuzzy,
997
+ contexts: done.contexts,
998
+ comments: done.comments,
999
+ refs: sortedUnique(done.refs),
1000
+ maxLength: done.maxLength,
1001
+ flags: done.flags,
1002
+ line: done.line,
1003
+ wrapped: done.wrapped
1004
+ });
1005
+ };
1006
+ const fail = (line, message) => {
1007
+ draft.error ??= parseError(line, message);
1008
+ };
1009
+ const start = text.charCodeAt(0) === 65279 ? 1 : 0;
1010
+ const lines = (start === 0 ? text : text.slice(start)).split("\n");
1011
+ for (let index = 0; index < lines.length; index++) {
1012
+ const lineNo = index + 1;
1013
+ let raw = lines[index];
1014
+ if (raw.endsWith("\r")) raw = raw.slice(0, -1);
1015
+ let s = 0;
1016
+ while (s < raw.length && (raw[s] === " " || raw[s] === " ")) s++;
1017
+ if (s === raw.length) {
1018
+ finish();
1019
+ closeBlock();
1020
+ continue;
1021
+ }
1022
+ const line = s === 0 ? raw : raw.slice(s);
1023
+ if (line.startsWith("#~")) {
1024
+ finish();
1025
+ const body = line.slice(2).trimStart();
1026
+ const startsEntry = body.startsWith("msgid") || body.startsWith("msgctxt");
1027
+ if (block !== null && blockHasMsgstr && startsEntry) closeBlock();
1028
+ if (block === null) {
1029
+ block = draft.raw;
1030
+ draft = newDraft();
1031
+ }
1032
+ block.push(raw);
1033
+ if (body.startsWith("msgstr")) blockHasMsgstr = true;
1034
+ continue;
1035
+ }
1036
+ closeBlock();
1037
+ if (line.charCodeAt(0) === 35) {
1038
+ if (hasKeywords(draft)) finish();
1039
+ if (draft.firstLine === 0) draft.firstLine = lineNo;
1040
+ draft.raw.push(raw);
1041
+ const kind = line[1];
1042
+ const rest = line.slice(2);
1043
+ const content = rest.startsWith(" ") ? rest.slice(1) : rest;
1044
+ if (kind === ".") {
1045
+ if (layout === "id" && content.startsWith("source:")) {
1046
+ if (!isSourceLocale) {
1047
+ const body = content.slice(content.startsWith("source: ") ? 8 : 7);
1048
+ const note = unescapePo(body);
1049
+ if (note === null) diagnostics.push({
1050
+ code: "catalog-parse",
1051
+ severity: "warning",
1052
+ message: "invalid escape in #. source:; the note is kept as written",
1053
+ line: lineNo
1054
+ });
1055
+ draft.sourceNote = note ?? body;
1056
+ }
1057
+ } else draft.contexts.push(layout === "id" && ESCAPED_SOURCE_LIKE.test(content) ? content.slice(1) : content);
1058
+ } else if (kind === ":") readRefs(content, draft.refs);
1059
+ else if (kind === ",") readFlags(content, "#,", draft);
1060
+ else if (kind === "=") readFlags(content, "#=", draft);
1061
+ else if (kind === "|") {} else {
1062
+ const body = line.slice(1);
1063
+ draft.comments.push(body.startsWith(" ") ? body.slice(1) : body);
1064
+ }
1065
+ continue;
1066
+ }
1067
+ if (line.charCodeAt(0) === 34) {
1068
+ const value = readString(line, 0);
1069
+ if (draft.field === null) {
1070
+ fail(lineNo, "string continuation without a keyword");
1071
+ draft.firstLine ||= lineNo;
1072
+ draft.field = "msgstr";
1073
+ continue;
1074
+ }
1075
+ if (value === null) {
1076
+ fail(lineNo, "malformed string");
1077
+ continue;
1078
+ }
1079
+ switch (draft.field) {
1080
+ case "msgctxt":
1081
+ draft.msgctxt = (draft.msgctxt ?? "") + value;
1082
+ break;
1083
+ case "msgid":
1084
+ draft.msgid = (draft.msgid ?? "") + value;
1085
+ draft.wrapped = true;
1086
+ break;
1087
+ case "msgstr":
1088
+ draft.msgstr = (draft.msgstr ?? "") + value;
1089
+ draft.wrapped = true;
1090
+ }
1091
+ continue;
1092
+ }
1093
+ const keyword = /^(msgctxt|msgid_plural|msgid|msgstr\[\d+\]|msgstr)(?=[\s"])/.exec(line);
1094
+ if (keyword === null) {
1095
+ if (!hasKeywords(draft)) draft.firstLine ||= lineNo;
1096
+ draft.field ??= "msgstr";
1097
+ fail(lineNo, `unexpected line: ${line.length > 40 ? `${line.slice(0, 40)}…` : line}`);
1098
+ continue;
1099
+ }
1100
+ const word = keyword[1];
1101
+ const field = word.startsWith("msgstr[") ? "msgstr[]" : word;
1102
+ if ((field === "msgctxt" || field === "msgid") && (draft.msgstr !== null || draft.plural)) finish();
1103
+ if (draft.firstLine === 0) draft.firstLine = lineNo;
1104
+ const value = readString(line, word.length);
1105
+ draft.field = field;
1106
+ if (value === null) {
1107
+ fail(lineNo, `malformed ${word} string`);
1108
+ continue;
1109
+ }
1110
+ switch (field) {
1111
+ case "msgctxt":
1112
+ if (draft.msgctxt !== null || draft.msgid !== null) fail(lineNo, "msgctxt must come first, once");
1113
+ draft.msgctxt = value;
1114
+ break;
1115
+ case "msgid":
1116
+ if (draft.msgid !== null) fail(lineNo, "duplicate msgid");
1117
+ draft.msgid = value;
1118
+ draft.line = lineNo;
1119
+ break;
1120
+ case "msgid_plural":
1121
+ draft.plural = true;
1122
+ break;
1123
+ case "msgstr":
1124
+ if (draft.msgid === null) fail(lineNo, "msgstr without msgid");
1125
+ if (draft.msgstr !== null) fail(lineNo, "duplicate msgstr");
1126
+ draft.msgstr = value;
1127
+ break;
1128
+ case "msgstr[]": if (!draft.plural) fail(lineNo, `${word} without msgid_plural`);
1129
+ }
1130
+ }
1131
+ finish();
1132
+ closeBlock();
1133
+ return {
1134
+ catalog: {
1135
+ locale,
1136
+ layout,
1137
+ header: header ?? {
1138
+ language: locale,
1139
+ idScheme: null,
1140
+ sourceLocale: null,
1141
+ aiModel: null,
1142
+ extra: []
1143
+ },
1144
+ entries,
1145
+ obsolete,
1146
+ droppedObsolete: obsolete.length
1147
+ },
1148
+ diagnostics
1149
+ };
1150
+ }
1151
+ //#endregion
1152
+ //#region src/core/catalog/io.ts
1153
+ /**
1154
+ * Catalog files (INTERNALS §2.6.1, §2.6.6, PLAN §5.6): one per locale,
1155
+ * source locale included. Every writer (dev, extract, translate, import,
1156
+ * restore) goes through `updateCatalogFile`, a compare-and-swap over an
1157
+ * atomic rename, so concurrent writers re-apply their change instead of
1158
+ * losing each other's.
1159
+ */
1160
+ function catalogPath(config, locale) {
1161
+ return config.catalogs.path.replaceAll("{locale}", locale);
1162
+ }
1163
+ /** The layout a config's catalogs use. */
1164
+ function catalogLayout(config) {
1165
+ return config.catalogs.format === "json" ? "json" : config.catalogs.msgid;
1166
+ }
1167
+ function parseCatalog(text, options) {
1168
+ if (options.layout === "json") return parseJsonCatalog(text, options);
1169
+ return parsePo(text, {
1170
+ locale: options.locale,
1171
+ layout: options.layout,
1172
+ isSourceLocale: options.isSourceLocale
1173
+ });
1174
+ }
1175
+ /** sha256 hex of a file's bytes: the compare-and-swap token. */
1176
+ function contentHash(data) {
1177
+ return hash("sha256", data, "hex");
1178
+ }
1179
+ function displayPath$1(root, file) {
1180
+ const rel = relative(root, file);
1181
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return file.split(sep).join("/");
1182
+ return rel.split(sep).join("/");
1183
+ }
1184
+ function isMissing(error) {
1185
+ return error?.code === "ENOENT";
1186
+ }
1187
+ async function readOptional(path) {
1188
+ try {
1189
+ return await readFile(path);
1190
+ } catch (error) {
1191
+ if (isMissing(error)) return null;
1192
+ throw diagnosticError({
1193
+ code: "catalog-parse",
1194
+ severity: "error",
1195
+ message: `cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`,
1196
+ file: path
1197
+ });
1198
+ }
1199
+ }
1200
+ const UTF8 = new TextDecoder("utf-8", { fatal: true });
1201
+ /** Labels that mean UTF-8; `CHARSET` is the placeholder of an unfilled POT header. */
1202
+ const UTF8_LABELS = /* @__PURE__ */ new Set([
1203
+ "utf-8",
1204
+ "utf8",
1205
+ "unicode-1-1-utf-8",
1206
+ "charset"
1207
+ ]);
1208
+ /**
1209
+ * The charset a PO header declares. GNU tools only write ASCII-compatible
1210
+ * charsets, so the header can be read as latin1 before the encoding is known.
1211
+ */
1212
+ function poCharset(bytes) {
1213
+ const head = Buffer.from(bytes.buffer, bytes.byteOffset, Math.min(bytes.length, 8192)).toString("latin1").split(/\r?\n[ \t]*\r?\n/, 1)[0];
1214
+ if (!/^[ \t]*msgid[ \t]+""/m.test(head)) return null;
1215
+ return /^[ \t]*"Content-Type:[^"\n]*?charset=([^"\s;\\]+)/im.exec(head)?.[1] ?? null;
1216
+ }
1217
+ /** 1-based line of the first byte sequence that is not UTF-8 (`\n` never occurs inside one). */
1218
+ function firstInvalidLine(bytes) {
1219
+ let start = 0;
1220
+ for (let line = 1;; line++) {
1221
+ const end = bytes.indexOf(10, start);
1222
+ const stop = end === -1 ? bytes.length : end;
1223
+ try {
1224
+ UTF8.decode(bytes.subarray(start, stop));
1225
+ } catch {
1226
+ return line;
1227
+ }
1228
+ if (end === -1) return line;
1229
+ start = end + 1;
1230
+ }
1231
+ }
1232
+ /**
1233
+ * Catalog bytes as text. A PO file in another charset that GNU tools wrote
1234
+ * is transcoded, and the next write saves it as UTF-8. Bytes that do not
1235
+ * decode come back as a `catalog-parse` error with lossily decoded text: the
1236
+ * text keeps the rest of the file readable, the error stops any write that
1237
+ * would persist the replacement characters.
1238
+ */
1239
+ function decodeCatalog(bytes, layout) {
1240
+ const charset = layout === "json" ? null : poCharset(bytes);
1241
+ const lossy = () => Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length).toString("utf8");
1242
+ if (charset === null || UTF8_LABELS.has(charset.toLowerCase())) try {
1243
+ return {
1244
+ text: UTF8.decode(bytes),
1245
+ diagnostic: null
1246
+ };
1247
+ } catch {
1248
+ return {
1249
+ text: lossy(),
1250
+ diagnostic: {
1251
+ code: "catalog-parse",
1252
+ severity: "error",
1253
+ message: "the file is not valid UTF-8; re-save it as UTF-8",
1254
+ line: firstInvalidLine(bytes)
1255
+ }
1256
+ };
1257
+ }
1258
+ let decoder;
1259
+ try {
1260
+ decoder = new TextDecoder(charset, { fatal: true });
1261
+ } catch {
1262
+ return {
1263
+ text: lossy(),
1264
+ diagnostic: {
1265
+ code: "catalog-parse",
1266
+ severity: "error",
1267
+ message: `unsupported charset ${charset} in Content-Type; convert the file to UTF-8`
1268
+ }
1269
+ };
1270
+ }
1271
+ try {
1272
+ return {
1273
+ text: decoder.decode(bytes),
1274
+ diagnostic: null
1275
+ };
1276
+ } catch {
1277
+ return {
1278
+ text: lossy(),
1279
+ diagnostic: {
1280
+ code: "catalog-parse",
1281
+ severity: "error",
1282
+ message: `the file is not valid ${charset} as its Content-Type declares; fix its encoding`
1283
+ }
1284
+ };
1285
+ }
1286
+ }
1287
+ function severityOf$1(level) {
1288
+ return level === "off" ? null : level === "warn" ? "warning" : "error";
1289
+ }
1290
+ /**
1291
+ * Sources that do not parse leave their entry without a key (INTERNALS
1292
+ * §2.6.2); they are reported here, once per load, on the entry's line. The
1293
+ * advice is what clears the report in every case: `vi18n extract` keeps a
1294
+ * generated entry it cannot match to code, and an `extraction.keep` id's.
1295
+ */
1296
+ function invalidSources(catalog, codec, isSourceLocale, severity) {
1297
+ if (catalog.layout === "id" && !isSourceLocale) return [];
1298
+ const found = [];
1299
+ for (const entry of catalog.entries) {
1300
+ if (entry.source === null || entry.foreignContext !== null) continue;
1301
+ if (entryCanonical(entry, codec) !== null) continue;
1302
+ try {
1303
+ codec.canonicalize(entry.source);
1304
+ } catch (error) {
1305
+ const reason = error instanceof Error ? error.message : String(error);
1306
+ const diagnostic = {
1307
+ code: "catalog-invalid-translation",
1308
+ severity,
1309
+ message: `the source ${JSON.stringify(entry.source)} is not valid ICU (${reason}); fix its source or delete the entry`,
1310
+ locale: catalog.locale
1311
+ };
1312
+ if (entry.manualId !== null) diagnostic.id = entry.manualId;
1313
+ if (entry.line > 0) diagnostic.line = entry.line;
1314
+ found.push(diagnostic);
1315
+ }
1316
+ }
1317
+ return found;
1318
+ }
1319
+ /**
1320
+ * Reads every locale's catalog (default: `config.locales`). Missing files
1321
+ * load as `catalog: null`. Entries are returned as read, duplicates
1322
+ * included: `resolveDuplicates` is the read-side pick.
1323
+ */
1324
+ async function loadCatalogSet(config, codec, locales) {
1325
+ const layout = catalogLayout(config);
1326
+ const wanted = locales ?? config.locales;
1327
+ const invalid = severityOf$1(config.strict.invalid);
1328
+ const loaded = await Promise.all(wanted.map(async (locale) => {
1329
+ const path = catalogPath(config, locale);
1330
+ const bytes = await readOptional(path);
1331
+ if (bytes === null) return {
1332
+ catalog: {
1333
+ locale,
1334
+ path,
1335
+ catalog: null,
1336
+ hash: null
1337
+ },
1338
+ diagnostics: []
1339
+ };
1340
+ const isSourceLocale = locale === config.sourceLocale;
1341
+ const decoded = decodeCatalog(bytes, layout);
1342
+ const read = parseCatalog(decoded.text, {
1343
+ locale,
1344
+ layout,
1345
+ isSourceLocale
1346
+ });
1347
+ const diagnostics = read.diagnostics;
1348
+ if (decoded.diagnostic !== null) diagnostics.unshift(decoded.diagnostic);
1349
+ if (invalid !== null) diagnostics.push(...invalidSources(read.catalog, codec, isSourceLocale, invalid));
1350
+ const file = displayPath$1(config.root, path);
1351
+ for (const diagnostic of diagnostics) {
1352
+ diagnostic.file ??= file;
1353
+ diagnostic.locale ??= locale;
1354
+ }
1355
+ return {
1356
+ catalog: {
1357
+ locale,
1358
+ path,
1359
+ catalog: read.catalog,
1360
+ hash: contentHash(bytes)
1361
+ },
1362
+ diagnostics
1363
+ };
1364
+ }));
1365
+ const set = /* @__PURE__ */ new Map();
1366
+ const diagnostics = [];
1367
+ for (const item of loaded) {
1368
+ set.set(item.catalog.locale, item.catalog);
1369
+ diagnostics.push(...item.diagnostics);
1370
+ }
1371
+ if (config.extraction.dependencies.length > 0) diagnostics.push(...await attachLibraryCatalogs(set, config, codec, wanted));
1372
+ return {
1373
+ set,
1374
+ diagnostics
1375
+ };
1376
+ }
1377
+ //#endregion
1378
+ //#region src/core/id/hash.ts
1379
+ /**
1380
+ * Ids (INTERNALS §2.2.1, §2.2.3, PLAN §5.5). An id depends only on its own
1381
+ * canonical text and `ids.length`, so every process computes it alone and
1382
+ * nothing is ever recorded. Frozen by the golden vectors: changing anything
1383
+ * here re-keys every catalog.
1384
+ */
1385
+ /**
1386
+ * Lingui's construction with an empty context (`text + "\u001F"`), so a Lingui
1387
+ * 6 id of the same canonical text is a prefix of the vi18n id.
1388
+ */
1389
+ function sourceHash(canonical) {
1390
+ return hash("sha256", canonical + "", "base64url");
1391
+ }
1392
+ function idScheme(length) {
1393
+ return `sha256-b64u-${length}-v2`;
1394
+ }
1395
+ const SCHEME = /^sha256-b64u-(8|9|1[0-6])-v2$/;
1396
+ /** The id length a catalog header records, or null for anything vi18n did not write. */
1397
+ function parseIdScheme(tag) {
1398
+ const match = SCHEME.exec(tag);
1399
+ return match === null ? null : {
1400
+ length: Number(match[1]),
1401
+ version: 2
1402
+ };
1403
+ }
1404
+ /** The base64url alphabet has no `.`, so a `.` is what marks a manual id. */
1405
+ function isManualId(id) {
1406
+ return id.includes(".");
1407
+ }
1408
+ /** Characters that need no escaping in JS, JSON, PO, file names or URLs, with at least one `.`. */
1409
+ const MANUAL_ID = /^[A-Za-z0-9_:-]+(\.[A-Za-z0-9_:-]+)+$/;
1410
+ function isValidManualId(id) {
1411
+ return MANUAL_ID.test(id);
1412
+ }
1413
+ //#endregion
1414
+ //#region src/core/codec.ts
1415
+ /**
1416
+ * The one place that turns source text into ids (INTERNALS §2.2). Extract,
1417
+ * catalogs and compilation see the same message many times per run, so parses,
1418
+ * canonical forms and hashes are memoised per string.
1419
+ */
1420
+ /**
1421
+ * Memo entries past this count are dropped all at once: a long dev session
1422
+ * sees every edit of every message, while one scan of a large project needs
1423
+ * the whole set warm.
1424
+ */
1425
+ const MEMO_LIMIT = 2e5;
1426
+ function createCodec(idLength) {
1427
+ if (!Number.isInteger(idLength) || idLength < 8 || idLength > 16) throw diagnosticError({
1428
+ code: "config-invalid",
1429
+ severity: "error",
1430
+ message: `ids.length must be an integer from 8 to 16, not ${String(idLength)}`
1431
+ });
1432
+ const parsed = /* @__PURE__ */ new Map();
1433
+ const hashes = /* @__PURE__ */ new Map();
1434
+ const entry = (source) => {
1435
+ let found = parsed.get(source);
1436
+ if (found === void 0) {
1437
+ found = {
1438
+ ast: null,
1439
+ error: null,
1440
+ canonical: null
1441
+ };
1442
+ try {
1443
+ const normalized = normalizeSource(source);
1444
+ found.ast = isPlainText(normalized) ? normalized === "" ? [] : [{
1445
+ type: "literal",
1446
+ value: normalized
1447
+ }] : parseSource(normalized);
1448
+ } catch (error) {
1449
+ if (!isDiagnosticError(error)) throw error;
1450
+ found.error = error;
1451
+ }
1452
+ if (parsed.size >= MEMO_LIMIT) parsed.clear();
1453
+ parsed.set(source, found);
1454
+ }
1455
+ return found;
1456
+ };
1457
+ const hashOf = (canonical) => {
1458
+ let found = hashes.get(canonical);
1459
+ if (found === void 0) {
1460
+ found = sourceHash(canonical);
1461
+ if (hashes.size >= MEMO_LIMIT) hashes.clear();
1462
+ hashes.set(canonical, found);
1463
+ }
1464
+ return found;
1465
+ };
1466
+ return {
1467
+ idLength,
1468
+ idScheme: idScheme(idLength),
1469
+ canonicalize(source) {
1470
+ const found = parsed.get(source);
1471
+ if (found === void 0) {
1472
+ const normalized = normalizeSource(source);
1473
+ if (isPlainText(normalized)) return normalized;
1474
+ }
1475
+ const memo = found ?? entry(source);
1476
+ if (memo.error !== null) throw memo.error;
1477
+ return memo.canonical ??= printICU(memo.ast);
1478
+ },
1479
+ parse(source) {
1480
+ const memo = entry(source);
1481
+ if (memo.error !== null) throw memo.error;
1482
+ return memo.ast;
1483
+ },
1484
+ sourceHash: hashOf,
1485
+ label(canonical) {
1486
+ return hashOf(canonical).slice(0, idLength);
1487
+ }
1488
+ };
1489
+ }
1490
+ //#endregion
1491
+ //#region src/core/catalog/duplicates.ts
1492
+ const RANK = {
1493
+ translated: 2,
1494
+ ai: 1,
1495
+ untranslated: 0
1496
+ };
1497
+ /** Entries grouped by key; only keys seen more than once. Null when there are none. */
1498
+ function duplicateGroups(catalog, codec) {
1499
+ const first = /* @__PURE__ */ new Map();
1500
+ let groups = null;
1501
+ const entries = catalog.entries;
1502
+ for (let i = 0; i < entries.length; i++) {
1503
+ const key = entryKey(entries[i], catalog.layout, codec);
1504
+ if (key === null) continue;
1505
+ const seen = first.get(key);
1506
+ if (seen === void 0) {
1507
+ first.set(key, i);
1508
+ continue;
1509
+ }
1510
+ groups ??= /* @__PURE__ */ new Map();
1511
+ const group = groups.get(key);
1512
+ if (group === void 0) groups.set(key, [seen, i]);
1513
+ else group.push(i);
1514
+ }
1515
+ return groups;
1516
+ }
1517
+ /** `translated` over `ai` over `untranslated`, then the first in the file. */
1518
+ function winnerOf(entries, indexes) {
1519
+ let best = indexes[0];
1520
+ for (const index of indexes) if (RANK[entries[index].state] > RANK[entries[best].state]) best = index;
1521
+ return best;
1522
+ }
1523
+ /**
1524
+ * The read-side pick: one entry per key, the losers dropped. Returns the
1525
+ * input catalog itself when it has no duplicates, so the common case
1526
+ * allocates nothing.
1527
+ */
1528
+ function resolveDuplicates(catalog, codec) {
1529
+ const groups = duplicateGroups(catalog, codec);
1530
+ if (groups === null) return {
1531
+ catalog,
1532
+ duplicates: []
1533
+ };
1534
+ const entries = catalog.entries;
1535
+ const dropped = /* @__PURE__ */ new Set();
1536
+ const duplicates = [];
1537
+ for (const [key, indexes] of groups) {
1538
+ const winner = winnerOf(entries, indexes);
1539
+ const lines = [entries[winner].line];
1540
+ for (const index of indexes) {
1541
+ if (index === winner) continue;
1542
+ dropped.add(index);
1543
+ lines.push(entries[index].line);
1544
+ }
1545
+ duplicates.push({
1546
+ locale: catalog.locale,
1547
+ key,
1548
+ lines
1549
+ });
1550
+ }
1551
+ return {
1552
+ catalog: {
1553
+ ...catalog,
1554
+ entries: entries.filter((_, index) => !dropped.has(index))
1555
+ },
1556
+ duplicates
1557
+ };
1558
+ }
1559
+ /**
1560
+ * The `catalog-duplicate` warnings for {@link resolveDuplicates}' reports,
1561
+ * for callers that surface them (`compileLocales`, `check`, `status`).
1562
+ */
1563
+ function duplicateDiagnostics(duplicates, file) {
1564
+ return duplicates.map((duplicate) => {
1565
+ const [winner, ...others] = duplicate.lines;
1566
+ const diagnostic = {
1567
+ code: "catalog-duplicate",
1568
+ severity: "warning",
1569
+ message: `${duplicate.key} appears ${duplicate.lines.length} times; the entry on line ${winner} is used and vi18n extract merges the copies (other lines: ${others.join(", ")})`,
1570
+ locale: duplicate.locale,
1571
+ id: duplicate.key
1572
+ };
1573
+ if (file !== void 0) diagnostic.file = file;
1574
+ if (winner !== void 0 && winner > 0) diagnostic.line = winner;
1575
+ return diagnostic;
1576
+ });
1577
+ }
1578
+ //#endregion
1579
+ //#region src/core/compile/fallback.ts
1580
+ const PSEUDO_LOCALES$1 = /* @__PURE__ */ new Set(["en-XA", "ar-XB"]);
1581
+ /**
1582
+ * The locales whose catalogs supply `locale`'s messages, most specific first
1583
+ * and always ending with `sourceLocale` (INTERNALS §2.8.1).
1584
+ *
1585
+ * Order: `locale`; its truncations at `-` boundaries that are configured
1586
+ * (`zh-Hant-TW` → `zh-Hant` → `zh`); then, for each of those in order, its
1587
+ * configured `fallbacks`; then `sourceLocale`. Duplicates keep their first
1588
+ * position, except `sourceLocale`, which only ever comes last: its messages
1589
+ * compile from code and are always complete, so a locale written after it in
1590
+ * `fallbacks` (`de: ["en", "fr"]`) would be unreachable for any consumer that
1591
+ * stops at the source, while `compileLocales` would still consult it. The
1592
+ * source locale's own chain is itself alone. Configured fallbacks are not
1593
+ * followed transitively: the chain is what the user wrote for the locale and
1594
+ * its ancestors, nothing they have to trace through other entries.
1595
+ */
1596
+ function fallbackChain(locale, config) {
1597
+ const source = config.sourceLocale;
1598
+ if (locale === source) return [locale];
1599
+ if (PSEUDO_LOCALES$1.has(locale)) return [locale, source];
1600
+ const chain = [locale];
1601
+ for (let end = locale.lastIndexOf("-"); end > 0; end = locale.lastIndexOf("-", end - 1)) {
1602
+ const ancestor = locale.slice(0, end);
1603
+ if (ancestor !== source && config.locales.includes(ancestor) && !chain.includes(ancestor)) chain.push(ancestor);
1604
+ }
1605
+ const derived = chain.length;
1606
+ for (let i = 0; i < derived; i++) {
1607
+ const member = chain[i];
1608
+ if (!Object.hasOwn(config.fallbacks, member)) continue;
1609
+ for (const next of config.fallbacks[member]) if (next !== source && !chain.includes(next)) chain.push(next);
1610
+ }
1611
+ chain.push(source);
1612
+ return chain;
1613
+ }
1614
+ //#endregion
1615
+ //#region src/core/compile/locales.ts
1616
+ /**
1617
+ * Locale compilation (INTERNALS §2.8.2, PLAN §5.5 D10, §5.9): catalogs and
1618
+ * code messages become one IR map per locale, fallbacks applied, with the
1619
+ * reason every untranslated id fell back. The build, `vi18n compile`, dev
1620
+ * catalog modules, `check` and `status` all read translations through here,
1621
+ * so "what ships" has one definition.
1622
+ */
1623
+ const PSEUDO_LOCALES = /* @__PURE__ */ new Set(["en-XA", "ar-XB"]);
1624
+ /** Ids listed in `catalog-missing-translation`; the rest are only counted. */
1625
+ const MISSING_LISTED = 20;
1626
+ const NO_ENTRY = {
1627
+ ok: false,
1628
+ reason: "no-entry"
1629
+ };
1630
+ const SCHEME_MISMATCH = {
1631
+ ok: false,
1632
+ reason: "scheme-mismatch"
1633
+ };
1634
+ function compileLocales(input) {
1635
+ const { config, codec, messages, catalogs } = input;
1636
+ const sourceFallback = input.sourceFallback ?? true;
1637
+ const layout = catalogLayout(config);
1638
+ const sourceLocale = config.sourceLocale;
1639
+ const invalidSeverity = severityOf(config.strict.invalid);
1640
+ const missingSeverity = severityOf(config.strict.missing);
1641
+ const prepared = /* @__PURE__ */ new Map();
1642
+ const prepare = (locale) => {
1643
+ let found = prepared.get(locale);
1644
+ if (found === void 0) {
1645
+ found = prepareCatalog(catalogs, locale, layout, codec, config.root);
1646
+ prepared.set(locale, found);
1647
+ }
1648
+ return found;
1649
+ };
1650
+ let sink = [];
1651
+ const report = (catalog) => {
1652
+ if (catalog !== null && catalog.pending !== null) {
1653
+ for (const diagnostic of catalog.pending) sink.push(diagnostic);
1654
+ catalog.pending = null;
1655
+ }
1656
+ return catalog;
1657
+ };
1658
+ const lookup = (catalog, id, message) => {
1659
+ if (catalog === null) return NO_ENTRY;
1660
+ const cached = catalog.lookups.get(id);
1661
+ if (cached !== void 0) return cached;
1662
+ const result = evaluate(catalog, id, message);
1663
+ catalog.lookups.set(id, result);
1664
+ return result;
1665
+ };
1666
+ const libraries = /* @__PURE__ */ new Map();
1667
+ /** A package's language, for its catalogs that differ from the app's (§2.6.11). */
1668
+ const librarySource = /* @__PURE__ */ new Map();
1669
+ const librariesOf = (locale) => {
1670
+ let found = libraries.get(locale);
1671
+ if (found === void 0) {
1672
+ found = [];
1673
+ for (const loaded of catalogs.get(locale)?.libraries ?? []) {
1674
+ const one = prepareCatalog(/* @__PURE__ */ new Map([[locale, loaded]]), locale, layout, codec, config.root);
1675
+ if (one === null) continue;
1676
+ const prepared = {
1677
+ ...one,
1678
+ pending: null,
1679
+ library: true
1680
+ };
1681
+ if (loaded.librarySource !== void 0) librarySource.set(prepared, loaded.librarySource);
1682
+ found.push(prepared);
1683
+ }
1684
+ libraries.set(locale, found);
1685
+ }
1686
+ return found;
1687
+ };
1688
+ /** A chain step: the app's catalog, then each library's for the same locale. */
1689
+ const step = (catalog, locale, id, message) => {
1690
+ const own = lookup(catalog, id, message);
1691
+ if (own.ok) return own;
1692
+ for (const library of librariesOf(locale)) {
1693
+ const found = lookup(library, id, message);
1694
+ if (found.ok) return found;
1695
+ }
1696
+ return own;
1697
+ };
1698
+ const evaluate = (catalog, id, message) => {
1699
+ if (catalog.schemeMismatch && (layout === "id" || !message.manual)) return SCHEME_MISMATCH;
1700
+ const entry = catalog.index.get(id);
1701
+ if (entry === void 0) return NO_ENTRY;
1702
+ const usable = usableTranslation(entry, layout, message, codec, catalog.locale);
1703
+ if (usable.ok) return {
1704
+ ok: true,
1705
+ ir: compileMessage(usable.parsed),
1706
+ state: entry.state === "ai" ? "ai" : "translated"
1707
+ };
1708
+ if (catalog.library) return {
1709
+ ok: false,
1710
+ reason: usable.reason
1711
+ };
1712
+ if (usable.reason === "invalid" && invalidSeverity !== null) sink.push(invalidDiagnostic(catalog, entry, id, usable.diagnostics, invalidSeverity));
1713
+ else if (usable.reason === "source-mismatch" && entry.manualId !== null) sink.push(sourceMismatchDiagnostic(catalog, entry, id, message, layout));
1714
+ return {
1715
+ ok: false,
1716
+ reason: usable.reason
1717
+ };
1718
+ };
1719
+ const sourceIRs = /* @__PURE__ */ new Map();
1720
+ const sourceIR = (id, message) => {
1721
+ let ir = sourceIRs.get(id);
1722
+ if (ir === void 0) {
1723
+ ir = parseOrReport(codec, message, sink, (ast) => compileMessage(ast));
1724
+ sourceIRs.set(id, ir);
1725
+ }
1726
+ return ir;
1727
+ };
1728
+ const all = withKeep(messages, input.keep ?? config.extraction.keep, {
1729
+ config,
1730
+ codec,
1731
+ layout,
1732
+ catalogs,
1733
+ prepare,
1734
+ sink: (diagnostic) => sink.push(diagnostic)
1735
+ });
1736
+ const keepSet = new Set(input.keep ?? config.extraction.keep);
1737
+ const preamble = sink;
1738
+ const result = /* @__PURE__ */ new Map();
1739
+ for (const locale of input.locales) {
1740
+ const chain = fallbackChain(locale, config);
1741
+ const compiled = {
1742
+ locale,
1743
+ chain,
1744
+ messages: /* @__PURE__ */ new Map(),
1745
+ status: /* @__PURE__ */ new Map(),
1746
+ labels: /* @__PURE__ */ new Map(),
1747
+ diagnostics: result.size === 0 ? preamble : []
1748
+ };
1749
+ sink = compiled.diagnostics;
1750
+ result.set(locale, compiled);
1751
+ if (PSEUDO_LOCALES.has(locale)) {
1752
+ compilePseudo(compiled, locale, all, codec);
1753
+ continue;
1754
+ }
1755
+ if (locale === sourceLocale) {
1756
+ report(prepare(locale));
1757
+ const status = {
1758
+ state: "translated",
1759
+ from: sourceLocale
1760
+ };
1761
+ const foreign = librariesOf(locale);
1762
+ for (const [id, message] of all) {
1763
+ let ir = null;
1764
+ for (const library of foreign) {
1765
+ const found = lookup(library, id, message);
1766
+ if (found.ok) {
1767
+ ir = found.ir;
1768
+ break;
1769
+ }
1770
+ }
1771
+ ir ??= sourceIR(id, message);
1772
+ if (ir === null) continue;
1773
+ compiled.messages.set(id, ir);
1774
+ compiled.status.set(id, status);
1775
+ if (message.manual) compiled.labels.set(id, message.label);
1776
+ }
1777
+ continue;
1778
+ }
1779
+ const own = report(prepare(locale));
1780
+ const walk = [];
1781
+ for (let i = 1; i < chain.length; i++) {
1782
+ const member = chain[i];
1783
+ if (member !== sourceLocale) walk.push(report(prepare(member)));
1784
+ }
1785
+ const walkLocales = chain.slice(1).filter((member) => member !== sourceLocale);
1786
+ const translated = {
1787
+ state: "translated",
1788
+ from: locale
1789
+ };
1790
+ const ai = {
1791
+ state: "ai",
1792
+ from: locale
1793
+ };
1794
+ const missing = /* @__PURE__ */ new Map();
1795
+ let missingCount = 0;
1796
+ const missingIds = [];
1797
+ for (const [id, message] of all) {
1798
+ const mine = step(own, locale, id, message);
1799
+ if (mine.ok) {
1800
+ compiled.messages.set(id, mine.ir);
1801
+ compiled.status.set(id, mine.state === "ai" ? ai : translated);
1802
+ if (message.manual) compiled.labels.set(id, message.label);
1803
+ continue;
1804
+ }
1805
+ missingCount++;
1806
+ if (missingIds.length < MISSING_LISTED) missingIds.push(id);
1807
+ let from = sourceLocale;
1808
+ let ir = null;
1809
+ for (let i = 0; i < walk.length; i++) {
1810
+ const found = step(walk[i], walkLocales[i], id, message);
1811
+ if (found.ok) {
1812
+ ir = found.ir;
1813
+ from = walkLocales[i];
1814
+ break;
1815
+ }
1816
+ }
1817
+ if (ir === null) for (const library of librariesOf(sourceLocale)) {
1818
+ if (librarySource.get(library) === locale) continue;
1819
+ const found = lookup(library, id, message);
1820
+ if (found.ok) {
1821
+ ir = found.ir;
1822
+ break;
1823
+ }
1824
+ }
1825
+ const suppliedByEntry = ir !== null;
1826
+ if (ir === null && (sourceFallback || keepSet.has(id))) ir = sourceIR(id, message);
1827
+ if (ir !== null) compiled.messages.set(id, ir);
1828
+ const statusKey = `${from}\0${mine.reason}`;
1829
+ let status = missing.get(statusKey);
1830
+ if (status === void 0) {
1831
+ status = {
1832
+ state: "missing",
1833
+ from,
1834
+ reason: mine.reason
1835
+ };
1836
+ missing.set(statusKey, status);
1837
+ }
1838
+ compiled.status.set(id, status);
1839
+ if (message.manual) {
1840
+ const label = suppliedByEntry ? message.label : ownLabel(own, id, layout, codec);
1841
+ if (label !== null) compiled.labels.set(id, label);
1842
+ else if (ir !== null) compiled.labels.set(id, message.label);
1843
+ }
1844
+ }
1845
+ if (missingCount > 0 && missingSeverity !== null) {
1846
+ const more = missingCount > missingIds.length ? ", …" : "";
1847
+ compiled.diagnostics.push({
1848
+ code: "catalog-missing-translation",
1849
+ severity: missingSeverity,
1850
+ message: `${missingCount} ${missingCount === 1 ? "message has" : "messages have"} no usable ${locale} translation (${missingIds.join(", ")}${more}); ${missingCount === 1 ? "it falls" : "they fall"} back along ${chain.join(" → ")}`,
1851
+ locale,
1852
+ ...own === null ? {} : { file: own.file }
1853
+ });
1854
+ }
1855
+ }
1856
+ return result;
1857
+ }
1858
+ function compilePseudo(compiled, locale, messages, codec) {
1859
+ const status = {
1860
+ state: "translated",
1861
+ from: locale
1862
+ };
1863
+ for (const [id, message] of messages) {
1864
+ const ir = parseOrReport(codec, message, compiled.diagnostics, (ast) => compileMessage(pseudoLocalize(ast, locale)));
1865
+ if (ir === null) continue;
1866
+ compiled.messages.set(id, ir);
1867
+ compiled.status.set(id, status);
1868
+ if (message.manual) compiled.labels.set(id, message.label);
1869
+ }
1870
+ }
1871
+ function parseOrReport(codec, message, sink, build) {
1872
+ try {
1873
+ return build(codec.parse(message.canonical));
1874
+ } catch (error) {
1875
+ if (!isDiagnosticError(error)) throw error;
1876
+ sink.push({
1877
+ ...error.diagnostic,
1878
+ id: error.diagnostic.id ?? message.id
1879
+ });
1880
+ return null;
1881
+ }
1882
+ }
1883
+ function severityOf(level) {
1884
+ return level === "off" ? null : level === "warn" ? "warning" : "error";
1885
+ }
1886
+ function displayPath(root, file) {
1887
+ const rel = relative(root, file);
1888
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return file.split(sep).join("/");
1889
+ return rel.split(sep).join("/");
1890
+ }
1891
+ function prepareCatalog(catalogs, locale, layout, codec, root) {
1892
+ const loaded = catalogs.get(locale);
1893
+ if (loaded === void 0 || loaded.catalog === null) return null;
1894
+ const file = displayPath(root, loaded.path);
1895
+ const { catalog, duplicates } = resolveDuplicates(loaded.catalog, codec);
1896
+ const pending = duplicateDiagnostics(duplicates, file);
1897
+ const index = /* @__PURE__ */ new Map();
1898
+ for (const entry of catalog.entries) {
1899
+ const key = entryKey(entry, layout, codec);
1900
+ if (key !== null) index.set(key, entry);
1901
+ }
1902
+ const recorded = catalog.header.idScheme;
1903
+ const schemeMismatch = layout !== "source" && recorded !== null && recorded !== codec.idScheme;
1904
+ if (schemeMismatch) pending.push(schemeDiagnostic(recorded, codec, layout, locale, file));
1905
+ return {
1906
+ locale,
1907
+ file,
1908
+ index,
1909
+ schemeMismatch,
1910
+ pending: pending.length > 0 ? pending : null,
1911
+ lookups: /* @__PURE__ */ new Map(),
1912
+ library: false
1913
+ };
1914
+ }
1915
+ function schemeDiagnostic(recorded, codec, layout, locale, file) {
1916
+ const old = parseIdScheme(recorded);
1917
+ return {
1918
+ code: "id-scheme-mismatch",
1919
+ severity: "error",
1920
+ message: old !== null ? `ids.length changed from ${old.length} to ${codec.idLength}: run vi18n extract to re-key` : `${layout === "json" ? "idScheme" : "X-Vi18n-Id"} is ${JSON.stringify(recorded)}, not ${codec.idScheme}: run vi18n extract to re-key`,
1921
+ locale,
1922
+ file
1923
+ };
1924
+ }
1925
+ function invalidDiagnostic(catalog, entry, id, found, severity) {
1926
+ const first = found[0];
1927
+ const reason = first === void 0 ? "it does not validate" : found.length === 1 ? first.message : `${first.message}, and ${found.length - 1} more`;
1928
+ const diagnostic = {
1929
+ code: "catalog-invalid-translation",
1930
+ severity,
1931
+ message: `${id}: the ${catalog.locale} translation is not used (${reason}); fix it or clear it`,
1932
+ file: catalog.file,
1933
+ locale: catalog.locale,
1934
+ id
1935
+ };
1936
+ if (entry.line > 0) diagnostic.line = entry.line;
1937
+ if (found.length > 0) diagnostic.related = found.map((inner) => ({
1938
+ file: catalog.file,
1939
+ ...inner.line === void 0 ? {} : { line: inner.line },
1940
+ message: `${inner.code}: ${inner.message}`
1941
+ }));
1942
+ return diagnostic;
1943
+ }
1944
+ function sourceMismatchDiagnostic(catalog, entry, id, message, layout) {
1945
+ const old = entry.source ?? (layout === "id" ? entry.label : null);
1946
+ const was = old === null ? "" : ` is for ${JSON.stringify(old)}`;
1947
+ const diagnostic = {
1948
+ code: "catalog-source-mismatch",
1949
+ severity: "info",
1950
+ message: `${id}: the ${catalog.locale} translation${was}, the code says ${JSON.stringify(message.canonical)}; run vi18n extract`,
1951
+ file: catalog.file,
1952
+ locale: catalog.locale,
1953
+ id
1954
+ };
1955
+ if (entry.line > 0) diagnostic.line = entry.line;
1956
+ return diagnostic;
1957
+ }
1958
+ /** The label of the source a locale's own manual entry was made from, valid or not. */
1959
+ function ownLabel(own, id, layout, codec) {
1960
+ const entry = own?.index.get(id);
1961
+ if (entry === void 0) return null;
1962
+ if (layout === "id") return entry.label;
1963
+ if (entry.source === null) return null;
1964
+ try {
1965
+ return codec.label(codec.canonicalize(entry.source));
1966
+ } catch (error) {
1967
+ if (isDiagnosticError(error)) return null;
1968
+ throw error;
1969
+ }
1970
+ }
1971
+ /**
1972
+ * The messages plus `keep` ids no message declares, whose identity comes from
1973
+ * their catalog entries (INTERNALS §2.8.2): the source locale's entry first
1974
+ * (in the `"id"` layout its `msgstr` is the only real source), then the other
1975
+ * locales in config order. A keep id without any entry is `keep-missing-entry`.
1976
+ */
1977
+ function withKeep(messages, keep, context) {
1978
+ let undeclared = null;
1979
+ for (const id of keep) if (!messages.has(id) && (undeclared === null || !undeclared.includes(id))) (undeclared ??= []).push(id);
1980
+ if (undeclared === null) return messages;
1981
+ const { config, codec, catalogs } = context;
1982
+ const sourceCatalog = catalogs.get(config.sourceLocale);
1983
+ const sourceFile = sourceCatalog === void 0 ? void 0 : displayPath(config.root, sourceCatalog.path);
1984
+ const all = new Map(messages);
1985
+ const order = [config.sourceLocale, ...config.locales.filter((l) => l !== config.sourceLocale)];
1986
+ for (const id of undeclared) {
1987
+ let found = false;
1988
+ let identity = null;
1989
+ for (const locale of order) {
1990
+ const entry = context.prepare(locale)?.index.get(id);
1991
+ if (entry === void 0) continue;
1992
+ found = true;
1993
+ if (entry.source === null) continue;
1994
+ identity = keepIdentity(id, entry.source, codec);
1995
+ if (identity !== null) break;
1996
+ }
1997
+ if (identity !== null) {
1998
+ all.set(id, identity);
1999
+ continue;
2000
+ }
2001
+ context.sink({
2002
+ code: "keep-missing-entry",
2003
+ severity: "error",
2004
+ message: found ? `extraction.keep lists ${id}, but no catalog entry for it has valid source text; run vi18n extract or fix the entry` : `extraction.keep lists ${id}, but no catalog has an entry for it; declare it with msg() in code or add it to the ${config.sourceLocale} catalog`,
2005
+ id,
2006
+ ...sourceFile === void 0 ? {} : { file: sourceFile }
2007
+ });
2008
+ }
2009
+ return all;
2010
+ }
2011
+ function keepIdentity(id, source, codec) {
2012
+ let canonical;
2013
+ try {
2014
+ canonical = codec.canonicalize(source);
2015
+ } catch (error) {
2016
+ if (isDiagnosticError(error)) return null;
2017
+ throw error;
2018
+ }
2019
+ const sourceHash = codec.sourceHash(canonical);
2020
+ return {
2021
+ id,
2022
+ manual: isManualId(id),
2023
+ canonical,
2024
+ sourceHash,
2025
+ label: sourceHash.slice(0, codec.idLength)
2026
+ };
2027
+ }
2028
+ //#endregion
2029
+ //#region src/config/types.ts
2030
+ /** The only config file name (revision 4.2). */
2031
+ const CONFIG_FILE = "vi18n.config.ts";
2032
+ /** Import sources that always apply; `extraction.importSources` is appended to them. */
2033
+ const DEFAULT_IMPORT_SOURCES = ["@menapo/vi18n", "@menapo/vi18n/server"];
2034
+ /** Valid range of `ids.length`. */
2035
+ const ID_LENGTH = {
2036
+ min: 8,
2037
+ max: 16,
2038
+ default: 12
2039
+ };
2040
+ /**
2041
+ * Defaults (PLAN §5.7). Relative paths here are resolved against the config
2042
+ * directory; `catalogs.path` becomes `locales/{locale}.json` when only
2043
+ * `format: "json"` is given.
2044
+ */
2045
+ const DEFAULT_CONFIG = {
2046
+ sourceLocale: "en",
2047
+ locales: ["en"],
2048
+ pseudoLocales: [],
2049
+ fallbacks: {},
2050
+ project: {
2051
+ include: ["src"],
2052
+ extensions: [
2053
+ ".js",
2054
+ ".jsx",
2055
+ ".mjs",
2056
+ ".ts",
2057
+ ".tsx",
2058
+ ".mts",
2059
+ ".svelte",
2060
+ ".vue",
2061
+ ".astro",
2062
+ ".mdx"
2063
+ ],
2064
+ exclude: [
2065
+ "node_modules",
2066
+ "/dist",
2067
+ "/build",
2068
+ "**/*.test.*",
2069
+ "**/*.spec.*",
2070
+ "**/__tests__/**"
2071
+ ]
2072
+ },
2073
+ extraction: {
2074
+ importSources: [...DEFAULT_IMPORT_SOURCES],
2075
+ aliases: [],
2076
+ globals: [],
2077
+ keep: [],
2078
+ dependencies: []
2079
+ },
2080
+ ids: { length: ID_LENGTH.default },
2081
+ catalogs: {
2082
+ format: "po",
2083
+ path: "locales/{locale}.po",
2084
+ msgid: "source",
2085
+ icuFlag: false
2086
+ },
2087
+ output: {
2088
+ split: {
2089
+ shared: "per-set",
2090
+ dupBelow: 1024,
2091
+ minBytes: 1024,
2092
+ maxBundlesPerRoot: null
2093
+ },
2094
+ guard: "auto",
2095
+ manifestPath: "vi18n/{locale}/manifest.json",
2096
+ manifest: "data",
2097
+ baseEntries: [],
2098
+ compactIds: false,
2099
+ perLocale: false
2100
+ },
2101
+ routing: {
2102
+ strategy: [
2103
+ "url",
2104
+ "cookie",
2105
+ "header",
2106
+ "default"
2107
+ ],
2108
+ routeStrategies: {},
2109
+ url: "prefix_except_default",
2110
+ redirectRoot: false,
2111
+ propagate: "header",
2112
+ switch: "in-place",
2113
+ cookie: {
2114
+ name: "vi18n_locale",
2115
+ maxAge: 31536e3
2116
+ },
2117
+ base: null,
2118
+ paths: {},
2119
+ domains: {}
2120
+ },
2121
+ ssr: {
2122
+ timeZone: "UTC",
2123
+ timeZoneFrom: {},
2124
+ payload: "bundles",
2125
+ catalogs: "auto",
2126
+ maxLoadedLocales: 8,
2127
+ normalizeSpaces: true
2128
+ },
2129
+ ai: {
2130
+ provider: "gemini",
2131
+ model: "gemini-3.8-flash",
2132
+ exclude: [],
2133
+ glossary: {},
2134
+ doNotTranslate: [],
2135
+ tone: {},
2136
+ description: null,
2137
+ apiKeyEnv: "GEMINI_API_KEY",
2138
+ batchSize: 60,
2139
+ concurrency: 4,
2140
+ dev: false
2141
+ },
2142
+ defaults: { tags: {} },
2143
+ devtools: false,
2144
+ strict: {
2145
+ missing: "warn",
2146
+ invalid: "error",
2147
+ moduleScope: "warn"
2148
+ }
2149
+ };
2150
+ //#endregion
2151
+ //#region src/core/config/exclude.ts
2152
+ /**
2153
+ * `ai.exclude` entries (PLAN §4.6, §5.20; INTERNALS §2.15): a configured
2154
+ * locale, a message id, or a glob over the config-relative files that use a
2155
+ * message. One list keeps the config short, so the kind is read from the
2156
+ * entry: the loader rejects what fits none, and `vi18n translate` matches
2157
+ * with the same reading.
2158
+ */
2159
+ /** Everything a message id may hold: base64url, plus a manual id's `.` and `:`. */
2160
+ const ID_CHARACTERS = /^[A-Za-z0-9_:.-]+$/;
2161
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
2162
+ /**
2163
+ * The kind of an entry, or null when it is none. A glob is anything with a
2164
+ * character no id or locale has, and must contain `/`, `*` or `?`: a bare
2165
+ * `legal.tsx` reads as the manual id it also is, so a file at the root is
2166
+ * written `./legal.tsx` or `**\/legal.tsx`.
2167
+ */
2168
+ function excludeKind(entry, locales, idLength) {
2169
+ if (!ID_CHARACTERS.test(entry)) return /[/*?]/.test(entry) ? "glob" : null;
2170
+ if (locales.includes(entry)) return "locale";
2171
+ if (isValidManualId(entry)) return "id";
2172
+ return entry.length === idLength && BASE64URL.test(entry) ? "id" : null;
2173
+ }
2174
+ //#endregion
2175
+ //#region src/core/config/schema.ts
2176
+ /**
2177
+ * The zod 4 schema of `vi18n.config.ts` (PLAN §5.7, INTERNALS §2.7.1). It
2178
+ * checks shapes and per-field rules only; rules that relate two keys
2179
+ * (`sourceLocale` ∈ `locales`, fallbacks, pseudo-locales, `ai.exclude`) need
2180
+ * the defaults applied first and live in `crossFieldProblems`, so a config
2181
+ * that leaves `locales` out is judged against `[sourceLocale]`.
2182
+ *
2183
+ * Every key is optional: defaults are applied by `resolveConfig`, never by
2184
+ * zod, so the parsed value still tells which keys the user wrote.
2185
+ */
2186
+ /**
2187
+ * `TIME_ZONE_COOKIE` of `src/server/types.ts`, which core may not import
2188
+ * (INTERNALS §0); the schema tests check the two agree.
2189
+ */
2190
+ const TIME_ZONE_COOKIE = "vi18n_tz";
2191
+ /** Problem with a locale tag, or null when it is valid and canonical. */
2192
+ function localeProblem(tag) {
2193
+ let canonical;
2194
+ try {
2195
+ canonical = Intl.getCanonicalLocales(tag)[0];
2196
+ } catch {
2197
+ return `${JSON.stringify(tag)} is not a valid BCP 47 locale tag`;
2198
+ }
2199
+ if (canonical !== tag) return `${JSON.stringify(tag)} is not canonical (write ${JSON.stringify(canonical)})`;
2200
+ return null;
2201
+ }
2202
+ const IANA_ZONE = "an IANA time zone name such as \"Europe/Berlin\"";
2203
+ /**
2204
+ * Problem with `ssr.timeZone`, or null. The rule is `isTimeZone` of
2205
+ * `src/server/catalogs.ts`, which core may not import (the schema tests check
2206
+ * the two agree): a name starts with a letter, has at most 64 characters and
2207
+ * Intl accepts it. UTC offsets, which Intl accepts too, are refused there
2208
+ * (each spelling would get its own formatter in caches keyed by zone), so a
2209
+ * config naming one must fail here rather than in `registerCatalogs()`.
2210
+ */
2211
+ function timeZoneProblem(zone) {
2212
+ if (/^[+\-\d]/.test(zone) || zone.charCodeAt(0) === 8722) return `must be ${IANA_ZONE}, not a UTC offset`;
2213
+ const problem = `must be ${IANA_ZONE} that Intl.DateTimeFormat accepts`;
2214
+ if (zone.length > 64 || !/^[a-z]/i.test(zone)) return problem;
2215
+ try {
2216
+ new Intl.DateTimeFormat("en", { timeZone: zone });
2217
+ return null;
2218
+ } catch {
2219
+ return problem;
2220
+ }
2221
+ }
2222
+ function isProvider(value) {
2223
+ if (typeof value !== "object" || value === null) return false;
2224
+ const provider = value;
2225
+ return typeof provider.name === "string" && provider.name !== "" && typeof provider.model === "string" && typeof provider.translate === "function";
2226
+ }
2227
+ /** `defaults.tags` keys: the message tag names FormatJS parses, ASCII only. */
2228
+ const TAG_NAME = /^[A-Za-z][\w.-]*$/;
2229
+ const ATTRIBUTE_NAME = /^[A-Za-z][\w:.-]*$/;
2230
+ /**
2231
+ * Elements whose content HTML does not parse as text: escaped translator
2232
+ * text inside `<script>` or `<style>` would still run (INTERNALS §6 D205).
2233
+ */
2234
+ const RAW_TEXT = /* @__PURE__ */ new Set([
2235
+ "script",
2236
+ "style",
2237
+ "iframe",
2238
+ "noembed",
2239
+ "noframes",
2240
+ "noscript",
2241
+ "plaintext",
2242
+ "xmp"
2243
+ ]);
2244
+ const element = z.string().regex(/^[a-z][a-z\d-]*$/, { error: "an HTML element name in lower case" }).refine((name) => !RAW_TEXT.has(name), { error: "must render its children as text (not script, style, iframe or noscript)" });
2245
+ /** PLAN §5.20: batches of 40–80 items. */
2246
+ const BATCH_SIZE_RANGE = "must be an integer from 40 to 80";
2247
+ const text = z.string().min(1, { error: "must be a non-empty string" });
2248
+ const locale = z.string().superRefine((value, ctx) => {
2249
+ const problem = localeProblem(value);
2250
+ if (problem !== null) ctx.addIssue({
2251
+ code: "custom",
2252
+ message: problem
2253
+ });
2254
+ });
2255
+ const positiveInt = z.number({ error: "must be a positive integer" }).int({ error: "must be a positive integer" }).positive({ error: "must be a positive integer" });
2256
+ const withLocale = z.string().includes("{locale}", { error: "must contain {locale}" });
2257
+ const manifestPath = withLocale.refine((path) => path.indexOf("{locale}", path.indexOf("{locale}") + 1) < 0, { error: "must contain {locale} exactly once" });
2258
+ const routePattern = z.string().startsWith("/", { error: "route patterns start with /" });
2259
+ const strategy = z.enum([
2260
+ "url",
2261
+ "cookie",
2262
+ "header",
2263
+ "localStorage",
2264
+ "default"
2265
+ ]);
2266
+ const level = z.enum([
2267
+ "off",
2268
+ "warn",
2269
+ "error"
2270
+ ]);
2271
+ const outputSchema = z.strictObject({
2272
+ split: z.strictObject({
2273
+ shared: z.enum(["per-set", "single"]).optional(),
2274
+ dupBelow: positiveInt.optional(),
2275
+ minBytes: positiveInt.optional(),
2276
+ maxBundlesPerRoot: positiveInt.nullable().optional()
2277
+ }).optional(),
2278
+ guard: z.enum([
2279
+ "auto",
2280
+ "always",
2281
+ "off"
2282
+ ]).optional(),
2283
+ manifestPath: manifestPath.optional(),
2284
+ manifest: z.enum(["data", "module"]).optional(),
2285
+ baseEntries: z.array(text).optional(),
2286
+ compactIds: z.boolean().optional(),
2287
+ perLocale: z.boolean().optional()
2288
+ });
2289
+ const configSchema = z.strictObject({
2290
+ sourceLocale: locale.optional(),
2291
+ locales: z.array(locale).min(1, { error: "must list at least one locale" }).optional(),
2292
+ pseudoLocales: z.array(z.enum(["en-XA", "ar-XB"])).optional(),
2293
+ fallbacks: z.record(locale, z.array(locale)).optional(),
2294
+ project: z.strictObject({
2295
+ include: z.array(text).optional(),
2296
+ extensions: z.array(z.string().regex(/^\.[^./\\]+$/, { error: "must look like \".tsx\"" })).optional(),
2297
+ exclude: z.array(text).optional()
2298
+ }).optional(),
2299
+ extraction: z.strictObject({
2300
+ importSources: z.array(text).optional(),
2301
+ aliases: z.array(text).optional(),
2302
+ globals: z.array(text).optional(),
2303
+ keep: z.array(text).optional(),
2304
+ dependencies: z.array(z.string().regex(/^(?:@[a-z0-9][\w.~-]*\/)?[a-z0-9][\w.~-]*$/, { error: "must be a package name, like \"@acme/ui\"" })).refine((names) => new Set(names).size === names.length, { error: "lists a package twice" }).optional()
2305
+ }).optional(),
2306
+ ids: z.strictObject({ length: z.number({ error: `must be an integer from ${ID_LENGTH.min} to ${ID_LENGTH.max}` }).int({ error: `must be an integer from ${ID_LENGTH.min} to ${ID_LENGTH.max}` }).min(ID_LENGTH.min, { error: `must be an integer from ${ID_LENGTH.min} to ${ID_LENGTH.max}` }).max(ID_LENGTH.max, { error: `must be an integer from ${ID_LENGTH.min} to ${ID_LENGTH.max}` }).optional() }).optional(),
2307
+ catalogs: z.strictObject({
2308
+ format: z.enum(["po", "json"]).optional(),
2309
+ path: withLocale.optional(),
2310
+ msgid: z.enum(["source", "id"]).optional(),
2311
+ icuFlag: z.boolean().optional()
2312
+ }).refine((catalogs) => !(catalogs.icuFlag === true && catalogs.format === "json"), {
2313
+ error: "is a PO flag; JSON catalogs have no #, line to carry it",
2314
+ path: ["icuFlag"]
2315
+ }).optional(),
2316
+ output: outputSchema.optional(),
2317
+ routing: z.strictObject({
2318
+ strategy: z.array(strategy).optional(),
2319
+ routeStrategies: z.record(routePattern, z.array(strategy)).optional(),
2320
+ url: z.enum([
2321
+ "prefix_except_default",
2322
+ "prefix",
2323
+ "prefix_and_default",
2324
+ "no_prefix"
2325
+ ]).optional(),
2326
+ redirectRoot: z.boolean().optional(),
2327
+ propagate: z.union([z.literal("header"), z.literal(false)], { error: "must be \"header\" or false" }).optional(),
2328
+ switch: z.enum(["in-place", "navigate"]).optional(),
2329
+ cookie: z.strictObject({
2330
+ name: z.string().regex(/^[!#$%&'*+\-.^`|~\w]+$/, { error: "must be a valid cookie name" }).refine((name) => name !== TIME_ZONE_COOKIE, { error: `${JSON.stringify(TIME_ZONE_COOKIE)} is reserved for the time-zone cookie` }).optional(),
2331
+ maxAge: positiveInt.optional()
2332
+ }).optional(),
2333
+ base: z.string().regex(/^\/(?!\/)/, { error: "must be a path starting with \"/\"" }).nullable().optional(),
2334
+ paths: z.record(z.string(), z.record(z.string(), z.string())).optional(),
2335
+ domains: z.record(z.string(), z.string()).optional()
2336
+ }).optional(),
2337
+ ssr: z.strictObject({
2338
+ timeZone: z.string().superRefine((value, ctx) => {
2339
+ const problem = timeZoneProblem(value);
2340
+ if (problem !== null) ctx.addIssue({
2341
+ code: "custom",
2342
+ message: problem
2343
+ });
2344
+ }).optional(),
2345
+ timeZoneFrom: z.record(routePattern, z.enum(["config", "request"])).optional(),
2346
+ payload: z.enum(["bundles", "preload"]).optional(),
2347
+ catalogs: z.enum([
2348
+ "auto",
2349
+ "modules",
2350
+ "assets"
2351
+ ]).optional(),
2352
+ maxLoadedLocales: positiveInt.optional(),
2353
+ normalizeSpaces: z.boolean().optional()
2354
+ }).optional(),
2355
+ ai: z.strictObject({
2356
+ provider: z.custom((value) => value === "gemini" || isProvider(value), { error: "must be \"gemini\" or a provider object with name, model and translate()" }).optional(),
2357
+ model: text.optional(),
2358
+ exclude: z.array(z.string()).optional(),
2359
+ glossary: z.record(text, z.record(z.string(), text)).optional(),
2360
+ doNotTranslate: z.array(text).optional(),
2361
+ tone: z.record(z.string(), z.string()).optional(),
2362
+ description: z.string().optional(),
2363
+ apiKeyEnv: z.string().regex(/^[A-Za-z_]\w*$/, { error: "must be an environment variable name" }).optional(),
2364
+ batchSize: z.number({ error: BATCH_SIZE_RANGE }).int({ error: BATCH_SIZE_RANGE }).min(40, { error: BATCH_SIZE_RANGE }).max(80, { error: BATCH_SIZE_RANGE }).optional(),
2365
+ concurrency: positiveInt.optional(),
2366
+ dev: z.boolean().optional()
2367
+ }).optional(),
2368
+ defaults: z.strictObject({ tags: z.custom((tags) => typeof tags !== "object" || tags === null || !Object.hasOwn(tags, "__proto__"), { error: "\"__proto__\" is not a message tag name" }).pipe(z.record(z.string().regex(TAG_NAME, { error: "a message tag name (letters, digits, _ and -)" }), z.union([element, z.strictObject({
2369
+ element,
2370
+ attributes: z.record(z.string().regex(ATTRIBUTE_NAME, { error: "an HTML attribute name" }), z.string()).optional()
2371
+ })]))).optional() }).optional(),
2372
+ devtools: z.boolean().optional(),
2373
+ strict: z.union([z.boolean(), z.strictObject({
2374
+ missing: level.optional(),
2375
+ invalid: level.optional(),
2376
+ moduleScope: level.optional()
2377
+ })], { error: "must be a boolean or { missing, invalid, moduleScope }" }).optional()
2378
+ });
2379
+ /** `a.b["pt-BR"][0]`: the key path as a user would write it. */
2380
+ function formatPath(path) {
2381
+ let out = "";
2382
+ for (const key of path) if (typeof key === "number") out += `[${key}]`;
2383
+ else if (typeof key === "string" && /^[A-Za-z_$][\w$]*$/.test(key)) out += out === "" ? key : `.${key}`;
2384
+ else out += `[${JSON.stringify(String(key))}]`;
2385
+ return out;
2386
+ }
2387
+ /**
2388
+ * One `<key path>: <problem>` line per zod issue, nested union and key
2389
+ * issues flattened. `label` names the object the paths start from.
2390
+ */
2391
+ function issueLines(issues, prefix = [], label = "") {
2392
+ const lines = [];
2393
+ const line = (path, message) => {
2394
+ const key = formatPath(path);
2395
+ const at = label === "" ? key || "config" : key === "" ? label : `${label}${key.startsWith("[") ? "" : "."}${key}`;
2396
+ lines.push(`${at}: ${message}`);
2397
+ };
2398
+ for (const issue of issues) {
2399
+ const path = [...prefix, ...issue.path];
2400
+ switch (issue.code) {
2401
+ case "unrecognized_keys":
2402
+ for (const key of issue.keys) line([...path, key], "unknown key");
2403
+ break;
2404
+ case "invalid_key":
2405
+ lines.push(...issueLines(issue.issues, path, label));
2406
+ break;
2407
+ case "invalid_union": {
2408
+ const deep = issue.errors.filter((branch) => branch.some((inner) => inner.path.length > 0));
2409
+ if (deep.length === 1) lines.push(...issueLines(deep[0], path, label));
2410
+ else line(path, issue.message);
2411
+ break;
2412
+ }
2413
+ default: line(path, issue.message.replace(/^Invalid (?:input|option): /, ""));
2414
+ }
2415
+ }
2416
+ return lines;
2417
+ }
2418
+ //#endregion
2419
+ //#region src/core/config/routing.ts
2420
+ /** Segments of a validated path as a pathname spells them (`*` and `**` stay). */
2421
+ const segmentsOf = (path) => new URL(path, "http://h").pathname.split("/").slice(1);
2422
+ /**
2423
+ * What is wrong with a `routing.paths` key or value, or null. A route path
2424
+ * as routers spell it: from the root, no empty, `.` or `..` segment, no
2425
+ * query, fragment or backslash, no trailing slash; `*` stands for one whole
2426
+ * segment and a final `**` for any number, and one segment at least is fixed.
2427
+ */
2428
+ function pathProblem(path) {
2429
+ if (!path.startsWith("/")) return "must start with /";
2430
+ if (path === "/") return "the root is not translated";
2431
+ if (/[?#\\]/.test(path)) return "must be a path without ?, # or \\";
2432
+ const segments = path.slice(1).split("/");
2433
+ for (const [i, s] of segments.entries()) {
2434
+ if (s === "") return "must not have empty segments or a trailing slash";
2435
+ if (s === "." || s === "..") return "must not have . or .. segments";
2436
+ if (s.includes("*") && s !== "*" && s !== "**") return "a wildcard is a whole segment: * or **";
2437
+ if (s === "**" && i !== segments.length - 1) return "** must be the last segment";
2438
+ }
2439
+ if (segments.every((s) => s.startsWith("*"))) return "must have a segment that is not a wildcard";
2440
+ return null;
2441
+ }
2442
+ /** The wildcard sequence of a path: what a translation must keep, in order. */
2443
+ const wildcards = (path) => path.split("/").filter((s) => s === "*" || s === "**").join("/");
2444
+ /** Whether some pathname matches both patterns. */
2445
+ function overlap(a, b) {
2446
+ for (let i = 0;; i++) {
2447
+ const x = a[i];
2448
+ const y = b[i];
2449
+ if (x === "**" || y === "**") return true;
2450
+ if (x === void 0 || y === void 0) return x === y;
2451
+ if (x !== y && x !== "*" && y !== "*") return false;
2452
+ }
2453
+ }
2454
+ /** Host and port of a `routing.domains` key in origin form, or null when it is no plain origin. */
2455
+ function domainOrigin(key) {
2456
+ const withScheme = key.includes("://") ? key : "https://" + key;
2457
+ let url;
2458
+ try {
2459
+ url = new URL(withScheme);
2460
+ } catch {
2461
+ return null;
2462
+ }
2463
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
2464
+ if (url.username || url.password || url.search || url.hash || url.pathname !== "/") return null;
2465
+ if (withScheme.replace(/^[a-z]+:\/\//i, "").includes("/")) return null;
2466
+ return url.origin;
2467
+ }
2468
+ /** `routing.domains` with every key as `https://host[:port]` (or its `http:` origin). */
2469
+ function resolveDomains(domains) {
2470
+ const out = {};
2471
+ for (const [key, locale] of Object.entries(domains)) out[domainOrigin(key)] = locale;
2472
+ return out;
2473
+ }
2474
+ /**
2475
+ * Rules between `routing.paths`, `routing.domains` and the other keys. Only
2476
+ * configured locales (never pseudo-locales, which are not advertised) may
2477
+ * have a path or a host.
2478
+ */
2479
+ function routingProblems(routing, sourceLocale, locales) {
2480
+ const problems = [];
2481
+ const url = routing?.url ?? "prefix_except_default";
2482
+ const paths = routing?.paths ?? {};
2483
+ const domains = routing?.domains ?? {};
2484
+ const hasDomains = Object.keys(domains).length > 0;
2485
+ const at = (...keys) => formatPath(["routing", ...keys]);
2486
+ if (hasDomains && url !== "prefix_except_default") problems.push(`${at("domains")}: needs routing.url "prefix_except_default" (a host's locale is its unprefixed one, every other host's prefix is redirected to that host)`);
2487
+ const seen = /* @__PURE__ */ new Map();
2488
+ for (const [key, locale] of Object.entries(domains)) {
2489
+ const origin = domainOrigin(key);
2490
+ if (origin === null) {
2491
+ problems.push(`${at("domains", key)}: must be a host such as "example.de" or an origin such as "http://localhost:5173"`);
2492
+ continue;
2493
+ }
2494
+ const other = seen.get(origin);
2495
+ if (other !== void 0) problems.push(`${at("domains", key)}: the same host as "${other}"`);
2496
+ seen.set(origin, key);
2497
+ if (!locales.includes(locale)) problems.push(`${at("domains", key)}: ${JSON.stringify(locale)} is not in locales`);
2498
+ }
2499
+ if (hasDomains) {
2500
+ const served = new Set(Object.values(domains));
2501
+ for (const l of locales) if (!served.has(l)) problems.push(`${at("domains")}: no host serves ${JSON.stringify(l)} (every locale needs one)`);
2502
+ }
2503
+ const keys = Object.keys(paths);
2504
+ if (keys.length > 0 && url === "no_prefix") problems.push(`${at("paths")}: needs URLs that name their locale (a routing.url other than "no_prefix")`);
2505
+ const valid = [];
2506
+ for (const key of keys) {
2507
+ const problem = pathProblem(key);
2508
+ if (problem !== null) {
2509
+ problems.push(`${at("paths", key)}: ${problem}`);
2510
+ continue;
2511
+ }
2512
+ let ok = true;
2513
+ for (const [locale, value] of Object.entries(paths[key])) {
2514
+ const where = at("paths", key, locale);
2515
+ if (!locales.includes(locale)) {
2516
+ problems.push(`${where}: ${JSON.stringify(locale)} is not in locales`);
2517
+ ok = false;
2518
+ }
2519
+ const p = pathProblem(value);
2520
+ if (p !== null) {
2521
+ problems.push(`${where}: ${p}`);
2522
+ ok = false;
2523
+ } else if (wildcards(value) !== wildcards(key)) {
2524
+ problems.push(`${where}: must keep the wildcards of ${JSON.stringify(key)}, in order`);
2525
+ ok = false;
2526
+ }
2527
+ }
2528
+ if (ok) valid.push(key);
2529
+ }
2530
+ const form = (key, l) => Object.hasOwn(paths[key], l) ? paths[key][l] : key;
2531
+ const seg = new Map(valid.map((k) => [k, segmentsOf(k)]));
2532
+ const segOf = (p) => seg.get(p) ?? segmentsOf(p);
2533
+ const unprefixed = url === "prefix" || url === "no_prefix" ? [] : hasDomains ? locales : [sourceLocale];
2534
+ for (const l of locales) {
2535
+ for (let i = 0; i < valid.length; i++) {
2536
+ const a = valid[i];
2537
+ const fa = form(a, l);
2538
+ for (let j = i + 1; j < valid.length; j++) {
2539
+ const b = valid[j];
2540
+ const fb = form(b, l);
2541
+ const where = at("paths", Object.hasOwn(paths[b], l) ? b : a, l);
2542
+ if (fa === fb) problems.push(`${where}: ${JSON.stringify(a)} and ${JSON.stringify(b)} share the path ${JSON.stringify(fa)} in ${l}`);
2543
+ else if (overlap(segOf(fa), segOf(fb)) && !overlap(segOf(a), segOf(b))) problems.push(`${where}: ${JSON.stringify(fa)} and ${JSON.stringify(fb)} overlap in ${l}, but ${JSON.stringify(a)} and ${JSON.stringify(b)} do not`);
2544
+ }
2545
+ }
2546
+ if (!unprefixed.includes(l)) continue;
2547
+ for (const a of valid) for (const b of valid) {
2548
+ if (a === b || !Object.hasOwn(paths[b], l)) continue;
2549
+ if (overlap(segOf(a), segOf(paths[b][l]))) problems.push(`${at("paths", b, l)}: ${JSON.stringify(paths[b][l])} would read as the route ${JSON.stringify(a)} where ${l} is unprefixed`);
2550
+ }
2551
+ }
2552
+ return problems;
2553
+ }
2554
+ //#endregion
2555
+ //#region src/core/config/load.ts
2556
+ /**
2557
+ * Finding, loading and resolving `vi18n.config.ts` (PLAN §5.7, INTERNALS
2558
+ * §2.7.1, spike S8). The plugin and the CLI both come through `loadConfig`,
2559
+ * so they can never disagree about a project's config.
2560
+ */
2561
+ const JSON_CATALOG_PATH = "locales/{locale}.json";
2562
+ /**
2563
+ * Walks up from `from` to the workspace root (inclusive) and returns the
2564
+ * first `vi18n.config.ts`. Stopping at the workspace root keeps a project
2565
+ * from picking up a config that belongs to an unrelated checkout above it.
2566
+ */
2567
+ async function findConfigFile(from) {
2568
+ let dir = resolve(from);
2569
+ for (;;) {
2570
+ const candidate = join(dir, CONFIG_FILE);
2571
+ if (await isFile(candidate)) return candidate;
2572
+ if (await isWorkspaceRoot(dir)) return null;
2573
+ const parent = dirname(dir);
2574
+ if (parent === dir) return null;
2575
+ dir = parent;
2576
+ }
2577
+ }
2578
+ async function isWorkspaceRoot(dir) {
2579
+ if (await exists(join(dir, ".git"))) return true;
2580
+ if (await isFile(join(dir, "pnpm-workspace.yaml"))) return true;
2581
+ let manifest;
2582
+ try {
2583
+ manifest = JSON.parse((await readFile(join(dir, "package.json"), "utf8")).replace(/^\uFEFF/, ""));
2584
+ } catch {
2585
+ return false;
2586
+ }
2587
+ return typeof manifest === "object" && manifest !== null && manifest.workspaces != null;
2588
+ }
2589
+ async function exists(file) {
2590
+ try {
2591
+ await stat(file);
2592
+ return true;
2593
+ } catch {
2594
+ return false;
2595
+ }
2596
+ }
2597
+ async function isFile(file) {
2598
+ try {
2599
+ return (await stat(file)).isFile();
2600
+ } catch {
2601
+ return false;
2602
+ }
2603
+ }
2604
+ async function loadConfig(options) {
2605
+ const cwd = resolve(options.cwd);
2606
+ let file;
2607
+ if (options.configFile != null) {
2608
+ file = resolve(cwd, options.configFile);
2609
+ if (!await isFile(file)) throw diagnosticError({
2610
+ code: "config-not-found",
2611
+ severity: "error",
2612
+ message: `Config file ${file} not found`,
2613
+ file: toPosix(file)
2614
+ });
2615
+ if (!/\.m?ts$/.test(file)) throw invalid(basename(file), `${basename(file)}: only a TypeScript module (vi18n.config.ts) is supported as a config`);
2616
+ } else file = await findConfigFile(cwd);
2617
+ const inline = options.output === void 0 ? void 0 : parseInlineOutput(options.output);
2618
+ if (file === null) return resolveWith({}, cwd, null, inline);
2619
+ return resolveWith(await importConfig(file), dirname(file), file, inline);
2620
+ }
2621
+ /**
2622
+ * Error codes of a native `import()` that a Vite transform can fix:
2623
+ * non-erasable TypeScript, extensionless or aliased specifiers, `.ts` inside
2624
+ * `node_modules`, JSON without an import attribute. Anything else was thrown
2625
+ * by the config's own code and would only be thrown again.
2626
+ */
2627
+ const TRANSFORMABLE = /* @__PURE__ */ new Set([
2628
+ "ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX",
2629
+ "ERR_INVALID_TYPESCRIPT_SYNTAX",
2630
+ "ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING",
2631
+ "ERR_UNKNOWN_FILE_EXTENSION",
2632
+ "ERR_MODULE_NOT_FOUND",
2633
+ "ERR_UNSUPPORTED_DIR_IMPORT",
2634
+ "ERR_PACKAGE_IMPORT_NOT_DEFINED",
2635
+ "ERR_IMPORT_ATTRIBUTE_MISSING",
2636
+ "ERR_IMPORT_ASSERTION_TYPE_MISSING"
2637
+ ]);
2638
+ function needsTransform(error) {
2639
+ if (!(error instanceof Error) || thrownByEvaluatedCode(error)) return false;
2640
+ const code = error.code;
2641
+ if (typeof code === "string") return TRANSFORMABLE.has(code);
2642
+ return error.name === "SyntaxError";
2643
+ }
2644
+ /**
2645
+ * Parse, resolution and link errors are raised by Node's loader before any
2646
+ * module body runs, so all their synchronous frames are `node:` internals.
2647
+ * An error with a synchronous frame in a `file:` module was thrown while the
2648
+ * config or one of its imports ran: retrying would run their side effects a
2649
+ * second time and throw again (a runtime `JSON.parse` SyntaxError, say).
2650
+ * `at async` frames are skipped because they are callers such as this loader.
2651
+ */
2652
+ function thrownByEvaluatedCode(error) {
2653
+ return /^\s*at (?!async )(?:.*\()?file:\/\//m.test(error.stack ?? "");
2654
+ }
2655
+ /**
2656
+ * Configs this process has already imported natively. Node caches ESM by URL
2657
+ * for the life of the process and the `?t=` query busts only the config
2658
+ * itself, so a reload would see stale versions of the modules it imports;
2659
+ * the runner evaluates the whole graph afresh, so reloads go through it.
2660
+ */
2661
+ const importedNatively = /* @__PURE__ */ new Set();
2662
+ async function importConfig(file) {
2663
+ const rel = basename(file);
2664
+ let module;
2665
+ const vite = importedNatively.has(file) ? await loadVite() : null;
2666
+ if (vite !== null) module = await runnerImport(vite, file, rel);
2667
+ else {
2668
+ importedNatively.add(file);
2669
+ try {
2670
+ const { mtimeMs } = await stat(file);
2671
+ module = await import(pathToFileURL(file).href + "?t=" + Math.trunc(mtimeMs * 1e3));
2672
+ } catch (nativeError) {
2673
+ if (!needsTransform(nativeError)) throw loadError(rel, nativeError);
2674
+ const fallback = await loadVite();
2675
+ if (fallback === null) throw invalid(rel, `${rel} needs a transform Node cannot apply (${firstLine(nativeError)}); install vite or use erasable TypeScript only`);
2676
+ module = await runnerImport(fallback, file, rel);
2677
+ }
2678
+ }
2679
+ if (!("default" in module) || module.default === void 0) throw invalid(rel, `${rel} has no default export; export default defineConfig({ … })`);
2680
+ try {
2681
+ return await (typeof module.default === "function" ? module.default() : module.default);
2682
+ } catch (error) {
2683
+ throw loadError(rel, error);
2684
+ }
2685
+ }
2686
+ async function loadVite() {
2687
+ try {
2688
+ return await import("vite");
2689
+ } catch {
2690
+ return null;
2691
+ }
2692
+ }
2693
+ async function runnerImport(vite, file, rel) {
2694
+ try {
2695
+ const { module } = await vite.runnerImport(file, {
2696
+ root: dirname(file),
2697
+ logLevel: "silent"
2698
+ });
2699
+ return module;
2700
+ } catch (error) {
2701
+ throw loadError(rel, error);
2702
+ }
2703
+ }
2704
+ function loadError(rel, cause) {
2705
+ const error = invalid(rel, `${rel} could not be loaded: ${firstLine(cause)}`);
2706
+ error.cause = cause;
2707
+ return error;
2708
+ }
2709
+ function firstLine(error) {
2710
+ return (error instanceof Error ? error.message : String(error)).split("\n", 1)[0].replace(/\.$/, "");
2711
+ }
2712
+ function invalid(file, message) {
2713
+ return diagnosticError({
2714
+ code: "config-invalid",
2715
+ severity: "error",
2716
+ message,
2717
+ ...file === void 0 ? {} : { file }
2718
+ });
2719
+ }
2720
+ function parseInlineOutput(output) {
2721
+ const result = outputSchema.safeParse(output);
2722
+ if (result.success) return result.data;
2723
+ throw invalid(void 0, issueLines(result.error.issues, [], "vi18n({ output })").join("; "));
2724
+ }
2725
+ /**
2726
+ * `inline` over the file's already validated `output`, `split` merged key by
2727
+ * key. Merging after validation means an inline value can never hide an
2728
+ * invalid one in the file, which the CLI (no inline options) would reject.
2729
+ */
2730
+ function withOutput(file, inline) {
2731
+ const merged = mergeDefined(file ?? {}, inline);
2732
+ if (file?.split !== void 0 && inline.split !== void 0) merged.split = mergeDefined(file.split, inline.split);
2733
+ return merged;
2734
+ }
2735
+ function mergeDefined(base, over) {
2736
+ const out = { ...base };
2737
+ for (const key of Object.keys(over)) if (over[key] !== void 0) out[key] = over[key];
2738
+ return out;
2739
+ }
2740
+ function resolveWith(input, root, configFile, inline) {
2741
+ root = resolve(root);
2742
+ const file = configFile === null ? void 0 : toPosix(relativeOrAbsolute(root, configFile));
2743
+ const result = configSchema.safeParse(input);
2744
+ if (!result.success) throw invalid(file, issueLines(result.error.issues).join("; "));
2745
+ const user = inline === void 0 ? result.data : {
2746
+ ...result.data,
2747
+ output: withOutput(result.data.output, inline)
2748
+ };
2749
+ const sourceLocale = user.sourceLocale ?? DEFAULT_CONFIG.sourceLocale;
2750
+ const locales = user.locales ? [...user.locales] : [sourceLocale];
2751
+ const problems = crossFieldProblems(user, sourceLocale, locales);
2752
+ if (problems.length > 0) throw invalid(file, problems.join("; "));
2753
+ const d = DEFAULT_CONFIG;
2754
+ const format = user.catalogs?.format ?? d.catalogs.format;
2755
+ const catalogPath = user.catalogs?.path ?? (format === "json" ? JSON_CATALOG_PATH : d.catalogs.path);
2756
+ const provider = user.ai?.provider ?? d.ai.provider;
2757
+ return {
2758
+ root,
2759
+ configFile: configFile === null ? null : resolve(configFile),
2760
+ sourceLocale,
2761
+ locales,
2762
+ pseudoLocales: [...new Set(user.pseudoLocales ?? d.pseudoLocales)],
2763
+ fallbacks: copyRecord(user.fallbacks ?? d.fallbacks, (chain) => [...chain]),
2764
+ project: {
2765
+ include: (user.project?.include ?? d.project.include).map((entry) => resolve(root, entry)),
2766
+ extensions: [...user.project?.extensions ?? d.project.extensions],
2767
+ exclude: [...user.project?.exclude ?? d.project.exclude]
2768
+ },
2769
+ extraction: {
2770
+ importSources: [.../* @__PURE__ */ new Set([...DEFAULT_IMPORT_SOURCES, ...user.extraction?.importSources ?? []])],
2771
+ aliases: [...user.extraction?.aliases ?? d.extraction.aliases],
2772
+ globals: [...user.extraction?.globals ?? d.extraction.globals],
2773
+ keep: [...user.extraction?.keep ?? d.extraction.keep],
2774
+ dependencies: [...user.extraction?.dependencies ?? d.extraction.dependencies]
2775
+ },
2776
+ ids: { length: user.ids?.length ?? d.ids.length },
2777
+ catalogs: {
2778
+ format,
2779
+ path: resolve(root, catalogPath),
2780
+ msgid: user.catalogs?.msgid ?? d.catalogs.msgid,
2781
+ icuFlag: user.catalogs?.icuFlag ?? d.catalogs.icuFlag
2782
+ },
2783
+ output: resolveOutput(user.output),
2784
+ routing: {
2785
+ strategy: [...user.routing?.strategy ?? d.routing.strategy],
2786
+ routeStrategies: copyRecord(user.routing?.routeStrategies ?? d.routing.routeStrategies, (chain) => [...chain]),
2787
+ url: user.routing?.url ?? d.routing.url,
2788
+ redirectRoot: user.routing?.redirectRoot ?? d.routing.redirectRoot,
2789
+ propagate: user.routing?.propagate ?? d.routing.propagate,
2790
+ switch: user.routing?.switch ?? d.routing.switch,
2791
+ cookie: {
2792
+ name: user.routing?.cookie?.name ?? d.routing.cookie.name,
2793
+ maxAge: user.routing?.cookie?.maxAge ?? d.routing.cookie.maxAge
2794
+ },
2795
+ base: user.routing?.base ?? d.routing.base,
2796
+ paths: copyRecord(user.routing?.paths ?? d.routing.paths, (entry) => ({ ...entry })),
2797
+ domains: resolveDomains(user.routing?.domains ?? d.routing.domains)
2798
+ },
2799
+ ssr: {
2800
+ timeZone: user.ssr?.timeZone ?? d.ssr.timeZone,
2801
+ timeZoneFrom: copyRecord(user.ssr?.timeZoneFrom ?? d.ssr.timeZoneFrom, (v) => v),
2802
+ payload: user.ssr?.payload ?? d.ssr.payload,
2803
+ catalogs: user.ssr?.catalogs ?? d.ssr.catalogs,
2804
+ maxLoadedLocales: user.ssr?.maxLoadedLocales ?? d.ssr.maxLoadedLocales,
2805
+ normalizeSpaces: user.ssr?.normalizeSpaces ?? d.ssr.normalizeSpaces
2806
+ },
2807
+ ai: {
2808
+ provider,
2809
+ model: user.ai?.model ?? (typeof provider === "string" ? d.ai.model : provider.model),
2810
+ exclude: [...user.ai?.exclude ?? d.ai.exclude],
2811
+ glossary: copyRecord(user.ai?.glossary ?? d.ai.glossary, (targets) => copyRecord(targets, (v) => v)),
2812
+ doNotTranslate: [...user.ai?.doNotTranslate ?? d.ai.doNotTranslate],
2813
+ tone: copyRecord(user.ai?.tone ?? d.ai.tone, (v) => v),
2814
+ description: user.ai?.description ?? d.ai.description,
2815
+ apiKeyEnv: user.ai?.apiKeyEnv ?? d.ai.apiKeyEnv,
2816
+ batchSize: user.ai?.batchSize ?? d.ai.batchSize,
2817
+ concurrency: user.ai?.concurrency ?? d.ai.concurrency,
2818
+ dev: user.ai?.dev ?? d.ai.dev
2819
+ },
2820
+ defaults: { tags: copyRecord(user.defaults?.tags ?? d.defaults.tags, (spec) => typeof spec === "string" ? spec : {
2821
+ element: spec.element,
2822
+ attributes: { ...spec.attributes }
2823
+ }) },
2824
+ devtools: user.devtools ?? d.devtools,
2825
+ strict: resolveStrict(user.strict)
2826
+ };
2827
+ }
2828
+ /** Rules between keys, checked once the locale list is known. */
2829
+ function crossFieldProblems(user, sourceLocale, locales) {
2830
+ const problems = [];
2831
+ const configured = /* @__PURE__ */ new Set();
2832
+ locales.forEach((tag, index) => {
2833
+ if (configured.has(tag)) problems.push(`locales[${index}]: ${JSON.stringify(tag)} is listed twice`);
2834
+ configured.add(tag);
2835
+ });
2836
+ if (!configured.has(sourceLocale)) problems.push(`sourceLocale: ${JSON.stringify(sourceLocale)} is not in locales (add it to locales)`);
2837
+ for (const [tag, chain] of Object.entries(user.fallbacks ?? {})) chain.forEach((target, index) => {
2838
+ if (!configured.has(target)) problems.push(`${formatPath([
2839
+ "fallbacks",
2840
+ tag,
2841
+ index
2842
+ ])}: ${JSON.stringify(target)} is not in locales`);
2843
+ });
2844
+ (user.pseudoLocales ?? []).forEach((tag, index) => {
2845
+ if (configured.has(tag)) problems.push(`pseudoLocales[${index}]: ${JSON.stringify(tag)} is also in locales`);
2846
+ });
2847
+ const idLength = user.ids?.length ?? DEFAULT_CONFIG.ids.length;
2848
+ (user.ai?.exclude ?? []).forEach((entry, index) => {
2849
+ if (excludeKind(entry, locales, idLength) === null) problems.push(`ai.exclude[${index}]: ${JSON.stringify(entry)} is not a configured locale, a message id or a file glob (a glob contains / or *)`);
2850
+ });
2851
+ problems.push(...routingProblems(user.routing, sourceLocale, locales));
2852
+ const languages = new Set(locales.map((tag) => tag.split("-")[0]));
2853
+ for (const [term, targets] of Object.entries(user.ai?.glossary ?? {})) for (const tag of Object.keys(targets)) if (!configured.has(tag) && !languages.has(tag)) problems.push(`${formatPath([
2854
+ "ai",
2855
+ "glossary",
2856
+ term,
2857
+ tag
2858
+ ])}: ${JSON.stringify(tag)} is not in locales (nor the language of one)`);
2859
+ return problems;
2860
+ }
2861
+ function resolveOutput(user) {
2862
+ const d = DEFAULT_CONFIG.output;
2863
+ return {
2864
+ split: {
2865
+ shared: user?.split?.shared ?? d.split.shared,
2866
+ dupBelow: user?.split?.dupBelow ?? d.split.dupBelow,
2867
+ minBytes: user?.split?.minBytes ?? d.split.minBytes,
2868
+ maxBundlesPerRoot: user?.split?.maxBundlesPerRoot === void 0 ? d.split.maxBundlesPerRoot : user.split.maxBundlesPerRoot
2869
+ },
2870
+ guard: user?.guard ?? d.guard,
2871
+ manifestPath: user?.manifestPath ?? d.manifestPath,
2872
+ manifest: user?.manifest ?? d.manifest,
2873
+ baseEntries: [...user?.baseEntries ?? d.baseEntries],
2874
+ compactIds: user?.compactIds ?? d.compactIds,
2875
+ perLocale: user?.perLocale ?? d.perLocale
2876
+ };
2877
+ }
2878
+ function resolveStrict(strict) {
2879
+ const d = DEFAULT_CONFIG.strict;
2880
+ if (strict === true) return {
2881
+ missing: "error",
2882
+ invalid: "error",
2883
+ moduleScope: "error"
2884
+ };
2885
+ if (strict === void 0 || strict === false) return { ...d };
2886
+ const pick = (value, fallback) => value ?? fallback;
2887
+ return {
2888
+ missing: pick(strict.missing, d.missing),
2889
+ invalid: pick(strict.invalid, d.invalid),
2890
+ moduleScope: pick(strict.moduleScope, d.moduleScope)
2891
+ };
2892
+ }
2893
+ /**
2894
+ * A fresh copy, so resolved configs never share arrays with the input or the
2895
+ * defaults. `fromEntries` defines properties, so a `__proto__` key stays a key.
2896
+ */
2897
+ function copyRecord(record, copy) {
2898
+ return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, copy(value)]));
2899
+ }
2900
+ function relativeOrAbsolute(root, file) {
2901
+ const rel = relative(root, resolve(file));
2902
+ return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? resolve(file) : rel;
2903
+ }
2904
+ function toPosix(path) {
2905
+ return sep === "/" ? path : path.split(sep).join("/");
2906
+ }
2907
+ //#endregion
2908
+ //#region src/test/load.ts
2909
+ /**
2910
+ * The Node half of `loadCatalog`: the config and every catalog file, compiled
2911
+ * for one locale by `compileLocales`, the function the build, dev and
2912
+ * `vi18n compile` read translations through. The messages are the ids the
2913
+ * catalogs hold (as `extraction.keep` ids are found), so the result depends
2914
+ * on the files only, never on which modules a test run transformed.
2915
+ */
2916
+ async function compileCatalog(locale, root, configFile) {
2917
+ const config = await loadConfig({
2918
+ cwd: root,
2919
+ configFile
2920
+ });
2921
+ if (locale === "en-XA" || locale === "ar-XB") throw new RangeError(`vi18n: ${locale} is a pseudo-locale and has no catalog; use createTestI18n({ mode: "pseudo" })`);
2922
+ if (!config.locales.includes(locale)) throw new RangeError(`vi18n: ${locale} is not a configured locale (${config.locales.join(", ")}) in ${config.configFile ?? config.root}`);
2923
+ const codec = createCodec(config.ids.length);
2924
+ const { set } = await loadCatalogSet(config, codec);
2925
+ const layout = catalogLayout(config);
2926
+ const keep = /* @__PURE__ */ new Set();
2927
+ for (const loaded of set.values()) for (const entry of loaded.catalog?.entries ?? []) try {
2928
+ const key = entryKey(entry, layout, codec);
2929
+ if (key !== null) keep.add(key);
2930
+ } catch (error) {
2931
+ if (!isDiagnosticError(error)) throw error;
2932
+ }
2933
+ const result = compileLocales({
2934
+ config,
2935
+ codec,
2936
+ messages: /* @__PURE__ */ new Map(),
2937
+ keep: [...keep],
2938
+ catalogs: set,
2939
+ locales: [locale],
2940
+ sourceFallback: false
2941
+ }).get(locale);
2942
+ const messages = /* @__PURE__ */ new Map();
2943
+ for (const [id, message] of result.messages) if (result.status.get(id)?.from !== config.sourceLocale) messages.set(id, message);
2944
+ return {
2945
+ locale,
2946
+ messages
2947
+ };
2948
+ }
2949
+ //#endregion
2950
+ export { compileCatalog };