@dialexjs/cli 0.7.2 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
3
2
  import pc from "picocolors";
4
- import { pathToFileURL } from "node:url";
3
+ import fs from "node:fs";
5
4
  import { generateCode, parseExpression, parseModule, writeFile } from "magicast";
5
+ import { pathToFileURL } from "node:url";
6
6
  import fg from "fast-glob";
7
7
  import ignore from "ignore";
8
8
  import { argumentSignature, findPlurals, getArguments, isIcuStructured, parseMessage } from "dialexjs/icu";
@@ -10,60 +10,68 @@ import { createHash } from "node:crypto";
10
10
  import { DEFAULT_CONFIG } from "dialexjs";
11
11
  import { loadConfigSync } from "unconfig";
12
12
  import chokidar from "chokidar";
13
- //#region src/utils/dictionary-edit.ts
13
+ //#region src/utils/dictionary/types.ts
14
14
  const TODO_PREFIX = "[TODO] ";
15
- const IDENTIFIER$1 = /^[A-Za-z_$][\w$]*$/;
16
- function unwrap(node) {
15
+ //#endregion
16
+ //#region src/utils/ast.ts
17
+ /** Strips `as`, `satisfies` and parentheses around an expression. */
18
+ function unwrapNode(node) {
17
19
  while (node && (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "ParenthesizedExpression")) node = node.expression;
18
20
  return node;
19
21
  }
20
- const isObject = (node) => node?.type === "ObjectExpression";
21
- function keyName(prop) {
22
+ const isObjectNode = (node) => node?.type === "ObjectExpression";
23
+ /** The static name of a property key, or `undefined` when it is computed. */
24
+ function propertyKeyName(prop) {
22
25
  const key = prop?.key;
23
26
  if (!key || prop.computed) return void 0;
24
27
  if (key.type === "Identifier") return key.name;
25
28
  if (key.type === "StringLiteral" || key.type === "Literal" && typeof key.value === "string") return key.value;
26
29
  if (key.type === "NumericLiteral") return String(key.value);
27
30
  }
28
- const isPropLike = (p) => p?.type === "ObjectProperty" || p?.type === "Property" || p?.type === "ObjectMethod";
29
- function findProp(obj, key) {
30
- return obj.properties.find((p) => isPropLike(p) && keyName(p) === key);
31
+ const isPropLike$1 = (p) => p?.type === "ObjectProperty" || p?.type === "Property" || p?.type === "ObjectMethod";
32
+ /** The first property of `obj` with the static name `key`. */
33
+ function findProperty(obj, key) {
34
+ return obj.properties.find((p) => isPropLike$1(p) && propertyKeyName(p) === key);
31
35
  }
36
+ //#endregion
37
+ //#region src/utils/dictionary/ast-helpers.ts
38
+ const IDENTIFIER$1 = /^[A-Za-z_$][\w$]*$/;
39
+ const isPropLike = (p) => p?.type === "ObjectProperty" || p?.type === "Property" || p?.type === "ObjectMethod";
32
40
  function stringValue(node) {
33
- node = unwrap(node);
41
+ node = unwrapNode(node);
34
42
  if (node?.type === "StringLiteral") return node.value;
35
43
  if (node?.type === "Literal" && typeof node.value === "string") return node.value;
36
44
  if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked ?? void 0;
37
45
  }
38
46
  function leafKind(node) {
39
- node = unwrap(node);
47
+ node = unwrapNode(node);
40
48
  if (stringValue(node) !== void 0) return "string";
41
49
  if (node?.type === "ArrowFunctionExpression" || node?.type === "FunctionExpression" || node?.type === "ObjectMethod") return "function";
42
50
  return "other";
43
51
  }
44
52
  function dictionaryProp(obj) {
45
- const value = unwrap(findProp(obj, "dictionary")?.value);
46
- return isObject(value) ? value : void 0;
53
+ const value = unwrapNode(findProperty(obj, "dictionary")?.value);
54
+ return isObjectNode(value) ? value : void 0;
47
55
  }
48
56
  function locateName(declaration) {
49
- const decl = unwrap(declaration);
57
+ const decl = unwrapNode(declaration);
50
58
  if (decl?.type === "CallExpression") {
51
- const first = unwrap(decl.arguments[0]);
52
- if (isObject(first)) return stringValue(findProp(first, "name")?.value);
59
+ const first = unwrapNode(decl.arguments[0]);
60
+ if (isObjectNode(first)) return stringValue(findProperty(first, "name")?.value);
53
61
  return stringValue(first);
54
62
  }
55
- if (isObject(decl)) return stringValue(findProp(decl, "name")?.value);
63
+ if (isObjectNode(decl)) return stringValue(findProperty(decl, "name")?.value);
56
64
  }
57
65
  function locateLocalesObject(declaration) {
58
- const decl = unwrap(declaration);
66
+ const decl = unwrapNode(declaration);
59
67
  if (decl?.type === "CallExpression") {
60
- const first = unwrap(decl.arguments[0]);
61
- const second = unwrap(decl.arguments[1]);
62
- if (isObject(second)) return second;
63
- if (isObject(first)) return dictionaryProp(first);
68
+ const first = unwrapNode(decl.arguments[0]);
69
+ const second = unwrapNode(decl.arguments[1]);
70
+ if (isObjectNode(second)) return second;
71
+ if (isObjectNode(first)) return dictionaryProp(first);
64
72
  return;
65
73
  }
66
- if (isObject(decl)) return dictionaryProp(decl);
74
+ if (isObjectNode(decl)) return dictionaryProp(decl);
67
75
  }
68
76
  /** Quotes a property key only when it is not a valid identifier. */
69
77
  function keyCode(key) {
@@ -82,17 +90,19 @@ function makeStringNode(value) {
82
90
  */
83
91
  function isStaticallyComplete(localesObj) {
84
92
  const walk = (obj) => obj.properties.every((prop) => {
85
- if (!isPropLike(prop) || keyName(prop) === void 0) return false;
93
+ if (!isPropLike(prop) || propertyKeyName(prop) === void 0) return false;
86
94
  if (prop.type === "ObjectMethod") return true;
87
- const value = unwrap(prop.value);
88
- return isObject(value) ? walk(value) : true;
95
+ const value = unwrapNode(prop.value);
96
+ return isObjectNode(value) ? walk(value) : true;
89
97
  });
90
98
  return localesObj.properties.every((prop) => {
91
- if (!isPropLike(prop) || keyName(prop) === void 0 || prop.type === "ObjectMethod") return false;
92
- const value = unwrap(prop.value);
93
- return isObject(value) && walk(value);
99
+ if (!isPropLike(prop) || propertyKeyName(prop) === void 0 || prop.type === "ObjectMethod") return false;
100
+ const value = unwrapNode(prop.value);
101
+ return isObjectNode(value) && walk(value);
94
102
  });
95
103
  }
104
+ //#endregion
105
+ //#region src/utils/dictionary/io.ts
96
106
  /**
97
107
  * Parses dictionary source text (for example an unsaved editor buffer). Returns `undefined` when
98
108
  * the file does not have an editable shape; throws on syntax errors.
@@ -128,12 +138,14 @@ async function saveDictionaryFile(df) {
128
138
  if (!text.endsWith("\n")) fs.writeFileSync(df.file, text + "\n", "utf-8");
129
139
  }
130
140
  }
141
+ //#endregion
142
+ //#region src/utils/dictionary/read.ts
131
143
  function listLocales(df) {
132
- return df.localesObj.properties.filter(isPropLike).map(keyName).filter((k) => k !== void 0);
144
+ return df.localesObj.properties.filter(isPropLike).map(propertyKeyName).filter((k) => k !== void 0);
133
145
  }
134
146
  function localeObject(df, locale) {
135
- const value = unwrap(findProp(df.localesObj, locale)?.value);
136
- return isObject(value) ? value : void 0;
147
+ const value = unwrapNode(findProperty(df.localesObj, locale)?.value);
148
+ return isObjectNode(value) ? value : void 0;
137
149
  }
138
150
  /** Lists every leaf (string, function, or other value) of a locale, in source order. */
139
151
  function listLeaves(df, locale) {
@@ -143,11 +155,11 @@ function listLeaves(df, locale) {
143
155
  const walk = (obj, prefix) => {
144
156
  for (const prop of obj.properties) {
145
157
  if (!isPropLike(prop)) continue;
146
- const name = keyName(prop);
158
+ const name = propertyKeyName(prop);
147
159
  if (name === void 0) continue;
148
160
  const path = [...prefix, name];
149
- const value = prop.type === "ObjectMethod" ? prop : unwrap(prop.value);
150
- if (isObject(value)) walk(value, path);
161
+ const value = prop.type === "ObjectMethod" ? prop : unwrapNode(prop.value);
162
+ if (isObjectNode(value)) walk(value, path);
151
163
  else {
152
164
  const kind = leafKind(value);
153
165
  leaves.push({
@@ -164,11 +176,11 @@ function listLeaves(df, locale) {
164
176
  function getProp(df, locale, path) {
165
177
  let obj = localeObject(df, locale);
166
178
  for (let i = 0; obj && i < path.length; i++) {
167
- const prop = findProp(obj, path[i]);
179
+ const prop = findProperty(obj, path[i]);
168
180
  if (!prop) return void 0;
169
181
  if (i === path.length - 1) return prop;
170
- const next = unwrap(prop.value);
171
- obj = isObject(next) ? next : void 0;
182
+ const next = unwrapNode(prop.value);
183
+ obj = isObjectNode(next) ? next : void 0;
172
184
  }
173
185
  }
174
186
  function hasPath(df, locale, path) {
@@ -178,6 +190,13 @@ function getString(df, locale, path) {
178
190
  const prop = getProp(df, locale, path);
179
191
  return prop && prop.type !== "ObjectMethod" ? stringValue(prop.value) : void 0;
180
192
  }
193
+ /** Leaf paths of a runtime dictionary value (plain objects recurse; everything else is a leaf). */
194
+ function flattenPaths(value, prefix = []) {
195
+ if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([k, v]) => flattenPaths(v, [...prefix, k]));
196
+ return prefix.length > 0 ? [prefix] : [];
197
+ }
198
+ //#endregion
199
+ //#region src/utils/dictionary/write.ts
181
200
  function ensureLocale(df, locale) {
182
201
  let obj = localeObject(df, locale);
183
202
  if (!obj) {
@@ -191,13 +210,13 @@ function ensureLocale(df, locale) {
191
210
  function ensureParent(df, locale, path) {
192
211
  let obj = ensureLocale(df, locale);
193
212
  for (const segment of path.slice(0, -1)) {
194
- let prop = findProp(obj, segment);
213
+ let prop = findProperty(obj, segment);
195
214
  if (!prop) {
196
215
  prop = makeProp(segment, "{}");
197
216
  obj.properties.push(prop);
198
217
  }
199
- const next = unwrap(prop.value);
200
- if (!isObject(next)) return void 0;
218
+ const next = unwrapNode(prop.value);
219
+ if (!isObjectNode(next)) return void 0;
201
220
  obj = next;
202
221
  }
203
222
  return obj;
@@ -210,7 +229,7 @@ function setString(df, locale, path, value) {
210
229
  const parent = ensureParent(df, locale, path);
211
230
  if (!parent) return "skipped";
212
231
  const last = path[path.length - 1];
213
- const existing = findProp(parent, last);
232
+ const existing = findProperty(parent, last);
214
233
  if (existing) {
215
234
  if (existing.type === "ObjectMethod" || leafKind(existing.value) !== "string") return "skipped";
216
235
  existing.value = makeStringNode(value);
@@ -239,11 +258,8 @@ function copyLeaf(df, fromLocale, toLocale, path, mapString) {
239
258
  }
240
259
  return true;
241
260
  }
242
- /** Leaf paths of a runtime dictionary value (plain objects recurse; everything else is a leaf). */
243
- function flattenPaths(value, prefix = []) {
244
- if (value && typeof value === "object" && !Array.isArray(value)) return Object.entries(value).flatMap(([k, v]) => flattenPaths(v, [...prefix, k]));
245
- return prefix.length > 0 ? [prefix] : [];
246
- }
261
+ //#endregion
262
+ //#region src/utils/dictionary/locations.ts
247
263
  const toRange = (loc) => loc ? {
248
264
  start: {
249
265
  line: loc.start.line - 1,
@@ -259,7 +275,7 @@ const toRange = (loc) => loc ? {
259
275
  * it is a good place to attach a diagnostic or jump to.
260
276
  */
261
277
  function locationOf(df, locale, path = []) {
262
- const prop = path.length === 0 ? findProp(df.localesObj, locale) : getProp(df, locale, path);
278
+ const prop = path.length === 0 ? findProperty(df.localesObj, locale) : getProp(df, locale, path);
263
279
  return toRange((prop?.key ?? prop)?.loc);
264
280
  }
265
281
  /** The source text of the dictionary object itself (the object whose keys are locales). */
@@ -273,6 +289,153 @@ function getLeafSource(df, locale, path) {
273
289
  return generateCode(prop.type === "ObjectMethod" ? prop : prop.value).code;
274
290
  }
275
291
  //#endregion
292
+ //#region src/analysis/cache.ts
293
+ function createAnalysisCache() {
294
+ return {
295
+ dictionaries: /* @__PURE__ */ new Map(),
296
+ sources: /* @__PURE__ */ new Map(),
297
+ gitignore: /* @__PURE__ */ new Map()
298
+ };
299
+ }
300
+ /** Forget the cached `.gitignore` rules, for example after a `.gitignore` file changed. */
301
+ function clearGitignore(cache) {
302
+ cache.gitignore.clear();
303
+ }
304
+ function stampOf(file) {
305
+ try {
306
+ const stat = fs.statSync(file);
307
+ return `${stat.mtimeMs}:${stat.size}`;
308
+ } catch {
309
+ return;
310
+ }
311
+ }
312
+ //#endregion
313
+ //#region src/analysis/load-dictionary.ts
314
+ function fromSyntaxTree(df) {
315
+ const locales = listLocales(df);
316
+ const leaves = /* @__PURE__ */ new Map();
317
+ for (const locale of locales) {
318
+ const entries = /* @__PURE__ */ new Map();
319
+ for (const leaf of listLeaves(df, locale)) entries.set(leaf.path.join("."), {
320
+ kind: leaf.kind,
321
+ value: leaf.value
322
+ });
323
+ leaves.set(locale, entries);
324
+ }
325
+ return {
326
+ name: df.name,
327
+ locales,
328
+ leaves,
329
+ df
330
+ };
331
+ }
332
+ async function fromRuntime(file) {
333
+ try {
334
+ const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}_${Math.random()}`);
335
+ const definition = mod.default || mod;
336
+ if (!definition || typeof definition !== "object" || !definition.dictionary) return {
337
+ ok: false,
338
+ level: "error",
339
+ code: "invalid-dictionary",
340
+ message: "Default export does not appear to be a valid defineDictionary output."
341
+ };
342
+ const dictionary = definition.dictionary;
343
+ const leaves = /* @__PURE__ */ new Map();
344
+ const invalid = [];
345
+ for (const [locale, content] of Object.entries(dictionary)) {
346
+ if (!content || typeof content !== "object") {
347
+ invalid.push(locale);
348
+ continue;
349
+ }
350
+ const entries = /* @__PURE__ */ new Map();
351
+ for (const leafPath of flattenPaths(content)) {
352
+ let value = content;
353
+ for (const segment of leafPath) value = value?.[segment];
354
+ entries.set(leafPath.join("."), typeof value === "string" ? {
355
+ kind: "string",
356
+ value
357
+ } : { kind: typeof value === "function" ? "function" : "other" });
358
+ }
359
+ leaves.set(locale, entries);
360
+ }
361
+ if (invalid.length > 0) return {
362
+ ok: false,
363
+ level: "error",
364
+ code: "invalid-locale",
365
+ message: invalid.map((l) => `Locale "${l}" content is empty or invalid.`).join("\n")
366
+ };
367
+ return {
368
+ ok: true,
369
+ data: {
370
+ name: String(definition.name ?? ""),
371
+ locales: [...leaves.keys()],
372
+ leaves
373
+ }
374
+ };
375
+ } catch (error) {
376
+ return {
377
+ ok: false,
378
+ level: "error",
379
+ code: "invalid-dictionary",
380
+ message: `Failed to import dictionary: ${error.message || String(error)}`
381
+ };
382
+ }
383
+ }
384
+ function fromParsed(parsed) {
385
+ try {
386
+ const df = parsed();
387
+ if (df?.complete && df.name) return {
388
+ ok: true,
389
+ data: fromSyntaxTree(df)
390
+ };
391
+ } catch (error) {
392
+ return {
393
+ ok: false,
394
+ level: "error",
395
+ code: "invalid-dictionary",
396
+ message: `Failed to parse dictionary: ${error.message}`
397
+ };
398
+ }
399
+ }
400
+ const UNANALYZABLE = {
401
+ ok: false,
402
+ level: "info",
403
+ code: "unanalyzable",
404
+ message: "This dictionary uses spreads, computed keys, imported values, or a computed name, so it cannot be analyzed statically."
405
+ };
406
+ /** Reads a dictionary from its syntax tree. Never runs project code. */
407
+ function loadDictionaryDataStatic(file) {
408
+ return fromParsed(() => parseDictionaryText(file, fs.readFileSync(file, "utf-8"))) ?? UNANALYZABLE;
409
+ }
410
+ async function loadDictionaryData(file, runtime) {
411
+ const parsed = fromParsed(() => parseDictionaryText(file, fs.readFileSync(file, "utf-8")));
412
+ if (parsed?.ok) return parsed;
413
+ if (runtime) return fromRuntime(file);
414
+ return parsed ?? UNANALYZABLE;
415
+ }
416
+ async function cachedDictionary(file, runtime, cache) {
417
+ const stamp = cache ? stampOf(file) : void 0;
418
+ const hit = stamp ? cache.dictionaries.get(file) : void 0;
419
+ if (hit && hit.stamp === stamp) return hit.loaded;
420
+ const loaded = await loadDictionaryData(file, runtime);
421
+ if (cache && stamp) cache.dictionaries.set(file, {
422
+ stamp,
423
+ loaded
424
+ });
425
+ return loaded;
426
+ }
427
+ function cachedDictionaryStatic(file, cache) {
428
+ const stamp = cache ? stampOf(file) : void 0;
429
+ const hit = stamp ? cache.dictionaries.get(file) : void 0;
430
+ if (hit && hit.stamp === stamp) return hit.loaded;
431
+ const loaded = loadDictionaryDataStatic(file);
432
+ if (cache && stamp) cache.dictionaries.set(file, {
433
+ stamp,
434
+ loaded
435
+ });
436
+ return loaded;
437
+ }
438
+ //#endregion
276
439
  //#region src/utils/files.ts
277
440
  /** Folders that never hold project dictionaries or sources, in every scanner. */
278
441
  const BUILTIN_IGNORE = [
@@ -517,29 +680,121 @@ function lockFor(root, sourceLocale) {
517
680
  };
518
681
  }
519
682
  //#endregion
520
- //#region src/analysis.ts
521
- function createAnalysisCache() {
522
- return {
523
- dictionaries: /* @__PURE__ */ new Map(),
524
- sources: /* @__PURE__ */ new Map(),
525
- gitignore: /* @__PURE__ */ new Map()
526
- };
527
- }
528
- /** Forget the cached `.gitignore` rules, for example after a `.gitignore` file changed. */
529
- function clearGitignore(cache) {
530
- cache.gitignore.clear();
531
- }
683
+ //#region src/analysis/source-scan.ts
532
684
  const DEFAULT_SOURCE_GLOB = "**/*.{ts,tsx,js,jsx,mjs,cjs,vue,svelte,astro,mdx}";
533
685
  /** `getDictionary("x")`, `useDictionary("x")`, `@DialexDictionary("x")` */
534
686
  const DICTIONARY_CALL = /\b(?:getDictionary|useDictionary|DialexDictionary)\(\s*(["'`])([^"'`$\n]+)\1/dg;
535
687
  /** `t("dictionary.path.to.key")` */
536
688
  const T_CALL = /(?<![\w$.])t\(\s*(["'`])([\w-]+(?:\.[\w-]+)+)\1/dg;
537
- /** Finds `getDictionary("x")`-style and `t("x.y.z")` references in source text. */
538
- function scanReferences(text) {
689
+ /**
690
+ * Offsets of `//`, `/* *\/` and `<!-- -->` comments. Quoted strings are skipped so `"http://x"`
691
+ * is not a comment; `'` and `"` end at a newline, template literals do not.
692
+ */
693
+ function commentRanges(text) {
694
+ const ranges = [];
695
+ const length = text.length;
696
+ let i = 0;
697
+ while (i < length) {
698
+ const c = text[i];
699
+ if (c === "\"" || c === "'" || c === "`") {
700
+ i++;
701
+ while (i < length && text[i] !== c && (c === "`" || text[i] !== "\n")) {
702
+ if (text[i] === "\\") i++;
703
+ i++;
704
+ }
705
+ i++;
706
+ continue;
707
+ }
708
+ let close;
709
+ if (c === "/" && text[i + 1] === "/") close = "\n";
710
+ else if (c === "/" && text[i + 1] === "*") close = "*/";
711
+ else if (c === "<" && text.startsWith("<!--", i)) close = "-->";
712
+ if (close === void 0) {
713
+ i++;
714
+ continue;
715
+ }
716
+ const found = text.indexOf(close, i + 2);
717
+ const end = found === -1 ? length : close === "\n" ? found : found + close.length;
718
+ ranges.push([i, end]);
719
+ i = end;
720
+ }
721
+ return ranges;
722
+ }
723
+ const IGNORE_DIRECTIVE = /dialex-ignore(-next-line)?(?![\w-])/;
724
+ /** `dictionaries: { name: ..., "other-name": ..., shorthand }`, up to the first key. */
725
+ const DICTIONARIES_OBJECT = /\bdictionaries\s*:\s*\{/g;
726
+ const OBJECT_KEY = /\s*(?:([A-Za-z_$][\w$-]*)|(["'])([^"'\n]+)\2)\s*(?=[:,}])/y;
727
+ /** Replaces comments with spaces of the same length, keeping offsets and line breaks. */
728
+ function maskComments(text, comments) {
729
+ if (comments.length === 0) return text;
730
+ let out = "";
731
+ let at = 0;
732
+ for (const [from, to] of comments) {
733
+ out += text.slice(at, from) + text.slice(from, to).replace(/[^\n]/g, " ");
734
+ at = to;
735
+ }
736
+ return out + text.slice(at);
737
+ }
738
+ /** Names that an object literal `dictionaries: { ... }` registers inline. */
739
+ function inlineDictionaryNames(masked) {
740
+ const names = /* @__PURE__ */ new Set();
741
+ for (const match of masked.matchAll(DICTIONARIES_OBJECT)) {
742
+ let i = match.index + match[0].length;
743
+ let depth = 1;
744
+ let expectKey = true;
745
+ while (i < masked.length && depth > 0) {
746
+ if (expectKey && depth === 1) {
747
+ OBJECT_KEY.lastIndex = i;
748
+ const key = OBJECT_KEY.exec(masked);
749
+ if (key) {
750
+ names.add(key[1] ?? key[3]);
751
+ i = OBJECT_KEY.lastIndex;
752
+ }
753
+ expectKey = false;
754
+ continue;
755
+ }
756
+ const c = masked[i];
757
+ if (c === "\"" || c === "'" || c === "`") {
758
+ i++;
759
+ while (i < masked.length && masked[i] !== c && (c === "`" || masked[i] !== "\n")) {
760
+ if (masked[i] === "\\") i++;
761
+ i++;
762
+ }
763
+ } else if (c === "{" || c === "[" || c === "(") depth++;
764
+ else if (c === "}" || c === "]" || c === ")") depth--;
765
+ else if (c === "," && depth === 1) expectKey = true;
766
+ i++;
767
+ }
768
+ }
769
+ return [...names];
770
+ }
771
+ /** Lines that a `dialex-ignore` / `dialex-ignore-next-line` comment excludes from checking. */
772
+ function ignoredLines(text, comments) {
773
+ const lines = /* @__PURE__ */ new Set();
774
+ if (!text.includes("dialex-ignore")) return lines;
775
+ const starts = lineStarts(text);
776
+ for (const [from, to] of comments) {
777
+ const directive = IGNORE_DIRECTIVE.exec(text.slice(from, to));
778
+ if (!directive) continue;
779
+ const first = positionAt(starts, from).line;
780
+ const last = positionAt(starts, Math.max(from, to - 1)).line;
781
+ if (directive[1]) lines.add(last + 1);
782
+ else for (let line = first; line <= last; line++) lines.add(line);
783
+ }
784
+ return lines;
785
+ }
786
+ /** References and inline dictionary names found in one source file. */
787
+ function scanText(text) {
788
+ const comments = commentRanges(text);
789
+ const ignored = ignoredLines(text, comments);
790
+ const starts = ignored.size > 0 ? lineStarts(text) : [];
791
+ const inComment = (offset) => comments.some(([from, to]) => offset >= from && offset < to);
539
792
  const references = [];
540
793
  const collect = (pattern, kind) => {
541
794
  for (const match of text.matchAll(pattern)) {
542
795
  const [start, end] = match.indices[2];
796
+ if (comments.length > 0 && inComment(start)) continue;
797
+ if (ignored.size > 0 && ignored.has(positionAt(starts, start).line)) continue;
543
798
  references.push({
544
799
  kind,
545
800
  value: match[2],
@@ -550,7 +805,18 @@ function scanReferences(text) {
550
805
  };
551
806
  collect(DICTIONARY_CALL, "dictionary");
552
807
  collect(T_CALL, "path");
553
- return references.sort((a, b) => a.start - b.start);
808
+ references.sort((a, b) => a.start - b.start);
809
+ return {
810
+ references,
811
+ inline: text.includes("dictionaries") ? inlineDictionaryNames(maskComments(text, comments)) : []
812
+ };
813
+ }
814
+ /**
815
+ * Finds `getDictionary("x")`-style and `t("x.y.z")` references in source text. References inside
816
+ * comments, and on lines a `dialex-ignore` comment covers, are skipped.
817
+ */
818
+ function scanReferences(text) {
819
+ return scanText(text).references;
554
820
  }
555
821
  function lineStarts(text) {
556
822
  const starts = [0];
@@ -570,134 +836,34 @@ function positionAt(starts, offset) {
570
836
  column: offset - starts[low]
571
837
  };
572
838
  }
573
- function fromSyntaxTree(df) {
574
- const locales = listLocales(df);
575
- const leaves = /* @__PURE__ */ new Map();
576
- for (const locale of locales) {
577
- const entries = /* @__PURE__ */ new Map();
578
- for (const leaf of listLeaves(df, locale)) entries.set(leaf.path.join("."), {
579
- kind: leaf.kind,
580
- value: leaf.value
581
- });
582
- leaves.set(locale, entries);
583
- }
584
- return {
585
- name: df.name,
586
- locales,
587
- leaves,
588
- df
589
- };
590
- }
591
- async function fromRuntime(file) {
592
- try {
593
- const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}_${Math.random()}`);
594
- const definition = mod.default || mod;
595
- if (!definition || typeof definition !== "object" || !definition.dictionary) return {
596
- ok: false,
597
- level: "error",
598
- code: "invalid-dictionary",
599
- message: "Default export does not appear to be a valid defineDictionary output."
600
- };
601
- const dictionary = definition.dictionary;
602
- const leaves = /* @__PURE__ */ new Map();
603
- const invalid = [];
604
- for (const [locale, content] of Object.entries(dictionary)) {
605
- if (!content || typeof content !== "object") {
606
- invalid.push(locale);
607
- continue;
608
- }
609
- const entries = /* @__PURE__ */ new Map();
610
- for (const leafPath of flattenPaths(content)) {
611
- let value = content;
612
- for (const segment of leafPath) value = value?.[segment];
613
- entries.set(leafPath.join("."), typeof value === "string" ? {
614
- kind: "string",
615
- value
616
- } : { kind: typeof value === "function" ? "function" : "other" });
617
- }
618
- leaves.set(locale, entries);
619
- }
620
- if (invalid.length > 0) return {
621
- ok: false,
622
- level: "error",
623
- code: "invalid-locale",
624
- message: invalid.map((l) => `Locale "${l}" content is empty or invalid.`).join("\n")
625
- };
626
- return {
627
- ok: true,
628
- data: {
629
- name: String(definition.name ?? ""),
630
- locales: [...leaves.keys()],
631
- leaves
632
- }
633
- };
634
- } catch (error) {
635
- return {
636
- ok: false,
637
- level: "error",
638
- code: "invalid-dictionary",
639
- message: `Failed to import dictionary: ${error.message || String(error)}`
640
- };
641
- }
642
- }
643
- async function loadDictionaryData(file, runtime) {
644
- let astError;
645
- try {
646
- const df = await loadDictionaryFile(file);
647
- if (df?.complete && df.name) return {
648
- ok: true,
649
- data: fromSyntaxTree(df)
650
- };
651
- } catch (error) {
652
- astError = error;
653
- }
654
- if (runtime) return fromRuntime(file);
655
- return astError ? {
656
- ok: false,
657
- level: "error",
658
- code: "invalid-dictionary",
659
- message: `Failed to parse dictionary: ${astError.message}`
660
- } : {
661
- ok: false,
662
- level: "info",
663
- code: "unanalyzable",
664
- message: "This dictionary uses spreads, computed keys, imported values, or a computed name, so it cannot be analyzed statically."
665
- };
666
- }
667
- function stampOf(file) {
668
- try {
669
- const stat = fs.statSync(file);
670
- return `${stat.mtimeMs}:${stat.size}`;
671
- } catch {
672
- return;
673
- }
674
- }
675
- async function cachedDictionary(file, runtime, cache) {
676
- const stamp = cache ? stampOf(file) : void 0;
677
- const hit = stamp ? cache.dictionaries.get(file) : void 0;
678
- if (hit && hit.stamp === stamp) return hit.loaded;
679
- const loaded = await loadDictionaryData(file, runtime);
680
- if (cache && stamp) cache.dictionaries.set(file, {
681
- stamp,
682
- loaded
683
- });
684
- return loaded;
685
- }
686
839
  function scanSource(text) {
687
840
  const starts = lineStarts(text);
688
841
  const rangeOf = (start, end) => ({
689
842
  start: positionAt(starts, start),
690
843
  end: positionAt(starts, end)
691
844
  });
845
+ const { references, inline } = scanText(text);
692
846
  return {
693
847
  tokens: [...new Set(text.match(/[\w$-]+/g) ?? [])],
694
- refs: scanReferences(text).map((ref) => ({
848
+ refs: references.map((ref) => ({
695
849
  ref,
696
850
  range: rangeOf(ref.start, ref.end)
697
- }))
851
+ })),
852
+ inline
698
853
  };
699
854
  }
700
- async function analyzeProject(options) {
855
+ //#endregion
856
+ //#region src/analysis/steps.ts
857
+ function lintLevel(setting) {
858
+ const level = Array.isArray(setting) ? setting[0] : setting;
859
+ return level === "off" || level === "warn" || level === "error" ? level : void 0;
860
+ }
861
+ /**
862
+ * The analysis itself, written once. It yields a `LoadRequest` for every dictionary so that a
863
+ * synchronous driver (`analyzeProjectSync`) and an asynchronous one (`analyzeProject`, which may
864
+ * import dictionaries at runtime) can share it.
865
+ */
866
+ function* analysisSteps(options) {
701
867
  const { root, config } = options;
702
868
  const include = config.include || "**/*.content.ts";
703
869
  const configuredLocales = config.locales || [];
@@ -718,7 +884,12 @@ async function analyzeProject(options) {
718
884
  const dictionaries = [];
719
885
  const lock = options.lock === false ? void 0 : readLock(root);
720
886
  for (const file of files) {
721
- const loaded = await cachedDictionary(file, options.runtime === true, cache);
887
+ const loaded = yield {
888
+ file,
889
+ runtime: options.runtime === true,
890
+ cache,
891
+ text: options.contents?.get(file)
892
+ };
722
893
  if (!loaded.ok) {
723
894
  for (const message of loaded.message.split("\n")) issues.push({
724
895
  file,
@@ -831,8 +1002,10 @@ async function analyzeProject(options) {
831
1002
  ignore: extraIgnore,
832
1003
  gitignoreFilter: gitignore
833
1004
  }).filter((f) => !dictionaryFiles.has(f));
1005
+ const inline = /* @__PURE__ */ new Set();
834
1006
  if (sourceFiles.length > 0) {
835
1007
  const tokens = /* @__PURE__ */ new Set();
1008
+ const scans = [];
836
1009
  for (const file of sourceFiles) {
837
1010
  const stamp = cache ? stampOf(file) : void 0;
838
1011
  let scan = stamp ? cache.sources.get(file) : void 0;
@@ -850,32 +1023,37 @@ async function analyzeProject(options) {
850
1023
  if (cache && stamp) cache.sources.set(file, scan);
851
1024
  }
852
1025
  for (const token of scan.scan.tokens) tokens.add(token);
853
- for (const { ref, range } of scan.scan.refs) {
854
- if (ref.kind === "dictionary") {
855
- if (!known.has(ref.value)) issues.push({
856
- file,
857
- level: "error",
858
- code: "unknown-dictionary",
859
- message: `Unknown dictionary "${ref.value}"`,
860
- range,
861
- dictionary: ref.value
862
- });
863
- continue;
864
- }
865
- const [name, ...rest] = ref.value.split(".");
866
- const dictionary = known.get(name);
867
- if (!dictionary) continue;
868
- const keyPath = rest.join(".");
869
- if (!dictionary.paths.some((p) => p === keyPath || p.startsWith(`${keyPath}.`))) issues.push({
1026
+ scans.push({
1027
+ file,
1028
+ scan: scan.scan
1029
+ });
1030
+ }
1031
+ for (const { scan } of scans) for (const name of scan.inline) inline.add(name);
1032
+ for (const { file, scan } of scans) for (const { ref, range } of scan.refs) {
1033
+ if (ref.kind === "dictionary") {
1034
+ if (!known.has(ref.value) && !inline.has(ref.value)) issues.push({
870
1035
  file,
871
1036
  level: "error",
872
- code: "unknown-path",
873
- message: `Unknown translation path "${ref.value}"`,
1037
+ code: "unknown-dictionary",
1038
+ message: `Unknown dictionary "${ref.value}"`,
874
1039
  range,
875
- dictionary: name,
876
- path: rest
1040
+ dictionary: ref.value
877
1041
  });
1042
+ continue;
878
1043
  }
1044
+ const [name, ...rest] = ref.value.split(".");
1045
+ const dictionary = known.get(name);
1046
+ if (!dictionary) continue;
1047
+ const keyPath = rest.join(".");
1048
+ if (!dictionary.paths.some((p) => p === keyPath || p.startsWith(`${keyPath}.`))) issues.push({
1049
+ file,
1050
+ level: "error",
1051
+ code: "unknown-path",
1052
+ message: `Unknown translation path "${ref.value}"`,
1053
+ range,
1054
+ dictionary: name,
1055
+ path: rest
1056
+ });
879
1057
  }
880
1058
  if (options.unused !== false) for (const dictionary of dictionaries) {
881
1059
  if (!tokens.has(dictionary.name)) {
@@ -909,13 +1087,49 @@ async function analyzeProject(options) {
909
1087
  const live = /* @__PURE__ */ new Set([...files, ...sourceFiles]);
910
1088
  for (const map of [cache.dictionaries, cache.sources]) for (const key of map.keys()) if (key.startsWith(root) && !live.has(key)) map.delete(key);
911
1089
  }
1090
+ const rules = config.lint?.rules;
1091
+ if (rules && Object.keys(rules).length > 0) for (let i = issues.length - 1; i >= 0; i--) {
1092
+ const level = lintLevel(rules[issues[i].code]);
1093
+ if (!level) continue;
1094
+ if (level === "off") issues.splice(i, 1);
1095
+ else issues[i].level = level === "error" ? "error" : "warning";
1096
+ }
912
1097
  return {
913
1098
  issues,
914
1099
  dictionaries,
915
1100
  files,
916
- sourceFilesScanned: sourceFiles.length
1101
+ sourceFilesScanned: sourceFiles.length,
1102
+ inlineDictionaries: [...inline]
917
1103
  };
918
1104
  }
1105
+ //#endregion
1106
+ //#region src/analysis/index.ts
1107
+ /** Analyzes a project. With `runtime: true`, dictionaries the syntax tree cannot describe are imported. */
1108
+ async function analyzeProject(options) {
1109
+ const steps = analysisSteps(options);
1110
+ let step = steps.next();
1111
+ while (!step.done) {
1112
+ const { file, runtime, cache, text } = step.value;
1113
+ step = steps.next(text === void 0 ? await cachedDictionary(file, runtime, cache) : fromParsed(() => parseDictionaryText(file, text)) ?? UNANALYZABLE);
1114
+ }
1115
+ return step.value;
1116
+ }
1117
+ /**
1118
+ * `analyzeProject` for callers that cannot await, such as lint rules. Reads dictionaries from the
1119
+ * syntax tree only: `runtime` is ignored, so project code is never executed.
1120
+ */
1121
+ function analyzeProjectSync(options) {
1122
+ const steps = analysisSteps({
1123
+ ...options,
1124
+ runtime: false
1125
+ });
1126
+ let step = steps.next();
1127
+ while (!step.done) {
1128
+ const { file, cache, text } = step.value;
1129
+ step = steps.next(text === void 0 ? cachedDictionaryStatic(file, cache) : fromParsed(() => parseDictionaryText(file, text)) ?? UNANALYZABLE);
1130
+ }
1131
+ return step.value;
1132
+ }
919
1133
  /** Project root-relative path with forward slashes, for display. */
920
1134
  function relativeFile(root, file) {
921
1135
  return path.relative(root, file);
@@ -930,44 +1144,48 @@ const logger = {
930
1144
  log: (msg) => console.log(msg)
931
1145
  };
932
1146
  //#endregion
1147
+ //#region src/utils/load-config.ts
1148
+ /**
1149
+ * Runs the first config file that exists (`dialex.config.*`, then `i18n.config.*`, or
1150
+ * `configFile`) with `unconfig` and returns what it exports. **This executes project code.**
1151
+ * Returns `undefined` when the project has no config file; throws what the file throws.
1152
+ */
1153
+ function loadConfigObject(root, configFile) {
1154
+ const sources = [];
1155
+ if (configFile) sources.push({ files: configFile });
1156
+ for (const name of ["dialex.config", "i18n.config"]) sources.push({
1157
+ files: name,
1158
+ extensions: [
1159
+ "ts",
1160
+ "mts",
1161
+ "cts",
1162
+ "js",
1163
+ "mjs",
1164
+ "cjs",
1165
+ "json",
1166
+ ""
1167
+ ]
1168
+ });
1169
+ const result = loadConfigSync({
1170
+ sources,
1171
+ merge: false,
1172
+ defaults: {},
1173
+ cwd: root
1174
+ });
1175
+ const file = result?.sources?.[0];
1176
+ if (!file) return void 0;
1177
+ return {
1178
+ config: result.config && typeof result.config === "object" ? result.config : {},
1179
+ file
1180
+ };
1181
+ }
1182
+ //#endregion
933
1183
  //#region src/utils/scanner.ts
934
1184
  function resolveDialexConfig(root, inlineConfig = {}) {
935
1185
  let loadedConfig = {};
936
1186
  try {
937
- const sources = [];
938
- if (inlineConfig.configFile) sources.push({ files: inlineConfig.configFile });
939
- sources.push({
940
- files: "dialex.config",
941
- extensions: [
942
- "ts",
943
- "mts",
944
- "cts",
945
- "js",
946
- "mjs",
947
- "cjs",
948
- "json",
949
- ""
950
- ]
951
- }, {
952
- files: "i18n.config",
953
- extensions: [
954
- "ts",
955
- "mts",
956
- "cts",
957
- "js",
958
- "mjs",
959
- "cjs",
960
- "json",
961
- ""
962
- ]
963
- });
964
- const result = loadConfigSync({
965
- sources,
966
- merge: false,
967
- defaults: {},
968
- cwd: root
969
- });
970
- if (result && result.config) loadedConfig = result.config;
1187
+ const loaded = loadConfigObject(root, inlineConfig.configFile);
1188
+ if (loaded) loadedConfig = loaded.config;
971
1189
  } catch {}
972
1190
  return {
973
1191
  ...DEFAULT_CONFIG,
@@ -990,7 +1208,15 @@ const REGISTRY_ADAPTERS = [
990
1208
  "vue",
991
1209
  "web",
992
1210
  "h3",
993
- "angular"
1211
+ "angular",
1212
+ "svelte",
1213
+ "solid",
1214
+ "preact",
1215
+ "qwik",
1216
+ "lit",
1217
+ "hapi",
1218
+ "adonis",
1219
+ "trpc"
994
1220
  ];
995
1221
  /** Dependencies that tell which `dialexjs/*` entry points a project can import. */
996
1222
  const ADAPTER_DEPENDENCIES = {
@@ -1011,6 +1237,17 @@ const ADAPTER_DEPENDENCIES = {
1011
1237
  h3: ["h3"],
1012
1238
  nitropack: ["h3"],
1013
1239
  "@angular/core": ["angular"],
1240
+ svelte: ["svelte"],
1241
+ "solid-js": ["solid"],
1242
+ preact: ["preact"],
1243
+ "@builder.io/qwik": ["qwik"],
1244
+ lit: ["lit"],
1245
+ "@hapi/hapi": ["hapi"],
1246
+ "@adonisjs/core": ["adonis"],
1247
+ "@adonisjs/http-server": ["adonis"],
1248
+ "@trpc/server": ["trpc"],
1249
+ "react-native": ["react"],
1250
+ expo: ["react"],
1014
1251
  "react-router": ["web"],
1015
1252
  "react-router-dom": ["web"],
1016
1253
  "@remix-run/react": ["web"],
@@ -1041,9 +1278,9 @@ function detectAdapters(root) {
1041
1278
  * The `dialex-env.d.ts` a project should have: its path and contents, or `undefined` when there
1042
1279
  * are no dictionary files. Nothing is written.
1043
1280
  */
1044
- function renderDts(root, files, locales, messageArguments = {}) {
1281
+ function renderDts(root, files, locales, messageArguments = {}, directory) {
1045
1282
  if (files.length === 0) return void 0;
1046
- const dtsDir = fs.existsSync(path.join(root, "src")) ? path.join(root, "src") : root;
1283
+ const dtsDir = directory ?? (fs.existsSync(path.join(root, "src")) ? path.join(root, "src") : root);
1047
1284
  const imports = [];
1048
1285
  const records = [];
1049
1286
  files.forEach((file, index) => {
@@ -1226,9 +1463,9 @@ function splitLocales(df) {
1226
1463
  const bound = topLevelNames(df);
1227
1464
  const slices = [];
1228
1465
  for (const prop of df.localesObj.properties) {
1229
- const locale = keyName(prop);
1230
- const value = unwrap(prop.value);
1231
- if (locale === void 0 || !isObject(value)) return {
1466
+ const locale = propertyKeyName(prop);
1467
+ const value = unwrapNode(prop.value);
1468
+ if (locale === void 0 || !isObjectNode(value)) return {
1232
1469
  ok: false,
1233
1470
  reason: "a locale is not an object literal"
1234
1471
  };
@@ -1253,8 +1490,10 @@ function splitLocales(df) {
1253
1490
  }
1254
1491
  //#endregion
1255
1492
  //#region src/utils/static-config.ts
1493
+ /** The names a config file can have, without extension, in order of preference. */
1256
1494
  const CONFIG_NAMES = ["dialex.config", "i18n.config"];
1257
- const EXTENSIONS = [
1495
+ /** The extensions tried for each name, in order. */
1496
+ const CONFIG_EXTENSIONS = [
1258
1497
  "ts",
1259
1498
  "mts",
1260
1499
  "cts",
@@ -1263,6 +1502,22 @@ const EXTENSIONS = [
1263
1502
  "cjs",
1264
1503
  "json"
1265
1504
  ];
1505
+ /** The keys of the config that tooling reads. */
1506
+ const CONFIG_KEYS = [
1507
+ "defaultLocale",
1508
+ "locales",
1509
+ "include",
1510
+ "exclude",
1511
+ "output",
1512
+ "fallbacks",
1513
+ "prefixDefault",
1514
+ "lazy",
1515
+ "lint"
1516
+ ];
1517
+ /** The paths `readStaticConfig` and `resolveConfig` try, in order, for a project root. */
1518
+ function configCandidates(root, configFile) {
1519
+ return configFile ? [path.resolve(root, configFile)] : CONFIG_NAMES.flatMap((name) => CONFIG_EXTENSIONS.map((ext) => path.join(root, `${name}.${ext}`)));
1520
+ }
1266
1521
  const DEFAULT_STATIC_CONFIG = {
1267
1522
  defaultLocale: "en",
1268
1523
  locales: [],
@@ -1270,11 +1525,12 @@ const DEFAULT_STATIC_CONFIG = {
1270
1525
  exclude: [],
1271
1526
  fallbacks: {},
1272
1527
  prefixDefault: true,
1273
- lazy: false
1528
+ lazy: false,
1529
+ lint: { rules: {} }
1274
1530
  };
1275
1531
  const NOT_LITERAL = Symbol("not literal");
1276
1532
  function literal(node) {
1277
- node = unwrap(node);
1533
+ node = unwrapNode(node);
1278
1534
  switch (node?.type) {
1279
1535
  case "StringLiteral":
1280
1536
  case "NumericLiteral":
@@ -1288,7 +1544,7 @@ function literal(node) {
1288
1544
  case "ObjectExpression": {
1289
1545
  const result = {};
1290
1546
  for (const prop of node.properties) {
1291
- const key = keyName(prop);
1547
+ const key = propertyKeyName(prop);
1292
1548
  const value = key === void 0 ? NOT_LITERAL : literal(prop.value);
1293
1549
  if (value === NOT_LITERAL) return NOT_LITERAL;
1294
1550
  result[key] = value;
@@ -1300,12 +1556,36 @@ function literal(node) {
1300
1556
  }
1301
1557
  /** The object literal passed to `defineConfig({...})`, or exported directly. */
1302
1558
  function findConfigObject(declaration) {
1303
- const node = unwrap(declaration);
1559
+ const node = unwrapNode(declaration);
1304
1560
  if (node?.type === "CallExpression") return findConfigObject(node.arguments[0]);
1305
- return isObject(node) ? node : void 0;
1561
+ return isObjectNode(node) ? node : void 0;
1306
1562
  }
1307
1563
  const isStringArray = (value) => Array.isArray(value) && value.every((v) => typeof v === "string");
1308
- function apply(object, config, notes) {
1564
+ const LINT_LEVELS = [
1565
+ "off",
1566
+ "warn",
1567
+ "error"
1568
+ ];
1569
+ /** `lint: { rules: { code: "warn" | ["warn", {...}] } }`, or `undefined` when it is not that shape. */
1570
+ function lintRules(value) {
1571
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
1572
+ const rules = value.rules;
1573
+ if (rules === void 0) return {};
1574
+ if (typeof rules !== "object" || rules === null || Array.isArray(rules)) return void 0;
1575
+ const result = {};
1576
+ for (const [code, setting] of Object.entries(rules)) {
1577
+ const [level, options] = Array.isArray(setting) ? setting : [setting];
1578
+ if (typeof level !== "string" || !LINT_LEVELS.includes(level)) return void 0;
1579
+ if (options !== void 0 && (typeof options !== "object" || options === null)) return void 0;
1580
+ result[code] = setting;
1581
+ }
1582
+ return result;
1583
+ }
1584
+ /**
1585
+ * Applies the values of a config object (read from a syntax tree or from a loaded module) over
1586
+ * `config`, with the same checks and notes either way.
1587
+ */
1588
+ function applyConfigValues(object, config, notes) {
1309
1589
  const accept = (key, valid, expected) => {
1310
1590
  if (!(key in object)) return;
1311
1591
  const value = object[key];
@@ -1316,25 +1596,33 @@ function apply(object, config, notes) {
1316
1596
  accept("locales", isStringArray, "an array of string literals");
1317
1597
  accept("include", (v) => typeof v === "string" || isStringArray(v), "a string or array of strings");
1318
1598
  accept("exclude", isStringArray, "an array of string literals");
1599
+ accept("output", (v) => typeof v === "string" && v !== "", "a string literal");
1319
1600
  accept("fallbacks", (v) => typeof v === "object" && v !== null && Object.values(v).every((list) => isStringArray(list)), "an object of string arrays");
1320
1601
  accept("prefixDefault", (v) => typeof v === "boolean", "true or false");
1321
1602
  accept("lazy", (v) => typeof v === "boolean" || v === "locale", "true, false or \"locale\"");
1603
+ if ("lint" in object) {
1604
+ const rules = lintRules(object.lint);
1605
+ if (rules) config.lint = { rules };
1606
+ else notes.push("\"lint\" could not be read statically (expected { rules: { code: \"off\" | \"warn\" | \"error\" | [level, options] } }).");
1607
+ }
1322
1608
  }
1323
1609
  /** Reads a config file's source text. Exposed for tests and for unsaved buffers. */
1324
1610
  function parseStaticConfig(text, file = "dialex.config.ts") {
1325
1611
  const notes = [];
1326
1612
  const config = { ...DEFAULT_STATIC_CONFIG };
1613
+ let opaque = false;
1327
1614
  if (file.endsWith(".json")) {
1328
1615
  try {
1329
1616
  const data = JSON.parse(text);
1330
- if (data && typeof data === "object") apply(data, config, notes);
1617
+ if (data && typeof data === "object") applyConfigValues(data, config, notes);
1331
1618
  } catch (error) {
1332
1619
  notes.push(`Could not parse ${path.basename(file)}: ${error.message}`);
1333
1620
  }
1334
1621
  return {
1335
1622
  config,
1336
1623
  file,
1337
- notes
1624
+ notes,
1625
+ opaque
1338
1626
  };
1339
1627
  }
1340
1628
  try {
@@ -1343,22 +1631,15 @@ function parseStaticConfig(text, file = "dialex.config.ts") {
1343
1631
  if (!object) notes.push(`${path.basename(file)} does not export a config object literal.`);
1344
1632
  else {
1345
1633
  const values = {};
1346
- for (const key of [
1347
- "defaultLocale",
1348
- "locales",
1349
- "include",
1350
- "exclude",
1351
- "fallbacks",
1352
- "prefixDefault",
1353
- "lazy"
1354
- ]) {
1355
- const prop = findProp(object, key);
1634
+ opaque = object.properties.some((p) => p.type === "SpreadElement" || p.type === "SpreadProperty" || p.computed === true);
1635
+ for (const key of CONFIG_KEYS) {
1636
+ const prop = findProperty(object, key);
1356
1637
  if (!prop) continue;
1357
1638
  const value = literal(prop.value);
1358
1639
  if (value === NOT_LITERAL) notes.push(`"${key}" could not be read statically (not a literal).`);
1359
1640
  else values[key] = value;
1360
1641
  }
1361
- apply(values, config, notes);
1642
+ applyConfigValues(values, config, notes);
1362
1643
  }
1363
1644
  } catch (error) {
1364
1645
  notes.push(`Could not parse ${path.basename(file)}: ${error.message}`);
@@ -1366,16 +1647,17 @@ function parseStaticConfig(text, file = "dialex.config.ts") {
1366
1647
  return {
1367
1648
  config,
1368
1649
  file,
1369
- notes
1650
+ notes,
1651
+ opaque
1370
1652
  };
1371
1653
  }
1372
1654
  /** Reads `dialex.config.*` (or `i18n.config.*`) from `root`, or from `configFile` if given. */
1373
1655
  function readStaticConfig(root, configFile) {
1374
- const candidates = configFile ? [path.resolve(root, configFile)] : CONFIG_NAMES.flatMap((name) => EXTENSIONS.map((ext) => path.join(root, `${name}.${ext}`)));
1375
- for (const file of candidates) if (fs.existsSync(file)) return parseStaticConfig(fs.readFileSync(file, "utf-8"), file);
1656
+ for (const file of configCandidates(root, configFile)) if (fs.existsSync(file)) return parseStaticConfig(fs.readFileSync(file, "utf-8"), file);
1376
1657
  return {
1377
1658
  config: { ...DEFAULT_STATIC_CONFIG },
1378
- notes: []
1659
+ notes: [],
1660
+ opaque: false
1379
1661
  };
1380
1662
  }
1381
1663
  //#endregion
@@ -1408,7 +1690,15 @@ function configFile(from) {
1408
1690
  if (fs.existsSync(from) && fs.statSync(from).isFile()) return from;
1409
1691
  for (const candidate of [`${from}.json`, path.join(from, "tsconfig.json")]) if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
1410
1692
  }
1411
- /** `moduleResolution` and `include` of a tsconfig, following relative `extends`. */
1693
+ /** A package in `extends` (`@adonisjs/tsconfig/tsconfig.app.json`), looked up from `dir` upwards. */
1694
+ function packageConfig(from, specifier) {
1695
+ for (let dir = from;; dir = path.dirname(dir)) {
1696
+ const found = configFile(path.join(dir, "node_modules", specifier));
1697
+ if (found) return found;
1698
+ if (path.dirname(dir) === dir) return void 0;
1699
+ }
1700
+ }
1701
+ /** `moduleResolution` and `include` of a tsconfig, following `extends` (relative or a package). */
1412
1702
  function read(file, depth = 0) {
1413
1703
  const result = { references: [] };
1414
1704
  let json;
@@ -1418,8 +1708,12 @@ function read(file, depth = 0) {
1418
1708
  return result;
1419
1709
  }
1420
1710
  const dir = path.dirname(file);
1421
- const parent = typeof json.extends === "string" && json.extends.startsWith(".") && depth < 5 ? configFile(path.resolve(dir, json.extends)) : void 0;
1422
- if (parent) Object.assign(result, read(parent, depth + 1));
1711
+ const parents = Array.isArray(json.extends) ? json.extends : [json.extends];
1712
+ for (const extendsFrom of parents) {
1713
+ if (typeof extendsFrom !== "string" || depth >= 5) continue;
1714
+ const parent = extendsFrom.startsWith(".") ? configFile(path.resolve(dir, extendsFrom)) : packageConfig(dir, extendsFrom);
1715
+ if (parent) Object.assign(result, read(parent, depth + 1));
1716
+ }
1423
1717
  const options = json.compilerOptions ?? {};
1424
1718
  const module = typeof options.module === "string" ? options.module.toLowerCase() : void 0;
1425
1719
  const resolution = typeof options.moduleResolution === "string" ? options.moduleResolution.toLowerCase() : module === "node16" || module === "nodenext" ? module : void 0;
@@ -1445,7 +1739,7 @@ function relativeImportExtension(root) {
1445
1739
  return resolution === "node16" || resolution === "nodenext" ? ".js" : "";
1446
1740
  }
1447
1741
  //#endregion
1448
- //#region src/commands/generate.ts
1742
+ //#region src/commands/generate/render.ts
1449
1743
  /** The folder, next to `dialex.generated.ts`, that holds the per-locale modules. */
1450
1744
  const LOCALES_DIR = "dialex.locales";
1451
1745
  const safeName = (text) => text.replace(/[^A-Za-z0-9._-]/g, "_");
@@ -1462,7 +1756,8 @@ function renderGenerated(root, options = {}) {
1462
1756
  const config = options.static ? staticRead.config : resolveDialexConfig(root, inlineConfig);
1463
1757
  const found = scanFiles(root, config.include || DEFAULT_CONFIG.include, { exclude: config.exclude }).sort();
1464
1758
  const srcDir = fs.existsSync(path.join(root, "src")) ? path.join(root, "src") : root;
1465
- const outputPath = options.output ? path.resolve(root, options.output) : path.join(srcDir, "dialex.generated.ts");
1759
+ const outputOption = options.output ?? config.output;
1760
+ const outputPath = outputOption ? path.resolve(root, outputOption) : path.join(srcDir, "dialex.generated.ts");
1466
1761
  const outputDir = path.dirname(outputPath);
1467
1762
  const files = found.filter((file) => file !== outputPath && !file.endsWith(".d.ts") && path.basename(file) !== "dialex.generated.ts");
1468
1763
  const parsed = files.map((file) => parseDictionaryText(file, fs.readFileSync(file, "utf-8")));
@@ -1552,18 +1847,70 @@ export const dialex = { dictionaries, config };
1552
1847
  export default dictionaries;
1553
1848
  `;
1554
1849
  }
1555
- const dts = renderDts(root, files, locales, messageArguments(parsed, clientConfig.defaultLocale));
1850
+ const dts = renderDts(root, files, locales, messageArguments(parsed, clientConfig.defaultLocale), outputOption ? outputDir : void 0);
1556
1851
  return {
1557
1852
  files,
1558
1853
  outputPath,
1559
1854
  content,
1560
- dtsPath: dts?.path ?? path.join(srcDir, "dialex-env.d.ts"),
1855
+ dtsPath: dts?.path ?? path.join(outputOption ? outputDir : srcDir, "dialex-env.d.ts"),
1561
1856
  dtsContent: dts?.content,
1562
1857
  configFile: staticRead.file,
1563
1858
  extraFiles,
1564
1859
  notes
1565
1860
  };
1566
1861
  }
1862
+ //#endregion
1863
+ //#region src/commands/generate/watch.ts
1864
+ const WATCH_IGNORED = /(^|[\\/])(node_modules|dist|\.next|\.nuxt|\.output|\.turbo|\.git|dialex\.locales)([\\/]|$)/;
1865
+ const CONFIG_FILE = /(^|[\\/])(dialex|i18n)\.config\.[cm]?[jt]s(on)?$/;
1866
+ /**
1867
+ * Regenerates when a dictionary or config file is added, edited or removed. chokidar 4+ no longer
1868
+ * understands globs, so this watches the project directory and decides for itself which events
1869
+ * matter, using the same `include` glob as `generate`.
1870
+ */
1871
+ function startGenerateWatcher(root, options, regenerate, delay = 100, log = (message) => logger.info(message)) {
1872
+ const dictionaryFiles = () => new Set(renderGenerated(root, options).files);
1873
+ let known = dictionaryFiles();
1874
+ let outputs = /* @__PURE__ */ new Set();
1875
+ let timer;
1876
+ const trigger = () => {
1877
+ if (timer) clearTimeout(timer);
1878
+ timer = setTimeout(() => {
1879
+ regenerate();
1880
+ try {
1881
+ known = dictionaryFiles();
1882
+ const rendered = renderGenerated(root, options);
1883
+ outputs = /* @__PURE__ */ new Set([rendered.outputPath, rendered.dtsPath]);
1884
+ } catch {}
1885
+ }, delay);
1886
+ };
1887
+ const gitIgnored = createGitignoreFilter(root);
1888
+ const watcher = chokidar.watch(root, {
1889
+ ignoreInitial: true,
1890
+ ignored: (file) => WATCH_IGNORED.test(file) || gitIgnored(path.resolve(root, file))
1891
+ });
1892
+ watcher.on("all", (event, filePath) => {
1893
+ const file = path.resolve(root, filePath);
1894
+ if (outputs.has(file)) return;
1895
+ const isConfig = CONFIG_FILE.test(file);
1896
+ if (event === "change") {
1897
+ if (!known.has(file) && !isConfig) return;
1898
+ } else if (event === "add" || event === "unlink") {
1899
+ let now;
1900
+ try {
1901
+ now = dictionaryFiles();
1902
+ } catch {
1903
+ return;
1904
+ }
1905
+ if (!(now.size !== known.size || [...now].some((f) => !known.has(f))) && !isConfig) return;
1906
+ } else return;
1907
+ log(`[${event}] ${path.relative(root, file)} changed, regenerating...`);
1908
+ trigger();
1909
+ });
1910
+ return watcher;
1911
+ }
1912
+ //#endregion
1913
+ //#region src/commands/generate/write.ts
1567
1914
  function writeIfChanged(file, content) {
1568
1915
  if ((fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : "") === content) return;
1569
1916
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -1591,6 +1938,8 @@ function generateDictionaries(root, options = {}) {
1591
1938
  notes: rendered.notes
1592
1939
  };
1593
1940
  }
1941
+ //#endregion
1942
+ //#region src/commands/generate/index.ts
1594
1943
  async function runGenerate(options = {}) {
1595
1944
  const root = options.cwd || process.cwd();
1596
1945
  const quiet = options.json === true;
@@ -1627,54 +1976,6 @@ async function runGenerate(options = {}) {
1627
1976
  startGenerateWatcher(root, options, doGenerate);
1628
1977
  }
1629
1978
  }
1630
- const WATCH_IGNORED = /(^|[\\/])(node_modules|dist|\.next|\.nuxt|\.output|\.turbo|\.git|dialex\.locales)([\\/]|$)/;
1631
- const CONFIG_FILE = /(^|[\\/])(dialex|i18n)\.config\.[cm]?[jt]s(on)?$/;
1632
- /**
1633
- * Regenerates when a dictionary or config file is added, edited or removed. chokidar 4+ no longer
1634
- * understands globs, so this watches the project directory and decides for itself which events
1635
- * matter, using the same `include` glob as `generate`.
1636
- */
1637
- function startGenerateWatcher(root, options, regenerate, delay = 100, log = (message) => logger.info(message)) {
1638
- const dictionaryFiles = () => new Set(renderGenerated(root, options).files);
1639
- let known = dictionaryFiles();
1640
- let outputs = /* @__PURE__ */ new Set();
1641
- let timer;
1642
- const trigger = () => {
1643
- if (timer) clearTimeout(timer);
1644
- timer = setTimeout(() => {
1645
- regenerate();
1646
- try {
1647
- known = dictionaryFiles();
1648
- const rendered = renderGenerated(root, options);
1649
- outputs = /* @__PURE__ */ new Set([rendered.outputPath, rendered.dtsPath]);
1650
- } catch {}
1651
- }, delay);
1652
- };
1653
- const gitIgnored = createGitignoreFilter(root);
1654
- const watcher = chokidar.watch(root, {
1655
- ignoreInitial: true,
1656
- ignored: (file) => WATCH_IGNORED.test(file) || gitIgnored(path.resolve(root, file))
1657
- });
1658
- watcher.on("all", (event, filePath) => {
1659
- const file = path.resolve(root, filePath);
1660
- if (outputs.has(file)) return;
1661
- const isConfig = CONFIG_FILE.test(file);
1662
- if (event === "change") {
1663
- if (!known.has(file) && !isConfig) return;
1664
- } else if (event === "add" || event === "unlink") {
1665
- let now;
1666
- try {
1667
- now = dictionaryFiles();
1668
- } catch {
1669
- return;
1670
- }
1671
- if (!(now.size !== known.size || [...now].some((f) => !known.has(f))) && !isConfig) return;
1672
- } else return;
1673
- log(`[${event}] ${path.relative(root, file)} changed, regenerating...`);
1674
- trigger();
1675
- });
1676
- return watcher;
1677
- }
1678
1979
  //#endregion
1679
1980
  //#region src/utils/project.ts
1680
1981
  async function loadDictionaries(root, config) {
@@ -1729,6 +2030,6 @@ async function loadStaticProject(root, configFile) {
1729
2030
  /** `home` + `["nav", "about"]` -> `home.nav.about` */
1730
2031
  const toKey = (dictionary, keyPath) => [dictionary, ...keyPath].join(".");
1731
2032
  //#endregion
1732
- export { scanFiles as A, locationOf as B, lockPath as C, BUILTIN_IGNORE as D, writeLock as E, getString as F, renderDictionaryFile as H, hasPath as I, listLeaves as L, copyLeaf as M, dictionaryLocation as N, SOURCE_BUILTIN_IGNORE as O, getLeafSource as P, listLocales as R, lockFor as S, record as T, saveDictionaryFile as U, parseDictionaryText as V, setString as W, relativeFile as _, renderGenerated as a, hashSource as b, DEFAULT_STATIC_CONFIG as c, resolveDialexConfig as d, logger as f, createAnalysisCache as g, clearGitignore as h, generateDictionaries as i, TODO_PREFIX as j, createGitignoreFilter as k, parseStaticConfig as l, analyzeProject as m, loadStaticProject as n, runGenerate as o, DEFAULT_SOURCE_GLOB as p, toKey as r, startGenerateWatcher as s, loadProject as t, readStaticConfig as u, scanReferences as v, readLock as w, isStale as x, LOCK_FILE as y, loadDictionaryFile as z };
2033
+ export { scanFiles as A, listLeaves as B, lockPath as C, BUILTIN_IGNORE as D, writeLock as E, locationOf as F, saveDictionaryFile as G, loadDictionaryFile as H, copyLeaf as I, TODO_PREFIX as K, setString as L, createAnalysisCache as M, dictionaryLocation as N, SOURCE_BUILTIN_IGNORE as O, getLeafSource as P, getString as R, lockFor as S, record as T, parseDictionaryText as U, listLocales as V, renderDictionaryFile as W, scanReferences as _, generateDictionaries as a, hashSource as b, DEFAULT_STATIC_CONFIG as c, resolveDialexConfig as d, logger as f, DEFAULT_SOURCE_GLOB as g, relativeFile as h, runGenerate as i, clearGitignore as j, createGitignoreFilter as k, parseStaticConfig as l, analyzeProjectSync as m, loadStaticProject as n, startGenerateWatcher as o, analyzeProject as p, toKey as r, renderGenerated as s, loadProject as t, readStaticConfig as u, scanText as v, readLock as w, isStale as x, LOCK_FILE as y, hasPath as z };
1733
2034
 
1734
- //# sourceMappingURL=project-Dz0FbydQ.mjs.map
2035
+ //# sourceMappingURL=project-BTpZGNjh.mjs.map