@x18n/core 0.0.0 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs DELETED
@@ -1,2824 +0,0 @@
1
- import * as v from "valibot";
2
- //#region src/text.ts
3
- /** One edit covering only the part that differs, so bytes outside it stay the same. */
4
- function diffText(before, after) {
5
- if (before === after) return [];
6
- let start = 0;
7
- const max = Math.min(before.length, after.length);
8
- while (start < max && before.charCodeAt(start) === after.charCodeAt(start)) start++;
9
- let endBefore = before.length;
10
- let endAfter = after.length;
11
- while (endBefore > start && endAfter > start && before.charCodeAt(endBefore - 1) === after.charCodeAt(endAfter - 1)) {
12
- endBefore--;
13
- endAfter--;
14
- }
15
- return [{
16
- range: {
17
- start,
18
- end: endBefore
19
- },
20
- text: after.slice(start, endAfter)
21
- }];
22
- }
23
- /** Applies non-overlapping edits given against the same text. */
24
- function applyTextEdits(text, edits) {
25
- const sorted = [...edits].sort((a, b) => b.range.start - a.range.start || b.range.end - a.range.end);
26
- let out = text;
27
- for (const edit of sorted) out = out.slice(0, edit.range.start) + edit.text + out.slice(edit.range.end);
28
- return out;
29
- }
30
- const lineIndexes = /* @__PURE__ */ new Map();
31
- /** Line index of a text, cached for the few texts used at a time (reporters, LSP ranges). */
32
- function createLineIndex(text) {
33
- let index = lineIndexes.get(text);
34
- if (!index) {
35
- if (lineIndexes.size >= 16) lineIndexes.delete(lineIndexes.keys().next().value);
36
- index = buildLineIndex(text);
37
- lineIndexes.set(text, index);
38
- }
39
- return index;
40
- }
41
- function buildLineIndex(text) {
42
- const starts = [0];
43
- for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1);
44
- return {
45
- lineCount: starts.length,
46
- position(offset) {
47
- let low = 0;
48
- let high = starts.length - 1;
49
- while (low < high) {
50
- const mid = low + high + 1 >> 1;
51
- if (starts[mid] <= offset) low = mid;
52
- else high = mid - 1;
53
- }
54
- return {
55
- line: low,
56
- character: offset - starts[low]
57
- };
58
- },
59
- offset(position) {
60
- const start = starts[Math.min(position.line, starts.length - 1)] ?? 0;
61
- return Math.min(start + position.character, text.length);
62
- },
63
- line(line) {
64
- const start = starts[line] ?? text.length;
65
- const end = starts[line + 1] ?? text.length;
66
- return text.slice(start, end).replace(/\r?\n$/, "");
67
- }
68
- };
69
- }
70
- function rangeContains(range, offset) {
71
- return offset >= range.start && offset <= range.end;
72
- }
73
- /** Fast non-cryptographic string hash (cyrb53), as hex. */
74
- function hashText(text) {
75
- let h1 = 3735928559;
76
- let h2 = 1103547991;
77
- for (let i = 0; i < text.length; i++) {
78
- const ch = text.charCodeAt(i);
79
- h1 = Math.imul(h1 ^ ch, 2654435761);
80
- h2 = Math.imul(h2 ^ ch, 1597334677);
81
- }
82
- h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
83
- h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
84
- return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16);
85
- }
86
- //#endregion
87
- //#region src/contracts.ts
88
- /** Version of every JSON contract (CLI `--json`, LSP `x18n/*`, RPC, MCP). Bumped on breaking changes to any `data` shape. */
89
- const contractVersion = 1;
90
- /** Line and column form of an offset range in a text. */
91
- function toWireRange(text, start, end) {
92
- const index = createLineIndex(text ?? "");
93
- const from = index.position(start);
94
- const to = index.position(end);
95
- return {
96
- line: from.line + 1,
97
- column: from.character + 1,
98
- endLine: to.line + 1,
99
- endColumn: to.character + 1,
100
- start,
101
- end
102
- };
103
- }
104
- function toWireDiagnostic(project, diagnostic, text) {
105
- const location = diagnostic.location;
106
- return {
107
- project,
108
- code: diagnostic.code,
109
- severity: diagnostic.severity,
110
- message: diagnostic.message,
111
- key: diagnostic.key,
112
- locale: diagnostic.locale,
113
- file: location?.path,
114
- range: location ? toWireRange(text(location.path), location.range.start, location.range.end) : void 0,
115
- fixes: diagnostic.fixes
116
- };
117
- }
118
- function envelope(command, data, diagnostics = [], errors = []) {
119
- return {
120
- x18n: 1,
121
- command,
122
- data,
123
- diagnostics,
124
- errors
125
- };
126
- }
127
- //#endregion
128
- //#region src/define.ts
129
- function definePlugin(plugin) {
130
- return plugin;
131
- }
132
- function definePreset(preset) {
133
- return preset;
134
- }
135
- function defineConfig(config) {
136
- return config;
137
- }
138
- //#endregion
139
- //#region src/errors.ts
140
- const errorCodes = {
141
- X18N0001: {
142
- message: "Invalid config",
143
- hint: "Check the reported field against the config schema (`x18n schema config`)."
144
- },
145
- X18N0002: {
146
- message: "Config file could not be loaded",
147
- hint: "Fix the syntax error, or run `x18n doctor` for details."
148
- },
149
- X18N0003: {
150
- message: "Unknown project",
151
- hint: "Pick a project name listed by `x18n status`."
152
- },
153
- X18N1001: {
154
- message: "Plugin could not be loaded",
155
- hint: "Install the plugin package, or check the path."
156
- },
157
- X18N1002: {
158
- message: "Duplicate capability id",
159
- hint: "Rename the id, or set `override: true` on the later one."
160
- },
161
- X18N1003: {
162
- message: "Unknown preset",
163
- hint: "Use a preset name listed in the docs, or a preset object."
164
- },
165
- X18N1004: {
166
- message: "Invalid YAML plugin",
167
- hint: "Validate the file against `schema/plugin.json`."
168
- },
169
- X18N2001: {
170
- message: "No format for resource",
171
- hint: "Map the extension in `formats`, or add a format plugin."
172
- },
173
- X18N2002: {
174
- message: "Resource could not be parsed",
175
- hint: "Fix the syntax error in the file."
176
- },
177
- X18N2003: {
178
- message: "Resource is not an object of messages",
179
- hint: "Locale files hold nested objects of strings, numbers, booleans or null."
180
- },
181
- X18N2004: {
182
- message: "Locale file is not static",
183
- hint: "Use JSON or YAML, or opt in to execution with `execute: true` on the resource pattern."
184
- },
185
- X18N3001: {
186
- message: "Source file could not be scanned",
187
- hint: "Fix the syntax error, or exclude the file with `usage.exclude`."
188
- },
189
- X18N4001: {
190
- message: "Resource is read-only",
191
- hint: "Edit the source of computed values, or use a writable format."
192
- },
193
- X18N4002: {
194
- message: "Rename target already exists",
195
- hint: "Delete or rename the existing key first."
196
- },
197
- X18N4003: {
198
- message: "A decision is needed",
199
- hint: "Answer the listed decisions, or pass `--yes` to accept defaults."
200
- },
201
- X18N4004: {
202
- message: "Plan is stale",
203
- hint: "Files changed since the plan was made. Plan again."
204
- },
205
- X18N4005: {
206
- message: "No resource for the key",
207
- hint: "Add a resource pattern for the locale, or create the locale file."
208
- },
209
- X18N4006: {
210
- message: "Key not found",
211
- hint: "List keys with `x18n keys`."
212
- },
213
- X18N4007: {
214
- message: "Placeholder mismatch",
215
- hint: "Keep every placeholder of the source value, or pass `--force`."
216
- },
217
- X18N4008: {
218
- message: "Invalid edit input",
219
- hint: "Check the input shape against the docs."
220
- }
221
- };
222
- var X18nError = class extends Error {
223
- code;
224
- hint;
225
- docs;
226
- details;
227
- constructor(code, detail, details = []) {
228
- const base = errorCodes[code];
229
- super(detail ? `${base.message}: ${detail}` : base.message);
230
- this.name = "X18nError";
231
- this.code = code;
232
- this.hint = base.hint;
233
- this.docs = `https://x18n.antfu.dev/errors/${code.toLowerCase()}`;
234
- this.details = details;
235
- }
236
- toJSON() {
237
- return {
238
- code: this.code,
239
- message: this.message,
240
- hint: this.hint,
241
- docs: this.docs
242
- };
243
- }
244
- };
245
- function isX18nError(error) {
246
- return error instanceof X18nError;
247
- }
248
- //#endregion
249
- //#region src/keys.ts
250
- /** Canonical string form of a key: `ns:a.b.c`, or `a.b.c` without namespace. */
251
- function joinKey(namespace, path) {
252
- const dotted = path.join(".");
253
- return namespace === void 0 ? dotted : `${namespace}:${dotted}`;
254
- }
255
- /** Splits a key string. `namespaced` is false for projects without namespaces, where `:` is part of the key. */
256
- function splitKey(key, namespaced = true) {
257
- const index = namespaced ? key.indexOf(":") : -1;
258
- const namespace = index === -1 ? void 0 : key.slice(0, index);
259
- const rest = index === -1 ? key : key.slice(index + 1);
260
- return {
261
- namespace,
262
- path: rest === "" ? [] : rest.split(".")
263
- };
264
- }
265
- /** True when `key` is `prefix` itself or lies under it (`a.b` is under `a`). */
266
- function isKeyUnder(key, prefix) {
267
- if (key === prefix) return true;
268
- const sep = prefix.endsWith(":") ? "" : ".";
269
- return key.startsWith(prefix + sep);
270
- }
271
- /** Replaces the `from` prefix of `key` with `to`. */
272
- function replaceKeyPrefix(key, from, to) {
273
- return key === from ? to : to + key.slice(from.length);
274
- }
275
- function escapeRegExp(text) {
276
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
277
- }
278
- /**
279
- * Glob over paths. `**` spans separators, `*` and `?` stay inside a segment,
280
- * `{a,b}` alternates, `[...]` is a character class.
281
- */
282
- function globToRegExp(glob, separator = "/") {
283
- const sep = escapeRegExp(separator);
284
- const notSep = `[^${sep}]`;
285
- let out = "";
286
- let depth = 0;
287
- for (let i = 0; i < glob.length; i++) {
288
- const char = glob[i];
289
- if (char === "*") {
290
- if (glob[i + 1] === "*") {
291
- const slashAfter = glob[i + 2] === separator;
292
- const atStart = i === 0 || glob[i - 1] === separator;
293
- i++;
294
- if (slashAfter && atStart) {
295
- out += `(?:.*${sep})?`;
296
- i++;
297
- } else out += ".*";
298
- } else out += `${notSep}*`;
299
- } else if (char === "?") out += notSep;
300
- else if (char === "{") {
301
- depth++;
302
- out += "(?:";
303
- } else if (char === "}" && depth > 0) {
304
- depth--;
305
- out += ")";
306
- } else if (char === "," && depth > 0) out += "|";
307
- else if (char === "[") {
308
- const close = glob.indexOf("]", i);
309
- if (close === -1) out += "\\[";
310
- else {
311
- out += `[${glob.slice(i + 1, close).replace(/^!/, "^").replace(/\\/g, "\\\\")}]`;
312
- i = close;
313
- }
314
- } else out += escapeRegExp(char);
315
- }
316
- return new RegExp(`^${out}$`);
317
- }
318
- /** Matcher over key globs: `*` stays inside a segment, `**` spans segments. */
319
- function keyMatcher(globs) {
320
- if (globs.length === 0) return () => false;
321
- const regexes = globs.map((glob) => globToRegExp(glob, "."));
322
- return (key) => regexes.some((re) => re.test(key));
323
- }
324
- /** Matcher over posix file paths. */
325
- function pathMatcher(globs) {
326
- if (globs.length === 0) return () => false;
327
- const regexes = globs.map((glob) => globToRegExp(glob.replace(/^\.\//, "")));
328
- return (path) => regexes.some((re) => re.test(path));
329
- }
330
- //#endregion
331
- //#region src/locale.ts
332
- const defaultRegions = {
333
- ar: "SA",
334
- cs: "CZ",
335
- da: "DK",
336
- de: "DE",
337
- el: "GR",
338
- en: "US",
339
- es: "ES",
340
- fa: "IR",
341
- fi: "FI",
342
- fr: "FR",
343
- he: "IL",
344
- hi: "IN",
345
- hu: "HU",
346
- id: "ID",
347
- it: "IT",
348
- ja: "JP",
349
- ko: "KR",
350
- nb: "NO",
351
- nl: "NL",
352
- pl: "PL",
353
- pt: "PT",
354
- ro: "RO",
355
- ru: "RU",
356
- sv: "SE",
357
- th: "TH",
358
- tr: "TR",
359
- uk: "UA",
360
- vi: "VN",
361
- zh: "CN"
362
- };
363
- const pluralOrder = [
364
- "zero",
365
- "one",
366
- "two",
367
- "few",
368
- "many",
369
- "other"
370
- ];
371
- function flagOf(region) {
372
- if (!/^[A-Z]{2}$/.test(region)) return void 0;
373
- return String.fromCodePoint(...[...region].map((c) => 127462 + c.charCodeAt(0) - 65));
374
- }
375
- function safe(fn) {
376
- try {
377
- return fn();
378
- } catch {
379
- return;
380
- }
381
- }
382
- /** Lenient display parse of a locale code (`zh_CN`, `pt-BR`, `sr-Latn-RS`). Never changes the code. */
383
- function parseLocaleInfo(code) {
384
- const parts = code.replace(/_/g, "-").split("-").filter(Boolean);
385
- const language = parts[0] && /^[a-z]{2,3}$/i.test(parts[0]) ? parts[0].toLowerCase() : void 0;
386
- let script;
387
- let region;
388
- for (const part of parts.slice(1)) if (!script && !region && /^[a-z]{4}$/i.test(part)) script = part[0].toUpperCase() + part.slice(1).toLowerCase();
389
- else if (!region && /^(?:[a-z]{2}|\d{3})$/i.test(part)) region = part.toUpperCase();
390
- const tag = [
391
- language,
392
- script,
393
- region
394
- ].filter(Boolean).join("-");
395
- const displayName = language ? safe(() => new Intl.DisplayNames(["en"], { type: "language" }).of(tag)) : void 0;
396
- const found = language ? safe(() => new Intl.PluralRules(tag).resolvedOptions().pluralCategories) : void 0;
397
- const pluralCategories = found ? pluralOrder.filter((category) => found.includes(category)) : ["other"];
398
- const flagRegion = region ?? (language ? defaultRegions[language] : void 0);
399
- return {
400
- code,
401
- language,
402
- region,
403
- displayName,
404
- flag: flagRegion ? flagOf(flagRegion) : void 0,
405
- pluralCategories
406
- };
407
- }
408
- /** Codes that differ only in spelling (`zh_CN` and `zh-cn`), the same after a lenient normalization. */
409
- function localeSpellingKey(code) {
410
- return code.replace(/_/g, "-").toLowerCase();
411
- }
412
- //#endregion
413
- //#region src/memory-host.ts
414
- /** A host over in-memory files, for tests, demos and agents that pass content directly. */
415
- function createMemoryHost(initial = {}, root = "/") {
416
- const files = new Map(Object.entries(initial));
417
- return {
418
- root,
419
- files,
420
- readFile: async (path) => files.get(path),
421
- writeFile: async (path, content) => {
422
- files.set(path, content);
423
- },
424
- deleteFile: async (path) => {
425
- files.delete(path);
426
- },
427
- glob: async (patterns, options = {}) => {
428
- const include = pathMatcher(patterns);
429
- const ignore = pathMatcher(options.ignore ?? []);
430
- return [...files.keys()].filter((path) => include(path) && !ignore(path)).sort();
431
- }
432
- };
433
- }
434
- //#endregion
435
- //#region src/message-tree.ts
436
- function isMessageLeaf(value) {
437
- return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
438
- }
439
- /** Checks parsed data (for example from `JSON.parse`) before it enters the project. */
440
- function isMessageTree(value) {
441
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
442
- return Object.values(value).every((child) => isMessageLeaf(child) || isMessageTree(child));
443
- }
444
- /** Stable id of a stored property path, the key of `ParseResult.ranges`. */
445
- function pathId(path) {
446
- return path.join("\0");
447
- }
448
- function* walkLeaves(tree, prefix = []) {
449
- for (const [segment, child] of Object.entries(tree)) {
450
- const path = [...prefix, segment];
451
- if (isMessageLeaf(child)) yield {
452
- path,
453
- value: child
454
- };
455
- else yield* walkLeaves(child, path);
456
- }
457
- }
458
- function cloneTree(tree) {
459
- const out = {};
460
- for (const [segment, child] of Object.entries(tree)) out[segment] = isMessageLeaf(child) ? child : cloneTree(child);
461
- return out;
462
- }
463
- function setIn(tree, path, value) {
464
- let node = tree;
465
- for (const segment of path.slice(0, -1)) {
466
- const child = node[segment];
467
- if (child === void 0 || isMessageLeaf(child)) {
468
- const created = {};
469
- node[segment] = created;
470
- node = created;
471
- } else node = child;
472
- }
473
- node[path.at(-1)] = value;
474
- }
475
- function deleteIn(tree, path) {
476
- const parents = [tree];
477
- let node = tree;
478
- for (const segment of path.slice(0, -1)) {
479
- const child = node[segment];
480
- if (child === void 0 || isMessageLeaf(child)) return;
481
- node = child;
482
- parents.push(node);
483
- }
484
- delete node[path.at(-1)];
485
- for (let i = parents.length - 1; i > 0; i--) {
486
- if (Object.keys(parents[i]).length > 0) break;
487
- delete parents[i - 1][path[i - 1]];
488
- }
489
- }
490
- /** Applies ops to a copy of the tree. Parent objects left empty are removed. */
491
- function applyMessageOps(tree, ops) {
492
- const out = cloneTree(tree);
493
- for (const op of ops) if (op.type === "set") setIn(out, op.path, op.value);
494
- else deleteIn(out, op.path);
495
- return out;
496
- }
497
- /** Sorts keys at every level. */
498
- function sortTree(tree, compare) {
499
- const out = {};
500
- for (const segment of Object.keys(tree).sort(compare)) {
501
- const child = tree[segment];
502
- out[segment] = isMessageLeaf(child) ? child : sortTree(child, compare);
503
- }
504
- return out;
505
- }
506
- /** Builds a nested or flat tree from leaves. */
507
- function buildTree(leaves) {
508
- const out = {};
509
- for (const leaf of leaves) setIn(out, leaf.path, leaf.value);
510
- return out;
511
- }
512
- //#endregion
513
- //#region src/overlay-host.ts
514
- /** Wraps a host so open editor buffers shadow the content on disk. */
515
- function createOverlayHost(base) {
516
- const overlays = /* @__PURE__ */ new Map();
517
- return {
518
- ...base,
519
- readFile: async (path) => overlays.get(path) ?? base.readFile(path),
520
- async writeFile(path, content) {
521
- overlays.delete(path);
522
- await base.writeFile(path, content);
523
- },
524
- setOverlay: (path, content) => {
525
- overlays.set(path, content);
526
- },
527
- clearOverlay: (path) => {
528
- overlays.delete(path);
529
- },
530
- hasOverlay: (path) => overlays.has(path)
531
- };
532
- }
533
- //#endregion
534
- //#region src/plugins.ts
535
- function isPreset(value) {
536
- return "plugins" in value && Array.isArray(value.plugins);
537
- }
538
- function extensionOf(path) {
539
- const base = path.slice(path.lastIndexOf("/") + 1);
540
- const dot = base.lastIndexOf(".");
541
- return dot <= 0 ? "" : base.slice(dot + 1).toLowerCase();
542
- }
543
- function sortByEnforce(plugins) {
544
- const rank = (plugin) => plugin.enforce === "pre" ? 0 : plugin.enforce === "post" ? 2 : 1;
545
- return [...plugins].sort((a, b) => rank(a) - rank(b));
546
- }
547
- function uniqueById(items, kind) {
548
- const out = /* @__PURE__ */ new Map();
549
- for (const item of items) {
550
- if (out.has(item.id) && !item.override) throw new X18nError("X18N1002", `${kind} "${item.id}"`);
551
- out.set(item.id, item);
552
- }
553
- return out;
554
- }
555
- function createRegistry(input, config) {
556
- const plugins = sortByEnforce(input);
557
- const formats = plugins.flatMap((plugin) => plugin.formats ?? []);
558
- const scanners = plugins.flatMap((plugin) => plugin.scanners ?? []);
559
- const finders = [...uniqueById(plugins.flatMap((plugin) => plugin.localeFinders ?? []), "Locale finder").values()];
560
- const checks = uniqueById(plugins.flatMap((plugin) => plugin.checks ?? []), "Check");
561
- const reporters = uniqueById(plugins.flatMap((plugin) => plugin.reporters ?? []), "Reporter");
562
- const conflicts = [];
563
- const byExtension = /* @__PURE__ */ new Map();
564
- for (const format of formats) for (const extension of format.extensions) {
565
- const winner = byExtension.get(extension);
566
- if (winner) conflicts.push({
567
- capability: "format",
568
- extension,
569
- winner: winner.id,
570
- loser: format.id
571
- });
572
- else byExtension.set(extension, format);
573
- }
574
- const formatById = (id) => formats.find((format) => format.id === id);
575
- const scannerCache = /* @__PURE__ */ new Map();
576
- return {
577
- plugins,
578
- formats,
579
- finders,
580
- scanners,
581
- checks,
582
- reporters,
583
- placeholders: plugins.flatMap((plugin) => plugin.placeholders ?? []),
584
- manifests: plugins.flatMap((plugin) => plugin.manifests ?? []),
585
- extractors: plugins.flatMap((plugin) => plugin.extractors ?? []),
586
- conflicts,
587
- formatById,
588
- formatFor(path, hint) {
589
- if (hint) return formatById(hint);
590
- const extension = extensionOf(path);
591
- const mapped = config.formats[extension];
592
- return mapped ? formatById(mapped) : byExtension.get(extension);
593
- },
594
- scannersFor(path) {
595
- const extension = extensionOf(path);
596
- let list = scannerCache.get(extension);
597
- if (!list) {
598
- list = extension ? scanners.filter((scanner) => scanner.extensions.includes(extension) || scanner.extensions.includes("*")) : [];
599
- scannerCache.set(extension, list);
600
- }
601
- return list;
602
- }
603
- };
604
- }
605
- //#endregion
606
- //#region src/schema.ts
607
- function isJsonValue(value) {
608
- if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return true;
609
- if (Array.isArray(value)) return value.every(isJsonValue);
610
- return isJsonObject(value);
611
- }
612
- function isJsonObject(value) {
613
- return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every(isJsonValue);
614
- }
615
- const jsonObject = v.custom(isJsonObject, "Expected a JSON object");
616
- const strings = v.array(v.string());
617
- const level = v.picklist([
618
- "error",
619
- "warning",
620
- "info",
621
- "hint",
622
- "off"
623
- ]);
624
- const argSource = v.strictObject({
625
- arg: v.number(),
626
- property: v.optional(v.string())
627
- });
628
- const callSpec = v.strictObject({
629
- callee: v.union([v.string(), strings]),
630
- kind: v.optional(v.picklist([
631
- "call",
632
- "selector",
633
- "accessor"
634
- ])),
635
- key: v.optional(v.union([v.number(), argSource])),
636
- namespace: v.optional(argSource),
637
- keyPrefix: v.optional(argSource),
638
- factory: v.optional(v.boolean()),
639
- returns: v.optional(strings),
640
- types: v.optional(strings)
641
- });
642
- const regexSpec = v.strictObject({
643
- extensions: strings,
644
- patterns: strings
645
- });
646
- const resourceSpec = v.strictObject({
647
- pattern: v.string(),
648
- format: v.optional(v.string()),
649
- namespace: v.optional(v.string()),
650
- locale: v.optional(v.string()),
651
- execute: v.optional(v.boolean())
652
- });
653
- const pluginSpecifier = v.union([v.string(), v.tuple([v.string(), jsonObject])]);
654
- const extractTemplate = v.strictObject({
655
- template: v.string(),
656
- kinds: v.optional(strings),
657
- files: v.optional(strings),
658
- imports: v.optional(strings)
659
- });
660
- const extractTemplates = v.array(v.union([v.string(), extractTemplate]));
661
- const extractOptions = v.strictObject({
662
- autoDetect: v.optional(v.boolean()),
663
- keygen: v.optional(v.picklist([
664
- "slug",
665
- "source",
666
- "random",
667
- "empty"
668
- ])),
669
- keyCase: v.optional(v.picklist([
670
- "camel",
671
- "snake",
672
- "kebab",
673
- "dot"
674
- ])),
675
- keyPrefix: v.optional(v.string()),
676
- keyMaxLength: v.optional(v.number()),
677
- reuseExisting: v.optional(v.boolean()),
678
- ignore: v.optional(strings),
679
- ignoreByFile: v.optional(v.record(v.string(), strings)),
680
- ignoreAttributes: v.optional(strings),
681
- ignoreCallees: v.optional(strings),
682
- templates: v.optional(extractTemplates)
683
- });
684
- const glossaryEntry = v.strictObject({
685
- term: v.string(),
686
- translations: v.optional(v.record(v.string(), v.string())),
687
- note: v.optional(v.string()),
688
- doNotTranslate: v.optional(v.boolean())
689
- });
690
- const checkSetting = v.union([level, v.tuple([level, jsonObject])]);
691
- const namespaceOptions = v.strictObject({
692
- separator: v.optional(v.string()),
693
- default: v.optional(v.string())
694
- });
695
- /** Validates YAML and JSON configs, and the serializable part of TS configs. */
696
- const ConfigSchema = v.strictObject({
697
- $schema: v.optional(v.string()),
698
- root: v.optional(v.string()),
699
- name: v.optional(v.string()),
700
- sourceLocale: v.optional(v.string()),
701
- locales: v.optional(strings),
702
- ignoreLocales: v.optional(strings),
703
- presets: v.optional(v.union([v.literal("auto"), v.array(pluginSpecifier)])),
704
- plugins: v.optional(v.array(pluginSpecifier)),
705
- resources: v.optional(v.array(resourceSpec)),
706
- formats: v.optional(v.record(v.string(), v.string())),
707
- keyStyle: v.optional(v.picklist([
708
- "auto",
709
- "nested",
710
- "flat"
711
- ])),
712
- namespace: v.optional(namespaceOptions),
713
- usage: v.optional(v.strictObject({
714
- include: v.optional(strings),
715
- exclude: v.optional(strings),
716
- calls: v.optional(v.array(callSpec)),
717
- regex: v.optional(v.array(regexSpec)),
718
- ignoreCallees: v.optional(strings),
719
- keysInUse: v.optional(strings),
720
- derivedKeys: v.optional(strings)
721
- })),
722
- write: v.optional(v.strictObject({
723
- sort: v.optional(v.union([v.literal(false), v.picklist(["binary", "locale"])])),
724
- indent: v.optional(v.union([v.number(), v.literal("tab")])),
725
- keepFilled: v.optional(v.boolean())
726
- })),
727
- extract: v.optional(extractOptions),
728
- checks: v.optional(v.record(v.string(), checkSetting)),
729
- strict: v.optional(v.boolean()),
730
- ignoreKeys: v.optional(strings),
731
- glossary: v.optional(v.array(glossaryEntry)),
732
- projects: v.optional(strings)
733
- });
734
- const PluginFileSchema = v.strictObject({
735
- $schema: v.optional(v.string()),
736
- name: v.string(),
737
- detect: v.optional(v.strictObject({
738
- dependencies: v.optional(v.strictObject({
739
- any: v.optional(strings),
740
- all: v.optional(strings)
741
- })),
742
- files: v.optional(strings)
743
- })),
744
- exclusive: v.optional(v.boolean()),
745
- config: v.optional(v.strictObject({
746
- sourceLocale: v.optional(v.string()),
747
- locales: v.optional(strings),
748
- resources: v.optional(v.array(resourceSpec)),
749
- namespace: v.optional(namespaceOptions),
750
- keyStyle: v.optional(v.picklist([
751
- "auto",
752
- "nested",
753
- "flat"
754
- ]))
755
- })),
756
- scanners: v.optional(v.strictObject({
757
- calls: v.optional(v.array(callSpec)),
758
- regex: v.optional(v.array(regexSpec))
759
- })),
760
- scopes: v.optional(v.strictObject({ regex: v.string() })),
761
- derivedKeys: v.optional(strings),
762
- normalizeKey: v.optional(v.array(v.strictObject({ replace: v.strictObject({
763
- pattern: v.string(),
764
- with: v.string()
765
- }) }))),
766
- placeholders: v.optional(strings),
767
- localeInfo: v.optional(v.record(v.string(), v.strictObject({
768
- flag: v.optional(v.string()),
769
- displayName: v.optional(v.string())
770
- }))),
771
- extract: v.optional(v.strictObject({ templates: v.optional(extractTemplates) }))
772
- });
773
- /** Validates data against a schema and returns readable issues instead of throwing. */
774
- function validate(schema, data) {
775
- const result = v.safeParse(schema, data);
776
- if (result.success) return {
777
- success: true,
778
- output: result.output
779
- };
780
- return {
781
- success: false,
782
- issues: result.issues.map((issue) => ({
783
- path: issue.path?.map((item) => String(item.key)).join(".") ?? "",
784
- message: issue.message
785
- }))
786
- };
787
- }
788
- //#endregion
789
- //#region src/project/batch.ts
790
- function isLeaf(value) {
791
- return value === null || typeof value !== "object";
792
- }
793
- /** Parses the batch shape `{ "<locale>": { "<key>": "<value>" } }` (as in `x18n apply`) into set ops. */
794
- function parseMessageBatch(project, data) {
795
- if (!isJsonObject(data)) throw new X18nError("X18N4008", "expected { \"<locale>\": { \"<key>\": \"<value>\" } }");
796
- const ops = [];
797
- for (const [locale, entries] of Object.entries(data)) {
798
- if (!isJsonObject(entries)) throw new X18nError("X18N4008", `"${locale}" must map keys to values`);
799
- for (const [raw, value] of Object.entries(entries)) {
800
- if (!isLeaf(value)) throw new X18nError("X18N4008", `"${locale}.${raw}" must be a string, number, boolean or null`);
801
- ops.push({
802
- type: "set",
803
- key: project.parseKey(raw),
804
- locale,
805
- value
806
- });
807
- }
808
- }
809
- return ops;
810
- }
811
- /** Set ops whose value drops or adds placeholders compared to the source value. */
812
- function placeholderProblems(project, ops) {
813
- const names = (text) => project.placeholderSyntaxes.flatMap((s) => s.find(text)).map((ph) => ph.name).sort();
814
- return ops.flatMap((op) => {
815
- if (op.type !== "set" || op.locale === project.sourceLocale || typeof op.value !== "string") return [];
816
- const source = project.message(op.key, project.sourceLocale)?.value;
817
- if (typeof source !== "string") return [];
818
- const expected = names(source);
819
- const actual = names(op.value);
820
- const missing = expected.filter((n) => !actual.includes(n));
821
- const extra = actual.filter((n) => !expected.includes(n));
822
- return missing.length || extra.length ? [{
823
- key: op.key,
824
- locale: op.locale,
825
- missing,
826
- extra
827
- }] : [];
828
- });
829
- }
830
- //#endregion
831
- //#region src/project/analysis.ts
832
- function levelOf(setting) {
833
- if (setting === void 0) return void 0;
834
- if (typeof setting === "string") return {
835
- level: setting,
836
- options: {}
837
- };
838
- return {
839
- level: setting[0],
840
- options: setting[1]
841
- };
842
- }
843
- function computeCoverage(messages, locales, reference) {
844
- return locales.map((locale) => {
845
- const missing = [];
846
- const empty = [];
847
- for (const key of reference) {
848
- const value = messages.get(key)?.get(locale)?.[0]?.value;
849
- if (value === void 0) missing.push(key);
850
- else if (value === "") empty.push(key);
851
- }
852
- return {
853
- locale,
854
- total: reference.length,
855
- translated: reference.length - missing.length - empty.length,
856
- missing,
857
- empty
858
- };
859
- });
860
- }
861
- function errorDiagnostics(state) {
862
- return [
863
- ...state.discoveryErrors,
864
- ...state.resourceErrors,
865
- ...state.scanErrors
866
- ].map(({ error, path }) => ({
867
- code: "invalid-resource",
868
- severity: error.code.startsWith("X18N3") ? "warning" : "error",
869
- message: `${error.message} (${error.code})`,
870
- key: void 0,
871
- locale: void 0,
872
- location: {
873
- path,
874
- range: {
875
- start: 0,
876
- end: 0
877
- }
878
- },
879
- fixes: []
880
- }));
881
- }
882
- function* runCheck(check, level, inputs, input) {
883
- const { state, config } = input;
884
- const resourcePath = (id) => state.resources.find((s) => s.resource.id === id)?.resource.path ?? id;
885
- for (const item of inputs) {
886
- if (item.key !== void 0 && input.ignored(item.key)) continue;
887
- const severity = config.strict && (item.severity ?? level) === "warning" ? "error" : item.severity ?? level;
888
- let location = item.location;
889
- if (!location && item.key !== void 0) {
890
- const byLocale = state.messages.get(item.key);
891
- const found = byLocale?.get(item.locale ?? state.sourceLocale)?.[0] ?? byLocale?.get(state.sourceLocale)?.[0];
892
- if (found) location = {
893
- path: resourcePath(found.resource),
894
- range: found.range?.key ?? {
895
- start: 0,
896
- end: 0
897
- }
898
- };
899
- }
900
- yield {
901
- code: check.id,
902
- severity,
903
- message: item.message,
904
- key: item.key,
905
- locale: item.locale,
906
- location,
907
- fixes: item.fixes ?? []
908
- };
909
- }
910
- }
911
- function runChecks(input) {
912
- const out = [];
913
- for (const check of input.registry.checks.values()) {
914
- const setting = levelOf(input.config.checks[check.id]) ?? {
915
- level: check.defaultSeverity,
916
- options: {}
917
- };
918
- if (setting.level === "off") continue;
919
- try {
920
- out.push(...runCheck(check, setting.level, check.run({
921
- ...input.ctx,
922
- options: setting.options
923
- }), input));
924
- } catch (error) {
925
- out.push({
926
- code: check.id,
927
- severity: "error",
928
- message: `Check failed: ${error instanceof Error ? error.message : String(error)}`,
929
- key: void 0,
930
- locale: void 0,
931
- location: void 0,
932
- fixes: []
933
- });
934
- }
935
- }
936
- return out;
937
- }
938
- function createAnalyzer(options) {
939
- const { config, registry } = options;
940
- let memo;
941
- const ignored = keyMatcher(config.ignoreKeys);
942
- const inUse = keyMatcher(config.usage.keysInUse);
943
- const placeholderCache = /* @__PURE__ */ new Map();
944
- function analyze() {
945
- const state = options.state();
946
- if (memo && memo.revision === state.revision) return memo;
947
- const { sourceLocale, locales, messages } = state;
948
- const sourceKeys = [...messages.keys()].filter((key) => messages.get(key)?.has(sourceLocale)).sort();
949
- const allKeys = [...messages.keys()].sort();
950
- const coverage = computeCoverage(messages, locales, sourceKeys.length > 0 ? sourceKeys : allKeys);
951
- const isDefined = (key) => messages.get(key)?.has(sourceLocale) === true || state.parents.has(key) || (state.derived.get(key)?.length ?? 0) > 0 || sourceKeys.length === 0 && messages.has(key);
952
- const hasScanners = state.files.size > 0;
953
- const referenceSyntaxes = registry.placeholders.filter((syntax) => syntax.reference);
954
- const referenced = /* @__PURE__ */ new Set();
955
- if (referenceSyntaxes.length > 0) for (const byLocale of messages.values()) {
956
- const value = byLocale.get(sourceLocale)?.[0]?.value;
957
- if (typeof value === "string") referenceSyntaxes.forEach((syntax) => syntax.find(value).forEach((p) => referenced.add(p.name)));
958
- }
959
- const prefixes = [...new Set(state.prefixUsages.flatMap((usage) => usage.key === void 0 ? [] : [usage.key]))];
960
- const isUsed = (key) => {
961
- if (state.usagesByKey.has(key) || inUse(key) || referenced.has(key)) return true;
962
- const base = options.baseOf(key);
963
- if (base !== void 0 && (state.usagesByKey.has(base) || inUse(base))) return true;
964
- return prefixes.some((prefix) => key.startsWith(prefix));
965
- };
966
- const unused = hasScanners ? allKeys.filter((key) => !isUsed(key)) : [];
967
- const undefinedUsages = state.usages.filter((usage) => usage.dynamic === false && usage.key !== void 0 && !isDefined(usage.key));
968
- const placeholders = (value) => {
969
- let found = placeholderCache.get(value);
970
- if (!found) {
971
- found = registry.placeholders.flatMap((syntax) => syntax.find(value)).sort((a, b) => a.range.start - b.range.start);
972
- if (placeholderCache.size > 1e5) placeholderCache.clear();
973
- placeholderCache.set(value, found);
974
- }
975
- return found;
976
- };
977
- const baseCtx = {
978
- config,
979
- sourceLocale,
980
- locales,
981
- resources: state.resources.map((s) => s.resource),
982
- hasScanners,
983
- keys: () => allKeys,
984
- *messages(locale) {
985
- for (const byLocale of messages.values()) for (const [code, list] of byLocale) if (locale === void 0 || code === locale) yield list[0];
986
- },
987
- message: (key, locale) => messages.get(key)?.get(locale)?.[0],
988
- duplicates: (key, locale) => messages.get(key)?.get(locale) ?? [],
989
- usages: () => state.usages,
990
- coverage: () => coverage,
991
- unused: () => unused,
992
- undefinedUsages: () => undefinedUsages,
993
- placeholders,
994
- syntaxErrors: (value) => registry.placeholders.flatMap((syntax) => syntax.validate?.(value) ?? []),
995
- localeInfo: options.localeInfo,
996
- text: options.text,
997
- *hardStrings() {
998
- for (const [path, file] of state.files) for (const hard of file.result.hardStrings ?? []) yield {
999
- ...hard,
1000
- path
1001
- };
1002
- }
1003
- };
1004
- const diagnostics = [...errorDiagnostics(state), ...runChecks({
1005
- config,
1006
- registry,
1007
- ctx: baseCtx,
1008
- state,
1009
- ignored
1010
- })];
1011
- diagnostics.sort((a, b) => (a.location?.path ?? "").localeCompare(b.location?.path ?? "") || (a.location?.range.start ?? 0) - (b.location?.range.start ?? 0) || a.code.localeCompare(b.code));
1012
- memo = {
1013
- revision: state.revision,
1014
- diagnostics,
1015
- coverage,
1016
- unused,
1017
- undefinedUsages
1018
- };
1019
- return memo;
1020
- }
1021
- return { analyze };
1022
- }
1023
- //#endregion
1024
- //#region src/project/planner.ts
1025
- const safeSegment = /^[\w-]+$/;
1026
- const safeFlat = /^[\w.-]+$/;
1027
- function commonPrefix(a, b) {
1028
- let i = 0;
1029
- while (i < a.length && i < b.length && a[i] === b[i]) i++;
1030
- return i;
1031
- }
1032
- function nodeAt(tree, path) {
1033
- let node = tree;
1034
- for (const segment of path) {
1035
- if (node === void 0 || isMessageLeaf(node)) return void 0;
1036
- node = node[segment];
1037
- }
1038
- return node;
1039
- }
1040
- /** True for flat files (`{ "a.b": "x" }`), false for nested ones, undefined when it cannot tell. */
1041
- function isFlatTree(tree) {
1042
- const entries = Object.entries(tree);
1043
- if (entries.length === 0) return void 0;
1044
- if (entries.some(([, value]) => !isMessageLeaf(value))) return false;
1045
- return entries.some(([key]) => key.includes(".")) ? true : void 0;
1046
- }
1047
- /** The longest key prefix a tree shares with `path`. */
1048
- function longestPrefix(tree, prefix, path) {
1049
- let best = 0;
1050
- for (const [segment, child] of Object.entries(tree)) {
1051
- const next = [...prefix, ...segment.split(".")];
1052
- best = Math.max(best, commonPrefix(next, path));
1053
- if (!isMessageLeaf(child)) best = Math.max(best, longestPrefix(child, next, path));
1054
- }
1055
- return best;
1056
- }
1057
- function mirrorPath(path, from, to) {
1058
- const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1059
- return path.replace(new RegExp(`(^|[/._-])${escaped}(?=$|[/._-])`, "g"), `$1${to}`);
1060
- }
1061
- function importEdit(text, lines) {
1062
- const missing = lines.filter((line) => !text.includes(line.trim()));
1063
- if (missing.length === 0) return void 0;
1064
- const script = /<script\b[^>]*>\r?\n?/.exec(text);
1065
- let at = 0;
1066
- if (script) at = script.index + script[0].length;
1067
- else {
1068
- const last = [...text.matchAll(/^import\s.*$/gm)].at(-1);
1069
- if (last) at = last.index + last[0].length + 1;
1070
- }
1071
- return {
1072
- range: {
1073
- start: at,
1074
- end: at
1075
- },
1076
- text: `${missing.join("\n")}\n`
1077
- };
1078
- }
1079
- var PlanBuilder = class {
1080
- options;
1081
- state;
1082
- answers;
1083
- work = /* @__PURE__ */ new Map();
1084
- created = /* @__PURE__ */ new Map();
1085
- sourceEdits = /* @__PURE__ */ new Map();
1086
- sourceImports = /* @__PURE__ */ new Map();
1087
- decisions = [];
1088
- warnings = [];
1089
- touched = /* @__PURE__ */ new Set();
1090
- stateById;
1091
- config;
1092
- constructor(options, state, answers) {
1093
- this.options = options;
1094
- this.state = state;
1095
- this.answers = answers;
1096
- this.config = options.config;
1097
- this.stateById = new Map(state.resources.map((s) => [s.resource.id, s]));
1098
- }
1099
- /** ---- Targets ---- */
1100
- targetOf(s) {
1101
- const format = this.options.registry.formatById(s.resource.format);
1102
- if (!format) throw new X18nError("X18N2001", s.resource.path);
1103
- return {
1104
- id: s.resource.id,
1105
- path: s.resource.path,
1106
- locale: s.resource.locale,
1107
- namespace: s.resource.namespace,
1108
- format,
1109
- state: s
1110
- };
1111
- }
1112
- workFor(target) {
1113
- let entry = this.work.get(target.id);
1114
- if (!entry) {
1115
- entry = {
1116
- target,
1117
- tree: target.state?.rawTree ?? {},
1118
- ops: [],
1119
- keyRenames: [],
1120
- formatOnly: false
1121
- };
1122
- this.work.set(target.id, entry);
1123
- }
1124
- return entry;
1125
- }
1126
- writable(id) {
1127
- const s = this.stateById.get(id);
1128
- if (s.resource.readonly) throw new X18nError("X18N4001", s.resource.path);
1129
- return s;
1130
- }
1131
- keyId(key) {
1132
- const id = splitKey(key, this.state.namespaced);
1133
- return {
1134
- namespace: id.namespace,
1135
- path: [...id.path]
1136
- };
1137
- }
1138
- /** Key path relative to a resource, or undefined when the key does not belong there. */
1139
- relativePath(key, target) {
1140
- const { namespace, path } = this.keyId(key);
1141
- const fallback = this.config.namespace.default;
1142
- if (this.state.namespaced) return (namespace ?? fallback) === (target.namespace ?? fallback) ? path : void 0;
1143
- const prefix = target.namespace ? target.namespace.split(".") : [];
1144
- if (path.length <= prefix.length || prefix.some((segment, i) => path[i] !== segment)) return void 0;
1145
- return path.slice(prefix.length);
1146
- }
1147
- storedPathFor(entry, relative) {
1148
- const stored = this.options.toStored(entry.target, relative);
1149
- return stored.length === relative.length && (isFlatTree(entry.tree) ?? this.config.keyStyle === "flat") ? [stored.join(".")] : stored;
1150
- }
1151
- createTarget(key, locale) {
1152
- const { resources, sourceLocale, namespaced } = this.state;
1153
- const fits = (s) => this.relativePath(key, s.resource) !== void 0;
1154
- const namespace = namespaced ? this.keyId(key).namespace ?? this.config.namespace.default : void 0;
1155
- const resolved = this.options.resolveNew(locale, namespace);
1156
- if (resolved) {
1157
- const format = this.options.registry.formatFor(resolved);
1158
- if (format?.serialize) {
1159
- const target = {
1160
- id: resolved,
1161
- path: resolved,
1162
- locale,
1163
- namespace,
1164
- format,
1165
- state: void 0
1166
- };
1167
- this.created.set(resolved, target);
1168
- return target;
1169
- }
1170
- }
1171
- const model = resources.find((s) => s.resource.locale === sourceLocale && fits(s)) ?? resources.find(fits);
1172
- const format = model && this.options.registry.formatById(model.resource.format);
1173
- const path = model && mirrorPath(model.resource.path, model.resource.locale, locale);
1174
- if (!model || !format?.serialize || model.resource.block || !path || path === model.resource.path) throw new X18nError("X18N4005", `${key} in ${locale}`);
1175
- const target = {
1176
- id: path,
1177
- path,
1178
- locale,
1179
- namespace: model.resource.namespace ?? namespace,
1180
- format,
1181
- state: void 0
1182
- };
1183
- this.created.set(path, target);
1184
- return target;
1185
- }
1186
- /** Placement: the namespace's resource, then the longest shared prefix, then the first; a tie is a decision. */
1187
- pickTarget(key, locale) {
1188
- const inLocale = this.state.resources.filter((s) => s.resource.locale === locale && this.relativePath(key, s.resource) !== void 0);
1189
- if (inLocale.length === 0) return [...this.created.values()].find((t) => t.locale === locale && this.relativePath(key, t) !== void 0) ?? this.createTarget(key, locale);
1190
- if (inLocale.length === 1) return this.targetOf(inLocale[0]);
1191
- const path = this.keyId(key).path;
1192
- const scores = inLocale.map((s) => {
1193
- const prefix = s.resource.namespace && !this.state.namespaced ? path.slice(0, s.resource.namespace.split(".").length) : [];
1194
- return {
1195
- s,
1196
- best: longestPrefix(s.tree, prefix, path)
1197
- };
1198
- });
1199
- const top = Math.max(...scores.map((x) => x.best));
1200
- const winners = scores.filter((x) => x.best === top);
1201
- if (winners.length === 1 || top === 0) return this.targetOf(winners[0].s);
1202
- const id = `target:${key}:${locale}`;
1203
- this.decisions.push({
1204
- id,
1205
- message: `Which file gets "${key}" in ${locale}?`,
1206
- options: winners.map((w) => ({
1207
- id: w.s.resource.id,
1208
- label: w.s.resource.id
1209
- })),
1210
- default: winners[0].s.resource.id
1211
- });
1212
- return this.targetOf((winners.find((w) => w.s.resource.id === this.answers[id]) ?? winners[0]).s);
1213
- }
1214
- /** ---- Message ops ---- */
1215
- pushOp(entry, op) {
1216
- entry.ops.push(op);
1217
- entry.tree = applyMessageOps(entry.tree, [op]);
1218
- }
1219
- setValue(key, locale, value) {
1220
- this.touched.add(key);
1221
- const existing = this.options.message(key, locale);
1222
- if (existing) {
1223
- this.pushOp(this.workFor(this.targetOf(this.writable(existing.resource))), {
1224
- type: "set",
1225
- path: existing.storedPath,
1226
- value
1227
- });
1228
- return;
1229
- }
1230
- const target = this.pickTarget(key, locale);
1231
- if (target.state) this.writable(target.state.resource.id);
1232
- const entry = this.workFor(target);
1233
- const stored = this.storedPathFor(entry, this.relativePath(key, target));
1234
- const occupied = nodeAt(entry.tree, stored);
1235
- if (occupied !== void 0 && !isMessageLeaf(occupied)) this.warnings.push(`"${key}" is a parent of other keys in ${target.path}; they are replaced.`);
1236
- this.pushOp(entry, {
1237
- type: "set",
1238
- path: stored,
1239
- value
1240
- });
1241
- }
1242
- deleteMessage(m) {
1243
- const entry = this.workFor(this.targetOf(this.writable(m.resource)));
1244
- let path = [...m.storedPath];
1245
- while (path.length > 1) {
1246
- const parent = nodeAt(entry.tree, path.slice(0, -1));
1247
- if (parent === void 0 || isMessageLeaf(parent) || Object.keys(parent).length > 1) break;
1248
- path = path.slice(0, -1);
1249
- }
1250
- this.pushOp(entry, {
1251
- type: "delete",
1252
- path
1253
- });
1254
- }
1255
- /** Renames a key in every locale, in place when only the last segment changes. */
1256
- moveKey(from, to) {
1257
- this.touched.add(from);
1258
- this.touched.add(to);
1259
- for (const [locale, list] of this.options.messages(from) ?? []) {
1260
- const m = list[0];
1261
- const s = this.writable(m.resource);
1262
- const relative = this.relativePath(to, s.resource);
1263
- if (!relative) {
1264
- this.deleteMessage(m);
1265
- this.setValue(to, locale, m.value);
1266
- continue;
1267
- }
1268
- const entry = this.workFor(this.targetOf(s));
1269
- const flat = m.storedPath.length === 1 && m.storedPath[0].includes(".");
1270
- const next = flat ? [relative.join(".")] : this.options.toStored(s.resource, relative);
1271
- const sameParent = next.length === m.storedPath.length && next.slice(0, -1).every((segment, i) => segment === m.storedPath[i]);
1272
- const last = next.at(-1);
1273
- if (sameParent && m.range && (flat ? safeFlat : safeSegment).test(last)) {
1274
- entry.keyRenames.push({
1275
- range: m.range.key,
1276
- text: last
1277
- });
1278
- entry.tree = applyMessageOps(entry.tree, [{
1279
- type: "delete",
1280
- path: m.storedPath
1281
- }, {
1282
- type: "set",
1283
- path: next,
1284
- value: m.value
1285
- }]);
1286
- continue;
1287
- }
1288
- this.deleteMessage(m);
1289
- this.pushOp(entry, {
1290
- type: "set",
1291
- path: this.storedPathFor(entry, relative),
1292
- value: m.value
1293
- });
1294
- }
1295
- }
1296
- /** The raw text a usage needs to refer to `next`, given its scope. */
1297
- rawFor(usage, next) {
1298
- const { namespace, path } = this.keyId(next);
1299
- let rest = path.join(".");
1300
- const separator = this.config.namespace.separator;
1301
- const explicit = this.state.namespaced && usage.raw.indexOf(separator) > 0;
1302
- if (usage.scopePrefix && !explicit) {
1303
- if (!rest.startsWith(`${usage.scopePrefix}.`)) return void 0;
1304
- rest = rest.slice(usage.scopePrefix.length + 1);
1305
- }
1306
- if (!this.state.namespaced) return rest;
1307
- if (explicit || namespace !== (usage.scopeNamespace ?? this.config.namespace.default)) return `${namespace}${separator}${rest}`;
1308
- return rest;
1309
- }
1310
- editSource(path, edit) {
1311
- const list = this.sourceEdits.get(path) ?? [];
1312
- this.sourceEdits.set(path, list);
1313
- if (!list.some((e) => e.range.start === edit.range.start && e.range.end === edit.range.end)) list.push(edit);
1314
- }
1315
- rewriteUsages(from, to) {
1316
- for (const { usage, possible } of this.options.usageMatches({ key: from })) {
1317
- if (possible || usage.key !== from) continue;
1318
- const raw = this.rawFor(usage, to);
1319
- if (raw === void 0) this.warnings.push(`${usage.path}: usage of "${from}" is under the key prefix "${usage.scopePrefix}" and was not updated.`);
1320
- else this.editSource(usage.path, {
1321
- range: usage.range,
1322
- text: raw
1323
- });
1324
- }
1325
- }
1326
- /** Rewrites the static part of prefix usages under `from`, and warns about the ones it cannot follow. */
1327
- rewritePrefixUsages(from, to) {
1328
- for (const { usage, possible } of this.options.usageMatches({ prefix: from })) {
1329
- if (!possible || usage.key === void 0) continue;
1330
- const raw = usage.key.startsWith(from) ? this.rawFor(usage, to + usage.key.slice(from.length)) : void 0;
1331
- if (raw === void 0) this.warnings.push(`${usage.path}: dynamic usage "${usage.raw}" may refer to renamed keys and was not updated.`);
1332
- else this.editSource(usage.path, {
1333
- range: usage.range,
1334
- text: raw
1335
- });
1336
- }
1337
- }
1338
- /** ---- Ops ---- */
1339
- conflictCheck(to) {
1340
- const existing = this.options.messages(to);
1341
- if (!existing && !this.options.parents().has(to)) return;
1342
- const files = [...new Set([...existing?.values() ?? []].flatMap((list) => list.map((m) => this.stateById.get(m.resource)?.resource.path ?? m.resource)))];
1343
- throw new X18nError("X18N4002", `"${to}"${files.length ? ` in ${files.join(", ")}` : ""}`, files);
1344
- }
1345
- rename(op) {
1346
- if (op.from === op.to) return;
1347
- const all = this.options.allKeys();
1348
- const isLeaf = this.options.messages(op.from) !== void 0;
1349
- const under = all.filter((key) => isKeyUnder(key, op.from));
1350
- const derived = all.filter((key) => this.options.baseOf(key) === op.from);
1351
- let keys;
1352
- if (op.type === "rename-prefix") keys = under;
1353
- else if (isLeaf) keys = [op.from, ...derived];
1354
- else keys = under.length > 0 ? under : derived;
1355
- if (keys.length === 0) throw new X18nError("X18N4006", op.from);
1356
- const pairs = keys.map((key) => [key, replaceKeyPrefix(key, op.from, op.to)]);
1357
- for (const [, to] of pairs) this.conflictCheck(to);
1358
- for (const [from, to] of pairs) {
1359
- this.moveKey(from, to);
1360
- if (op.usages !== false) this.rewriteUsages(from, to);
1361
- }
1362
- if (op.usages === false) return;
1363
- if (!keys.includes(op.from)) this.rewriteUsages(op.from, op.to);
1364
- this.rewritePrefixUsages(op.from, op.to);
1365
- }
1366
- delete(op) {
1367
- const keys = this.options.messages(op.key) ? [op.key] : this.options.allKeys().filter((key) => isKeyUnder(key, op.key));
1368
- if (keys.length === 0) throw new X18nError("X18N4006", op.key);
1369
- for (const key of keys) {
1370
- this.touched.add(key);
1371
- for (const [locale, list] of this.options.messages(key) ?? []) if (op.locales === void 0 || op.locales === "all" || op.locales.includes(locale)) list.forEach((m) => this.deleteMessage(m));
1372
- const used = this.options.usageMatches({ key }).filter((match) => !match.possible).length;
1373
- if (used > 0) this.warnings.push(`"${key}" still has ${used} usage${used === 1 ? "" : "s"}.`);
1374
- }
1375
- }
1376
- extract(items) {
1377
- for (const item of items) {
1378
- if (!this.options.message(item.key, this.state.sourceLocale)) this.setValue(item.key, this.state.sourceLocale, item.value);
1379
- this.editSource(item.path, {
1380
- range: item.range,
1381
- text: item.replacement
1382
- });
1383
- if (item.imports?.length) {
1384
- const set = this.sourceImports.get(item.path) ?? /* @__PURE__ */ new Set();
1385
- this.sourceImports.set(item.path, set);
1386
- item.imports.forEach((line) => set.add(line));
1387
- }
1388
- }
1389
- }
1390
- run(ops) {
1391
- for (const op of ops) switch (op.type) {
1392
- case "set":
1393
- this.setValue(op.key, op.locale, op.value);
1394
- if (this.config.write.keepFilled) {
1395
- for (const locale of this.state.locales) if (locale !== op.locale && !this.options.message(op.key, locale)) this.setValue(op.key, locale, "");
1396
- }
1397
- break;
1398
- case "delete":
1399
- this.delete(op);
1400
- break;
1401
- case "rename":
1402
- case "rename-prefix":
1403
- this.rename(op);
1404
- break;
1405
- case "extract":
1406
- this.extract(op.items);
1407
- break;
1408
- case "format": for (const s of this.state.resources) if ((!op.paths || op.paths.includes(s.resource.path)) && !s.resource.readonly && !s.error) this.workFor(this.targetOf(s)).formatOnly = true;
1409
- }
1410
- }
1411
- /** ---- Changes ---- */
1412
- compare() {
1413
- return this.config.write.sort === "locale" ? (a, b) => a.localeCompare(b) : (a, b) => a < b ? -1 : a > b ? 1 : 0;
1414
- }
1415
- /** The new content of one existing resource. */
1416
- nextContent(entry, s) {
1417
- const { target } = entry;
1418
- const ctx = {
1419
- path: target.path,
1420
- locale: target.locale,
1421
- namespace: target.namespace,
1422
- indent: this.config.write.indent
1423
- };
1424
- const sort = this.config.write.sort !== false;
1425
- const edit = target.format.edit;
1426
- if (entry.formatOnly || sort || !edit) {
1427
- if (!target.format.serialize) throw new X18nError("X18N4001", target.path);
1428
- if (!entry.formatOnly && !edit) this.warnings.push(`${target.path} was re-written in full (the ${target.format.id} format cannot edit in place); formatting was reset.`);
1429
- return target.format.serialize(sort || entry.formatOnly ? sortTree(entry.tree, sort ? this.compare() : () => 0) : entry.tree, ctx);
1430
- }
1431
- const local = (e) => ({
1432
- range: {
1433
- start: e.range.start - s.offset,
1434
- end: e.range.end - s.offset
1435
- },
1436
- text: e.text
1437
- });
1438
- if (entry.ops.length === 0) return applyTextEdits(s.content, entry.keyRenames.map(local));
1439
- const renameOps = [];
1440
- for (const rename of entry.keyRenames) {
1441
- const node = [...s.ranges].find(([, range]) => range.key.start + s.offset === rename.range.start);
1442
- if (!node) continue;
1443
- const oldPath = node[0].split("\0");
1444
- const newPath = [...oldPath.slice(0, -1), rename.text];
1445
- const value = nodeAt(entry.tree, newPath);
1446
- renameOps.push({
1447
- type: "delete",
1448
- path: oldPath
1449
- });
1450
- if (value !== void 0 && isMessageLeaf(value)) renameOps.push({
1451
- type: "set",
1452
- path: newPath,
1453
- value
1454
- });
1455
- }
1456
- return applyTextEdits(s.content, edit(s.content, [...renameOps, ...entry.ops], ctx));
1457
- }
1458
- changes() {
1459
- const changes = [];
1460
- const texts = /* @__PURE__ */ new Map();
1461
- const resourceEdits = /* @__PURE__ */ new Map();
1462
- for (const entry of this.work.values()) {
1463
- const { target } = entry;
1464
- const s = target.state;
1465
- if (!s) {
1466
- const ctx = {
1467
- path: target.path,
1468
- locale: target.locale,
1469
- namespace: target.namespace,
1470
- indent: this.config.write.indent
1471
- };
1472
- const tree = this.config.write.sort === false ? entry.tree : sortTree(entry.tree, this.compare());
1473
- changes.push({
1474
- type: "create",
1475
- path: target.path,
1476
- content: target.format.serialize(tree, ctx)
1477
- });
1478
- texts.set(target.path, void 0);
1479
- continue;
1480
- }
1481
- const edits = diffText(s.content, this.nextContent(entry, s)).map((e) => ({
1482
- range: {
1483
- start: e.range.start + s.offset,
1484
- end: e.range.end + s.offset
1485
- },
1486
- text: e.text
1487
- }));
1488
- if (edits.length === 0) continue;
1489
- const list = resourceEdits.get(target.path) ?? [];
1490
- resourceEdits.set(target.path, list);
1491
- list.push(...edits);
1492
- }
1493
- for (const [path, edits] of resourceEdits) {
1494
- changes.push({
1495
- type: "edit",
1496
- path,
1497
- edits
1498
- });
1499
- texts.set(path, this.state.fileTexts.get(path));
1500
- }
1501
- for (const [path, edits] of this.sourceEdits) {
1502
- const text = this.options.text(path);
1503
- const imports = this.sourceImports.get(path);
1504
- const extra = imports && text !== void 0 ? importEdit(text, [...imports]) : void 0;
1505
- changes.push({
1506
- type: "edit",
1507
- path,
1508
- edits: [...edits, ...extra ? [extra] : []].sort((a, b) => a.range.start - b.range.start)
1509
- });
1510
- texts.set(path, text);
1511
- }
1512
- return {
1513
- changes,
1514
- texts
1515
- };
1516
- }
1517
- };
1518
- function createPlanner(options) {
1519
- const texts = /* @__PURE__ */ new Map();
1520
- function planWith(ops, answers) {
1521
- const state = options.state();
1522
- const builder = new PlanBuilder(options, state, answers);
1523
- builder.run(ops);
1524
- const result = builder.changes();
1525
- return {
1526
- plan: {
1527
- id: hashText(JSON.stringify({
1528
- revision: state.revision,
1529
- ops,
1530
- answers
1531
- })),
1532
- revision: state.revision,
1533
- ops,
1534
- changes: result.changes,
1535
- decisions: builder.decisions,
1536
- warnings: builder.warnings,
1537
- keys: [...builder.touched].sort()
1538
- },
1539
- texts: result.texts
1540
- };
1541
- }
1542
- async function write(change) {
1543
- if (change.type === "edit") {
1544
- const current = await options.host.readFile(change.path);
1545
- await options.host.writeFile(change.path, applyTextEdits(current ?? "", change.edits));
1546
- return {
1547
- type: "change",
1548
- path: change.path
1549
- };
1550
- }
1551
- if (change.type === "create") {
1552
- await options.host.writeFile(change.path, change.content);
1553
- return {
1554
- type: "create",
1555
- path: change.path
1556
- };
1557
- }
1558
- await options.host.deleteFile(change.path);
1559
- return {
1560
- type: "delete",
1561
- path: change.path
1562
- };
1563
- }
1564
- return {
1565
- plan(ops, answers = {}) {
1566
- const result = planWith(ops, answers);
1567
- texts.set(result.plan.id, result.texts);
1568
- return result.plan;
1569
- },
1570
- async apply(plan, answers) {
1571
- const unanswered = plan.decisions.filter((d) => answers[d.id] === void 0);
1572
- if (unanswered.length > 0) throw new X18nError("X18N4003", unanswered.map((d) => d.message).join(" "), unanswered.map((d) => d.id));
1573
- if (plan.revision !== options.state().revision) throw new X18nError("X18N4004");
1574
- const fresh = plan.decisions.length > 0 ? planWith(plan.ops, answers) : {
1575
- plan,
1576
- texts: texts.get(plan.id) ?? /* @__PURE__ */ new Map()
1577
- };
1578
- for (const change of fresh.plan.changes) {
1579
- const current = await options.host.readFile(change.path);
1580
- const expected = fresh.texts.get(change.path);
1581
- if (change.type === "edit" && expected !== void 0 && current !== expected || change.type === "create" && current !== void 0) throw new X18nError("X18N4004", change.path);
1582
- }
1583
- const events = [];
1584
- for (const change of fresh.plan.changes) events.push(await write(change));
1585
- texts.delete(plan.id);
1586
- return {
1587
- revision: (await options.update(events))?.revision ?? options.state().revision,
1588
- changes: fresh.plan.changes
1589
- };
1590
- }
1591
- };
1592
- }
1593
- //#endregion
1594
- //#region src/project/prompt.ts
1595
- function text(value) {
1596
- return value === void 0 || value === null ? void 0 : String(value);
1597
- }
1598
- function contextOf(project, key) {
1599
- return project.usages({ key }).slice(0, 2).flatMap(({ usage }) => {
1600
- const source = project.text(usage.path);
1601
- if (source === void 0) return [];
1602
- const index = createLineIndex(source);
1603
- const line = index.position(usage.range.start).line;
1604
- const lines = [
1605
- line - 1,
1606
- line,
1607
- line + 1
1608
- ].filter((n) => n >= 0 && n < index.lineCount).map((n) => index.line(n));
1609
- return [{
1610
- path: usage.path,
1611
- line: line + 1,
1612
- snippet: lines.join("\n")
1613
- }];
1614
- });
1615
- }
1616
- /** Builds the parts of a translate or review prompt from project state. */
1617
- function buildPrompt(project, kind, to, options, extra) {
1618
- const from = project.sourceLocale;
1619
- const match = typeof options.keys === "string" ? keyMatcher([options.keys]) : void 0;
1620
- const list = Array.isArray(options.keys) ? new Set(options.keys) : void 0;
1621
- const candidates = project.keys({ namespace: options.namespace }).filter((key) => {
1622
- if (match && !match(key)) return false;
1623
- if (list && !list.has(key)) return false;
1624
- const source = text(project.message(key, from)?.value);
1625
- if (source === void 0 || source === "") return false;
1626
- const target = text(project.message(key, to)?.value);
1627
- return kind === "translate" ? list !== void 0 || target === void 0 || target === "" : target !== void 0 && target !== "";
1628
- });
1629
- const start = Number(options.cursor ?? 0) || 0;
1630
- const limit = options.limit ?? 50;
1631
- const page = candidates.slice(start, start + limit);
1632
- const end = start + page.length;
1633
- const syntaxes = project.placeholderSyntaxes;
1634
- const info = project.localeInfo(to);
1635
- const draft = {
1636
- rules: [],
1637
- notes: []
1638
- };
1639
- if (syntaxes.length > 0) draft.rules.push(`Keep every placeholder exactly as written (${syntaxes.map((s) => `\`${s.example ?? s.id}\``).join(", ")}); do not translate their names.`);
1640
- draft.rules.push("Keep markup tags and their attributes as they are.", "Match the tone and wording of the existing messages in the target locale.", "Do not translate glossary terms marked \"do not translate\".");
1641
- if (info.pluralCategories.length > 1) draft.notes.push(`${to} uses the plural categories ${info.pluralCategories.join(", ")}.`);
1642
- for (const hook of extra.hooks) hook(draft, {
1643
- sourceLocale: from,
1644
- targetLocale: to,
1645
- localeInfo: info
1646
- });
1647
- const examples = kind === "translate" ? project.keys().flatMap((key) => {
1648
- const source = text(project.message(key, from)?.value);
1649
- const target = text(project.message(key, to)?.value);
1650
- return source && target && !candidates.includes(key) ? [{
1651
- key,
1652
- source,
1653
- target
1654
- }] : [];
1655
- }).slice(0, 5) : [];
1656
- const items = page.map((key) => {
1657
- const source = text(project.message(key, from)?.value) ?? "";
1658
- return {
1659
- key,
1660
- source,
1661
- placeholders: [...new Set(syntaxes.flatMap((s) => s.find(source)).map((p) => p.text))],
1662
- existing: text(project.message(key, to)?.value) || void 0,
1663
- context: options.context === false ? [] : contextOf(project, key)
1664
- };
1665
- });
1666
- const nextCursor = end < candidates.length ? String(end) : void 0;
1667
- const instructions = kind === "translate" ? [
1668
- `Write a JSON file \`{ "${to}": { "<key>": "<translation>" } }\` and run \`x18n apply <file> --json\`, or run \`x18n set <key> --locale ${to} --value <text>\` per key.`,
1669
- `Then run \`x18n check --only placeholder-mismatch,invalid-syntax --locale ${to} --json\` and fix the reported keys.`,
1670
- nextCursor ? `${candidates.length - end} more keys remain: \`x18n prompt translate --to ${to} --cursor ${nextCursor}\`.` : `To find more work: \`x18n keys --missing ${to} --json --limit 50\`.`
1671
- ] : [`Fix wrong translations with \`x18n set <key> --locale ${to} --value <text>\`, then list what you changed and why.`, `Then run \`x18n check --only placeholder-mismatch,invalid-syntax --locale ${to} --json\`.`];
1672
- return {
1673
- kind,
1674
- project: project.name,
1675
- from,
1676
- to,
1677
- targetName: info.displayName,
1678
- total: candidates.length,
1679
- remaining: candidates.length - end,
1680
- nextCursor,
1681
- rules: draft.rules,
1682
- notes: draft.notes,
1683
- glossary: extra.config.glossary.map((entry) => ({
1684
- term: entry.term,
1685
- translation: entry.translations?.[to],
1686
- note: entry.note,
1687
- doNotTranslate: entry.doNotTranslate === true
1688
- })),
1689
- examples,
1690
- items,
1691
- instructions
1692
- };
1693
- }
1694
- function renderItem(item, to) {
1695
- const out = [
1696
- `### \`${item.key}\``,
1697
- "",
1698
- `- Source: ${JSON.stringify(item.source)}`
1699
- ];
1700
- if (item.placeholders.length) out.push(`- Placeholders: ${item.placeholders.map((p) => `\`${p}\``).join(", ")}`);
1701
- if (item.existing !== void 0) out.push(`- Current ${to}: ${JSON.stringify(item.existing)}`);
1702
- for (const context of item.context) out.push(`- Used in \`${context.path}:${context.line}\`:`, "", " ```", ...context.snippet.split("\n").map((line) => ` ${line}`), " ```");
1703
- out.push("");
1704
- return out;
1705
- }
1706
- /** Plain markdown, with no provider-specific syntax. */
1707
- function renderPrompt(data) {
1708
- const target = data.targetName ? `${data.to} (${data.targetName})` : data.to;
1709
- const out = [];
1710
- if (data.kind === "translate") out.push(`# Translate ${data.items.length} messages from ${data.from} to ${target}`, "");
1711
- else out.push(`# Review ${data.items.length} ${target} translations against ${data.from}`, "", "Check meaning, placeholders, glossary terms, tone and length. Report and fix wrong translations.", "");
1712
- if (data.items.length === 0) return `${out.join("\n")}Nothing to do: every key is ${data.kind === "translate" ? "translated" : "reviewed"}.\n`;
1713
- out.push("## Rules", "", ...data.rules.map((rule) => `- ${rule}`), ...data.notes.map((note) => `- ${note}`), "");
1714
- if (data.glossary.length > 0) {
1715
- out.push("## Glossary", "");
1716
- for (const entry of data.glossary) out.push(`- ${entry.term}${entry.doNotTranslate ? ": do not translate" : entry.translation ? ` → ${entry.translation}` : ""}${entry.note ? ` (${entry.note})` : ""}`);
1717
- out.push("");
1718
- }
1719
- if (data.examples.length > 0) {
1720
- out.push(`## Existing ${data.to} messages (style reference)`, "");
1721
- for (const example of data.examples) out.push(`- \`${example.key}\`: ${JSON.stringify(example.source)} → ${JSON.stringify(example.target)}`);
1722
- out.push("");
1723
- }
1724
- out.push("## Messages", "", ...data.items.flatMap((item) => renderItem(item, data.to)));
1725
- out.push("## How to write results", "", ...data.instructions.map((line) => `- ${line}`), "");
1726
- if (data.remaining > 0) out.push(`This prompt holds ${data.items.length} of ${data.total} messages.`, "");
1727
- return out.join("\n");
1728
- }
1729
- //#endregion
1730
- //#region src/project/usages.ts
1731
- function innermost(scopes, range) {
1732
- let best;
1733
- for (const scope of scopes) if (scope.range.start <= range.start && scope.range.end >= range.end && (!best || scope.range.end - scope.range.start < best.range.end - best.range.start)) best = scope;
1734
- return best;
1735
- }
1736
- function createUsageResolver(config, namespaced, normalizers) {
1737
- const separator = config.namespace.separator;
1738
- return { resolve(candidate, scopes, path, scanner) {
1739
- const scope = innermost(scopes, candidate.range);
1740
- const ctx = { path };
1741
- let text = candidate.raw;
1742
- for (const normalize of normalizers) {
1743
- const result = normalize(text, ctx);
1744
- if (result !== void 0) {
1745
- text = result;
1746
- break;
1747
- }
1748
- }
1749
- const scopeNamespace = candidate.namespace ?? scope?.namespace;
1750
- const scopePrefix = candidate.keyPrefix ?? scope?.keyPrefix;
1751
- let namespace;
1752
- let explicit = false;
1753
- if (namespaced) {
1754
- const index = text.indexOf(separator);
1755
- explicit = index > 0;
1756
- if (explicit) {
1757
- namespace = text.slice(0, index);
1758
- text = text.slice(index + separator.length);
1759
- } else namespace = scopeNamespace ?? config.namespace.default;
1760
- }
1761
- if (scopePrefix && !explicit) text = text ? `${scopePrefix}.${text}` : scopePrefix;
1762
- let key;
1763
- if (candidate.dynamic === "unknown") key = void 0;
1764
- else key = joinKey(namespace, text === "" ? [] : text.split("."));
1765
- return {
1766
- key,
1767
- raw: candidate.raw,
1768
- path,
1769
- range: candidate.range,
1770
- scanner,
1771
- kind: candidate.kind,
1772
- dynamic: candidate.dynamic,
1773
- locale: candidate.locale,
1774
- scopeNamespace,
1775
- scopePrefix
1776
- };
1777
- } };
1778
- }
1779
- /** True when a prefix usage may refer to `key`. The key of `t(`pages.${id}`)` is `pages.`, so only children match. */
1780
- function prefixMatches(usage, key) {
1781
- return usage.dynamic === "prefix" && usage.key !== void 0 && key.startsWith(usage.key);
1782
- }
1783
- /** Ranges covered by `x18n-disable-next-line` and `x18n-disable` ... `x18n-enable` comments. */
1784
- function suppressedRanges(text) {
1785
- if (!text.includes("x18n-disable")) return [];
1786
- const ranges = [];
1787
- for (const match of text.matchAll(/x18n-disable-next-line/g)) {
1788
- const lineEnd = text.indexOf("\n", match.index);
1789
- if (lineEnd === -1) continue;
1790
- const nextEnd = text.indexOf("\n", lineEnd + 1);
1791
- ranges.push({
1792
- start: lineEnd + 1,
1793
- end: nextEnd === -1 ? text.length : nextEnd
1794
- });
1795
- }
1796
- for (const match of text.matchAll(/x18n-disable(?![-\w])/g)) {
1797
- const end = text.indexOf("x18n-enable", match.index);
1798
- ranges.push({
1799
- start: match.index,
1800
- end: end === -1 ? text.length : end
1801
- });
1802
- }
1803
- return ranges;
1804
- }
1805
- /** Compiles derived-key rules (`{key}_plural`, `{key}_*`) into a function from a key to its base key. */
1806
- function derivedBase(rules) {
1807
- const regexes = rules.map((rule) => {
1808
- const source = rule.split("{key}").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^.:]+")).join("(.+)");
1809
- return new RegExp(`^${source}$`);
1810
- });
1811
- return (key) => {
1812
- for (const re of regexes) {
1813
- const match = re.exec(key);
1814
- if (match?.[1]) return match[1];
1815
- }
1816
- };
1817
- }
1818
- //#endregion
1819
- //#region src/project/project.ts
1820
- const scanConcurrency = 64;
1821
- function createProject(options) {
1822
- const { host, setup } = options;
1823
- const { config, registry } = setup;
1824
- const listeners = /* @__PURE__ */ new Set();
1825
- const fileTexts = /* @__PURE__ */ new Map();
1826
- const resourceStates = /* @__PURE__ */ new Map();
1827
- const files = /* @__PURE__ */ new Map();
1828
- const discoveryErrors = [];
1829
- let duplicates = [];
1830
- let finderResults = [];
1831
- let revision = 0;
1832
- let namespaced = false;
1833
- let locales = [];
1834
- let sourceLocale = config.sourceLocale;
1835
- let resolver;
1836
- let watcher;
1837
- let usageEligible = () => false;
1838
- let resourceIndex;
1839
- let resolved = /* @__PURE__ */ new WeakMap();
1840
- const normalizers = registry.plugins.flatMap((plugin) => plugin.normalizeKey ? [plugin.normalizeKey] : []);
1841
- const transforms = registry.plugins.flatMap((plugin) => plugin.transformResource ? [plugin.transformResource] : []);
1842
- const localeInfoHooks = registry.plugins.flatMap((plugin) => plugin.localeInfo ? [plugin.localeInfo] : []);
1843
- const baseOf = derivedBase(config.usage.derivedKeys);
1844
- const scanSignature = hashText(JSON.stringify({
1845
- usage: config.usage,
1846
- namespace: config.namespace,
1847
- scanners: registry.scanners.map((s) => s.id),
1848
- v: 1
1849
- }));
1850
- const localeFilter = (code) => (!config.locales || config.locales.includes(code)) && !config.ignoreLocales.includes(code);
1851
- const infoCache = /* @__PURE__ */ new Map();
1852
- function localeInfo(code) {
1853
- let info = infoCache.get(code);
1854
- if (!info) {
1855
- info = parseLocaleInfo(code);
1856
- for (const hook of localeInfoHooks) {
1857
- const result = hook(code);
1858
- if (result) {
1859
- info = {
1860
- ...info,
1861
- ...result,
1862
- code
1863
- };
1864
- break;
1865
- }
1866
- }
1867
- infoCache.set(code, info);
1868
- }
1869
- return info;
1870
- }
1871
- async function discover() {
1872
- const ctx = {
1873
- host,
1874
- config,
1875
- extensions: [...new Set(registry.formats.flatMap((format) => format.extensions))]
1876
- };
1877
- const seen = /* @__PURE__ */ new Map();
1878
- const out = [];
1879
- duplicates = [];
1880
- finderResults = [];
1881
- for (const finder of registry.finders) {
1882
- const found = await finder.find(ctx);
1883
- const kept = [];
1884
- for (const candidate of found) {
1885
- const id = candidate.block ? `${candidate.path}#${candidate.block.index}` : candidate.path;
1886
- const previous = seen.get(id);
1887
- if (previous) {
1888
- duplicates.push({
1889
- path: id,
1890
- finder: finder.id,
1891
- keptFrom: previous
1892
- });
1893
- continue;
1894
- }
1895
- seen.set(id, finder.id);
1896
- if (!localeFilter(candidate.locale)) continue;
1897
- kept.push(id);
1898
- out.push({
1899
- path: candidate.path,
1900
- locale: candidate.locale,
1901
- namespace: candidate.namespace,
1902
- format: candidate.format,
1903
- block: candidate.block,
1904
- readonly: candidate.readonly === true,
1905
- finder: finder.id
1906
- });
1907
- }
1908
- finderResults.push({
1909
- id: finder.id,
1910
- resources: kept
1911
- });
1912
- }
1913
- return out;
1914
- }
1915
- function keyFor(resource, stored) {
1916
- const logical = stored.flatMap((segment) => segment.split("."));
1917
- if (namespaced) return joinKey(resource.namespace ?? config.namespace.default, logical);
1918
- return joinKey(void 0, [...resource.namespace ? resource.namespace.split(".") : [], ...logical]);
1919
- }
1920
- /** Key path to stored path through the transforms that apply to a resource, last first. */
1921
- function storedPathOf(ctx) {
1922
- const active = transforms.filter((transform) => transform.applies?.(ctx) !== false);
1923
- if (active.length === 0) return (path) => [...path];
1924
- return (path) => active.reduceRight((current, transform) => transform.toStored(current, ctx), [...path]);
1925
- }
1926
- function parseResource(candidate, text) {
1927
- const format = registry.formatFor(candidate.path, candidate.format);
1928
- const resource = {
1929
- id: candidate.block ? `${candidate.path}#${candidate.block.index}` : candidate.path,
1930
- path: candidate.path,
1931
- locale: candidate.locale,
1932
- namespace: candidate.namespace,
1933
- format: format?.id ?? "unknown",
1934
- block: candidate.block,
1935
- readonly: candidate.readonly || !format || !format.edit && !format.serialize,
1936
- finder: candidate.finder
1937
- };
1938
- const content = candidate.block ? text.slice(candidate.block.range.start, candidate.block.range.end) : text;
1939
- const offset = candidate.block?.range.start ?? 0;
1940
- const ctx = {
1941
- path: candidate.path,
1942
- locale: candidate.locale,
1943
- namespace: candidate.namespace,
1944
- indent: config.write.indent
1945
- };
1946
- const base = {
1947
- resource,
1948
- hash: hashText(content),
1949
- content,
1950
- offset,
1951
- toStored: storedPathOf(ctx)
1952
- };
1953
- if (!format) return {
1954
- ...base,
1955
- tree: {},
1956
- rawTree: {},
1957
- ranges: /* @__PURE__ */ new Map(),
1958
- error: new X18nError("X18N2001", candidate.path)
1959
- };
1960
- try {
1961
- const parsed = format.parse(content, ctx);
1962
- let tree = parsed.tree;
1963
- for (const transform of transforms) if (transform.applies?.(ctx) !== false) tree = transform.read(tree, ctx);
1964
- if (!isMessageTree(tree)) throw new X18nError("X18N2003", candidate.path);
1965
- return {
1966
- ...base,
1967
- tree,
1968
- rawTree: parsed.tree,
1969
- ranges: parsed.ranges ?? /* @__PURE__ */ new Map(),
1970
- error: void 0
1971
- };
1972
- } catch (error) {
1973
- const coded = isX18nError(error) ? error : new X18nError("X18N2002", `${candidate.path}: ${error instanceof Error ? error.message : String(error)}`);
1974
- return {
1975
- ...base,
1976
- tree: {},
1977
- rawTree: {},
1978
- ranges: /* @__PURE__ */ new Map(),
1979
- error: coded
1980
- };
1981
- }
1982
- }
1983
- async function loadResources(candidates, reread) {
1984
- const next = /* @__PURE__ */ new Map();
1985
- const paths = [...new Set(candidates.map((c) => c.path))];
1986
- await Promise.all(paths.map(async (path) => {
1987
- if (reread === "all" || reread.has(path) || !fileTexts.has(path)) {
1988
- const text = await host.readFile(path);
1989
- if (text === void 0) fileTexts.delete(path);
1990
- else fileTexts.set(path, text);
1991
- }
1992
- }));
1993
- namespaced = config.namespace.separator !== "." && candidates.some((c) => c.namespace !== void 0);
1994
- for (const candidate of candidates) {
1995
- const text = fileTexts.get(candidate.path);
1996
- if (text === void 0) continue;
1997
- const id = candidate.block ? `${candidate.path}#${candidate.block.index}` : candidate.path;
1998
- const previous = resourceStates.get(id);
1999
- const content = candidate.block ? text.slice(candidate.block.range.start, candidate.block.range.end) : text;
2000
- if (previous && previous.hash === hashText(content) && previous.resource.locale === candidate.locale && previous.resource.namespace === candidate.namespace && previous.offset === (candidate.block?.range.start ?? 0)) next.set(id, previous);
2001
- else next.set(id, parseResource(candidate, text));
2002
- }
2003
- resourceStates.clear();
2004
- for (const [id, state] of next) resourceStates.set(id, state);
2005
- const found = [...new Set([...resourceStates.values()].map((state) => state.resource.locale))];
2006
- sourceLocale = config.sourceLocale;
2007
- if (!found.includes(sourceLocale) && config.origins.sourceLocale === void 0 && found.length > 0) sourceLocale = found.find((code) => code.toLowerCase().startsWith("en")) ?? [...found].sort()[0];
2008
- locales = [sourceLocale, ...found.filter((code) => code !== sourceLocale).sort()];
2009
- if (!found.includes(sourceLocale) && found.length === 0) locales = [sourceLocale];
2010
- resolver = createUsageResolver(config, namespaced, normalizers);
2011
- resolved = /* @__PURE__ */ new WeakMap();
2012
- resourceIndex = void 0;
2013
- }
2014
- async function scanOne(file) {
2015
- const usages = [];
2016
- const scopes = [];
2017
- const keySlots = [];
2018
- const hardStrings = [];
2019
- const calls = config.usage.calls.filter((spec) => ![spec.callee].flat().some((name) => config.usage.ignoreCallees.includes(name)));
2020
- for (const scanner of registry.scannersFor(file.path)) {
2021
- const result = await scanner.scan(file, {
2022
- config,
2023
- calls
2024
- });
2025
- usages.push(...result.usages);
2026
- scopes.push(...result.scopes ?? []);
2027
- keySlots.push(...result.keySlots ?? []);
2028
- hardStrings.push(...result.hardStrings ?? []);
2029
- }
2030
- const suppressed = suppressedRanges(file.text);
2031
- const seen = /* @__PURE__ */ new Set();
2032
- const kept = usages.filter((usage) => {
2033
- const id = `${usage.range.start}:${usage.range.end}`;
2034
- if (seen.has(id)) return false;
2035
- seen.add(id);
2036
- return !suppressed.some((range) => usage.range.start >= range.start && usage.range.end <= range.end);
2037
- });
2038
- kept.sort((a, b) => a.range.start - b.range.start);
2039
- return {
2040
- usages: kept,
2041
- scopes,
2042
- keySlots,
2043
- hardStrings
2044
- };
2045
- }
2046
- async function scanPaths(paths) {
2047
- const pending = [];
2048
- const cacheKeys = /* @__PURE__ */ new Map();
2049
- await Promise.all(paths.map(async (path) => {
2050
- const text = await host.readFile(path);
2051
- if (text === void 0) {
2052
- files.delete(path);
2053
- return;
2054
- }
2055
- const hash = hashText(text);
2056
- if (files.get(path)?.hash === hash) return;
2057
- if (host.cache) {
2058
- const cacheKey = `scan:${scanSignature}:${path}:${hash}`;
2059
- cacheKeys.set(path, cacheKey);
2060
- const cached = await host.cache.get(cacheKey);
2061
- if (cached !== void 0) {
2062
- files.set(path, {
2063
- hash,
2064
- text,
2065
- result: JSON.parse(cached),
2066
- error: void 0
2067
- });
2068
- return;
2069
- }
2070
- }
2071
- pending.push({
2072
- path,
2073
- text,
2074
- extension: extensionOf(path)
2075
- });
2076
- }));
2077
- const store = (file, result, error) => {
2078
- files.set(file.path, {
2079
- hash: hashText(file.text),
2080
- text: file.text,
2081
- result: result ?? { usages: [] },
2082
- error
2083
- });
2084
- const cacheKey = cacheKeys.get(file.path);
2085
- if (result && cacheKey && host.cache) host.cache.set(cacheKey, JSON.stringify(result));
2086
- };
2087
- for (let i = 0; i < pending.length; i += scanConcurrency) await Promise.all(pending.slice(i, i + scanConcurrency).map(async (file) => {
2088
- try {
2089
- store(file, await scanOne(file));
2090
- } catch (error) {
2091
- store(file, void 0, new X18nError("X18N3001", `${file.path}: ${error instanceof Error ? error.message : String(error)}`));
2092
- }
2093
- }));
2094
- }
2095
- async function scanAll() {
2096
- if (registry.scanners.length === 0) return;
2097
- const include = pathMatcher(config.usage.include);
2098
- const exclude = pathMatcher(config.usage.exclude);
2099
- usageEligible = (path) => registry.scannersFor(path).length > 0 && include(path) && !exclude(path);
2100
- await scanPaths((await host.glob(config.usage.include, {
2101
- ignore: config.usage.exclude,
2102
- gitignore: true
2103
- })).filter((path) => registry.scannersFor(path).length > 0));
2104
- }
2105
- let index;
2106
- function buildResourceIndex() {
2107
- const messages = /* @__PURE__ */ new Map();
2108
- const keyPaths = /* @__PURE__ */ new Map();
2109
- const nodes = /* @__PURE__ */ new Map();
2110
- const parents = /* @__PURE__ */ new Set();
2111
- for (const state of resourceStates.values()) {
2112
- const { resource } = state;
2113
- const fileNodes = nodes.get(resource.path) ?? [];
2114
- nodes.set(resource.path, fileNodes);
2115
- const shift = (range) => ({
2116
- start: range.start + state.offset,
2117
- end: range.end + state.offset
2118
- });
2119
- const visit = (tree, stored) => {
2120
- for (const [segment, child] of Object.entries(tree)) {
2121
- const path = [...stored, segment];
2122
- const key = keyFor(resource, path);
2123
- const storedPath = state.toStored(path);
2124
- const raw = state.ranges.get(pathId(storedPath));
2125
- const range = raw ? {
2126
- key: shift(raw.key),
2127
- value: shift(raw.value)
2128
- } : void 0;
2129
- if (range) fileNodes.push({
2130
- key,
2131
- range,
2132
- leaf: isMessageLeaf(child),
2133
- locale: resource.locale,
2134
- path: resource.path
2135
- });
2136
- if (isMessageLeaf(child)) {
2137
- const byLocale = messages.get(key) ?? /* @__PURE__ */ new Map();
2138
- messages.set(key, byLocale);
2139
- const list = byLocale.get(resource.locale) ?? [];
2140
- byLocale.set(resource.locale, list);
2141
- list.push({
2142
- key,
2143
- locale: resource.locale,
2144
- value: child,
2145
- resource: resource.id,
2146
- storedPath,
2147
- range
2148
- });
2149
- if (!keyPaths.has(key)) {
2150
- const logical = path.flatMap((s) => s.split("."));
2151
- keyPaths.set(key, namespaced ? {
2152
- namespace: resource.namespace ?? config.namespace.default,
2153
- path: logical
2154
- } : {
2155
- namespace: void 0,
2156
- path: [...resource.namespace ? resource.namespace.split(".") : [], ...logical]
2157
- });
2158
- }
2159
- } else {
2160
- parents.add(key);
2161
- visit(child, path);
2162
- }
2163
- }
2164
- };
2165
- visit(state.tree, []);
2166
- }
2167
- for (const { namespace, path } of keyPaths.values()) for (let i = 1; i < path.length; i++) parents.add(joinKey(namespace, path.slice(0, i)));
2168
- const derived = /* @__PURE__ */ new Map();
2169
- if (config.usage.derivedKeys.length > 0) for (const key of messages.keys()) {
2170
- const base = baseOf(key);
2171
- if (base) {
2172
- const list = derived.get(base) ?? [];
2173
- derived.set(base, list);
2174
- list.push(key);
2175
- }
2176
- }
2177
- return {
2178
- messages,
2179
- keyPaths,
2180
- nodes,
2181
- parents,
2182
- derived
2183
- };
2184
- }
2185
- function buildIndex() {
2186
- resourceIndex ??= buildResourceIndex();
2187
- const usages = [];
2188
- for (const [path, state] of files) {
2189
- let list = resolved.get(state);
2190
- if (!list) {
2191
- list = state.result.usages.map((candidate) => resolver.resolve(candidate, state.result.scopes ?? [], path, "scanner"));
2192
- resolved.set(state, list);
2193
- }
2194
- usages.push(...list);
2195
- }
2196
- const usagesByKey = /* @__PURE__ */ new Map();
2197
- const prefixUsages = [];
2198
- for (const usage of usages) {
2199
- if (usage.key === void 0) continue;
2200
- if (usage.dynamic === "prefix") {
2201
- prefixUsages.push(usage);
2202
- continue;
2203
- }
2204
- const list = usagesByKey.get(usage.key) ?? [];
2205
- usagesByKey.set(usage.key, list);
2206
- list.push(usage);
2207
- }
2208
- return {
2209
- revision,
2210
- ...resourceIndex,
2211
- usages,
2212
- usagesByKey,
2213
- prefixUsages
2214
- };
2215
- }
2216
- function idx() {
2217
- if (!index || index.revision !== revision) index = buildIndex();
2218
- return index;
2219
- }
2220
- function message(key, locale) {
2221
- return idx().messages.get(key)?.get(locale)?.[0];
2222
- }
2223
- function usageMatches(query = {}) {
2224
- const { usages, usagesByKey, prefixUsages } = idx();
2225
- const inPath = (usage) => query.path === void 0 || usage.path === query.path;
2226
- if (query.key !== void 0) {
2227
- const key = query.key;
2228
- const base = baseOf(key);
2229
- return [...[...usagesByKey.get(key) ?? [], ...base ? usagesByKey.get(base) ?? [] : []].filter(inPath).map((usage) => ({
2230
- usage,
2231
- possible: false
2232
- })), ...prefixUsages.filter((usage) => inPath(usage) && prefixMatches(usage, key)).map((usage) => ({
2233
- usage,
2234
- possible: true
2235
- }))];
2236
- }
2237
- if (query.prefix !== void 0) {
2238
- const prefix = query.prefix;
2239
- return [...usages.filter((usage) => inPath(usage) && usage.dynamic === false && usage.key !== void 0 && isKeyUnder(usage.key, prefix)).map((usage) => ({
2240
- usage,
2241
- possible: false
2242
- })), ...prefixUsages.filter((usage) => inPath(usage) && usage.key !== void 0 && (usage.key.startsWith(prefix) || prefix.startsWith(usage.key))).map((usage) => ({
2243
- usage,
2244
- possible: true
2245
- }))];
2246
- }
2247
- return usages.filter(inPath).map((usage) => ({
2248
- usage,
2249
- possible: usage.dynamic !== false
2250
- }));
2251
- }
2252
- const analyzer = createAnalyzer({
2253
- config,
2254
- registry,
2255
- state: () => ({
2256
- revision,
2257
- sourceLocale,
2258
- locales,
2259
- resources: [...resourceStates.values()],
2260
- resourceErrors: [...resourceStates.values()].flatMap((s) => s.error ? [{
2261
- error: s.error,
2262
- path: s.resource.path
2263
- }] : []),
2264
- scanErrors: [...files].flatMap(([path, s]) => s.error ? [{
2265
- error: s.error,
2266
- path
2267
- }] : []),
2268
- discoveryErrors,
2269
- messages: idx().messages,
2270
- parents: idx().parents,
2271
- usages: idx().usages,
2272
- usagesByKey: idx().usagesByKey,
2273
- prefixUsages: idx().prefixUsages,
2274
- derived: idx().derived,
2275
- files
2276
- }),
2277
- baseOf,
2278
- localeInfo,
2279
- text: (path) => fileTexts.get(path) ?? files.get(path)?.text
2280
- });
2281
- function keys(query = {}) {
2282
- const { messages, keyPaths } = idx();
2283
- const match = query.glob ? keyMatcher([query.glob]) : void 0;
2284
- let list = [...messages.keys()];
2285
- if (match) list = list.filter((key) => match(key));
2286
- if (query.namespace !== void 0) list = list.filter((key) => keyPaths.get(key)?.namespace === query.namespace);
2287
- if (query.missing !== void 0) {
2288
- const locale = query.missing;
2289
- list = list.filter((key) => {
2290
- const value = message(key, locale)?.value;
2291
- return message(key, sourceLocale) !== void 0 && (value === void 0 || value === "");
2292
- });
2293
- }
2294
- if (query.unused) {
2295
- const unused = new Set(analyzer.analyze().unused);
2296
- list = list.filter((key) => unused.has(key));
2297
- }
2298
- return list.sort();
2299
- }
2300
- function get(key) {
2301
- const { messages, keyPaths } = idx();
2302
- const byLocale = messages.get(key);
2303
- if (!byLocale) return void 0;
2304
- const id = keyPaths.get(key);
2305
- const list = locales.flatMap((locale) => byLocale.get(locale)?.slice(0, 1) ?? []);
2306
- return {
2307
- key,
2308
- namespace: id.namespace,
2309
- path: id.path,
2310
- messages: list,
2311
- missing: locales.filter((locale) => !byLocale.has(locale)),
2312
- usages: usageMatches({ key }),
2313
- diagnostics: analyzer.analyze().diagnostics.filter((d) => d.key === key)
2314
- };
2315
- }
2316
- function keyAt(path, offset) {
2317
- const fileNodes = idx().nodes.get(path);
2318
- if (fileNodes) {
2319
- let best;
2320
- for (const node of fileNodes) if ((rangeContains(node.range.key, offset) || node.leaf && rangeContains(node.range.value, offset)) && (!best || node.range.key.start > best.range.key.start)) best = node;
2321
- if (best) return best.leaf ? {
2322
- type: "key",
2323
- key: best.key,
2324
- range: best.range.key,
2325
- source: "resource"
2326
- } : {
2327
- type: "subtree",
2328
- key: best.key,
2329
- range: best.range.key,
2330
- source: "resource"
2331
- };
2332
- }
2333
- if (files.get(path)) {
2334
- const usage = idx().usages.find((u) => u.path === path && u.key !== void 0 && rangeContains(u.range, offset));
2335
- if (usage?.key !== void 0) return {
2336
- type: "key",
2337
- key: usage.key,
2338
- range: usage.range,
2339
- source: "usage",
2340
- usage
2341
- };
2342
- }
2343
- }
2344
- function explain() {
2345
- const resources = [...resourceStates.values()];
2346
- return {
2347
- name: config.name,
2348
- root: host.root,
2349
- presets: setup.presets,
2350
- plugins: registry.plugins.map((plugin) => plugin.name),
2351
- skipped: setup.skipped,
2352
- finders: finderResults,
2353
- duplicates,
2354
- formats: registry.formats.map((format) => ({
2355
- id: format.id,
2356
- extensions: format.extensions,
2357
- resources: resources.filter((state) => state.resource.format === format.id).length
2358
- })),
2359
- scanners: registry.scanners.map((scanner) => ({
2360
- id: scanner.id,
2361
- extensions: scanner.extensions,
2362
- files: [...files.keys()].filter((path) => registry.scannersFor(path).includes(scanner)).length
2363
- })),
2364
- conflicts: registry.conflicts,
2365
- errors: [
2366
- ...discoveryErrors,
2367
- ...resources.flatMap((state) => state.error ? [{
2368
- error: state.error,
2369
- path: state.resource.path
2370
- }] : []),
2371
- ...[...files].flatMap(([path, state]) => state.error ? [{
2372
- error: state.error,
2373
- path
2374
- }] : [])
2375
- ].map(({ error, path }) => ({
2376
- ...error.toJSON(),
2377
- path
2378
- })),
2379
- config
2380
- };
2381
- }
2382
- function emit(change) {
2383
- for (const listener of listeners) listener(change);
2384
- }
2385
- function touchedKeys(paths, before) {
2386
- const out = /* @__PURE__ */ new Set();
2387
- const collect = (from) => {
2388
- if (!from) return;
2389
- for (const path of paths) for (const node of from.nodes.get(path) ?? []) out.add(node.key);
2390
- for (const usage of from.usages) if (usage.key !== void 0 && paths.has(usage.path)) out.add(usage.key);
2391
- };
2392
- collect(before);
2393
- collect(idx());
2394
- return [...out];
2395
- }
2396
- let current = Promise.resolve();
2397
- async function update(events) {
2398
- const run = current.then(async () => {
2399
- if (events.length === 0) return void 0;
2400
- const before = idx();
2401
- const paths = new Set(events.map((event) => event.path));
2402
- const resourcePaths = new Set([...resourceStates.values()].map((state) => state.resource.path));
2403
- const maybeResource = (path) => !resourcePaths.has(path) && registry.formatFor(path) !== void 0 && !usageEligible(path);
2404
- const structural = events.some((event) => event.type !== "change" || maybeResource(event.path));
2405
- if (structural || [...paths].some((path) => resourcePaths.has(path))) await loadResources(structural ? await discover() : [...resourceStates.values()].map((state) => ({
2406
- ...state.resource,
2407
- format: state.resource.format === "unknown" ? void 0 : state.resource.format
2408
- })), paths);
2409
- const scanTargets = [...paths].filter((path) => usageEligible(path) || files.has(path));
2410
- for (const event of events) if (event.type === "delete") files.delete(event.path);
2411
- await scanPaths(scanTargets.filter((path) => events.some((event) => event.path === path && event.type !== "delete")));
2412
- revision++;
2413
- const change = {
2414
- revision,
2415
- paths: [...paths],
2416
- keys: touchedKeys(paths, before)
2417
- };
2418
- emit(change);
2419
- return change;
2420
- });
2421
- current = run.then(() => void 0, () => void 0);
2422
- return run;
2423
- }
2424
- const planner = createPlanner({
2425
- host,
2426
- config,
2427
- registry,
2428
- state: () => ({
2429
- revision,
2430
- sourceLocale,
2431
- locales,
2432
- namespaced,
2433
- resources: [...resourceStates.values()],
2434
- fileTexts
2435
- }),
2436
- message: (key, locale) => message(key, locale),
2437
- messages: (key) => idx().messages.get(key),
2438
- allKeys: () => [...idx().messages.keys()],
2439
- parents: () => idx().parents,
2440
- usageMatches,
2441
- text: (path) => fileTexts.get(path) ?? files.get(path)?.text,
2442
- baseOf,
2443
- toStored: (target, path) => storedPathOf(target)(path),
2444
- resolveNew(locale, namespace) {
2445
- const ctx = {
2446
- host,
2447
- config,
2448
- extensions: [...new Set(registry.formats.flatMap((format) => format.extensions))]
2449
- };
2450
- for (const finder of registry.finders) {
2451
- const path = finder.resolveNew?.({
2452
- locale,
2453
- namespace
2454
- }, ctx);
2455
- if (path) return path;
2456
- }
2457
- },
2458
- update
2459
- });
2460
- const project = {
2461
- get name() {
2462
- return config.name;
2463
- },
2464
- config,
2465
- ready: (async () => {
2466
- await loadResources(await discover(), "all");
2467
- await scanAll();
2468
- revision++;
2469
- })(),
2470
- get revision() {
2471
- return revision;
2472
- },
2473
- get locales() {
2474
- return locales;
2475
- },
2476
- get sourceLocale() {
2477
- return sourceLocale;
2478
- },
2479
- get resources() {
2480
- return [...resourceStates.values()].map((state) => state.resource);
2481
- },
2482
- get namespaced() {
2483
- return namespaced;
2484
- },
2485
- reporters: registry.reporters,
2486
- placeholderSyntaxes: registry.placeholders,
2487
- extractTemplates: [...(config.extract.templates ?? []).map((template) => typeof template === "string" ? { template } : template), ...registry.extractors.flatMap((extractor) => extractor.templates)],
2488
- keys,
2489
- get,
2490
- message,
2491
- parseKey(input) {
2492
- const separator = config.namespace.separator;
2493
- if (!namespaced) return input;
2494
- if (input.indexOf(":") > 0) return input;
2495
- const custom = separator !== ":" ? input.indexOf(separator) : -1;
2496
- if (custom > 0) return `${input.slice(0, custom)}:${input.slice(custom + separator.length)}`;
2497
- return config.namespace.default ? `${config.namespace.default}:${input}` : input;
2498
- },
2499
- usages: usageMatches,
2500
- keyAt,
2501
- nodes: (path) => idx().nodes.get(path) ?? [],
2502
- locations(key) {
2503
- const order = new Map(locales.map((locale, i) => [locale, i]));
2504
- return [...idx().nodes.values()].flat().filter((node) => node.key === key).sort((a, b) => (order.get(a.locale) ?? 0) - (order.get(b.locale) ?? 0));
2505
- },
2506
- keySlots: (path) => files.get(path)?.result.keySlots ?? [],
2507
- hardStrings: (path) => [...files].filter(([p]) => path === void 0 || p === path).flatMap(([p, state]) => (state.result.hardStrings ?? []).map((h) => ({
2508
- ...h,
2509
- path: p
2510
- }))),
2511
- analyze: () => analyzer.analyze(),
2512
- prompt: (kind, locale, promptOptions = {}) => buildPrompt(project, kind, locale, promptOptions, {
2513
- config,
2514
- hooks: registry.plugins.flatMap((plugin) => plugin.translatePrompt ? [plugin.translatePrompt] : [])
2515
- }),
2516
- explain,
2517
- localeInfo,
2518
- text: (path) => fileTexts.get(path) ?? files.get(path)?.text,
2519
- owns: (path) => fileTexts.has(path) || files.has(path) || usageEligible(path),
2520
- plan: (ops) => planner.plan(ops),
2521
- apply: (plan, applyOptions) => planner.apply(plan, applyOptions?.answers ?? {}),
2522
- update,
2523
- watch() {
2524
- if (watcher || !host.watch) return;
2525
- watcher = host.watch(["**/*"], (events) => {
2526
- update(events);
2527
- });
2528
- },
2529
- onChange(listener) {
2530
- listeners.add(listener);
2531
- return () => listeners.delete(listener);
2532
- },
2533
- dispose() {
2534
- watcher?.dispose();
2535
- listeners.clear();
2536
- }
2537
- };
2538
- return project;
2539
- }
2540
- //#endregion
2541
- //#region src/resolve.ts
2542
- const defaultExclude = [
2543
- "**/node_modules/**",
2544
- "**/.git/**",
2545
- "**/dist/**",
2546
- "**/build/**",
2547
- "**/coverage/**",
2548
- "**/.nuxt/**",
2549
- "**/.output/**",
2550
- "**/.next/**",
2551
- "**/.svelte-kit/**",
2552
- "**/*.d.ts",
2553
- "**/*.min.js"
2554
- ];
2555
- function defaultDraft() {
2556
- return {
2557
- sourceLocale: "en",
2558
- locales: void 0,
2559
- ignoreLocales: [],
2560
- resources: [],
2561
- formats: {},
2562
- keyStyle: "auto",
2563
- namespace: {
2564
- separator: ".",
2565
- default: void 0
2566
- },
2567
- usage: {
2568
- include: ["**/*"],
2569
- exclude: defaultExclude,
2570
- calls: [],
2571
- regex: [],
2572
- ignoreCallees: [],
2573
- keysInUse: [],
2574
- derivedKeys: []
2575
- },
2576
- write: {
2577
- sort: false,
2578
- indent: void 0,
2579
- keepFilled: false
2580
- },
2581
- extract: {},
2582
- checks: {},
2583
- strict: false,
2584
- ignoreKeys: [],
2585
- glossary: [],
2586
- pluginConfigs: {}
2587
- };
2588
- }
2589
- function serializablePart(config) {
2590
- const { presets, plugins, pluginConfigs, ...rest } = config;
2591
- return rest;
2592
- }
2593
- /** Applies a user config layer: scalars replace, lists of rules append. */
2594
- function applyLayer(draft, layer, origin, origins) {
2595
- const mark = (key) => {
2596
- origins[key] = origin;
2597
- };
2598
- if (layer.sourceLocale !== void 0) {
2599
- draft.sourceLocale = layer.sourceLocale;
2600
- mark("sourceLocale");
2601
- }
2602
- if (layer.locales !== void 0) {
2603
- draft.locales = layer.locales;
2604
- mark("locales");
2605
- }
2606
- if (layer.ignoreLocales !== void 0) {
2607
- draft.ignoreLocales = [...draft.ignoreLocales, ...layer.ignoreLocales];
2608
- mark("ignoreLocales");
2609
- }
2610
- if (layer.resources !== void 0) {
2611
- draft.resources = layer.resources;
2612
- mark("resources");
2613
- }
2614
- if (layer.formats !== void 0) {
2615
- draft.formats = {
2616
- ...draft.formats,
2617
- ...layer.formats
2618
- };
2619
- mark("formats");
2620
- }
2621
- if (layer.keyStyle !== void 0) {
2622
- draft.keyStyle = layer.keyStyle;
2623
- mark("keyStyle");
2624
- }
2625
- if (layer.namespace !== void 0) {
2626
- draft.namespace = {
2627
- separator: layer.namespace.separator ?? draft.namespace.separator,
2628
- default: layer.namespace.default ?? draft.namespace.default
2629
- };
2630
- mark("namespace");
2631
- }
2632
- const usage = layer.usage;
2633
- if (usage !== void 0) {
2634
- draft.usage = {
2635
- include: usage.include ?? draft.usage.include,
2636
- exclude: [...draft.usage.exclude, ...usage.exclude ?? []],
2637
- calls: [...draft.usage.calls, ...usage.calls ?? []],
2638
- regex: [...draft.usage.regex, ...usage.regex ?? []],
2639
- ignoreCallees: [...draft.usage.ignoreCallees, ...usage.ignoreCallees ?? []],
2640
- keysInUse: [...draft.usage.keysInUse, ...usage.keysInUse ?? []],
2641
- derivedKeys: [...draft.usage.derivedKeys, ...usage.derivedKeys ?? []]
2642
- };
2643
- mark("usage");
2644
- }
2645
- if (layer.write !== void 0) {
2646
- draft.write = {
2647
- sort: layer.write.sort ?? draft.write.sort,
2648
- indent: layer.write.indent ?? draft.write.indent,
2649
- keepFilled: layer.write.keepFilled ?? draft.write.keepFilled
2650
- };
2651
- mark("write");
2652
- }
2653
- if (layer.extract !== void 0) {
2654
- draft.extract = {
2655
- ...draft.extract,
2656
- ...layer.extract,
2657
- templates: [...draft.extract.templates ?? [], ...layer.extract.templates ?? []]
2658
- };
2659
- mark("extract");
2660
- }
2661
- if (layer.checks !== void 0) {
2662
- draft.checks = {
2663
- ...draft.checks,
2664
- ...layer.checks
2665
- };
2666
- mark("checks");
2667
- }
2668
- if (layer.strict !== void 0) {
2669
- draft.strict = layer.strict;
2670
- mark("strict");
2671
- }
2672
- if (layer.ignoreKeys !== void 0) {
2673
- draft.ignoreKeys = [...draft.ignoreKeys, ...layer.ignoreKeys];
2674
- mark("ignoreKeys");
2675
- }
2676
- if (layer.glossary !== void 0) {
2677
- draft.glossary = [...draft.glossary, ...layer.glossary];
2678
- mark("glossary");
2679
- }
2680
- if (layer.pluginConfigs !== void 0) {
2681
- draft.pluginConfigs = {
2682
- ...draft.pluginConfigs,
2683
- ...layer.pluginConfigs
2684
- };
2685
- mark("pluginConfigs");
2686
- }
2687
- }
2688
- async function createDetectContext(host, readers, roots) {
2689
- const dependencies = /* @__PURE__ */ new Set();
2690
- const seen = /* @__PURE__ */ new Set();
2691
- for (const root of ["", ...roots]) for (const reader of readers) {
2692
- const path = root ? `${root.replace(/\/$/, "")}/${reader.file}` : reader.file;
2693
- if (seen.has(path)) continue;
2694
- seen.add(path);
2695
- const text = await host.readFile(path);
2696
- if (text === void 0) continue;
2697
- try {
2698
- for (const name of reader.read(text)) dependencies.add(name);
2699
- } catch {
2700
- host.logger?.warn(`Could not read manifest ${path}`);
2701
- }
2702
- }
2703
- return {
2704
- host,
2705
- dependencies,
2706
- hasDependency: (name) => dependencies.has(name),
2707
- hasAnyDependency: (names) => names.some((name) => dependencies.has(name)),
2708
- fileExists: async (pattern) => (await host.glob([pattern], { gitignore: true })).length > 0
2709
- };
2710
- }
2711
- function validateLayers(layers) {
2712
- for (const layer of layers) {
2713
- if (!layer) continue;
2714
- const result = validate(ConfigSchema, serializablePart(layer));
2715
- if (!result.success) throw new X18nError("X18N0001", result.issues.map((issue) => `${issue.path || "(root)"}: ${issue.message}`).join("; "));
2716
- }
2717
- }
2718
- function createLoader(options) {
2719
- return async (option) => {
2720
- if (option === false || option === void 0) return void 0;
2721
- if (typeof option === "object" && !Array.isArray(option)) return option;
2722
- const [specifier, pluginOptions] = typeof option === "string" ? [option, void 0] : [option[0], option[1]];
2723
- const factory = options.builtins.presets[specifier];
2724
- if (factory) return factory(pluginOptions);
2725
- if (!options.loadPlugin) throw new X18nError("X18N1003", specifier);
2726
- return options.loadPlugin(specifier, pluginOptions);
2727
- };
2728
- }
2729
- async function loadAll(load, list) {
2730
- const out = [];
2731
- for (const option of list) {
2732
- const loaded = await load(option);
2733
- if (loaded) out.push(loaded);
2734
- }
2735
- return out;
2736
- }
2737
- /** Expands presets depth-first: listed presets always apply, others only when detected; an exclusive one stops the rest. */
2738
- async function expandPresets(presets, userPlugins, listed, detectCtx) {
2739
- const explanations = [];
2740
- const plugins = [];
2741
- const flatten = async (item, force) => {
2742
- if (!isPreset(item)) {
2743
- if (!plugins.some((plugin) => plugin.name === item.name)) plugins.push(item);
2744
- return false;
2745
- }
2746
- const applies = force || (item.detect ? await item.detect(detectCtx) : false);
2747
- explanations.push({
2748
- name: item.name,
2749
- reason: applies ? force ? "listed" : "detected" : "not-detected"
2750
- });
2751
- if (!applies) return false;
2752
- for (const child of item.plugins) await flatten(child, false);
2753
- return item.exclusive === true;
2754
- };
2755
- let stopped = false;
2756
- for (const preset of presets) if (stopped) explanations.push({
2757
- name: preset.name,
2758
- reason: "after-exclusive"
2759
- });
2760
- else stopped = await flatten(preset, listed);
2761
- for (const plugin of userPlugins) await flatten(plugin, true);
2762
- return {
2763
- plugins,
2764
- explanations
2765
- };
2766
- }
2767
- async function runConfigHooks(plugins, draft, ctx, origins) {
2768
- for (const plugin of plugins) {
2769
- if (!plugin.config) continue;
2770
- const snapshot = Object.fromEntries(Object.entries(draft).map(([key, value]) => [key, JSON.stringify(value)]));
2771
- await plugin.config(draft, ctx);
2772
- for (const [key, value] of Object.entries(draft)) if (snapshot[key] !== JSON.stringify(value)) origins[key] = `plugin:${plugin.name}`;
2773
- }
2774
- }
2775
- async function resolveConfig(options) {
2776
- const { host, builtins } = options;
2777
- const user = options.config;
2778
- validateLayers([options.parent, user]);
2779
- const load = createLoader(options);
2780
- const userPlugins = await loadAll(load, [...options.parent?.plugins ?? [], ...user.plugins ?? []]);
2781
- const presetOption = user.presets ?? options.parent?.presets ?? "auto";
2782
- const auto = presetOption === "auto";
2783
- const presets = auto ? Object.values(builtins.presets).map((factory) => factory()) : (await loadAll(load, presetOption)).map((item) => isPreset(item) ? item : {
2784
- name: item.name,
2785
- plugins: [item]
2786
- });
2787
- const detectCtx = await createDetectContext(host, [...builtins.plugins, ...userPlugins].flatMap((item) => isPreset(item) ? [] : item.manifests ?? []), options.manifestRoots ?? []);
2788
- const expanded = await expandPresets(presets, userPlugins, !auto, detectCtx);
2789
- const skipped = [];
2790
- const finalPlugins = [...builtins.plugins, ...expanded.plugins].filter((plugin) => {
2791
- const skip = options.runtime === "browser" && plugin.runtime === "node";
2792
- if (skip) skipped.push({
2793
- name: plugin.name,
2794
- reason: "node only"
2795
- });
2796
- return !skip;
2797
- });
2798
- const draft = defaultDraft();
2799
- const origins = {};
2800
- await runConfigHooks(finalPlugins, draft, {
2801
- host,
2802
- user: serializablePart(user)
2803
- }, origins);
2804
- if (options.parent) applyLayer(draft, options.parent, "parent", origins);
2805
- applyLayer(draft, user, "user", origins);
2806
- const config = Object.freeze({
2807
- ...draft,
2808
- name: user.name ?? options.name ?? "default",
2809
- usage: {
2810
- ...draft.usage,
2811
- derivedKeys: [...draft.usage.derivedKeys, ...finalPlugins.flatMap((plugin) => plugin.derivedKeys ?? [])]
2812
- },
2813
- origins
2814
- });
2815
- for (const plugin of finalPlugins) plugin.configResolved?.(config);
2816
- return {
2817
- config,
2818
- registry: createRegistry(finalPlugins, config),
2819
- presets: expanded.explanations,
2820
- skipped
2821
- };
2822
- }
2823
- //#endregion
2824
- export { ConfigSchema, PluginFileSchema, X18nError, applyMessageOps, applyTextEdits, buildTree, contractVersion, createLineIndex, createMemoryHost, createOverlayHost, createProject, createRegistry, defineConfig, definePlugin, definePreset, diffText, envelope, errorCodes, extensionOf, globToRegExp, hashText, isJsonObject, isKeyUnder, isMessageLeaf, isMessageTree, isPreset, isX18nError, joinKey, keyMatcher, localeSpellingKey, parseLocaleInfo, parseMessageBatch, pathId, pathMatcher, placeholderProblems, rangeContains, renderPrompt, replaceKeyPrefix, resolveConfig, sortTree, splitKey, toWireDiagnostic, toWireRange, validate, walkLeaves };