@dialexjs/cli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,992 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import fg from "fast-glob";
4
+ import chokidar from "chokidar";
5
+ import { generateDts, resolveI18nConfig } from "dialexjs/scanner";
6
+ import pc from "picocolors";
7
+ import { generateCode, parseExpression, parseModule, writeFile } from "magicast";
8
+ import { pathToFileURL } from "node:url";
9
+ import { argumentSignature, findPlurals, isIcuStructured, parseMessage } from "dialexjs/icu";
10
+ //#region src/utils/logger.ts
11
+ const logger = {
12
+ info: (msg) => console.log(pc.blue("ℹ ") + msg),
13
+ success: (msg) => console.log(pc.green("✔ ") + pc.bold(msg)),
14
+ warn: (msg) => console.warn(pc.yellow("⚠ ") + msg),
15
+ error: (msg) => console.error(pc.red("✖ ") + pc.bold(msg)),
16
+ log: (msg) => console.log(msg)
17
+ };
18
+ //#endregion
19
+ //#region src/commands/generate.ts
20
+ function generateDictionaries(root, options = {}) {
21
+ const inlineConfig = options.config ? { configFile: options.config } : {};
22
+ const config = resolveI18nConfig(root, inlineConfig);
23
+ const include = config.include || "**/*.content.ts";
24
+ const files = fg.sync(include, {
25
+ cwd: root,
26
+ absolute: true,
27
+ ignore: [
28
+ "**/node_modules/**",
29
+ "**/dist/**",
30
+ "**/.next/**"
31
+ ]
32
+ });
33
+ const srcDir = fs.existsSync(path.join(root, "src")) ? path.join(root, "src") : root;
34
+ const targetOutput = options.output ? path.resolve(root, options.output) : path.join(srcDir, "i18n.generated.ts");
35
+ const targetDir = path.dirname(targetOutput);
36
+ generateDts(root, files, config.locales);
37
+ const dtsPath = path.join(srcDir, "dialex-env.d.ts");
38
+ const importLines = [];
39
+ const dictIdentifiers = [];
40
+ files.forEach((file, idx) => {
41
+ let rel = path.relative(targetDir, file).replace(/\\/g, "/");
42
+ rel = rel.replace(/\.ts$/, ".js");
43
+ if (!rel.startsWith(".")) rel = "./" + rel;
44
+ const id = `dict_${idx}`;
45
+ importLines.push(`import ${id} from "${rel}";`);
46
+ dictIdentifiers.push(id);
47
+ });
48
+ const dictList = dictIdentifiers.length <= 1 ? `[${dictIdentifiers.join(", ")}]` : `[\n ${dictIdentifiers.join(",\n ")}\n]`;
49
+ const content = `// Auto-generated by dialex CLI. Do not edit directly.
50
+ /* eslint-disable */
51
+ // @ts-nocheck
52
+ ${importLines.join("\n")}
53
+
54
+ export const dictionaries = ${dictList};
55
+
56
+ export const dictionaryMap = {
57
+ ${dictIdentifiers.map((id) => ` [${id}.name]: ${id}.dictionary,`).join("\n")}
58
+ };
59
+
60
+ export default dictionaries;
61
+ `;
62
+ fs.mkdirSync(targetDir, { recursive: true });
63
+ if ((fs.existsSync(targetOutput) ? fs.readFileSync(targetOutput, "utf-8") : "") !== content) fs.writeFileSync(targetOutput, content, "utf-8");
64
+ return {
65
+ files,
66
+ outputPath: targetOutput,
67
+ dtsPath
68
+ };
69
+ }
70
+ async function runGenerate(options = {}) {
71
+ const root = options.cwd || process.cwd();
72
+ const quiet = options.json === true;
73
+ const doGenerate = () => {
74
+ try {
75
+ const { files, outputPath, dtsPath } = generateDictionaries(root, options);
76
+ if (quiet) console.log(JSON.stringify({
77
+ success: true,
78
+ files: files.map((f) => path.relative(root, f)),
79
+ outputPath: path.relative(root, outputPath),
80
+ dtsPath: path.relative(root, dtsPath)
81
+ }, null, 2));
82
+ else {
83
+ logger.success(`Generated dictionaries for ${files.length} file${files.length === 1 ? "" : "s"}:`);
84
+ logger.info(`→ ${path.relative(root, outputPath)}`);
85
+ logger.info(`→ ${path.relative(root, dtsPath)}`);
86
+ }
87
+ return true;
88
+ } catch (err) {
89
+ if (quiet) console.log(JSON.stringify({
90
+ success: false,
91
+ error: err.message || String(err)
92
+ }, null, 2));
93
+ else logger.error(`Generation failed: ${err.message || String(err)}`);
94
+ return false;
95
+ }
96
+ };
97
+ if (!doGenerate() && !options.watch) process.exit(1);
98
+ if (options.watch) {
99
+ logger.info("Watching for dictionary changes (-w, --watch)...");
100
+ const inlineConfig = options.config ? { configFile: options.config } : {};
101
+ const pattern = resolveI18nConfig(root, inlineConfig).include || "**/*.content.ts";
102
+ chokidar.watch(pattern, {
103
+ cwd: root,
104
+ ignoreInitial: true,
105
+ ignored: [
106
+ "**/node_modules/**",
107
+ "**/dist/**",
108
+ "**/.next/**"
109
+ ]
110
+ }).on("all", (event, filePath) => {
111
+ logger.info(`[${event}] ${filePath} changed, regenerating...`);
112
+ doGenerate();
113
+ });
114
+ }
115
+ }
116
+ //#endregion
117
+ //#region src/utils/dictionary-edit.ts
118
+ const TODO_PREFIX = "[TODO] ";
119
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
120
+ function unwrap(node) {
121
+ while (node && (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "ParenthesizedExpression")) node = node.expression;
122
+ return node;
123
+ }
124
+ const isObject = (node) => node?.type === "ObjectExpression";
125
+ function keyName(prop) {
126
+ const key = prop?.key;
127
+ if (!key || prop.computed) return void 0;
128
+ if (key.type === "Identifier") return key.name;
129
+ if (key.type === "StringLiteral" || key.type === "Literal" && typeof key.value === "string") return key.value;
130
+ if (key.type === "NumericLiteral") return String(key.value);
131
+ }
132
+ const isPropLike = (p) => p?.type === "ObjectProperty" || p?.type === "Property" || p?.type === "ObjectMethod";
133
+ function findProp(obj, key) {
134
+ return obj.properties.find((p) => isPropLike(p) && keyName(p) === key);
135
+ }
136
+ function stringValue(node) {
137
+ node = unwrap(node);
138
+ if (node?.type === "StringLiteral") return node.value;
139
+ if (node?.type === "Literal" && typeof node.value === "string") return node.value;
140
+ if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked ?? void 0;
141
+ }
142
+ function leafKind(node) {
143
+ node = unwrap(node);
144
+ if (stringValue(node) !== void 0) return "string";
145
+ if (node?.type === "ArrowFunctionExpression" || node?.type === "FunctionExpression" || node?.type === "ObjectMethod") return "function";
146
+ return "other";
147
+ }
148
+ function dictionaryProp(obj) {
149
+ const value = unwrap(findProp(obj, "dictionary")?.value);
150
+ return isObject(value) ? value : void 0;
151
+ }
152
+ function locateName(declaration) {
153
+ const decl = unwrap(declaration);
154
+ if (decl?.type === "CallExpression") {
155
+ const first = unwrap(decl.arguments[0]);
156
+ if (isObject(first)) return stringValue(findProp(first, "name")?.value);
157
+ return stringValue(first);
158
+ }
159
+ if (isObject(decl)) return stringValue(findProp(decl, "name")?.value);
160
+ }
161
+ function locateLocalesObject(declaration) {
162
+ const decl = unwrap(declaration);
163
+ if (decl?.type === "CallExpression") {
164
+ const first = unwrap(decl.arguments[0]);
165
+ const second = unwrap(decl.arguments[1]);
166
+ if (isObject(second)) return second;
167
+ if (isObject(first)) return dictionaryProp(first);
168
+ return;
169
+ }
170
+ if (isObject(decl)) return dictionaryProp(decl);
171
+ }
172
+ /** Quotes a property key only when it is not a valid identifier. */
173
+ function keyCode(key) {
174
+ return IDENTIFIER.test(key) ? key : JSON.stringify(key);
175
+ }
176
+ /** Builds a detached `key: <value>` property node from source text. */
177
+ function makeProp(key, valueCode) {
178
+ return parseExpression(`({ ${keyCode(key)}: ${valueCode} })`).$ast.properties[0];
179
+ }
180
+ function makeStringNode(value) {
181
+ return makeProp("_", JSON.stringify(value)).value;
182
+ }
183
+ /**
184
+ * A dictionary is statically readable when every locale is an object literal and no object on the
185
+ * way down uses spreads or computed keys.
186
+ */
187
+ function isStaticallyComplete(localesObj) {
188
+ const walk = (obj) => obj.properties.every((prop) => {
189
+ if (!isPropLike(prop) || keyName(prop) === void 0) return false;
190
+ if (prop.type === "ObjectMethod") return true;
191
+ const value = unwrap(prop.value);
192
+ return isObject(value) ? walk(value) : true;
193
+ });
194
+ return localesObj.properties.every((prop) => {
195
+ if (!isPropLike(prop) || keyName(prop) === void 0 || prop.type === "ObjectMethod") return false;
196
+ const value = unwrap(prop.value);
197
+ return isObject(value) && walk(value);
198
+ });
199
+ }
200
+ /**
201
+ * Parses dictionary source text (for example an unsaved editor buffer). Returns `undefined` when
202
+ * the file does not have an editable shape; throws on syntax errors.
203
+ */
204
+ function parseDictionaryText(file, text) {
205
+ const mod = parseModule(text);
206
+ const ast = mod.exports.default?.$ast;
207
+ if (!ast) return void 0;
208
+ const declaration = ast.declaration ?? ast;
209
+ const localesObj = locateLocalesObject(declaration);
210
+ return localesObj ? {
211
+ file,
212
+ mod,
213
+ localesObj,
214
+ name: locateName(declaration),
215
+ endsWithNewline: text.endsWith("\n"),
216
+ complete: isStaticallyComplete(localesObj)
217
+ } : void 0;
218
+ }
219
+ /** Loads a dictionary file; returns `undefined` when its shape is not editable. */
220
+ async function loadDictionaryFile(file) {
221
+ return parseDictionaryText(file, fs.readFileSync(file, "utf-8"));
222
+ }
223
+ /** The source text with all edits applied, without writing anything. */
224
+ function renderDictionaryFile(df) {
225
+ const code = generateCode(df.mod).code;
226
+ return df.endsWithNewline && !code.endsWith("\n") ? `${code}\n` : code;
227
+ }
228
+ async function saveDictionaryFile(df) {
229
+ await writeFile(df.mod, df.file);
230
+ if (df.endsWithNewline) {
231
+ const text = fs.readFileSync(df.file, "utf-8");
232
+ if (!text.endsWith("\n")) fs.writeFileSync(df.file, text + "\n", "utf-8");
233
+ }
234
+ }
235
+ function listLocales(df) {
236
+ return df.localesObj.properties.filter(isPropLike).map(keyName).filter((k) => k !== void 0);
237
+ }
238
+ function localeObject(df, locale) {
239
+ const value = unwrap(findProp(df.localesObj, locale)?.value);
240
+ return isObject(value) ? value : void 0;
241
+ }
242
+ /** Lists every leaf (string, function, or other value) of a locale, in source order. */
243
+ function listLeaves(df, locale) {
244
+ const root = localeObject(df, locale);
245
+ if (!root) return [];
246
+ const leaves = [];
247
+ const walk = (obj, prefix) => {
248
+ for (const prop of obj.properties) {
249
+ if (!isPropLike(prop)) continue;
250
+ const name = keyName(prop);
251
+ if (name === void 0) continue;
252
+ const path = [...prefix, name];
253
+ const value = prop.type === "ObjectMethod" ? prop : unwrap(prop.value);
254
+ if (isObject(value)) walk(value, path);
255
+ else {
256
+ const kind = leafKind(value);
257
+ leaves.push({
258
+ path,
259
+ kind,
260
+ ...kind === "string" ? { value: stringValue(value) } : {}
261
+ });
262
+ }
263
+ }
264
+ };
265
+ walk(root, []);
266
+ return leaves;
267
+ }
268
+ function getProp(df, locale, path) {
269
+ let obj = localeObject(df, locale);
270
+ for (let i = 0; obj && i < path.length; i++) {
271
+ const prop = findProp(obj, path[i]);
272
+ if (!prop) return void 0;
273
+ if (i === path.length - 1) return prop;
274
+ const next = unwrap(prop.value);
275
+ obj = isObject(next) ? next : void 0;
276
+ }
277
+ }
278
+ function hasPath(df, locale, path) {
279
+ return getProp(df, locale, path) !== void 0;
280
+ }
281
+ function getString(df, locale, path) {
282
+ const prop = getProp(df, locale, path);
283
+ return prop && prop.type !== "ObjectMethod" ? stringValue(prop.value) : void 0;
284
+ }
285
+ function ensureLocale(df, locale) {
286
+ let obj = localeObject(df, locale);
287
+ if (!obj) {
288
+ const prop = makeProp(locale, "{}");
289
+ df.localesObj.properties.push(prop);
290
+ obj = prop.value;
291
+ }
292
+ return obj;
293
+ }
294
+ /** Returns the object that should contain the last path segment, creating objects as needed. */
295
+ function ensureParent(df, locale, path) {
296
+ let obj = ensureLocale(df, locale);
297
+ for (const segment of path.slice(0, -1)) {
298
+ let prop = findProp(obj, segment);
299
+ if (!prop) {
300
+ prop = makeProp(segment, "{}");
301
+ obj.properties.push(prop);
302
+ }
303
+ const next = unwrap(prop.value);
304
+ if (!isObject(next)) return void 0;
305
+ obj = next;
306
+ }
307
+ return obj;
308
+ }
309
+ /**
310
+ * Sets a string value at `path`. Existing function or non-string values are left alone
311
+ * (`"skipped"`) because they cannot be translated as plain text.
312
+ */
313
+ function setString(df, locale, path, value) {
314
+ const parent = ensureParent(df, locale, path);
315
+ if (!parent) return "skipped";
316
+ const last = path[path.length - 1];
317
+ const existing = findProp(parent, last);
318
+ if (existing) {
319
+ if (existing.type === "ObjectMethod" || leafKind(existing.value) !== "string") return "skipped";
320
+ existing.value = makeStringNode(value);
321
+ return "updated";
322
+ }
323
+ parent.properties.push(makeProp(last, JSON.stringify(value)));
324
+ return "created";
325
+ }
326
+ /**
327
+ * Copies the leaf at `path` from one locale to another. String values can be rewritten with
328
+ * `mapString` (for example to add a `[TODO]` marker); other values are copied verbatim.
329
+ * Returns `false` when the source has no such leaf or the target already has one.
330
+ */
331
+ function copyLeaf(df, fromLocale, toLocale, path, mapString) {
332
+ const source = getProp(df, fromLocale, path);
333
+ if (!source || hasPath(df, toLocale, path)) return false;
334
+ const parent = ensureParent(df, toLocale, path);
335
+ if (!parent) return false;
336
+ const last = path[path.length - 1];
337
+ const text = source.type === "ObjectMethod" ? void 0 : stringValue(source.value);
338
+ if (text !== void 0 && mapString) parent.properties.push(makeProp(last, JSON.stringify(mapString(text))));
339
+ else {
340
+ const code = generateCode(source).code;
341
+ const expr = parseExpression(`({ ${code} })`);
342
+ parent.properties.push(expr.$ast.properties[0]);
343
+ }
344
+ return true;
345
+ }
346
+ /** Leaf paths of a runtime dictionary value (plain objects recurse; everything else is a leaf). */
347
+ function flattenPaths(value, prefix = []) {
348
+ if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([k, v]) => flattenPaths(v, [...prefix, k]));
349
+ return prefix.length > 0 ? [prefix] : [];
350
+ }
351
+ const toRange = (loc) => loc ? {
352
+ start: {
353
+ line: loc.start.line - 1,
354
+ column: loc.start.column
355
+ },
356
+ end: {
357
+ line: loc.end.line - 1,
358
+ column: loc.end.column
359
+ }
360
+ } : void 0;
361
+ /**
362
+ * Where a locale (no `path`) or a key within it is written. The range covers the property key, so
363
+ * it is a good place to attach a diagnostic or jump to.
364
+ */
365
+ function locationOf(df, locale, path = []) {
366
+ const prop = path.length === 0 ? findProp(df.localesObj, locale) : getProp(df, locale, path);
367
+ return toRange((prop?.key ?? prop)?.loc);
368
+ }
369
+ /** The source text of the dictionary object itself (the object whose keys are locales). */
370
+ function dictionaryLocation(df) {
371
+ return toRange(df.localesObj.loc);
372
+ }
373
+ /** The source code of a leaf's value (a function's source, or a string with its quotes). */
374
+ function getLeafSource(df, locale, path) {
375
+ const prop = getProp(df, locale, path);
376
+ if (!prop) return void 0;
377
+ return generateCode(prop.type === "ObjectMethod" ? prop : prop.value).code;
378
+ }
379
+ //#endregion
380
+ //#region src/utils/icu-check.ts
381
+ /** CLDR order. `resolvedOptions().pluralCategories` order differs between Node versions. */
382
+ const CATEGORY_ORDER = [
383
+ "zero",
384
+ "one",
385
+ "two",
386
+ "few",
387
+ "many"
388
+ ];
389
+ function requiredCategories(locale, ordinal) {
390
+ try {
391
+ return new Intl.PluralRules(locale, { type: ordinal ? "ordinal" : "cardinal" }).resolvedOptions().pluralCategories.filter((c) => c !== "other").sort((a, b) => CATEGORY_ORDER.indexOf(a) - CATEGORY_ORDER.indexOf(b));
392
+ } catch {
393
+ return [];
394
+ }
395
+ }
396
+ /**
397
+ * Validates ICU messages across the locales of one dictionary:
398
+ *
399
+ * - invalid syntax: an error for messages that clearly use ICU (plural/select/number/date/time),
400
+ * a warning for plain `{placeholders}` that do not parse (they may not be meant as ICU);
401
+ * - every locale must use the same arguments as the reference locale;
402
+ * - plural options should cover the categories the locale's plural rules need.
403
+ */
404
+ function checkIcu(dictionary, strings, defaultLocale) {
405
+ const findings = [];
406
+ const parsed = /* @__PURE__ */ new Map();
407
+ for (const [locale, entries] of strings) {
408
+ const ok = /* @__PURE__ */ new Map();
409
+ for (const [path, value] of entries) {
410
+ if (!value.includes("{")) continue;
411
+ const key = `${dictionary}.${path}`;
412
+ try {
413
+ ok.set(path, parseMessage(value));
414
+ } catch (error) {
415
+ const structured = isIcuStructured(value);
416
+ findings.push({
417
+ level: structured ? "error" : "warning",
418
+ code: "invalid-icu",
419
+ locale,
420
+ key,
421
+ message: structured ? `Invalid ICU message "${key}" (${locale}): ${error.message}` : `"${key}" (${locale}) contains braces but is not valid ICU, so t() values cannot be applied: ${error.message}`
422
+ });
423
+ }
424
+ }
425
+ parsed.set(locale, ok);
426
+ }
427
+ const order = [defaultLocale, ...[...parsed.keys()].filter((l) => l !== defaultLocale)];
428
+ const paths = new Set([...parsed.values()].flatMap((m) => [...m.keys()]));
429
+ for (const path of paths) {
430
+ const key = `${dictionary}.${path}`;
431
+ const reference = order.find((l) => parsed.get(l)?.has(path));
432
+ if (!reference) continue;
433
+ const expected = argumentSignature(parsed.get(reference).get(path));
434
+ for (const locale of order) {
435
+ const nodes = parsed.get(locale)?.get(path);
436
+ if (nodes && locale !== reference) {
437
+ const found = argumentSignature(nodes);
438
+ if (found !== expected) findings.push({
439
+ level: "error",
440
+ code: "icu-args-mismatch",
441
+ locale,
442
+ key,
443
+ message: `Locale "${locale}" uses different arguments than "${reference}" for "${key}" (expected ${expected || "none"}, found ${found || "none"})`
444
+ });
445
+ }
446
+ if (!nodes) continue;
447
+ for (const plural of findPlurals(nodes)) {
448
+ const missing = requiredCategories(locale, plural.ordinal).filter((c) => !(c in plural.options));
449
+ if (missing.length > 0) findings.push({
450
+ level: "warning",
451
+ code: "icu-plural-categories",
452
+ locale,
453
+ key,
454
+ message: `Locale "${locale}" plural "${plural.name}" in "${key}" has no ${missing.map((c) => `"${c}"`).join(", ")} option (used by this language; "other" will be shown instead)`
455
+ });
456
+ }
457
+ }
458
+ }
459
+ return findings;
460
+ }
461
+ //#endregion
462
+ //#region src/analysis.ts
463
+ const DICTIONARY_IGNORE = [
464
+ "**/node_modules/**",
465
+ "**/dist/**",
466
+ "**/.next/**"
467
+ ];
468
+ const DEFAULT_SOURCE_GLOB = "**/*.{ts,tsx,js,jsx,mjs,cjs,vue,svelte,astro,mdx}";
469
+ const SOURCE_IGNORE = [
470
+ ...DICTIONARY_IGNORE,
471
+ "**/.nuxt/**",
472
+ "**/.output/**",
473
+ "**/.svelte-kit/**",
474
+ "**/.astro/**",
475
+ "**/coverage/**",
476
+ "**/graphify-out/**",
477
+ "**/*.d.ts",
478
+ "**/i18n.generated.*",
479
+ "**/dialex.config.*",
480
+ "**/i18n.config.*"
481
+ ];
482
+ /** `getDictionary("x")`, `useDictionary("x")`, `@I18nDictionary("x")` */
483
+ const DICTIONARY_CALL = /\b(?:getDictionary|useDictionary|I18nDictionary)\(\s*(["'`])([^"'`$\n]+)\1/dg;
484
+ /** `t("dictionary.path.to.key")` */
485
+ const T_CALL = /(?<![\w$.])t\(\s*(["'`])([\w-]+(?:\.[\w-]+)+)\1/dg;
486
+ /** Finds `getDictionary("x")`-style and `t("x.y.z")` references in source text. */
487
+ function scanReferences(text) {
488
+ const references = [];
489
+ const collect = (pattern, kind) => {
490
+ for (const match of text.matchAll(pattern)) {
491
+ const [start, end] = match.indices[2];
492
+ references.push({
493
+ kind,
494
+ value: match[2],
495
+ start,
496
+ end
497
+ });
498
+ }
499
+ };
500
+ collect(DICTIONARY_CALL, "dictionary");
501
+ collect(T_CALL, "path");
502
+ return references.sort((a, b) => a.start - b.start);
503
+ }
504
+ function lineStarts(text) {
505
+ const starts = [0];
506
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1);
507
+ return starts;
508
+ }
509
+ function positionAt(starts, offset) {
510
+ let low = 0;
511
+ let high = starts.length - 1;
512
+ while (low < high) {
513
+ const mid = Math.ceil((low + high) / 2);
514
+ if (starts[mid] <= offset) low = mid;
515
+ else high = mid - 1;
516
+ }
517
+ return {
518
+ line: low,
519
+ column: offset - starts[low]
520
+ };
521
+ }
522
+ function fromSyntaxTree(df) {
523
+ const locales = listLocales(df);
524
+ const leaves = /* @__PURE__ */ new Map();
525
+ for (const locale of locales) {
526
+ const entries = /* @__PURE__ */ new Map();
527
+ for (const leaf of listLeaves(df, locale)) entries.set(leaf.path.join("."), {
528
+ kind: leaf.kind,
529
+ value: leaf.value
530
+ });
531
+ leaves.set(locale, entries);
532
+ }
533
+ return {
534
+ name: df.name,
535
+ locales,
536
+ leaves,
537
+ df
538
+ };
539
+ }
540
+ async function fromRuntime(file) {
541
+ try {
542
+ const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}_${Math.random()}`);
543
+ const definition = mod.default || mod;
544
+ if (!definition || typeof definition !== "object" || !definition.dictionary) return {
545
+ ok: false,
546
+ level: "error",
547
+ code: "invalid-dictionary",
548
+ message: "Default export does not appear to be a valid defineDictionary output."
549
+ };
550
+ const dictionary = definition.dictionary;
551
+ const leaves = /* @__PURE__ */ new Map();
552
+ const invalid = [];
553
+ for (const [locale, content] of Object.entries(dictionary)) {
554
+ if (!content || typeof content !== "object") {
555
+ invalid.push(locale);
556
+ continue;
557
+ }
558
+ const entries = /* @__PURE__ */ new Map();
559
+ for (const leafPath of flattenPaths(content)) {
560
+ let value = content;
561
+ for (const segment of leafPath) value = value?.[segment];
562
+ entries.set(leafPath.join("."), typeof value === "string" ? {
563
+ kind: "string",
564
+ value
565
+ } : { kind: typeof value === "function" ? "function" : "other" });
566
+ }
567
+ leaves.set(locale, entries);
568
+ }
569
+ if (invalid.length > 0) return {
570
+ ok: false,
571
+ level: "error",
572
+ code: "invalid-locale",
573
+ message: invalid.map((l) => `Locale "${l}" content is empty or invalid.`).join("\n")
574
+ };
575
+ return {
576
+ ok: true,
577
+ data: {
578
+ name: String(definition.name ?? ""),
579
+ locales: [...leaves.keys()],
580
+ leaves
581
+ }
582
+ };
583
+ } catch (error) {
584
+ return {
585
+ ok: false,
586
+ level: "error",
587
+ code: "invalid-dictionary",
588
+ message: `Failed to import dictionary: ${error.message || String(error)}`
589
+ };
590
+ }
591
+ }
592
+ async function loadDictionaryData(file, runtime) {
593
+ let astError;
594
+ try {
595
+ const df = await loadDictionaryFile(file);
596
+ if (df?.complete && df.name) return {
597
+ ok: true,
598
+ data: fromSyntaxTree(df)
599
+ };
600
+ } catch (error) {
601
+ astError = error;
602
+ }
603
+ if (runtime) return fromRuntime(file);
604
+ return astError ? {
605
+ ok: false,
606
+ level: "error",
607
+ code: "invalid-dictionary",
608
+ message: `Failed to parse dictionary: ${astError.message}`
609
+ } : {
610
+ ok: false,
611
+ level: "info",
612
+ code: "unanalyzable",
613
+ message: "This dictionary uses spreads, computed keys, imported values, or a computed name, so it cannot be analyzed statically."
614
+ };
615
+ }
616
+ async function analyzeProject(options) {
617
+ const { root, config } = options;
618
+ const include = config.include || "**/*.content.ts";
619
+ const configuredLocales = config.locales || [];
620
+ const defaultLocale = config.defaultLocale || "en";
621
+ const extraIgnore = options.ignore ?? [];
622
+ const files = fg.sync(include, {
623
+ cwd: root,
624
+ absolute: true,
625
+ ignore: [...DICTIONARY_IGNORE, ...extraIgnore]
626
+ });
627
+ const issues = [];
628
+ const dictionaries = [];
629
+ for (const file of files) {
630
+ const loaded = await loadDictionaryData(file, options.runtime === true);
631
+ if (!loaded.ok) {
632
+ for (const message of loaded.message.split("\n")) issues.push({
633
+ file,
634
+ level: loaded.level,
635
+ code: loaded.code,
636
+ message
637
+ });
638
+ continue;
639
+ }
640
+ const { data } = loaded;
641
+ const { df } = data;
642
+ const at = (locale, keyPath) => df && locale ? locationOf(df, locale, keyPath) : void 0;
643
+ const add = (issue) => issues.push({
644
+ ...issue,
645
+ file,
646
+ dictionary: data.name
647
+ });
648
+ for (const required of configuredLocales) if (!data.locales.includes(required)) add({
649
+ level: "error",
650
+ code: "missing-locale",
651
+ message: `Missing configured locale "${required}"`,
652
+ range: df ? dictionaryLocation(df) : void 0,
653
+ locale: required
654
+ });
655
+ const paths = [];
656
+ const seen = /* @__PURE__ */ new Set();
657
+ for (const locale of data.locales) for (const leafPath of data.leaves.get(locale).keys()) if (!seen.has(leafPath)) {
658
+ seen.add(leafPath);
659
+ paths.push(leafPath);
660
+ }
661
+ for (const locale of data.locales) {
662
+ const present = data.leaves.get(locale);
663
+ for (const leafPath of paths) {
664
+ if (present.has(leafPath)) continue;
665
+ const sourceLocale = [defaultLocale, ...data.locales].find((l) => l !== locale && data.leaves.get(l)?.has(leafPath));
666
+ add({
667
+ level: "error",
668
+ code: "missing-key",
669
+ message: `Locale "${locale}" is missing key "${leafPath}"`,
670
+ range: at(locale),
671
+ locale,
672
+ path: leafPath.split("."),
673
+ sourceLocale
674
+ });
675
+ }
676
+ }
677
+ const strings = /* @__PURE__ */ new Map();
678
+ for (const locale of data.locales) {
679
+ const entries = /* @__PURE__ */ new Map();
680
+ strings.set(locale, entries);
681
+ for (const [leafPath, leaf] of data.leaves.get(locale)) {
682
+ if (leaf.kind !== "string" || leaf.value === void 0) continue;
683
+ entries.set(leafPath, leaf.value);
684
+ if (leaf.value.startsWith("[TODO] ")) add({
685
+ level: "warning",
686
+ code: "todo-placeholder",
687
+ message: `Locale "${locale}" key "${leafPath}" still has a ${TODO_PREFIX.trim()} placeholder`,
688
+ range: at(locale, leafPath.split(".")),
689
+ locale,
690
+ path: leafPath.split(".")
691
+ });
692
+ }
693
+ }
694
+ for (const finding of checkIcu(data.name, strings, defaultLocale)) {
695
+ const keyPath = finding.key.slice(data.name.length + 1).split(".");
696
+ add({
697
+ level: finding.level,
698
+ code: finding.code,
699
+ message: finding.message,
700
+ range: at(finding.locale, keyPath),
701
+ locale: finding.locale,
702
+ path: keyPath
703
+ });
704
+ }
705
+ dictionaries.push({
706
+ name: data.name,
707
+ file,
708
+ locales: data.locales,
709
+ leaves: data.leaves,
710
+ paths,
711
+ df
712
+ });
713
+ }
714
+ const known = new Map(dictionaries.map((d) => [d.name, d]));
715
+ const dictionaryFiles = new Set(files);
716
+ const sourceGlobs = options.src ?? ["**/*.{ts,tsx,js,jsx,mjs,cjs,vue,svelte,astro,mdx}"];
717
+ const sourceFiles = sourceGlobs.length === 0 ? [] : fg.sync(sourceGlobs, {
718
+ cwd: root,
719
+ absolute: true,
720
+ ignore: [...SOURCE_IGNORE, ...extraIgnore]
721
+ }).filter((f) => !dictionaryFiles.has(f));
722
+ if (sourceFiles.length > 0) {
723
+ const tokens = /* @__PURE__ */ new Set();
724
+ for (const file of sourceFiles) {
725
+ let text;
726
+ try {
727
+ text = fs.readFileSync(file, "utf-8");
728
+ } catch {
729
+ continue;
730
+ }
731
+ for (const token of text.match(/[\w$-]+/g) ?? []) tokens.add(token);
732
+ const starts = lineStarts(text);
733
+ const rangeOf = (start, end) => ({
734
+ start: positionAt(starts, start),
735
+ end: positionAt(starts, end)
736
+ });
737
+ for (const ref of scanReferences(text)) {
738
+ if (ref.kind === "dictionary") {
739
+ if (!known.has(ref.value)) issues.push({
740
+ file,
741
+ level: "error",
742
+ code: "unknown-dictionary",
743
+ message: `Unknown dictionary "${ref.value}"`,
744
+ range: rangeOf(ref.start, ref.end),
745
+ dictionary: ref.value
746
+ });
747
+ continue;
748
+ }
749
+ const [name, ...rest] = ref.value.split(".");
750
+ const dictionary = known.get(name);
751
+ if (!dictionary) continue;
752
+ const keyPath = rest.join(".");
753
+ if (!dictionary.paths.some((p) => p === keyPath || p.startsWith(`${keyPath}.`))) issues.push({
754
+ file,
755
+ level: "error",
756
+ code: "unknown-path",
757
+ message: `Unknown translation path "${ref.value}"`,
758
+ range: rangeOf(ref.start, ref.end),
759
+ dictionary: name,
760
+ path: rest
761
+ });
762
+ }
763
+ }
764
+ if (options.unused !== false) for (const dictionary of dictionaries) {
765
+ if (!tokens.has(dictionary.name)) {
766
+ issues.push({
767
+ file: dictionary.file,
768
+ level: "warning",
769
+ code: "unused-dictionary",
770
+ message: `Dictionary "${dictionary.name}" is never referenced`,
771
+ range: dictionary.df ? dictionaryLocation(dictionary.df) : void 0,
772
+ dictionary: dictionary.name
773
+ });
774
+ continue;
775
+ }
776
+ for (const keyPath of dictionary.paths) {
777
+ const last = keyPath.split(".").pop();
778
+ if (tokens.has(last)) continue;
779
+ const holder = dictionary.locales.find((l) => dictionary.leaves.get(l)?.has(keyPath));
780
+ issues.push({
781
+ file: dictionary.file,
782
+ level: "warning",
783
+ code: "unused-key",
784
+ message: `Key "${keyPath}" is possibly unused`,
785
+ range: dictionary.df && holder ? locationOf(dictionary.df, holder, keyPath.split(".")) : void 0,
786
+ dictionary: dictionary.name,
787
+ path: keyPath.split(".")
788
+ });
789
+ }
790
+ }
791
+ }
792
+ return {
793
+ issues,
794
+ dictionaries,
795
+ files,
796
+ sourceFilesScanned: sourceFiles.length
797
+ };
798
+ }
799
+ /** Project root-relative path with forward slashes, for display. */
800
+ function relativeFile(root, file) {
801
+ return path.relative(root, file);
802
+ }
803
+ //#endregion
804
+ //#region src/utils/static-config.ts
805
+ const CONFIG_NAMES = ["dialex.config", "i18n.config"];
806
+ const EXTENSIONS = [
807
+ "ts",
808
+ "mts",
809
+ "cts",
810
+ "js",
811
+ "mjs",
812
+ "cjs",
813
+ "json"
814
+ ];
815
+ const DEFAULT_STATIC_CONFIG = {
816
+ defaultLocale: "en",
817
+ locales: [],
818
+ include: "**/*.content.ts",
819
+ fallbacks: {},
820
+ prefixDefault: true,
821
+ lazy: false
822
+ };
823
+ const NOT_LITERAL = Symbol("not literal");
824
+ function literal(node) {
825
+ node = unwrap(node);
826
+ switch (node?.type) {
827
+ case "StringLiteral":
828
+ case "NumericLiteral":
829
+ case "BooleanLiteral": return node.value;
830
+ case "Literal": return node.value;
831
+ case "TemplateLiteral": return node.expressions.length === 0 ? node.quasis[0]?.value?.cooked ?? "" : NOT_LITERAL;
832
+ case "ArrayExpression": {
833
+ const items = node.elements.map((e) => literal(e));
834
+ return items.includes(NOT_LITERAL) ? NOT_LITERAL : items;
835
+ }
836
+ case "ObjectExpression": {
837
+ const result = {};
838
+ for (const prop of node.properties) {
839
+ const key = keyName(prop);
840
+ const value = key === void 0 ? NOT_LITERAL : literal(prop.value);
841
+ if (value === NOT_LITERAL) return NOT_LITERAL;
842
+ result[key] = value;
843
+ }
844
+ return result;
845
+ }
846
+ default: return NOT_LITERAL;
847
+ }
848
+ }
849
+ /** The object literal passed to `defineConfig({...})`, or exported directly. */
850
+ function findConfigObject(declaration) {
851
+ const node = unwrap(declaration);
852
+ if (node?.type === "CallExpression") return findConfigObject(node.arguments[0]);
853
+ return isObject(node) ? node : void 0;
854
+ }
855
+ const isStringArray = (value) => Array.isArray(value) && value.every((v) => typeof v === "string");
856
+ function apply(object, config, notes) {
857
+ const accept = (key, valid, expected) => {
858
+ if (!(key in object)) return;
859
+ const value = object[key];
860
+ if (valid(value)) config[key] = value;
861
+ else notes.push(`"${key}" could not be read statically (expected ${expected}).`);
862
+ };
863
+ accept("defaultLocale", (v) => typeof v === "string" && v !== "", "a string literal");
864
+ accept("locales", isStringArray, "an array of string literals");
865
+ accept("include", (v) => typeof v === "string" || isStringArray(v), "a string or array of strings");
866
+ accept("fallbacks", (v) => typeof v === "object" && v !== null && Object.values(v).every((list) => isStringArray(list)), "an object of string arrays");
867
+ accept("prefixDefault", (v) => typeof v === "boolean", "true or false");
868
+ accept("lazy", (v) => typeof v === "boolean", "true or false");
869
+ }
870
+ /** Reads a config file's source text. Exposed for tests and for unsaved buffers. */
871
+ function parseStaticConfig(text, file = "dialex.config.ts") {
872
+ const notes = [];
873
+ const config = { ...DEFAULT_STATIC_CONFIG };
874
+ if (file.endsWith(".json")) {
875
+ try {
876
+ const data = JSON.parse(text);
877
+ if (data && typeof data === "object") apply(data, config, notes);
878
+ } catch (error) {
879
+ notes.push(`Could not parse ${path.basename(file)}: ${error.message}`);
880
+ }
881
+ return {
882
+ config,
883
+ file,
884
+ notes
885
+ };
886
+ }
887
+ try {
888
+ const exported = parseModule(text).exports.default;
889
+ const object = findConfigObject(exported?.$ast?.declaration ?? exported?.$ast);
890
+ if (!object) notes.push(`${path.basename(file)} does not export a config object literal.`);
891
+ else {
892
+ const values = {};
893
+ for (const key of [
894
+ "defaultLocale",
895
+ "locales",
896
+ "include",
897
+ "fallbacks",
898
+ "prefixDefault",
899
+ "lazy"
900
+ ]) {
901
+ const prop = findProp(object, key);
902
+ if (!prop) continue;
903
+ const value = literal(prop.value);
904
+ if (value === NOT_LITERAL) notes.push(`"${key}" could not be read statically (not a literal).`);
905
+ else values[key] = value;
906
+ }
907
+ apply(values, config, notes);
908
+ }
909
+ } catch (error) {
910
+ notes.push(`Could not parse ${path.basename(file)}: ${error.message}`);
911
+ }
912
+ return {
913
+ config,
914
+ file,
915
+ notes
916
+ };
917
+ }
918
+ /** Reads `dialex.config.*` (or `i18n.config.*`) from `root`, or from `configFile` if given. */
919
+ function readStaticConfig(root, configFile) {
920
+ const candidates = configFile ? [path.resolve(root, configFile)] : CONFIG_NAMES.flatMap((name) => EXTENSIONS.map((ext) => path.join(root, `${name}.${ext}`)));
921
+ for (const file of candidates) if (fs.existsSync(file)) return parseStaticConfig(fs.readFileSync(file, "utf-8"), file);
922
+ return {
923
+ config: { ...DEFAULT_STATIC_CONFIG },
924
+ notes: []
925
+ };
926
+ }
927
+ //#endregion
928
+ //#region src/utils/project.ts
929
+ async function loadDictionaries(root, config) {
930
+ const include = config.include || "**/*.content.ts";
931
+ const files = fg.sync(include, {
932
+ cwd: root,
933
+ absolute: true,
934
+ ignore: [
935
+ "**/node_modules/**",
936
+ "**/dist/**",
937
+ "**/.next/**"
938
+ ]
939
+ });
940
+ const dictionaries = [];
941
+ const unsupported = [];
942
+ for (const file of files) {
943
+ const rel = path.relative(root, file);
944
+ try {
945
+ const df = await loadDictionaryFile(file);
946
+ if (!df || !df.name) unsupported.push(rel);
947
+ else dictionaries.push({
948
+ rel,
949
+ name: df.name,
950
+ df
951
+ });
952
+ } catch {
953
+ unsupported.push(rel);
954
+ }
955
+ }
956
+ return {
957
+ dictionaries,
958
+ unsupported
959
+ };
960
+ }
961
+ /**
962
+ * Resolves the config (running `dialex.config.*` through the loader, which executes it) and loads
963
+ * every dictionary file for AST-level reading and editing.
964
+ */
965
+ async function loadProject(root, configFile) {
966
+ const config = resolveI18nConfig(root, configFile ? { configFile } : {});
967
+ return {
968
+ root,
969
+ config,
970
+ notes: [],
971
+ ...await loadDictionaries(root, config)
972
+ };
973
+ }
974
+ /**
975
+ * Like `loadProject`, but reads the config from the syntax tree without executing it. Use this in
976
+ * editors and anywhere project code must not run.
977
+ */
978
+ async function loadStaticProject(root, configFile) {
979
+ const { config, notes } = readStaticConfig(root, configFile);
980
+ return {
981
+ root,
982
+ config,
983
+ notes,
984
+ ...await loadDictionaries(root, config)
985
+ };
986
+ }
987
+ /** `home` + `["nav", "about"]` -> `home.nav.about` */
988
+ const toKey = (dictionary, keyPath) => [dictionary, ...keyPath].join(".");
989
+ //#endregion
990
+ export { saveDictionaryFile as C, logger as D, runGenerate as E, renderDictionaryFile as S, generateDictionaries as T, listLeaves as _, parseStaticConfig as a, locationOf as b, analyzeProject as c, TODO_PREFIX as d, copyLeaf as f, hasPath as g, getString as h, DEFAULT_STATIC_CONFIG as i, relativeFile as l, getLeafSource as m, loadStaticProject as n, readStaticConfig as o, dictionaryLocation as p, toKey as r, DEFAULT_SOURCE_GLOB as s, loadProject as t, scanReferences as u, listLocales as v, setString as w, parseDictionaryText as x, loadDictionaryFile as y };
991
+
992
+ //# sourceMappingURL=project-gkFBurqG.mjs.map