alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
279 lines (278 loc) • 9.6 kB
JavaScript
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