Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,239 @@
#!/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());
}