@dialexjs/cli 0.7.2 → 0.9.1

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.
@@ -10,60 +10,66 @@ 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
14
- const TODO_PREFIX = "[TODO] ";
15
- const IDENTIFIER$1 = /^[A-Za-z_$][\w$]*$/;
16
- function unwrap(node) {
13
+ //#region src/utils/ast.ts
14
+ /** Strips `as`, `satisfies` and parentheses around an expression. */
15
+ function unwrapNode(node) {
17
16
  while (node && (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "ParenthesizedExpression")) node = node.expression;
18
17
  return node;
19
18
  }
20
- const isObject = (node) => node?.type === "ObjectExpression";
21
- function keyName(prop) {
19
+ const isObjectNode = (node) => node?.type === "ObjectExpression";
20
+ /** The static name of a property key, or `undefined` when it is computed. */
21
+ function propertyKeyName(prop) {
22
22
  const key = prop?.key;
23
23
  if (!key || prop.computed) return void 0;
24
24
  if (key.type === "Identifier") return key.name;
25
25
  if (key.type === "StringLiteral" || key.type === "Literal" && typeof key.value === "string") return key.value;
26
26
  if (key.type === "NumericLiteral") return String(key.value);
27
27
  }
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);
28
+ const isPropLike$1 = (p) => p?.type === "ObjectProperty" || p?.type === "Property" || p?.type === "ObjectMethod";
29
+ /** The first property of `obj` with the static name `key`. */
30
+ function findProperty(obj, key) {
31
+ return obj.properties.find((p) => isPropLike$1(p) && propertyKeyName(p) === key);
31
32
  }
33
+ //#endregion
34
+ //#region src/utils/dictionary-edit.ts
35
+ const TODO_PREFIX = "[TODO] ";
36
+ const IDENTIFIER$1 = /^[A-Za-z_$][\w$]*$/;
37
+ const isPropLike = (p) => p?.type === "ObjectProperty" || p?.type === "Property" || p?.type === "ObjectMethod";
32
38
  function stringValue(node) {
33
- node = unwrap(node);
39
+ node = unwrapNode(node);
34
40
  if (node?.type === "StringLiteral") return node.value;
35
41
  if (node?.type === "Literal" && typeof node.value === "string") return node.value;
36
42
  if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked ?? void 0;
37
43
  }
38
44
  function leafKind(node) {
39
- node = unwrap(node);
45
+ node = unwrapNode(node);
40
46
  if (stringValue(node) !== void 0) return "string";
41
47
  if (node?.type === "ArrowFunctionExpression" || node?.type === "FunctionExpression" || node?.type === "ObjectMethod") return "function";
42
48
  return "other";
43
49
  }
44
50
  function dictionaryProp(obj) {
45
- const value = unwrap(findProp(obj, "dictionary")?.value);
46
- return isObject(value) ? value : void 0;
51
+ const value = unwrapNode(findProperty(obj, "dictionary")?.value);
52
+ return isObjectNode(value) ? value : void 0;
47
53
  }
48
54
  function locateName(declaration) {
49
- const decl = unwrap(declaration);
55
+ const decl = unwrapNode(declaration);
50
56
  if (decl?.type === "CallExpression") {
51
- const first = unwrap(decl.arguments[0]);
52
- if (isObject(first)) return stringValue(findProp(first, "name")?.value);
57
+ const first = unwrapNode(decl.arguments[0]);
58
+ if (isObjectNode(first)) return stringValue(findProperty(first, "name")?.value);
53
59
  return stringValue(first);
54
60
  }
55
- if (isObject(decl)) return stringValue(findProp(decl, "name")?.value);
61
+ if (isObjectNode(decl)) return stringValue(findProperty(decl, "name")?.value);
56
62
  }
57
63
  function locateLocalesObject(declaration) {
58
- const decl = unwrap(declaration);
64
+ const decl = unwrapNode(declaration);
59
65
  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);
66
+ const first = unwrapNode(decl.arguments[0]);
67
+ const second = unwrapNode(decl.arguments[1]);
68
+ if (isObjectNode(second)) return second;
69
+ if (isObjectNode(first)) return dictionaryProp(first);
64
70
  return;
65
71
  }
66
- if (isObject(decl)) return dictionaryProp(decl);
72
+ if (isObjectNode(decl)) return dictionaryProp(decl);
67
73
  }
68
74
  /** Quotes a property key only when it is not a valid identifier. */
69
75
  function keyCode(key) {
@@ -82,15 +88,15 @@ function makeStringNode(value) {
82
88
  */
83
89
  function isStaticallyComplete(localesObj) {
84
90
  const walk = (obj) => obj.properties.every((prop) => {
85
- if (!isPropLike(prop) || keyName(prop) === void 0) return false;
91
+ if (!isPropLike(prop) || propertyKeyName(prop) === void 0) return false;
86
92
  if (prop.type === "ObjectMethod") return true;
87
- const value = unwrap(prop.value);
88
- return isObject(value) ? walk(value) : true;
93
+ const value = unwrapNode(prop.value);
94
+ return isObjectNode(value) ? walk(value) : true;
89
95
  });
90
96
  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);
97
+ if (!isPropLike(prop) || propertyKeyName(prop) === void 0 || prop.type === "ObjectMethod") return false;
98
+ const value = unwrapNode(prop.value);
99
+ return isObjectNode(value) && walk(value);
94
100
  });
95
101
  }
96
102
  /**
@@ -129,11 +135,11 @@ async function saveDictionaryFile(df) {
129
135
  }
130
136
  }
131
137
  function listLocales(df) {
132
- return df.localesObj.properties.filter(isPropLike).map(keyName).filter((k) => k !== void 0);
138
+ return df.localesObj.properties.filter(isPropLike).map(propertyKeyName).filter((k) => k !== void 0);
133
139
  }
134
140
  function localeObject(df, locale) {
135
- const value = unwrap(findProp(df.localesObj, locale)?.value);
136
- return isObject(value) ? value : void 0;
141
+ const value = unwrapNode(findProperty(df.localesObj, locale)?.value);
142
+ return isObjectNode(value) ? value : void 0;
137
143
  }
138
144
  /** Lists every leaf (string, function, or other value) of a locale, in source order. */
139
145
  function listLeaves(df, locale) {
@@ -143,11 +149,11 @@ function listLeaves(df, locale) {
143
149
  const walk = (obj, prefix) => {
144
150
  for (const prop of obj.properties) {
145
151
  if (!isPropLike(prop)) continue;
146
- const name = keyName(prop);
152
+ const name = propertyKeyName(prop);
147
153
  if (name === void 0) continue;
148
154
  const path = [...prefix, name];
149
- const value = prop.type === "ObjectMethod" ? prop : unwrap(prop.value);
150
- if (isObject(value)) walk(value, path);
155
+ const value = prop.type === "ObjectMethod" ? prop : unwrapNode(prop.value);
156
+ if (isObjectNode(value)) walk(value, path);
151
157
  else {
152
158
  const kind = leafKind(value);
153
159
  leaves.push({
@@ -164,11 +170,11 @@ function listLeaves(df, locale) {
164
170
  function getProp(df, locale, path) {
165
171
  let obj = localeObject(df, locale);
166
172
  for (let i = 0; obj && i < path.length; i++) {
167
- const prop = findProp(obj, path[i]);
173
+ const prop = findProperty(obj, path[i]);
168
174
  if (!prop) return void 0;
169
175
  if (i === path.length - 1) return prop;
170
- const next = unwrap(prop.value);
171
- obj = isObject(next) ? next : void 0;
176
+ const next = unwrapNode(prop.value);
177
+ obj = isObjectNode(next) ? next : void 0;
172
178
  }
173
179
  }
174
180
  function hasPath(df, locale, path) {
@@ -191,13 +197,13 @@ function ensureLocale(df, locale) {
191
197
  function ensureParent(df, locale, path) {
192
198
  let obj = ensureLocale(df, locale);
193
199
  for (const segment of path.slice(0, -1)) {
194
- let prop = findProp(obj, segment);
200
+ let prop = findProperty(obj, segment);
195
201
  if (!prop) {
196
202
  prop = makeProp(segment, "{}");
197
203
  obj.properties.push(prop);
198
204
  }
199
- const next = unwrap(prop.value);
200
- if (!isObject(next)) return void 0;
205
+ const next = unwrapNode(prop.value);
206
+ if (!isObjectNode(next)) return void 0;
201
207
  obj = next;
202
208
  }
203
209
  return obj;
@@ -210,7 +216,7 @@ function setString(df, locale, path, value) {
210
216
  const parent = ensureParent(df, locale, path);
211
217
  if (!parent) return "skipped";
212
218
  const last = path[path.length - 1];
213
- const existing = findProp(parent, last);
219
+ const existing = findProperty(parent, last);
214
220
  if (existing) {
215
221
  if (existing.type === "ObjectMethod" || leafKind(existing.value) !== "string") return "skipped";
216
222
  existing.value = makeStringNode(value);
@@ -259,7 +265,7 @@ const toRange = (loc) => loc ? {
259
265
  * it is a good place to attach a diagnostic or jump to.
260
266
  */
261
267
  function locationOf(df, locale, path = []) {
262
- const prop = path.length === 0 ? findProp(df.localesObj, locale) : getProp(df, locale, path);
268
+ const prop = path.length === 0 ? findProperty(df.localesObj, locale) : getProp(df, locale, path);
263
269
  return toRange((prop?.key ?? prop)?.loc);
264
270
  }
265
271
  /** The source text of the dictionary object itself (the object whose keys are locales). */
@@ -518,6 +524,10 @@ function lockFor(root, sourceLocale) {
518
524
  }
519
525
  //#endregion
520
526
  //#region src/analysis.ts
527
+ function lintLevel(setting) {
528
+ const level = Array.isArray(setting) ? setting[0] : setting;
529
+ return level === "off" || level === "warn" || level === "error" ? level : void 0;
530
+ }
521
531
  function createAnalysisCache() {
522
532
  return {
523
533
  dictionaries: /* @__PURE__ */ new Map(),
@@ -534,12 +544,115 @@ const DEFAULT_SOURCE_GLOB = "**/*.{ts,tsx,js,jsx,mjs,cjs,vue,svelte,astro,mdx}";
534
544
  const DICTIONARY_CALL = /\b(?:getDictionary|useDictionary|DialexDictionary)\(\s*(["'`])([^"'`$\n]+)\1/dg;
535
545
  /** `t("dictionary.path.to.key")` */
536
546
  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) {
547
+ /**
548
+ * Offsets of `//`, `/* *\/` and `<!-- -->` comments. Quoted strings are skipped so `"http://x"`
549
+ * is not a comment; `'` and `"` end at a newline, template literals do not.
550
+ */
551
+ function commentRanges(text) {
552
+ const ranges = [];
553
+ const length = text.length;
554
+ let i = 0;
555
+ while (i < length) {
556
+ const c = text[i];
557
+ if (c === "\"" || c === "'" || c === "`") {
558
+ i++;
559
+ while (i < length && text[i] !== c && (c === "`" || text[i] !== "\n")) {
560
+ if (text[i] === "\\") i++;
561
+ i++;
562
+ }
563
+ i++;
564
+ continue;
565
+ }
566
+ let close;
567
+ if (c === "/" && text[i + 1] === "/") close = "\n";
568
+ else if (c === "/" && text[i + 1] === "*") close = "*/";
569
+ else if (c === "<" && text.startsWith("<!--", i)) close = "-->";
570
+ if (close === void 0) {
571
+ i++;
572
+ continue;
573
+ }
574
+ const found = text.indexOf(close, i + 2);
575
+ const end = found === -1 ? length : close === "\n" ? found : found + close.length;
576
+ ranges.push([i, end]);
577
+ i = end;
578
+ }
579
+ return ranges;
580
+ }
581
+ const IGNORE_DIRECTIVE = /dialex-ignore(-next-line)?(?![\w-])/;
582
+ /** `dictionaries: { name: ..., "other-name": ..., shorthand }`, up to the first key. */
583
+ const DICTIONARIES_OBJECT = /\bdictionaries\s*:\s*\{/g;
584
+ const OBJECT_KEY = /\s*(?:([A-Za-z_$][\w$-]*)|(["'])([^"'\n]+)\2)\s*(?=[:,}])/y;
585
+ /** Replaces comments with spaces of the same length, keeping offsets and line breaks. */
586
+ function maskComments(text, comments) {
587
+ if (comments.length === 0) return text;
588
+ let out = "";
589
+ let at = 0;
590
+ for (const [from, to] of comments) {
591
+ out += text.slice(at, from) + text.slice(from, to).replace(/[^\n]/g, " ");
592
+ at = to;
593
+ }
594
+ return out + text.slice(at);
595
+ }
596
+ /** Names that an object literal `dictionaries: { ... }` registers inline. */
597
+ function inlineDictionaryNames(masked) {
598
+ const names = /* @__PURE__ */ new Set();
599
+ for (const match of masked.matchAll(DICTIONARIES_OBJECT)) {
600
+ let i = match.index + match[0].length;
601
+ let depth = 1;
602
+ let expectKey = true;
603
+ while (i < masked.length && depth > 0) {
604
+ if (expectKey && depth === 1) {
605
+ OBJECT_KEY.lastIndex = i;
606
+ const key = OBJECT_KEY.exec(masked);
607
+ if (key) {
608
+ names.add(key[1] ?? key[3]);
609
+ i = OBJECT_KEY.lastIndex;
610
+ }
611
+ expectKey = false;
612
+ continue;
613
+ }
614
+ const c = masked[i];
615
+ if (c === "\"" || c === "'" || c === "`") {
616
+ i++;
617
+ while (i < masked.length && masked[i] !== c && (c === "`" || masked[i] !== "\n")) {
618
+ if (masked[i] === "\\") i++;
619
+ i++;
620
+ }
621
+ } else if (c === "{" || c === "[" || c === "(") depth++;
622
+ else if (c === "}" || c === "]" || c === ")") depth--;
623
+ else if (c === "," && depth === 1) expectKey = true;
624
+ i++;
625
+ }
626
+ }
627
+ return [...names];
628
+ }
629
+ /** Lines that a `dialex-ignore` / `dialex-ignore-next-line` comment excludes from checking. */
630
+ function ignoredLines(text, comments) {
631
+ const lines = /* @__PURE__ */ new Set();
632
+ if (!text.includes("dialex-ignore")) return lines;
633
+ const starts = lineStarts(text);
634
+ for (const [from, to] of comments) {
635
+ const directive = IGNORE_DIRECTIVE.exec(text.slice(from, to));
636
+ if (!directive) continue;
637
+ const first = positionAt(starts, from).line;
638
+ const last = positionAt(starts, Math.max(from, to - 1)).line;
639
+ if (directive[1]) lines.add(last + 1);
640
+ else for (let line = first; line <= last; line++) lines.add(line);
641
+ }
642
+ return lines;
643
+ }
644
+ /** References and inline dictionary names found in one source file. */
645
+ function scanText(text) {
646
+ const comments = commentRanges(text);
647
+ const ignored = ignoredLines(text, comments);
648
+ const starts = ignored.size > 0 ? lineStarts(text) : [];
649
+ const inComment = (offset) => comments.some(([from, to]) => offset >= from && offset < to);
539
650
  const references = [];
540
651
  const collect = (pattern, kind) => {
541
652
  for (const match of text.matchAll(pattern)) {
542
653
  const [start, end] = match.indices[2];
654
+ if (comments.length > 0 && inComment(start)) continue;
655
+ if (ignored.size > 0 && ignored.has(positionAt(starts, start).line)) continue;
543
656
  references.push({
544
657
  kind,
545
658
  value: match[2],
@@ -550,7 +663,18 @@ function scanReferences(text) {
550
663
  };
551
664
  collect(DICTIONARY_CALL, "dictionary");
552
665
  collect(T_CALL, "path");
553
- return references.sort((a, b) => a.start - b.start);
666
+ references.sort((a, b) => a.start - b.start);
667
+ return {
668
+ references,
669
+ inline: text.includes("dictionaries") ? inlineDictionaryNames(maskComments(text, comments)) : []
670
+ };
671
+ }
672
+ /**
673
+ * Finds `getDictionary("x")`-style and `t("x.y.z")` references in source text. References inside
674
+ * comments, and on lines a `dialex-ignore` comment covers, are skipped.
675
+ */
676
+ function scanReferences(text) {
677
+ return scanText(text).references;
554
678
  }
555
679
  function lineStarts(text) {
556
680
  const starts = [0];
@@ -640,29 +764,37 @@ async function fromRuntime(file) {
640
764
  };
641
765
  }
642
766
  }
643
- async function loadDictionaryData(file, runtime) {
644
- let astError;
767
+ function fromParsed(parsed) {
645
768
  try {
646
- const df = await loadDictionaryFile(file);
769
+ const df = parsed();
647
770
  if (df?.complete && df.name) return {
648
771
  ok: true,
649
772
  data: fromSyntaxTree(df)
650
773
  };
651
774
  } catch (error) {
652
- astError = error;
775
+ return {
776
+ ok: false,
777
+ level: "error",
778
+ code: "invalid-dictionary",
779
+ message: `Failed to parse dictionary: ${error.message}`
780
+ };
653
781
  }
782
+ }
783
+ const UNANALYZABLE = {
784
+ ok: false,
785
+ level: "info",
786
+ code: "unanalyzable",
787
+ message: "This dictionary uses spreads, computed keys, imported values, or a computed name, so it cannot be analyzed statically."
788
+ };
789
+ /** Reads a dictionary from its syntax tree. Never runs project code. */
790
+ function loadDictionaryDataStatic(file) {
791
+ return fromParsed(() => parseDictionaryText(file, fs.readFileSync(file, "utf-8"))) ?? UNANALYZABLE;
792
+ }
793
+ async function loadDictionaryData(file, runtime) {
794
+ const parsed = fromParsed(() => parseDictionaryText(file, fs.readFileSync(file, "utf-8")));
795
+ if (parsed?.ok) return parsed;
654
796
  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
- };
797
+ return parsed ?? UNANALYZABLE;
666
798
  }
667
799
  function stampOf(file) {
668
800
  try {
@@ -683,21 +815,39 @@ async function cachedDictionary(file, runtime, cache) {
683
815
  });
684
816
  return loaded;
685
817
  }
818
+ function cachedDictionaryStatic(file, cache) {
819
+ const stamp = cache ? stampOf(file) : void 0;
820
+ const hit = stamp ? cache.dictionaries.get(file) : void 0;
821
+ if (hit && hit.stamp === stamp) return hit.loaded;
822
+ const loaded = loadDictionaryDataStatic(file);
823
+ if (cache && stamp) cache.dictionaries.set(file, {
824
+ stamp,
825
+ loaded
826
+ });
827
+ return loaded;
828
+ }
686
829
  function scanSource(text) {
687
830
  const starts = lineStarts(text);
688
831
  const rangeOf = (start, end) => ({
689
832
  start: positionAt(starts, start),
690
833
  end: positionAt(starts, end)
691
834
  });
835
+ const { references, inline } = scanText(text);
692
836
  return {
693
837
  tokens: [...new Set(text.match(/[\w$-]+/g) ?? [])],
694
- refs: scanReferences(text).map((ref) => ({
838
+ refs: references.map((ref) => ({
695
839
  ref,
696
840
  range: rangeOf(ref.start, ref.end)
697
- }))
841
+ })),
842
+ inline
698
843
  };
699
844
  }
700
- async function analyzeProject(options) {
845
+ /**
846
+ * The analysis itself, written once. It yields a `LoadRequest` for every dictionary so that a
847
+ * synchronous driver (`analyzeProjectSync`) and an asynchronous one (`analyzeProject`, which may
848
+ * import dictionaries at runtime) can share it.
849
+ */
850
+ function* analysisSteps(options) {
701
851
  const { root, config } = options;
702
852
  const include = config.include || "**/*.content.ts";
703
853
  const configuredLocales = config.locales || [];
@@ -718,7 +868,12 @@ async function analyzeProject(options) {
718
868
  const dictionaries = [];
719
869
  const lock = options.lock === false ? void 0 : readLock(root);
720
870
  for (const file of files) {
721
- const loaded = await cachedDictionary(file, options.runtime === true, cache);
871
+ const loaded = yield {
872
+ file,
873
+ runtime: options.runtime === true,
874
+ cache,
875
+ text: options.contents?.get(file)
876
+ };
722
877
  if (!loaded.ok) {
723
878
  for (const message of loaded.message.split("\n")) issues.push({
724
879
  file,
@@ -831,8 +986,10 @@ async function analyzeProject(options) {
831
986
  ignore: extraIgnore,
832
987
  gitignoreFilter: gitignore
833
988
  }).filter((f) => !dictionaryFiles.has(f));
989
+ const inline = /* @__PURE__ */ new Set();
834
990
  if (sourceFiles.length > 0) {
835
991
  const tokens = /* @__PURE__ */ new Set();
992
+ const scans = [];
836
993
  for (const file of sourceFiles) {
837
994
  const stamp = cache ? stampOf(file) : void 0;
838
995
  let scan = stamp ? cache.sources.get(file) : void 0;
@@ -850,32 +1007,37 @@ async function analyzeProject(options) {
850
1007
  if (cache && stamp) cache.sources.set(file, scan);
851
1008
  }
852
1009
  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({
1010
+ scans.push({
1011
+ file,
1012
+ scan: scan.scan
1013
+ });
1014
+ }
1015
+ for (const { scan } of scans) for (const name of scan.inline) inline.add(name);
1016
+ for (const { file, scan } of scans) for (const { ref, range } of scan.refs) {
1017
+ if (ref.kind === "dictionary") {
1018
+ if (!known.has(ref.value) && !inline.has(ref.value)) issues.push({
870
1019
  file,
871
1020
  level: "error",
872
- code: "unknown-path",
873
- message: `Unknown translation path "${ref.value}"`,
1021
+ code: "unknown-dictionary",
1022
+ message: `Unknown dictionary "${ref.value}"`,
874
1023
  range,
875
- dictionary: name,
876
- path: rest
1024
+ dictionary: ref.value
877
1025
  });
1026
+ continue;
878
1027
  }
1028
+ const [name, ...rest] = ref.value.split(".");
1029
+ const dictionary = known.get(name);
1030
+ if (!dictionary) continue;
1031
+ const keyPath = rest.join(".");
1032
+ if (!dictionary.paths.some((p) => p === keyPath || p.startsWith(`${keyPath}.`))) issues.push({
1033
+ file,
1034
+ level: "error",
1035
+ code: "unknown-path",
1036
+ message: `Unknown translation path "${ref.value}"`,
1037
+ range,
1038
+ dictionary: name,
1039
+ path: rest
1040
+ });
879
1041
  }
880
1042
  if (options.unused !== false) for (const dictionary of dictionaries) {
881
1043
  if (!tokens.has(dictionary.name)) {
@@ -909,13 +1071,47 @@ async function analyzeProject(options) {
909
1071
  const live = /* @__PURE__ */ new Set([...files, ...sourceFiles]);
910
1072
  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
1073
  }
1074
+ const rules = config.lint?.rules;
1075
+ if (rules && Object.keys(rules).length > 0) for (let i = issues.length - 1; i >= 0; i--) {
1076
+ const level = lintLevel(rules[issues[i].code]);
1077
+ if (!level) continue;
1078
+ if (level === "off") issues.splice(i, 1);
1079
+ else issues[i].level = level === "error" ? "error" : "warning";
1080
+ }
912
1081
  return {
913
1082
  issues,
914
1083
  dictionaries,
915
1084
  files,
916
- sourceFilesScanned: sourceFiles.length
1085
+ sourceFilesScanned: sourceFiles.length,
1086
+ inlineDictionaries: [...inline]
917
1087
  };
918
1088
  }
1089
+ /** Analyzes a project. With `runtime: true`, dictionaries the syntax tree cannot describe are imported. */
1090
+ async function analyzeProject(options) {
1091
+ const steps = analysisSteps(options);
1092
+ let step = steps.next();
1093
+ while (!step.done) {
1094
+ const { file, runtime, cache, text } = step.value;
1095
+ step = steps.next(text === void 0 ? await cachedDictionary(file, runtime, cache) : fromParsed(() => parseDictionaryText(file, text)) ?? UNANALYZABLE);
1096
+ }
1097
+ return step.value;
1098
+ }
1099
+ /**
1100
+ * `analyzeProject` for callers that cannot await, such as lint rules. Reads dictionaries from the
1101
+ * syntax tree only: `runtime` is ignored, so project code is never executed.
1102
+ */
1103
+ function analyzeProjectSync(options) {
1104
+ const steps = analysisSteps({
1105
+ ...options,
1106
+ runtime: false
1107
+ });
1108
+ let step = steps.next();
1109
+ while (!step.done) {
1110
+ const { file, cache, text } = step.value;
1111
+ step = steps.next(text === void 0 ? cachedDictionaryStatic(file, cache) : fromParsed(() => parseDictionaryText(file, text)) ?? UNANALYZABLE);
1112
+ }
1113
+ return step.value;
1114
+ }
919
1115
  /** Project root-relative path with forward slashes, for display. */
920
1116
  function relativeFile(root, file) {
921
1117
  return path.relative(root, file);
@@ -930,44 +1126,48 @@ const logger = {
930
1126
  log: (msg) => console.log(msg)
931
1127
  };
932
1128
  //#endregion
1129
+ //#region src/utils/load-config.ts
1130
+ /**
1131
+ * Runs the first config file that exists (`dialex.config.*`, then `i18n.config.*`, or
1132
+ * `configFile`) with `unconfig` and returns what it exports. **This executes project code.**
1133
+ * Returns `undefined` when the project has no config file; throws what the file throws.
1134
+ */
1135
+ function loadConfigObject(root, configFile) {
1136
+ const sources = [];
1137
+ if (configFile) sources.push({ files: configFile });
1138
+ for (const name of ["dialex.config", "i18n.config"]) sources.push({
1139
+ files: name,
1140
+ extensions: [
1141
+ "ts",
1142
+ "mts",
1143
+ "cts",
1144
+ "js",
1145
+ "mjs",
1146
+ "cjs",
1147
+ "json",
1148
+ ""
1149
+ ]
1150
+ });
1151
+ const result = loadConfigSync({
1152
+ sources,
1153
+ merge: false,
1154
+ defaults: {},
1155
+ cwd: root
1156
+ });
1157
+ const file = result?.sources?.[0];
1158
+ if (!file) return void 0;
1159
+ return {
1160
+ config: result.config && typeof result.config === "object" ? result.config : {},
1161
+ file
1162
+ };
1163
+ }
1164
+ //#endregion
933
1165
  //#region src/utils/scanner.ts
934
1166
  function resolveDialexConfig(root, inlineConfig = {}) {
935
1167
  let loadedConfig = {};
936
1168
  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;
1169
+ const loaded = loadConfigObject(root, inlineConfig.configFile);
1170
+ if (loaded) loadedConfig = loaded.config;
971
1171
  } catch {}
972
1172
  return {
973
1173
  ...DEFAULT_CONFIG,
@@ -990,7 +1190,15 @@ const REGISTRY_ADAPTERS = [
990
1190
  "vue",
991
1191
  "web",
992
1192
  "h3",
993
- "angular"
1193
+ "angular",
1194
+ "svelte",
1195
+ "solid",
1196
+ "preact",
1197
+ "qwik",
1198
+ "lit",
1199
+ "hapi",
1200
+ "adonis",
1201
+ "trpc"
994
1202
  ];
995
1203
  /** Dependencies that tell which `dialexjs/*` entry points a project can import. */
996
1204
  const ADAPTER_DEPENDENCIES = {
@@ -1011,6 +1219,17 @@ const ADAPTER_DEPENDENCIES = {
1011
1219
  h3: ["h3"],
1012
1220
  nitropack: ["h3"],
1013
1221
  "@angular/core": ["angular"],
1222
+ svelte: ["svelte"],
1223
+ "solid-js": ["solid"],
1224
+ preact: ["preact"],
1225
+ "@builder.io/qwik": ["qwik"],
1226
+ lit: ["lit"],
1227
+ "@hapi/hapi": ["hapi"],
1228
+ "@adonisjs/core": ["adonis"],
1229
+ "@adonisjs/http-server": ["adonis"],
1230
+ "@trpc/server": ["trpc"],
1231
+ "react-native": ["react"],
1232
+ expo: ["react"],
1014
1233
  "react-router": ["web"],
1015
1234
  "react-router-dom": ["web"],
1016
1235
  "@remix-run/react": ["web"],
@@ -1041,9 +1260,9 @@ function detectAdapters(root) {
1041
1260
  * The `dialex-env.d.ts` a project should have: its path and contents, or `undefined` when there
1042
1261
  * are no dictionary files. Nothing is written.
1043
1262
  */
1044
- function renderDts(root, files, locales, messageArguments = {}) {
1263
+ function renderDts(root, files, locales, messageArguments = {}, directory) {
1045
1264
  if (files.length === 0) return void 0;
1046
- const dtsDir = fs.existsSync(path.join(root, "src")) ? path.join(root, "src") : root;
1265
+ const dtsDir = directory ?? (fs.existsSync(path.join(root, "src")) ? path.join(root, "src") : root);
1047
1266
  const imports = [];
1048
1267
  const records = [];
1049
1268
  files.forEach((file, index) => {
@@ -1226,9 +1445,9 @@ function splitLocales(df) {
1226
1445
  const bound = topLevelNames(df);
1227
1446
  const slices = [];
1228
1447
  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 {
1448
+ const locale = propertyKeyName(prop);
1449
+ const value = unwrapNode(prop.value);
1450
+ if (locale === void 0 || !isObjectNode(value)) return {
1232
1451
  ok: false,
1233
1452
  reason: "a locale is not an object literal"
1234
1453
  };
@@ -1253,8 +1472,10 @@ function splitLocales(df) {
1253
1472
  }
1254
1473
  //#endregion
1255
1474
  //#region src/utils/static-config.ts
1475
+ /** The names a config file can have, without extension, in order of preference. */
1256
1476
  const CONFIG_NAMES = ["dialex.config", "i18n.config"];
1257
- const EXTENSIONS = [
1477
+ /** The extensions tried for each name, in order. */
1478
+ const CONFIG_EXTENSIONS = [
1258
1479
  "ts",
1259
1480
  "mts",
1260
1481
  "cts",
@@ -1263,6 +1484,22 @@ const EXTENSIONS = [
1263
1484
  "cjs",
1264
1485
  "json"
1265
1486
  ];
1487
+ /** The keys of the config that tooling reads. */
1488
+ const CONFIG_KEYS = [
1489
+ "defaultLocale",
1490
+ "locales",
1491
+ "include",
1492
+ "exclude",
1493
+ "output",
1494
+ "fallbacks",
1495
+ "prefixDefault",
1496
+ "lazy",
1497
+ "lint"
1498
+ ];
1499
+ /** The paths `readStaticConfig` and `resolveConfig` try, in order, for a project root. */
1500
+ function configCandidates(root, configFile) {
1501
+ return configFile ? [path.resolve(root, configFile)] : CONFIG_NAMES.flatMap((name) => CONFIG_EXTENSIONS.map((ext) => path.join(root, `${name}.${ext}`)));
1502
+ }
1266
1503
  const DEFAULT_STATIC_CONFIG = {
1267
1504
  defaultLocale: "en",
1268
1505
  locales: [],
@@ -1270,11 +1507,12 @@ const DEFAULT_STATIC_CONFIG = {
1270
1507
  exclude: [],
1271
1508
  fallbacks: {},
1272
1509
  prefixDefault: true,
1273
- lazy: false
1510
+ lazy: false,
1511
+ lint: { rules: {} }
1274
1512
  };
1275
1513
  const NOT_LITERAL = Symbol("not literal");
1276
1514
  function literal(node) {
1277
- node = unwrap(node);
1515
+ node = unwrapNode(node);
1278
1516
  switch (node?.type) {
1279
1517
  case "StringLiteral":
1280
1518
  case "NumericLiteral":
@@ -1288,7 +1526,7 @@ function literal(node) {
1288
1526
  case "ObjectExpression": {
1289
1527
  const result = {};
1290
1528
  for (const prop of node.properties) {
1291
- const key = keyName(prop);
1529
+ const key = propertyKeyName(prop);
1292
1530
  const value = key === void 0 ? NOT_LITERAL : literal(prop.value);
1293
1531
  if (value === NOT_LITERAL) return NOT_LITERAL;
1294
1532
  result[key] = value;
@@ -1300,12 +1538,36 @@ function literal(node) {
1300
1538
  }
1301
1539
  /** The object literal passed to `defineConfig({...})`, or exported directly. */
1302
1540
  function findConfigObject(declaration) {
1303
- const node = unwrap(declaration);
1541
+ const node = unwrapNode(declaration);
1304
1542
  if (node?.type === "CallExpression") return findConfigObject(node.arguments[0]);
1305
- return isObject(node) ? node : void 0;
1543
+ return isObjectNode(node) ? node : void 0;
1306
1544
  }
1307
1545
  const isStringArray = (value) => Array.isArray(value) && value.every((v) => typeof v === "string");
1308
- function apply(object, config, notes) {
1546
+ const LINT_LEVELS = [
1547
+ "off",
1548
+ "warn",
1549
+ "error"
1550
+ ];
1551
+ /** `lint: { rules: { code: "warn" | ["warn", {...}] } }`, or `undefined` when it is not that shape. */
1552
+ function lintRules(value) {
1553
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
1554
+ const rules = value.rules;
1555
+ if (rules === void 0) return {};
1556
+ if (typeof rules !== "object" || rules === null || Array.isArray(rules)) return void 0;
1557
+ const result = {};
1558
+ for (const [code, setting] of Object.entries(rules)) {
1559
+ const [level, options] = Array.isArray(setting) ? setting : [setting];
1560
+ if (typeof level !== "string" || !LINT_LEVELS.includes(level)) return void 0;
1561
+ if (options !== void 0 && (typeof options !== "object" || options === null)) return void 0;
1562
+ result[code] = setting;
1563
+ }
1564
+ return result;
1565
+ }
1566
+ /**
1567
+ * Applies the values of a config object (read from a syntax tree or from a loaded module) over
1568
+ * `config`, with the same checks and notes either way.
1569
+ */
1570
+ function applyConfigValues(object, config, notes) {
1309
1571
  const accept = (key, valid, expected) => {
1310
1572
  if (!(key in object)) return;
1311
1573
  const value = object[key];
@@ -1316,25 +1578,33 @@ function apply(object, config, notes) {
1316
1578
  accept("locales", isStringArray, "an array of string literals");
1317
1579
  accept("include", (v) => typeof v === "string" || isStringArray(v), "a string or array of strings");
1318
1580
  accept("exclude", isStringArray, "an array of string literals");
1581
+ accept("output", (v) => typeof v === "string" && v !== "", "a string literal");
1319
1582
  accept("fallbacks", (v) => typeof v === "object" && v !== null && Object.values(v).every((list) => isStringArray(list)), "an object of string arrays");
1320
1583
  accept("prefixDefault", (v) => typeof v === "boolean", "true or false");
1321
1584
  accept("lazy", (v) => typeof v === "boolean" || v === "locale", "true, false or \"locale\"");
1585
+ if ("lint" in object) {
1586
+ const rules = lintRules(object.lint);
1587
+ if (rules) config.lint = { rules };
1588
+ else notes.push("\"lint\" could not be read statically (expected { rules: { code: \"off\" | \"warn\" | \"error\" | [level, options] } }).");
1589
+ }
1322
1590
  }
1323
1591
  /** Reads a config file's source text. Exposed for tests and for unsaved buffers. */
1324
1592
  function parseStaticConfig(text, file = "dialex.config.ts") {
1325
1593
  const notes = [];
1326
1594
  const config = { ...DEFAULT_STATIC_CONFIG };
1595
+ let opaque = false;
1327
1596
  if (file.endsWith(".json")) {
1328
1597
  try {
1329
1598
  const data = JSON.parse(text);
1330
- if (data && typeof data === "object") apply(data, config, notes);
1599
+ if (data && typeof data === "object") applyConfigValues(data, config, notes);
1331
1600
  } catch (error) {
1332
1601
  notes.push(`Could not parse ${path.basename(file)}: ${error.message}`);
1333
1602
  }
1334
1603
  return {
1335
1604
  config,
1336
1605
  file,
1337
- notes
1606
+ notes,
1607
+ opaque
1338
1608
  };
1339
1609
  }
1340
1610
  try {
@@ -1343,22 +1613,15 @@ function parseStaticConfig(text, file = "dialex.config.ts") {
1343
1613
  if (!object) notes.push(`${path.basename(file)} does not export a config object literal.`);
1344
1614
  else {
1345
1615
  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);
1616
+ opaque = object.properties.some((p) => p.type === "SpreadElement" || p.type === "SpreadProperty" || p.computed === true);
1617
+ for (const key of CONFIG_KEYS) {
1618
+ const prop = findProperty(object, key);
1356
1619
  if (!prop) continue;
1357
1620
  const value = literal(prop.value);
1358
1621
  if (value === NOT_LITERAL) notes.push(`"${key}" could not be read statically (not a literal).`);
1359
1622
  else values[key] = value;
1360
1623
  }
1361
- apply(values, config, notes);
1624
+ applyConfigValues(values, config, notes);
1362
1625
  }
1363
1626
  } catch (error) {
1364
1627
  notes.push(`Could not parse ${path.basename(file)}: ${error.message}`);
@@ -1366,16 +1629,17 @@ function parseStaticConfig(text, file = "dialex.config.ts") {
1366
1629
  return {
1367
1630
  config,
1368
1631
  file,
1369
- notes
1632
+ notes,
1633
+ opaque
1370
1634
  };
1371
1635
  }
1372
1636
  /** Reads `dialex.config.*` (or `i18n.config.*`) from `root`, or from `configFile` if given. */
1373
1637
  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);
1638
+ for (const file of configCandidates(root, configFile)) if (fs.existsSync(file)) return parseStaticConfig(fs.readFileSync(file, "utf-8"), file);
1376
1639
  return {
1377
1640
  config: { ...DEFAULT_STATIC_CONFIG },
1378
- notes: []
1641
+ notes: [],
1642
+ opaque: false
1379
1643
  };
1380
1644
  }
1381
1645
  //#endregion
@@ -1408,7 +1672,15 @@ function configFile(from) {
1408
1672
  if (fs.existsSync(from) && fs.statSync(from).isFile()) return from;
1409
1673
  for (const candidate of [`${from}.json`, path.join(from, "tsconfig.json")]) if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
1410
1674
  }
1411
- /** `moduleResolution` and `include` of a tsconfig, following relative `extends`. */
1675
+ /** A package in `extends` (`@adonisjs/tsconfig/tsconfig.app.json`), looked up from `dir` upwards. */
1676
+ function packageConfig(from, specifier) {
1677
+ for (let dir = from;; dir = path.dirname(dir)) {
1678
+ const found = configFile(path.join(dir, "node_modules", specifier));
1679
+ if (found) return found;
1680
+ if (path.dirname(dir) === dir) return void 0;
1681
+ }
1682
+ }
1683
+ /** `moduleResolution` and `include` of a tsconfig, following `extends` (relative or a package). */
1412
1684
  function read(file, depth = 0) {
1413
1685
  const result = { references: [] };
1414
1686
  let json;
@@ -1418,8 +1690,12 @@ function read(file, depth = 0) {
1418
1690
  return result;
1419
1691
  }
1420
1692
  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));
1693
+ const parents = Array.isArray(json.extends) ? json.extends : [json.extends];
1694
+ for (const extendsFrom of parents) {
1695
+ if (typeof extendsFrom !== "string" || depth >= 5) continue;
1696
+ const parent = extendsFrom.startsWith(".") ? configFile(path.resolve(dir, extendsFrom)) : packageConfig(dir, extendsFrom);
1697
+ if (parent) Object.assign(result, read(parent, depth + 1));
1698
+ }
1423
1699
  const options = json.compilerOptions ?? {};
1424
1700
  const module = typeof options.module === "string" ? options.module.toLowerCase() : void 0;
1425
1701
  const resolution = typeof options.moduleResolution === "string" ? options.moduleResolution.toLowerCase() : module === "node16" || module === "nodenext" ? module : void 0;
@@ -1462,7 +1738,8 @@ function renderGenerated(root, options = {}) {
1462
1738
  const config = options.static ? staticRead.config : resolveDialexConfig(root, inlineConfig);
1463
1739
  const found = scanFiles(root, config.include || DEFAULT_CONFIG.include, { exclude: config.exclude }).sort();
1464
1740
  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");
1741
+ const outputOption = options.output ?? config.output;
1742
+ const outputPath = outputOption ? path.resolve(root, outputOption) : path.join(srcDir, "dialex.generated.ts");
1466
1743
  const outputDir = path.dirname(outputPath);
1467
1744
  const files = found.filter((file) => file !== outputPath && !file.endsWith(".d.ts") && path.basename(file) !== "dialex.generated.ts");
1468
1745
  const parsed = files.map((file) => parseDictionaryText(file, fs.readFileSync(file, "utf-8")));
@@ -1552,12 +1829,12 @@ export const dialex = { dictionaries, config };
1552
1829
  export default dictionaries;
1553
1830
  `;
1554
1831
  }
1555
- const dts = renderDts(root, files, locales, messageArguments(parsed, clientConfig.defaultLocale));
1832
+ const dts = renderDts(root, files, locales, messageArguments(parsed, clientConfig.defaultLocale), outputOption ? outputDir : void 0);
1556
1833
  return {
1557
1834
  files,
1558
1835
  outputPath,
1559
1836
  content,
1560
- dtsPath: dts?.path ?? path.join(srcDir, "dialex-env.d.ts"),
1837
+ dtsPath: dts?.path ?? path.join(outputOption ? outputDir : srcDir, "dialex-env.d.ts"),
1561
1838
  dtsContent: dts?.content,
1562
1839
  configFile: staticRead.file,
1563
1840
  extraFiles,
@@ -1729,6 +2006,6 @@ async function loadStaticProject(root, configFile) {
1729
2006
  /** `home` + `["nav", "about"]` -> `home.nav.about` */
1730
2007
  const toKey = (dictionary, keyPath) => [dictionary, ...keyPath].join(".");
1731
2008
  //#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 };
2009
+ export { SOURCE_BUILTIN_IGNORE as A, listLocales as B, isStale as C, record as D, readLock as E, dictionaryLocation as F, saveDictionaryFile as G, locationOf as H, getLeafSource as I, setString as K, getString as L, scanFiles as M, TODO_PREFIX as N, writeLock as O, copyLeaf as P, hasPath as R, hashSource as S, lockPath as T, parseDictionaryText as U, loadDictionaryFile as V, renderDictionaryFile as W, createAnalysisCache as _, renderGenerated as a, scanText as b, DEFAULT_STATIC_CONFIG as c, resolveDialexConfig as d, logger as f, clearGitignore as g, analyzeProjectSync as h, generateDictionaries as i, createGitignoreFilter as j, BUILTIN_IGNORE 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, relativeFile as v, lockFor as w, LOCK_FILE as x, scanReferences as y, listLeaves as z };
1733
2010
 
1734
- //# sourceMappingURL=project-Dz0FbydQ.mjs.map
2011
+ //# sourceMappingURL=project-B27699T2.mjs.map