UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

279 lines (278 loc) 9.6 kB
import { $atom, $context, $inject, $module, $state, z } from "alepha"; import { $command } from "alepha/command"; import { $logger, ConsoleColorProvider } from "alepha/logger"; import { FileSystemProvider } from "alepha/system"; //#region ../../src/cli/i18n/atoms/i18nOptions.ts /** * i18n CLI configuration atom. * * Filled from the `i18n` plugin in `alepha.config.ts`. * Read by `I18nCommand` to drive `alepha i18n check`. */ const i18nOptions = $atom({ name: "alepha.cli.i18n.options", description: "i18n unused-key check configuration", schema: z.object({ /** * Directories (relative to the project root) to scan both for * `$dictionary(...)` declarations and for translation key usage. * * @default ["src"] */ scan: z.array(z.text()).optional(), /** * Key prefixes that are constructed at runtime (e.g. via template * literals like `` tr(`archive.type.${kind}`) ``). Every key * starting with one of these prefixes is exempted from the * unused check. * * Keep this list short and audit it when a feature is removed — * a stale prefix here means dead keys can hide. * * @default [] */ dynamicPrefixes: z.array(z.text()).optional(), /** * Additional path substrings (matched against the full file * path) that should be excluded from the scan, on top of the * defaults (`node_modules`, `dist`, `__tests__`, `.spec.*`, * `.test.*`, `.alepha`). * * @default [] */ exclude: z.array(z.text()).optional() }).optional() }); //#endregion //#region ../../src/cli/i18n/services/I18nCheckService.ts /** * File extensions considered when scanning for dictionaries / usage. */ const SCAN_EXTS = [ ".ts", ".tsx", ".mts", ".cts" ]; /** * Built-in path substrings that are always excluded from scanning. */ const DEFAULT_EXCLUDES = [ "/node_modules/", "/dist/", "/__tests__/", "/.alepha/", ".spec.ts", ".spec.tsx", ".test.ts", ".test.tsx" ]; /** * Matches a quoted dotted property key on the left-hand side of a * dictionary entry: `"some.dotted.key":`. The "at least one dot" * requirement rules out unrelated quoted strings (e.g. JSON literals * inside helper text). */ const KEY_DECLARATION_RE = /"([\w-]+(?:\.[\w-]+)+)"\s*:/g; /** * Heuristic for spotting files that declare a dictionary. Conservative * by design — we'd rather scan a few unrelated files than miss a * dictionary tucked away in an unusual location. */ const DICTIONARY_MARKER = "$dictionary"; /** * Captures the module specifier of a lazily-imported dictionary, i.e. the * `"./fr.ts"` in `$dictionary({ lazy: () => import("./fr.ts") })`. Apps * commonly split each language into its own file so a session only ships the * active locale — those key files carry no `$dictionary` marker, so without * this the keys would be invisible to the check. * * `[^{}]*?` keeps the match inside the `$dictionary` call's own object literal * (it stops at the first brace), so unrelated lazy imports in the same file — * e.g. a sibling `$page({ lazy: () => import("./Page.tsx") })` or a * `$dictionary` whose `lazy` returns an inline `({ default: {…} })` object — * are never mistaken for dictionary key files. */ const DICTIONARY_LAZY_IMPORT_RE = /\$dictionary\s*\(\s*\{[^{}]*?import\s*\(\s*["']([^"']+)["']/g; var I18nCheckService = class { fs = $inject(FileSystemProvider); /** * Find unused translation keys. * * Discovery is fully static: we walk `scan` dirs, identify files * that import `$dictionary` (matched via the literal substring) plus * any per-language files they lazily `import(...)`, extract every * `"a.b.c": ...` property key declared across them, then grep the * remaining source files for a quoted-literal occurrence of each key. * Anything matching a `dynamicPrefixes` entry is exempted. */ async check(options) { const { root, scan, dynamicPrefixes, exclude } = options; const excludes = [...DEFAULT_EXCLUDES, ...exclude]; const allFiles = []; for (const dir of scan) { const absDir = this.fs.join(root, dir); let entries; try { entries = await this.fs.ls(absDir, { recursive: true }); } catch { continue; } for (const rel of entries) { const abs = this.fs.join(absDir, rel); if (!SCAN_EXTS.some((ext) => abs.endsWith(ext))) continue; if (excludes.some((sub) => abs.includes(sub))) continue; allFiles.push(abs); } } const fileContents = /* @__PURE__ */ new Map(); for (const file of allFiles) fileContents.set(file, (await this.fs.readFile(file)).toString("utf8")); const dictionarySet = /* @__PURE__ */ new Set(); for (const [file, text] of fileContents) { if (!text.includes(DICTIONARY_MARKER)) continue; dictionarySet.add(file); for (const m of text.matchAll(DICTIONARY_LAZY_IMPORT_RE)) { const target = this.resolveImport(file, m[1], fileContents); if (target) dictionarySet.add(target); } } const dictionaryFiles = []; const allKeys = /* @__PURE__ */ new Set(); for (const file of dictionarySet) { const text = fileContents.get(file); if (!text) continue; const before = allKeys.size; for (const m of text.matchAll(KEY_DECLARATION_RE)) allKeys.add(m[1]); if (allKeys.size > before) dictionaryFiles.push(file); } const corpusParts = []; let scannedFiles = 0; for (const [file, text] of fileContents) { if (dictionarySet.has(file)) continue; corpusParts.push(text); scannedFiles++; } const corpus = corpusParts.join("\n"); let exemptKeys = 0; const unused = []; for (const key of allKeys) { if (dynamicPrefixes.some((p) => key.startsWith(p))) { exemptKeys++; continue; } const literal = key.replace(/[.\\]/g, (c) => `\\${c}`); if (!new RegExp(`["'\`]${literal}["'\`]`).test(corpus)) unused.push(key); } return { totalKeys: allKeys.size, exemptKeys, scannedFiles, dictionaryFiles, unused: unused.sort() }; } /** * Resolve a relative `import("…")` specifier from `fromFile` to an absolute * path that was actually scanned. Returns `undefined` for bare/package * specifiers or targets outside the scan set. Extensionless specifiers are * probed against each supported source extension. */ resolveImport(fromFile, spec, files) { if (!spec.startsWith(".")) return void 0; const base = this.fs.join(fromFile, "..", spec); if (files.has(base)) return base; for (const ext of SCAN_EXTS) if (files.has(base + ext)) return base + ext; } }; //#endregion //#region ../../src/cli/i18n/commands/I18nCommand.ts var I18nCommand = class { log = $logger(); options = $state(i18nOptions); checkService = $inject(I18nCheckService); color = $inject(ConsoleColorProvider); resolveOptions() { return { scan: this.options?.scan ?? ["src"], dynamicPrefixes: this.options?.dynamicPrefixes ?? [], exclude: this.options?.exclude ?? [] }; } check = $command({ name: "check", description: "Report translation keys with no quoted-literal reference", handler: async ({ root }) => { const opts = this.resolveOptions(); const c = this.color; const result = await this.checkService.check({ root, ...opts }); if (result.totalKeys === 0) { process.stdout.write(`\n${c.set("ORANGE", "warn")} No translation keys found. Did the dictionary location change? Searched: ${opts.scan.join(", ")}\n\n`); process.exit(2); } process.stdout.write(`\nChecked ${c.set("CYAN", String(result.totalKeys))} keys across ${c.set("CYAN", String(result.scannedFiles))} files (${result.dictionaryFiles.length} dictionary ${result.dictionaryFiles.length === 1 ? "file" : "files"}).\n`); if (result.exemptKeys > 0) process.stdout.write(` exempt (dynamic prefixes): ${result.exemptKeys}\n`); if (result.unused.length === 0) { process.stdout.write(`\n${c.set("GREEN", "✓")} All translations are referenced.\n\n`); return; } process.stdout.write(`\n${c.set("RED", "✗")} Unused translations (${result.unused.length}):\n`); for (const k of result.unused) process.stdout.write(` ${c.set("DIM", "-")} ${k}\n`); process.stdout.write(`\nEither delete the key from its dictionary, or add its prefix to ${c.set("CYAN", "dynamicPrefixes")} in alepha.config.ts if it's constructed at runtime.\n\n`); process.exit(1); } }); i18n = $command({ name: "i18n", description: "Internationalization tooling", children: [this.check], handler: async ({ help }) => { help(); } }); }; //#endregion //#region ../../src/cli/i18n/index.ts /** * CLI plugin for finding unused translation keys. * * Statically scans the project for `$dictionary(...)` calls, extracts * every declared key, and reports the ones that have no quoted-literal * reference anywhere else in the source tree. Designed to be wired * into `yarn v` (or any verify pipeline) so dead i18n entries can't * pile up unnoticed when a feature is removed. * * Commands: * - `alepha i18n check` — report unused translation keys * * Configuration in `alepha.config.ts`: * * ```typescript * import { i18n } from "alepha/cli/i18n"; * * export default defineConfig({ * plugins: [ * i18n({ * scan: ["src", ".vendor/@alepha/ui"], * dynamicPrefixes: ["archive.type.", "petitions.filter."], * }), * ], * }); * ``` */ const AlephaCliI18nPlugin = $module({ name: "alepha.cli.plugins.i18n", atoms: [i18nOptions], services: [I18nCommand, I18nCheckService] }); const i18n = (options = {}) => { return () => { const { alepha } = $context(); alepha.with(AlephaCliI18nPlugin).set(i18nOptions, options); }; }; //#endregion export { AlephaCliI18nPlugin, I18nCheckService, I18nCommand, i18n, i18nOptions }; //# sourceMappingURL=index.js.map