@dialexjs/cli 0.2.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,122 +1,17 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { renderDts, resolveDialexConfig } from "dialexjs/scanner";
3
4
  import fg from "fast-glob";
4
- import chokidar from "chokidar";
5
- import { generateDts, resolveI18nConfig } from "dialexjs/scanner";
6
5
  import pc from "picocolors";
7
- import { generateCode, parseExpression, parseModule, writeFile } from "magicast";
8
6
  import { pathToFileURL } from "node:url";
9
- import { argumentSignature, findPlurals, isIcuStructured, parseMessage } from "dialexjs/icu";
10
- //#region src/utils/logger.ts
11
- const logger = {
12
- info: (msg) => console.log(pc.blue("ℹ ") + msg),
13
- success: (msg) => console.log(pc.green("✔ ") + pc.bold(msg)),
14
- warn: (msg) => console.warn(pc.yellow("⚠ ") + msg),
15
- error: (msg) => console.error(pc.red("✖ ") + pc.bold(msg)),
16
- log: (msg) => console.log(msg)
17
- };
18
- //#endregion
19
- //#region src/commands/generate.ts
20
- function generateDictionaries(root, options = {}) {
21
- const inlineConfig = options.config ? { configFile: options.config } : {};
22
- const config = resolveI18nConfig(root, inlineConfig);
23
- const include = config.include || "**/*.content.ts";
24
- const files = fg.sync(include, {
25
- cwd: root,
26
- absolute: true,
27
- ignore: [
28
- "**/node_modules/**",
29
- "**/dist/**",
30
- "**/.next/**"
31
- ]
32
- });
33
- const srcDir = fs.existsSync(path.join(root, "src")) ? path.join(root, "src") : root;
34
- const targetOutput = options.output ? path.resolve(root, options.output) : path.join(srcDir, "dialex.generated.ts");
35
- const targetDir = path.dirname(targetOutput);
36
- generateDts(root, files, config.locales);
37
- const dtsPath = path.join(srcDir, "dialex-env.d.ts");
38
- const importLines = [];
39
- const dictIdentifiers = [];
40
- files.forEach((file, idx) => {
41
- let rel = path.relative(targetDir, file).replace(/\\/g, "/");
42
- rel = rel.replace(/\.ts$/, ".js");
43
- if (!rel.startsWith(".")) rel = "./" + rel;
44
- const id = `dict_${idx}`;
45
- importLines.push(`import ${id} from "${rel}";`);
46
- dictIdentifiers.push(id);
47
- });
48
- const dictList = dictIdentifiers.length <= 1 ? `[${dictIdentifiers.join(", ")}]` : `[\n ${dictIdentifiers.join(",\n ")}\n]`;
49
- const content = `// Auto-generated by dialex CLI. Do not edit directly.
50
- /* eslint-disable */
51
- // @ts-nocheck
52
- ${importLines.join("\n")}
53
-
54
- export const dictionaries = ${dictList};
55
-
56
- export const dictionaryMap = {
57
- ${dictIdentifiers.map((id) => ` [${id}.name]: ${id}.dictionary,`).join("\n")}
58
- };
59
-
60
- export default dictionaries;
61
- `;
62
- fs.mkdirSync(targetDir, { recursive: true });
63
- if ((fs.existsSync(targetOutput) ? fs.readFileSync(targetOutput, "utf-8") : "") !== content) fs.writeFileSync(targetOutput, content, "utf-8");
64
- return {
65
- files,
66
- outputPath: targetOutput,
67
- dtsPath
68
- };
69
- }
70
- async function runGenerate(options = {}) {
71
- const root = options.cwd || process.cwd();
72
- const quiet = options.json === true;
73
- const doGenerate = () => {
74
- try {
75
- const { files, outputPath, dtsPath } = generateDictionaries(root, options);
76
- if (quiet) console.log(JSON.stringify({
77
- success: true,
78
- files: files.map((f) => path.relative(root, f)),
79
- outputPath: path.relative(root, outputPath),
80
- dtsPath: path.relative(root, dtsPath)
81
- }, null, 2));
82
- else {
83
- logger.success(`Generated dictionaries for ${files.length} file${files.length === 1 ? "" : "s"}:`);
84
- logger.info(`→ ${path.relative(root, outputPath)}`);
85
- logger.info(`→ ${path.relative(root, dtsPath)}`);
86
- }
87
- return true;
88
- } catch (err) {
89
- if (quiet) console.log(JSON.stringify({
90
- success: false,
91
- error: err.message || String(err)
92
- }, null, 2));
93
- else logger.error(`Generation failed: ${err.message || String(err)}`);
94
- return false;
95
- }
96
- };
97
- if (!doGenerate() && !options.watch) process.exit(1);
98
- if (options.watch) {
99
- logger.info("Watching for dictionary changes (-w, --watch)...");
100
- const inlineConfig = options.config ? { configFile: options.config } : {};
101
- const pattern = resolveI18nConfig(root, inlineConfig).include || "**/*.content.ts";
102
- chokidar.watch(pattern, {
103
- cwd: root,
104
- ignoreInitial: true,
105
- ignored: [
106
- "**/node_modules/**",
107
- "**/dist/**",
108
- "**/.next/**"
109
- ]
110
- }).on("all", (event, filePath) => {
111
- logger.info(`[${event}] ${filePath} changed, regenerating...`);
112
- doGenerate();
113
- });
114
- }
115
- }
116
- //#endregion
7
+ import { generateCode, parseExpression, parseModule, writeFile } from "magicast";
8
+ import { argumentSignature, findPlurals, getArguments, isIcuStructured, parseMessage } from "dialexjs/icu";
9
+ import { createHash } from "node:crypto";
10
+ import chokidar from "chokidar";
11
+ import { DEFAULT_CONFIG } from "dialexjs";
117
12
  //#region src/utils/dictionary-edit.ts
118
13
  const TODO_PREFIX = "[TODO] ";
119
- const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
14
+ const IDENTIFIER$1 = /^[A-Za-z_$][\w$]*$/;
120
15
  function unwrap(node) {
121
16
  while (node && (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "ParenthesizedExpression")) node = node.expression;
122
17
  return node;
@@ -171,7 +66,7 @@ function locateLocalesObject(declaration) {
171
66
  }
172
67
  /** Quotes a property key only when it is not a valid identifier. */
173
68
  function keyCode(key) {
174
- return IDENTIFIER.test(key) ? key : JSON.stringify(key);
69
+ return IDENTIFIER$1.test(key) ? key : JSON.stringify(key);
175
70
  }
176
71
  /** Builds a detached `key: <value>` property node from source text. */
177
72
  function makeProp(key, valueCode) {
@@ -459,15 +354,84 @@ function checkIcu(dictionary, strings, defaultLocale) {
459
354
  return findings;
460
355
  }
461
356
  //#endregion
357
+ //#region src/utils/lockfile.ts
358
+ const LOCK_FILE = "dialex.lock.json";
359
+ const emptyLock = (sourceLocale) => ({
360
+ version: 1,
361
+ ...sourceLocale ? { sourceLocale } : {},
362
+ locales: {}
363
+ });
364
+ const lockPath = (root) => path.join(root, LOCK_FILE);
365
+ /** A short, stable fingerprint of a source string. */
366
+ function hashSource(text) {
367
+ return createHash("sha1").update(text).digest("hex").slice(0, 8);
368
+ }
369
+ /** The lock file, or `undefined` when there is none (or it cannot be read). */
370
+ function readLock(root) {
371
+ try {
372
+ const data = JSON.parse(fs.readFileSync(lockPath(root), "utf-8"));
373
+ if (data?.version !== 1 || typeof data.locales !== "object" || data.locales === null) return;
374
+ return {
375
+ version: 1,
376
+ ...typeof data.sourceLocale === "string" ? { sourceLocale: data.sourceLocale } : {},
377
+ locales: data.locales
378
+ };
379
+ } catch {
380
+ return;
381
+ }
382
+ }
383
+ /** Writes the lock with sorted keys, so it produces small, stable diffs. */
384
+ function writeLock(root, lock) {
385
+ const sorted = {
386
+ version: 1,
387
+ ...lock.sourceLocale ? { sourceLocale: lock.sourceLocale } : {},
388
+ locales: Object.fromEntries(Object.keys(lock.locales).sort().map((locale) => [locale, Object.fromEntries(Object.entries(lock.locales[locale]).sort(([a], [b]) => a.localeCompare(b)))]))
389
+ };
390
+ fs.writeFileSync(lockPath(root), JSON.stringify(sorted, null, 2) + "\n", "utf-8");
391
+ }
392
+ /** Notes that `key` in `locale` was written from this source text. */
393
+ function record(lock, locale, key, sourceText) {
394
+ (lock.locales[locale] ??= {})[key] = hashSource(sourceText);
395
+ }
396
+ /**
397
+ * True when `key` was recorded for `locale` from different source text than `sourceText`. A key
398
+ * with no record is not stale.
399
+ */
400
+ function isStale(lock, locale, key, sourceText) {
401
+ const recorded = lock.locales[locale]?.[key];
402
+ return recorded !== void 0 && recorded !== hashSource(sourceText);
403
+ }
404
+ /**
405
+ * The lock to use for a run translating from `sourceLocale`. A lock written from another source
406
+ * locale says nothing about this one, so it is replaced by a fresh one.
407
+ */
408
+ function lockFor(root, sourceLocale) {
409
+ const existing = readLock(root);
410
+ if (!existing) return {
411
+ lock: emptyLock(sourceLocale),
412
+ replaced: false
413
+ };
414
+ if (existing.sourceLocale && existing.sourceLocale !== sourceLocale) return {
415
+ lock: emptyLock(sourceLocale),
416
+ replaced: true
417
+ };
418
+ existing.sourceLocale = sourceLocale;
419
+ return {
420
+ lock: existing,
421
+ replaced: false
422
+ };
423
+ }
424
+ //#endregion
462
425
  //#region src/analysis.ts
463
- const DICTIONARY_IGNORE = [
426
+ const DICTIONARY_IGNORE$1 = [
464
427
  "**/node_modules/**",
465
428
  "**/dist/**",
466
- "**/.next/**"
429
+ "**/.next/**",
430
+ "**/dialex.locales/**"
467
431
  ];
468
432
  const DEFAULT_SOURCE_GLOB = "**/*.{ts,tsx,js,jsx,mjs,cjs,vue,svelte,astro,mdx}";
469
433
  const SOURCE_IGNORE = [
470
- ...DICTIONARY_IGNORE,
434
+ ...DICTIONARY_IGNORE$1,
471
435
  "**/.nuxt/**",
472
436
  "**/.output/**",
473
437
  "**/.svelte-kit/**",
@@ -479,8 +443,8 @@ const SOURCE_IGNORE = [
479
443
  "**/dialex.config.*",
480
444
  "**/i18n.config.*"
481
445
  ];
482
- /** `getDictionary("x")`, `useDictionary("x")`, `@I18nDictionary("x")` */
483
- const DICTIONARY_CALL = /\b(?:getDictionary|useDictionary|I18nDictionary)\(\s*(["'`])([^"'`$\n]+)\1/dg;
446
+ /** `getDictionary("x")`, `useDictionary("x")`, `@DialexDictionary("x")` */
447
+ const DICTIONARY_CALL = /\b(?:getDictionary|useDictionary|DialexDictionary)\(\s*(["'`])([^"'`$\n]+)\1/dg;
484
448
  /** `t("dictionary.path.to.key")` */
485
449
  const T_CALL = /(?<![\w$.])t\(\s*(["'`])([\w-]+(?:\.[\w-]+)+)\1/dg;
486
450
  /** Finds `getDictionary("x")`-style and `t("x.y.z")` references in source text. */
@@ -622,10 +586,11 @@ async function analyzeProject(options) {
622
586
  const files = fg.sync(include, {
623
587
  cwd: root,
624
588
  absolute: true,
625
- ignore: [...DICTIONARY_IGNORE, ...extraIgnore]
589
+ ignore: [...DICTIONARY_IGNORE$1, ...extraIgnore]
626
590
  });
627
591
  const issues = [];
628
592
  const dictionaries = [];
593
+ const lock = options.lock === false ? void 0 : readLock(root);
629
594
  for (const file of files) {
630
595
  const loaded = await loadDictionaryData(file, options.runtime === true);
631
596
  if (!loaded.ok) {
@@ -691,6 +656,26 @@ async function analyzeProject(options) {
691
656
  });
692
657
  }
693
658
  }
659
+ const sourceLeaves = lock?.sourceLocale ? data.leaves.get(lock.sourceLocale) : void 0;
660
+ if (lock && sourceLeaves) for (const locale of data.locales) {
661
+ if (locale === lock.sourceLocale) continue;
662
+ for (const [leafPath, leaf] of data.leaves.get(locale)) {
663
+ if (leaf.kind !== "string" || leaf.value === void 0) continue;
664
+ if (leaf.value.startsWith("[TODO] ")) continue;
665
+ const source = sourceLeaves.get(leafPath);
666
+ if (source?.kind !== "string" || source.value === void 0) continue;
667
+ if (!isStale(lock, locale, `${data.name}.${leafPath}`, source.value)) continue;
668
+ add({
669
+ level: "warning",
670
+ code: "stale-translation",
671
+ message: `Locale "${locale}" key "${leafPath}" is out of date: the "${lock.sourceLocale}" text changed after it was translated`,
672
+ range: at(locale, leafPath.split(".")),
673
+ locale,
674
+ path: leafPath.split("."),
675
+ sourceLocale: lock.sourceLocale
676
+ });
677
+ }
678
+ }
694
679
  for (const finding of checkIcu(data.name, strings, defaultLocale)) {
695
680
  const keyPath = finding.key.slice(data.name.length + 1).split(".");
696
681
  add({
@@ -801,6 +786,186 @@ function relativeFile(root, file) {
801
786
  return path.relative(root, file);
802
787
  }
803
788
  //#endregion
789
+ //#region src/utils/logger.ts
790
+ const logger = {
791
+ info: (msg) => console.log(pc.blue("ℹ ") + msg),
792
+ success: (msg) => console.log(pc.green("✔ ") + pc.bold(msg)),
793
+ warn: (msg) => console.warn(pc.yellow("⚠ ") + msg),
794
+ error: (msg) => console.error(pc.red("✖ ") + pc.bold(msg)),
795
+ log: (msg) => console.log(msg)
796
+ };
797
+ //#endregion
798
+ //#region src/utils/message-arguments.ts
799
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
800
+ /**
801
+ * The TypeScript type of one ICU argument. A name can appear more than once (in a `plural` and as
802
+ * a plain `{name}`, say); the narrowest use wins, because that is the value the message needs.
803
+ */
804
+ function typeOfArgument(uses) {
805
+ if (uses.some((u) => u.type === "plural" || u.type === "selectordinal" || u.type === "number")) return "number";
806
+ if (uses.some((u) => u.type === "date" || u.type === "time")) return "Date | number";
807
+ const select = uses.find((u) => u.type === "select");
808
+ if (select) {
809
+ const options = (select.selectors ?? []).filter((s) => s !== "other");
810
+ return options.length > 0 ? `${options.map((o) => JSON.stringify(o)).join(" | ")} | (string & {})` : "string";
811
+ }
812
+ return "string | number | boolean | Date";
813
+ }
814
+ /** `{ count: number; name: string | number | boolean | Date }` for the arguments of one message. */
815
+ function messageType(message) {
816
+ if (!message.includes("{")) return void 0;
817
+ let args;
818
+ try {
819
+ args = getArguments(parseMessage(message));
820
+ } catch {
821
+ return;
822
+ }
823
+ if (args.length === 0) return void 0;
824
+ const byName = /* @__PURE__ */ new Map();
825
+ for (const arg of args) byName.set(arg.name, [...byName.get(arg.name) ?? [], arg]);
826
+ return `{ ${[...byName].map(([name, uses]) => `${IDENTIFIER.test(name) ? name : JSON.stringify(name)}: ${typeOfArgument(uses)}`).join("; ")} }`;
827
+ }
828
+ /**
829
+ * `dictionary.key.path` to the type of its ICU values, read from each dictionary's reference
830
+ * locale (the default locale when the dictionary has it, otherwise its first). Other locales must
831
+ * use the same arguments, which `dx check` verifies.
832
+ */
833
+ function messageArguments(dictionaries, defaultLocale) {
834
+ const result = {};
835
+ for (const df of dictionaries) {
836
+ if (!df?.name) continue;
837
+ const locales = listLocales(df);
838
+ const reference = locales.includes(defaultLocale) ? defaultLocale : locales[0];
839
+ if (!reference) continue;
840
+ for (const leaf of listLeaves(df, reference)) {
841
+ if (leaf.kind !== "string" || leaf.value === void 0) continue;
842
+ const type = messageType(leaf.value);
843
+ if (type) result[`${df.name}.${leaf.path.join(".")}`] = type;
844
+ }
845
+ }
846
+ return result;
847
+ }
848
+ //#endregion
849
+ //#region src/utils/split-locales.ts
850
+ const SKIPPED_KEYS = /* @__PURE__ */ new Set([
851
+ "loc",
852
+ "start",
853
+ "end",
854
+ "extra",
855
+ "range",
856
+ "comments",
857
+ "leadingComments",
858
+ "trailingComments",
859
+ "innerComments",
860
+ "tokens"
861
+ ]);
862
+ /** Every identifier name used in a node, except property names that are not variable references. */
863
+ function usedNames(node, found = /* @__PURE__ */ new Set()) {
864
+ if (!node || typeof node !== "object") return found;
865
+ if (Array.isArray(node)) {
866
+ node.forEach((child) => usedNames(child, found));
867
+ return found;
868
+ }
869
+ const ast = node;
870
+ if (ast.type === "Identifier") found.add(ast.name);
871
+ for (const key of Object.keys(ast)) {
872
+ if (SKIPPED_KEYS.has(key)) continue;
873
+ const isPropertyName = key === "key" && !ast.computed && [
874
+ "ObjectProperty",
875
+ "ObjectMethod",
876
+ "TSPropertySignature",
877
+ "TSMethodSignature"
878
+ ].includes(ast.type);
879
+ const isMemberName = key === "property" && !ast.computed && (ast.type === "MemberExpression" || ast.type === "OptionalMemberExpression");
880
+ if (isPropertyName || isMemberName) continue;
881
+ usedNames(ast[key], found);
882
+ }
883
+ return found;
884
+ }
885
+ /** Names bound anywhere in a pattern (`const { a, b: [c] } = ...`). */
886
+ function patternNames(pattern, found) {
887
+ if (!pattern) return;
888
+ switch (pattern.type) {
889
+ case "Identifier":
890
+ found.add(pattern.name);
891
+ break;
892
+ case "ObjectPattern":
893
+ for (const prop of pattern.properties) patternNames(prop.value ?? prop.argument, found);
894
+ break;
895
+ case "ArrayPattern":
896
+ for (const element of pattern.elements) patternNames(element ?? void 0, found);
897
+ break;
898
+ case "AssignmentPattern":
899
+ patternNames(pattern.left, found);
900
+ break;
901
+ case "RestElement": patternNames(pattern.argument, found);
902
+ }
903
+ }
904
+ /** What the file declares or imports at the top level: a locale that uses any of it cannot move. */
905
+ function topLevelNames(df) {
906
+ const names = /* @__PURE__ */ new Set();
907
+ const program = df.mod.$ast;
908
+ const statements = program?.body ?? program?.program?.body ?? [];
909
+ for (const raw of statements) {
910
+ const statement = raw.type === "ExportNamedDeclaration" && raw.declaration ? raw.declaration : raw;
911
+ switch (statement.type) {
912
+ case "ImportDeclaration":
913
+ for (const specifier of statement.specifiers) names.add(specifier.local.name);
914
+ break;
915
+ case "VariableDeclaration":
916
+ for (const declarator of statement.declarations) patternNames(declarator.id, names);
917
+ break;
918
+ case "FunctionDeclaration":
919
+ case "ClassDeclaration":
920
+ case "TSInterfaceDeclaration":
921
+ case "TSTypeAliasDeclaration":
922
+ case "TSEnumDeclaration": if (statement.id?.name) names.add(statement.id.name);
923
+ }
924
+ }
925
+ return names;
926
+ }
927
+ /**
928
+ * Cuts a dictionary into one self-contained piece per locale, for `lazy: "locale"`.
929
+ *
930
+ * A piece moves to its own file, so it must not depend on anything else in the original file.
931
+ * That is checked conservatively: if a locale uses any name the file imports or declares (even one
932
+ * that is only shadowed), the whole file is left alone. A missed split costs bytes; a wrong split
933
+ * would break the app.
934
+ */
935
+ function splitLocales(df) {
936
+ if (!df.complete) return {
937
+ ok: false,
938
+ reason: "it uses spreads, computed keys or non-literal locale values"
939
+ };
940
+ const bound = topLevelNames(df);
941
+ const slices = [];
942
+ for (const prop of df.localesObj.properties) {
943
+ const locale = keyName(prop);
944
+ const value = unwrap(prop.value);
945
+ if (locale === void 0 || !isObject(value)) return {
946
+ ok: false,
947
+ reason: "a locale is not an object literal"
948
+ };
949
+ const clash = [...usedNames(value)].find((name) => bound.has(name));
950
+ if (clash) return {
951
+ ok: false,
952
+ reason: `"${locale}" uses "${clash}", which is defined elsewhere in the file`
953
+ };
954
+ slices.push({
955
+ locale,
956
+ code: generateCode(value).code
957
+ });
958
+ }
959
+ if (slices.length === 0) return {
960
+ ok: false,
961
+ reason: "it defines no locales"
962
+ };
963
+ return {
964
+ ok: true,
965
+ slices
966
+ };
967
+ }
968
+ //#endregion
804
969
  //#region src/utils/static-config.ts
805
970
  const CONFIG_NAMES = ["dialex.config", "i18n.config"];
806
971
  const EXTENSIONS = [
@@ -865,7 +1030,7 @@ function apply(object, config, notes) {
865
1030
  accept("include", (v) => typeof v === "string" || isStringArray(v), "a string or array of strings");
866
1031
  accept("fallbacks", (v) => typeof v === "object" && v !== null && Object.values(v).every((list) => isStringArray(list)), "an object of string arrays");
867
1032
  accept("prefixDefault", (v) => typeof v === "boolean", "true or false");
868
- accept("lazy", (v) => typeof v === "boolean", "true or false");
1033
+ accept("lazy", (v) => typeof v === "boolean" || v === "locale", "true, false or \"locale\"");
869
1034
  }
870
1035
  /** Reads a config file's source text. Exposed for tests and for unsaved buffers. */
871
1036
  function parseStaticConfig(text, file = "dialex.config.ts") {
@@ -925,6 +1090,313 @@ function readStaticConfig(root, configFile) {
925
1090
  };
926
1091
  }
927
1092
  //#endregion
1093
+ //#region src/utils/tsconfig.ts
1094
+ /** Parses tsconfig-style JSON: comments and trailing commas are allowed. */
1095
+ function parseJsonc(text) {
1096
+ let out = "";
1097
+ let inString = false;
1098
+ for (let i = 0; i < text.length; i++) {
1099
+ const c = text[i];
1100
+ if (inString) {
1101
+ out += c;
1102
+ if (c === "\\") out += text[++i] ?? "";
1103
+ else if (c === "\"") inString = false;
1104
+ } else if (c === "\"") {
1105
+ inString = true;
1106
+ out += c;
1107
+ } else if (c === "/" && text[i + 1] === "/") {
1108
+ while (i < text.length && text[i] !== "\n") i++;
1109
+ out += "\n";
1110
+ } else if (c === "/" && text[i + 1] === "*") {
1111
+ i += 2;
1112
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
1113
+ i++;
1114
+ } else out += c;
1115
+ }
1116
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
1117
+ }
1118
+ function configFile(from) {
1119
+ if (fs.existsSync(from) && fs.statSync(from).isFile()) return from;
1120
+ for (const candidate of [`${from}.json`, path.join(from, "tsconfig.json")]) if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
1121
+ }
1122
+ /** `moduleResolution` and `include` of a tsconfig, following relative `extends`. */
1123
+ function read(file, depth = 0) {
1124
+ const result = { references: [] };
1125
+ let json;
1126
+ try {
1127
+ json = parseJsonc(fs.readFileSync(file, "utf-8"));
1128
+ } catch {
1129
+ return result;
1130
+ }
1131
+ const dir = path.dirname(file);
1132
+ const parent = typeof json.extends === "string" && json.extends.startsWith(".") && depth < 5 ? configFile(path.resolve(dir, json.extends)) : void 0;
1133
+ if (parent) Object.assign(result, read(parent, depth + 1));
1134
+ const options = json.compilerOptions ?? {};
1135
+ const module = typeof options.module === "string" ? options.module.toLowerCase() : void 0;
1136
+ const resolution = typeof options.moduleResolution === "string" ? options.moduleResolution.toLowerCase() : module === "node16" || module === "nodenext" ? module : void 0;
1137
+ if (resolution) result.moduleResolution = resolution;
1138
+ if (Array.isArray(json.include)) result.include = json.include;
1139
+ result.references = (json.references ?? []).map((ref) => typeof ref?.path === "string" ? configFile(path.resolve(dir, ref.path)) : void 0).filter(Boolean);
1140
+ return result;
1141
+ }
1142
+ /**
1143
+ * The extension for relative imports in generated files. TypeScript's `node16`/`nodenext` need
1144
+ * `./x.js`; bundlers (webpack, Vite) resolve extensionless imports, and webpack cannot map `.js`
1145
+ * to a `.ts` file. Without any tsconfig it is plain Node ESM, which needs the extension.
1146
+ */
1147
+ function relativeImportExtension(root) {
1148
+ const file = configFile(path.join(root, "tsconfig.json"));
1149
+ if (!file) return ".js";
1150
+ let resolved = read(file);
1151
+ if (!resolved.moduleResolution && resolved.references.length > 0) {
1152
+ const projects = resolved.references.map((ref) => read(ref));
1153
+ resolved = projects.find((p) => p.include?.some((entry) => /^(\.\/)?src(\/|$)/.test(entry))) ?? projects[0] ?? resolved;
1154
+ }
1155
+ const resolution = resolved.moduleResolution;
1156
+ return resolution === "node16" || resolution === "nodenext" ? ".js" : "";
1157
+ }
1158
+ //#endregion
1159
+ //#region src/commands/generate.ts
1160
+ const DICTIONARY_IGNORE = [
1161
+ "**/node_modules/**",
1162
+ "**/dist/**",
1163
+ "**/.next/**",
1164
+ "**/dialex.locales/**"
1165
+ ];
1166
+ /** The folder, next to `dialex.generated.ts`, that holds the per-locale modules. */
1167
+ const LOCALES_DIR = "dialex.locales";
1168
+ const safeName = (text) => text.replace(/[^A-Za-z0-9._-]/g, "_");
1169
+ /**
1170
+ * Works out `dialex.generated.ts` and `dialex-env.d.ts` for a project without writing anything,
1171
+ * so `check` can tell whether they are up to date.
1172
+ *
1173
+ * Settings come from `dialex.config.*` and fall back to {@link DEFAULT_CONFIG}; `locales` falls
1174
+ * back to the locales the dictionaries define.
1175
+ */
1176
+ function renderGenerated(root, options = {}) {
1177
+ const inlineConfig = options.config ? { configFile: options.config } : {};
1178
+ const staticRead = readStaticConfig(root, options.config);
1179
+ const config = options.static ? staticRead.config : resolveDialexConfig(root, inlineConfig);
1180
+ const include = config.include || DEFAULT_CONFIG.include;
1181
+ const found = fg.sync(include, {
1182
+ cwd: root,
1183
+ absolute: true,
1184
+ ignore: DICTIONARY_IGNORE
1185
+ }).sort();
1186
+ const srcDir = fs.existsSync(path.join(root, "src")) ? path.join(root, "src") : root;
1187
+ const outputPath = options.output ? path.resolve(root, options.output) : path.join(srcDir, "dialex.generated.ts");
1188
+ const outputDir = path.dirname(outputPath);
1189
+ const files = found.filter((file) => file !== outputPath && !file.endsWith(".d.ts") && path.basename(file) !== "dialex.generated.ts");
1190
+ const parsed = files.map((file) => parseDictionaryText(file, fs.readFileSync(file, "utf-8")));
1191
+ const inferred = [];
1192
+ for (const df of parsed) if (df) {
1193
+ for (const locale of listLocales(df)) if (!inferred.includes(locale)) inferred.push(locale);
1194
+ }
1195
+ const locales = config.locales?.length ? config.locales : inferred;
1196
+ const clientConfig = {
1197
+ defaultLocale: config.defaultLocale || DEFAULT_CONFIG.defaultLocale,
1198
+ locales,
1199
+ fallbacks: config.fallbacks ?? DEFAULT_CONFIG.fallbacks,
1200
+ prefixDefault: config.prefixDefault ?? DEFAULT_CONFIG.prefixDefault,
1201
+ lazy: config.lazy ?? DEFAULT_CONFIG.lazy
1202
+ };
1203
+ const extension = relativeImportExtension(root);
1204
+ const importPath = (file) => {
1205
+ let rel = path.relative(outputDir, file).replace(/\\/g, "/").replace(/\.ts$/, extension);
1206
+ if (!rel.startsWith(".")) rel = "./" + rel;
1207
+ return rel;
1208
+ };
1209
+ const header = `// Auto-generated by dialex CLI. Do not edit directly.
1210
+ /* eslint-disable */
1211
+ // @ts-nocheck`;
1212
+ const configExport = `export const config = ${JSON.stringify(clientConfig, null, 2).replace("\"lazy\": \"locale\"", "\"lazy\": \"locale\" as const")};
1213
+
1214
+ ${locales.length > 0 ? `export const locales = ${JSON.stringify(locales)} as const;\nexport type Locale = (typeof locales)[number];` : "export const locales: readonly string[] = [];\nexport type Locale = string;"}`;
1215
+ const extraFiles = [];
1216
+ const notes = [];
1217
+ const takenNames = /* @__PURE__ */ new Set();
1218
+ let content;
1219
+ if (clientConfig.lazy) content = `${header}
1220
+
1221
+ export const dictionaries = [];
1222
+
1223
+ export const dictionaryMap = {};
1224
+
1225
+ export const loaders = {
1226
+ ${files.map((file, i) => {
1227
+ const df = parsed[i];
1228
+ const name = df?.name;
1229
+ if (!name || !df) throw new Error(`Cannot determine the dictionary name of ${path.relative(root, file)} for lazy loading. Use defineDictionary("name", ...) or a literal \`name\` property.`);
1230
+ const whole = ` ${JSON.stringify(name)}: () => import("${importPath(file)}"),`;
1231
+ if (clientConfig.lazy !== "locale") return whole;
1232
+ const split = splitLocales(df);
1233
+ if (!split.ok) {
1234
+ notes.push(`${path.relative(root, file)} is loaded whole, not per locale: ${split.reason}.`);
1235
+ return whole;
1236
+ }
1237
+ let base = safeName(name);
1238
+ for (let n = 2; takenNames.has(base); n++) base = `${safeName(name)}-${n}`;
1239
+ takenNames.add(base);
1240
+ const entries = split.slices.map((slice) => {
1241
+ const target = path.join(outputDir, LOCALES_DIR, `${base}.${safeName(slice.locale)}.ts`);
1242
+ extraFiles.push({
1243
+ path: target,
1244
+ content: `${header}\nexport default ${slice.code};\n`
1245
+ });
1246
+ return ` ${JSON.stringify(slice.locale)}: () => import("${importPath(target)}"),`;
1247
+ });
1248
+ return ` ${JSON.stringify(name)}: {\n${entries.join("\n")}\n },`;
1249
+ }).join("\n")}
1250
+ };
1251
+
1252
+ ${configExport}
1253
+
1254
+ export const dialex = { dictionaries, config, loaders };
1255
+
1256
+ export default dictionaries;
1257
+ `;
1258
+ else {
1259
+ const ids = files.map((_, i) => `dict_${i}`);
1260
+ const dictList = ids.length <= 1 ? `[${ids.join(", ")}]` : `[\n ${ids.join(",\n ")}\n]`;
1261
+ content = `${header}
1262
+ ${files.map((file, i) => `import ${ids[i]} from "${importPath(file)}";`).join("\n")}
1263
+
1264
+ export const dictionaries = ${dictList};
1265
+
1266
+ export const dictionaryMap = {
1267
+ ${ids.map((id) => ` [${id}.name]: ${id}.dictionary,`).join("\n")}
1268
+ };
1269
+
1270
+ ${configExport}
1271
+
1272
+ export const dialex = { dictionaries, config };
1273
+
1274
+ export default dictionaries;
1275
+ `;
1276
+ }
1277
+ const dts = renderDts(root, files, locales, messageArguments(parsed, clientConfig.defaultLocale));
1278
+ return {
1279
+ files,
1280
+ outputPath,
1281
+ content,
1282
+ dtsPath: dts?.path ?? path.join(srcDir, "dialex-env.d.ts"),
1283
+ dtsContent: dts?.content,
1284
+ configFile: staticRead.file,
1285
+ extraFiles,
1286
+ notes
1287
+ };
1288
+ }
1289
+ function writeIfChanged(file, content) {
1290
+ if ((fs.existsSync(file) ? fs.readFileSync(file, "utf-8") : "") === content) return;
1291
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1292
+ fs.writeFileSync(file, content, "utf-8");
1293
+ }
1294
+ function generateDictionaries(root, options = {}) {
1295
+ const rendered = renderGenerated(root, options);
1296
+ if (rendered.dtsContent !== void 0) writeIfChanged(rendered.dtsPath, rendered.dtsContent);
1297
+ writeIfChanged(rendered.outputPath, rendered.content);
1298
+ const dir = path.join(path.dirname(rendered.outputPath), LOCALES_DIR);
1299
+ const wanted = new Set(rendered.extraFiles.map((f) => f.path));
1300
+ for (const file of rendered.extraFiles) writeIfChanged(file.path, file.content);
1301
+ if (fs.existsSync(dir)) {
1302
+ for (const entry of fs.readdirSync(dir)) {
1303
+ const full = path.join(dir, entry);
1304
+ if (entry.endsWith(".ts") && !wanted.has(full)) fs.rmSync(full);
1305
+ }
1306
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
1307
+ }
1308
+ return {
1309
+ files: rendered.files,
1310
+ outputPath: rendered.outputPath,
1311
+ dtsPath: rendered.dtsPath,
1312
+ configFile: rendered.configFile,
1313
+ notes: rendered.notes
1314
+ };
1315
+ }
1316
+ async function runGenerate(options = {}) {
1317
+ const root = options.cwd || process.cwd();
1318
+ const quiet = options.json === true;
1319
+ const doGenerate = () => {
1320
+ try {
1321
+ const { files, outputPath, dtsPath, configFile, notes } = generateDictionaries(root, options);
1322
+ if (quiet) console.log(JSON.stringify({
1323
+ success: true,
1324
+ files: files.map((f) => path.relative(root, f)),
1325
+ outputPath: path.relative(root, outputPath),
1326
+ dtsPath: path.relative(root, dtsPath),
1327
+ notes
1328
+ }, null, 2));
1329
+ else {
1330
+ if (!configFile) logger.info("No dialex.config.* found, using defaults (run `dialex init` to add one).");
1331
+ logger.success(`Generated dictionaries for ${files.length} file${files.length === 1 ? "" : "s"}:`);
1332
+ logger.info(`→ ${path.relative(root, outputPath)}`);
1333
+ logger.info(`→ ${path.relative(root, dtsPath)}`);
1334
+ for (const note of notes) logger.info(note);
1335
+ }
1336
+ return true;
1337
+ } catch (err) {
1338
+ if (quiet) console.log(JSON.stringify({
1339
+ success: false,
1340
+ error: err.message || String(err)
1341
+ }, null, 2));
1342
+ else logger.error(`Generation failed: ${err.message || String(err)}`);
1343
+ return false;
1344
+ }
1345
+ };
1346
+ if (!doGenerate() && !options.watch) process.exit(1);
1347
+ if (options.watch) {
1348
+ logger.info("Watching for dictionary changes (-w, --watch)...");
1349
+ startGenerateWatcher(root, options, doGenerate);
1350
+ }
1351
+ }
1352
+ const WATCH_IGNORED = /(^|[\\/])(node_modules|dist|\.next|\.nuxt|\.output|\.turbo|\.git|dialex\.locales)([\\/]|$)/;
1353
+ const CONFIG_FILE = /(^|[\\/])(dialex|i18n)\.config\.[cm]?[jt]s(on)?$/;
1354
+ /**
1355
+ * Regenerates when a dictionary or config file is added, edited or removed. chokidar 4+ no longer
1356
+ * understands globs, so this watches the project directory and decides for itself which events
1357
+ * matter, using the same `include` glob as `generate`.
1358
+ */
1359
+ function startGenerateWatcher(root, options, regenerate, delay = 100) {
1360
+ const dictionaryFiles = () => new Set(renderGenerated(root, options).files);
1361
+ let known = dictionaryFiles();
1362
+ let outputs = /* @__PURE__ */ new Set();
1363
+ let timer;
1364
+ const trigger = () => {
1365
+ if (timer) clearTimeout(timer);
1366
+ timer = setTimeout(() => {
1367
+ regenerate();
1368
+ try {
1369
+ known = dictionaryFiles();
1370
+ const rendered = renderGenerated(root, options);
1371
+ outputs = /* @__PURE__ */ new Set([rendered.outputPath, rendered.dtsPath]);
1372
+ } catch {}
1373
+ }, delay);
1374
+ };
1375
+ const watcher = chokidar.watch(root, {
1376
+ ignoreInitial: true,
1377
+ ignored: (file) => WATCH_IGNORED.test(file)
1378
+ });
1379
+ watcher.on("all", (event, filePath) => {
1380
+ const file = path.resolve(root, filePath);
1381
+ if (outputs.has(file)) return;
1382
+ const isConfig = CONFIG_FILE.test(file);
1383
+ if (event === "change") {
1384
+ if (!known.has(file) && !isConfig) return;
1385
+ } else if (event === "add" || event === "unlink") {
1386
+ let now;
1387
+ try {
1388
+ now = dictionaryFiles();
1389
+ } catch {
1390
+ return;
1391
+ }
1392
+ if (!(now.size !== known.size || [...now].some((f) => !known.has(f))) && !isConfig) return;
1393
+ } else return;
1394
+ logger.info(`[${event}] ${path.relative(root, file)} changed, regenerating...`);
1395
+ trigger();
1396
+ });
1397
+ return watcher;
1398
+ }
1399
+ //#endregion
928
1400
  //#region src/utils/project.ts
929
1401
  async function loadDictionaries(root, config) {
930
1402
  const include = config.include || "**/*.content.ts";
@@ -934,7 +1406,8 @@ async function loadDictionaries(root, config) {
934
1406
  ignore: [
935
1407
  "**/node_modules/**",
936
1408
  "**/dist/**",
937
- "**/.next/**"
1409
+ "**/.next/**",
1410
+ "**/dialex.locales/**"
938
1411
  ]
939
1412
  });
940
1413
  const dictionaries = [];
@@ -963,7 +1436,7 @@ async function loadDictionaries(root, config) {
963
1436
  * every dictionary file for AST-level reading and editing.
964
1437
  */
965
1438
  async function loadProject(root, configFile) {
966
- const config = resolveI18nConfig(root, configFile ? { configFile } : {});
1439
+ const config = resolveDialexConfig(root, configFile ? { configFile } : {});
967
1440
  return {
968
1441
  root,
969
1442
  config,
@@ -987,6 +1460,6 @@ async function loadStaticProject(root, configFile) {
987
1460
  /** `home` + `["nav", "about"]` -> `home.nav.about` */
988
1461
  const toKey = (dictionary, keyPath) => [dictionary, ...keyPath].join(".");
989
1462
  //#endregion
990
- export { saveDictionaryFile as C, logger as D, runGenerate as E, renderDictionaryFile as S, generateDictionaries as T, listLeaves as _, parseStaticConfig as a, locationOf as b, analyzeProject as c, TODO_PREFIX as d, copyLeaf as f, hasPath as g, getString as h, DEFAULT_STATIC_CONFIG as i, relativeFile as l, getLeafSource as m, loadStaticProject as n, readStaticConfig as o, dictionaryLocation as p, toKey as r, DEFAULT_SOURCE_GLOB as s, loadProject as t, scanReferences as u, listLocales as v, setString as w, parseDictionaryText as x, loadDictionaryFile as y };
1463
+ export { listLocales as A, TODO_PREFIX as C, getString as D, getLeafSource as E, saveDictionaryFile as F, setString as I, locationOf as M, parseDictionaryText as N, hasPath as O, renderDictionaryFile as P, writeLock as S, dictionaryLocation as T, isStale as _, renderGenerated as a, readLock as b, parseStaticConfig as c, DEFAULT_SOURCE_GLOB as d, analyzeProject as f, hashSource as g, LOCK_FILE as h, generateDictionaries as i, loadDictionaryFile as j, listLeaves as k, readStaticConfig as l, scanReferences as m, loadStaticProject as n, runGenerate as o, relativeFile as p, toKey as r, DEFAULT_STATIC_CONFIG as s, loadProject as t, logger as u, lockFor as v, copyLeaf as w, record as x, lockPath as y };
991
1464
 
992
- //# sourceMappingURL=project-BnG6V2I0.mjs.map
1465
+ //# sourceMappingURL=project-BJYmnKiU.mjs.map