@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,658 @@
|
|
|
1
|
+
import { o as isDiagnosticError, t as CONFIG_FILE } from "./types-D3QGM8XO.js";
|
|
2
|
+
import { s as parseIdScheme } from "./validate-BxpupdAf.js";
|
|
3
|
+
import { S as entryKey, a as ownedMessages, b as emptyCatalog, f as sameCatalogText, n as libraryCoverage, o as catalogLayout, p as serializeCatalog, r as libraryIds, s as catalogPath, y as createEntry } from "./library-D5CWvTQs.js";
|
|
4
|
+
import { t as VERSION } from "./version-BlyLGlOa.js";
|
|
5
|
+
import { n as mergeDuplicates } from "./duplicates-yuRljQ6h.js";
|
|
6
|
+
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
9
|
+
//#region src/types-gen/gen.ts
|
|
10
|
+
/**
|
|
11
|
+
* `vi18n.gen.ts` (docs/INTERNALS.md §2.16, PLAN §5.18): the generated
|
|
12
|
+
* `Register` augmentation that turns `Locale` into the configured union.
|
|
13
|
+
* Written beside `vi18n.config.ts` by the plugin and by `vi18n init`/`extract`,
|
|
14
|
+
* and committed like `routeTree.gen.ts`.
|
|
15
|
+
*/
|
|
16
|
+
/** The generated file's name, in the directory of `vi18n.config.ts`. */
|
|
17
|
+
const GEN_FILE = "vi18n.gen.ts";
|
|
18
|
+
/** The `locales` union: configured locales in config order, pseudo-locales left out. */
|
|
19
|
+
function localesUnion(config) {
|
|
20
|
+
const pseudo = config.pseudoLocales;
|
|
21
|
+
let union = "";
|
|
22
|
+
for (const locale of config.locales) {
|
|
23
|
+
if (pseudo.includes(locale)) continue;
|
|
24
|
+
union += (union ? " | " : "") + JSON.stringify(locale);
|
|
25
|
+
}
|
|
26
|
+
return union || "never";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* `messages`: the manual-id messages of the packages `extraction.dependencies`
|
|
30
|
+
* opts in (their `vi18n.messages.json`, §2.16), by id, so `msg({ id })` is
|
|
31
|
+
* typed from the library's text. Empty without any.
|
|
32
|
+
*/
|
|
33
|
+
function messagesMember(config) {
|
|
34
|
+
if (config.extraction.dependencies.length === 0) return "";
|
|
35
|
+
const ids = [...libraryIds(config)].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
36
|
+
if (ids.length === 0) return "";
|
|
37
|
+
let out = ` messages: {\n`;
|
|
38
|
+
for (const [id, message] of ids) out += ` ${JSON.stringify(id)}: ${JSON.stringify(message)};\n`;
|
|
39
|
+
return out + ` };\n`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The file's exact content: configured locales in config order, pseudo-locales
|
|
43
|
+
* left out (they are switched on per build and never part of the app's API),
|
|
44
|
+
* then the opted-in packages' manual-id messages, if any.
|
|
45
|
+
*/
|
|
46
|
+
function genFileContent(config) {
|
|
47
|
+
return `// Generated by @menapo/vi18n from ${CONFIG_FILE}. Do not edit; commit it.\nimport type {} from "@menapo/vi18n";\n\ndeclare module "@menapo/vi18n" {\n interface Register {\n locales: ${localesUnion(config)};\n` + messagesMember(config) + " }\n}\n";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* What earlier releases wrote for the same locales (no import, `export {}`
|
|
51
|
+
* last). The drift checks accept it: like the catalogs' `X-Generator` line
|
|
52
|
+
* (INTERNALS §2.6.3), a vi18n upgrade alone must not fail `extract --check`
|
|
53
|
+
* or make `compile` refuse. Any write replaces it with the current content.
|
|
54
|
+
*/
|
|
55
|
+
function previousGenFileContent(config) {
|
|
56
|
+
return `// Generated by @menapo/vi18n from ${CONFIG_FILE}. Do not edit; commit it.\ndeclare module "@menapo/vi18n" {\n interface Register {\n locales: ${localesUnion(config)};\n }\n}\nexport {};\n`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether `text` (the file as read) is up to date for the drift checks: the
|
|
60
|
+
* current content or an earlier release's for the same locales. CRLF line
|
|
61
|
+
* ends (a checkout with `core.autocrlf`) are not drift either.
|
|
62
|
+
*/
|
|
63
|
+
function genFileUpToDate(config, text) {
|
|
64
|
+
const normalized = text.replace(/\r\n/g, "\n");
|
|
65
|
+
return normalized === genFileContent(config) || normalized === previousGenFileContent(config);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Writes `vi18n.gen.ts` beside the config when its content changed; true when
|
|
69
|
+
* written. Zero-config mode (no config file) writes nothing (INTERNALS
|
|
70
|
+
* §2.7.1). The plugin's environments and the CLI may write concurrently, so
|
|
71
|
+
* the file is replaced by an atomic rename and never seen half-written by the
|
|
72
|
+
* editor's TypeScript server.
|
|
73
|
+
*/
|
|
74
|
+
async function writeGenFile(config) {
|
|
75
|
+
if (config.configFile === null) return false;
|
|
76
|
+
const path = join(dirname(config.configFile), GEN_FILE);
|
|
77
|
+
const content = genFileContent(config);
|
|
78
|
+
let current = null;
|
|
79
|
+
try {
|
|
80
|
+
current = await readFile(path, "utf8");
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code !== "ENOENT") throw error;
|
|
83
|
+
}
|
|
84
|
+
if (current !== null && current.replace(/\r\n/g, "\n") === content) return false;
|
|
85
|
+
const temp = join(dirname(path), `.${GEN_FILE}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
86
|
+
try {
|
|
87
|
+
await writeFile(temp, content);
|
|
88
|
+
await rename(temp, path);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
await rm(temp, { force: true });
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/core/sync.ts
|
|
97
|
+
/**
|
|
98
|
+
* Catalog sync (INTERNALS §2.6.8, PLAN §5.5): what `vi18n extract` (`full`)
|
|
99
|
+
* and the dev writer or `extract --watch` (`add-only`) do to each locale's
|
|
100
|
+
* catalog given the scanned messages. Pure: callers read the files, write
|
|
101
|
+
* the result through `updateCatalogFile` and re-run `syncCatalog` when a file
|
|
102
|
+
* changed under them.
|
|
103
|
+
*
|
|
104
|
+
* Only a one-shot `full` sync clears or deletes, so a branch switch with the
|
|
105
|
+
* dev server running can never destroy translations; `full` is in turn held
|
|
106
|
+
* back by the safety limit unless the user passes `--yes`.
|
|
107
|
+
*/
|
|
108
|
+
/** Removals allowed without `--yes` whatever the catalog size (PLAN §5.5). */
|
|
109
|
+
const LIMIT_FLOOR = 50;
|
|
110
|
+
/** Share of a locale's translated entries one extract may clear or delete without `--yes`. */
|
|
111
|
+
const LIMIT_SHARE = .1;
|
|
112
|
+
function syncCatalogs(input) {
|
|
113
|
+
const { config, codec, messages, catalogs, options } = input;
|
|
114
|
+
const sourceCatalog = catalogs.get(config.sourceLocale)?.catalog ?? null;
|
|
115
|
+
const result = {
|
|
116
|
+
catalogs: /* @__PURE__ */ new Map(),
|
|
117
|
+
changed: [],
|
|
118
|
+
stats: {},
|
|
119
|
+
blocked: [],
|
|
120
|
+
deletedKeys: [],
|
|
121
|
+
diagnostics: []
|
|
122
|
+
};
|
|
123
|
+
const deleted = /* @__PURE__ */ new Set();
|
|
124
|
+
const limits = [];
|
|
125
|
+
let overLimit = false;
|
|
126
|
+
const coverage = libraryCoverage(catalogs, messages, config, codec);
|
|
127
|
+
for (const locale of config.locales) {
|
|
128
|
+
const loaded = catalogs.get(locale);
|
|
129
|
+
const read = loaded?.catalog ?? null;
|
|
130
|
+
const synced = syncLocale({
|
|
131
|
+
config,
|
|
132
|
+
codec,
|
|
133
|
+
messages,
|
|
134
|
+
locale,
|
|
135
|
+
catalog: read,
|
|
136
|
+
sourceCatalog,
|
|
137
|
+
options,
|
|
138
|
+
coverage
|
|
139
|
+
});
|
|
140
|
+
const file = displayPath(config.root, loaded?.path ?? catalogPath(config, locale));
|
|
141
|
+
for (const diagnostic of synced.diagnostics) {
|
|
142
|
+
diagnostic.file ??= file;
|
|
143
|
+
diagnostic.locale ??= locale;
|
|
144
|
+
result.diagnostics.push(diagnostic);
|
|
145
|
+
}
|
|
146
|
+
result.catalogs.set(locale, synced.catalog);
|
|
147
|
+
result.stats[locale] = synced.stats;
|
|
148
|
+
if (wouldChange(read, synced.catalog, locale === config.sourceLocale)) result.changed.push(locale);
|
|
149
|
+
for (const key of synced.deleted) deleted.add(key);
|
|
150
|
+
if (options.mode !== "full" || locale === config.sourceLocale) continue;
|
|
151
|
+
const limit = Math.max(LIMIT_FLOOR, Math.ceil(LIMIT_SHARE * translatedCount(read)));
|
|
152
|
+
limits.push({
|
|
153
|
+
locale,
|
|
154
|
+
removals: synced.removals,
|
|
155
|
+
limit
|
|
156
|
+
});
|
|
157
|
+
if (synced.removals > limit && !options.yes) {
|
|
158
|
+
overLimit = true;
|
|
159
|
+
result.diagnostics.push({
|
|
160
|
+
code: "catalog-safety-limit",
|
|
161
|
+
severity: "error",
|
|
162
|
+
message: `vi18n extract would clear or delete ${synced.removals} translated ${synced.removals === 1 ? "entry" : "entries"} in ${locale}, over its limit of ${limit} (10 % of its translated entries, at least ${LIMIT_FLOOR}); check the scan covers every app, then run vi18n extract --yes`,
|
|
163
|
+
file,
|
|
164
|
+
locale
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (overLimit) result.blocked = limits;
|
|
169
|
+
result.deletedKeys = [...deleted].sort(compareText);
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
/** One locale; what writers re-run inside `updateCatalogFile` when the file changed under them. */
|
|
173
|
+
function syncCatalog(input) {
|
|
174
|
+
const { catalog, stats, removals, diagnostics } = syncLocale(input);
|
|
175
|
+
return {
|
|
176
|
+
catalog,
|
|
177
|
+
stats,
|
|
178
|
+
removals,
|
|
179
|
+
diagnostics
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function syncLocale(input) {
|
|
183
|
+
const { config, codec, locale } = input;
|
|
184
|
+
const layout = catalogLayout(config);
|
|
185
|
+
const messages = ownedMessages(input.messages, locale, input.catalog, input.coverage, {
|
|
186
|
+
sourceLocale: config.sourceLocale,
|
|
187
|
+
layout,
|
|
188
|
+
codec
|
|
189
|
+
});
|
|
190
|
+
const base = input.catalog ?? emptyCatalog(locale, layout, {
|
|
191
|
+
idScheme: codec.idScheme,
|
|
192
|
+
sourceLocale: config.sourceLocale
|
|
193
|
+
});
|
|
194
|
+
const recorded = base.header.idScheme;
|
|
195
|
+
const rekey = layout !== "source" && recorded !== null && recorded !== codec.idScheme;
|
|
196
|
+
const context = {
|
|
197
|
+
config,
|
|
198
|
+
codec,
|
|
199
|
+
layout,
|
|
200
|
+
isSource: locale === config.sourceLocale,
|
|
201
|
+
messages,
|
|
202
|
+
sourceCatalog: input.sourceCatalog,
|
|
203
|
+
sourceIndex: sourceIndex(input.sourceCatalog, layout, locale === config.sourceLocale),
|
|
204
|
+
icuFlag: config.catalogs.icuFlag && layout !== "json"
|
|
205
|
+
};
|
|
206
|
+
if (input.options.mode === "add-only") {
|
|
207
|
+
if (rekey) return {
|
|
208
|
+
catalog: base,
|
|
209
|
+
stats: emptyStats(),
|
|
210
|
+
removals: 0,
|
|
211
|
+
diagnostics: [schemeDiagnostic(recorded, codec)],
|
|
212
|
+
deleted: []
|
|
213
|
+
};
|
|
214
|
+
return addOnly(base, context);
|
|
215
|
+
}
|
|
216
|
+
return full(base, context, rekey);
|
|
217
|
+
}
|
|
218
|
+
function emptyStats() {
|
|
219
|
+
return {
|
|
220
|
+
added: 0,
|
|
221
|
+
deleted: 0,
|
|
222
|
+
cleared: 0,
|
|
223
|
+
moved: 0,
|
|
224
|
+
rekeyed: 0,
|
|
225
|
+
updated: 0
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function addOnly(catalog, context) {
|
|
229
|
+
const { codec, layout, messages } = context;
|
|
230
|
+
const stats = emptyStats();
|
|
231
|
+
const index = /* @__PURE__ */ new Map();
|
|
232
|
+
const entries = catalog.entries;
|
|
233
|
+
for (let i = 0; i < entries.length; i++) {
|
|
234
|
+
const key = entryKey(entries[i], layout, codec);
|
|
235
|
+
if (key !== null && !index.has(key)) index.set(key, i);
|
|
236
|
+
}
|
|
237
|
+
let out = null;
|
|
238
|
+
for (const message of messages.values()) {
|
|
239
|
+
const at = index.get(message.id);
|
|
240
|
+
if (at === void 0) {
|
|
241
|
+
(out ??= entries.slice()).push(newEntry(message, context));
|
|
242
|
+
stats.added++;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const entry = entries[at];
|
|
246
|
+
if (classify(entry, message, context) !== "same") continue;
|
|
247
|
+
const grown = withMissingNotes(entry, message);
|
|
248
|
+
if (grown !== entry) {
|
|
249
|
+
(out ??= entries.slice())[at] = grown;
|
|
250
|
+
stats.updated++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const header = completeHeader(catalog.header, context, false);
|
|
254
|
+
if (out === null && header === catalog.header) return {
|
|
255
|
+
catalog,
|
|
256
|
+
stats,
|
|
257
|
+
removals: 0,
|
|
258
|
+
diagnostics: [],
|
|
259
|
+
deleted: []
|
|
260
|
+
};
|
|
261
|
+
return {
|
|
262
|
+
catalog: {
|
|
263
|
+
...catalog,
|
|
264
|
+
header,
|
|
265
|
+
entries: out ?? entries
|
|
266
|
+
},
|
|
267
|
+
stats,
|
|
268
|
+
removals: 0,
|
|
269
|
+
diagnostics: [],
|
|
270
|
+
deleted: []
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/** Adds the scan's references and notes an entry lacks, keeping everything it has. */
|
|
274
|
+
function withMissingNotes(entry, message) {
|
|
275
|
+
let refs = null;
|
|
276
|
+
for (const ref of message.refs) if (!entry.refs.includes(ref)) (refs ??= entry.refs.slice()).push(ref);
|
|
277
|
+
let contexts = null;
|
|
278
|
+
for (const note of message.contexts) if (!entry.contexts.includes(note)) (contexts ??= entry.contexts.slice()).push(note);
|
|
279
|
+
if (refs === null && contexts === null) return entry;
|
|
280
|
+
return {
|
|
281
|
+
...entry,
|
|
282
|
+
refs: refs === null ? entry.refs : refs.sort(compareText),
|
|
283
|
+
contexts: contexts ?? entry.contexts
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function full(read, context, rekey) {
|
|
287
|
+
const { codec, layout, messages, isSource } = context;
|
|
288
|
+
const stats = emptyStats();
|
|
289
|
+
const diagnostics = [];
|
|
290
|
+
let catalog = read;
|
|
291
|
+
if (rekey) {
|
|
292
|
+
const rekeyed = rekeyCatalog(catalog, context);
|
|
293
|
+
catalog = rekeyed.catalog;
|
|
294
|
+
stats.rekeyed = rekeyed.count;
|
|
295
|
+
}
|
|
296
|
+
catalog = mergeDuplicates(catalog, codec);
|
|
297
|
+
const keep = new Set(context.config.extraction.keep);
|
|
298
|
+
const out = [];
|
|
299
|
+
const matched = /* @__PURE__ */ new Set();
|
|
300
|
+
const deletions = [];
|
|
301
|
+
let removals = 0;
|
|
302
|
+
for (const entry of catalog.entries) {
|
|
303
|
+
const key = entryKey(entry, layout, codec);
|
|
304
|
+
if (key === null) {
|
|
305
|
+
out.push(entry);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const message = messages.get(key);
|
|
309
|
+
if (message === void 0) {
|
|
310
|
+
if (keep.has(key)) out.push(entry);
|
|
311
|
+
else deletions.push({
|
|
312
|
+
entry,
|
|
313
|
+
key,
|
|
314
|
+
canonical: canonicalOf(sourceText(entry, context), codec),
|
|
315
|
+
movedTo: false
|
|
316
|
+
});
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const kind = classify(entry, message, context);
|
|
320
|
+
if (kind === "other") {
|
|
321
|
+
deletions.push({
|
|
322
|
+
entry,
|
|
323
|
+
key,
|
|
324
|
+
canonical: canonicalOf(sourceText(entry, context), codec),
|
|
325
|
+
movedTo: false
|
|
326
|
+
});
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
matched.add(key);
|
|
330
|
+
if (kind === "changed") {
|
|
331
|
+
if (!isSource && hasTranslation(entry)) {
|
|
332
|
+
stats.cleared++;
|
|
333
|
+
removals++;
|
|
334
|
+
}
|
|
335
|
+
out.push(rewritten(entry, message, layout));
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const refreshed = refresh(entry, message, layout);
|
|
339
|
+
if (refreshed.notesChanged) stats.updated++;
|
|
340
|
+
out.push(refreshed.entry);
|
|
341
|
+
}
|
|
342
|
+
const additions = [];
|
|
343
|
+
const addedTexts = /* @__PURE__ */ new Map();
|
|
344
|
+
for (const message of messages.values()) {
|
|
345
|
+
if (matched.has(message.id)) continue;
|
|
346
|
+
matched.add(message.id);
|
|
347
|
+
additions.push(newEntry(message, context));
|
|
348
|
+
addedTexts.set(message.canonical, (addedTexts.get(message.canonical) ?? 0) + 1);
|
|
349
|
+
}
|
|
350
|
+
let deletedTexts = null;
|
|
351
|
+
for (const deletion of deletions) {
|
|
352
|
+
if (deletion.canonical === null) continue;
|
|
353
|
+
deletedTexts ??= /* @__PURE__ */ new Map();
|
|
354
|
+
const list = deletedTexts.get(deletion.canonical);
|
|
355
|
+
if (list === void 0) deletedTexts.set(deletion.canonical, [deletion]);
|
|
356
|
+
else list.push(deletion);
|
|
357
|
+
}
|
|
358
|
+
for (let i = 0; i < additions.length; i++) {
|
|
359
|
+
const added = additions[i];
|
|
360
|
+
const canonical = added.source;
|
|
361
|
+
const from = deletedTexts?.get(canonical);
|
|
362
|
+
if (from === void 0 || from.length !== 1 || addedTexts.get(canonical) !== 1) {
|
|
363
|
+
stats.added++;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const source = from[0];
|
|
367
|
+
source.movedTo = true;
|
|
368
|
+
stats.moved++;
|
|
369
|
+
additions[i] = {
|
|
370
|
+
...added,
|
|
371
|
+
translation: source.entry.translation,
|
|
372
|
+
state: source.entry.state,
|
|
373
|
+
fuzzy: source.entry.fuzzy
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
for (const deletion of deletions) {
|
|
377
|
+
if (deletion.movedTo) continue;
|
|
378
|
+
stats.deleted++;
|
|
379
|
+
if (!isSource && hasTranslation(deletion.entry)) removals++;
|
|
380
|
+
}
|
|
381
|
+
for (const added of additions) out.push(added);
|
|
382
|
+
for (let i = 0; i < out.length; i++) out[i] = withIcuFlag(out[i], context.icuFlag);
|
|
383
|
+
let obsolete = catalog.obsolete;
|
|
384
|
+
let droppedObsolete = catalog.droppedObsolete;
|
|
385
|
+
if (obsolete.length > 0) {
|
|
386
|
+
diagnostics.push({
|
|
387
|
+
code: "catalog-obsolete-dropped",
|
|
388
|
+
severity: "info",
|
|
389
|
+
message: `dropped ${obsolete.length} obsolete #~ ${obsolete.length === 1 ? "entry" : "entries"}; git history keeps them`
|
|
390
|
+
});
|
|
391
|
+
obsolete = [];
|
|
392
|
+
droppedObsolete = 0;
|
|
393
|
+
}
|
|
394
|
+
const header = completeHeader(catalog.header, context, true);
|
|
395
|
+
return {
|
|
396
|
+
catalog: header === read.header && obsolete === read.obsolete && out.length === read.entries.length && out.every((entry, i) => entry === read.entries[i]) ? read : {
|
|
397
|
+
...catalog,
|
|
398
|
+
header,
|
|
399
|
+
entries: out,
|
|
400
|
+
obsolete,
|
|
401
|
+
droppedObsolete
|
|
402
|
+
},
|
|
403
|
+
stats,
|
|
404
|
+
removals,
|
|
405
|
+
diagnostics,
|
|
406
|
+
deleted: deletions.map((deletion) => deletion.key)
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
/** The matched entry with refs, notes, `maxLength` and the canonical source taken from the scan. */
|
|
410
|
+
function refresh(entry, message, layout) {
|
|
411
|
+
const maxLength = message.maxLength ?? null;
|
|
412
|
+
const notesChanged = entry.maxLength !== maxLength || !sameList(entry.refs, message.refs) || !sameList(entry.contexts, message.contexts);
|
|
413
|
+
const label = layout === "id" ? message.label : entry.label;
|
|
414
|
+
if (!notesChanged && entry.source === message.canonical && entry.label === label) return {
|
|
415
|
+
entry,
|
|
416
|
+
notesChanged
|
|
417
|
+
};
|
|
418
|
+
return {
|
|
419
|
+
entry: {
|
|
420
|
+
...entry,
|
|
421
|
+
source: message.canonical,
|
|
422
|
+
label,
|
|
423
|
+
refs: message.refs.slice(),
|
|
424
|
+
contexts: message.contexts.slice(),
|
|
425
|
+
maxLength
|
|
426
|
+
},
|
|
427
|
+
notesChanged
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
/** A manual entry whose text changed: the new source, an empty translation, no `ai` or `fuzzy`. */
|
|
431
|
+
function rewritten(entry, message, layout) {
|
|
432
|
+
return {
|
|
433
|
+
...entry,
|
|
434
|
+
source: message.canonical,
|
|
435
|
+
label: layout === "id" ? message.label : entry.label,
|
|
436
|
+
translation: "",
|
|
437
|
+
state: "untranslated",
|
|
438
|
+
fuzzy: false,
|
|
439
|
+
refs: message.refs.slice(),
|
|
440
|
+
contexts: message.contexts.slice(),
|
|
441
|
+
maxLength: message.maxLength ?? null
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function newEntry(message, context) {
|
|
445
|
+
const { layout } = context;
|
|
446
|
+
return createEntry({
|
|
447
|
+
manualId: message.manual ? message.id : null,
|
|
448
|
+
label: layout === "id" ? message.label : layout === "json" && !message.manual ? message.id : null,
|
|
449
|
+
source: message.canonical,
|
|
450
|
+
refs: message.refs.slice(),
|
|
451
|
+
contexts: message.contexts.slice(),
|
|
452
|
+
maxLength: message.maxLength ?? null,
|
|
453
|
+
flags: context.icuFlag ? [{
|
|
454
|
+
name: ICU_FLAG,
|
|
455
|
+
line: "#,"
|
|
456
|
+
}] : []
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
const ICU_FLAG = "icu-message-format";
|
|
460
|
+
/**
|
|
461
|
+
* `catalogs.icuFlag` is vi18n's to set (PLAN §4.4): a full sync adds the
|
|
462
|
+
* flag to every entry, or takes it off when the option is false, so the
|
|
463
|
+
* file follows the config. A flag already present stays where it was read.
|
|
464
|
+
*/
|
|
465
|
+
function withIcuFlag(entry, on) {
|
|
466
|
+
if (entry.foreignContext !== null) return entry;
|
|
467
|
+
if (entry.flags.some((flag) => flag.name === ICU_FLAG) === on) return entry;
|
|
468
|
+
return {
|
|
469
|
+
...entry,
|
|
470
|
+
flags: on ? [{
|
|
471
|
+
name: ICU_FLAG,
|
|
472
|
+
line: "#,"
|
|
473
|
+
}, ...entry.flags] : entry.flags.filter((flag) => flag.name !== ICU_FLAG)
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Re-key after an `ids.length` change (PLAN §5.5): each entry's text comes
|
|
478
|
+
* from its own source (JSON, the source locale) or, for `"id"`-layout
|
|
479
|
+
* targets, from the source locale's entry for the same old key (read at the
|
|
480
|
+
* recorded length), else the target's own `#. source:` note, and its
|
|
481
|
+
* label is recomputed at the new length. Translations stay, and nothing here
|
|
482
|
+
* counts against the safety limit. An entry whose text cannot be recovered
|
|
483
|
+
* keeps its old key, so the normal rules (and the limit) apply to it.
|
|
484
|
+
*/
|
|
485
|
+
function rekeyCatalog(catalog, context) {
|
|
486
|
+
const { codec, layout, isSource } = context;
|
|
487
|
+
const joined = layout === "id" && !isSource ? oldLabelIndex(context.sourceCatalog, catalog.header.idScheme, codec) : null;
|
|
488
|
+
let count = 0;
|
|
489
|
+
const entries = catalog.entries.map((entry) => {
|
|
490
|
+
if (entry.foreignContext !== null) return entry;
|
|
491
|
+
if (layout === "json" && entry.manualId !== null) return entry;
|
|
492
|
+
let text = entry.source;
|
|
493
|
+
if (layout === "id" && !isSource) {
|
|
494
|
+
const key = entry.manualId ?? entry.label ?? "";
|
|
495
|
+
text = joined?.get(key) ?? context.sourceIndex?.get(key) ?? entry.source;
|
|
496
|
+
}
|
|
497
|
+
const canonical = canonicalOf(text, codec);
|
|
498
|
+
if (canonical === null) return entry;
|
|
499
|
+
const label = codec.label(canonical);
|
|
500
|
+
if (label !== entry.label) count++;
|
|
501
|
+
return {
|
|
502
|
+
...entry,
|
|
503
|
+
label,
|
|
504
|
+
source: canonical
|
|
505
|
+
};
|
|
506
|
+
});
|
|
507
|
+
return {
|
|
508
|
+
catalog: {
|
|
509
|
+
...catalog,
|
|
510
|
+
entries,
|
|
511
|
+
header: {
|
|
512
|
+
...catalog.header,
|
|
513
|
+
idScheme: codec.idScheme
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
count
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* The source locale's text per key at the target's recorded length: manual
|
|
521
|
+
* ids as they are, generated entries by their text's hash cut to the old
|
|
522
|
+
* length. Hashing rather than reading the source file's labels keeps the
|
|
523
|
+
* join working when the source locale is already at the new scheme (an
|
|
524
|
+
* interrupted extract, a TMS export from before the change merged back),
|
|
525
|
+
* where the target's `#. source:` notes may be all that is left otherwise and
|
|
526
|
+
* a key-based TMS strips them.
|
|
527
|
+
*/
|
|
528
|
+
function oldLabelIndex(sourceCatalog, recorded, codec) {
|
|
529
|
+
const old = recorded === null ? null : parseIdScheme(recorded);
|
|
530
|
+
if (old === null || sourceCatalog === null) return null;
|
|
531
|
+
const index = /* @__PURE__ */ new Map();
|
|
532
|
+
for (const entry of sourceCatalog.entries) {
|
|
533
|
+
if (entry.foreignContext !== null || entry.source === null) continue;
|
|
534
|
+
let key = entry.manualId;
|
|
535
|
+
if (key === null) {
|
|
536
|
+
const canonical = canonicalOf(entry.source, codec);
|
|
537
|
+
if (canonical === null) continue;
|
|
538
|
+
key = codec.sourceHash(canonical).slice(0, old.length);
|
|
539
|
+
}
|
|
540
|
+
if (!index.has(key)) index.set(key, entry.source);
|
|
541
|
+
}
|
|
542
|
+
return index;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* How an entry found under a message's key relates to it:
|
|
546
|
+
* - `same`: the entry is for the message's text;
|
|
547
|
+
* - `changed`: a manual entry whose source text changed (D10);
|
|
548
|
+
* - `other`: a generated entry whose recorded text is a different text with
|
|
549
|
+
* the same id, i.e. a deleted message whose id the new one took.
|
|
550
|
+
*
|
|
551
|
+
* A generated entry's key is a fingerprint of its text, so a recorded source
|
|
552
|
+
* that no longer hashes to the key (a hand-edited JSON `source`, a stale
|
|
553
|
+
* `#. source:` note) is a damaged copy of the same text, not another text.
|
|
554
|
+
*/
|
|
555
|
+
function classify(entry, message, context) {
|
|
556
|
+
const { codec, layout } = context;
|
|
557
|
+
if (entry.manualId !== null) {
|
|
558
|
+
if (layout === "id") return entry.label === message.label ? "same" : "changed";
|
|
559
|
+
if (entry.source === message.canonical) return "same";
|
|
560
|
+
return canonicalOf(entry.source, codec) === message.canonical ? "same" : "changed";
|
|
561
|
+
}
|
|
562
|
+
const text = sourceText(entry, context);
|
|
563
|
+
if (text === null || text === message.canonical) return "same";
|
|
564
|
+
const canonical = canonicalOf(text, codec);
|
|
565
|
+
if (canonical === null || canonical === message.canonical) return "same";
|
|
566
|
+
return codec.label(canonical) === message.id ? "other" : "same";
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* An entry's source text. `"id"`-layout targets only carry a translator
|
|
570
|
+
* note, so the source locale's text for the same key wins when there is one.
|
|
571
|
+
*/
|
|
572
|
+
function sourceText(entry, context) {
|
|
573
|
+
if (context.sourceIndex !== null) {
|
|
574
|
+
const key = entry.manualId ?? entry.label;
|
|
575
|
+
const text = key === null ? void 0 : context.sourceIndex.get(key);
|
|
576
|
+
if (text !== void 0) return text;
|
|
577
|
+
}
|
|
578
|
+
return entry.source;
|
|
579
|
+
}
|
|
580
|
+
function sourceIndex(sourceCatalog, layout, isSource) {
|
|
581
|
+
if (layout !== "id" || isSource || sourceCatalog === null) return null;
|
|
582
|
+
const index = /* @__PURE__ */ new Map();
|
|
583
|
+
for (const entry of sourceCatalog.entries) {
|
|
584
|
+
if (entry.foreignContext !== null || entry.source === null) continue;
|
|
585
|
+
const key = entry.manualId ?? entry.label;
|
|
586
|
+
if (key !== null && !index.has(key)) index.set(key, entry.source);
|
|
587
|
+
}
|
|
588
|
+
return index;
|
|
589
|
+
}
|
|
590
|
+
/** The header with the scheme (and JSON source locale) a writer records; the same object when nothing changes. */
|
|
591
|
+
function completeHeader(header, context, full) {
|
|
592
|
+
const { codec, layout, config } = context;
|
|
593
|
+
const idScheme = full || header.idScheme === null ? codec.idScheme : header.idScheme;
|
|
594
|
+
const sourceLocale = layout === "json" && (full || header.sourceLocale === null) ? config.sourceLocale : header.sourceLocale;
|
|
595
|
+
if (idScheme === header.idScheme && sourceLocale === header.sourceLocale) return header;
|
|
596
|
+
return {
|
|
597
|
+
...header,
|
|
598
|
+
idScheme,
|
|
599
|
+
sourceLocale
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
function schemeDiagnostic(recorded, codec) {
|
|
603
|
+
const old = parseIdScheme(recorded);
|
|
604
|
+
return {
|
|
605
|
+
code: "id-scheme-mismatch",
|
|
606
|
+
severity: "error",
|
|
607
|
+
message: `${old !== null ? `ids.length changed from ${old.length} to ${codec.idLength}` : `the catalog records id scheme ${JSON.stringify(recorded)}, not ${codec.idScheme}`}: run vi18n extract to re-key (until then this catalog is left untouched)`
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
function canonicalOf(source, codec) {
|
|
611
|
+
if (source === null) return null;
|
|
612
|
+
try {
|
|
613
|
+
return codec.canonicalize(source);
|
|
614
|
+
} catch (error) {
|
|
615
|
+
if (isDiagnosticError(error)) return null;
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/** Translated or `ai`: what the safety limit protects. */
|
|
620
|
+
function hasTranslation(entry) {
|
|
621
|
+
return entry.translation !== "" && entry.state !== "untranslated";
|
|
622
|
+
}
|
|
623
|
+
function translatedCount(catalog) {
|
|
624
|
+
if (catalog === null) return 0;
|
|
625
|
+
let count = 0;
|
|
626
|
+
for (const entry of catalog.entries) if (entry.foreignContext === null && hasTranslation(entry)) count++;
|
|
627
|
+
return count;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Whether writing `next` would change the file `read` came from. Serialising
|
|
631
|
+
* compares content, not object identity, and the writer unwraps wrapped
|
|
632
|
+
* strings, so a wrapped file changes even when its content does not.
|
|
633
|
+
*/
|
|
634
|
+
function wouldChange(read, next, isSourceLocale) {
|
|
635
|
+
if (read === null) return true;
|
|
636
|
+
if (read.entries.some((entry) => entry.wrapped)) return true;
|
|
637
|
+
if (next === read) return false;
|
|
638
|
+
const options = {
|
|
639
|
+
isSourceLocale,
|
|
640
|
+
version: VERSION
|
|
641
|
+
};
|
|
642
|
+
return !sameCatalogText(serializeCatalog(read, options), serializeCatalog(next, options));
|
|
643
|
+
}
|
|
644
|
+
function sameList(a, b) {
|
|
645
|
+
if (a.length !== b.length) return false;
|
|
646
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
function compareText(a, b) {
|
|
650
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
651
|
+
}
|
|
652
|
+
function displayPath(root, file) {
|
|
653
|
+
const rel = relative(root, file);
|
|
654
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return file.split(sep).join("/");
|
|
655
|
+
return rel.split(sep).join("/");
|
|
656
|
+
}
|
|
657
|
+
//#endregion
|
|
658
|
+
export { writeGenFile as a, genFileUpToDate as i, syncCatalogs as n, GEN_FILE as r, syncCatalog as t };
|