240 lines
8.1 KiB
TypeScript
240 lines
8.1 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* `check-i18n-keys.ts` — translation-key drift gate for
|
|
* `@suaveplan/react-ui`.
|
|
*
|
|
* Walks every `*.{ts,tsx}` under `src/components/**` and `src/i18n/**`
|
|
* and extracts translation-key references from these call shapes:
|
|
*
|
|
* - `t("react-ui.…")` / `t('react-ui.…')`
|
|
* - `tDynamic("react-ui.…")` / `tDynamic('react-ui.…')`
|
|
* - `<FormattedMessage i18nKey="react-ui.…" />` / `i18nKey='…'`
|
|
* - `<Trans i18nKey="…">` (existing components)
|
|
* - `i18nKey: "react-ui.…"` (object-literal column-config style)
|
|
* - `(.+)Key={?["']react-ui\.…["']` for prop-drilled keys
|
|
* (Pagination, Calendar, DataTable name multiple `*Key` props)
|
|
* - `?? "react-ui.…"` (default-key fallback pattern)
|
|
*
|
|
* The script then diffs the discovered set against the canonical
|
|
* `src/locales/en-GB.json`, reporting:
|
|
*
|
|
* - `MISSING <key> at <file>:<line>` — referenced in code but absent
|
|
* from the bundle (consumers will hit a missing-key warning).
|
|
* - `ORPHAN <key>` — present in the bundle but never referenced
|
|
* (dead translation key).
|
|
*
|
|
* Exits non-zero on any diagnostic. Wired into `bunx turbo lint`.
|
|
*
|
|
* Pattern-based extraction is intentional — the package has no
|
|
* `@babel/parser` dep and the supported call shapes are a closed set.
|
|
* The script accepts both single and double-quoted attribute values
|
|
* and ignores keys outside the `react-ui.…` namespace.
|
|
*/
|
|
|
|
import { readdir, readFile } from "node:fs/promises";
|
|
import { dirname, join, relative, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const PACKAGE_ROOT = resolve(
|
|
dirname(fileURLToPath(import.meta.url)),
|
|
".."
|
|
);
|
|
const SCAN_ROOTS = [
|
|
join(PACKAGE_ROOT, "src", "components"),
|
|
join(PACKAGE_ROOT, "src", "i18n"),
|
|
join(PACKAGE_ROOT, "src", "theme"),
|
|
join(PACKAGE_ROOT, "src", "pack"),
|
|
join(PACKAGE_ROOT, "src", "provider"),
|
|
join(PACKAGE_ROOT, "src", "hooks"),
|
|
join(PACKAGE_ROOT, "src", "errors"),
|
|
];
|
|
const BUNDLE_PATH = join(PACKAGE_ROOT, "src", "locales", "en-GB.json");
|
|
const NAMESPACE = "react-ui.";
|
|
|
|
export interface Diagnostic {
|
|
readonly kind: "MISSING" | "ORPHAN";
|
|
readonly key: string;
|
|
/** Source path + line for MISSING diagnostics; absent for ORPHAN. */
|
|
readonly location?: string;
|
|
}
|
|
|
|
export interface KeyExtraction {
|
|
readonly keys: ReadonlyMap<string, string>;
|
|
}
|
|
|
|
const KEY_PATTERNS: ReadonlyArray<RegExp> = [
|
|
/\bt\s*\(\s*["'](react-ui\.[^"']+)["']/g,
|
|
/\btDynamic\s*\(\s*["'](react-ui\.[^"']+)["']/g,
|
|
/\bi18nKey\s*=\s*["'](react-ui\.[^"']+)["']/g,
|
|
/\bi18nKey\s*:\s*["'](react-ui\.[^"']+)["']/g,
|
|
/\b\w+Key\s*=\s*\{?\s*["'](react-ui\.[^"']+)["']/g,
|
|
/\?\?\s*["'](react-ui\.[^"']+)["']/g,
|
|
// `const DEFAULT_*_KEY = "react-ui.…"` — the chassis convention
|
|
// for module-scoped default keys consumed via the *Key prop
|
|
// pattern above.
|
|
/(?:const|let|var)\s+\w+\s*(?::[^=]+)?=\s*["'](react-ui\.[^"']+)["']/g,
|
|
// `labelKey: "react-ui.…"` — theme-plugin / pack-plugin literal
|
|
// (chassis convention).
|
|
/\blabelKey\s*:\s*["'](react-ui\.[^"']+)["']/g,
|
|
// `as ThemePlugin["labelKey"]` cast pattern — keys forwarded
|
|
// through `as const` annotations into a typed slot.
|
|
/["'](react-ui\.[a-zA-Z][a-zA-Z0-9.]*)["']\s*as\s+const\b/g,
|
|
];
|
|
|
|
async function walk(dir: string): Promise<string[]> {
|
|
const out: string[] = [];
|
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
|
const full = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
out.push(...(await walk(full)));
|
|
} else if (
|
|
entry.isFile() &&
|
|
(entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) &&
|
|
!entry.name.endsWith(".test.ts") &&
|
|
!entry.name.endsWith(".test.tsx") &&
|
|
!entry.name.endsWith(".stories.tsx") &&
|
|
!entry.name.endsWith(".stories.test.tsx") &&
|
|
!entry.name.endsWith(".bench.ts")
|
|
) {
|
|
out.push(full);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function lineNumber(source: string, offset: number): number {
|
|
let line = 1;
|
|
for (let i = 0; i < offset && i < source.length; i += 1) {
|
|
if (source.charCodeAt(i) === 10) line += 1;
|
|
}
|
|
return line;
|
|
}
|
|
|
|
export async function extractKeysFromSource(
|
|
sourceFiles: ReadonlyArray<string>,
|
|
rootForRelative: string
|
|
): Promise<KeyExtraction> {
|
|
const keys = new Map<string, string>();
|
|
for (const file of sourceFiles) {
|
|
const text = await readFile(file, "utf8");
|
|
for (const pattern of KEY_PATTERNS) {
|
|
for (const match of text.matchAll(pattern)) {
|
|
const key = match[1];
|
|
if (typeof key !== "string" || keys.has(key)) continue;
|
|
const idx = match.index ?? 0;
|
|
const ln = lineNumber(text, idx);
|
|
keys.set(key, `${relative(rootForRelative, file)}:${ln}`);
|
|
}
|
|
}
|
|
}
|
|
return { keys };
|
|
}
|
|
|
|
export function diff(
|
|
sourceKeys: ReadonlyMap<string, string>,
|
|
bundleKeys: ReadonlySet<string>
|
|
): Diagnostic[] {
|
|
const diagnostics: Diagnostic[] = [];
|
|
for (const [key, location] of sourceKeys) {
|
|
if (!bundleKeys.has(key)) {
|
|
diagnostics.push({ kind: "MISSING", key, location });
|
|
}
|
|
}
|
|
for (const key of bundleKeys) {
|
|
if (!sourceKeys.has(key)) {
|
|
diagnostics.push({ kind: "ORPHAN", key });
|
|
}
|
|
}
|
|
diagnostics.sort((a, b) => {
|
|
if (a.kind !== b.kind) return a.kind === "MISSING" ? -1 : 1;
|
|
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
});
|
|
return diagnostics;
|
|
}
|
|
|
|
export async function loadBundleKeys(
|
|
bundlePath: string
|
|
): Promise<Set<string>> {
|
|
const raw = await readFile(bundlePath, "utf8");
|
|
const parsed: Record<string, unknown> = JSON.parse(raw);
|
|
const out = new Set<string>();
|
|
for (const key of Object.keys(parsed)) {
|
|
if (key.startsWith(NAMESPACE)) out.add(key);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export interface CheckOptions {
|
|
readonly strict: boolean;
|
|
}
|
|
|
|
function parseArgs(argv: ReadonlyArray<string>): CheckOptions {
|
|
let strict = false;
|
|
for (const arg of argv) {
|
|
if (arg === "--strict") strict = true;
|
|
}
|
|
return { strict };
|
|
}
|
|
|
|
async function main(): Promise<number> {
|
|
const opts = parseArgs(process.argv.slice(2));
|
|
const sourceFiles: string[] = [];
|
|
for (const root of SCAN_ROOTS) {
|
|
try {
|
|
sourceFiles.push(...(await walk(root)));
|
|
} catch (err) {
|
|
if (
|
|
(err as NodeJS.ErrnoException).code !== "ENOENT" &&
|
|
(err as NodeJS.ErrnoException).code !== "ENOTDIR"
|
|
) {
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
if (sourceFiles.length === 0) {
|
|
console.error(
|
|
`check-i18n-keys: no source files found under any of ${SCAN_ROOTS.join(", ")}`
|
|
);
|
|
return 2;
|
|
}
|
|
const { keys: sourceKeys } = await extractKeysFromSource(
|
|
sourceFiles,
|
|
PACKAGE_ROOT
|
|
);
|
|
const bundleKeys = await loadBundleKeys(BUNDLE_PATH);
|
|
const diagnostics = diff(sourceKeys, bundleKeys);
|
|
|
|
if (diagnostics.length === 0) {
|
|
console.log(
|
|
`check-i18n-keys: clean — ${sourceKeys.size} keys in code, ${bundleKeys.size} keys in bundle.`
|
|
);
|
|
return 0;
|
|
}
|
|
|
|
const missingCount = diagnostics.filter((d) => d.kind === "MISSING").length;
|
|
const orphanCount = diagnostics.length - missingCount;
|
|
|
|
for (const d of diagnostics) {
|
|
if (d.kind === "MISSING") {
|
|
console.error(`MISSING ${d.key} at ${d.location ?? "?"}`);
|
|
} else if (opts.strict) {
|
|
console.error(`ORPHAN ${d.key}`);
|
|
} else {
|
|
console.warn(`ORPHAN ${d.key} (warn — use --strict to fail)`);
|
|
}
|
|
}
|
|
console.error(
|
|
`check-i18n-keys: ${missingCount} missing, ${orphanCount} orphan` +
|
|
(opts.strict ? " (strict)" : " (orphan = warn)")
|
|
);
|
|
if (missingCount > 0) return 1;
|
|
if (opts.strict && orphanCount > 0) return 1;
|
|
return 0;
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
process.exit(await main());
|
|
}
|