@dialexjs/cli 0.6.0 → 0.7.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.
@@ -1,9 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import fg from "fast-glob";
4
3
  import pc from "picocolors";
5
4
  import { pathToFileURL } from "node:url";
6
5
  import { generateCode, parseExpression, parseModule, writeFile } from "magicast";
6
+ import fg from "fast-glob";
7
+ import ignore from "ignore";
7
8
  import { argumentSignature, findPlurals, getArguments, isIcuStructured, parseMessage } from "dialexjs/icu";
8
9
  import { createHash } from "node:crypto";
9
10
  import { DEFAULT_CONFIG } from "dialexjs";
@@ -272,6 +273,100 @@ function getLeafSource(df, locale, path) {
272
273
  return generateCode(prop.type === "ObjectMethod" ? prop : prop.value).code;
273
274
  }
274
275
  //#endregion
276
+ //#region src/utils/files.ts
277
+ /** Folders that never hold project dictionaries or sources, in every scanner. */
278
+ const BUILTIN_IGNORE = [
279
+ "**/node_modules/**",
280
+ "**/dist/**",
281
+ "**/.next/**",
282
+ "**/dialex.locales/**"
283
+ ];
284
+ /** What the source scan skips on top: other tools' output, declarations, generated and config files. */
285
+ const SOURCE_BUILTIN_IGNORE = [
286
+ ...BUILTIN_IGNORE,
287
+ "**/.nuxt/**",
288
+ "**/.output/**",
289
+ "**/.svelte-kit/**",
290
+ "**/.astro/**",
291
+ "**/.turbo/**",
292
+ "**/coverage/**",
293
+ "**/graphify-out/**",
294
+ "**/*.d.ts",
295
+ "**/dialex.generated.*",
296
+ "**/dialex.config.*",
297
+ "**/i18n.config.*"
298
+ ];
299
+ /** The nearest directory at or above `root` that contains `.git`, or `root` itself. */
300
+ function repositoryRoot(root) {
301
+ for (let dir = root;; dir = path.dirname(dir)) {
302
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
303
+ if (path.dirname(dir) === dir) return root;
304
+ }
305
+ }
306
+ function readScope(dir) {
307
+ try {
308
+ const text = fs.readFileSync(path.join(dir, ".gitignore"), "utf-8");
309
+ return {
310
+ dir,
311
+ matcher: ignore().add(text)
312
+ };
313
+ } catch {
314
+ return;
315
+ }
316
+ }
317
+ /**
318
+ * A function that says whether an absolute path is ignored by Git. It reads the `.gitignore`
319
+ * files of the project folder, of every folder above it up to the repository root (monorepo
320
+ * packages inherit the root's rules), and of nested folders.
321
+ */
322
+ function createGitignoreFilter(root) {
323
+ const base = path.resolve(root);
324
+ const top = repositoryRoot(base);
325
+ const scopes = [];
326
+ for (let dir = base;; dir = path.dirname(dir)) {
327
+ const scope = readScope(dir);
328
+ if (scope) scopes.push(scope);
329
+ if (dir === top || path.dirname(dir) === dir) break;
330
+ }
331
+ const nested = fg.sync("**/.gitignore", {
332
+ cwd: base,
333
+ absolute: true,
334
+ dot: true,
335
+ ignore: ["**/node_modules/**", "**/.git/**"]
336
+ });
337
+ for (const file of nested) {
338
+ const dir = path.dirname(file);
339
+ if (dir === base) continue;
340
+ const scope = readScope(dir);
341
+ if (scope) scopes.push(scope);
342
+ }
343
+ if (scopes.length === 0) return () => false;
344
+ return (absolutePath) => {
345
+ for (const { dir, matcher } of scopes) {
346
+ const relative = path.relative(dir, absolutePath);
347
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) continue;
348
+ if (matcher.ignores(relative.split(path.sep).join("/"))) return true;
349
+ }
350
+ return false;
351
+ };
352
+ }
353
+ /** Finds files under `root`, skipping build output, configured excludes and Git-ignored paths. */
354
+ function scanFiles(root, patterns, options = {}) {
355
+ const builtin = options.kind === "source" ? SOURCE_BUILTIN_IGNORE : BUILTIN_IGNORE;
356
+ const files = fg.sync(patterns, {
357
+ cwd: root,
358
+ absolute: true,
359
+ ignore: [
360
+ ...builtin,
361
+ ...options.exclude ?? [],
362
+ ...options.ignore ?? []
363
+ ]
364
+ });
365
+ if (options.gitignore === false) return files;
366
+ const ignored = options.gitignoreFilter ?? createGitignoreFilter(root);
367
+ return files.filter((file) => !ignored(file));
368
+ }
369
+ //#endregion
275
370
  //#region src/utils/icu-check.ts
276
371
  /** CLDR order. `resolvedOptions().pluralCategories` order differs between Node versions. */
277
372
  const CATEGORY_ORDER = [
@@ -423,26 +518,18 @@ function lockFor(root, sourceLocale) {
423
518
  }
424
519
  //#endregion
425
520
  //#region src/analysis.ts
426
- const DICTIONARY_IGNORE$1 = [
427
- "**/node_modules/**",
428
- "**/dist/**",
429
- "**/.next/**",
430
- "**/dialex.locales/**"
431
- ];
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
+ }
432
532
  const DEFAULT_SOURCE_GLOB = "**/*.{ts,tsx,js,jsx,mjs,cjs,vue,svelte,astro,mdx}";
433
- const SOURCE_IGNORE = [
434
- ...DICTIONARY_IGNORE$1,
435
- "**/.nuxt/**",
436
- "**/.output/**",
437
- "**/.svelte-kit/**",
438
- "**/.astro/**",
439
- "**/coverage/**",
440
- "**/graphify-out/**",
441
- "**/*.d.ts",
442
- "**/dialex.generated.*",
443
- "**/dialex.config.*",
444
- "**/i18n.config.*"
445
- ];
446
533
  /** `getDictionary("x")`, `useDictionary("x")`, `@DialexDictionary("x")` */
447
534
  const DICTIONARY_CALL = /\b(?:getDictionary|useDictionary|DialexDictionary)\(\s*(["'`])([^"'`$\n]+)\1/dg;
448
535
  /** `t("dictionary.path.to.key")` */
@@ -577,22 +664,61 @@ async function loadDictionaryData(file, runtime) {
577
664
  message: "This dictionary uses spreads, computed keys, imported values, or a computed name, so it cannot be analyzed statically."
578
665
  };
579
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
+ function scanSource(text) {
687
+ const starts = lineStarts(text);
688
+ const rangeOf = (start, end) => ({
689
+ start: positionAt(starts, start),
690
+ end: positionAt(starts, end)
691
+ });
692
+ return {
693
+ tokens: [...new Set(text.match(/[\w$-]+/g) ?? [])],
694
+ refs: scanReferences(text).map((ref) => ({
695
+ ref,
696
+ range: rangeOf(ref.start, ref.end)
697
+ }))
698
+ };
699
+ }
580
700
  async function analyzeProject(options) {
581
701
  const { root, config } = options;
582
702
  const include = config.include || "**/*.content.ts";
583
703
  const configuredLocales = config.locales || [];
584
704
  const defaultLocale = config.defaultLocale || "en";
585
705
  const extraIgnore = options.ignore ?? [];
586
- const files = fg.sync(include, {
587
- cwd: root,
588
- absolute: true,
589
- ignore: [...DICTIONARY_IGNORE$1, ...extraIgnore]
706
+ const cache = options.runtime ? void 0 : options.cache;
707
+ let gitignore = cache?.gitignore.get(root);
708
+ if (cache && !gitignore) {
709
+ gitignore = createGitignoreFilter(root);
710
+ cache.gitignore.set(root, gitignore);
711
+ }
712
+ const files = scanFiles(root, include, {
713
+ exclude: config.exclude,
714
+ ignore: extraIgnore,
715
+ gitignoreFilter: gitignore
590
716
  });
591
717
  const issues = [];
592
718
  const dictionaries = [];
593
719
  const lock = options.lock === false ? void 0 : readLock(root);
594
720
  for (const file of files) {
595
- const loaded = await loadDictionaryData(file, options.runtime === true);
721
+ const loaded = await cachedDictionary(file, options.runtime === true, cache);
596
722
  if (!loaded.ok) {
597
723
  for (const message of loaded.message.split("\n")) issues.push({
598
724
  file,
@@ -699,34 +825,39 @@ async function analyzeProject(options) {
699
825
  const known = new Map(dictionaries.map((d) => [d.name, d]));
700
826
  const dictionaryFiles = new Set(files);
701
827
  const sourceGlobs = options.src ?? ["**/*.{ts,tsx,js,jsx,mjs,cjs,vue,svelte,astro,mdx}"];
702
- const sourceFiles = sourceGlobs.length === 0 ? [] : fg.sync(sourceGlobs, {
703
- cwd: root,
704
- absolute: true,
705
- ignore: [...SOURCE_IGNORE, ...extraIgnore]
828
+ const sourceFiles = sourceGlobs.length === 0 ? [] : scanFiles(root, sourceGlobs, {
829
+ kind: "source",
830
+ exclude: config.exclude,
831
+ ignore: extraIgnore,
832
+ gitignoreFilter: gitignore
706
833
  }).filter((f) => !dictionaryFiles.has(f));
707
834
  if (sourceFiles.length > 0) {
708
835
  const tokens = /* @__PURE__ */ new Set();
709
836
  for (const file of sourceFiles) {
710
- let text;
711
- try {
712
- text = fs.readFileSync(file, "utf-8");
713
- } catch {
714
- continue;
837
+ const stamp = cache ? stampOf(file) : void 0;
838
+ let scan = stamp ? cache.sources.get(file) : void 0;
839
+ if (!scan || scan.stamp !== stamp) {
840
+ let text;
841
+ try {
842
+ text = fs.readFileSync(file, "utf-8");
843
+ } catch {
844
+ continue;
845
+ }
846
+ scan = {
847
+ stamp: stamp ?? "",
848
+ scan: scanSource(text)
849
+ };
850
+ if (cache && stamp) cache.sources.set(file, scan);
715
851
  }
716
- for (const token of text.match(/[\w$-]+/g) ?? []) tokens.add(token);
717
- const starts = lineStarts(text);
718
- const rangeOf = (start, end) => ({
719
- start: positionAt(starts, start),
720
- end: positionAt(starts, end)
721
- });
722
- for (const ref of scanReferences(text)) {
852
+ for (const token of scan.scan.tokens) tokens.add(token);
853
+ for (const { ref, range } of scan.scan.refs) {
723
854
  if (ref.kind === "dictionary") {
724
855
  if (!known.has(ref.value)) issues.push({
725
856
  file,
726
857
  level: "error",
727
858
  code: "unknown-dictionary",
728
859
  message: `Unknown dictionary "${ref.value}"`,
729
- range: rangeOf(ref.start, ref.end),
860
+ range,
730
861
  dictionary: ref.value
731
862
  });
732
863
  continue;
@@ -740,7 +871,7 @@ async function analyzeProject(options) {
740
871
  level: "error",
741
872
  code: "unknown-path",
742
873
  message: `Unknown translation path "${ref.value}"`,
743
- range: rangeOf(ref.start, ref.end),
874
+ range,
744
875
  dictionary: name,
745
876
  path: rest
746
877
  });
@@ -774,6 +905,10 @@ async function analyzeProject(options) {
774
905
  }
775
906
  }
776
907
  }
908
+ if (cache) {
909
+ const live = /* @__PURE__ */ new Set([...files, ...sourceFiles]);
910
+ 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
+ }
777
912
  return {
778
913
  issues,
779
914
  dictionaries,
@@ -1132,6 +1267,7 @@ const DEFAULT_STATIC_CONFIG = {
1132
1267
  defaultLocale: "en",
1133
1268
  locales: [],
1134
1269
  include: "**/*.content.ts",
1270
+ exclude: [],
1135
1271
  fallbacks: {},
1136
1272
  prefixDefault: true,
1137
1273
  lazy: false
@@ -1179,6 +1315,7 @@ function apply(object, config, notes) {
1179
1315
  accept("defaultLocale", (v) => typeof v === "string" && v !== "", "a string literal");
1180
1316
  accept("locales", isStringArray, "an array of string literals");
1181
1317
  accept("include", (v) => typeof v === "string" || isStringArray(v), "a string or array of strings");
1318
+ accept("exclude", isStringArray, "an array of string literals");
1182
1319
  accept("fallbacks", (v) => typeof v === "object" && v !== null && Object.values(v).every((list) => isStringArray(list)), "an object of string arrays");
1183
1320
  accept("prefixDefault", (v) => typeof v === "boolean", "true or false");
1184
1321
  accept("lazy", (v) => typeof v === "boolean" || v === "locale", "true, false or \"locale\"");
@@ -1210,6 +1347,7 @@ function parseStaticConfig(text, file = "dialex.config.ts") {
1210
1347
  "defaultLocale",
1211
1348
  "locales",
1212
1349
  "include",
1350
+ "exclude",
1213
1351
  "fallbacks",
1214
1352
  "prefixDefault",
1215
1353
  "lazy"
@@ -1308,12 +1446,6 @@ function relativeImportExtension(root) {
1308
1446
  }
1309
1447
  //#endregion
1310
1448
  //#region src/commands/generate.ts
1311
- const DICTIONARY_IGNORE = [
1312
- "**/node_modules/**",
1313
- "**/dist/**",
1314
- "**/.next/**",
1315
- "**/dialex.locales/**"
1316
- ];
1317
1449
  /** The folder, next to `dialex.generated.ts`, that holds the per-locale modules. */
1318
1450
  const LOCALES_DIR = "dialex.locales";
1319
1451
  const safeName = (text) => text.replace(/[^A-Za-z0-9._-]/g, "_");
@@ -1328,12 +1460,7 @@ function renderGenerated(root, options = {}) {
1328
1460
  const inlineConfig = options.config ? { configFile: options.config } : {};
1329
1461
  const staticRead = readStaticConfig(root, options.config);
1330
1462
  const config = options.static ? staticRead.config : resolveDialexConfig(root, inlineConfig);
1331
- const include = config.include || DEFAULT_CONFIG.include;
1332
- const found = fg.sync(include, {
1333
- cwd: root,
1334
- absolute: true,
1335
- ignore: DICTIONARY_IGNORE
1336
- }).sort();
1463
+ const found = scanFiles(root, config.include || DEFAULT_CONFIG.include, { exclude: config.exclude }).sort();
1337
1464
  const srcDir = fs.existsSync(path.join(root, "src")) ? path.join(root, "src") : root;
1338
1465
  const outputPath = options.output ? path.resolve(root, options.output) : path.join(srcDir, "dialex.generated.ts");
1339
1466
  const outputDir = path.dirname(outputPath);
@@ -1507,7 +1634,7 @@ const CONFIG_FILE = /(^|[\\/])(dialex|i18n)\.config\.[cm]?[jt]s(on)?$/;
1507
1634
  * understands globs, so this watches the project directory and decides for itself which events
1508
1635
  * matter, using the same `include` glob as `generate`.
1509
1636
  */
1510
- function startGenerateWatcher(root, options, regenerate, delay = 100) {
1637
+ function startGenerateWatcher(root, options, regenerate, delay = 100, log = (message) => logger.info(message)) {
1511
1638
  const dictionaryFiles = () => new Set(renderGenerated(root, options).files);
1512
1639
  let known = dictionaryFiles();
1513
1640
  let outputs = /* @__PURE__ */ new Set();
@@ -1523,9 +1650,10 @@ function startGenerateWatcher(root, options, regenerate, delay = 100) {
1523
1650
  } catch {}
1524
1651
  }, delay);
1525
1652
  };
1653
+ const gitIgnored = createGitignoreFilter(root);
1526
1654
  const watcher = chokidar.watch(root, {
1527
1655
  ignoreInitial: true,
1528
- ignored: (file) => WATCH_IGNORED.test(file)
1656
+ ignored: (file) => WATCH_IGNORED.test(file) || gitIgnored(path.resolve(root, file))
1529
1657
  });
1530
1658
  watcher.on("all", (event, filePath) => {
1531
1659
  const file = path.resolve(root, filePath);
@@ -1542,7 +1670,7 @@ function startGenerateWatcher(root, options, regenerate, delay = 100) {
1542
1670
  }
1543
1671
  if (!(now.size !== known.size || [...now].some((f) => !known.has(f))) && !isConfig) return;
1544
1672
  } else return;
1545
- logger.info(`[${event}] ${path.relative(root, file)} changed, regenerating...`);
1673
+ log(`[${event}] ${path.relative(root, file)} changed, regenerating...`);
1546
1674
  trigger();
1547
1675
  });
1548
1676
  return watcher;
@@ -1550,17 +1678,7 @@ function startGenerateWatcher(root, options, regenerate, delay = 100) {
1550
1678
  //#endregion
1551
1679
  //#region src/utils/project.ts
1552
1680
  async function loadDictionaries(root, config) {
1553
- const include = config.include || "**/*.content.ts";
1554
- const files = fg.sync(include, {
1555
- cwd: root,
1556
- absolute: true,
1557
- ignore: [
1558
- "**/node_modules/**",
1559
- "**/dist/**",
1560
- "**/.next/**",
1561
- "**/dialex.locales/**"
1562
- ]
1563
- });
1681
+ const files = scanFiles(root, config.include || "**/*.content.ts", { exclude: config.exclude });
1564
1682
  const dictionaries = [];
1565
1683
  const unsupported = [];
1566
1684
  for (const file of files) {
@@ -1611,6 +1729,6 @@ async function loadStaticProject(root, configFile) {
1611
1729
  /** `home` + `["nav", "about"]` -> `home.nav.about` */
1612
1730
  const toKey = (dictionary, keyPath) => [dictionary, ...keyPath].join(".");
1613
1731
  //#endregion
1614
- export { listLeaves as A, writeLock as C, getLeafSource as D, dictionaryLocation as E, renderDictionaryFile as F, saveDictionaryFile as I, setString as L, loadDictionaryFile as M, locationOf as N, getString as O, parseDictionaryText as P, record as S, copyLeaf as T, hashSource as _, renderGenerated as a, lockPath as b, parseStaticConfig as c, logger as d, DEFAULT_SOURCE_GLOB as f, LOCK_FILE as g, scanReferences as h, generateDictionaries as i, listLocales as j, hasPath as k, readStaticConfig as l, relativeFile as m, loadStaticProject as n, runGenerate as o, analyzeProject as p, toKey as r, DEFAULT_STATIC_CONFIG as s, loadProject as t, resolveDialexConfig as u, isStale as v, TODO_PREFIX as w, readLock as x, lockFor as y };
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 };
1615
1733
 
1616
- //# sourceMappingURL=project-DquSNdz8.mjs.map
1734
+ //# sourceMappingURL=project-Dz0FbydQ.mjs.map