#!/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.…')` * - `` / `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 at :` — referenced in code but absent * from the bundle (consumers will hit a missing-key warning). * - `ORPHAN ` — 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; } const KEY_PATTERNS: ReadonlyArray = [ /\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 { 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, rootForRelative: string ): Promise { const keys = new Map(); 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, bundleKeys: ReadonlySet ): 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> { const raw = await readFile(bundlePath, "utf8"); const parsed: Record = JSON.parse(raw); const out = new Set(); 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): CheckOptions { let strict = false; for (const arg of argv) { if (arg === "--strict") strict = true; } return { strict }; } async function main(): Promise { 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()); }