@x18n/core 0.0.2 → 0.0.5
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.d.mts +1316 -0
- package/dist/index.mjs +3046 -0
- package/package.json +4 -4
- package/schema/config.json +561 -0
- package/schema/plugin.json +374 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3046 @@
|
|
|
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 === "zh" && script === "Hant" ? "TW" : 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
|
+
histories: plugins.flatMap((plugin) => plugin.histories ?? []),
|
|
587
|
+
conflicts,
|
|
588
|
+
formatById,
|
|
589
|
+
formatFor(path, hint) {
|
|
590
|
+
if (hint) return formatById(hint);
|
|
591
|
+
const extension = extensionOf(path);
|
|
592
|
+
const mapped = config.formats[extension];
|
|
593
|
+
return mapped ? formatById(mapped) : byExtension.get(extension);
|
|
594
|
+
},
|
|
595
|
+
scannersFor(path) {
|
|
596
|
+
const extension = extensionOf(path);
|
|
597
|
+
let list = scannerCache.get(extension);
|
|
598
|
+
if (!list) {
|
|
599
|
+
list = extension ? scanners.filter((scanner) => scanner.extensions.includes(extension) || scanner.extensions.includes("*")) : [];
|
|
600
|
+
scannerCache.set(extension, list);
|
|
601
|
+
}
|
|
602
|
+
return list;
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region src/schema.ts
|
|
608
|
+
function isJsonValue(value) {
|
|
609
|
+
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return true;
|
|
610
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
611
|
+
return isJsonObject(value);
|
|
612
|
+
}
|
|
613
|
+
function isJsonObject(value) {
|
|
614
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every(isJsonValue);
|
|
615
|
+
}
|
|
616
|
+
const jsonObject = v.custom(isJsonObject, "Expected a JSON object");
|
|
617
|
+
const strings = v.array(v.string());
|
|
618
|
+
const level = v.picklist([
|
|
619
|
+
"error",
|
|
620
|
+
"warning",
|
|
621
|
+
"info",
|
|
622
|
+
"hint",
|
|
623
|
+
"off"
|
|
624
|
+
]);
|
|
625
|
+
const argSource = v.strictObject({
|
|
626
|
+
arg: v.number(),
|
|
627
|
+
property: v.optional(v.string())
|
|
628
|
+
});
|
|
629
|
+
const callSpec = v.strictObject({
|
|
630
|
+
callee: v.union([v.string(), strings]),
|
|
631
|
+
kind: v.optional(v.picklist([
|
|
632
|
+
"call",
|
|
633
|
+
"selector",
|
|
634
|
+
"accessor"
|
|
635
|
+
])),
|
|
636
|
+
key: v.optional(v.union([v.number(), argSource])),
|
|
637
|
+
namespace: v.optional(argSource),
|
|
638
|
+
keyPrefix: v.optional(argSource),
|
|
639
|
+
factory: v.optional(v.boolean()),
|
|
640
|
+
returns: v.optional(strings),
|
|
641
|
+
types: v.optional(strings)
|
|
642
|
+
});
|
|
643
|
+
const regexSpec = v.strictObject({
|
|
644
|
+
extensions: strings,
|
|
645
|
+
patterns: strings
|
|
646
|
+
});
|
|
647
|
+
const resourceSpec = v.strictObject({
|
|
648
|
+
pattern: v.string(),
|
|
649
|
+
format: v.optional(v.string()),
|
|
650
|
+
namespace: v.optional(v.string()),
|
|
651
|
+
locale: v.optional(v.string()),
|
|
652
|
+
execute: v.optional(v.boolean())
|
|
653
|
+
});
|
|
654
|
+
const pluginSpecifier = v.union([v.string(), v.tuple([v.string(), jsonObject])]);
|
|
655
|
+
const extractTemplate = v.strictObject({
|
|
656
|
+
template: v.string(),
|
|
657
|
+
kinds: v.optional(strings),
|
|
658
|
+
files: v.optional(strings),
|
|
659
|
+
imports: v.optional(strings)
|
|
660
|
+
});
|
|
661
|
+
const extractTemplates = v.array(v.union([v.string(), extractTemplate]));
|
|
662
|
+
const extractOptions = v.strictObject({
|
|
663
|
+
autoDetect: v.optional(v.boolean()),
|
|
664
|
+
keygen: v.optional(v.picklist([
|
|
665
|
+
"slug",
|
|
666
|
+
"source",
|
|
667
|
+
"random",
|
|
668
|
+
"empty"
|
|
669
|
+
])),
|
|
670
|
+
keyCase: v.optional(v.picklist([
|
|
671
|
+
"camel",
|
|
672
|
+
"snake",
|
|
673
|
+
"kebab",
|
|
674
|
+
"dot"
|
|
675
|
+
])),
|
|
676
|
+
keyPrefix: v.optional(v.string()),
|
|
677
|
+
keyMaxLength: v.optional(v.number()),
|
|
678
|
+
reuseExisting: v.optional(v.boolean()),
|
|
679
|
+
ignore: v.optional(strings),
|
|
680
|
+
ignoreByFile: v.optional(v.record(v.string(), strings)),
|
|
681
|
+
ignoreAttributes: v.optional(strings),
|
|
682
|
+
ignoreCallees: v.optional(strings),
|
|
683
|
+
templates: v.optional(extractTemplates)
|
|
684
|
+
});
|
|
685
|
+
const glossaryEntry = v.strictObject({
|
|
686
|
+
term: v.string(),
|
|
687
|
+
translations: v.optional(v.record(v.string(), v.string())),
|
|
688
|
+
note: v.optional(v.string()),
|
|
689
|
+
doNotTranslate: v.optional(v.boolean())
|
|
690
|
+
});
|
|
691
|
+
const checkSetting = v.union([level, v.tuple([level, jsonObject])]);
|
|
692
|
+
const namespaceOptions = v.strictObject({
|
|
693
|
+
separator: v.optional(v.string()),
|
|
694
|
+
default: v.optional(v.string()),
|
|
695
|
+
fromPath: v.optional(v.picklist([
|
|
696
|
+
"dot",
|
|
697
|
+
"slash",
|
|
698
|
+
"camel"
|
|
699
|
+
]))
|
|
700
|
+
});
|
|
701
|
+
/** Validates YAML and JSON configs, and the serializable part of TS configs. */
|
|
702
|
+
const ConfigSchema = v.strictObject({
|
|
703
|
+
$schema: v.optional(v.string()),
|
|
704
|
+
root: v.optional(v.string()),
|
|
705
|
+
name: v.optional(v.string()),
|
|
706
|
+
sourceLocale: v.optional(v.string()),
|
|
707
|
+
locales: v.optional(strings),
|
|
708
|
+
ignoreLocales: v.optional(strings),
|
|
709
|
+
presets: v.optional(v.union([v.literal("auto"), v.array(pluginSpecifier)])),
|
|
710
|
+
plugins: v.optional(v.array(pluginSpecifier)),
|
|
711
|
+
resources: v.optional(v.array(resourceSpec)),
|
|
712
|
+
formats: v.optional(v.record(v.string(), v.string())),
|
|
713
|
+
keyStyle: v.optional(v.picklist([
|
|
714
|
+
"auto",
|
|
715
|
+
"nested",
|
|
716
|
+
"flat"
|
|
717
|
+
])),
|
|
718
|
+
namespace: v.optional(namespaceOptions),
|
|
719
|
+
usage: v.optional(v.strictObject({
|
|
720
|
+
include: v.optional(strings),
|
|
721
|
+
exclude: v.optional(strings),
|
|
722
|
+
calls: v.optional(v.array(callSpec)),
|
|
723
|
+
regex: v.optional(v.array(regexSpec)),
|
|
724
|
+
ignoreCallees: v.optional(strings),
|
|
725
|
+
keysInUse: v.optional(strings),
|
|
726
|
+
derivedKeys: v.optional(strings)
|
|
727
|
+
})),
|
|
728
|
+
write: v.optional(v.strictObject({
|
|
729
|
+
sort: v.optional(v.union([v.literal(false), v.picklist(["binary", "locale"])])),
|
|
730
|
+
indent: v.optional(v.union([v.number(), v.literal("tab")])),
|
|
731
|
+
keepFilled: v.optional(v.boolean())
|
|
732
|
+
})),
|
|
733
|
+
extract: v.optional(extractOptions),
|
|
734
|
+
checks: v.optional(v.record(v.string(), checkSetting)),
|
|
735
|
+
strict: v.optional(v.boolean()),
|
|
736
|
+
ignoreKeys: v.optional(strings),
|
|
737
|
+
glossary: v.optional(v.array(glossaryEntry)),
|
|
738
|
+
history: v.optional(v.union([v.boolean(), v.record(v.string(), v.union([v.literal(false), v.looseObject({})]))])),
|
|
739
|
+
projects: v.optional(strings)
|
|
740
|
+
});
|
|
741
|
+
const PluginFileSchema = v.strictObject({
|
|
742
|
+
$schema: v.optional(v.string()),
|
|
743
|
+
name: v.string(),
|
|
744
|
+
detect: v.optional(v.strictObject({
|
|
745
|
+
dependencies: v.optional(v.strictObject({
|
|
746
|
+
any: v.optional(strings),
|
|
747
|
+
all: v.optional(strings)
|
|
748
|
+
})),
|
|
749
|
+
files: v.optional(strings)
|
|
750
|
+
})),
|
|
751
|
+
exclusive: v.optional(v.boolean()),
|
|
752
|
+
config: v.optional(v.strictObject({
|
|
753
|
+
sourceLocale: v.optional(v.string()),
|
|
754
|
+
locales: v.optional(strings),
|
|
755
|
+
resources: v.optional(v.array(resourceSpec)),
|
|
756
|
+
namespace: v.optional(namespaceOptions),
|
|
757
|
+
keyStyle: v.optional(v.picklist([
|
|
758
|
+
"auto",
|
|
759
|
+
"nested",
|
|
760
|
+
"flat"
|
|
761
|
+
]))
|
|
762
|
+
})),
|
|
763
|
+
scanners: v.optional(v.strictObject({
|
|
764
|
+
calls: v.optional(v.array(callSpec)),
|
|
765
|
+
regex: v.optional(v.array(regexSpec))
|
|
766
|
+
})),
|
|
767
|
+
scopes: v.optional(v.strictObject({ regex: v.string() })),
|
|
768
|
+
derivedKeys: v.optional(strings),
|
|
769
|
+
normalizeKey: v.optional(v.array(v.strictObject({ replace: v.strictObject({
|
|
770
|
+
pattern: v.string(),
|
|
771
|
+
with: v.string()
|
|
772
|
+
}) }))),
|
|
773
|
+
placeholders: v.optional(strings),
|
|
774
|
+
localeInfo: v.optional(v.record(v.string(), v.strictObject({
|
|
775
|
+
flag: v.optional(v.string()),
|
|
776
|
+
displayName: v.optional(v.string())
|
|
777
|
+
}))),
|
|
778
|
+
extract: v.optional(v.strictObject({ templates: v.optional(extractTemplates) }))
|
|
779
|
+
});
|
|
780
|
+
/** Validates data against a schema and returns readable issues instead of throwing. */
|
|
781
|
+
function validate(schema, data) {
|
|
782
|
+
const result = v.safeParse(schema, data);
|
|
783
|
+
if (result.success) return {
|
|
784
|
+
success: true,
|
|
785
|
+
output: result.output
|
|
786
|
+
};
|
|
787
|
+
return {
|
|
788
|
+
success: false,
|
|
789
|
+
issues: result.issues.map((issue) => ({
|
|
790
|
+
path: issue.path?.map((item) => String(item.key)).join(".") ?? "",
|
|
791
|
+
message: issue.message
|
|
792
|
+
}))
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
//#endregion
|
|
796
|
+
//#region src/project/batch.ts
|
|
797
|
+
function isLeaf(value) {
|
|
798
|
+
return value === null || typeof value !== "object";
|
|
799
|
+
}
|
|
800
|
+
/** Parses the batch shape `{ "<locale>": { "<key>": "<value>" } }` (as in `x18n apply`) into set ops. */
|
|
801
|
+
function parseMessageBatch(project, data) {
|
|
802
|
+
if (!isJsonObject(data)) throw new X18nError("X18N4008", "expected { \"<locale>\": { \"<key>\": \"<value>\" } }");
|
|
803
|
+
const ops = [];
|
|
804
|
+
for (const [locale, entries] of Object.entries(data)) {
|
|
805
|
+
if (!isJsonObject(entries)) throw new X18nError("X18N4008", `"${locale}" must map keys to values`);
|
|
806
|
+
for (const [raw, value] of Object.entries(entries)) {
|
|
807
|
+
if (!isLeaf(value)) throw new X18nError("X18N4008", `"${locale}.${raw}" must be a string, number, boolean or null`);
|
|
808
|
+
ops.push({
|
|
809
|
+
type: "set",
|
|
810
|
+
key: project.parseKey(raw),
|
|
811
|
+
locale,
|
|
812
|
+
value
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return ops;
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Compares the placeholders of a target value with its source. Key references (`@:key`) are not
|
|
820
|
+
* parameters, and implicit names (vue-i18n `{n}` in plural messages) may be left out. Build it once per run.
|
|
821
|
+
*/
|
|
822
|
+
function createPlaceholderComparer(syntaxes, find = (value) => syntaxes.flatMap((s) => s.find(value))) {
|
|
823
|
+
const references = new Set(syntaxes.filter((s) => s.reference).map((s) => s.id));
|
|
824
|
+
const implicit = new Set(syntaxes.flatMap((s) => s.implicit ?? []));
|
|
825
|
+
const seen = /* @__PURE__ */ new Map();
|
|
826
|
+
const names = (text) => {
|
|
827
|
+
let found = seen.get(text);
|
|
828
|
+
if (!found) {
|
|
829
|
+
found = new Set(find(text).filter((p) => !references.has(p.syntax) && !implicit.has(p.name)).map((p) => p.name));
|
|
830
|
+
seen.set(text, found);
|
|
831
|
+
}
|
|
832
|
+
return found;
|
|
833
|
+
};
|
|
834
|
+
return (source, target) => {
|
|
835
|
+
const expected = names(source);
|
|
836
|
+
const actual = names(target);
|
|
837
|
+
return {
|
|
838
|
+
missing: [...expected].filter((name) => !actual.has(name)).sort(),
|
|
839
|
+
extra: [...actual].filter((name) => !expected.has(name)).sort()
|
|
840
|
+
};
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
function placeholderProblems(project, ops) {
|
|
844
|
+
const compare = createPlaceholderComparer(project.placeholderSyntaxes);
|
|
845
|
+
return ops.flatMap((op) => {
|
|
846
|
+
if (op.type !== "set" || op.locale === project.sourceLocale || typeof op.value !== "string") return [];
|
|
847
|
+
const source = project.message(op.key, project.sourceLocale)?.value;
|
|
848
|
+
if (typeof source !== "string") return [];
|
|
849
|
+
const { missing, extra } = compare(source, op.value);
|
|
850
|
+
return missing.length || extra.length ? [{
|
|
851
|
+
key: op.key,
|
|
852
|
+
locale: op.locale,
|
|
853
|
+
missing,
|
|
854
|
+
extra
|
|
855
|
+
}] : [];
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
//#endregion
|
|
859
|
+
//#region src/project/history.ts
|
|
860
|
+
const localChange = { updatedAt: Number.POSITIVE_INFINITY };
|
|
861
|
+
/** A target message is stale when it last changed before its source message. */
|
|
862
|
+
function isOutdated(source, target) {
|
|
863
|
+
return source !== void 0 && target !== void 0 && target.updatedAt < source.updatedAt;
|
|
864
|
+
}
|
|
865
|
+
function createHistory(input) {
|
|
866
|
+
const { providers } = input;
|
|
867
|
+
let histories = [];
|
|
868
|
+
let loading;
|
|
869
|
+
let watchers;
|
|
870
|
+
async function reload() {
|
|
871
|
+
const ctx = input.context();
|
|
872
|
+
histories = (await Promise.all(providers.map((provider) => provider.load(ctx).catch((error) => {
|
|
873
|
+
input.logger?.warn(`History provider "${provider.id}" failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
874
|
+
})))).filter((history) => history !== void 0);
|
|
875
|
+
input.onLoad();
|
|
876
|
+
}
|
|
877
|
+
const owner = (key, locale) => {
|
|
878
|
+
for (const history of histories) {
|
|
879
|
+
const change = history.changes.get(key)?.get(locale);
|
|
880
|
+
if (change) return {
|
|
881
|
+
history,
|
|
882
|
+
change
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
return {
|
|
887
|
+
load(options) {
|
|
888
|
+
if (providers.length === 0) return Promise.resolve();
|
|
889
|
+
loading ??= reload();
|
|
890
|
+
if (options.watch && !watchers) {
|
|
891
|
+
const ctx = input.context();
|
|
892
|
+
watchers = providers.flatMap((provider) => provider.watch ? [provider.watch(ctx, () => {
|
|
893
|
+
loading = reload();
|
|
894
|
+
})] : []);
|
|
895
|
+
}
|
|
896
|
+
return loading;
|
|
897
|
+
},
|
|
898
|
+
get(key, locale) {
|
|
899
|
+
const change = owner(key, locale)?.change;
|
|
900
|
+
if (!change) return void 0;
|
|
901
|
+
const current = input.current(key, locale);
|
|
902
|
+
return change.value !== void 0 && current !== void 0 && current !== change.value ? localChange : change;
|
|
903
|
+
},
|
|
904
|
+
valueAt: (key, locale, at) => owner(key, locale)?.history.valueAt?.(key, locale, at),
|
|
905
|
+
dispose() {
|
|
906
|
+
watchers?.forEach((watcher) => watcher.dispose());
|
|
907
|
+
watchers = void 0;
|
|
908
|
+
}
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
//#endregion
|
|
912
|
+
//#region src/project/analysis.ts
|
|
913
|
+
function levelOf(setting) {
|
|
914
|
+
if (setting === void 0) return void 0;
|
|
915
|
+
if (typeof setting === "string") return {
|
|
916
|
+
level: setting,
|
|
917
|
+
options: {}
|
|
918
|
+
};
|
|
919
|
+
return {
|
|
920
|
+
level: setting[0],
|
|
921
|
+
options: setting[1]
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
function computeCoverage(state, reference) {
|
|
925
|
+
const { messages, sourceLocale } = state;
|
|
926
|
+
return state.locales.map((locale) => {
|
|
927
|
+
const missing = [];
|
|
928
|
+
const empty = [];
|
|
929
|
+
const outdated = [];
|
|
930
|
+
for (const key of reference) {
|
|
931
|
+
const value = messages.get(key)?.get(locale)?.[0]?.value;
|
|
932
|
+
if (value === void 0) missing.push(key);
|
|
933
|
+
else if (value === "") empty.push(key);
|
|
934
|
+
else if (locale !== sourceLocale && isOutdated(state.history(key, sourceLocale), state.history(key, locale))) outdated.push(key);
|
|
935
|
+
}
|
|
936
|
+
return {
|
|
937
|
+
locale,
|
|
938
|
+
total: reference.length,
|
|
939
|
+
translated: reference.length - missing.length - empty.length,
|
|
940
|
+
missing,
|
|
941
|
+
empty,
|
|
942
|
+
outdated
|
|
943
|
+
};
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
function errorDiagnostics(state) {
|
|
947
|
+
return [
|
|
948
|
+
...state.discoveryErrors,
|
|
949
|
+
...state.resourceErrors,
|
|
950
|
+
...state.scanErrors
|
|
951
|
+
].map(({ error, path }) => ({
|
|
952
|
+
code: "invalid-resource",
|
|
953
|
+
severity: error.code.startsWith("X18N3") ? "warning" : "error",
|
|
954
|
+
message: `${error.message} (${error.code})`,
|
|
955
|
+
key: void 0,
|
|
956
|
+
locale: void 0,
|
|
957
|
+
location: {
|
|
958
|
+
path,
|
|
959
|
+
range: {
|
|
960
|
+
start: 0,
|
|
961
|
+
end: 0
|
|
962
|
+
}
|
|
963
|
+
},
|
|
964
|
+
fixes: []
|
|
965
|
+
}));
|
|
966
|
+
}
|
|
967
|
+
function* runCheck(check, level, inputs, input) {
|
|
968
|
+
const { state, config } = input;
|
|
969
|
+
const resourcePath = (id) => state.resources.find((s) => s.resource.id === id)?.resource.path ?? id;
|
|
970
|
+
for (const item of inputs) {
|
|
971
|
+
if (item.key !== void 0 && input.ignored(item.key)) continue;
|
|
972
|
+
const severity = config.strict && (item.severity ?? level) === "warning" ? "error" : item.severity ?? level;
|
|
973
|
+
let location = item.location;
|
|
974
|
+
if (!location && item.key !== void 0) {
|
|
975
|
+
const byLocale = state.messages.get(item.key);
|
|
976
|
+
const found = byLocale?.get(item.locale ?? state.sourceLocale)?.[0] ?? byLocale?.get(state.sourceLocale)?.[0];
|
|
977
|
+
if (found) location = {
|
|
978
|
+
path: resourcePath(found.resource),
|
|
979
|
+
range: found.range?.key ?? {
|
|
980
|
+
start: 0,
|
|
981
|
+
end: 0
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
yield {
|
|
986
|
+
code: check.id,
|
|
987
|
+
severity,
|
|
988
|
+
message: item.message,
|
|
989
|
+
key: item.key,
|
|
990
|
+
locale: item.locale,
|
|
991
|
+
location,
|
|
992
|
+
fixes: item.fixes ?? []
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
function runChecks(input) {
|
|
997
|
+
const out = [];
|
|
998
|
+
for (const check of input.registry.checks.values()) {
|
|
999
|
+
const setting = levelOf(input.config.checks[check.id]) ?? {
|
|
1000
|
+
level: check.defaultSeverity,
|
|
1001
|
+
options: {}
|
|
1002
|
+
};
|
|
1003
|
+
if (setting.level === "off") continue;
|
|
1004
|
+
try {
|
|
1005
|
+
out.push(...runCheck(check, setting.level, check.run({
|
|
1006
|
+
...input.ctx,
|
|
1007
|
+
options: setting.options
|
|
1008
|
+
}), input));
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
out.push({
|
|
1011
|
+
code: check.id,
|
|
1012
|
+
severity: "error",
|
|
1013
|
+
message: `Check failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1014
|
+
key: void 0,
|
|
1015
|
+
locale: void 0,
|
|
1016
|
+
location: void 0,
|
|
1017
|
+
fixes: []
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return out;
|
|
1022
|
+
}
|
|
1023
|
+
function createAnalyzer(options) {
|
|
1024
|
+
const { config, registry } = options;
|
|
1025
|
+
let memo;
|
|
1026
|
+
const ignored = keyMatcher(config.ignoreKeys);
|
|
1027
|
+
const inUse = keyMatcher(config.usage.keysInUse);
|
|
1028
|
+
const placeholderCache = /* @__PURE__ */ new Map();
|
|
1029
|
+
function analyze() {
|
|
1030
|
+
const state = options.state();
|
|
1031
|
+
if (memo && memo.revision === state.revision) return memo;
|
|
1032
|
+
const { sourceLocale, locales, messages } = state;
|
|
1033
|
+
const sourceKeys = [...messages.keys()].filter((key) => messages.get(key)?.has(sourceLocale)).sort();
|
|
1034
|
+
const allKeys = [...messages.keys()].sort();
|
|
1035
|
+
const coverage = computeCoverage(state, sourceKeys.length > 0 ? sourceKeys : allKeys);
|
|
1036
|
+
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);
|
|
1037
|
+
const hasScanners = state.files.size > 0;
|
|
1038
|
+
const referenceSyntaxes = registry.placeholders.filter((syntax) => syntax.reference);
|
|
1039
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
1040
|
+
if (referenceSyntaxes.length > 0) for (const byLocale of messages.values()) {
|
|
1041
|
+
const value = byLocale.get(sourceLocale)?.[0]?.value;
|
|
1042
|
+
if (typeof value === "string") referenceSyntaxes.forEach((syntax) => syntax.find(value).forEach((p) => referenced.add(p.name)));
|
|
1043
|
+
}
|
|
1044
|
+
const prefixes = [...new Set(state.prefixUsages.flatMap((usage) => usage.key === void 0 ? [] : [usage.key]))];
|
|
1045
|
+
const isUsed = (key) => {
|
|
1046
|
+
if (state.usagesByKey.has(key) || inUse(key) || referenced.has(key)) return true;
|
|
1047
|
+
const base = options.baseOf(key);
|
|
1048
|
+
if (base !== void 0 && (state.usagesByKey.has(base) || inUse(base))) return true;
|
|
1049
|
+
return prefixes.some((prefix) => key.startsWith(prefix));
|
|
1050
|
+
};
|
|
1051
|
+
const unused = hasScanners ? allKeys.filter((key) => !isUsed(key)) : [];
|
|
1052
|
+
const undefinedUsages = state.usages.filter((usage) => usage.dynamic === false && usage.key !== void 0 && !isDefined(usage.key));
|
|
1053
|
+
const placeholders = (value) => {
|
|
1054
|
+
let found = placeholderCache.get(value);
|
|
1055
|
+
if (!found) {
|
|
1056
|
+
found = registry.placeholders.flatMap((syntax) => syntax.find(value)).sort((a, b) => a.range.start - b.range.start);
|
|
1057
|
+
if (placeholderCache.size > 1e5) placeholderCache.clear();
|
|
1058
|
+
placeholderCache.set(value, found);
|
|
1059
|
+
}
|
|
1060
|
+
return found;
|
|
1061
|
+
};
|
|
1062
|
+
const baseCtx = {
|
|
1063
|
+
config,
|
|
1064
|
+
sourceLocale,
|
|
1065
|
+
locales,
|
|
1066
|
+
resources: state.resources.map((s) => s.resource),
|
|
1067
|
+
hasScanners,
|
|
1068
|
+
keys: () => allKeys,
|
|
1069
|
+
*messages(locale) {
|
|
1070
|
+
for (const byLocale of messages.values()) for (const [code, list] of byLocale) if (locale === void 0 || code === locale) yield list[0];
|
|
1071
|
+
},
|
|
1072
|
+
message: (key, locale) => messages.get(key)?.get(locale)?.[0],
|
|
1073
|
+
duplicates: (key, locale) => messages.get(key)?.get(locale) ?? [],
|
|
1074
|
+
usages: () => state.usages,
|
|
1075
|
+
coverage: () => coverage,
|
|
1076
|
+
unused: () => unused,
|
|
1077
|
+
undefinedUsages: () => undefinedUsages,
|
|
1078
|
+
placeholders,
|
|
1079
|
+
placeholderSyntaxes: registry.placeholders,
|
|
1080
|
+
syntaxErrors: (value) => registry.placeholders.flatMap((syntax) => syntax.validate?.(value) ?? []),
|
|
1081
|
+
localeInfo: options.localeInfo,
|
|
1082
|
+
text: options.text,
|
|
1083
|
+
history: state.history,
|
|
1084
|
+
*hardStrings() {
|
|
1085
|
+
for (const [path, file] of state.files) for (const hard of file.result.hardStrings ?? []) yield {
|
|
1086
|
+
...hard,
|
|
1087
|
+
path
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
const diagnostics = [...errorDiagnostics(state), ...runChecks({
|
|
1092
|
+
config,
|
|
1093
|
+
registry,
|
|
1094
|
+
ctx: baseCtx,
|
|
1095
|
+
state,
|
|
1096
|
+
ignored
|
|
1097
|
+
})];
|
|
1098
|
+
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));
|
|
1099
|
+
memo = {
|
|
1100
|
+
revision: state.revision,
|
|
1101
|
+
diagnostics,
|
|
1102
|
+
coverage,
|
|
1103
|
+
unused,
|
|
1104
|
+
undefinedUsages
|
|
1105
|
+
};
|
|
1106
|
+
return memo;
|
|
1107
|
+
}
|
|
1108
|
+
return { analyze };
|
|
1109
|
+
}
|
|
1110
|
+
//#endregion
|
|
1111
|
+
//#region src/project/planner.ts
|
|
1112
|
+
const safeSegment = /^[\w-]+$/;
|
|
1113
|
+
const safeFlat = /^[\w.-]+$/;
|
|
1114
|
+
function commonPrefix(a, b) {
|
|
1115
|
+
let i = 0;
|
|
1116
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
1117
|
+
return i;
|
|
1118
|
+
}
|
|
1119
|
+
function nodeAt(tree, path) {
|
|
1120
|
+
let node = tree;
|
|
1121
|
+
for (const segment of path) {
|
|
1122
|
+
if (node === void 0 || isMessageLeaf(node)) return void 0;
|
|
1123
|
+
node = node[segment];
|
|
1124
|
+
}
|
|
1125
|
+
return node;
|
|
1126
|
+
}
|
|
1127
|
+
/** True for flat files (`{ "a.b": "x" }`), false for nested ones, undefined when it cannot tell. */
|
|
1128
|
+
function isFlatTree(tree) {
|
|
1129
|
+
const entries = Object.entries(tree);
|
|
1130
|
+
if (entries.length === 0) return void 0;
|
|
1131
|
+
if (entries.some(([, value]) => !isMessageLeaf(value))) return false;
|
|
1132
|
+
return entries.some(([key]) => key.includes(".")) ? true : void 0;
|
|
1133
|
+
}
|
|
1134
|
+
/** The longest key prefix a tree shares with `path`. */
|
|
1135
|
+
function longestPrefix(tree, prefix, path) {
|
|
1136
|
+
let best = 0;
|
|
1137
|
+
for (const [segment, child] of Object.entries(tree)) {
|
|
1138
|
+
const next = [...prefix, ...segment.split(".")];
|
|
1139
|
+
best = Math.max(best, commonPrefix(next, path));
|
|
1140
|
+
if (!isMessageLeaf(child)) best = Math.max(best, longestPrefix(child, next, path));
|
|
1141
|
+
}
|
|
1142
|
+
return best;
|
|
1143
|
+
}
|
|
1144
|
+
function mirrorPath(path, from, to) {
|
|
1145
|
+
const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1146
|
+
return path.replace(new RegExp(`(^|[/._-])${escaped}(?=$|[/._-])`, "g"), `$1${to}`);
|
|
1147
|
+
}
|
|
1148
|
+
function importEdit(text, lines) {
|
|
1149
|
+
const missing = lines.filter((line) => !text.includes(line.trim()));
|
|
1150
|
+
if (missing.length === 0) return void 0;
|
|
1151
|
+
const script = /<script\b[^>]*>\r?\n?/.exec(text);
|
|
1152
|
+
let at = 0;
|
|
1153
|
+
if (script) at = script.index + script[0].length;
|
|
1154
|
+
else {
|
|
1155
|
+
const last = [...text.matchAll(/^import\s.*$/gm)].at(-1);
|
|
1156
|
+
if (last) at = last.index + last[0].length + 1;
|
|
1157
|
+
}
|
|
1158
|
+
return {
|
|
1159
|
+
range: {
|
|
1160
|
+
start: at,
|
|
1161
|
+
end: at
|
|
1162
|
+
},
|
|
1163
|
+
text: `${missing.join("\n")}\n`
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
var PlanBuilder = class {
|
|
1167
|
+
options;
|
|
1168
|
+
state;
|
|
1169
|
+
answers;
|
|
1170
|
+
work = /* @__PURE__ */ new Map();
|
|
1171
|
+
created = /* @__PURE__ */ new Map();
|
|
1172
|
+
sourceEdits = /* @__PURE__ */ new Map();
|
|
1173
|
+
sourceImports = /* @__PURE__ */ new Map();
|
|
1174
|
+
decisions = [];
|
|
1175
|
+
warnings = [];
|
|
1176
|
+
touched = /* @__PURE__ */ new Set();
|
|
1177
|
+
stateById;
|
|
1178
|
+
config;
|
|
1179
|
+
constructor(options, state, answers) {
|
|
1180
|
+
this.options = options;
|
|
1181
|
+
this.state = state;
|
|
1182
|
+
this.answers = answers;
|
|
1183
|
+
this.config = options.config;
|
|
1184
|
+
this.stateById = new Map(state.resources.map((s) => [s.resource.id, s]));
|
|
1185
|
+
}
|
|
1186
|
+
/** ---- Targets ---- */
|
|
1187
|
+
targetOf(s) {
|
|
1188
|
+
const format = this.options.registry.formatById(s.resource.format);
|
|
1189
|
+
if (!format) throw new X18nError("X18N2001", s.resource.path);
|
|
1190
|
+
return {
|
|
1191
|
+
id: s.resource.id,
|
|
1192
|
+
path: s.resource.path,
|
|
1193
|
+
locale: s.resource.locale,
|
|
1194
|
+
namespace: s.resource.namespace,
|
|
1195
|
+
format,
|
|
1196
|
+
state: s
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
workFor(target) {
|
|
1200
|
+
let entry = this.work.get(target.id);
|
|
1201
|
+
if (!entry) {
|
|
1202
|
+
entry = {
|
|
1203
|
+
target,
|
|
1204
|
+
tree: target.state?.rawTree ?? {},
|
|
1205
|
+
ops: [],
|
|
1206
|
+
keyRenames: [],
|
|
1207
|
+
formatOnly: false
|
|
1208
|
+
};
|
|
1209
|
+
this.work.set(target.id, entry);
|
|
1210
|
+
}
|
|
1211
|
+
return entry;
|
|
1212
|
+
}
|
|
1213
|
+
writable(id) {
|
|
1214
|
+
const s = this.stateById.get(id);
|
|
1215
|
+
if (s.resource.readonly) throw new X18nError("X18N4001", s.resource.path);
|
|
1216
|
+
return s;
|
|
1217
|
+
}
|
|
1218
|
+
keyId(key) {
|
|
1219
|
+
const id = splitKey(key, this.state.namespaced);
|
|
1220
|
+
return {
|
|
1221
|
+
namespace: id.namespace,
|
|
1222
|
+
path: [...id.path]
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
/** Key path relative to a resource, or undefined when the key does not belong there. */
|
|
1226
|
+
relativePath(key, target) {
|
|
1227
|
+
const { namespace, path } = this.keyId(key);
|
|
1228
|
+
const fallback = this.config.namespace.default;
|
|
1229
|
+
if (this.state.namespaced) return (namespace ?? fallback) === (target.namespace ?? fallback) ? path : void 0;
|
|
1230
|
+
const prefix = target.namespace ? target.namespace.split(".") : [];
|
|
1231
|
+
if (path.length <= prefix.length || prefix.some((segment, i) => path[i] !== segment)) return void 0;
|
|
1232
|
+
return path.slice(prefix.length);
|
|
1233
|
+
}
|
|
1234
|
+
storedPathFor(entry, relative) {
|
|
1235
|
+
const stored = this.options.toStored(entry.target, relative);
|
|
1236
|
+
return stored.length === relative.length && (isFlatTree(entry.tree) ?? this.config.keyStyle === "flat") ? [stored.join(".")] : stored;
|
|
1237
|
+
}
|
|
1238
|
+
createTarget(key, locale) {
|
|
1239
|
+
const { resources, sourceLocale, namespaced } = this.state;
|
|
1240
|
+
const fits = (s) => this.relativePath(key, s.resource) !== void 0;
|
|
1241
|
+
const source = this.options.message(key, sourceLocale);
|
|
1242
|
+
const sourceState = source && this.stateById.get(source.resource);
|
|
1243
|
+
const namespace = namespaced ? this.keyId(key).namespace ?? this.config.namespace.default : sourceState?.resource.namespace;
|
|
1244
|
+
const exists = (path) => resources.some((r) => r.resource.path === path);
|
|
1245
|
+
const resolved = this.options.resolveNew(locale, namespace);
|
|
1246
|
+
if (resolved && !exists(resolved)) {
|
|
1247
|
+
const format = this.options.registry.formatFor(resolved);
|
|
1248
|
+
if (format?.serialize) {
|
|
1249
|
+
const target = {
|
|
1250
|
+
id: resolved,
|
|
1251
|
+
path: resolved,
|
|
1252
|
+
locale,
|
|
1253
|
+
namespace,
|
|
1254
|
+
format,
|
|
1255
|
+
state: void 0
|
|
1256
|
+
};
|
|
1257
|
+
this.created.set(resolved, target);
|
|
1258
|
+
return target;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const sibling = (r) => r.resource.locale === locale && r.resource.namespace !== void 0 && namespace !== void 0;
|
|
1262
|
+
const model = (sourceState && fits(sourceState) ? sourceState : void 0) ?? resources.find((s) => s.resource.locale === sourceLocale && fits(s)) ?? resources.find(fits) ?? resources.find(sibling);
|
|
1263
|
+
const format = model && this.options.registry.formatById(model.resource.format);
|
|
1264
|
+
let path = model && mirrorPath(model.resource.path, model.resource.locale, locale);
|
|
1265
|
+
if (model && path && namespace !== void 0 && model.resource.namespace !== void 0 && model.resource.namespace !== namespace) path = mirrorPath(path, model.resource.namespace, namespace);
|
|
1266
|
+
if (!model || !format?.serialize || model.resource.block || !path || exists(path)) throw new X18nError("X18N4005", `${key} in ${locale}`);
|
|
1267
|
+
const target = {
|
|
1268
|
+
id: path,
|
|
1269
|
+
path,
|
|
1270
|
+
locale,
|
|
1271
|
+
namespace: namespace ?? model.resource.namespace,
|
|
1272
|
+
format,
|
|
1273
|
+
state: void 0
|
|
1274
|
+
};
|
|
1275
|
+
this.created.set(path, target);
|
|
1276
|
+
return target;
|
|
1277
|
+
}
|
|
1278
|
+
/** Placement: the namespace's resource, then the longest shared prefix, then the first; a tie is a decision. */
|
|
1279
|
+
pickTarget(key, locale) {
|
|
1280
|
+
let inLocale = this.state.resources.filter((s) => s.resource.locale === locale && this.relativePath(key, s.resource) !== void 0);
|
|
1281
|
+
const source = this.options.message(key, this.state.sourceLocale);
|
|
1282
|
+
const sourceNamespace = source && this.stateById.get(source.resource)?.resource.namespace;
|
|
1283
|
+
if (sourceNamespace !== void 0 && !this.state.namespaced) inLocale = inLocale.filter((s) => s.resource.namespace === sourceNamespace);
|
|
1284
|
+
if (inLocale.length === 0) return [...this.created.values()].find((t) => t.locale === locale && this.relativePath(key, t) !== void 0) ?? this.createTarget(key, locale);
|
|
1285
|
+
if (inLocale.length === 1) return this.targetOf(inLocale[0]);
|
|
1286
|
+
const path = this.keyId(key).path;
|
|
1287
|
+
const scores = inLocale.map((s) => {
|
|
1288
|
+
const prefix = s.resource.namespace && !this.state.namespaced ? path.slice(0, s.resource.namespace.split(".").length) : [];
|
|
1289
|
+
return {
|
|
1290
|
+
s,
|
|
1291
|
+
best: longestPrefix(s.tree, prefix, path)
|
|
1292
|
+
};
|
|
1293
|
+
});
|
|
1294
|
+
const top = Math.max(...scores.map((x) => x.best));
|
|
1295
|
+
const winners = scores.filter((x) => x.best === top);
|
|
1296
|
+
if (winners.length === 1 || top === 0) return this.targetOf(winners[0].s);
|
|
1297
|
+
const id = `target:${key}:${locale}`;
|
|
1298
|
+
this.decisions.push({
|
|
1299
|
+
id,
|
|
1300
|
+
message: `Which file gets "${key}" in ${locale}?`,
|
|
1301
|
+
options: winners.map((w) => ({
|
|
1302
|
+
id: w.s.resource.id,
|
|
1303
|
+
label: w.s.resource.id
|
|
1304
|
+
})),
|
|
1305
|
+
default: winners[0].s.resource.id
|
|
1306
|
+
});
|
|
1307
|
+
return this.targetOf((winners.find((w) => w.s.resource.id === this.answers[id]) ?? winners[0]).s);
|
|
1308
|
+
}
|
|
1309
|
+
/** ---- Message ops ---- */
|
|
1310
|
+
pushOp(entry, op) {
|
|
1311
|
+
entry.ops.push(op);
|
|
1312
|
+
entry.tree = applyMessageOps(entry.tree, [op]);
|
|
1313
|
+
}
|
|
1314
|
+
setValue(key, locale, value) {
|
|
1315
|
+
this.touched.add(key);
|
|
1316
|
+
const existing = this.options.message(key, locale);
|
|
1317
|
+
if (existing) {
|
|
1318
|
+
this.pushOp(this.workFor(this.targetOf(this.writable(existing.resource))), {
|
|
1319
|
+
type: "set",
|
|
1320
|
+
path: existing.storedPath,
|
|
1321
|
+
value
|
|
1322
|
+
});
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
const target = this.pickTarget(key, locale);
|
|
1326
|
+
if (target.state) this.writable(target.state.resource.id);
|
|
1327
|
+
const entry = this.workFor(target);
|
|
1328
|
+
const stored = this.storedPathFor(entry, this.relativePath(key, target));
|
|
1329
|
+
const occupied = nodeAt(entry.tree, stored);
|
|
1330
|
+
if (occupied !== void 0 && !isMessageLeaf(occupied)) this.warnings.push(`"${key}" is a parent of other keys in ${target.path}; they are replaced.`);
|
|
1331
|
+
this.pushOp(entry, {
|
|
1332
|
+
type: "set",
|
|
1333
|
+
path: stored,
|
|
1334
|
+
value
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
deleteMessage(m) {
|
|
1338
|
+
const entry = this.workFor(this.targetOf(this.writable(m.resource)));
|
|
1339
|
+
let path = [...m.storedPath];
|
|
1340
|
+
while (path.length > 1) {
|
|
1341
|
+
const parent = nodeAt(entry.tree, path.slice(0, -1));
|
|
1342
|
+
if (parent === void 0 || isMessageLeaf(parent) || Object.keys(parent).length > 1) break;
|
|
1343
|
+
path = path.slice(0, -1);
|
|
1344
|
+
}
|
|
1345
|
+
this.pushOp(entry, {
|
|
1346
|
+
type: "delete",
|
|
1347
|
+
path
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
/** Renames a key in every locale, in place when only the last segment changes. */
|
|
1351
|
+
/**
|
|
1352
|
+
* Renames a parent object in place where only its last segment changes, so the subtree keeps its
|
|
1353
|
+
* position and layout. Returns the resources it handled.
|
|
1354
|
+
*/
|
|
1355
|
+
renameNode(from, to) {
|
|
1356
|
+
const handled = /* @__PURE__ */ new Set();
|
|
1357
|
+
for (const s of this.state.resources) {
|
|
1358
|
+
const relativeFrom = this.relativePath(from, s.resource);
|
|
1359
|
+
const relativeTo = this.relativePath(to, s.resource);
|
|
1360
|
+
if (!relativeFrom || !relativeTo || s.resource.readonly || !this.options.registry.formatById(s.resource.format)?.edit) continue;
|
|
1361
|
+
const stored = s.toStored(relativeFrom);
|
|
1362
|
+
const next = s.toStored(relativeTo);
|
|
1363
|
+
const node = nodeAt(s.rawTree, stored);
|
|
1364
|
+
const range = s.ranges.get(pathId(stored));
|
|
1365
|
+
const last = next.at(-1);
|
|
1366
|
+
const sameParent = next.length === stored.length && next.slice(0, -1).every((segment, i) => segment === stored[i]);
|
|
1367
|
+
if (node === void 0 || isMessageLeaf(node) || !range || !sameParent || !safeSegment.test(last)) continue;
|
|
1368
|
+
const entry = this.workFor(this.targetOf(s));
|
|
1369
|
+
entry.keyRenames.push({
|
|
1370
|
+
range: {
|
|
1371
|
+
start: range.key.start + s.offset,
|
|
1372
|
+
end: range.key.end + s.offset
|
|
1373
|
+
},
|
|
1374
|
+
text: last
|
|
1375
|
+
});
|
|
1376
|
+
const leaves = [...walkLeaves(node, stored)];
|
|
1377
|
+
entry.tree = applyMessageOps(entry.tree, [...leaves.map((leaf) => ({
|
|
1378
|
+
type: "delete",
|
|
1379
|
+
path: leaf.path
|
|
1380
|
+
})), ...leaves.map((leaf) => ({
|
|
1381
|
+
type: "set",
|
|
1382
|
+
path: [...next, ...leaf.path.slice(stored.length)],
|
|
1383
|
+
value: leaf.value
|
|
1384
|
+
}))]);
|
|
1385
|
+
handled.add(s.resource.id);
|
|
1386
|
+
}
|
|
1387
|
+
return handled;
|
|
1388
|
+
}
|
|
1389
|
+
moveKey(from, to, renamed = /* @__PURE__ */ new Set()) {
|
|
1390
|
+
this.touched.add(from);
|
|
1391
|
+
this.touched.add(to);
|
|
1392
|
+
for (const [locale, list] of this.options.messages(from) ?? []) {
|
|
1393
|
+
const m = list[0];
|
|
1394
|
+
if (renamed.has(m.resource)) continue;
|
|
1395
|
+
const s = this.writable(m.resource);
|
|
1396
|
+
const relative = this.relativePath(to, s.resource);
|
|
1397
|
+
if (!relative) {
|
|
1398
|
+
this.deleteMessage(m);
|
|
1399
|
+
this.setValue(to, locale, m.value);
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
const entry = this.workFor(this.targetOf(s));
|
|
1403
|
+
const flat = m.storedPath.length === 1 && m.storedPath[0].includes(".");
|
|
1404
|
+
const next = flat ? [relative.join(".")] : this.options.toStored(s.resource, relative);
|
|
1405
|
+
const sameParent = next.length === m.storedPath.length && next.slice(0, -1).every((segment, i) => segment === m.storedPath[i]);
|
|
1406
|
+
const last = next.at(-1);
|
|
1407
|
+
if (sameParent && m.range && (flat ? safeFlat : safeSegment).test(last)) {
|
|
1408
|
+
entry.keyRenames.push({
|
|
1409
|
+
range: m.range.key,
|
|
1410
|
+
text: last
|
|
1411
|
+
});
|
|
1412
|
+
entry.tree = applyMessageOps(entry.tree, [{
|
|
1413
|
+
type: "delete",
|
|
1414
|
+
path: m.storedPath
|
|
1415
|
+
}, {
|
|
1416
|
+
type: "set",
|
|
1417
|
+
path: next,
|
|
1418
|
+
value: m.value
|
|
1419
|
+
}]);
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
this.deleteMessage(m);
|
|
1423
|
+
this.pushOp(entry, {
|
|
1424
|
+
type: "set",
|
|
1425
|
+
path: this.storedPathFor(entry, relative),
|
|
1426
|
+
value: m.value
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
/** The raw text a usage needs to refer to `next`, given its scope. */
|
|
1431
|
+
rawFor(usage, next) {
|
|
1432
|
+
if (usage.raw.startsWith(".") && usage.key?.endsWith(usage.raw)) {
|
|
1433
|
+
const scope = usage.key.slice(0, usage.key.length - usage.raw.length);
|
|
1434
|
+
if (next.startsWith(`${scope}.`)) return next.slice(scope.length);
|
|
1435
|
+
}
|
|
1436
|
+
const { namespace, path } = this.keyId(next);
|
|
1437
|
+
let rest = path.join(".");
|
|
1438
|
+
const separator = this.config.namespace.separator;
|
|
1439
|
+
const explicit = this.state.namespaced && usage.raw.indexOf(separator) > 0;
|
|
1440
|
+
if (usage.scopePrefix && !explicit) {
|
|
1441
|
+
if (!rest.startsWith(`${usage.scopePrefix}.`)) return void 0;
|
|
1442
|
+
rest = rest.slice(usage.scopePrefix.length + 1);
|
|
1443
|
+
}
|
|
1444
|
+
if (!this.state.namespaced) return rest;
|
|
1445
|
+
if (explicit || namespace !== (usage.scopeNamespace ?? this.config.namespace.default)) return `${namespace}${separator}${rest}`;
|
|
1446
|
+
return rest;
|
|
1447
|
+
}
|
|
1448
|
+
editSource(path, edit) {
|
|
1449
|
+
const list = this.sourceEdits.get(path) ?? [];
|
|
1450
|
+
this.sourceEdits.set(path, list);
|
|
1451
|
+
if (!list.some((e) => e.range.start === edit.range.start && e.range.end === edit.range.end)) list.push(edit);
|
|
1452
|
+
}
|
|
1453
|
+
rewriteUsages(from, to) {
|
|
1454
|
+
for (const { usage, possible } of this.options.usageMatches({ key: from })) {
|
|
1455
|
+
if (possible || usage.key !== from) continue;
|
|
1456
|
+
const raw = this.rawFor(usage, to);
|
|
1457
|
+
if (raw === void 0) this.warnings.push(`${usage.path}: usage of "${from}" is under the key prefix "${usage.scopePrefix}" and was not updated.`);
|
|
1458
|
+
else this.editSource(usage.path, {
|
|
1459
|
+
range: usage.range,
|
|
1460
|
+
text: raw
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
/** Rewrites the static part of prefix usages under `from`, and warns about the ones it cannot follow. */
|
|
1465
|
+
rewritePrefixUsages(from, to) {
|
|
1466
|
+
for (const { usage, possible } of this.options.usageMatches({ prefix: from })) {
|
|
1467
|
+
if (!possible || usage.key === void 0) continue;
|
|
1468
|
+
const raw = usage.key.startsWith(from) ? this.rawFor(usage, to + usage.key.slice(from.length)) : void 0;
|
|
1469
|
+
if (raw === void 0) this.warnings.push(`${usage.path}: dynamic usage "${usage.raw}" may refer to renamed keys and was not updated.`);
|
|
1470
|
+
else this.editSource(usage.path, {
|
|
1471
|
+
range: usage.range,
|
|
1472
|
+
text: raw
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
/** ---- Ops ---- */
|
|
1477
|
+
conflictCheck(to) {
|
|
1478
|
+
const existing = this.options.messages(to);
|
|
1479
|
+
if (!existing && !this.options.parents().has(to)) return;
|
|
1480
|
+
const files = [...new Set([...existing?.values() ?? []].flatMap((list) => list.map((m) => this.stateById.get(m.resource)?.resource.path ?? m.resource)))];
|
|
1481
|
+
throw new X18nError("X18N4002", `"${to}"${files.length ? ` in ${files.join(", ")}` : ""}`, files);
|
|
1482
|
+
}
|
|
1483
|
+
rename(op) {
|
|
1484
|
+
if (op.from === op.to) return;
|
|
1485
|
+
const all = this.options.allKeys();
|
|
1486
|
+
const isLeaf = this.options.messages(op.from) !== void 0;
|
|
1487
|
+
const under = all.filter((key) => isKeyUnder(key, op.from));
|
|
1488
|
+
const derived = all.filter((key) => this.options.baseOf(key) === op.from);
|
|
1489
|
+
let keys;
|
|
1490
|
+
if (op.type === "rename-prefix") keys = under;
|
|
1491
|
+
else if (isLeaf) keys = [op.from, ...derived];
|
|
1492
|
+
else keys = under.length > 0 ? under : derived;
|
|
1493
|
+
if (keys.length === 0) throw new X18nError("X18N4006", op.from);
|
|
1494
|
+
const pairs = keys.map((key) => [key, replaceKeyPrefix(key, op.from, op.to)]);
|
|
1495
|
+
for (const [, to] of pairs) this.conflictCheck(to);
|
|
1496
|
+
const renamed = keys.includes(op.from) ? /* @__PURE__ */ new Set() : this.renameNode(op.from, op.to);
|
|
1497
|
+
for (const [from, to] of pairs) {
|
|
1498
|
+
this.moveKey(from, to, renamed);
|
|
1499
|
+
if (op.usages !== false) this.rewriteUsages(from, to);
|
|
1500
|
+
}
|
|
1501
|
+
if (op.usages === false) return;
|
|
1502
|
+
if (!keys.includes(op.from)) this.rewriteUsages(op.from, op.to);
|
|
1503
|
+
this.rewritePrefixUsages(op.from, op.to);
|
|
1504
|
+
}
|
|
1505
|
+
delete(op) {
|
|
1506
|
+
const keys = this.options.messages(op.key) ? [op.key] : this.options.allKeys().filter((key) => isKeyUnder(key, op.key));
|
|
1507
|
+
if (keys.length === 0) throw new X18nError("X18N4006", op.key);
|
|
1508
|
+
for (const key of keys) {
|
|
1509
|
+
this.touched.add(key);
|
|
1510
|
+
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));
|
|
1511
|
+
const used = this.options.usageMatches({ key }).filter((match) => !match.possible).length;
|
|
1512
|
+
if (used > 0) this.warnings.push(`"${key}" still has ${used} usage${used === 1 ? "" : "s"}.`);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
extract(items) {
|
|
1516
|
+
for (const item of items) {
|
|
1517
|
+
if (!this.options.message(item.key, this.state.sourceLocale)) this.setValue(item.key, this.state.sourceLocale, item.value);
|
|
1518
|
+
this.editSource(item.path, {
|
|
1519
|
+
range: item.range,
|
|
1520
|
+
text: item.replacement
|
|
1521
|
+
});
|
|
1522
|
+
if (item.imports?.length) {
|
|
1523
|
+
const set = this.sourceImports.get(item.path) ?? /* @__PURE__ */ new Set();
|
|
1524
|
+
this.sourceImports.set(item.path, set);
|
|
1525
|
+
item.imports.forEach((line) => set.add(line));
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
run(ops) {
|
|
1530
|
+
for (const op of ops) switch (op.type) {
|
|
1531
|
+
case "set":
|
|
1532
|
+
this.setValue(op.key, op.locale, op.value);
|
|
1533
|
+
if (this.config.write.keepFilled) {
|
|
1534
|
+
for (const locale of this.state.locales) if (locale !== op.locale && !this.options.message(op.key, locale)) this.setValue(op.key, locale, "");
|
|
1535
|
+
}
|
|
1536
|
+
break;
|
|
1537
|
+
case "delete":
|
|
1538
|
+
this.delete(op);
|
|
1539
|
+
break;
|
|
1540
|
+
case "rename":
|
|
1541
|
+
case "rename-prefix":
|
|
1542
|
+
this.rename(op);
|
|
1543
|
+
break;
|
|
1544
|
+
case "extract":
|
|
1545
|
+
this.extract(op.items);
|
|
1546
|
+
break;
|
|
1547
|
+
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;
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
/** ---- Changes ---- */
|
|
1551
|
+
compare() {
|
|
1552
|
+
return this.config.write.sort === "locale" ? (a, b) => a.localeCompare(b) : (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
1553
|
+
}
|
|
1554
|
+
/** The new content of one existing resource. */
|
|
1555
|
+
nextContent(entry, s) {
|
|
1556
|
+
const { target } = entry;
|
|
1557
|
+
const ctx = {
|
|
1558
|
+
path: target.path,
|
|
1559
|
+
locale: target.locale,
|
|
1560
|
+
namespace: target.namespace,
|
|
1561
|
+
indent: this.config.write.indent
|
|
1562
|
+
};
|
|
1563
|
+
const sort = this.config.write.sort !== false;
|
|
1564
|
+
const edit = target.format.edit;
|
|
1565
|
+
if (entry.formatOnly || sort || !edit) {
|
|
1566
|
+
if (!target.format.serialize) throw new X18nError("X18N4001", target.path);
|
|
1567
|
+
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.`);
|
|
1568
|
+
return target.format.serialize(sort || entry.formatOnly ? sortTree(entry.tree, sort ? this.compare() : () => 0) : entry.tree, ctx);
|
|
1569
|
+
}
|
|
1570
|
+
const local = (e) => ({
|
|
1571
|
+
range: {
|
|
1572
|
+
start: e.range.start - s.offset,
|
|
1573
|
+
end: e.range.end - s.offset
|
|
1574
|
+
},
|
|
1575
|
+
text: e.text
|
|
1576
|
+
});
|
|
1577
|
+
if (entry.ops.length === 0) return applyTextEdits(s.content, entry.keyRenames.map(local));
|
|
1578
|
+
const renameOps = [];
|
|
1579
|
+
for (const rename of entry.keyRenames) {
|
|
1580
|
+
const node = [...s.ranges].find(([, range]) => range.key.start + s.offset === rename.range.start);
|
|
1581
|
+
if (!node) continue;
|
|
1582
|
+
const oldPath = node[0].split("\0");
|
|
1583
|
+
const newPath = [...oldPath.slice(0, -1), rename.text];
|
|
1584
|
+
const value = nodeAt(entry.tree, newPath);
|
|
1585
|
+
renameOps.push({
|
|
1586
|
+
type: "delete",
|
|
1587
|
+
path: oldPath
|
|
1588
|
+
});
|
|
1589
|
+
if (value !== void 0 && isMessageLeaf(value)) renameOps.push({
|
|
1590
|
+
type: "set",
|
|
1591
|
+
path: newPath,
|
|
1592
|
+
value
|
|
1593
|
+
});
|
|
1594
|
+
else if (value !== void 0) renameOps.push(...[...walkLeaves(value, newPath)].map((leaf) => ({
|
|
1595
|
+
type: "set",
|
|
1596
|
+
path: leaf.path,
|
|
1597
|
+
value: leaf.value
|
|
1598
|
+
})));
|
|
1599
|
+
}
|
|
1600
|
+
return applyTextEdits(s.content, edit(s.content, [...renameOps, ...entry.ops], ctx));
|
|
1601
|
+
}
|
|
1602
|
+
changes() {
|
|
1603
|
+
const changes = [];
|
|
1604
|
+
const texts = /* @__PURE__ */ new Map();
|
|
1605
|
+
const resourceEdits = /* @__PURE__ */ new Map();
|
|
1606
|
+
for (const entry of this.work.values()) {
|
|
1607
|
+
const { target } = entry;
|
|
1608
|
+
const s = target.state;
|
|
1609
|
+
if (!s) {
|
|
1610
|
+
const ctx = {
|
|
1611
|
+
path: target.path,
|
|
1612
|
+
locale: target.locale,
|
|
1613
|
+
namespace: target.namespace,
|
|
1614
|
+
indent: this.config.write.indent
|
|
1615
|
+
};
|
|
1616
|
+
const tree = this.config.write.sort === false ? entry.tree : sortTree(entry.tree, this.compare());
|
|
1617
|
+
changes.push({
|
|
1618
|
+
type: "create",
|
|
1619
|
+
path: target.path,
|
|
1620
|
+
content: target.format.serialize(tree, ctx)
|
|
1621
|
+
});
|
|
1622
|
+
texts.set(target.path, void 0);
|
|
1623
|
+
continue;
|
|
1624
|
+
}
|
|
1625
|
+
const edits = diffText(s.content, this.nextContent(entry, s)).map((e) => ({
|
|
1626
|
+
range: {
|
|
1627
|
+
start: e.range.start + s.offset,
|
|
1628
|
+
end: e.range.end + s.offset
|
|
1629
|
+
},
|
|
1630
|
+
text: e.text
|
|
1631
|
+
}));
|
|
1632
|
+
if (edits.length === 0) continue;
|
|
1633
|
+
const list = resourceEdits.get(target.path) ?? [];
|
|
1634
|
+
resourceEdits.set(target.path, list);
|
|
1635
|
+
list.push(...edits);
|
|
1636
|
+
}
|
|
1637
|
+
for (const [path, edits] of resourceEdits) {
|
|
1638
|
+
changes.push({
|
|
1639
|
+
type: "edit",
|
|
1640
|
+
path,
|
|
1641
|
+
edits
|
|
1642
|
+
});
|
|
1643
|
+
texts.set(path, this.state.fileTexts.get(path));
|
|
1644
|
+
}
|
|
1645
|
+
for (const [path, edits] of this.sourceEdits) {
|
|
1646
|
+
const text = this.options.text(path);
|
|
1647
|
+
const imports = this.sourceImports.get(path);
|
|
1648
|
+
const extra = imports && text !== void 0 ? importEdit(text, [...imports]) : void 0;
|
|
1649
|
+
changes.push({
|
|
1650
|
+
type: "edit",
|
|
1651
|
+
path,
|
|
1652
|
+
edits: [...edits, ...extra ? [extra] : []].sort((a, b) => a.range.start - b.range.start)
|
|
1653
|
+
});
|
|
1654
|
+
texts.set(path, text);
|
|
1655
|
+
}
|
|
1656
|
+
return {
|
|
1657
|
+
changes,
|
|
1658
|
+
texts
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
function createPlanner(options) {
|
|
1663
|
+
const texts = /* @__PURE__ */ new Map();
|
|
1664
|
+
function planWith(ops, answers) {
|
|
1665
|
+
const state = options.state();
|
|
1666
|
+
const builder = new PlanBuilder(options, state, answers);
|
|
1667
|
+
builder.run(ops);
|
|
1668
|
+
const result = builder.changes();
|
|
1669
|
+
return {
|
|
1670
|
+
plan: {
|
|
1671
|
+
id: hashText(JSON.stringify({
|
|
1672
|
+
revision: state.revision,
|
|
1673
|
+
ops,
|
|
1674
|
+
answers
|
|
1675
|
+
})),
|
|
1676
|
+
revision: state.revision,
|
|
1677
|
+
ops,
|
|
1678
|
+
changes: result.changes,
|
|
1679
|
+
decisions: builder.decisions,
|
|
1680
|
+
warnings: builder.warnings,
|
|
1681
|
+
keys: [...builder.touched].sort()
|
|
1682
|
+
},
|
|
1683
|
+
texts: result.texts
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
async function write(change) {
|
|
1687
|
+
if (change.type === "edit") {
|
|
1688
|
+
const current = await options.host.readFile(change.path);
|
|
1689
|
+
await options.host.writeFile(change.path, applyTextEdits(current ?? "", change.edits));
|
|
1690
|
+
return {
|
|
1691
|
+
type: "change",
|
|
1692
|
+
path: change.path
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
if (change.type === "create") {
|
|
1696
|
+
await options.host.writeFile(change.path, change.content);
|
|
1697
|
+
return {
|
|
1698
|
+
type: "create",
|
|
1699
|
+
path: change.path
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
await options.host.deleteFile(change.path);
|
|
1703
|
+
return {
|
|
1704
|
+
type: "delete",
|
|
1705
|
+
path: change.path
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
return {
|
|
1709
|
+
plan(ops, answers = {}) {
|
|
1710
|
+
const result = planWith(ops, answers);
|
|
1711
|
+
texts.set(result.plan.id, result.texts);
|
|
1712
|
+
return result.plan;
|
|
1713
|
+
},
|
|
1714
|
+
async apply(plan, answers) {
|
|
1715
|
+
const unanswered = plan.decisions.filter((d) => answers[d.id] === void 0);
|
|
1716
|
+
if (unanswered.length > 0) throw new X18nError("X18N4003", unanswered.map((d) => d.message).join(" "), unanswered.map((d) => d.id));
|
|
1717
|
+
if (plan.revision !== options.state().revision) throw new X18nError("X18N4004");
|
|
1718
|
+
const fresh = plan.decisions.length > 0 ? planWith(plan.ops, answers) : {
|
|
1719
|
+
plan,
|
|
1720
|
+
texts: texts.get(plan.id) ?? /* @__PURE__ */ new Map()
|
|
1721
|
+
};
|
|
1722
|
+
for (const change of fresh.plan.changes) {
|
|
1723
|
+
const current = await options.host.readFile(change.path);
|
|
1724
|
+
const expected = fresh.texts.get(change.path);
|
|
1725
|
+
if (change.type === "edit" && expected !== void 0 && current !== expected || change.type === "create" && current !== void 0) throw new X18nError("X18N4004", change.path);
|
|
1726
|
+
}
|
|
1727
|
+
const events = [];
|
|
1728
|
+
for (const change of fresh.plan.changes) events.push(await write(change));
|
|
1729
|
+
texts.delete(plan.id);
|
|
1730
|
+
return {
|
|
1731
|
+
revision: (await options.update(events))?.revision ?? options.state().revision,
|
|
1732
|
+
changes: fresh.plan.changes
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
//#endregion
|
|
1738
|
+
//#region src/project/prompt.ts
|
|
1739
|
+
function text(value) {
|
|
1740
|
+
return value === void 0 || value === null ? void 0 : String(value);
|
|
1741
|
+
}
|
|
1742
|
+
function contextOf(project, key) {
|
|
1743
|
+
return project.usages({ key }).slice(0, 2).flatMap(({ usage }) => {
|
|
1744
|
+
const source = project.text(usage.path);
|
|
1745
|
+
if (source === void 0) return [];
|
|
1746
|
+
const index = createLineIndex(source);
|
|
1747
|
+
const line = index.position(usage.range.start).line;
|
|
1748
|
+
const lines = [
|
|
1749
|
+
line - 1,
|
|
1750
|
+
line,
|
|
1751
|
+
line + 1
|
|
1752
|
+
].filter((n) => n >= 0 && n < index.lineCount).map((n) => index.line(n));
|
|
1753
|
+
return [{
|
|
1754
|
+
path: usage.path,
|
|
1755
|
+
line: line + 1,
|
|
1756
|
+
snippet: lines.join("\n")
|
|
1757
|
+
}];
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
/** Builds the parts of a translate or review prompt from project state. */
|
|
1761
|
+
function buildPrompt(project, kind, to, options, extra) {
|
|
1762
|
+
const from = project.sourceLocale;
|
|
1763
|
+
const match = typeof options.keys === "string" ? keyMatcher([options.keys]) : void 0;
|
|
1764
|
+
const list = Array.isArray(options.keys) ? new Set(options.keys) : void 0;
|
|
1765
|
+
const stale = (key) => isOutdated(project.history(key, from), project.history(key, to));
|
|
1766
|
+
const candidates = project.keys({ namespace: options.namespace }).filter((key) => {
|
|
1767
|
+
if (match && !match(key)) return false;
|
|
1768
|
+
if (list && !list.has(key)) return false;
|
|
1769
|
+
const source = text(project.message(key, from)?.value);
|
|
1770
|
+
if (source === void 0 || source === "") return false;
|
|
1771
|
+
const target = text(project.message(key, to)?.value);
|
|
1772
|
+
if (kind === "review") return target !== void 0 && target !== "";
|
|
1773
|
+
return list !== void 0 || target === void 0 || target === "" || options.stale === true && stale(key);
|
|
1774
|
+
});
|
|
1775
|
+
const start = Number(options.cursor ?? 0) || 0;
|
|
1776
|
+
const limit = options.limit ?? 50;
|
|
1777
|
+
const page = candidates.slice(start, start + limit);
|
|
1778
|
+
const end = start + page.length;
|
|
1779
|
+
const syntaxes = project.placeholderSyntaxes;
|
|
1780
|
+
const info = project.localeInfo(to);
|
|
1781
|
+
const draft = {
|
|
1782
|
+
rules: [],
|
|
1783
|
+
notes: []
|
|
1784
|
+
};
|
|
1785
|
+
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.`);
|
|
1786
|
+
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\".");
|
|
1787
|
+
if (info.pluralCategories.length > 1) draft.notes.push(`${to} uses the plural categories ${info.pluralCategories.join(", ")}.`);
|
|
1788
|
+
for (const hook of extra.hooks) hook(draft, {
|
|
1789
|
+
sourceLocale: from,
|
|
1790
|
+
targetLocale: to,
|
|
1791
|
+
localeInfo: info
|
|
1792
|
+
});
|
|
1793
|
+
const examples = kind === "translate" ? project.keys().flatMap((key) => {
|
|
1794
|
+
const source = text(project.message(key, from)?.value);
|
|
1795
|
+
const target = text(project.message(key, to)?.value);
|
|
1796
|
+
return source && target && !candidates.includes(key) ? [{
|
|
1797
|
+
key,
|
|
1798
|
+
source,
|
|
1799
|
+
target
|
|
1800
|
+
}] : [];
|
|
1801
|
+
}).slice(0, 5) : [];
|
|
1802
|
+
const items = page.map((key) => {
|
|
1803
|
+
const source = text(project.message(key, from)?.value) ?? "";
|
|
1804
|
+
const translated = project.history(key, to);
|
|
1805
|
+
const previous = translated && stale(key) ? text(project.valueAt(key, from, translated.updatedAt)) : void 0;
|
|
1806
|
+
return {
|
|
1807
|
+
key,
|
|
1808
|
+
source,
|
|
1809
|
+
placeholders: [...new Set(syntaxes.flatMap((s) => s.find(source)).map((p) => p.text))],
|
|
1810
|
+
existing: text(project.message(key, to)?.value) || void 0,
|
|
1811
|
+
previousSource: previous === source ? void 0 : previous,
|
|
1812
|
+
context: options.context === false ? [] : contextOf(project, key)
|
|
1813
|
+
};
|
|
1814
|
+
});
|
|
1815
|
+
const nextCursor = end < candidates.length ? String(end) : void 0;
|
|
1816
|
+
const instructions = kind === "translate" ? [
|
|
1817
|
+
`Write a JSON file \`{ "${to}": { "<key>": "<translation>" } }\` and run \`x18n apply <file> --json\`, or run \`x18n set <key> --locale ${to} --value <text>\` per key.`,
|
|
1818
|
+
`Then run \`x18n check --only placeholder-mismatch,invalid-syntax --locale ${to} --json\` and fix the reported keys.`,
|
|
1819
|
+
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\`.`
|
|
1820
|
+
] : [`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\`.`];
|
|
1821
|
+
return {
|
|
1822
|
+
kind,
|
|
1823
|
+
project: project.name,
|
|
1824
|
+
from,
|
|
1825
|
+
to,
|
|
1826
|
+
targetName: info.displayName,
|
|
1827
|
+
total: candidates.length,
|
|
1828
|
+
remaining: candidates.length - end,
|
|
1829
|
+
nextCursor,
|
|
1830
|
+
rules: draft.rules,
|
|
1831
|
+
notes: draft.notes,
|
|
1832
|
+
glossary: extra.config.glossary.map((entry) => ({
|
|
1833
|
+
term: entry.term,
|
|
1834
|
+
translation: entry.translations?.[to],
|
|
1835
|
+
note: entry.note,
|
|
1836
|
+
doNotTranslate: entry.doNotTranslate === true
|
|
1837
|
+
})),
|
|
1838
|
+
examples,
|
|
1839
|
+
items,
|
|
1840
|
+
instructions
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
function renderItem(item, to) {
|
|
1844
|
+
const out = [
|
|
1845
|
+
`### \`${item.key}\``,
|
|
1846
|
+
"",
|
|
1847
|
+
`- Source: ${JSON.stringify(item.source)}`
|
|
1848
|
+
];
|
|
1849
|
+
if (item.placeholders.length) out.push(`- Placeholders: ${item.placeholders.map((p) => `\`${p}\``).join(", ")}`);
|
|
1850
|
+
if (item.previousSource !== void 0) out.push(`- Source when ${to} last changed: ${JSON.stringify(item.previousSource)}`);
|
|
1851
|
+
if (item.existing !== void 0) out.push(`- Current ${to}: ${JSON.stringify(item.existing)}`);
|
|
1852
|
+
for (const context of item.context) out.push(`- Used in \`${context.path}:${context.line}\`:`, "", " ```", ...context.snippet.split("\n").map((line) => ` ${line}`), " ```");
|
|
1853
|
+
out.push("");
|
|
1854
|
+
return out;
|
|
1855
|
+
}
|
|
1856
|
+
/** Plain markdown, with no provider-specific syntax. */
|
|
1857
|
+
function renderPrompt(data) {
|
|
1858
|
+
const target = data.targetName ? `${data.to} (${data.targetName})` : data.to;
|
|
1859
|
+
const out = [];
|
|
1860
|
+
if (data.kind === "translate") out.push(`# Translate ${data.items.length} messages from ${data.from} to ${target}`, "");
|
|
1861
|
+
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.", "");
|
|
1862
|
+
if (data.items.length === 0) return `${out.join("\n")}Nothing to do: every key is ${data.kind === "translate" ? "translated" : "reviewed"}.\n`;
|
|
1863
|
+
out.push("## Rules", "", ...data.rules.map((rule) => `- ${rule}`), ...data.notes.map((note) => `- ${note}`), "");
|
|
1864
|
+
if (data.glossary.length > 0) {
|
|
1865
|
+
out.push("## Glossary", "");
|
|
1866
|
+
for (const entry of data.glossary) out.push(`- ${entry.term}${entry.doNotTranslate ? ": do not translate" : entry.translation ? ` → ${entry.translation}` : ""}${entry.note ? ` (${entry.note})` : ""}`);
|
|
1867
|
+
out.push("");
|
|
1868
|
+
}
|
|
1869
|
+
if (data.examples.length > 0) {
|
|
1870
|
+
out.push(`## Existing ${data.to} messages (style reference)`, "");
|
|
1871
|
+
for (const example of data.examples) out.push(`- \`${example.key}\`: ${JSON.stringify(example.source)} → ${JSON.stringify(example.target)}`);
|
|
1872
|
+
out.push("");
|
|
1873
|
+
}
|
|
1874
|
+
out.push("## Messages", "", ...data.items.flatMap((item) => renderItem(item, data.to)));
|
|
1875
|
+
out.push("## How to write results", "", ...data.instructions.map((line) => `- ${line}`), "");
|
|
1876
|
+
if (data.remaining > 0) out.push(`This prompt holds ${data.items.length} of ${data.total} messages.`, "");
|
|
1877
|
+
return out.join("\n");
|
|
1878
|
+
}
|
|
1879
|
+
//#endregion
|
|
1880
|
+
//#region src/project/usages.ts
|
|
1881
|
+
function innermost(scopes, range) {
|
|
1882
|
+
let best;
|
|
1883
|
+
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;
|
|
1884
|
+
return best;
|
|
1885
|
+
}
|
|
1886
|
+
function createUsageResolver(config, namespaced, normalizers) {
|
|
1887
|
+
const separator = config.namespace.separator;
|
|
1888
|
+
return { resolve(candidate, scopes, path, scanner) {
|
|
1889
|
+
const scope = innermost(scopes, candidate.range);
|
|
1890
|
+
const ctx = { path };
|
|
1891
|
+
let text = candidate.raw;
|
|
1892
|
+
for (const normalize of normalizers) {
|
|
1893
|
+
const result = normalize(text, ctx);
|
|
1894
|
+
if (result !== void 0) {
|
|
1895
|
+
text = result;
|
|
1896
|
+
break;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
const scopeNamespace = candidate.namespace ?? scope?.namespace;
|
|
1900
|
+
const scopePrefix = candidate.keyPrefix ?? scope?.keyPrefix;
|
|
1901
|
+
let namespace;
|
|
1902
|
+
let explicit = false;
|
|
1903
|
+
if (namespaced) {
|
|
1904
|
+
const index = text.indexOf(separator);
|
|
1905
|
+
explicit = index > 0;
|
|
1906
|
+
if (explicit) {
|
|
1907
|
+
namespace = text.slice(0, index);
|
|
1908
|
+
text = text.slice(index + separator.length);
|
|
1909
|
+
} else namespace = scopeNamespace ?? config.namespace.default;
|
|
1910
|
+
}
|
|
1911
|
+
if (scopePrefix && !explicit) text = text ? `${scopePrefix}.${text}` : scopePrefix;
|
|
1912
|
+
let key;
|
|
1913
|
+
if (candidate.dynamic === "unknown") key = void 0;
|
|
1914
|
+
else key = joinKey(namespace, text === "" ? [] : text.split("."));
|
|
1915
|
+
return {
|
|
1916
|
+
key,
|
|
1917
|
+
raw: candidate.raw,
|
|
1918
|
+
path,
|
|
1919
|
+
range: candidate.range,
|
|
1920
|
+
scanner,
|
|
1921
|
+
kind: candidate.kind,
|
|
1922
|
+
dynamic: candidate.dynamic,
|
|
1923
|
+
locale: candidate.locale,
|
|
1924
|
+
scopeNamespace,
|
|
1925
|
+
scopePrefix
|
|
1926
|
+
};
|
|
1927
|
+
} };
|
|
1928
|
+
}
|
|
1929
|
+
/** True when a prefix usage may refer to `key`. The key of `t(`pages.${id}`)` is `pages.`, so only children match. */
|
|
1930
|
+
function prefixMatches(usage, key) {
|
|
1931
|
+
return usage.dynamic === "prefix" && usage.key !== void 0 && key.startsWith(usage.key);
|
|
1932
|
+
}
|
|
1933
|
+
/** Ranges covered by `x18n-disable-next-line` and `x18n-disable` ... `x18n-enable` comments. */
|
|
1934
|
+
function suppressedRanges(text) {
|
|
1935
|
+
if (!text.includes("x18n-disable")) return [];
|
|
1936
|
+
const ranges = [];
|
|
1937
|
+
for (const match of text.matchAll(/x18n-disable-next-line/g)) {
|
|
1938
|
+
const lineEnd = text.indexOf("\n", match.index);
|
|
1939
|
+
if (lineEnd === -1) continue;
|
|
1940
|
+
const nextEnd = text.indexOf("\n", lineEnd + 1);
|
|
1941
|
+
ranges.push({
|
|
1942
|
+
start: lineEnd + 1,
|
|
1943
|
+
end: nextEnd === -1 ? text.length : nextEnd
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
for (const match of text.matchAll(/x18n-disable(?![-\w])/g)) {
|
|
1947
|
+
const end = text.indexOf("x18n-enable", match.index);
|
|
1948
|
+
ranges.push({
|
|
1949
|
+
start: match.index,
|
|
1950
|
+
end: end === -1 ? text.length : end
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
return ranges;
|
|
1954
|
+
}
|
|
1955
|
+
/** Compiles derived-key rules (`{key}_plural`, `{key}_*`) into a function from a key to its base key. */
|
|
1956
|
+
function derivedBase(rules) {
|
|
1957
|
+
const regexes = rules.map((rule) => {
|
|
1958
|
+
const source = rule.split("{key}").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^.:]+")).join("(.+)");
|
|
1959
|
+
return new RegExp(`^${source}$`);
|
|
1960
|
+
});
|
|
1961
|
+
return (key) => {
|
|
1962
|
+
for (const re of regexes) {
|
|
1963
|
+
const match = re.exec(key);
|
|
1964
|
+
if (match?.[1]) return match[1];
|
|
1965
|
+
}
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
//#endregion
|
|
1969
|
+
//#region src/project/project.ts
|
|
1970
|
+
const scanConcurrency = 64;
|
|
1971
|
+
function createProject(options) {
|
|
1972
|
+
const { host, setup } = options;
|
|
1973
|
+
const { config, registry } = setup;
|
|
1974
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1975
|
+
const fileTexts = /* @__PURE__ */ new Map();
|
|
1976
|
+
const resourceStates = /* @__PURE__ */ new Map();
|
|
1977
|
+
const files = /* @__PURE__ */ new Map();
|
|
1978
|
+
const discoveryErrors = [];
|
|
1979
|
+
let duplicates = [];
|
|
1980
|
+
let finderResults = [];
|
|
1981
|
+
let revision = 0;
|
|
1982
|
+
let namespaced = false;
|
|
1983
|
+
let locales = [];
|
|
1984
|
+
let sourceLocale = config.sourceLocale;
|
|
1985
|
+
let resolver;
|
|
1986
|
+
let watcher;
|
|
1987
|
+
let usageEligible = () => false;
|
|
1988
|
+
let resourceIndex;
|
|
1989
|
+
let resolved = /* @__PURE__ */ new WeakMap();
|
|
1990
|
+
const normalizers = registry.plugins.flatMap((plugin) => plugin.normalizeKey ? [plugin.normalizeKey] : []);
|
|
1991
|
+
const transforms = registry.plugins.flatMap((plugin) => plugin.transformResource ? [plugin.transformResource] : []);
|
|
1992
|
+
const localeInfoHooks = registry.plugins.flatMap((plugin) => plugin.localeInfo ? [plugin.localeInfo] : []);
|
|
1993
|
+
const baseOf = derivedBase(config.usage.derivedKeys);
|
|
1994
|
+
const scanSignature = hashText(JSON.stringify({
|
|
1995
|
+
usage: config.usage,
|
|
1996
|
+
namespace: config.namespace,
|
|
1997
|
+
scanners: registry.scanners.map((s) => s.id),
|
|
1998
|
+
v: 1
|
|
1999
|
+
}));
|
|
2000
|
+
const localeFilter = (code) => (!config.locales || config.locales.includes(code)) && !config.ignoreLocales.includes(code);
|
|
2001
|
+
const infoCache = /* @__PURE__ */ new Map();
|
|
2002
|
+
function localeInfo(code) {
|
|
2003
|
+
let info = infoCache.get(code);
|
|
2004
|
+
if (!info) {
|
|
2005
|
+
info = parseLocaleInfo(code);
|
|
2006
|
+
for (const hook of localeInfoHooks) {
|
|
2007
|
+
const result = hook(code);
|
|
2008
|
+
if (result) {
|
|
2009
|
+
info = {
|
|
2010
|
+
...info,
|
|
2011
|
+
...result,
|
|
2012
|
+
code
|
|
2013
|
+
};
|
|
2014
|
+
break;
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
infoCache.set(code, info);
|
|
2018
|
+
}
|
|
2019
|
+
return info;
|
|
2020
|
+
}
|
|
2021
|
+
async function discover() {
|
|
2022
|
+
const ctx = {
|
|
2023
|
+
host,
|
|
2024
|
+
config,
|
|
2025
|
+
extensions: [...new Set(registry.formats.flatMap((format) => format.extensions))]
|
|
2026
|
+
};
|
|
2027
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2028
|
+
const out = [];
|
|
2029
|
+
duplicates = [];
|
|
2030
|
+
finderResults = [];
|
|
2031
|
+
for (const finder of registry.finders) {
|
|
2032
|
+
const found = await finder.find(ctx);
|
|
2033
|
+
const kept = [];
|
|
2034
|
+
for (const candidate of found) {
|
|
2035
|
+
const id = candidate.block ? `${candidate.path}#${candidate.block.index}` : candidate.path;
|
|
2036
|
+
const previous = seen.get(id);
|
|
2037
|
+
if (previous) {
|
|
2038
|
+
duplicates.push({
|
|
2039
|
+
path: id,
|
|
2040
|
+
finder: finder.id,
|
|
2041
|
+
keptFrom: previous
|
|
2042
|
+
});
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
seen.set(id, finder.id);
|
|
2046
|
+
if (!localeFilter(candidate.locale)) continue;
|
|
2047
|
+
kept.push(id);
|
|
2048
|
+
out.push({
|
|
2049
|
+
path: candidate.path,
|
|
2050
|
+
locale: candidate.locale,
|
|
2051
|
+
namespace: candidate.namespace,
|
|
2052
|
+
format: candidate.format,
|
|
2053
|
+
block: candidate.block,
|
|
2054
|
+
readonly: candidate.readonly === true,
|
|
2055
|
+
finder: finder.id
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
finderResults.push({
|
|
2059
|
+
id: finder.id,
|
|
2060
|
+
resources: kept
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
return out;
|
|
2064
|
+
}
|
|
2065
|
+
function keyFor(resource, stored) {
|
|
2066
|
+
const logical = stored.flatMap((segment) => segment.split("."));
|
|
2067
|
+
if (namespaced) return joinKey(resource.namespace ?? config.namespace.default, logical);
|
|
2068
|
+
return joinKey(void 0, [...resource.namespace ? resource.namespace.split(".") : [], ...logical]);
|
|
2069
|
+
}
|
|
2070
|
+
/** Key path to stored path through the transforms that apply to a resource, last first. */
|
|
2071
|
+
function storedPathOf(ctx) {
|
|
2072
|
+
const active = transforms.filter((transform) => transform.applies?.(ctx) !== false);
|
|
2073
|
+
if (active.length === 0) return (path) => [...path];
|
|
2074
|
+
return (path) => active.reduceRight((current, transform) => transform.toStored(current, ctx), [...path]);
|
|
2075
|
+
}
|
|
2076
|
+
function parseResource(candidate, text) {
|
|
2077
|
+
const format = registry.formatFor(candidate.path, candidate.format);
|
|
2078
|
+
const resource = {
|
|
2079
|
+
id: candidate.block ? `${candidate.path}#${candidate.block.index}` : candidate.path,
|
|
2080
|
+
path: candidate.path,
|
|
2081
|
+
locale: candidate.locale,
|
|
2082
|
+
namespace: candidate.namespace,
|
|
2083
|
+
format: format?.id ?? "unknown",
|
|
2084
|
+
block: candidate.block,
|
|
2085
|
+
readonly: candidate.readonly || !format || !format.edit && !format.serialize,
|
|
2086
|
+
finder: candidate.finder
|
|
2087
|
+
};
|
|
2088
|
+
const content = candidate.block ? text.slice(candidate.block.range.start, candidate.block.range.end) : text;
|
|
2089
|
+
const offset = candidate.block?.range.start ?? 0;
|
|
2090
|
+
const ctx = {
|
|
2091
|
+
path: candidate.path,
|
|
2092
|
+
locale: candidate.locale,
|
|
2093
|
+
namespace: candidate.namespace,
|
|
2094
|
+
indent: config.write.indent
|
|
2095
|
+
};
|
|
2096
|
+
const base = {
|
|
2097
|
+
resource,
|
|
2098
|
+
hash: hashText(content),
|
|
2099
|
+
content,
|
|
2100
|
+
offset,
|
|
2101
|
+
toStored: storedPathOf(ctx)
|
|
2102
|
+
};
|
|
2103
|
+
if (!format) return {
|
|
2104
|
+
...base,
|
|
2105
|
+
tree: {},
|
|
2106
|
+
rawTree: {},
|
|
2107
|
+
ranges: /* @__PURE__ */ new Map(),
|
|
2108
|
+
error: new X18nError("X18N2001", candidate.path)
|
|
2109
|
+
};
|
|
2110
|
+
try {
|
|
2111
|
+
const parsed = format.parse(content, ctx);
|
|
2112
|
+
let tree = parsed.tree;
|
|
2113
|
+
for (const transform of transforms) if (transform.applies?.(ctx) !== false) tree = transform.read(tree, ctx);
|
|
2114
|
+
if (!isMessageTree(tree)) throw new X18nError("X18N2003", candidate.path);
|
|
2115
|
+
return {
|
|
2116
|
+
...base,
|
|
2117
|
+
tree,
|
|
2118
|
+
rawTree: parsed.tree,
|
|
2119
|
+
ranges: parsed.ranges ?? /* @__PURE__ */ new Map(),
|
|
2120
|
+
error: void 0
|
|
2121
|
+
};
|
|
2122
|
+
} catch (error) {
|
|
2123
|
+
const coded = isX18nError(error) ? error : new X18nError("X18N2002", `${candidate.path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2124
|
+
return {
|
|
2125
|
+
...base,
|
|
2126
|
+
tree: {},
|
|
2127
|
+
rawTree: {},
|
|
2128
|
+
ranges: /* @__PURE__ */ new Map(),
|
|
2129
|
+
error: coded
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
function messagesIn(resource, text) {
|
|
2134
|
+
if (resource.block) return void 0;
|
|
2135
|
+
const format = resource.format === "unknown" ? void 0 : resource.format;
|
|
2136
|
+
const state = parseResource({
|
|
2137
|
+
...resource,
|
|
2138
|
+
format,
|
|
2139
|
+
block: void 0
|
|
2140
|
+
}, text);
|
|
2141
|
+
if (state.error) return void 0;
|
|
2142
|
+
return new Map([...walkLeaves(state.tree)].map((leaf) => [keyFor(state.resource, leaf.path), leaf.value]));
|
|
2143
|
+
}
|
|
2144
|
+
async function loadResources(candidates, reread) {
|
|
2145
|
+
const next = /* @__PURE__ */ new Map();
|
|
2146
|
+
const paths = [...new Set(candidates.map((c) => c.path))];
|
|
2147
|
+
await Promise.all(paths.map(async (path) => {
|
|
2148
|
+
if (reread === "all" || reread.has(path) || !fileTexts.has(path)) {
|
|
2149
|
+
const text = await host.readFile(path);
|
|
2150
|
+
if (text === void 0) fileTexts.delete(path);
|
|
2151
|
+
else fileTexts.set(path, text);
|
|
2152
|
+
}
|
|
2153
|
+
}));
|
|
2154
|
+
namespaced = config.namespace.separator !== "." && candidates.some((c) => c.namespace !== void 0);
|
|
2155
|
+
for (const candidate of candidates) {
|
|
2156
|
+
const text = fileTexts.get(candidate.path);
|
|
2157
|
+
if (text === void 0) continue;
|
|
2158
|
+
const id = candidate.block ? `${candidate.path}#${candidate.block.index}` : candidate.path;
|
|
2159
|
+
const previous = resourceStates.get(id);
|
|
2160
|
+
const content = candidate.block ? text.slice(candidate.block.range.start, candidate.block.range.end) : text;
|
|
2161
|
+
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);
|
|
2162
|
+
else next.set(id, parseResource(candidate, text));
|
|
2163
|
+
}
|
|
2164
|
+
resourceStates.clear();
|
|
2165
|
+
for (const [id, state] of next) resourceStates.set(id, state);
|
|
2166
|
+
const found = [...new Set([...resourceStates.values()].map((state) => state.resource.locale))];
|
|
2167
|
+
sourceLocale = config.sourceLocale;
|
|
2168
|
+
if (!found.includes(sourceLocale) && config.origins.sourceLocale === void 0 && found.length > 0) sourceLocale = found.find((code) => code.toLowerCase().startsWith("en")) ?? [...found].sort()[0];
|
|
2169
|
+
locales = [sourceLocale, ...found.filter((code) => code !== sourceLocale).sort()];
|
|
2170
|
+
if (!found.includes(sourceLocale) && found.length === 0) locales = [sourceLocale];
|
|
2171
|
+
resolver = createUsageResolver(config, namespaced, normalizers);
|
|
2172
|
+
resolved = /* @__PURE__ */ new WeakMap();
|
|
2173
|
+
resourceIndex = void 0;
|
|
2174
|
+
}
|
|
2175
|
+
async function scanOne(file) {
|
|
2176
|
+
const usages = [];
|
|
2177
|
+
const scopes = [];
|
|
2178
|
+
const keySlots = [];
|
|
2179
|
+
const hardStrings = [];
|
|
2180
|
+
const calls = config.usage.calls.filter((spec) => ![spec.callee].flat().some((name) => config.usage.ignoreCallees.includes(name)));
|
|
2181
|
+
for (const scanner of registry.scannersFor(file.path)) {
|
|
2182
|
+
const result = await scanner.scan(file, {
|
|
2183
|
+
config,
|
|
2184
|
+
calls
|
|
2185
|
+
});
|
|
2186
|
+
usages.push(...result.usages);
|
|
2187
|
+
scopes.push(...result.scopes ?? []);
|
|
2188
|
+
keySlots.push(...result.keySlots ?? []);
|
|
2189
|
+
hardStrings.push(...result.hardStrings ?? []);
|
|
2190
|
+
}
|
|
2191
|
+
const suppressed = suppressedRanges(file.text);
|
|
2192
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2193
|
+
const kept = usages.filter((usage) => {
|
|
2194
|
+
const id = `${usage.range.start}:${usage.range.end}`;
|
|
2195
|
+
if (seen.has(id)) return false;
|
|
2196
|
+
seen.add(id);
|
|
2197
|
+
return !suppressed.some((range) => usage.range.start >= range.start && usage.range.end <= range.end);
|
|
2198
|
+
});
|
|
2199
|
+
kept.sort((a, b) => a.range.start - b.range.start);
|
|
2200
|
+
return {
|
|
2201
|
+
usages: kept,
|
|
2202
|
+
scopes,
|
|
2203
|
+
keySlots,
|
|
2204
|
+
hardStrings
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
async function scanPaths(paths) {
|
|
2208
|
+
const pending = [];
|
|
2209
|
+
const cacheKeys = /* @__PURE__ */ new Map();
|
|
2210
|
+
await Promise.all(paths.map(async (path) => {
|
|
2211
|
+
const text = await host.readFile(path);
|
|
2212
|
+
if (text === void 0) {
|
|
2213
|
+
files.delete(path);
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
const hash = hashText(text);
|
|
2217
|
+
if (files.get(path)?.hash === hash) return;
|
|
2218
|
+
if (host.cache) {
|
|
2219
|
+
const cacheKey = `scan:${scanSignature}:${path}:${hash}`;
|
|
2220
|
+
cacheKeys.set(path, cacheKey);
|
|
2221
|
+
const cached = await host.cache.get(cacheKey);
|
|
2222
|
+
if (cached !== void 0) {
|
|
2223
|
+
files.set(path, {
|
|
2224
|
+
hash,
|
|
2225
|
+
text,
|
|
2226
|
+
result: JSON.parse(cached),
|
|
2227
|
+
error: void 0
|
|
2228
|
+
});
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
pending.push({
|
|
2233
|
+
path,
|
|
2234
|
+
text,
|
|
2235
|
+
extension: extensionOf(path)
|
|
2236
|
+
});
|
|
2237
|
+
}));
|
|
2238
|
+
const store = (file, result, error) => {
|
|
2239
|
+
files.set(file.path, {
|
|
2240
|
+
hash: hashText(file.text),
|
|
2241
|
+
text: file.text,
|
|
2242
|
+
result: result ?? { usages: [] },
|
|
2243
|
+
error
|
|
2244
|
+
});
|
|
2245
|
+
const cacheKey = cacheKeys.get(file.path);
|
|
2246
|
+
if (result && cacheKey && host.cache) host.cache.set(cacheKey, JSON.stringify(result));
|
|
2247
|
+
};
|
|
2248
|
+
for (let i = 0; i < pending.length; i += scanConcurrency) await Promise.all(pending.slice(i, i + scanConcurrency).map(async (file) => {
|
|
2249
|
+
try {
|
|
2250
|
+
store(file, await scanOne(file));
|
|
2251
|
+
} catch (error) {
|
|
2252
|
+
store(file, void 0, new X18nError("X18N3001", `${file.path}: ${error instanceof Error ? error.message : String(error)}`));
|
|
2253
|
+
}
|
|
2254
|
+
}));
|
|
2255
|
+
}
|
|
2256
|
+
async function scanAll() {
|
|
2257
|
+
if (registry.scanners.length === 0) return;
|
|
2258
|
+
const include = pathMatcher(config.usage.include);
|
|
2259
|
+
const exclude = pathMatcher(config.usage.exclude);
|
|
2260
|
+
usageEligible = (path) => registry.scannersFor(path).length > 0 && include(path) && !exclude(path);
|
|
2261
|
+
await scanPaths((await host.glob(config.usage.include, {
|
|
2262
|
+
ignore: config.usage.exclude,
|
|
2263
|
+
gitignore: true
|
|
2264
|
+
})).filter((path) => registry.scannersFor(path).length > 0));
|
|
2265
|
+
}
|
|
2266
|
+
let index;
|
|
2267
|
+
function buildResourceIndex() {
|
|
2268
|
+
const messages = /* @__PURE__ */ new Map();
|
|
2269
|
+
const keyPaths = /* @__PURE__ */ new Map();
|
|
2270
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
2271
|
+
const parents = /* @__PURE__ */ new Set();
|
|
2272
|
+
for (const state of resourceStates.values()) {
|
|
2273
|
+
const { resource } = state;
|
|
2274
|
+
const fileNodes = nodes.get(resource.path) ?? [];
|
|
2275
|
+
nodes.set(resource.path, fileNodes);
|
|
2276
|
+
const shift = (range) => ({
|
|
2277
|
+
start: range.start + state.offset,
|
|
2278
|
+
end: range.end + state.offset
|
|
2279
|
+
});
|
|
2280
|
+
const visit = (tree, stored) => {
|
|
2281
|
+
for (const [segment, child] of Object.entries(tree)) {
|
|
2282
|
+
const path = [...stored, segment];
|
|
2283
|
+
const key = keyFor(resource, path);
|
|
2284
|
+
const storedPath = state.toStored(path);
|
|
2285
|
+
const raw = state.ranges.get(pathId(storedPath));
|
|
2286
|
+
const range = raw ? {
|
|
2287
|
+
key: shift(raw.key),
|
|
2288
|
+
value: shift(raw.value)
|
|
2289
|
+
} : void 0;
|
|
2290
|
+
if (range) fileNodes.push({
|
|
2291
|
+
key,
|
|
2292
|
+
range,
|
|
2293
|
+
leaf: isMessageLeaf(child),
|
|
2294
|
+
locale: resource.locale,
|
|
2295
|
+
path: resource.path
|
|
2296
|
+
});
|
|
2297
|
+
if (isMessageLeaf(child)) {
|
|
2298
|
+
const byLocale = messages.get(key) ?? /* @__PURE__ */ new Map();
|
|
2299
|
+
messages.set(key, byLocale);
|
|
2300
|
+
const list = byLocale.get(resource.locale) ?? [];
|
|
2301
|
+
byLocale.set(resource.locale, list);
|
|
2302
|
+
list.push({
|
|
2303
|
+
key,
|
|
2304
|
+
locale: resource.locale,
|
|
2305
|
+
value: child,
|
|
2306
|
+
resource: resource.id,
|
|
2307
|
+
storedPath,
|
|
2308
|
+
range
|
|
2309
|
+
});
|
|
2310
|
+
if (!keyPaths.has(key)) {
|
|
2311
|
+
const logical = path.flatMap((s) => s.split("."));
|
|
2312
|
+
keyPaths.set(key, namespaced ? {
|
|
2313
|
+
namespace: resource.namespace ?? config.namespace.default,
|
|
2314
|
+
path: logical
|
|
2315
|
+
} : {
|
|
2316
|
+
namespace: void 0,
|
|
2317
|
+
path: [...resource.namespace ? resource.namespace.split(".") : [], ...logical]
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
} else {
|
|
2321
|
+
parents.add(key);
|
|
2322
|
+
visit(child, path);
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
};
|
|
2326
|
+
visit(state.tree, []);
|
|
2327
|
+
}
|
|
2328
|
+
for (const { namespace, path } of keyPaths.values()) for (let i = 1; i < path.length; i++) parents.add(joinKey(namespace, path.slice(0, i)));
|
|
2329
|
+
const derived = /* @__PURE__ */ new Map();
|
|
2330
|
+
if (config.usage.derivedKeys.length > 0) for (const key of messages.keys()) {
|
|
2331
|
+
const base = baseOf(key);
|
|
2332
|
+
if (base) {
|
|
2333
|
+
const list = derived.get(base) ?? [];
|
|
2334
|
+
derived.set(base, list);
|
|
2335
|
+
list.push(key);
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
return {
|
|
2339
|
+
messages,
|
|
2340
|
+
keyPaths,
|
|
2341
|
+
nodes,
|
|
2342
|
+
parents,
|
|
2343
|
+
derived
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
function buildIndex() {
|
|
2347
|
+
resourceIndex ??= buildResourceIndex();
|
|
2348
|
+
const usages = [];
|
|
2349
|
+
for (const [path, state] of [...files].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
2350
|
+
let list = resolved.get(state);
|
|
2351
|
+
if (!list) {
|
|
2352
|
+
list = state.result.usages.map((candidate) => resolver.resolve(candidate, state.result.scopes ?? [], path, "scanner"));
|
|
2353
|
+
resolved.set(state, list);
|
|
2354
|
+
}
|
|
2355
|
+
usages.push(...list);
|
|
2356
|
+
}
|
|
2357
|
+
const usagesByKey = /* @__PURE__ */ new Map();
|
|
2358
|
+
const prefixUsages = [];
|
|
2359
|
+
for (const usage of usages) {
|
|
2360
|
+
if (usage.key === void 0) continue;
|
|
2361
|
+
if (usage.dynamic === "prefix") {
|
|
2362
|
+
prefixUsages.push(usage);
|
|
2363
|
+
continue;
|
|
2364
|
+
}
|
|
2365
|
+
const list = usagesByKey.get(usage.key) ?? [];
|
|
2366
|
+
usagesByKey.set(usage.key, list);
|
|
2367
|
+
list.push(usage);
|
|
2368
|
+
}
|
|
2369
|
+
return {
|
|
2370
|
+
revision,
|
|
2371
|
+
...resourceIndex,
|
|
2372
|
+
usages,
|
|
2373
|
+
usagesByKey,
|
|
2374
|
+
prefixUsages
|
|
2375
|
+
};
|
|
2376
|
+
}
|
|
2377
|
+
function idx() {
|
|
2378
|
+
if (!index || index.revision !== revision) index = buildIndex();
|
|
2379
|
+
return index;
|
|
2380
|
+
}
|
|
2381
|
+
function message(key, locale) {
|
|
2382
|
+
return idx().messages.get(key)?.get(locale)?.[0];
|
|
2383
|
+
}
|
|
2384
|
+
const history = createHistory({
|
|
2385
|
+
providers: config.history === false ? [] : [...config.history.providers, ...registry.histories],
|
|
2386
|
+
context: () => ({
|
|
2387
|
+
host,
|
|
2388
|
+
config,
|
|
2389
|
+
resources: [...resourceStates.values()].map((state) => state.resource),
|
|
2390
|
+
messagesIn
|
|
2391
|
+
}),
|
|
2392
|
+
current: (key, locale) => message(key, locale)?.value,
|
|
2393
|
+
onLoad() {
|
|
2394
|
+
revision++;
|
|
2395
|
+
emit({
|
|
2396
|
+
revision,
|
|
2397
|
+
paths: [],
|
|
2398
|
+
keys: []
|
|
2399
|
+
});
|
|
2400
|
+
},
|
|
2401
|
+
logger: host.logger
|
|
2402
|
+
});
|
|
2403
|
+
function usageMatches(query = {}) {
|
|
2404
|
+
const { usages, usagesByKey, prefixUsages } = idx();
|
|
2405
|
+
const inPath = (usage) => query.path === void 0 || usage.path === query.path;
|
|
2406
|
+
if (query.key !== void 0) {
|
|
2407
|
+
const key = query.key;
|
|
2408
|
+
const base = baseOf(key);
|
|
2409
|
+
return [...[...usagesByKey.get(key) ?? [], ...base ? usagesByKey.get(base) ?? [] : []].filter(inPath).map((usage) => ({
|
|
2410
|
+
usage,
|
|
2411
|
+
possible: false
|
|
2412
|
+
})), ...prefixUsages.filter((usage) => inPath(usage) && prefixMatches(usage, key)).map((usage) => ({
|
|
2413
|
+
usage,
|
|
2414
|
+
possible: true
|
|
2415
|
+
}))];
|
|
2416
|
+
}
|
|
2417
|
+
if (query.prefix !== void 0) {
|
|
2418
|
+
const prefix = query.prefix;
|
|
2419
|
+
return [...usages.filter((usage) => inPath(usage) && usage.dynamic === false && usage.key !== void 0 && isKeyUnder(usage.key, prefix)).map((usage) => ({
|
|
2420
|
+
usage,
|
|
2421
|
+
possible: false
|
|
2422
|
+
})), ...prefixUsages.filter((usage) => inPath(usage) && usage.key !== void 0 && (usage.key.startsWith(prefix) || prefix.startsWith(usage.key))).map((usage) => ({
|
|
2423
|
+
usage,
|
|
2424
|
+
possible: true
|
|
2425
|
+
}))];
|
|
2426
|
+
}
|
|
2427
|
+
return usages.filter(inPath).map((usage) => ({
|
|
2428
|
+
usage,
|
|
2429
|
+
possible: usage.dynamic !== false
|
|
2430
|
+
}));
|
|
2431
|
+
}
|
|
2432
|
+
const analyzer = createAnalyzer({
|
|
2433
|
+
config,
|
|
2434
|
+
registry,
|
|
2435
|
+
state: () => ({
|
|
2436
|
+
revision,
|
|
2437
|
+
sourceLocale,
|
|
2438
|
+
locales,
|
|
2439
|
+
resources: [...resourceStates.values()],
|
|
2440
|
+
resourceErrors: [...resourceStates.values()].flatMap((s) => s.error ? [{
|
|
2441
|
+
error: s.error,
|
|
2442
|
+
path: s.resource.path
|
|
2443
|
+
}] : []),
|
|
2444
|
+
scanErrors: [...files].flatMap(([path, s]) => s.error ? [{
|
|
2445
|
+
error: s.error,
|
|
2446
|
+
path
|
|
2447
|
+
}] : []),
|
|
2448
|
+
discoveryErrors,
|
|
2449
|
+
messages: idx().messages,
|
|
2450
|
+
parents: idx().parents,
|
|
2451
|
+
usages: idx().usages,
|
|
2452
|
+
usagesByKey: idx().usagesByKey,
|
|
2453
|
+
prefixUsages: idx().prefixUsages,
|
|
2454
|
+
derived: idx().derived,
|
|
2455
|
+
files,
|
|
2456
|
+
history: history.get
|
|
2457
|
+
}),
|
|
2458
|
+
baseOf,
|
|
2459
|
+
localeInfo,
|
|
2460
|
+
text: (path) => fileTexts.get(path) ?? files.get(path)?.text
|
|
2461
|
+
});
|
|
2462
|
+
function keys(query = {}) {
|
|
2463
|
+
const { messages, keyPaths } = idx();
|
|
2464
|
+
const match = query.glob ? keyMatcher([query.glob]) : void 0;
|
|
2465
|
+
let list = [...messages.keys()];
|
|
2466
|
+
if (match) list = list.filter((key) => match(key));
|
|
2467
|
+
if (query.namespace !== void 0) list = list.filter((key) => keyPaths.get(key)?.namespace === query.namespace);
|
|
2468
|
+
if (query.missing !== void 0) {
|
|
2469
|
+
const locale = query.missing;
|
|
2470
|
+
list = list.filter((key) => {
|
|
2471
|
+
const value = message(key, locale)?.value;
|
|
2472
|
+
return message(key, sourceLocale) !== void 0 && (value === void 0 || value === "");
|
|
2473
|
+
});
|
|
2474
|
+
}
|
|
2475
|
+
if (query.unused) {
|
|
2476
|
+
const unused = new Set(analyzer.analyze().unused);
|
|
2477
|
+
list = list.filter((key) => unused.has(key));
|
|
2478
|
+
}
|
|
2479
|
+
return list.sort();
|
|
2480
|
+
}
|
|
2481
|
+
function get(key) {
|
|
2482
|
+
const { messages, keyPaths } = idx();
|
|
2483
|
+
const byLocale = messages.get(key);
|
|
2484
|
+
if (!byLocale) return void 0;
|
|
2485
|
+
const id = keyPaths.get(key);
|
|
2486
|
+
const list = locales.flatMap((locale) => byLocale.get(locale)?.slice(0, 1) ?? []);
|
|
2487
|
+
return {
|
|
2488
|
+
key,
|
|
2489
|
+
namespace: id.namespace,
|
|
2490
|
+
path: id.path,
|
|
2491
|
+
messages: list,
|
|
2492
|
+
missing: locales.filter((locale) => !byLocale.has(locale)),
|
|
2493
|
+
usages: usageMatches({ key }),
|
|
2494
|
+
diagnostics: analyzer.analyze().diagnostics.filter((d) => d.key === key)
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
function keyAt(path, offset) {
|
|
2498
|
+
const fileNodes = idx().nodes.get(path);
|
|
2499
|
+
if (fileNodes) {
|
|
2500
|
+
let best;
|
|
2501
|
+
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;
|
|
2502
|
+
if (best) return best.leaf ? {
|
|
2503
|
+
type: "key",
|
|
2504
|
+
key: best.key,
|
|
2505
|
+
range: best.range.key,
|
|
2506
|
+
source: "resource"
|
|
2507
|
+
} : {
|
|
2508
|
+
type: "subtree",
|
|
2509
|
+
key: best.key,
|
|
2510
|
+
range: best.range.key,
|
|
2511
|
+
source: "resource"
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
if (files.get(path)) {
|
|
2515
|
+
const usage = idx().usages.find((u) => u.path === path && u.key !== void 0 && rangeContains(u.range, offset));
|
|
2516
|
+
if (usage?.key !== void 0) return {
|
|
2517
|
+
type: "key",
|
|
2518
|
+
key: usage.key,
|
|
2519
|
+
range: usage.range,
|
|
2520
|
+
source: "usage",
|
|
2521
|
+
usage
|
|
2522
|
+
};
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
function explain() {
|
|
2526
|
+
const resources = [...resourceStates.values()];
|
|
2527
|
+
return {
|
|
2528
|
+
name: config.name,
|
|
2529
|
+
root: host.root,
|
|
2530
|
+
presets: setup.presets,
|
|
2531
|
+
plugins: registry.plugins.map((plugin) => plugin.name),
|
|
2532
|
+
skipped: setup.skipped,
|
|
2533
|
+
finders: finderResults,
|
|
2534
|
+
duplicates,
|
|
2535
|
+
formats: registry.formats.map((format) => ({
|
|
2536
|
+
id: format.id,
|
|
2537
|
+
extensions: format.extensions,
|
|
2538
|
+
resources: resources.filter((state) => state.resource.format === format.id).length
|
|
2539
|
+
})),
|
|
2540
|
+
scanners: registry.scanners.map((scanner) => ({
|
|
2541
|
+
id: scanner.id,
|
|
2542
|
+
extensions: scanner.extensions,
|
|
2543
|
+
files: [...files.keys()].filter((path) => registry.scannersFor(path).includes(scanner)).length
|
|
2544
|
+
})),
|
|
2545
|
+
conflicts: registry.conflicts,
|
|
2546
|
+
errors: [
|
|
2547
|
+
...discoveryErrors,
|
|
2548
|
+
...resources.flatMap((state) => state.error ? [{
|
|
2549
|
+
error: state.error,
|
|
2550
|
+
path: state.resource.path
|
|
2551
|
+
}] : []),
|
|
2552
|
+
...[...files].flatMap(([path, state]) => state.error ? [{
|
|
2553
|
+
error: state.error,
|
|
2554
|
+
path
|
|
2555
|
+
}] : [])
|
|
2556
|
+
].map(({ error, path }) => ({
|
|
2557
|
+
...error.toJSON(),
|
|
2558
|
+
path
|
|
2559
|
+
})),
|
|
2560
|
+
config
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
function emit(change) {
|
|
2564
|
+
for (const listener of listeners) listener(change);
|
|
2565
|
+
}
|
|
2566
|
+
function touchedKeys(paths, before) {
|
|
2567
|
+
const out = /* @__PURE__ */ new Set();
|
|
2568
|
+
const collect = (from) => {
|
|
2569
|
+
if (!from) return;
|
|
2570
|
+
for (const path of paths) for (const node of from.nodes.get(path) ?? []) out.add(node.key);
|
|
2571
|
+
for (const usage of from.usages) if (usage.key !== void 0 && paths.has(usage.path)) out.add(usage.key);
|
|
2572
|
+
};
|
|
2573
|
+
collect(before);
|
|
2574
|
+
collect(idx());
|
|
2575
|
+
return [...out];
|
|
2576
|
+
}
|
|
2577
|
+
let current = Promise.resolve();
|
|
2578
|
+
async function update(events) {
|
|
2579
|
+
const run = current.then(async () => {
|
|
2580
|
+
if (events.length === 0) return void 0;
|
|
2581
|
+
const before = idx();
|
|
2582
|
+
const paths = new Set(events.map((event) => event.path));
|
|
2583
|
+
const resourcePaths = new Set([...resourceStates.values()].map((state) => state.resource.path));
|
|
2584
|
+
const maybeResource = (path) => !resourcePaths.has(path) && registry.formatFor(path) !== void 0 && !usageEligible(path);
|
|
2585
|
+
const structural = events.some((event) => event.type !== "change" || maybeResource(event.path));
|
|
2586
|
+
if (structural || [...paths].some((path) => resourcePaths.has(path))) await loadResources(structural ? await discover() : [...resourceStates.values()].map((state) => ({
|
|
2587
|
+
...state.resource,
|
|
2588
|
+
format: state.resource.format === "unknown" ? void 0 : state.resource.format
|
|
2589
|
+
})), paths);
|
|
2590
|
+
const scanTargets = [...paths].filter((path) => usageEligible(path) || files.has(path));
|
|
2591
|
+
for (const event of events) if (event.type === "delete") files.delete(event.path);
|
|
2592
|
+
await scanPaths(scanTargets.filter((path) => events.some((event) => event.path === path && event.type !== "delete")));
|
|
2593
|
+
revision++;
|
|
2594
|
+
const change = {
|
|
2595
|
+
revision,
|
|
2596
|
+
paths: [...paths],
|
|
2597
|
+
keys: touchedKeys(paths, before)
|
|
2598
|
+
};
|
|
2599
|
+
emit(change);
|
|
2600
|
+
return change;
|
|
2601
|
+
});
|
|
2602
|
+
current = run.then(() => void 0, () => void 0);
|
|
2603
|
+
return run;
|
|
2604
|
+
}
|
|
2605
|
+
const planner = createPlanner({
|
|
2606
|
+
host,
|
|
2607
|
+
config,
|
|
2608
|
+
registry,
|
|
2609
|
+
state: () => ({
|
|
2610
|
+
revision,
|
|
2611
|
+
sourceLocale,
|
|
2612
|
+
locales,
|
|
2613
|
+
namespaced,
|
|
2614
|
+
resources: [...resourceStates.values()],
|
|
2615
|
+
fileTexts
|
|
2616
|
+
}),
|
|
2617
|
+
message: (key, locale) => message(key, locale),
|
|
2618
|
+
messages: (key) => idx().messages.get(key),
|
|
2619
|
+
allKeys: () => [...idx().messages.keys()],
|
|
2620
|
+
parents: () => idx().parents,
|
|
2621
|
+
usageMatches,
|
|
2622
|
+
text: (path) => fileTexts.get(path) ?? files.get(path)?.text,
|
|
2623
|
+
baseOf,
|
|
2624
|
+
toStored: (target, path) => storedPathOf(target)(path),
|
|
2625
|
+
resolveNew(locale, namespace) {
|
|
2626
|
+
const ctx = {
|
|
2627
|
+
host,
|
|
2628
|
+
config,
|
|
2629
|
+
extensions: [...new Set(registry.formats.flatMap((format) => format.extensions))]
|
|
2630
|
+
};
|
|
2631
|
+
for (const finder of registry.finders) {
|
|
2632
|
+
const path = finder.resolveNew?.({
|
|
2633
|
+
locale,
|
|
2634
|
+
namespace
|
|
2635
|
+
}, ctx);
|
|
2636
|
+
if (path) return path;
|
|
2637
|
+
}
|
|
2638
|
+
},
|
|
2639
|
+
update
|
|
2640
|
+
});
|
|
2641
|
+
const ready = (async () => {
|
|
2642
|
+
await loadResources(await discover(), "all");
|
|
2643
|
+
await scanAll();
|
|
2644
|
+
revision++;
|
|
2645
|
+
})();
|
|
2646
|
+
const project = {
|
|
2647
|
+
get name() {
|
|
2648
|
+
return config.name;
|
|
2649
|
+
},
|
|
2650
|
+
config,
|
|
2651
|
+
ready,
|
|
2652
|
+
get revision() {
|
|
2653
|
+
return revision;
|
|
2654
|
+
},
|
|
2655
|
+
get locales() {
|
|
2656
|
+
return locales;
|
|
2657
|
+
},
|
|
2658
|
+
get sourceLocale() {
|
|
2659
|
+
return sourceLocale;
|
|
2660
|
+
},
|
|
2661
|
+
get resources() {
|
|
2662
|
+
return [...resourceStates.values()].map((state) => state.resource);
|
|
2663
|
+
},
|
|
2664
|
+
get namespaced() {
|
|
2665
|
+
return namespaced;
|
|
2666
|
+
},
|
|
2667
|
+
reporters: registry.reporters,
|
|
2668
|
+
placeholderSyntaxes: registry.placeholders,
|
|
2669
|
+
extractTemplates: [...(config.extract.templates ?? []).map((template) => typeof template === "string" ? { template } : template), ...registry.extractors.flatMap((extractor) => extractor.templates)],
|
|
2670
|
+
keys,
|
|
2671
|
+
get,
|
|
2672
|
+
message,
|
|
2673
|
+
parseKey(input) {
|
|
2674
|
+
const separator = config.namespace.separator;
|
|
2675
|
+
if (!namespaced) return input;
|
|
2676
|
+
if (input.indexOf(":") > 0) return input;
|
|
2677
|
+
const custom = separator !== ":" ? input.indexOf(separator) : -1;
|
|
2678
|
+
if (custom > 0) return `${input.slice(0, custom)}:${input.slice(custom + separator.length)}`;
|
|
2679
|
+
return config.namespace.default ? `${config.namespace.default}:${input}` : input;
|
|
2680
|
+
},
|
|
2681
|
+
usages: usageMatches,
|
|
2682
|
+
keyAt,
|
|
2683
|
+
nodes: (path) => idx().nodes.get(path) ?? [],
|
|
2684
|
+
locations(key) {
|
|
2685
|
+
const order = new Map(locales.map((locale, i) => [locale, i]));
|
|
2686
|
+
const all = [...idx().nodes.values()].flat();
|
|
2687
|
+
let found = all.filter((node) => node.key === key);
|
|
2688
|
+
if (found.length === 0) {
|
|
2689
|
+
const derived = new Set(idx().derived.get(key) ?? []);
|
|
2690
|
+
found = all.filter((node) => derived.has(node.key));
|
|
2691
|
+
}
|
|
2692
|
+
return found.sort((a, b) => (order.get(a.locale) ?? 0) - (order.get(b.locale) ?? 0));
|
|
2693
|
+
},
|
|
2694
|
+
lookup(key, locale) {
|
|
2695
|
+
const exact = message(key, locale);
|
|
2696
|
+
if (exact) return exact;
|
|
2697
|
+
const derived = [...idx().derived.get(key) ?? []].sort();
|
|
2698
|
+
const other = derived.find((k) => /[._-]other$/.test(k)) ?? derived[0];
|
|
2699
|
+
return other === void 0 ? void 0 : message(other, locale);
|
|
2700
|
+
},
|
|
2701
|
+
keySlots: (path) => files.get(path)?.result.keySlots ?? [],
|
|
2702
|
+
hardStrings: (path) => [...files].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).filter(([p]) => path === void 0 || p === path).flatMap(([p, state]) => (state.result.hardStrings ?? []).map((h) => ({
|
|
2703
|
+
...h,
|
|
2704
|
+
path: p
|
|
2705
|
+
}))),
|
|
2706
|
+
analyze: () => analyzer.analyze(),
|
|
2707
|
+
async loadHistory(historyOptions = {}) {
|
|
2708
|
+
await ready;
|
|
2709
|
+
await history.load(historyOptions);
|
|
2710
|
+
},
|
|
2711
|
+
history: history.get,
|
|
2712
|
+
valueAt: history.valueAt,
|
|
2713
|
+
prompt: (kind, locale, promptOptions = {}) => buildPrompt(project, kind, locale, promptOptions, {
|
|
2714
|
+
config,
|
|
2715
|
+
hooks: registry.plugins.flatMap((plugin) => plugin.translatePrompt ? [plugin.translatePrompt] : [])
|
|
2716
|
+
}),
|
|
2717
|
+
explain,
|
|
2718
|
+
localeInfo,
|
|
2719
|
+
text: (path) => fileTexts.get(path) ?? files.get(path)?.text,
|
|
2720
|
+
owns: (path) => fileTexts.has(path) || files.has(path) || usageEligible(path),
|
|
2721
|
+
plan: (ops) => planner.plan(ops),
|
|
2722
|
+
apply: (plan, applyOptions) => planner.apply(plan, applyOptions?.answers ?? {}),
|
|
2723
|
+
update,
|
|
2724
|
+
watch() {
|
|
2725
|
+
if (watcher || !host.watch) return;
|
|
2726
|
+
watcher = host.watch(["**/*"], (events) => {
|
|
2727
|
+
update(events);
|
|
2728
|
+
});
|
|
2729
|
+
},
|
|
2730
|
+
onChange(listener) {
|
|
2731
|
+
listeners.add(listener);
|
|
2732
|
+
return () => listeners.delete(listener);
|
|
2733
|
+
},
|
|
2734
|
+
dispose() {
|
|
2735
|
+
watcher?.dispose();
|
|
2736
|
+
history.dispose();
|
|
2737
|
+
listeners.clear();
|
|
2738
|
+
}
|
|
2739
|
+
};
|
|
2740
|
+
return project;
|
|
2741
|
+
}
|
|
2742
|
+
//#endregion
|
|
2743
|
+
//#region src/resolve.ts
|
|
2744
|
+
const defaultExclude = [
|
|
2745
|
+
"**/node_modules/**",
|
|
2746
|
+
"**/.git/**",
|
|
2747
|
+
"**/dist/**",
|
|
2748
|
+
"**/build/**",
|
|
2749
|
+
"**/coverage/**",
|
|
2750
|
+
"**/.nuxt/**",
|
|
2751
|
+
"**/.output/**",
|
|
2752
|
+
"**/.next/**",
|
|
2753
|
+
"**/.svelte-kit/**",
|
|
2754
|
+
"**/*.d.ts",
|
|
2755
|
+
"**/*.min.js"
|
|
2756
|
+
];
|
|
2757
|
+
function defaultDraft() {
|
|
2758
|
+
return {
|
|
2759
|
+
sourceLocale: "en",
|
|
2760
|
+
locales: void 0,
|
|
2761
|
+
ignoreLocales: [],
|
|
2762
|
+
resources: [],
|
|
2763
|
+
formats: {},
|
|
2764
|
+
keyStyle: "auto",
|
|
2765
|
+
namespace: {
|
|
2766
|
+
separator: ".",
|
|
2767
|
+
default: void 0,
|
|
2768
|
+
fromPath: "dot"
|
|
2769
|
+
},
|
|
2770
|
+
usage: {
|
|
2771
|
+
include: ["**/*"],
|
|
2772
|
+
exclude: defaultExclude,
|
|
2773
|
+
calls: [],
|
|
2774
|
+
regex: [],
|
|
2775
|
+
ignoreCallees: [],
|
|
2776
|
+
keysInUse: [],
|
|
2777
|
+
derivedKeys: []
|
|
2778
|
+
},
|
|
2779
|
+
write: {
|
|
2780
|
+
sort: false,
|
|
2781
|
+
indent: void 0,
|
|
2782
|
+
keepFilled: false
|
|
2783
|
+
},
|
|
2784
|
+
extract: {},
|
|
2785
|
+
checks: {},
|
|
2786
|
+
strict: false,
|
|
2787
|
+
ignoreKeys: [],
|
|
2788
|
+
glossary: [],
|
|
2789
|
+
history: { providers: [] },
|
|
2790
|
+
pluginConfigs: {}
|
|
2791
|
+
};
|
|
2792
|
+
}
|
|
2793
|
+
function serializablePart(config) {
|
|
2794
|
+
const { presets, plugins, pluginConfigs, history, ...rest } = config;
|
|
2795
|
+
if (typeof history !== "object") return {
|
|
2796
|
+
...rest,
|
|
2797
|
+
history
|
|
2798
|
+
};
|
|
2799
|
+
const { providers, ...options } = history;
|
|
2800
|
+
return {
|
|
2801
|
+
...rest,
|
|
2802
|
+
history: options
|
|
2803
|
+
};
|
|
2804
|
+
}
|
|
2805
|
+
/** Applies a user config layer: scalars replace, lists of rules append. */
|
|
2806
|
+
function applyLayer(draft, layer, origin, origins) {
|
|
2807
|
+
const mark = (key) => {
|
|
2808
|
+
origins[key] = origin;
|
|
2809
|
+
};
|
|
2810
|
+
if (layer.sourceLocale !== void 0) {
|
|
2811
|
+
draft.sourceLocale = layer.sourceLocale;
|
|
2812
|
+
mark("sourceLocale");
|
|
2813
|
+
}
|
|
2814
|
+
if (layer.locales !== void 0) {
|
|
2815
|
+
draft.locales = layer.locales;
|
|
2816
|
+
mark("locales");
|
|
2817
|
+
}
|
|
2818
|
+
if (layer.ignoreLocales !== void 0) {
|
|
2819
|
+
draft.ignoreLocales = [...draft.ignoreLocales, ...layer.ignoreLocales];
|
|
2820
|
+
mark("ignoreLocales");
|
|
2821
|
+
}
|
|
2822
|
+
if (layer.resources !== void 0) {
|
|
2823
|
+
draft.resources = layer.resources;
|
|
2824
|
+
mark("resources");
|
|
2825
|
+
}
|
|
2826
|
+
if (layer.formats !== void 0) {
|
|
2827
|
+
draft.formats = {
|
|
2828
|
+
...draft.formats,
|
|
2829
|
+
...layer.formats
|
|
2830
|
+
};
|
|
2831
|
+
mark("formats");
|
|
2832
|
+
}
|
|
2833
|
+
if (layer.keyStyle !== void 0) {
|
|
2834
|
+
draft.keyStyle = layer.keyStyle;
|
|
2835
|
+
mark("keyStyle");
|
|
2836
|
+
}
|
|
2837
|
+
if (layer.namespace !== void 0) {
|
|
2838
|
+
draft.namespace = {
|
|
2839
|
+
separator: layer.namespace.separator ?? draft.namespace.separator,
|
|
2840
|
+
default: layer.namespace.default ?? draft.namespace.default,
|
|
2841
|
+
fromPath: layer.namespace.fromPath ?? draft.namespace.fromPath
|
|
2842
|
+
};
|
|
2843
|
+
mark("namespace");
|
|
2844
|
+
}
|
|
2845
|
+
const usage = layer.usage;
|
|
2846
|
+
if (usage !== void 0) {
|
|
2847
|
+
draft.usage = {
|
|
2848
|
+
include: usage.include ?? draft.usage.include,
|
|
2849
|
+
exclude: [...draft.usage.exclude, ...usage.exclude ?? []],
|
|
2850
|
+
calls: [...draft.usage.calls, ...usage.calls ?? []],
|
|
2851
|
+
regex: [...draft.usage.regex, ...usage.regex ?? []],
|
|
2852
|
+
ignoreCallees: [...draft.usage.ignoreCallees, ...usage.ignoreCallees ?? []],
|
|
2853
|
+
keysInUse: [...draft.usage.keysInUse, ...usage.keysInUse ?? []],
|
|
2854
|
+
derivedKeys: [...draft.usage.derivedKeys, ...usage.derivedKeys ?? []]
|
|
2855
|
+
};
|
|
2856
|
+
mark("usage");
|
|
2857
|
+
}
|
|
2858
|
+
if (layer.write !== void 0) {
|
|
2859
|
+
draft.write = {
|
|
2860
|
+
sort: layer.write.sort ?? draft.write.sort,
|
|
2861
|
+
indent: layer.write.indent ?? draft.write.indent,
|
|
2862
|
+
keepFilled: layer.write.keepFilled ?? draft.write.keepFilled
|
|
2863
|
+
};
|
|
2864
|
+
mark("write");
|
|
2865
|
+
}
|
|
2866
|
+
if (layer.extract !== void 0) {
|
|
2867
|
+
draft.extract = {
|
|
2868
|
+
...draft.extract,
|
|
2869
|
+
...layer.extract,
|
|
2870
|
+
templates: [...draft.extract.templates ?? [], ...layer.extract.templates ?? []]
|
|
2871
|
+
};
|
|
2872
|
+
mark("extract");
|
|
2873
|
+
}
|
|
2874
|
+
if (layer.checks !== void 0) {
|
|
2875
|
+
draft.checks = {
|
|
2876
|
+
...draft.checks,
|
|
2877
|
+
...layer.checks
|
|
2878
|
+
};
|
|
2879
|
+
mark("checks");
|
|
2880
|
+
}
|
|
2881
|
+
if (layer.strict !== void 0) {
|
|
2882
|
+
draft.strict = layer.strict;
|
|
2883
|
+
mark("strict");
|
|
2884
|
+
}
|
|
2885
|
+
if (layer.ignoreKeys !== void 0) {
|
|
2886
|
+
draft.ignoreKeys = [...draft.ignoreKeys, ...layer.ignoreKeys];
|
|
2887
|
+
mark("ignoreKeys");
|
|
2888
|
+
}
|
|
2889
|
+
if (layer.glossary !== void 0) {
|
|
2890
|
+
draft.glossary = [...draft.glossary, ...layer.glossary];
|
|
2891
|
+
mark("glossary");
|
|
2892
|
+
}
|
|
2893
|
+
if (layer.history !== void 0) {
|
|
2894
|
+
const previous = draft.history || { providers: [] };
|
|
2895
|
+
draft.history = layer.history === false ? false : layer.history === true ? previous : {
|
|
2896
|
+
...previous,
|
|
2897
|
+
...layer.history,
|
|
2898
|
+
providers: [...previous.providers, ...layer.history.providers ?? []]
|
|
2899
|
+
};
|
|
2900
|
+
mark("history");
|
|
2901
|
+
}
|
|
2902
|
+
if (layer.pluginConfigs !== void 0) {
|
|
2903
|
+
draft.pluginConfigs = {
|
|
2904
|
+
...draft.pluginConfigs,
|
|
2905
|
+
...layer.pluginConfigs
|
|
2906
|
+
};
|
|
2907
|
+
mark("pluginConfigs");
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
async function createDetectContext(host, readers, roots) {
|
|
2911
|
+
const dependencies = /* @__PURE__ */ new Set();
|
|
2912
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2913
|
+
for (const root of ["", ...roots]) for (const reader of readers) {
|
|
2914
|
+
const path = root ? `${root.replace(/\/$/, "")}/${reader.file}` : reader.file;
|
|
2915
|
+
if (seen.has(path)) continue;
|
|
2916
|
+
seen.add(path);
|
|
2917
|
+
const text = await host.readFile(path);
|
|
2918
|
+
if (text === void 0) continue;
|
|
2919
|
+
try {
|
|
2920
|
+
for (const name of reader.read(text)) dependencies.add(name);
|
|
2921
|
+
} catch {
|
|
2922
|
+
host.logger?.warn(`Could not read manifest ${path}`);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
return {
|
|
2926
|
+
host,
|
|
2927
|
+
dependencies,
|
|
2928
|
+
hasDependency: (name) => dependencies.has(name),
|
|
2929
|
+
hasAnyDependency: (names) => names.some((name) => dependencies.has(name)),
|
|
2930
|
+
fileExists: async (pattern) => (await host.glob([pattern], { gitignore: true })).length > 0
|
|
2931
|
+
};
|
|
2932
|
+
}
|
|
2933
|
+
function validateLayers(layers) {
|
|
2934
|
+
for (const layer of layers) {
|
|
2935
|
+
if (!layer) continue;
|
|
2936
|
+
const result = validate(ConfigSchema, serializablePart(layer));
|
|
2937
|
+
if (!result.success) throw new X18nError("X18N0001", result.issues.map((issue) => `${issue.path || "(root)"}: ${issue.message}`).join("; "));
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
function createLoader(options) {
|
|
2941
|
+
return async (option) => {
|
|
2942
|
+
if (option === false || option === void 0) return void 0;
|
|
2943
|
+
if (typeof option === "object" && !Array.isArray(option)) return option;
|
|
2944
|
+
const [specifier, pluginOptions] = typeof option === "string" ? [option, void 0] : [option[0], option[1]];
|
|
2945
|
+
const factory = options.builtins.presets[specifier];
|
|
2946
|
+
if (factory) return factory(pluginOptions);
|
|
2947
|
+
if (!options.loadPlugin) throw new X18nError("X18N1003", specifier);
|
|
2948
|
+
return options.loadPlugin(specifier, pluginOptions);
|
|
2949
|
+
};
|
|
2950
|
+
}
|
|
2951
|
+
async function loadAll(load, list) {
|
|
2952
|
+
const out = [];
|
|
2953
|
+
for (const option of list) {
|
|
2954
|
+
const loaded = await load(option);
|
|
2955
|
+
if (loaded) out.push(loaded);
|
|
2956
|
+
}
|
|
2957
|
+
return out;
|
|
2958
|
+
}
|
|
2959
|
+
/** Expands presets depth-first: listed presets always apply, others only when detected; an exclusive one stops the rest. */
|
|
2960
|
+
async function expandPresets(presets, userPlugins, listed, detectCtx) {
|
|
2961
|
+
const explanations = [];
|
|
2962
|
+
const plugins = [];
|
|
2963
|
+
const flatten = async (item, force) => {
|
|
2964
|
+
if (!isPreset(item)) {
|
|
2965
|
+
if (!plugins.some((plugin) => plugin.name === item.name)) plugins.push(item);
|
|
2966
|
+
return false;
|
|
2967
|
+
}
|
|
2968
|
+
const applies = force || (item.detect ? await item.detect(detectCtx) : false);
|
|
2969
|
+
explanations.push({
|
|
2970
|
+
name: item.name,
|
|
2971
|
+
reason: applies ? force ? "listed" : "detected" : "not-detected"
|
|
2972
|
+
});
|
|
2973
|
+
if (!applies) return false;
|
|
2974
|
+
for (const child of item.plugins) await flatten(child, false);
|
|
2975
|
+
return item.exclusive === true;
|
|
2976
|
+
};
|
|
2977
|
+
let stopped = false;
|
|
2978
|
+
for (const preset of presets) if (stopped) explanations.push({
|
|
2979
|
+
name: preset.name,
|
|
2980
|
+
reason: "after-exclusive"
|
|
2981
|
+
});
|
|
2982
|
+
else stopped = await flatten(preset, listed);
|
|
2983
|
+
for (const plugin of userPlugins) await flatten(plugin, true);
|
|
2984
|
+
return {
|
|
2985
|
+
plugins,
|
|
2986
|
+
explanations
|
|
2987
|
+
};
|
|
2988
|
+
}
|
|
2989
|
+
async function runConfigHooks(plugins, draft, ctx, origins) {
|
|
2990
|
+
for (const plugin of plugins) {
|
|
2991
|
+
if (!plugin.config) continue;
|
|
2992
|
+
const snapshot = Object.fromEntries(Object.entries(draft).map(([key, value]) => [key, JSON.stringify(value)]));
|
|
2993
|
+
await plugin.config(draft, ctx);
|
|
2994
|
+
for (const [key, value] of Object.entries(draft)) if (snapshot[key] !== JSON.stringify(value)) origins[key] = `plugin:${plugin.name}`;
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
async function resolveConfig(options) {
|
|
2998
|
+
const { host, builtins } = options;
|
|
2999
|
+
const user = options.config;
|
|
3000
|
+
validateLayers([options.parent, user]);
|
|
3001
|
+
const load = createLoader(options);
|
|
3002
|
+
const userPlugins = await loadAll(load, [...options.parent?.plugins ?? [], ...user.plugins ?? []]);
|
|
3003
|
+
const presetOption = user.presets ?? options.parent?.presets ?? "auto";
|
|
3004
|
+
const auto = presetOption === "auto";
|
|
3005
|
+
const presets = auto ? Object.values(builtins.presets).map((factory) => factory()) : (await loadAll(load, presetOption)).map((item) => isPreset(item) ? item : {
|
|
3006
|
+
name: item.name,
|
|
3007
|
+
plugins: [item]
|
|
3008
|
+
});
|
|
3009
|
+
const detectCtx = await createDetectContext(host, [...builtins.plugins, ...userPlugins].flatMap((item) => isPreset(item) ? [] : item.manifests ?? []), options.manifestRoots ?? []);
|
|
3010
|
+
const expanded = await expandPresets(presets, userPlugins, !auto, detectCtx);
|
|
3011
|
+
const skipped = [];
|
|
3012
|
+
const finalPlugins = [...builtins.plugins, ...expanded.plugins].filter((plugin) => {
|
|
3013
|
+
const skip = options.runtime === "browser" && plugin.runtime === "node";
|
|
3014
|
+
if (skip) skipped.push({
|
|
3015
|
+
name: plugin.name,
|
|
3016
|
+
reason: "node only"
|
|
3017
|
+
});
|
|
3018
|
+
return !skip;
|
|
3019
|
+
});
|
|
3020
|
+
const draft = defaultDraft();
|
|
3021
|
+
const origins = {};
|
|
3022
|
+
await runConfigHooks(finalPlugins, draft, {
|
|
3023
|
+
host,
|
|
3024
|
+
user: serializablePart(user)
|
|
3025
|
+
}, origins);
|
|
3026
|
+
if (options.parent) applyLayer(draft, options.parent, "parent", origins);
|
|
3027
|
+
applyLayer(draft, user, "user", origins);
|
|
3028
|
+
const config = Object.freeze({
|
|
3029
|
+
...draft,
|
|
3030
|
+
name: user.name ?? options.name ?? "default",
|
|
3031
|
+
usage: {
|
|
3032
|
+
...draft.usage,
|
|
3033
|
+
derivedKeys: [...draft.usage.derivedKeys, ...finalPlugins.flatMap((plugin) => plugin.derivedKeys ?? [])]
|
|
3034
|
+
},
|
|
3035
|
+
origins
|
|
3036
|
+
});
|
|
3037
|
+
for (const plugin of finalPlugins) plugin.configResolved?.(config);
|
|
3038
|
+
return {
|
|
3039
|
+
config,
|
|
3040
|
+
registry: createRegistry(finalPlugins, config),
|
|
3041
|
+
presets: expanded.explanations,
|
|
3042
|
+
skipped
|
|
3043
|
+
};
|
|
3044
|
+
}
|
|
3045
|
+
//#endregion
|
|
3046
|
+
export { ConfigSchema, PluginFileSchema, X18nError, applyMessageOps, applyTextEdits, buildTree, contractVersion, createLineIndex, createMemoryHost, createOverlayHost, createPlaceholderComparer, 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 };
|