diff --git a/package-lock.json b/package-lock.json index 55277bf..cb67256 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,10 +31,12 @@ "clsx": "^2.1.1", "framer-motion": "^11.16.4", "html2canvas": "^1.4.1", + "i18next": "^26.4.2", "lucide-react": "^0.475.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-hot-toast": "^2.6.0", + "react-i18next": "^17.0.16", "react-markdown": "^9.0.1", "react-router-dom": "^6.26.0", "recharts": "^3.10.1", @@ -6315,6 +6317,15 @@ "react-is": "^16.7.0" } }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -6366,6 +6377,34 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", + "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -8701,6 +8740,33 @@ "react-dom": ">=16" } }, + "node_modules/react-i18next": { + "version": "17.0.16", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz", + "integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -10073,7 +10139,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index f33a70d..33bb88c 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,12 @@ "clsx": "^2.1.1", "framer-motion": "^11.16.4", "html2canvas": "^1.4.1", + "i18next": "^26.4.2", "lucide-react": "^0.475.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-hot-toast": "^2.6.0", + "react-i18next": "^17.0.16", "react-markdown": "^9.0.1", "react-router-dom": "^6.26.0", "recharts": "^3.10.1", diff --git a/scripts/i18n-audit.mjs b/scripts/i18n-audit.mjs new file mode 100644 index 0000000..9e6884e --- /dev/null +++ b/scripts/i18n-audit.mjs @@ -0,0 +1,58 @@ +/** + * Every t('key') in the source, checked against the catalogues. + * + * A key the code calls and the catalogue lacks renders as humanised English at + * best, so this is the check that says the extraction is actually complete. + * The reverse — a catalogue entry nothing calls — is only waste, and reported + * separately. + */ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const walk = (dir, acc = []) => { + for (const e of readdirSync(dir)) { + if (e === 'node_modules' || e.startsWith('.')) continue; + const p = join(dir, e); + if (statSync(p).isDirectory()) walk(p, acc); + else if (/\.(tsx|ts|jsx|js)$/.test(p)) acc.push(p); + } + return acc; +}; + +const flat = (obj, prefix = '', out = {}) => { + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === 'object') flat(v, key, out); + else out[key] = v; + } + return out; +}; + +const en = flat(JSON.parse(readFileSync('src/lib/i18n/locales/en.json', 'utf8'))); +const es = flat(JSON.parse(readFileSync('src/lib/i18n/locales/es.json', 'utf8'))); + +const used = new Set(); +const dynamic = []; +for (const file of walk('src')) { + const src = readFileSync(file, 'utf8'); + for (const m of src.matchAll(/\bt\(\s*'([^']+)'/g)) used.add(m[1]); + for (const m of src.matchAll(/\bt\(\s*`([^`]+)`/g)) dynamic.push(`${file}: ${m[1]}`); +} + +/* A plural key is stored as name_one / name_other, so the base name is never + itself in the catalogue and is not missing. */ +const present = (k) => k in en || `${k}_other` in en || `${k}_one` in en; + +const missingEn = [...used].filter((k) => !present(k)).sort(); +const missingEs = [...used].filter((k) => present(k) && !(k in es || `${k}_other` in es)).sort(); +const unused = Object.keys(en).filter((k) => !used.has(k) && !used.has(k.replace(/_(one|other)$/, ''))).sort(); + +console.log(`\n t() calls with a literal key : ${used.size}`); +console.log(` built from a template : ${dynamic.length} (checked by hand)`); +console.log(` MISSING from en.json : ${missingEn.length}`); +console.log(` MISSING from es.json : ${missingEs.length}`); +console.log(` in en.json, never called : ${unused.length}`); + +for (const k of missingEn.slice(0, 15)) console.log(` missing en: ${k}`); +for (const k of missingEs.slice(0, 10)) console.log(` missing es: ${k}`); +if (missingEn.length) process.exitCode = 1; diff --git a/scripts/i18n-coverage.mjs b/scripts/i18n-coverage.mjs new file mode 100644 index 0000000..522c8ea --- /dev/null +++ b/scripts/i18n-coverage.mjs @@ -0,0 +1,126 @@ +/** + * How much of each page is actually translated. + * + * Renders every migrated page twice — once in English, once in Spanish — and + * reports the text that did NOT change. That residue is the untranslated part, + * and it is the only honest measure of coverage: a catalogue can be full of + * Spanish that no component ever calls, and counting keys would score that as + * finished. + * + * Reports rather than asserts. The point is a number per page while the work is + * half done; `skill-check.mjs` is where a finished page gets a check that keeps + * it finished. + * + * node scripts/i18n-coverage.mjs + */ + +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { createServer } from 'vite'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { withSourceResolution } from './ssr-resolve.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); + +/* The pages reach for `window` on the way up — a media query, a resize + listener. The same minimal stub the skill-check uses, installed before any + page module is loaded. */ +if (!('window' in globalThis)) { + globalThis.window = { + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + addEventListener() {}, removeEventListener() {}, + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + }; +} + +const server = await createServer({ + root: ROOT, + server: { middlewareMode: true }, + appType: 'custom', + logLevel: 'error', + resolve: { alias: { 'react-hot-toast': join(ROOT, 'scripts/stubs/react-hot-toast.js') } }, +}); +withSourceResolution(server); + +const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query'); +const { MemoryRouter } = await import('react-router-dom'); +const { UiEditingProvider } = await server.ssrLoadModule('/src/components/ui-tree/UiEditingProvider.jsx'); +const i18n = (await server.ssrLoadModule('/src/lib/i18n/index.ts')).default; + +const PAGES = [ + ['Control Center', '/src/pages/admin/ControlCenter.jsx', '/admin', 'control-center'], + ['Positions', '/src/pages/admin/Positions.jsx', '/admin/positions', 'positions'], + ['Candidates', '/src/pages/admin/Candidates.jsx', '/admin/candidates', 'candidates'], + ['Talent Pool', '/src/pages/admin/TalentPool.jsx', '/admin/talent-pool', 'talent-pool'], + ['Analytics', '/src/pages/admin/Analytics.jsx', '/admin/analytics', 'analytics'], + ['Hired History', '/src/pages/admin/HiredHistory.jsx', '/admin/hired', 'hired-history'], + ['Activity', '/src/pages/admin/Activity.jsx', '/admin/activity', 'activity'], +]; + +/* Text only, split into phrases on the punctuation the interface uses to + separate one label from the next, so "17 total" counts separately from the + heading above it. Numbers and symbols alone are not evidence either way — + "27" is the same in both languages — so anything without a letter is + dropped rather than scored as translated. */ +const phrases = (html) => + html + .replace(/<[^>]*>/g, '\n') + .split(/[\n·|•]+/) + .map((p) => p.replace(/\s+/g, ' ').trim()) + .filter((p) => p.length > 2 && /[A-Za-z]{3}/.test(p)); + +async function render(path, page) { + const Page = (await server.ssrLoadModule(path)).default; + const client = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } }); + return renderToStaticMarkup( + React.createElement(MemoryRouter, { initialEntries: [page.route] }, + React.createElement(QueryClientProvider, { client }, + React.createElement(UiEditingProvider, { page: page.key }, + React.createElement(Page)))) + ); +} + +console.log('\n Translation coverage — text that changes when the language does\n'); + +let totalSame = 0; +let totalAll = 0; + +for (const [name, path, route, key] of PAGES) { + let en; + let es; + try { + await i18n.changeLanguage('en'); + en = phrases(await render(path, { route, key })); + await i18n.changeLanguage('es'); + es = phrases(await render(path, { route, key })); + await i18n.changeLanguage('en'); + } catch (err) { + console.log(` ${name.padEnd(16)} could not render — ${String(err.message).slice(0, 60)}`); + continue; + } + + /* A phrase present in BOTH renders is one the switch did not touch. Proper + nouns and figures land here too, which is why the sample below is printed: + a human reads it and sees at once whether the residue is "KROW Forge" or a + whole untranslated paragraph. */ + const esSet = new Set(es); + const untouched = en.filter((p) => esSet.has(p)); + + totalSame += untouched.length; + totalAll += en.length; + + const pct = en.length ? Math.round(((en.length - untouched.length) / en.length) * 100) : 0; + const bar = '█'.repeat(Math.round(pct / 5)).padEnd(20, '·'); + console.log(` ${name.padEnd(16)} ${bar} ${String(pct).padStart(3)}% ${en.length - untouched.length}/${en.length} phrases`); + + if (untouched.length) { + const sample = untouched.filter((p) => /[a-z]{4}/.test(p)).slice(0, 4); + if (sample.length) console.log(` ${''.padEnd(16)} still English: ${sample.join(' / ').slice(0, 96)}`); + } +} + +const overall = totalAll ? Math.round(((totalAll - totalSame) / totalAll) * 100) : 0; +console.log(`\n Overall: ${overall}% of rendered phrases change with the language.\n`); + +await server.close(); diff --git a/scripts/i18n-extract.mjs b/scripts/i18n-extract.mjs new file mode 100644 index 0000000..f214a34 --- /dev/null +++ b/scripts/i18n-extract.mjs @@ -0,0 +1,307 @@ +/** + * Lift hard-coded English out of components and into the catalogue. + * + * node scripts/i18n-extract.mjs --dry src/components/krow + * node scripts/i18n-extract.mjs src/components/krow/SomeCard.tsx + * + * An AST codemod rather than a regex pass, because the hard part is not finding + * the string — it is knowing which component encloses it, so `const { t } = + * useTranslation()` lands in the right function and not in a module-level + * constant where hooks cannot run. + * + * WHAT IT WILL TOUCH + * - JSX text children that are entirely static words + * - string literals on a short list of text-bearing attributes + * + * WHAT IT LEAVES ALONE, and why each one would be a bug: + * - anything inside a module-level object or array; those are built once at + * import, so a t() there freezes the first language the app loaded + * - strings with no letters, single characters, and pure punctuation + * - `className`, `id`, `key`, `type`, `name`, routes, urls, test ids + * - text inside ,
,