@menapo/vi18n 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-D34P-S--.js +1 -0
- package/dist/check-DyrAbv73.js +201 -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-B5Mz6WJm.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-B7p40M__.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-DGsMbcTZ.js +103 -0
- package/dist/extract-Dujlsgnk.js +298 -0
- package/dist/formats-KPIg-08V.js +781 -0
- package/dist/i18next-CrUnKFfh.js +1596 -0
- package/dist/i18next-Rgo-v9l0.js +1 -0
- package/dist/import-Cwdhcywt.js +225 -0
- package/dist/index.d.ts +156 -0
- package/dist/index.js +239 -0
- package/dist/init-BecCTvKi.js +792 -0
- package/dist/library-D5CWvTQs.js +1882 -0
- package/dist/lingui--PcOOYry.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-Cq1Nuu-Q.js +31 -0
- package/dist/match-BGr8EXB3.js +89 -0
- package/dist/mcp-Cf0T7qPW.js +28 -0
- package/dist/merge-driver-CpiiSVIc.js +422 -0
- package/dist/messages-Cxa-5tR0.js +166 -0
- package/dist/migrate-CntqvrW4.js +1 -0
- package/dist/migrate-dPgq9uzy.js +1484 -0
- package/dist/openai-CTaMDcl-.js +346 -0
- package/dist/output-DsaHKHEt.js +164 -0
- package/dist/parse.d.ts +62 -0
- package/dist/parse.js +156 -0
- package/dist/pipeline-C7g11THL.js +2031 -0
- package/dist/pipeline-wxS0SApG.js +1 -0
- package/dist/project-Dwx7iPm6.js +381 -0
- package/dist/project-pXipT8iG.js +1046 -0
- package/dist/pseudo-DRv30iN5.js +145 -0
- package/dist/raw-C4Y6vyGY.js +4708 -0
- package/dist/react-intl-CaBVwJ9u.js +519 -0
- package/dist/restore-Btq66Tid.js +175 -0
- package/dist/rewrite-CiTRAICl.js +803 -0
- package/dist/rich-DCUKl5HM.js +97 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/run-DYD_VXKy.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-BDkykMjb.js +11823 -0
- package/dist/server-qv3mz70d.js +10485 -0
- package/dist/server.d.ts +169 -0
- package/dist/server.js +1234 -0
- package/dist/sitemap-DV3o8QD7.js +159 -0
- package/dist/sources-D19czDSA.js +2242 -0
- package/dist/status-DZArW5aE.js +149 -0
- package/dist/stdio-CVaJfxA7.js +29 -0
- package/dist/stdio-FPOKQAgu.js +95 -0
- package/dist/sync-CcVzger_.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-DZR3MkSA.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-BlyLGlOa.js +28 -0
- package/dist/vite-scan-B_YW4X_a.js +242 -0
- package/dist/vite.d.ts +34 -0
- package/dist/vite.js +7558 -0
- package/dist/vue-i18n-CEMnpv7k.js +651 -0
- package/dist/which-C6PFI1Dm.js +116 -0
- package/dist/worker-84U6uYB3.js +1656 -0
- package/package.json +97 -3
- package/schemas/catalog.json +57 -0
|
@@ -0,0 +1,1882 @@
|
|
|
1
|
+
import { a as diagnosticError, o as isDiagnosticError } from "./types-D3QGM8XO.js";
|
|
2
|
+
import { r as validateTranslation } from "./validate-BxpupdAf.js";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { hash, randomBytes } from "node:crypto";
|
|
5
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
6
|
+
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
//#region src/core/extract/dependencies.ts
|
|
8
|
+
/**
|
|
9
|
+
* `extraction.dependencies` (PLAN §4.5, §5.24; INTERNALS §2.5.7): published
|
|
10
|
+
* packages the app opts in. Each name resolves from the config root the way
|
|
11
|
+
* Node resolves a package directory (`node_modules/<name>` in the root or a
|
|
12
|
+
* directory above it), by realpath, which is what Vite hands the plugin under
|
|
13
|
+
* pnpm's links. Their files are extracted and rewritten like the app's,
|
|
14
|
+
* except the package's own `node_modules`, tests and declarations, and their
|
|
15
|
+
* catalog references read `<name>/<path in the package>`, the same on every
|
|
16
|
+
* machine and package manager and across the package's versions.
|
|
17
|
+
*/
|
|
18
|
+
/** As `files.ts` has it (which imports this module). */
|
|
19
|
+
const toPosix = (path) => sep === "/" ? path : path.replaceAll("\\", "/");
|
|
20
|
+
const resolved = /* @__PURE__ */ new WeakMap();
|
|
21
|
+
/** Directories and names never extracted inside a package. */
|
|
22
|
+
const SKIPPED_DIRS = /* @__PURE__ */ new Set([
|
|
23
|
+
"node_modules",
|
|
24
|
+
"__tests__",
|
|
25
|
+
".git"
|
|
26
|
+
]);
|
|
27
|
+
const TEST_FILE = /\.(?:test|spec)\.[^/]*$/;
|
|
28
|
+
const DECLARATION = /\.d\.[cm]?ts$/;
|
|
29
|
+
/**
|
|
30
|
+
* Every `extraction.dependencies` package, resolved once per config. A
|
|
31
|
+
* name that does not resolve is a `config-invalid` error: its strings would
|
|
32
|
+
* otherwise ship untranslated and unreported.
|
|
33
|
+
*/
|
|
34
|
+
function dependenciesOf(config) {
|
|
35
|
+
let list = resolved.get(config.extraction);
|
|
36
|
+
if (list !== void 0) return list;
|
|
37
|
+
list = [];
|
|
38
|
+
for (const name of config.extraction.dependencies) {
|
|
39
|
+
const dir = packageDir(config.root, name);
|
|
40
|
+
if (dir === null) throw diagnosticError({
|
|
41
|
+
code: "config-invalid",
|
|
42
|
+
severity: "error",
|
|
43
|
+
message: `extraction.dependencies: ${name} is not installed (no node_modules/${name} from ${toPosix(config.root)} up); add it to the app's dependencies`
|
|
44
|
+
});
|
|
45
|
+
list.push({
|
|
46
|
+
name,
|
|
47
|
+
dir,
|
|
48
|
+
messages: messagesFile(dir)
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
resolved.set(config.extraction, list);
|
|
52
|
+
return list;
|
|
53
|
+
}
|
|
54
|
+
/** The package directory as Node finds it from `root`, by realpath; null when not installed. */
|
|
55
|
+
function packageDir(root, name) {
|
|
56
|
+
let dir = resolve(root);
|
|
57
|
+
for (;;) {
|
|
58
|
+
const candidate = join(dir, "node_modules", name);
|
|
59
|
+
if (existsSync(join(candidate, "package.json"))) return toPosix(realpathSync(candidate)).replace(/\/+$/, "");
|
|
60
|
+
const parent = dirname(dir);
|
|
61
|
+
if (parent === dir) return null;
|
|
62
|
+
dir = parent;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** `package.json#vi18n.messages`, absolute; null without one (or an unreadable manifest). */
|
|
66
|
+
function messagesFile(dir) {
|
|
67
|
+
try {
|
|
68
|
+
const path = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).vi18n?.messages;
|
|
69
|
+
return typeof path === "string" && path !== "" ? toPosix(resolve(dir, path)) : null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The opted-in package `file` belongs to, when vi18n extracts it: under the
|
|
76
|
+
* package directory (a `?query` or `\0` prefix ignored), with a scanned
|
|
77
|
+
* extension, outside the package's `node_modules` and tests, not a
|
|
78
|
+
* declaration file.
|
|
79
|
+
*/
|
|
80
|
+
function dependencyOf(file, config) {
|
|
81
|
+
if (config.extraction.dependencies.length === 0) return null;
|
|
82
|
+
let path = file.startsWith("\0") ? file.slice(1) : file;
|
|
83
|
+
const query = path.indexOf("?");
|
|
84
|
+
if (query !== -1) path = path.slice(0, query);
|
|
85
|
+
path = toPosix(path);
|
|
86
|
+
for (const dependency of dependenciesOf(config)) {
|
|
87
|
+
if (!path.startsWith(dependency.dir + "/")) continue;
|
|
88
|
+
return extracted(path.slice(dependency.dir.length + 1), config) ? dependency : null;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
function extracted(rel, config) {
|
|
93
|
+
const segments = rel.split("/");
|
|
94
|
+
const name = segments[segments.length - 1];
|
|
95
|
+
if (DECLARATION.test(name) || TEST_FILE.test(name)) return false;
|
|
96
|
+
if (!config.project.extensions.some((ext) => name.endsWith(ext) && name.length > ext.length)) return false;
|
|
97
|
+
for (let i = 0; i < segments.length - 1; i++) if (SKIPPED_DIRS.has(segments[i])) return false;
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
/** `<name>/<path in the package>` for a file of an opted-in package, else null. */
|
|
101
|
+
function dependencyRef(file, config) {
|
|
102
|
+
if (config.extraction.dependencies.length === 0) return null;
|
|
103
|
+
const path = toPosix(file);
|
|
104
|
+
for (const dependency of dependenciesOf(config)) if (path.startsWith(dependency.dir + "/")) return dependency.name + path.slice(dependency.dir.length);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/** Every extracted file of the opted-in packages, by realpath, sorted by code unit. */
|
|
108
|
+
async function listDependencyFiles(config) {
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const dependency of dependenciesOf(config)) {
|
|
111
|
+
const walk = async (dir, rel) => {
|
|
112
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
113
|
+
const path = rel === "" ? entry.name : rel + "/" + entry.name;
|
|
114
|
+
if (entry.isDirectory()) {
|
|
115
|
+
if (!SKIPPED_DIRS.has(entry.name)) await walk(join(dir, entry.name), path);
|
|
116
|
+
} else if (entry.isFile() && extracted(path, config)) out.push(dependency.dir + "/" + path);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
await walk(dependency.dir, "");
|
|
120
|
+
}
|
|
121
|
+
return [...new Set(out)].sort();
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
//#region src/core/catalog/validity.ts
|
|
125
|
+
function sourceMatches(entry, layout, message, codec) {
|
|
126
|
+
if (layout === "id") return entry.label !== null && entry.label === message.label;
|
|
127
|
+
const source = entry.source;
|
|
128
|
+
if (source === null) return false;
|
|
129
|
+
if (source === message.canonical) return true;
|
|
130
|
+
return entryCanonical(entry, codec) === message.canonical;
|
|
131
|
+
}
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/core/catalog/model.ts
|
|
134
|
+
/**
|
|
135
|
+
* The catalog model shared by every reader, writer and consumer (INTERNALS
|
|
136
|
+
* §2.6.2, §2.6.5, §2.6.7): how an entry is keyed, how entries sort, and when
|
|
137
|
+
* a translation may ship. Formats (`po.ts`, `json.ts`) and files (`io.ts`)
|
|
138
|
+
* build on these; nothing here touches the file system.
|
|
139
|
+
*/
|
|
140
|
+
function emptyCatalog(locale, layout, header) {
|
|
141
|
+
return {
|
|
142
|
+
locale,
|
|
143
|
+
layout,
|
|
144
|
+
header: {
|
|
145
|
+
language: locale,
|
|
146
|
+
idScheme: header.idScheme,
|
|
147
|
+
sourceLocale: header.sourceLocale,
|
|
148
|
+
aiModel: null,
|
|
149
|
+
extra: []
|
|
150
|
+
},
|
|
151
|
+
entries: [],
|
|
152
|
+
obsolete: [],
|
|
153
|
+
droppedObsolete: 0
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* A blank entry with every field present, so sync and tests spell out only
|
|
158
|
+
* what differs and new fields of `CatalogEntry` get a default in one place.
|
|
159
|
+
*/
|
|
160
|
+
function createEntry(fields = {}) {
|
|
161
|
+
return {
|
|
162
|
+
manualId: null,
|
|
163
|
+
foreignContext: null,
|
|
164
|
+
label: null,
|
|
165
|
+
source: null,
|
|
166
|
+
translation: "",
|
|
167
|
+
state: "untranslated",
|
|
168
|
+
fuzzy: false,
|
|
169
|
+
contexts: [],
|
|
170
|
+
comments: [],
|
|
171
|
+
refs: [],
|
|
172
|
+
maxLength: null,
|
|
173
|
+
flags: [],
|
|
174
|
+
line: 0,
|
|
175
|
+
wrapped: false,
|
|
176
|
+
...fields
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const canonicals = /* @__PURE__ */ new WeakMap();
|
|
180
|
+
/**
|
|
181
|
+
* `codec.canonicalize(entry.source)`, memoised on the entry (catalog load,
|
|
182
|
+
* the store's index, duplicate resolution and compilation each need it for
|
|
183
|
+
* every entry); null for an entry without a source or whose source is not
|
|
184
|
+
* valid ICU. Canonical forms do not depend on the codec's id length.
|
|
185
|
+
*/
|
|
186
|
+
function entryCanonical(entry, codec) {
|
|
187
|
+
const source = entry.source;
|
|
188
|
+
if (source === null) return null;
|
|
189
|
+
const memo = canonicals.get(entry);
|
|
190
|
+
if (memo !== void 0 && memo.source === source) return memo.canonical;
|
|
191
|
+
let canonical;
|
|
192
|
+
try {
|
|
193
|
+
canonical = codec.canonicalize(source);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (!isDiagnosticError(error)) throw error;
|
|
196
|
+
canonical = null;
|
|
197
|
+
}
|
|
198
|
+
canonicals.set(entry, {
|
|
199
|
+
source,
|
|
200
|
+
canonical
|
|
201
|
+
});
|
|
202
|
+
return canonical;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The key that joins an entry to code. Null for foreign contexts (never
|
|
206
|
+
* matched) and for `"source"`-layout entries whose source does not parse,
|
|
207
|
+
* which consumers report on the source instead.
|
|
208
|
+
*/
|
|
209
|
+
function entryKey(entry, layout, codec) {
|
|
210
|
+
if (entry.foreignContext !== null) return null;
|
|
211
|
+
if (entry.manualId !== null) return entry.manualId;
|
|
212
|
+
if (layout !== "source") return entry.label;
|
|
213
|
+
const canonical = entryCanonical(entry, codec);
|
|
214
|
+
return canonical === null ? null : codec.label(canonical);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The `msgctxt` sort field. JSON has no `msgctxt`: its key already holds the
|
|
218
|
+
* manual id, so every JSON entry sorts as if the context were absent.
|
|
219
|
+
*/
|
|
220
|
+
function contextOf(entry, layout) {
|
|
221
|
+
if (layout === "json") return "";
|
|
222
|
+
return entry.manualId ?? entry.foreignContext ?? "";
|
|
223
|
+
}
|
|
224
|
+
function msgidOf(entry, layout) {
|
|
225
|
+
switch (layout) {
|
|
226
|
+
case "source": return entry.source ?? "";
|
|
227
|
+
case "id": return entry.label ?? "";
|
|
228
|
+
case "json": return entry.manualId ?? entry.label ?? "";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** UTF-16 code-unit order: `localeCompare` would differ between machines and ICU versions. */
|
|
232
|
+
function compareText(a, b) {
|
|
233
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Catalog order (INTERNALS §2.6.5): first reference (entries without one
|
|
237
|
+
* last), then `msgctxt` (absent as `""`), then `msgid` or the JSON key, so a
|
|
238
|
+
* route's strings cluster for translators.
|
|
239
|
+
*/
|
|
240
|
+
function compareEntries(a, b, layout) {
|
|
241
|
+
const refA = a.refs[0];
|
|
242
|
+
const refB = b.refs[0];
|
|
243
|
+
if (refA !== refB) {
|
|
244
|
+
if (refA === void 0) return 1;
|
|
245
|
+
if (refB === void 0) return -1;
|
|
246
|
+
const byRef = compareText(refA, refB);
|
|
247
|
+
if (byRef !== 0) return byRef;
|
|
248
|
+
}
|
|
249
|
+
return compareText(contextOf(a, layout), contextOf(b, layout)) || compareText(msgidOf(a, layout), msgidOf(b, layout));
|
|
250
|
+
}
|
|
251
|
+
/** Splits a JSON `context` (notes joined with `"\n"`) back into single-line notes. */
|
|
252
|
+
function splitContexts(context) {
|
|
253
|
+
if (context === "") return [];
|
|
254
|
+
return context.split("\n");
|
|
255
|
+
}
|
|
256
|
+
/** Joins single-line notes for a JSON `context`; null when there are none. */
|
|
257
|
+
function joinContexts(contexts) {
|
|
258
|
+
return contexts.length === 0 ? null : contexts.join("\n");
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The one definition of "this entry's translation may ship" (INTERNALS
|
|
262
|
+
* §2.6.7), shared by compilation, AI planning, `check` and `status`. Steps
|
|
263
|
+
* run in contract order and the first failure names the reason.
|
|
264
|
+
* `scheme-mismatch` and `no-entry` are decided by callers.
|
|
265
|
+
*/
|
|
266
|
+
function usableTranslation(entry, layout, message, codec, locale) {
|
|
267
|
+
if (!sourceMatches(entry, layout, message, codec)) return {
|
|
268
|
+
ok: false,
|
|
269
|
+
reason: "source-mismatch",
|
|
270
|
+
diagnostics: []
|
|
271
|
+
};
|
|
272
|
+
if (entry.translation === "") return {
|
|
273
|
+
ok: false,
|
|
274
|
+
reason: "untranslated",
|
|
275
|
+
diagnostics: []
|
|
276
|
+
};
|
|
277
|
+
if (entry.fuzzy) return {
|
|
278
|
+
ok: false,
|
|
279
|
+
reason: "fuzzy",
|
|
280
|
+
diagnostics: []
|
|
281
|
+
};
|
|
282
|
+
let parsed;
|
|
283
|
+
try {
|
|
284
|
+
parsed = codec.parse(entry.translation);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (!isDiagnosticError(error)) throw error;
|
|
287
|
+
return {
|
|
288
|
+
ok: false,
|
|
289
|
+
reason: "invalid",
|
|
290
|
+
diagnostics: [withSubject(error.diagnostic, locale, message.id, entry.line)]
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const found = validateTranslation(parsed, codec.parse(message.canonical), {
|
|
294
|
+
locale,
|
|
295
|
+
text: entry.translation
|
|
296
|
+
});
|
|
297
|
+
let errors = null;
|
|
298
|
+
for (const diagnostic of found) if (diagnostic.severity === "error") (errors ??= []).push(withSubject(diagnostic, locale, message.id, entry.line));
|
|
299
|
+
return errors === null ? {
|
|
300
|
+
ok: true,
|
|
301
|
+
parsed
|
|
302
|
+
} : {
|
|
303
|
+
ok: false,
|
|
304
|
+
reason: "invalid",
|
|
305
|
+
diagnostics: errors
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* ICU diagnostics locate problems inside the message text; for a catalog the
|
|
310
|
+
* useful position is the entry's line, and the locale and id say which entry.
|
|
311
|
+
*/
|
|
312
|
+
function withSubject(diagnostic, locale, id, line) {
|
|
313
|
+
const located = {
|
|
314
|
+
...diagnostic,
|
|
315
|
+
locale: diagnostic.locale ?? locale,
|
|
316
|
+
id: diagnostic.id ?? id
|
|
317
|
+
};
|
|
318
|
+
delete located.line;
|
|
319
|
+
delete located.column;
|
|
320
|
+
if (line > 0) located.line = line;
|
|
321
|
+
return located;
|
|
322
|
+
}
|
|
323
|
+
//#endregion
|
|
324
|
+
//#region src/core/catalog/json.ts
|
|
325
|
+
const CATALOG_SCHEMA_URL = "https://unpkg.com/@menapo/vi18n/schemas/catalog.json";
|
|
326
|
+
/** Top-level keys the reader maps onto the header; anything else is preserved in `extra`. */
|
|
327
|
+
const OWNED_KEYS = /* @__PURE__ */ new Set([
|
|
328
|
+
"$schema",
|
|
329
|
+
"locale",
|
|
330
|
+
"sourceLocale",
|
|
331
|
+
"idScheme",
|
|
332
|
+
"aiModel",
|
|
333
|
+
"messages"
|
|
334
|
+
]);
|
|
335
|
+
/** Per-message fields with a model field; anything else is preserved in `extra`. */
|
|
336
|
+
const MESSAGE_FIELDS = /* @__PURE__ */ new Set([
|
|
337
|
+
"context",
|
|
338
|
+
"fuzzy",
|
|
339
|
+
"maxLength",
|
|
340
|
+
"flags",
|
|
341
|
+
"refs",
|
|
342
|
+
"state",
|
|
343
|
+
"source",
|
|
344
|
+
"translation"
|
|
345
|
+
]);
|
|
346
|
+
const STATES = /* @__PURE__ */ new Set([
|
|
347
|
+
"untranslated",
|
|
348
|
+
"ai",
|
|
349
|
+
"translated"
|
|
350
|
+
]);
|
|
351
|
+
function skipBlank(text, i) {
|
|
352
|
+
while (i < text.length) {
|
|
353
|
+
const code = text.charCodeAt(i);
|
|
354
|
+
if (code !== 32 && code !== 10 && code !== 13 && code !== 9) break;
|
|
355
|
+
i++;
|
|
356
|
+
}
|
|
357
|
+
return i;
|
|
358
|
+
}
|
|
359
|
+
/** Index just past the string starting at `i` (which is `"`). */
|
|
360
|
+
function skipString(text, i) {
|
|
361
|
+
i++;
|
|
362
|
+
while (i < text.length) {
|
|
363
|
+
const code = text.charCodeAt(i);
|
|
364
|
+
if (code === 92) i += 2;
|
|
365
|
+
else if (code === 34) return i + 1;
|
|
366
|
+
else i++;
|
|
367
|
+
}
|
|
368
|
+
return i;
|
|
369
|
+
}
|
|
370
|
+
/** Index just past the value starting at `i`. The text is known to be valid JSON. */
|
|
371
|
+
function skipValue(text, i) {
|
|
372
|
+
const code = text.charCodeAt(i);
|
|
373
|
+
if (code === 34) return skipString(text, i);
|
|
374
|
+
if (code === 123 || code === 91) {
|
|
375
|
+
let depth = 0;
|
|
376
|
+
while (i < text.length) {
|
|
377
|
+
const c = text.charCodeAt(i);
|
|
378
|
+
if (c === 34) {
|
|
379
|
+
i = skipString(text, i);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (c === 123 || c === 91) depth++;
|
|
383
|
+
else if (c === 125 || c === 93) {
|
|
384
|
+
depth--;
|
|
385
|
+
if (depth === 0) return i + 1;
|
|
386
|
+
}
|
|
387
|
+
i++;
|
|
388
|
+
}
|
|
389
|
+
return i;
|
|
390
|
+
}
|
|
391
|
+
while (i < text.length) {
|
|
392
|
+
const c = text.charCodeAt(i);
|
|
393
|
+
if (c === 44 || c === 125 || c === 93 || c === 32 || c === 10 || c === 13 || c === 9) break;
|
|
394
|
+
i++;
|
|
395
|
+
}
|
|
396
|
+
return i;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* The members of the top-level `messages` object in text order, duplicates
|
|
400
|
+
* included. Null when there is no `messages` object.
|
|
401
|
+
*/
|
|
402
|
+
function scanMessages(text) {
|
|
403
|
+
let line = 1;
|
|
404
|
+
let counted = 0;
|
|
405
|
+
const lineAt = (offset) => {
|
|
406
|
+
for (let i = counted; i < offset; i++) if (text.charCodeAt(i) === 10) line++;
|
|
407
|
+
counted = offset;
|
|
408
|
+
return line;
|
|
409
|
+
};
|
|
410
|
+
let i = skipBlank(text, text.charCodeAt(0) === 65279 ? 1 : 0);
|
|
411
|
+
if (text.charCodeAt(i) !== 123) return null;
|
|
412
|
+
i = skipBlank(text, i + 1);
|
|
413
|
+
while (i < text.length && text.charCodeAt(i) === 34) {
|
|
414
|
+
const keyEnd = skipString(text, i);
|
|
415
|
+
const key = JSON.parse(text.slice(i, keyEnd));
|
|
416
|
+
i = skipBlank(text, keyEnd);
|
|
417
|
+
i = skipBlank(text, i + 1);
|
|
418
|
+
const valueEnd = skipValue(text, i);
|
|
419
|
+
if (key === "messages" && text.charCodeAt(i) === 123) {
|
|
420
|
+
const members = [];
|
|
421
|
+
let j = skipBlank(text, i + 1);
|
|
422
|
+
while (j < valueEnd && text.charCodeAt(j) === 34) {
|
|
423
|
+
const memberKeyEnd = skipString(text, j);
|
|
424
|
+
const memberLine = lineAt(j);
|
|
425
|
+
const memberKey = JSON.parse(text.slice(j, memberKeyEnd));
|
|
426
|
+
j = skipBlank(text, memberKeyEnd);
|
|
427
|
+
j = skipBlank(text, j + 1);
|
|
428
|
+
const end = skipValue(text, j);
|
|
429
|
+
members.push({
|
|
430
|
+
key: memberKey,
|
|
431
|
+
line: memberLine,
|
|
432
|
+
start: j,
|
|
433
|
+
end
|
|
434
|
+
});
|
|
435
|
+
j = skipBlank(text, end);
|
|
436
|
+
if (text.charCodeAt(j) === 44) j = skipBlank(text, j + 1);
|
|
437
|
+
}
|
|
438
|
+
return members;
|
|
439
|
+
}
|
|
440
|
+
i = skipBlank(text, valueEnd);
|
|
441
|
+
if (text.charCodeAt(i) === 44) i = skipBlank(text, i + 1);
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
function isRecord(value) {
|
|
446
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
447
|
+
}
|
|
448
|
+
function isStringArray(value) {
|
|
449
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
450
|
+
}
|
|
451
|
+
function sortedUnique$1(values) {
|
|
452
|
+
return [...new Set(values)].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
453
|
+
}
|
|
454
|
+
/** Line and column of a `JSON.parse` error, when V8 names a position. */
|
|
455
|
+
function errorPosition(text, error) {
|
|
456
|
+
const match = /position (\d+)/.exec(error instanceof Error ? error.message : "");
|
|
457
|
+
if (match === null) return {};
|
|
458
|
+
const offset = Number(match[1]);
|
|
459
|
+
let line = 1;
|
|
460
|
+
let lineStart = 0;
|
|
461
|
+
for (let i = 0; i < offset && i < text.length; i++) if (text.charCodeAt(i) === 10) {
|
|
462
|
+
line++;
|
|
463
|
+
lineStart = i + 1;
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
line,
|
|
467
|
+
column: offset - lineStart + 1
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
function readEntry(key, value, line, isSourceLocale) {
|
|
471
|
+
if (!isRecord(value)) return { error: `message ${JSON.stringify(key)} must be an object` };
|
|
472
|
+
const { context, fuzzy, maxLength, flags, refs, state, source, translation } = value;
|
|
473
|
+
if (context !== void 0 && typeof context !== "string") return { error: `${key}: context must be a string` };
|
|
474
|
+
if (fuzzy !== void 0 && typeof fuzzy !== "boolean") return { error: `${key}: fuzzy must be true or absent` };
|
|
475
|
+
if (maxLength !== void 0 && !(Number.isInteger(maxLength) && maxLength > 0)) return { error: `${key}: maxLength must be a positive integer` };
|
|
476
|
+
if (flags !== void 0 && !isStringArray(flags)) return { error: `${key}: flags must be an array of strings` };
|
|
477
|
+
if (refs !== void 0 && !isStringArray(refs)) return { error: `${key}: refs must be an array of strings` };
|
|
478
|
+
if (state !== void 0 && (typeof state !== "string" || !STATES.has(state))) return { error: `${key}: state must be "untranslated", "ai" or "translated"` };
|
|
479
|
+
if (source !== void 0 && source !== null && typeof source !== "string") return { error: `${key}: source must be a string` };
|
|
480
|
+
if (translation !== void 0 && typeof translation !== "string") return { error: `${key}: translation must be a string` };
|
|
481
|
+
const text = isSourceLocale ? "" : translation ?? "";
|
|
482
|
+
const entryState = text === "" ? "untranslated" : state === "ai" ? "ai" : "translated";
|
|
483
|
+
let extra;
|
|
484
|
+
for (const field of Object.keys(value)) if (!MESSAGE_FIELDS.has(field)) Object.defineProperty(extra ??= {}, field, {
|
|
485
|
+
value: value[field],
|
|
486
|
+
enumerable: true,
|
|
487
|
+
writable: true,
|
|
488
|
+
configurable: true
|
|
489
|
+
});
|
|
490
|
+
const manual = key.includes(".");
|
|
491
|
+
const entry = {
|
|
492
|
+
manualId: manual ? key : null,
|
|
493
|
+
foreignContext: null,
|
|
494
|
+
label: manual ? null : key,
|
|
495
|
+
source: typeof source === "string" ? source : null,
|
|
496
|
+
translation: text,
|
|
497
|
+
state: entryState,
|
|
498
|
+
fuzzy: fuzzy === true,
|
|
499
|
+
contexts: typeof context === "string" ? splitContexts(context) : [],
|
|
500
|
+
comments: [],
|
|
501
|
+
refs: refs === void 0 ? [] : sortedUnique$1(refs),
|
|
502
|
+
maxLength: maxLength === void 0 ? null : maxLength,
|
|
503
|
+
flags: flags === void 0 ? [] : [...new Set(flags)].map((name) => ({
|
|
504
|
+
name,
|
|
505
|
+
line: "#,"
|
|
506
|
+
})),
|
|
507
|
+
line,
|
|
508
|
+
wrapped: false
|
|
509
|
+
};
|
|
510
|
+
if (extra !== void 0) entry.extra = extra;
|
|
511
|
+
return { entry };
|
|
512
|
+
}
|
|
513
|
+
function parseJsonCatalog(text, options) {
|
|
514
|
+
const { locale, isSourceLocale } = options;
|
|
515
|
+
const diagnostics = [];
|
|
516
|
+
const header = {
|
|
517
|
+
language: locale,
|
|
518
|
+
idScheme: null,
|
|
519
|
+
sourceLocale: null,
|
|
520
|
+
aiModel: null,
|
|
521
|
+
extra: []
|
|
522
|
+
};
|
|
523
|
+
const catalog = {
|
|
524
|
+
locale,
|
|
525
|
+
layout: "json",
|
|
526
|
+
header,
|
|
527
|
+
entries: [],
|
|
528
|
+
obsolete: [],
|
|
529
|
+
droppedObsolete: 0
|
|
530
|
+
};
|
|
531
|
+
const fail = (message, line) => {
|
|
532
|
+
const diagnostic = {
|
|
533
|
+
code: "catalog-parse",
|
|
534
|
+
severity: "error",
|
|
535
|
+
message
|
|
536
|
+
};
|
|
537
|
+
if (line !== void 0) diagnostic.line = line;
|
|
538
|
+
diagnostics.push(diagnostic);
|
|
539
|
+
};
|
|
540
|
+
let root;
|
|
541
|
+
try {
|
|
542
|
+
root = JSON.parse(text.charCodeAt(0) === 65279 ? text.slice(1) : text);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
545
|
+
diagnostics.push({
|
|
546
|
+
code: "catalog-parse",
|
|
547
|
+
severity: "error",
|
|
548
|
+
message,
|
|
549
|
+
...errorPosition(text, error)
|
|
550
|
+
});
|
|
551
|
+
return {
|
|
552
|
+
catalog,
|
|
553
|
+
diagnostics
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
if (!isRecord(root)) {
|
|
557
|
+
fail("a JSON catalog must be an object", 1);
|
|
558
|
+
return {
|
|
559
|
+
catalog,
|
|
560
|
+
diagnostics
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
for (const [key, value] of Object.entries(root)) switch (key) {
|
|
564
|
+
case "$schema":
|
|
565
|
+
case "messages": break;
|
|
566
|
+
case "locale":
|
|
567
|
+
if (typeof value === "string" && value !== "") header.language = value;
|
|
568
|
+
else fail("locale must be a string");
|
|
569
|
+
break;
|
|
570
|
+
case "sourceLocale":
|
|
571
|
+
case "idScheme":
|
|
572
|
+
case "aiModel":
|
|
573
|
+
if (typeof value === "string") header[key] = value;
|
|
574
|
+
else if (value !== null) fail(`${key} must be a string`);
|
|
575
|
+
break;
|
|
576
|
+
default: if (!OWNED_KEYS.has(key)) header.extra.push([key, value]);
|
|
577
|
+
}
|
|
578
|
+
const messages = root.messages;
|
|
579
|
+
if (messages === void 0) return {
|
|
580
|
+
catalog,
|
|
581
|
+
diagnostics
|
|
582
|
+
};
|
|
583
|
+
if (!isRecord(messages)) {
|
|
584
|
+
fail("messages must be an object");
|
|
585
|
+
return {
|
|
586
|
+
catalog,
|
|
587
|
+
diagnostics
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
const members = scanMessages(text) ?? [];
|
|
591
|
+
const seen = /* @__PURE__ */ new Set();
|
|
592
|
+
let duplicates = null;
|
|
593
|
+
for (const member of members) if (seen.has(member.key)) (duplicates ??= /* @__PURE__ */ new Set()).add(member.key);
|
|
594
|
+
else seen.add(member.key);
|
|
595
|
+
for (const member of members) {
|
|
596
|
+
const value = duplicates !== null && duplicates.has(member.key) ? JSON.parse(text.slice(member.start, member.end)) : messages[member.key];
|
|
597
|
+
const read = readEntry(member.key, value, member.line, isSourceLocale);
|
|
598
|
+
if ("error" in read) fail(read.error, member.line);
|
|
599
|
+
else catalog.entries.push(read.entry);
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
catalog,
|
|
603
|
+
diagnostics
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
function isPrimitive(value) {
|
|
607
|
+
return value === null || typeof value !== "object";
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* `JSON.stringify(value, null, 2)` except that arrays of primitives stay on
|
|
611
|
+
* one line (`"refs": ["a", "b"]`), as in the documented catalog shape.
|
|
612
|
+
*/
|
|
613
|
+
function stringify(value, indent) {
|
|
614
|
+
if (Array.isArray(value)) {
|
|
615
|
+
if (value.length === 0) return "[]";
|
|
616
|
+
if (value.every(isPrimitive)) return `[${value.map((item) => JSON.stringify(item) ?? "null").join(", ")}]`;
|
|
617
|
+
const inner = `${indent} `;
|
|
618
|
+
return `[\n${value.map((item) => inner + stringify(item, inner)).join(",\n")}\n${indent}]`;
|
|
619
|
+
}
|
|
620
|
+
if (isRecord(value)) {
|
|
621
|
+
const keys = Object.keys(value).filter((key) => value[key] !== void 0);
|
|
622
|
+
if (keys.length === 0) return "{}";
|
|
623
|
+
const inner = `${indent} `;
|
|
624
|
+
return `{\n${keys.map((key) => `${inner}${JSON.stringify(key)}: ${stringify(value[key], inner)}`).join(",\n")}\n${indent}}`;
|
|
625
|
+
}
|
|
626
|
+
return JSON.stringify(value) ?? "null";
|
|
627
|
+
}
|
|
628
|
+
function pushMember(lines, indent, key, value) {
|
|
629
|
+
lines.push(`${indent}${JSON.stringify(key)}: ${stringify(value, indent)}`);
|
|
630
|
+
}
|
|
631
|
+
function entryLines(entry, isSourceLocale) {
|
|
632
|
+
const indent = " ";
|
|
633
|
+
const lines = [];
|
|
634
|
+
const context = joinContexts(entry.contexts);
|
|
635
|
+
if (context !== null) pushMember(lines, indent, "context", context);
|
|
636
|
+
if (entry.fuzzy) pushMember(lines, indent, "fuzzy", true);
|
|
637
|
+
if (entry.maxLength !== null) pushMember(lines, indent, "maxLength", entry.maxLength);
|
|
638
|
+
if (entry.flags.length > 0) pushMember(lines, indent, "flags", [...new Set(entry.flags.map((flag) => flag.name))]);
|
|
639
|
+
if (entry.extra !== void 0) {
|
|
640
|
+
for (const field of Object.keys(entry.extra).sort()) if (!MESSAGE_FIELDS.has(field) && entry.extra[field] !== void 0) pushMember(lines, indent, field, entry.extra[field]);
|
|
641
|
+
}
|
|
642
|
+
if (entry.refs.length > 0) pushMember(lines, indent, "refs", entry.refs);
|
|
643
|
+
const translation = isSourceLocale ? "" : entry.translation;
|
|
644
|
+
pushMember(lines, indent, "state", translation === "" ? "untranslated" : entry.state);
|
|
645
|
+
if (entry.source !== null) pushMember(lines, indent, "source", entry.source);
|
|
646
|
+
pushMember(lines, indent, "translation", translation);
|
|
647
|
+
return lines;
|
|
648
|
+
}
|
|
649
|
+
function serializeJsonCatalog(catalog, options) {
|
|
650
|
+
const { header } = catalog;
|
|
651
|
+
const top = [];
|
|
652
|
+
pushMember(top, " ", "$schema", CATALOG_SCHEMA_URL);
|
|
653
|
+
pushMember(top, " ", "locale", header.language);
|
|
654
|
+
if (header.sourceLocale !== null) pushMember(top, " ", "sourceLocale", header.sourceLocale);
|
|
655
|
+
if (header.idScheme !== null) pushMember(top, " ", "idScheme", header.idScheme);
|
|
656
|
+
if (header.aiModel !== null) pushMember(top, " ", "aiModel", header.aiModel);
|
|
657
|
+
for (const [key, value] of header.extra) if (!OWNED_KEYS.has(key) && value !== void 0) pushMember(top, " ", key, value);
|
|
658
|
+
const entries = catalog.entries.slice().sort((a, b) => compareEntries(a, b, "json"));
|
|
659
|
+
if (entries.length === 0) top.push(" \"messages\": {}");
|
|
660
|
+
else {
|
|
661
|
+
const members = entries.map((entry) => {
|
|
662
|
+
const key = entry.manualId ?? entry.label ?? "";
|
|
663
|
+
return ` ${JSON.stringify(key)}: {\n${entryLines(entry, options.isSourceLocale).join(",\n")}\n }`;
|
|
664
|
+
});
|
|
665
|
+
top.push(` "messages": {\n${members.join(",\n")}\n }`);
|
|
666
|
+
}
|
|
667
|
+
return `{\n${top.join(",\n")}\n}\n`;
|
|
668
|
+
}
|
|
669
|
+
//#endregion
|
|
670
|
+
//#region src/core/catalog/plural-forms.ts
|
|
671
|
+
/**
|
|
672
|
+
* The PO `Plural-Forms` header (PLAN §4.4 P1, INTERNALS §2.6.3). vi18n never
|
|
673
|
+
* evaluates it: an ICU message carries its own plural cases, and vi18n never
|
|
674
|
+
* writes gettext plurals. PO editors and `msgfmt -c` expect a real one, so the
|
|
675
|
+
* writer emits the GNU gettext manual's formula for the file's language (the
|
|
676
|
+
* "Plural forms" table of gettext.texi, plus Chinese), and keeps a formula a
|
|
677
|
+
* tool already wrote, so vi18n and Poedit or Weblate never rewrite each
|
|
678
|
+
* other's spelling of the same rule.
|
|
679
|
+
*/
|
|
680
|
+
const ONE = "nplurals=1; plural=0;";
|
|
681
|
+
const NOT_ONE = "nplurals=2; plural=n != 1;";
|
|
682
|
+
const OVER_ONE = "nplurals=2; plural=n>1;";
|
|
683
|
+
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;";
|
|
684
|
+
/** Lower-case BCP 47 tags; a region-specific row wins over its language's. */
|
|
685
|
+
const FORMULAS = {
|
|
686
|
+
ja: ONE,
|
|
687
|
+
ko: ONE,
|
|
688
|
+
th: ONE,
|
|
689
|
+
vi: ONE,
|
|
690
|
+
zh: ONE,
|
|
691
|
+
bg: NOT_ONE,
|
|
692
|
+
da: NOT_ONE,
|
|
693
|
+
de: NOT_ONE,
|
|
694
|
+
el: NOT_ONE,
|
|
695
|
+
en: NOT_ONE,
|
|
696
|
+
eo: NOT_ONE,
|
|
697
|
+
es: NOT_ONE,
|
|
698
|
+
et: NOT_ONE,
|
|
699
|
+
fi: NOT_ONE,
|
|
700
|
+
fo: NOT_ONE,
|
|
701
|
+
he: NOT_ONE,
|
|
702
|
+
hu: NOT_ONE,
|
|
703
|
+
id: NOT_ONE,
|
|
704
|
+
it: NOT_ONE,
|
|
705
|
+
nb: NOT_ONE,
|
|
706
|
+
nl: NOT_ONE,
|
|
707
|
+
nn: NOT_ONE,
|
|
708
|
+
no: NOT_ONE,
|
|
709
|
+
pt: NOT_ONE,
|
|
710
|
+
sv: NOT_ONE,
|
|
711
|
+
tr: NOT_ONE,
|
|
712
|
+
fr: OVER_ONE,
|
|
713
|
+
"pt-br": OVER_ONE,
|
|
714
|
+
lv: "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2;",
|
|
715
|
+
ga: "nplurals=3; plural=n==1 ? 0 : n==2 ? 1 : 2;",
|
|
716
|
+
ro: "nplurals=3; plural=n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2;",
|
|
717
|
+
lt: "nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2;",
|
|
718
|
+
be: EAST_SLAVIC,
|
|
719
|
+
hr: EAST_SLAVIC,
|
|
720
|
+
ru: EAST_SLAVIC,
|
|
721
|
+
sr: EAST_SLAVIC,
|
|
722
|
+
uk: EAST_SLAVIC,
|
|
723
|
+
cs: "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;",
|
|
724
|
+
sk: "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;",
|
|
725
|
+
pl: "nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;",
|
|
726
|
+
sl: "nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;",
|
|
727
|
+
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;"
|
|
728
|
+
};
|
|
729
|
+
/** The formula for a language tag (`pt-BR`, `pt_BR`, `de`), or null when the table has none. */
|
|
730
|
+
function pluralFormsFor(language) {
|
|
731
|
+
const subtags = language.trim().toLowerCase().replaceAll("_", "-").split("-");
|
|
732
|
+
for (let length = subtags.length; length > 0; length--) {
|
|
733
|
+
const formula = FORMULAS[subtags.slice(0, length).join("-")];
|
|
734
|
+
if (formula !== void 0) return formula;
|
|
735
|
+
}
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
/** `msginit` leaves this placeholder of a POT header until someone fills it in. */
|
|
739
|
+
function isPluralFormsPlaceholder(value) {
|
|
740
|
+
return /\bINTEGER\b|\bEXPRESSION\b/.test(value) || !/\bnplurals\s*=\s*\d/.test(value);
|
|
741
|
+
}
|
|
742
|
+
//#endregion
|
|
743
|
+
//#region src/core/catalog/po.ts
|
|
744
|
+
/**
|
|
745
|
+
* Header fields the writer always emits itself; keeping them in `extra` would
|
|
746
|
+
* write them twice.
|
|
747
|
+
*/
|
|
748
|
+
const OWNED_HEADER_FIELDS = /* @__PURE__ */ new Set([
|
|
749
|
+
"language",
|
|
750
|
+
"content-type",
|
|
751
|
+
"x-generator",
|
|
752
|
+
"x-vi18n-id",
|
|
753
|
+
"x-vi18n-ai-model"
|
|
754
|
+
]);
|
|
755
|
+
const FSI = "";
|
|
756
|
+
const PDI = "";
|
|
757
|
+
/**
|
|
758
|
+
* In the `"id"` layout `#. source:` is the source note, so a context line that
|
|
759
|
+
* would read as one is written with one more leading backslash, which the
|
|
760
|
+
* reader removes. Escaping every backslash run before `source:` keeps the
|
|
761
|
+
* encoding reversible while leaving all other contexts verbatim.
|
|
762
|
+
*/
|
|
763
|
+
const SOURCE_LIKE = /^\\*source:/;
|
|
764
|
+
const ESCAPED_SOURCE_LIKE = /^\\+source:/;
|
|
765
|
+
/** `#:` is line-based; no isolation can carry a line break inside a file name. */
|
|
766
|
+
const LINE_BREAK = /[\n\r]/;
|
|
767
|
+
const NEEDS_ESCAPE = /[\\"\n\t\r]/;
|
|
768
|
+
/** The body of a PO string: only what would break the line or the quotes is escaped. */
|
|
769
|
+
function escapePo(value) {
|
|
770
|
+
if (!NEEDS_ESCAPE.test(value)) return value;
|
|
771
|
+
let out = "";
|
|
772
|
+
for (let i = 0; i < value.length; i++) {
|
|
773
|
+
const ch = value[i];
|
|
774
|
+
switch (ch) {
|
|
775
|
+
case "\\":
|
|
776
|
+
out += "\\\\";
|
|
777
|
+
break;
|
|
778
|
+
case "\"":
|
|
779
|
+
out += "\\\"";
|
|
780
|
+
break;
|
|
781
|
+
case "\n":
|
|
782
|
+
out += "\\n";
|
|
783
|
+
break;
|
|
784
|
+
case " ":
|
|
785
|
+
out += "\\t";
|
|
786
|
+
break;
|
|
787
|
+
case "\r":
|
|
788
|
+
out += "\\r";
|
|
789
|
+
break;
|
|
790
|
+
default: out += ch;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return out;
|
|
794
|
+
}
|
|
795
|
+
const SIMPLE_ESCAPES = {
|
|
796
|
+
"\\": "\\",
|
|
797
|
+
"\"": "\"",
|
|
798
|
+
n: "\n",
|
|
799
|
+
t: " ",
|
|
800
|
+
r: "\r",
|
|
801
|
+
a: "\x07",
|
|
802
|
+
b: "\b",
|
|
803
|
+
f: "\f",
|
|
804
|
+
v: "\v",
|
|
805
|
+
"'": "'",
|
|
806
|
+
"?": "?"
|
|
807
|
+
};
|
|
808
|
+
function isOctal(code) {
|
|
809
|
+
return code >= 48 && code <= 55;
|
|
810
|
+
}
|
|
811
|
+
function hexValue(code) {
|
|
812
|
+
if (code >= 48 && code <= 57) return code - 48;
|
|
813
|
+
if (code >= 97 && code <= 102) return code - 87;
|
|
814
|
+
if (code >= 65 && code <= 70) return code - 55;
|
|
815
|
+
return -1;
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Decodes a PO string body (C escapes). Octal and hex escapes are bytes, so
|
|
819
|
+
* runs of them are decoded together as UTF-8 (`\303\251` is `é`). Returns
|
|
820
|
+
* null on an invalid escape.
|
|
821
|
+
*/
|
|
822
|
+
function unescapePo(body) {
|
|
823
|
+
if (!body.includes("\\")) return body;
|
|
824
|
+
let out = "";
|
|
825
|
+
let bytes = null;
|
|
826
|
+
const flush = () => {
|
|
827
|
+
if (bytes !== null) {
|
|
828
|
+
out += Buffer.from(bytes).toString("utf8");
|
|
829
|
+
bytes = null;
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
let i = 0;
|
|
833
|
+
while (i < body.length) {
|
|
834
|
+
if (body[i] !== "\\") {
|
|
835
|
+
flush();
|
|
836
|
+
const next = body.indexOf("\\", i);
|
|
837
|
+
const end = next === -1 ? body.length : next;
|
|
838
|
+
out += body.slice(i, end);
|
|
839
|
+
i = end;
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
const escape = body[i + 1];
|
|
843
|
+
if (escape === void 0) return null;
|
|
844
|
+
if (Object.hasOwn(SIMPLE_ESCAPES, escape)) {
|
|
845
|
+
flush();
|
|
846
|
+
out += SIMPLE_ESCAPES[escape];
|
|
847
|
+
i += 2;
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
if (isOctal(escape.charCodeAt(0))) {
|
|
851
|
+
let value = 0;
|
|
852
|
+
let j = i + 1;
|
|
853
|
+
while (j < body.length && j < i + 4 && isOctal(body.charCodeAt(j))) value = value * 8 + body.charCodeAt(j++) - 48;
|
|
854
|
+
(bytes ??= []).push(value & 255);
|
|
855
|
+
i = j;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (escape === "x") {
|
|
859
|
+
let value = 0;
|
|
860
|
+
let j = i + 2;
|
|
861
|
+
while (j < body.length && j < i + 4 && hexValue(body.charCodeAt(j)) !== -1) value = value * 16 + hexValue(body.charCodeAt(j++));
|
|
862
|
+
if (j === i + 2) return null;
|
|
863
|
+
(bytes ??= []).push(value);
|
|
864
|
+
i = j;
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
flush();
|
|
870
|
+
return out;
|
|
871
|
+
}
|
|
872
|
+
/** Parses `"…"` (trailing blanks allowed); null when the token is not exactly one string. */
|
|
873
|
+
function readString(text, from) {
|
|
874
|
+
let start = from;
|
|
875
|
+
while (start < text.length && (text[start] === " " || text[start] === " ")) start++;
|
|
876
|
+
if (text[start] !== "\"") return null;
|
|
877
|
+
let i = start + 1;
|
|
878
|
+
while (i < text.length) {
|
|
879
|
+
const ch = text[i];
|
|
880
|
+
if (ch === "\\") i += 2;
|
|
881
|
+
else if (ch === "\"") break;
|
|
882
|
+
else i++;
|
|
883
|
+
}
|
|
884
|
+
if (i >= text.length) return null;
|
|
885
|
+
for (let j = i + 1; j < text.length; j++) if (text[j] !== " " && text[j] !== " ") return null;
|
|
886
|
+
return unescapePo(text.slice(start + 1, i));
|
|
887
|
+
}
|
|
888
|
+
function newDraft() {
|
|
889
|
+
return {
|
|
890
|
+
raw: [],
|
|
891
|
+
comments: [],
|
|
892
|
+
contexts: [],
|
|
893
|
+
sourceNote: null,
|
|
894
|
+
refs: [],
|
|
895
|
+
flags: [],
|
|
896
|
+
flagKeys: null,
|
|
897
|
+
fuzzy: false,
|
|
898
|
+
ai: false,
|
|
899
|
+
maxLength: null,
|
|
900
|
+
msgctxt: null,
|
|
901
|
+
msgid: null,
|
|
902
|
+
plural: false,
|
|
903
|
+
msgstr: null,
|
|
904
|
+
field: null,
|
|
905
|
+
wrapped: false,
|
|
906
|
+
line: 0,
|
|
907
|
+
firstLine: 0,
|
|
908
|
+
error: null
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
function hasKeywords(draft) {
|
|
912
|
+
return draft.field !== null;
|
|
913
|
+
}
|
|
914
|
+
function parseError(line, message) {
|
|
915
|
+
return {
|
|
916
|
+
code: "catalog-parse",
|
|
917
|
+
severity: "error",
|
|
918
|
+
message,
|
|
919
|
+
line
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* `#:` references: whitespace-separated, GNU's FSI…PDI isolation for names
|
|
924
|
+
* with spaces, and `:line` suffixes dropped (vi18n references files only).
|
|
925
|
+
*/
|
|
926
|
+
function readRefs(text, into) {
|
|
927
|
+
let i = 0;
|
|
928
|
+
while (i < text.length) {
|
|
929
|
+
const ch = text[i];
|
|
930
|
+
if (ch === " " || ch === " ") {
|
|
931
|
+
i++;
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
let token;
|
|
935
|
+
if (ch === FSI) {
|
|
936
|
+
const end = text.indexOf(PDI, i + 1);
|
|
937
|
+
const stop = end === -1 ? text.length : end;
|
|
938
|
+
token = text.slice(i + 1, stop);
|
|
939
|
+
i = stop + 1;
|
|
940
|
+
while (i < text.length && text[i] !== " " && text[i] !== " ") i++;
|
|
941
|
+
} else {
|
|
942
|
+
let end = i;
|
|
943
|
+
while (end < text.length && text[end] !== " " && text[end] !== " ") end++;
|
|
944
|
+
token = text.slice(i, end).replace(/:\d+$/, "");
|
|
945
|
+
i = end;
|
|
946
|
+
}
|
|
947
|
+
if (token !== "") into.push(token);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
function readFlags(text, line, draft) {
|
|
951
|
+
for (const part of text.split(",")) {
|
|
952
|
+
const name = part.trim();
|
|
953
|
+
if (name === "") continue;
|
|
954
|
+
if (name === "fuzzy") draft.fuzzy = true;
|
|
955
|
+
else if (name === "ai") draft.ai = true;
|
|
956
|
+
else if (name.startsWith("max-length:") && /^max-length:[1-9]\d*$/.test(name)) {
|
|
957
|
+
const value = Number(name.slice(11));
|
|
958
|
+
draft.maxLength = draft.maxLength === null ? value : Math.min(draft.maxLength, value);
|
|
959
|
+
} else {
|
|
960
|
+
const key = line + name;
|
|
961
|
+
const keys = draft.flagKeys ??= /* @__PURE__ */ new Set();
|
|
962
|
+
if (!keys.has(key)) {
|
|
963
|
+
keys.add(key);
|
|
964
|
+
draft.flags.push({
|
|
965
|
+
name,
|
|
966
|
+
line
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function sortedUnique(values) {
|
|
973
|
+
if (values.length < 2) return values;
|
|
974
|
+
const sorted = values.slice().sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
975
|
+
let w = 1;
|
|
976
|
+
for (let r = 1; r < sorted.length; r++) if (sorted[r] !== sorted[w - 1]) sorted[w++] = sorted[r];
|
|
977
|
+
sorted.length = w;
|
|
978
|
+
return sorted;
|
|
979
|
+
}
|
|
980
|
+
function readHeader(msgstr, locale) {
|
|
981
|
+
const header = {
|
|
982
|
+
language: locale,
|
|
983
|
+
idScheme: null,
|
|
984
|
+
sourceLocale: null,
|
|
985
|
+
aiModel: null,
|
|
986
|
+
extra: []
|
|
987
|
+
};
|
|
988
|
+
for (const field of msgstr.split("\n")) {
|
|
989
|
+
const colon = field.indexOf(":");
|
|
990
|
+
if (colon <= 0) continue;
|
|
991
|
+
const name = field.slice(0, colon).trim();
|
|
992
|
+
const value = field.slice(colon + 1).trim();
|
|
993
|
+
const lower = name.toLowerCase();
|
|
994
|
+
if (lower === "language") {
|
|
995
|
+
if (value !== "") header.language = value;
|
|
996
|
+
} else if (lower === "x-vi18n-id") header.idScheme = value === "" ? null : value;
|
|
997
|
+
else if (lower === "x-vi18n-ai-model") header.aiModel = value === "" ? null : value;
|
|
998
|
+
else if (!OWNED_HEADER_FIELDS.has(lower)) header.extra.push([name, value]);
|
|
999
|
+
}
|
|
1000
|
+
const derived = pluralFormsFor(header.language);
|
|
1001
|
+
if (derived !== null) header.extra = header.extra.filter(([name, value]) => name.toLowerCase() !== "plural-forms" || value !== derived);
|
|
1002
|
+
return header;
|
|
1003
|
+
}
|
|
1004
|
+
function parsePo(text, options) {
|
|
1005
|
+
const { locale, layout, isSourceLocale } = options;
|
|
1006
|
+
const diagnostics = [];
|
|
1007
|
+
const entries = [];
|
|
1008
|
+
const obsolete = [];
|
|
1009
|
+
let header = null;
|
|
1010
|
+
let draft = newDraft();
|
|
1011
|
+
let block = null;
|
|
1012
|
+
let blockHasMsgstr = false;
|
|
1013
|
+
const closeBlock = () => {
|
|
1014
|
+
if (block !== null) {
|
|
1015
|
+
obsolete.push(block.join("\n"));
|
|
1016
|
+
block = null;
|
|
1017
|
+
blockHasMsgstr = false;
|
|
1018
|
+
}
|
|
1019
|
+
};
|
|
1020
|
+
const finish = () => {
|
|
1021
|
+
const done = draft;
|
|
1022
|
+
if (!hasKeywords(done)) return;
|
|
1023
|
+
draft = newDraft();
|
|
1024
|
+
if (done.error !== null) {
|
|
1025
|
+
diagnostics.push(done.error);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (done.msgid === null) {
|
|
1029
|
+
diagnostics.push(parseError(done.firstLine, "entry without msgid"));
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (done.plural) {
|
|
1033
|
+
diagnostics.push(parseError(done.line, "gettext plural entries (msgid_plural) are not supported: write the plural as ICU in the source text"));
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
if (done.msgstr === null) {
|
|
1037
|
+
diagnostics.push(parseError(done.line, "msgid without msgstr"));
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
if (done.msgid === "" && done.msgctxt === null && header === null && entries.length === 0) {
|
|
1041
|
+
header = readHeader(done.msgstr, locale);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
const manual = done.msgctxt !== null && done.msgctxt.includes(".");
|
|
1045
|
+
const translation = isSourceLocale ? "" : done.msgstr;
|
|
1046
|
+
let source;
|
|
1047
|
+
let label = null;
|
|
1048
|
+
if (layout === "source") source = done.msgid;
|
|
1049
|
+
else {
|
|
1050
|
+
label = done.msgid;
|
|
1051
|
+
source = isSourceLocale ? done.msgstr : done.sourceNote;
|
|
1052
|
+
}
|
|
1053
|
+
const state = translation === "" ? "untranslated" : done.ai ? "ai" : "translated";
|
|
1054
|
+
entries.push({
|
|
1055
|
+
manualId: manual ? done.msgctxt : null,
|
|
1056
|
+
foreignContext: manual ? null : done.msgctxt,
|
|
1057
|
+
label,
|
|
1058
|
+
source,
|
|
1059
|
+
translation,
|
|
1060
|
+
state,
|
|
1061
|
+
fuzzy: done.fuzzy,
|
|
1062
|
+
contexts: done.contexts,
|
|
1063
|
+
comments: done.comments,
|
|
1064
|
+
refs: sortedUnique(done.refs),
|
|
1065
|
+
maxLength: done.maxLength,
|
|
1066
|
+
flags: done.flags,
|
|
1067
|
+
line: done.line,
|
|
1068
|
+
wrapped: done.wrapped
|
|
1069
|
+
});
|
|
1070
|
+
};
|
|
1071
|
+
const fail = (line, message) => {
|
|
1072
|
+
draft.error ??= parseError(line, message);
|
|
1073
|
+
};
|
|
1074
|
+
const start = text.charCodeAt(0) === 65279 ? 1 : 0;
|
|
1075
|
+
const lines = (start === 0 ? text : text.slice(start)).split("\n");
|
|
1076
|
+
for (let index = 0; index < lines.length; index++) {
|
|
1077
|
+
const lineNo = index + 1;
|
|
1078
|
+
let raw = lines[index];
|
|
1079
|
+
if (raw.endsWith("\r")) raw = raw.slice(0, -1);
|
|
1080
|
+
let s = 0;
|
|
1081
|
+
while (s < raw.length && (raw[s] === " " || raw[s] === " ")) s++;
|
|
1082
|
+
if (s === raw.length) {
|
|
1083
|
+
finish();
|
|
1084
|
+
closeBlock();
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
const line = s === 0 ? raw : raw.slice(s);
|
|
1088
|
+
if (line.startsWith("#~")) {
|
|
1089
|
+
finish();
|
|
1090
|
+
const body = line.slice(2).trimStart();
|
|
1091
|
+
const startsEntry = body.startsWith("msgid") || body.startsWith("msgctxt");
|
|
1092
|
+
if (block !== null && blockHasMsgstr && startsEntry) closeBlock();
|
|
1093
|
+
if (block === null) {
|
|
1094
|
+
block = draft.raw;
|
|
1095
|
+
draft = newDraft();
|
|
1096
|
+
}
|
|
1097
|
+
block.push(raw);
|
|
1098
|
+
if (body.startsWith("msgstr")) blockHasMsgstr = true;
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
closeBlock();
|
|
1102
|
+
if (line.charCodeAt(0) === 35) {
|
|
1103
|
+
if (hasKeywords(draft)) finish();
|
|
1104
|
+
if (draft.firstLine === 0) draft.firstLine = lineNo;
|
|
1105
|
+
draft.raw.push(raw);
|
|
1106
|
+
const kind = line[1];
|
|
1107
|
+
const rest = line.slice(2);
|
|
1108
|
+
const content = rest.startsWith(" ") ? rest.slice(1) : rest;
|
|
1109
|
+
if (kind === ".") {
|
|
1110
|
+
if (layout === "id" && content.startsWith("source:")) {
|
|
1111
|
+
if (!isSourceLocale) {
|
|
1112
|
+
const body = content.slice(content.startsWith("source: ") ? 8 : 7);
|
|
1113
|
+
const note = unescapePo(body);
|
|
1114
|
+
if (note === null) diagnostics.push({
|
|
1115
|
+
code: "catalog-parse",
|
|
1116
|
+
severity: "warning",
|
|
1117
|
+
message: "invalid escape in #. source:; the note is kept as written",
|
|
1118
|
+
line: lineNo
|
|
1119
|
+
});
|
|
1120
|
+
draft.sourceNote = note ?? body;
|
|
1121
|
+
}
|
|
1122
|
+
} else draft.contexts.push(layout === "id" && ESCAPED_SOURCE_LIKE.test(content) ? content.slice(1) : content);
|
|
1123
|
+
} else if (kind === ":") readRefs(content, draft.refs);
|
|
1124
|
+
else if (kind === ",") readFlags(content, "#,", draft);
|
|
1125
|
+
else if (kind === "=") readFlags(content, "#=", draft);
|
|
1126
|
+
else if (kind === "|") {} else {
|
|
1127
|
+
const body = line.slice(1);
|
|
1128
|
+
draft.comments.push(body.startsWith(" ") ? body.slice(1) : body);
|
|
1129
|
+
}
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
if (line.charCodeAt(0) === 34) {
|
|
1133
|
+
const value = readString(line, 0);
|
|
1134
|
+
if (draft.field === null) {
|
|
1135
|
+
fail(lineNo, "string continuation without a keyword");
|
|
1136
|
+
draft.firstLine ||= lineNo;
|
|
1137
|
+
draft.field = "msgstr";
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
if (value === null) {
|
|
1141
|
+
fail(lineNo, "malformed string");
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
switch (draft.field) {
|
|
1145
|
+
case "msgctxt":
|
|
1146
|
+
draft.msgctxt = (draft.msgctxt ?? "") + value;
|
|
1147
|
+
break;
|
|
1148
|
+
case "msgid":
|
|
1149
|
+
draft.msgid = (draft.msgid ?? "") + value;
|
|
1150
|
+
draft.wrapped = true;
|
|
1151
|
+
break;
|
|
1152
|
+
case "msgstr":
|
|
1153
|
+
draft.msgstr = (draft.msgstr ?? "") + value;
|
|
1154
|
+
draft.wrapped = true;
|
|
1155
|
+
}
|
|
1156
|
+
continue;
|
|
1157
|
+
}
|
|
1158
|
+
const keyword = /^(msgctxt|msgid_plural|msgid|msgstr\[\d+\]|msgstr)(?=[\s"])/.exec(line);
|
|
1159
|
+
if (keyword === null) {
|
|
1160
|
+
if (!hasKeywords(draft)) draft.firstLine ||= lineNo;
|
|
1161
|
+
draft.field ??= "msgstr";
|
|
1162
|
+
fail(lineNo, `unexpected line: ${line.length > 40 ? `${line.slice(0, 40)}…` : line}`);
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
const word = keyword[1];
|
|
1166
|
+
const field = word.startsWith("msgstr[") ? "msgstr[]" : word;
|
|
1167
|
+
if ((field === "msgctxt" || field === "msgid") && (draft.msgstr !== null || draft.plural)) finish();
|
|
1168
|
+
if (draft.firstLine === 0) draft.firstLine = lineNo;
|
|
1169
|
+
const value = readString(line, word.length);
|
|
1170
|
+
draft.field = field;
|
|
1171
|
+
if (value === null) {
|
|
1172
|
+
fail(lineNo, `malformed ${word} string`);
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
switch (field) {
|
|
1176
|
+
case "msgctxt":
|
|
1177
|
+
if (draft.msgctxt !== null || draft.msgid !== null) fail(lineNo, "msgctxt must come first, once");
|
|
1178
|
+
draft.msgctxt = value;
|
|
1179
|
+
break;
|
|
1180
|
+
case "msgid":
|
|
1181
|
+
if (draft.msgid !== null) fail(lineNo, "duplicate msgid");
|
|
1182
|
+
draft.msgid = value;
|
|
1183
|
+
draft.line = lineNo;
|
|
1184
|
+
break;
|
|
1185
|
+
case "msgid_plural":
|
|
1186
|
+
draft.plural = true;
|
|
1187
|
+
break;
|
|
1188
|
+
case "msgstr":
|
|
1189
|
+
if (draft.msgid === null) fail(lineNo, "msgstr without msgid");
|
|
1190
|
+
if (draft.msgstr !== null) fail(lineNo, "duplicate msgstr");
|
|
1191
|
+
draft.msgstr = value;
|
|
1192
|
+
break;
|
|
1193
|
+
case "msgstr[]": if (!draft.plural) fail(lineNo, `${word} without msgid_plural`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
finish();
|
|
1197
|
+
closeBlock();
|
|
1198
|
+
return {
|
|
1199
|
+
catalog: {
|
|
1200
|
+
locale,
|
|
1201
|
+
layout,
|
|
1202
|
+
header: header ?? {
|
|
1203
|
+
language: locale,
|
|
1204
|
+
idScheme: null,
|
|
1205
|
+
sourceLocale: null,
|
|
1206
|
+
aiModel: null,
|
|
1207
|
+
extra: []
|
|
1208
|
+
},
|
|
1209
|
+
entries,
|
|
1210
|
+
obsolete,
|
|
1211
|
+
droppedObsolete: obsolete.length
|
|
1212
|
+
},
|
|
1213
|
+
diagnostics
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
function quoted(value) {
|
|
1217
|
+
return `"${escapePo(value)}"`;
|
|
1218
|
+
}
|
|
1219
|
+
function headerLine(name, value) {
|
|
1220
|
+
return quoted(`${name}: ${value}\n`);
|
|
1221
|
+
}
|
|
1222
|
+
/** Notes are single lines in the model; a stray newline must not break the file. */
|
|
1223
|
+
function pushNoteLines(out, prefix, value, idContext = false) {
|
|
1224
|
+
for (const part of value.split("\n")) {
|
|
1225
|
+
const line = idContext && SOURCE_LIKE.test(part) ? `\\${part}` : part;
|
|
1226
|
+
out.push(line === "" ? prefix : `${prefix} ${line}`);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
function refLine(ref) {
|
|
1230
|
+
return /\s/.test(ref) ? `#: ${FSI}${ref}${PDI}` : `#: ${ref}`;
|
|
1231
|
+
}
|
|
1232
|
+
function pushEntry(out, entry, layout, isSourceLocale) {
|
|
1233
|
+
for (const comment of entry.comments) pushNoteLines(out, "#", comment);
|
|
1234
|
+
for (const context of entry.contexts) pushNoteLines(out, "#.", context, layout === "id");
|
|
1235
|
+
if (layout === "id" && !isSourceLocale && entry.source !== null) out.push(`#. source: ${escapePo(entry.source)}`);
|
|
1236
|
+
for (const ref of entry.refs) if (!LINE_BREAK.test(ref)) out.push(refLine(ref));
|
|
1237
|
+
const comma = [];
|
|
1238
|
+
if (entry.fuzzy) comma.push("fuzzy");
|
|
1239
|
+
if (entry.state === "ai" && !isSourceLocale) comma.push("ai");
|
|
1240
|
+
if (entry.maxLength !== null) comma.push(`max-length:${entry.maxLength}`);
|
|
1241
|
+
let equals = null;
|
|
1242
|
+
for (const flag of entry.flags) if (flag.line === "#,") comma.push(flag.name);
|
|
1243
|
+
else (equals ??= []).push(flag.name);
|
|
1244
|
+
if (comma.length > 0) out.push(`#, ${comma.join(", ")}`);
|
|
1245
|
+
if (equals !== null) out.push(`#= ${equals.join(", ")}`);
|
|
1246
|
+
const context = entry.manualId ?? entry.foreignContext;
|
|
1247
|
+
if (context !== null) out.push(`msgctxt ${quoted(context)}`);
|
|
1248
|
+
out.push(`msgid ${quoted((layout === "id" ? entry.label : entry.source) ?? "")}`);
|
|
1249
|
+
const msgstr = isSourceLocale ? layout === "id" ? entry.source ?? "" : "" : entry.translation;
|
|
1250
|
+
out.push(`msgstr ${quoted(msgstr)}`);
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* The `Plural-Forms` value to write: one a tool wrote (kept, so vi18n and a
|
|
1254
|
+
* PO editor never rewrite each other's spelling of a rule), else the table's
|
|
1255
|
+
* formula for the language, else none. Read ones arrive in `extra`.
|
|
1256
|
+
*/
|
|
1257
|
+
function pluralForms(header) {
|
|
1258
|
+
for (const [name, value] of header.extra) {
|
|
1259
|
+
if (name.toLowerCase() !== "plural-forms") continue;
|
|
1260
|
+
const text = String(value);
|
|
1261
|
+
if (!isPluralFormsPlaceholder(text)) return text;
|
|
1262
|
+
break;
|
|
1263
|
+
}
|
|
1264
|
+
return pluralFormsFor(header.language);
|
|
1265
|
+
}
|
|
1266
|
+
function serializePo(catalog, options) {
|
|
1267
|
+
const { header, layout } = catalog;
|
|
1268
|
+
const out = [
|
|
1269
|
+
"msgid \"\"",
|
|
1270
|
+
"msgstr \"\"",
|
|
1271
|
+
headerLine("Language", header.language),
|
|
1272
|
+
headerLine("Content-Type", "text/plain; charset=UTF-8")
|
|
1273
|
+
];
|
|
1274
|
+
const plural = pluralForms(header);
|
|
1275
|
+
if (plural !== null) out.push(headerLine("Plural-Forms", plural));
|
|
1276
|
+
out.push(headerLine("X-Generator", `@menapo/vi18n ${options.version}`));
|
|
1277
|
+
if (header.idScheme !== null) out.push(headerLine("X-Vi18n-Id", header.idScheme));
|
|
1278
|
+
if (header.aiModel !== null) out.push(headerLine("X-Vi18n-AI-Model", header.aiModel));
|
|
1279
|
+
for (const [name, value] of header.extra) if (name.toLowerCase() !== "plural-forms") out.push(headerLine(name, String(value)));
|
|
1280
|
+
const entries = catalog.entries.slice().sort((a, b) => compareEntries(a, b, layout));
|
|
1281
|
+
for (const entry of entries) {
|
|
1282
|
+
out.push("");
|
|
1283
|
+
pushEntry(out, entry, layout, options.isSourceLocale);
|
|
1284
|
+
}
|
|
1285
|
+
for (const block of catalog.obsolete) out.push("", block);
|
|
1286
|
+
return `${out.join("\n")}\n`;
|
|
1287
|
+
}
|
|
1288
|
+
//#endregion
|
|
1289
|
+
//#region src/core/catalog/io.ts
|
|
1290
|
+
/**
|
|
1291
|
+
* Catalog files (INTERNALS §2.6.1, §2.6.6, PLAN §5.6): one per locale,
|
|
1292
|
+
* source locale included. Every writer (dev, extract, translate, import,
|
|
1293
|
+
* restore) goes through `updateCatalogFile`, a compare-and-swap over an
|
|
1294
|
+
* atomic rename, so concurrent writers re-apply their change instead of
|
|
1295
|
+
* losing each other's.
|
|
1296
|
+
*/
|
|
1297
|
+
/** Attempts after the first before `catalog-write-conflict`. */
|
|
1298
|
+
const MAX_RETRIES = 5;
|
|
1299
|
+
function catalogPath(config, locale) {
|
|
1300
|
+
return config.catalogs.path.replaceAll("{locale}", locale);
|
|
1301
|
+
}
|
|
1302
|
+
/** The layout a config's catalogs use. */
|
|
1303
|
+
function catalogLayout(config) {
|
|
1304
|
+
return config.catalogs.format === "json" ? "json" : config.catalogs.msgid;
|
|
1305
|
+
}
|
|
1306
|
+
function parseCatalog(text, options) {
|
|
1307
|
+
if (options.layout === "json") return parseJsonCatalog(text, options);
|
|
1308
|
+
return parsePo(text, {
|
|
1309
|
+
locale: options.locale,
|
|
1310
|
+
layout: options.layout,
|
|
1311
|
+
isSourceLocale: options.isSourceLocale
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
function serializeCatalog(catalog, options) {
|
|
1315
|
+
if (catalog.layout === "json") return serializeJsonCatalog(catalog, options);
|
|
1316
|
+
return serializePo(catalog, options);
|
|
1317
|
+
}
|
|
1318
|
+
/** sha256 hex of a file's bytes: the compare-and-swap token. */
|
|
1319
|
+
function contentHash(data) {
|
|
1320
|
+
return hash("sha256", data, "hex");
|
|
1321
|
+
}
|
|
1322
|
+
const GENERATOR_LINE = /^"X-Generator: [^\n]*\n/m;
|
|
1323
|
+
/** Drops the `X-Generator` line, looking only inside the PO header block. */
|
|
1324
|
+
function withoutGenerator(text) {
|
|
1325
|
+
if (!text.startsWith("msgid \"\"")) return text;
|
|
1326
|
+
const headerEnd = text.indexOf("\n\n");
|
|
1327
|
+
const header = headerEnd === -1 ? text : text.slice(0, headerEnd + 1);
|
|
1328
|
+
const stripped = header.replace(GENERATOR_LINE, "");
|
|
1329
|
+
return stripped === header ? text : stripped + text.slice(header.length);
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Whether two catalog texts are the same output. The `X-Generator` value is
|
|
1333
|
+
* ignored, so upgrading vi18n alone never reports drift or rewrites catalogs
|
|
1334
|
+
* (INTERNALS §2.6.3); `extract --check`, `compile` and the dev writer use
|
|
1335
|
+
* this for every "unchanged" decision.
|
|
1336
|
+
*/
|
|
1337
|
+
function sameCatalogText(a, b) {
|
|
1338
|
+
return a === b || withoutGenerator(a) === withoutGenerator(b);
|
|
1339
|
+
}
|
|
1340
|
+
function displayPath$1(root, file) {
|
|
1341
|
+
const rel = relative(root, file);
|
|
1342
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return file.split(sep).join("/");
|
|
1343
|
+
return rel.split(sep).join("/");
|
|
1344
|
+
}
|
|
1345
|
+
function isMissing(error) {
|
|
1346
|
+
return error?.code === "ENOENT";
|
|
1347
|
+
}
|
|
1348
|
+
async function readOptional(path) {
|
|
1349
|
+
try {
|
|
1350
|
+
return await readFile(path);
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
if (isMissing(error)) return null;
|
|
1353
|
+
throw diagnosticError({
|
|
1354
|
+
code: "catalog-parse",
|
|
1355
|
+
severity: "error",
|
|
1356
|
+
message: `cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`,
|
|
1357
|
+
file: path
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true });
|
|
1362
|
+
/** Labels that mean UTF-8; `CHARSET` is the placeholder of an unfilled POT header. */
|
|
1363
|
+
const UTF8_LABELS = /* @__PURE__ */ new Set([
|
|
1364
|
+
"utf-8",
|
|
1365
|
+
"utf8",
|
|
1366
|
+
"unicode-1-1-utf-8",
|
|
1367
|
+
"charset"
|
|
1368
|
+
]);
|
|
1369
|
+
/**
|
|
1370
|
+
* The charset a PO header declares. GNU tools only write ASCII-compatible
|
|
1371
|
+
* charsets, so the header can be read as latin1 before the encoding is known.
|
|
1372
|
+
*/
|
|
1373
|
+
function poCharset(bytes) {
|
|
1374
|
+
const head = Buffer.from(bytes.buffer, bytes.byteOffset, Math.min(bytes.length, 8192)).toString("latin1").split(/\r?\n[ \t]*\r?\n/, 1)[0];
|
|
1375
|
+
if (!/^[ \t]*msgid[ \t]+""/m.test(head)) return null;
|
|
1376
|
+
return /^[ \t]*"Content-Type:[^"\n]*?charset=([^"\s;\\]+)/im.exec(head)?.[1] ?? null;
|
|
1377
|
+
}
|
|
1378
|
+
/** 1-based line of the first byte sequence that is not UTF-8 (`\n` never occurs inside one). */
|
|
1379
|
+
function firstInvalidLine(bytes) {
|
|
1380
|
+
let start = 0;
|
|
1381
|
+
for (let line = 1;; line++) {
|
|
1382
|
+
const end = bytes.indexOf(10, start);
|
|
1383
|
+
const stop = end === -1 ? bytes.length : end;
|
|
1384
|
+
try {
|
|
1385
|
+
UTF8.decode(bytes.subarray(start, stop));
|
|
1386
|
+
} catch {
|
|
1387
|
+
return line;
|
|
1388
|
+
}
|
|
1389
|
+
if (end === -1) return line;
|
|
1390
|
+
start = end + 1;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Catalog bytes as text. A PO file in another charset that GNU tools wrote
|
|
1395
|
+
* is transcoded, and the next write saves it as UTF-8. Bytes that do not
|
|
1396
|
+
* decode come back as a `catalog-parse` error with lossily decoded text: the
|
|
1397
|
+
* text keeps the rest of the file readable, the error stops any write that
|
|
1398
|
+
* would persist the replacement characters.
|
|
1399
|
+
*/
|
|
1400
|
+
function decodeCatalog(bytes, layout) {
|
|
1401
|
+
const charset = layout === "json" ? null : poCharset(bytes);
|
|
1402
|
+
const lossy = () => Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length).toString("utf8");
|
|
1403
|
+
if (charset === null || UTF8_LABELS.has(charset.toLowerCase())) try {
|
|
1404
|
+
return {
|
|
1405
|
+
text: UTF8.decode(bytes),
|
|
1406
|
+
diagnostic: null
|
|
1407
|
+
};
|
|
1408
|
+
} catch {
|
|
1409
|
+
return {
|
|
1410
|
+
text: lossy(),
|
|
1411
|
+
diagnostic: {
|
|
1412
|
+
code: "catalog-parse",
|
|
1413
|
+
severity: "error",
|
|
1414
|
+
message: "the file is not valid UTF-8; re-save it as UTF-8",
|
|
1415
|
+
line: firstInvalidLine(bytes)
|
|
1416
|
+
}
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
let decoder;
|
|
1420
|
+
try {
|
|
1421
|
+
decoder = new TextDecoder(charset, { fatal: true });
|
|
1422
|
+
} catch {
|
|
1423
|
+
return {
|
|
1424
|
+
text: lossy(),
|
|
1425
|
+
diagnostic: {
|
|
1426
|
+
code: "catalog-parse",
|
|
1427
|
+
severity: "error",
|
|
1428
|
+
message: `unsupported charset ${charset} in Content-Type; convert the file to UTF-8`
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
try {
|
|
1433
|
+
return {
|
|
1434
|
+
text: decoder.decode(bytes),
|
|
1435
|
+
diagnostic: null
|
|
1436
|
+
};
|
|
1437
|
+
} catch {
|
|
1438
|
+
return {
|
|
1439
|
+
text: lossy(),
|
|
1440
|
+
diagnostic: {
|
|
1441
|
+
code: "catalog-parse",
|
|
1442
|
+
severity: "error",
|
|
1443
|
+
message: `the file is not valid ${charset} as its Content-Type declares; fix its encoding`
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
function severityOf(level) {
|
|
1449
|
+
return level === "off" ? null : level === "warn" ? "warning" : "error";
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Sources that do not parse leave their entry without a key (INTERNALS
|
|
1453
|
+
* §2.6.2); they are reported here, once per load, on the entry's line. The
|
|
1454
|
+
* advice is what clears the report in every case: `vi18n extract` keeps a
|
|
1455
|
+
* generated entry it cannot match to code, and an `extraction.keep` id's.
|
|
1456
|
+
*/
|
|
1457
|
+
function invalidSources(catalog, codec, isSourceLocale, severity) {
|
|
1458
|
+
if (catalog.layout === "id" && !isSourceLocale) return [];
|
|
1459
|
+
const found = [];
|
|
1460
|
+
for (const entry of catalog.entries) {
|
|
1461
|
+
if (entry.source === null || entry.foreignContext !== null) continue;
|
|
1462
|
+
if (entryCanonical(entry, codec) !== null) continue;
|
|
1463
|
+
try {
|
|
1464
|
+
codec.canonicalize(entry.source);
|
|
1465
|
+
} catch (error) {
|
|
1466
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1467
|
+
const diagnostic = {
|
|
1468
|
+
code: "catalog-invalid-translation",
|
|
1469
|
+
severity,
|
|
1470
|
+
message: `the source ${JSON.stringify(entry.source)} is not valid ICU (${reason}); fix its source or delete the entry`,
|
|
1471
|
+
locale: catalog.locale
|
|
1472
|
+
};
|
|
1473
|
+
if (entry.manualId !== null) diagnostic.id = entry.manualId;
|
|
1474
|
+
if (entry.line > 0) diagnostic.line = entry.line;
|
|
1475
|
+
found.push(diagnostic);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
return found;
|
|
1479
|
+
}
|
|
1480
|
+
/**
|
|
1481
|
+
* Reads every locale's catalog (default: `config.locales`). Missing files
|
|
1482
|
+
* load as `catalog: null`. Entries are returned as read, duplicates
|
|
1483
|
+
* included: `resolveDuplicates` is the read-side pick.
|
|
1484
|
+
*/
|
|
1485
|
+
async function loadCatalogSet(config, codec, locales) {
|
|
1486
|
+
const layout = catalogLayout(config);
|
|
1487
|
+
const wanted = locales ?? config.locales;
|
|
1488
|
+
const invalid = severityOf(config.strict.invalid);
|
|
1489
|
+
const loaded = await Promise.all(wanted.map(async (locale) => {
|
|
1490
|
+
const path = catalogPath(config, locale);
|
|
1491
|
+
const bytes = await readOptional(path);
|
|
1492
|
+
if (bytes === null) return {
|
|
1493
|
+
catalog: {
|
|
1494
|
+
locale,
|
|
1495
|
+
path,
|
|
1496
|
+
catalog: null,
|
|
1497
|
+
hash: null
|
|
1498
|
+
},
|
|
1499
|
+
diagnostics: []
|
|
1500
|
+
};
|
|
1501
|
+
const isSourceLocale = locale === config.sourceLocale;
|
|
1502
|
+
const decoded = decodeCatalog(bytes, layout);
|
|
1503
|
+
const read = parseCatalog(decoded.text, {
|
|
1504
|
+
locale,
|
|
1505
|
+
layout,
|
|
1506
|
+
isSourceLocale
|
|
1507
|
+
});
|
|
1508
|
+
const diagnostics = read.diagnostics;
|
|
1509
|
+
if (decoded.diagnostic !== null) diagnostics.unshift(decoded.diagnostic);
|
|
1510
|
+
if (invalid !== null) diagnostics.push(...invalidSources(read.catalog, codec, isSourceLocale, invalid));
|
|
1511
|
+
const file = displayPath$1(config.root, path);
|
|
1512
|
+
for (const diagnostic of diagnostics) {
|
|
1513
|
+
diagnostic.file ??= file;
|
|
1514
|
+
diagnostic.locale ??= locale;
|
|
1515
|
+
}
|
|
1516
|
+
return {
|
|
1517
|
+
catalog: {
|
|
1518
|
+
locale,
|
|
1519
|
+
path,
|
|
1520
|
+
catalog: read.catalog,
|
|
1521
|
+
hash: contentHash(bytes)
|
|
1522
|
+
},
|
|
1523
|
+
diagnostics
|
|
1524
|
+
};
|
|
1525
|
+
}));
|
|
1526
|
+
const set = /* @__PURE__ */ new Map();
|
|
1527
|
+
const diagnostics = [];
|
|
1528
|
+
for (const item of loaded) {
|
|
1529
|
+
set.set(item.catalog.locale, item.catalog);
|
|
1530
|
+
diagnostics.push(...item.diagnostics);
|
|
1531
|
+
}
|
|
1532
|
+
if (config.extraction.dependencies.length > 0) diagnostics.push(...await attachLibraryCatalogs(set, config, codec, wanted));
|
|
1533
|
+
return {
|
|
1534
|
+
set,
|
|
1535
|
+
diagnostics
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* A catalog from bytes that did not come from its file (`git show` for
|
|
1540
|
+
* `vi18n restore`, the merge driver's temporary files), decoded as the file
|
|
1541
|
+
* readers decode them.
|
|
1542
|
+
*/
|
|
1543
|
+
function parseCatalogBytes(bytes, options) {
|
|
1544
|
+
const decoded = decodeCatalog(bytes, options.layout);
|
|
1545
|
+
const read = parseCatalog(decoded.text, options);
|
|
1546
|
+
if (decoded.diagnostic !== null) read.diagnostics.unshift(decoded.diagnostic);
|
|
1547
|
+
return read;
|
|
1548
|
+
}
|
|
1549
|
+
function parseForUpdate(decoded, path, options) {
|
|
1550
|
+
const read = parseCatalog(decoded.text, options);
|
|
1551
|
+
const error = decoded.diagnostic ?? read.diagnostics.find((diagnostic) => diagnostic.severity === "error");
|
|
1552
|
+
if (error !== void 0) throw diagnosticError({
|
|
1553
|
+
...error,
|
|
1554
|
+
file: error.file ?? path,
|
|
1555
|
+
locale: error.locale ?? options.locale,
|
|
1556
|
+
message: `${error.message}; fix ${basename(path)} before vi18n writes to it`
|
|
1557
|
+
});
|
|
1558
|
+
return read.catalog;
|
|
1559
|
+
}
|
|
1560
|
+
/** Tail of each file's write queue (in-process writers only). */
|
|
1561
|
+
const queues = /* @__PURE__ */ new Map();
|
|
1562
|
+
/**
|
|
1563
|
+
* Compare-and-swap write (INTERNALS §2.6.6). `update` receives the file as it
|
|
1564
|
+
* is now (null when missing) and returns the new catalog, or null to leave
|
|
1565
|
+
* the file alone; it may run several times, so it must derive its result
|
|
1566
|
+
* from its argument. The new content goes to a temp file in the same
|
|
1567
|
+
* directory, the target is re-read, and the temp file is renamed over it
|
|
1568
|
+
* only if the target still has the hash `update` saw; otherwise `update`
|
|
1569
|
+
* re-runs on the new content. Unchanged output (ignoring `X-Generator`) is
|
|
1570
|
+
* never written. `retried` counts re-runs, including one for a file that
|
|
1571
|
+
* already differed from `expectedHash` on entry.
|
|
1572
|
+
*/
|
|
1573
|
+
function updateCatalogFile(path, expectedHash, update, options) {
|
|
1574
|
+
const key = resolve(path);
|
|
1575
|
+
const run = (queues.get(key) ?? Promise.resolve()).then(() => writeCatalogFile(path, expectedHash, update, options), () => writeCatalogFile(path, expectedHash, update, options));
|
|
1576
|
+
const settled = run.then(() => void 0, () => void 0);
|
|
1577
|
+
queues.set(key, settled);
|
|
1578
|
+
settled.then(() => {
|
|
1579
|
+
if (queues.get(key) === settled) queues.delete(key);
|
|
1580
|
+
});
|
|
1581
|
+
return run;
|
|
1582
|
+
}
|
|
1583
|
+
async function writeCatalogFile(path, expectedHash, update, options) {
|
|
1584
|
+
const readOptions = {
|
|
1585
|
+
locale: options.locale,
|
|
1586
|
+
layout: options.layout,
|
|
1587
|
+
isSourceLocale: options.isSourceLocale
|
|
1588
|
+
};
|
|
1589
|
+
const writeOptions = {
|
|
1590
|
+
isSourceLocale: options.isSourceLocale,
|
|
1591
|
+
version: options.version
|
|
1592
|
+
};
|
|
1593
|
+
let bytes = await readOptional(path);
|
|
1594
|
+
let currentHash = bytes === null ? null : contentHash(bytes);
|
|
1595
|
+
let retried = currentHash === expectedHash ? 0 : 1;
|
|
1596
|
+
const directory = dirname(path);
|
|
1597
|
+
for (;;) {
|
|
1598
|
+
const decoded = bytes === null ? null : decodeCatalog(bytes, options.layout);
|
|
1599
|
+
const text = decoded === null ? null : decoded.text;
|
|
1600
|
+
const next = update(decoded === null ? null : parseForUpdate(decoded, path, readOptions));
|
|
1601
|
+
if (next === null) return {
|
|
1602
|
+
written: false,
|
|
1603
|
+
hash: currentHash,
|
|
1604
|
+
retried
|
|
1605
|
+
};
|
|
1606
|
+
const output = serializeCatalog(next, writeOptions);
|
|
1607
|
+
if (text !== null && sameCatalogText(output, text)) return {
|
|
1608
|
+
written: false,
|
|
1609
|
+
hash: currentHash,
|
|
1610
|
+
retried
|
|
1611
|
+
};
|
|
1612
|
+
if (bytes === null) await mkdir(directory, { recursive: true });
|
|
1613
|
+
const temp = join(directory, `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
1614
|
+
await writeFile(temp, output);
|
|
1615
|
+
let renamed = false;
|
|
1616
|
+
try {
|
|
1617
|
+
const now = await readOptional(path);
|
|
1618
|
+
const nowHash = now === null ? null : contentHash(now);
|
|
1619
|
+
if (nowHash === currentHash) {
|
|
1620
|
+
await rename(temp, path);
|
|
1621
|
+
renamed = true;
|
|
1622
|
+
return {
|
|
1623
|
+
written: true,
|
|
1624
|
+
hash: contentHash(output),
|
|
1625
|
+
retried
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
bytes = now;
|
|
1629
|
+
currentHash = nowHash;
|
|
1630
|
+
} finally {
|
|
1631
|
+
if (!renamed) await rm(temp, { force: true });
|
|
1632
|
+
}
|
|
1633
|
+
if (++retried > MAX_RETRIES) throw diagnosticError({
|
|
1634
|
+
code: "catalog-write-conflict",
|
|
1635
|
+
severity: "error",
|
|
1636
|
+
message: `${basename(path)} kept changing while vi18n wrote it (${MAX_RETRIES} retries); run the command again`,
|
|
1637
|
+
file: path,
|
|
1638
|
+
locale: options.locale
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
//#endregion
|
|
1643
|
+
//#region src/core/catalog/library.ts
|
|
1644
|
+
/**
|
|
1645
|
+
* Library mode, the app's side (PLAN §4.7, §5.24; INTERNALS §2.6.11): what an
|
|
1646
|
+
* opted-in package ships next to its code. Its `vi18n.messages.json` (written
|
|
1647
|
+
* by its own `build.lib` build) lists its messages and its catalogs, and
|
|
1648
|
+
* `package.json#vi18n.messages` points at it. Each catalog loads under the
|
|
1649
|
+
* app's catalog for the same locale (`LoadedCatalog.libraries`), its entries
|
|
1650
|
+
* joined to the app's ids by canonical source or manual id, never by label:
|
|
1651
|
+
* a library built with another `ids.length`, or another msgid layout, still
|
|
1652
|
+
* matches. Precedence is app catalog, then library catalogs in
|
|
1653
|
+
* `extraction.dependencies` order, then the source, at each step of a
|
|
1654
|
+
* fallback chain.
|
|
1655
|
+
*/
|
|
1656
|
+
/** The file a library build writes beside its code (`build.lib`). */
|
|
1657
|
+
const MESSAGES_FILE = "vi18n.messages.json";
|
|
1658
|
+
const manifests = /* @__PURE__ */ new WeakMap();
|
|
1659
|
+
/** Every opted-in package's manifest (those with `package.json#vi18n.messages`), read once per config. */
|
|
1660
|
+
function libraryManifests(config) {
|
|
1661
|
+
let list = manifests.get(config.extraction);
|
|
1662
|
+
if (list !== void 0) return list;
|
|
1663
|
+
list = [];
|
|
1664
|
+
for (const dependency of dependenciesOf(config)) {
|
|
1665
|
+
if (dependency.messages === null) continue;
|
|
1666
|
+
list.push({
|
|
1667
|
+
name: dependency.name,
|
|
1668
|
+
file: dependency.messages,
|
|
1669
|
+
...readManifest(dependency.messages, config.root)
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
manifests.set(config.extraction, list);
|
|
1673
|
+
return list;
|
|
1674
|
+
}
|
|
1675
|
+
function readManifest(file, root) {
|
|
1676
|
+
const shown = displayPath(root, file);
|
|
1677
|
+
let data;
|
|
1678
|
+
try {
|
|
1679
|
+
data = JSON.parse(readFileSync(file, "utf8"));
|
|
1680
|
+
} catch (error) {
|
|
1681
|
+
return {
|
|
1682
|
+
manifest: null,
|
|
1683
|
+
diagnostic: {
|
|
1684
|
+
code: "catalog-parse",
|
|
1685
|
+
severity: "warning",
|
|
1686
|
+
message: `${shown} cannot be read (${error.message}); the package's translations are left out`,
|
|
1687
|
+
file: shown
|
|
1688
|
+
}
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
const manifest = data;
|
|
1692
|
+
if (manifest === null || typeof manifest !== "object" || manifest.vi18n !== 1 || !Array.isArray(manifest.messages)) return {
|
|
1693
|
+
manifest: null,
|
|
1694
|
+
diagnostic: {
|
|
1695
|
+
code: "catalog-parse",
|
|
1696
|
+
severity: "warning",
|
|
1697
|
+
message: `${shown} is not a vi18n.messages.json of version 1; the package's translations are left out`,
|
|
1698
|
+
file: shown
|
|
1699
|
+
}
|
|
1700
|
+
};
|
|
1701
|
+
return {
|
|
1702
|
+
manifest,
|
|
1703
|
+
diagnostic: null
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* The manual-id messages of every opted-in package, first package first:
|
|
1708
|
+
* what `msg({ id })` may name without a message (`vi18n.gen.ts` types them).
|
|
1709
|
+
*/
|
|
1710
|
+
function libraryIds(config) {
|
|
1711
|
+
const out = /* @__PURE__ */ new Map();
|
|
1712
|
+
for (const { manifest } of libraryManifests(config)) for (const message of manifest?.messages ?? []) if (typeof message.id === "string" && typeof message.message === "string" && !out.has(message.id)) out.set(message.id, message.message);
|
|
1713
|
+
return out;
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Loads the opted-in packages' catalogs for `locales` under the app's
|
|
1717
|
+
* (`LoadedCatalog.libraries`), normalised for joining (below). The source
|
|
1718
|
+
* locale compiles from the code, so its library catalogs are read only for a
|
|
1719
|
+
* package written in another language: its texts are not the app's source
|
|
1720
|
+
* language, and its catalog for that language is what those pages show.
|
|
1721
|
+
*/
|
|
1722
|
+
async function attachLibraryCatalogs(set, config, codec, locales) {
|
|
1723
|
+
const diagnostics = [];
|
|
1724
|
+
if (config.extraction.dependencies.length === 0) return diagnostics;
|
|
1725
|
+
const layout = catalogLayout(config);
|
|
1726
|
+
for (const loaded of libraryManifests(config)) {
|
|
1727
|
+
if (loaded.diagnostic !== null) diagnostics.push(loaded.diagnostic);
|
|
1728
|
+
const catalogs = loaded.manifest?.catalogs;
|
|
1729
|
+
if (catalogs == null || typeof catalogs.files !== "object") continue;
|
|
1730
|
+
const from = catalogs.format === "json" ? "json" : catalogs.msgid === "id" ? "id" : "source";
|
|
1731
|
+
const foreign = typeof loaded.manifest?.sourceLocale === "string" && loaded.manifest.sourceLocale !== config.sourceLocale;
|
|
1732
|
+
for (const locale of locales) {
|
|
1733
|
+
if (locale === config.sourceLocale && !foreign) continue;
|
|
1734
|
+
const own = set.get(locale);
|
|
1735
|
+
const relativePath = Object.hasOwn(catalogs.files, locale) ? catalogs.files[locale] : void 0;
|
|
1736
|
+
if (locale === config.sourceLocale && typeof relativePath !== "string") {
|
|
1737
|
+
diagnostics.push({
|
|
1738
|
+
code: "catalog-parse",
|
|
1739
|
+
severity: "warning",
|
|
1740
|
+
message: `${loaded.name} is written in ${loaded.manifest?.sourceLocale} and ships no ${locale} catalog; its messages show in ${loaded.manifest?.sourceLocale} on ${locale} pages`,
|
|
1741
|
+
file: displayPath(config.root, loaded.file),
|
|
1742
|
+
locale
|
|
1743
|
+
});
|
|
1744
|
+
continue;
|
|
1745
|
+
}
|
|
1746
|
+
if (own === void 0 || typeof relativePath !== "string") continue;
|
|
1747
|
+
const path = resolve(dirname(loaded.file), relativePath);
|
|
1748
|
+
let text;
|
|
1749
|
+
try {
|
|
1750
|
+
text = await readFile(path, "utf8");
|
|
1751
|
+
} catch {
|
|
1752
|
+
diagnostics.push({
|
|
1753
|
+
code: "catalog-parse",
|
|
1754
|
+
severity: "warning",
|
|
1755
|
+
message: `${loaded.name} lists a ${locale} catalog it does not ship (${relativePath}); its ${locale} translations are left out`,
|
|
1756
|
+
file: displayPath(config.root, loaded.file),
|
|
1757
|
+
locale
|
|
1758
|
+
});
|
|
1759
|
+
continue;
|
|
1760
|
+
}
|
|
1761
|
+
const read = parseCatalog(text, {
|
|
1762
|
+
locale,
|
|
1763
|
+
layout: from,
|
|
1764
|
+
isSourceLocale: false
|
|
1765
|
+
});
|
|
1766
|
+
for (const diagnostic of read.diagnostics) diagnostics.push({
|
|
1767
|
+
...diagnostic,
|
|
1768
|
+
severity: diagnostic.severity === "error" ? "warning" : diagnostic.severity,
|
|
1769
|
+
file: diagnostic.file ?? displayPath(config.root, path),
|
|
1770
|
+
locale
|
|
1771
|
+
});
|
|
1772
|
+
const library = {
|
|
1773
|
+
locale,
|
|
1774
|
+
path,
|
|
1775
|
+
catalog: joined(read.catalog, layout, codec),
|
|
1776
|
+
hash: contentHash(text),
|
|
1777
|
+
library: true,
|
|
1778
|
+
...foreign ? { librarySource: loaded.manifest?.sourceLocale } : {}
|
|
1779
|
+
};
|
|
1780
|
+
set.set(locale, {
|
|
1781
|
+
...own,
|
|
1782
|
+
libraries: [...own.libraries ?? [], library]
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
return diagnostics;
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* A library catalog in the app's terms: the app's layout, no recorded
|
|
1790
|
+
* scheme, and every entry's `label` computed from its source by the app's
|
|
1791
|
+
* codec, so `entryKey`, `sourceMatches` and duplicates join by canonical
|
|
1792
|
+
* source (generated ids) or manual id whatever the library's `ids.length`
|
|
1793
|
+
* and layout. Entries without a source that parses cannot join and are
|
|
1794
|
+
* left out.
|
|
1795
|
+
*/
|
|
1796
|
+
function joined(catalog, layout, codec) {
|
|
1797
|
+
const entries = [];
|
|
1798
|
+
for (const entry of catalog.entries) {
|
|
1799
|
+
if (entry.foreignContext !== null) continue;
|
|
1800
|
+
const canonical = entryCanonical(entry, codec);
|
|
1801
|
+
if (canonical === null) continue;
|
|
1802
|
+
entries.push({
|
|
1803
|
+
...entry,
|
|
1804
|
+
source: canonical,
|
|
1805
|
+
label: codec.label(canonical)
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
return {
|
|
1809
|
+
...catalog,
|
|
1810
|
+
layout,
|
|
1811
|
+
header: {
|
|
1812
|
+
...catalog.header,
|
|
1813
|
+
idScheme: null
|
|
1814
|
+
},
|
|
1815
|
+
entries,
|
|
1816
|
+
obsolete: [],
|
|
1817
|
+
droppedObsolete: 0
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
function displayPath(root, file) {
|
|
1821
|
+
const rel = relative(root, file);
|
|
1822
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return file.split(sep).join("/");
|
|
1823
|
+
return rel.split(sep).join("/");
|
|
1824
|
+
}
|
|
1825
|
+
function libraryCoverage(set, messages, config, codec) {
|
|
1826
|
+
const layout = catalogLayout(config);
|
|
1827
|
+
const locales = /* @__PURE__ */ new Map();
|
|
1828
|
+
let any = false;
|
|
1829
|
+
for (const locale of config.locales) {
|
|
1830
|
+
if (locale === config.sourceLocale) continue;
|
|
1831
|
+
const covered = /* @__PURE__ */ new Set();
|
|
1832
|
+
locales.set(locale, covered);
|
|
1833
|
+
for (const library of set.get(locale)?.libraries ?? []) {
|
|
1834
|
+
any = true;
|
|
1835
|
+
if (library.catalog === null) continue;
|
|
1836
|
+
for (const entry of library.catalog.entries) {
|
|
1837
|
+
const key = entryKey(entry, layout, codec);
|
|
1838
|
+
const message = key === null ? void 0 : messages.get(key);
|
|
1839
|
+
if (message === void 0 || covered.has(message.id)) continue;
|
|
1840
|
+
if (usableTranslation(entry, layout, message, codec, locale).ok) covered.add(message.id);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
if (!any) return null;
|
|
1845
|
+
const targets = [...locales.values()];
|
|
1846
|
+
const everywhere = /* @__PURE__ */ new Set();
|
|
1847
|
+
for (const id of targets[0] ?? []) if (targets.every((covered) => covered.has(id))) everywhere.add(id);
|
|
1848
|
+
return {
|
|
1849
|
+
locales,
|
|
1850
|
+
everywhere
|
|
1851
|
+
};
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* The messages a locale's own catalog lists (§2.6.11): an opted-in package
|
|
1855
|
+
* translating a message in that locale makes the app's entry unnecessary,
|
|
1856
|
+
* unless the app translated it itself (an override, kept). The source
|
|
1857
|
+
* locale's catalog leaves out what the packages translate everywhere.
|
|
1858
|
+
*/
|
|
1859
|
+
function ownedMessages(messages, locale, catalog, coverage, context) {
|
|
1860
|
+
if (coverage == null) return messages;
|
|
1861
|
+
const covered = locale === context.sourceLocale ? coverage.everywhere : coverage.locales.get(locale);
|
|
1862
|
+
if (covered === void 0 || covered.size === 0) return messages;
|
|
1863
|
+
const overridden = /* @__PURE__ */ new Set();
|
|
1864
|
+
if (locale !== context.sourceLocale) for (const entry of catalog?.entries ?? []) {
|
|
1865
|
+
if (entry.translation === "") continue;
|
|
1866
|
+
const key = entryKey(entry, context.layout, context.codec);
|
|
1867
|
+
if (key !== null && covered.has(key)) overridden.add(key);
|
|
1868
|
+
}
|
|
1869
|
+
const out = /* @__PURE__ */ new Map();
|
|
1870
|
+
for (const [id, message] of messages) if (!covered.has(id) || overridden.has(id)) out.set(id, message);
|
|
1871
|
+
return out;
|
|
1872
|
+
}
|
|
1873
|
+
/** The first library entry of `loaded` that translates `message` usably, for `status` and `check`. */
|
|
1874
|
+
function libraryTranslation(loaded, message, layout, codec) {
|
|
1875
|
+
for (const library of loaded?.libraries ?? []) for (const entry of library.catalog?.entries ?? []) {
|
|
1876
|
+
if (entryKey(entry, layout, codec) !== message.id) continue;
|
|
1877
|
+
if (usableTranslation(entry, layout, message, codec, library.locale).ok) return entry;
|
|
1878
|
+
}
|
|
1879
|
+
return null;
|
|
1880
|
+
}
|
|
1881
|
+
//#endregion
|
|
1882
|
+
export { usableTranslation as C, dependencyRef as D, dependencyOf as E, listDependencyFiles as O, entryKey as S, dependenciesOf as T, parseJsonCatalog as _, ownedMessages as a, emptyCatalog as b, contentHash as c, parseCatalogBytes as d, sameCatalogText as f, serializePo as g, parsePo as h, libraryTranslation as i, loadCatalogSet as l, updateCatalogFile as m, libraryCoverage as n, catalogLayout as o, serializeCatalog as p, libraryIds as r, catalogPath as s, MESSAGES_FILE as t, parseCatalog as u, serializeJsonCatalog as v, sourceMatches as w, entryCanonical as x, createEntry as y };
|