@menapo/vi18n 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +49 -0
- package/README.md +110 -2
- package/dist/apply-Bre-lY9J.js +228 -0
- package/dist/captured-Bj8bO6vd.js +1034 -0
- package/dist/check-BIKEuu6B.js +201 -0
- package/dist/check-HsmmpJZg.js +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +41 -0
- package/dist/codec-DuvU2VQy.js +207 -0
- package/dist/collect-Cx4_SKOe.js +13 -0
- package/dist/collisions-CfpZU8e4.js +217 -0
- package/dist/compile-BaDh0cHG.js +245 -0
- package/dist/compile-Cc97JaW2.js +2502 -0
- package/dist/concurrency-C9UUWuDC.js +96 -0
- package/dist/concurrency-DONL7t4V.js +1 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +39 -0
- package/dist/dir-zw6bGMKQ.js +290 -0
- package/dist/duplicates-yuRljQ6h.js +185 -0
- package/dist/engine-C2ENBRJe.js +531 -0
- package/dist/engine.d.ts +43 -0
- package/dist/engine.js +1241 -0
- package/dist/exclude-Du-Zd_qI.js +26 -0
- package/dist/export-C9Sy6UWv.js +103 -0
- package/dist/extract-C3S8btx0.js +298 -0
- package/dist/formats-9unqj_fZ.js +781 -0
- package/dist/i18next-9HAoxwUI.js +1 -0
- package/dist/i18next-BEJgv_S8.js +1596 -0
- package/dist/import-nANvQutw.js +225 -0
- package/dist/index.d.ts +156 -0
- package/dist/index.js +239 -0
- package/dist/init-C2ScuW6a.js +792 -0
- package/dist/library-D5CWvTQs.js +1882 -0
- package/dist/lingui-zUiCl9Um.js +833 -0
- package/dist/lint.d.ts +105 -0
- package/dist/lint.js +971 -0
- package/dist/load-CQRz4PpM.js +2950 -0
- package/dist/load-gW-oJt1Q.js +747 -0
- package/dist/locales-DX4MOBFE.js +827 -0
- package/dist/lsp-RMMMc88P.js +31 -0
- package/dist/match-BGr8EXB3.js +89 -0
- package/dist/mcp-CHVwBoRP.js +28 -0
- package/dist/merge-driver-BTX87aVm.js +422 -0
- package/dist/messages-BfjIExyB.js +166 -0
- package/dist/migrate-IpLDMXmq.js +1484 -0
- package/dist/migrate-rCQuOGsf.js +1 -0
- package/dist/openai-CTaMDcl-.js +346 -0
- package/dist/output-DWE3SIUA.js +164 -0
- package/dist/parse.d.ts +62 -0
- package/dist/parse.js +156 -0
- package/dist/pipeline-C9Dbotkh.js +2031 -0
- package/dist/pipeline-DbX6rlo5.js +1 -0
- package/dist/project-Bw9UPIdx.js +1046 -0
- package/dist/project-C4GpaN-n.js +381 -0
- package/dist/pseudo-DRv30iN5.js +145 -0
- package/dist/raw-C4Y6vyGY.js +4708 -0
- package/dist/react-intl-BP0u-D5m.js +519 -0
- package/dist/restore-CiYv-jxS.js +175 -0
- package/dist/rewrite-EsIw_lsq.js +803 -0
- package/dist/rich-DCUKl5HM.js +97 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/run-BEcnBbu-.js +694 -0
- package/dist/runtime-DTMmliT1.js +64 -0
- package/dist/scan-worker.d.ts +62 -0
- package/dist/scan-worker.js +1 -0
- package/dist/scoped-BMYpSthj.js +44 -0
- package/dist/server-Da1Ot9IN.js +10485 -0
- package/dist/server-deBOq2vq.js +11823 -0
- package/dist/server.d.ts +169 -0
- package/dist/server.js +1234 -0
- package/dist/sitemap-DJXzKoza.js +159 -0
- package/dist/sources-D19czDSA.js +2242 -0
- package/dist/status-zr4jY59Y.js +149 -0
- package/dist/stdio-CVaJfxA7.js +29 -0
- package/dist/stdio-FPOKQAgu.js +95 -0
- package/dist/sync-B9V2KFwb.js +658 -0
- package/dist/t-DC-c9zE5.js +454 -0
- package/dist/test.d.ts +116 -0
- package/dist/test.js +319 -0
- package/dist/translate-CQnJfLjO.js +299 -0
- package/dist/ts-BaU5yOf0.js +4863 -0
- package/dist/types-CgUCkFU1.d.ts +707 -0
- package/dist/types-D3QGM8XO.js +146 -0
- package/dist/types-DsdWBG0b.d.ts +558 -0
- package/dist/types-DtvQ7UdD.js +1948 -0
- package/dist/types-wSh9fCBY.js +11 -0
- package/dist/units-CQ5Q99RC.js +150 -0
- package/dist/url-CRfCOhUI.js +172 -0
- package/dist/url-CsBkRaJy.js +225 -0
- package/dist/validate-BxpupdAf.js +2615 -0
- package/dist/validate-D6DJepZv.js +365 -0
- package/dist/version-Byqi2guX.js +28 -0
- package/dist/vite-scan-CT_vsp4g.js +242 -0
- package/dist/vite.d.ts +34 -0
- package/dist/vite.js +7558 -0
- package/dist/vue-i18n-g0iGy6CZ.js +651 -0
- package/dist/which-BOemZ5Q8.js +116 -0
- package/dist/worker-84U6uYB3.js +1656 -0
- package/package.json +97 -3
- 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 };
|