/** * translations-check — validate the texts/idlangref discipline across UIX. * * Runs four independent checks and exits non-zero if any of them fails: * * 1. **Morfo `texts:` shape**: every value is an absolute idlangref * (`'#?…|fallback'`). Component-scoped refs (`#?components.{kebab}.…`) must * match the morfo's own `kebab`. * * 2. **Component catalog coverage**: every component-scoped idlangref points * into `src/uix/langs/components/{kebab}.ts`. The dotted path must resolve * to a leaf `LangRecord` (object whose values are strings). * * 3. **Common catalog coverage**: every `#?common.…` idlangref points into * `src/uix/langs/common.ts`. * * 4. **Orphan detection**: every leaf in the component catalogs is referenced * by at least one `morfo.texts` entry OR a soma/eidos source file. Orphan * entries are surfaced as warnings (not errors) so consumers can remove * them safely. * * Also enforces: * - No raw `translations:` field survives anywhere under `src/uix/morfo/`. * - No source file (.ts / .svelte under src/uix) declares an idlangref to a * nonexistent path. * * The implementation evaluates each `src/uix/langs/components/*.ts` and the * common catalog via a controlled `Function(...)` call; those files are pure * data so this is safe in CI. */ import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, dirname, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = join(HERE, '..'); const UIX = join(REPO, 'src', 'uix'); const MORFO_DIR = join(UIX, 'morfo', 'components'); const LANGS_COMMON = join(UIX, 'langs', 'common.ts'); const LANGS_COMPONENTS = join(UIX, 'langs', 'components'); type LangLeaf = Record; type LangBranch = { [key: string]: LangLeaf | LangBranch }; interface Finding { severity: 'error' | 'warn'; source: string; message: string; } const findings: Finding[] = []; function err(source: string, message: string) { findings.push({ severity: 'error', source, message }); } function warn(source: string, message: string) { findings.push({ severity: 'warn', source, message }); } // ── Catalog evaluation ──────────────────────────────────────────────────── function evalCatalogFile(file: string, exportName: string): LangBranch { const source = readFileSync(file, 'utf8'); const stripped = source .replace(/\/\/[^\n]*/g, '') .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/,(\s*[}\]])/g, '$1'); const re = new RegExp(`export\\s+const\\s+${exportName}\\s*=\\s*(\\{[\\s\\S]*?\\})\\s+satisfies`); const match = stripped.match(re); if (!match) throw new Error(`No export const ${exportName} found in ${file}`); // eslint-disable-next-line @typescript-eslint/no-implied-eval const fn = new Function(`return (${match[1]});`); return fn() as LangBranch; } function kebabToCamel(kebab: string): string { return kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); } function loadCommonCatalog(): LangBranch { return evalCatalogFile(LANGS_COMMON, 'commonLangs'); } function loadComponentCatalogs(): Map { const map = new Map(); const files = readdirSync(LANGS_COMPONENTS).filter( (f) => f.endsWith('.ts') && f !== 'index.ts' ); for (const file of files) { const kebab = file.replace(/\.ts$/, ''); const exportName = `${kebabToCamel(kebab)}Langs`; const data = evalCatalogFile(join(LANGS_COMPONENTS, file), exportName); map.set(kebab, data); } return map; } // ── Path resolution inside a catalog branch ─────────────────────────────── function resolvePath(node: LangBranch, path: string[]): unknown { let cur: unknown = node; for (const seg of path) { if (cur == null || typeof cur !== 'object') return undefined; cur = (cur as Record)[seg]; } return cur; } function isLeafRecord(v: unknown): v is LangLeaf { return ( typeof v === 'object' && v !== null && !Array.isArray(v) && Object.values(v as Record).every((x) => typeof x === 'string') ); } function collectLeafPaths(node: unknown, prefix: string[] = []): string[][] { if (isLeafRecord(node)) return [prefix]; if (typeof node !== 'object' || node === null) return []; return Object.entries(node).flatMap(([k, v]) => collectLeafPaths(v, [...prefix, k])); } // ── Morfo file scanning ─────────────────────────────────────────────────── interface MorfoTextEntry { file: string; kebab: string; textKey: string; rawRef: string; } function extractKebab(source: string): string | null { const m = source.match(/kebab:\s*'([^']+)'/); return m?.[1] ?? null; } function extractTextsBlock(source: string): string | null { const m = source.match(/^\ttexts:\s*\{/m); if (!m) return null; const open = m.index! + m[0].length - 1; let depth = 1; let i = open + 1; while (i < source.length && depth > 0) { if (source[i] === '{') depth++; else if (source[i] === '}') depth--; i++; } if (depth !== 0) return null; return source.slice(open + 1, i - 1); } function scanMorfoTexts(): MorfoTextEntry[] { const out: MorfoTextEntry[] = []; const files = readdirSync(MORFO_DIR).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')); for (const file of files) { const path = join(MORFO_DIR, file); const source = readFileSync(path, 'utf8'); // Hard ban: no `translations:` field at root indent. if (/^\ttranslations:\s*\{/m.test(source)) { err(path, 'Legacy `translations:` field present. Replace with `texts:` idlangrefs.'); } const kebab = extractKebab(source); if (!kebab) continue; const block = extractTextsBlock(source); if (!block) continue; // Walk key: '#?…' or "key": '#?…' lines. The value is always a single // quoted string per the migrated layout. const lineRe = /['"]?([\w-]+(?:\.[\w-]+)*)['"]?\s*:\s*['"`](#\?[^'"`]+)['"`]/g; let lm: RegExpExecArray | null; while ((lm = lineRe.exec(block))) { out.push({ file: path, kebab, textKey: lm[1], rawRef: lm[2] }); } } return out; } // ── Cross-source idlangref scanning ─────────────────────────────────────── function listSourceFiles(root: string): string[] { if (!statSync(root).isDirectory()) return []; const out: string[] = []; for (const entry of readdirSync(root)) { const path = join(root, entry); const stat = statSync(path); if (stat.isDirectory()) { out.push(...listSourceFiles(path)); } else if ( (entry.endsWith('.ts') || entry.endsWith('.svelte')) && !entry.endsWith('.test.ts') && !entry.endsWith('.test.svelte') ) { out.push(path); } } return out; } function stripCommentsAndStrings(source: string): string { // Drop // line comments, /* */ block comments, and `` HTML comments // inside .svelte. Templates with `{var}` placeholders inside comments would // otherwise produce false-positive idlangref scans. return source .replace(/\/\/[^\n]*/g, '') .replace(/\/\*[\s\S]*?\*\//g, '') .replace(//g, ''); } function scanIdlangrefsInSource(): Map> { const occurrences = new Map>(); // ref → files const files = listSourceFiles(UIX); // Only match refs with no `{` `}` `:` whitespace — those are real idlangrefs, // not doc placeholders like `#?components.{kebab}.{key}`. const re = /#\?([A-Za-z][\w.-]*)(?:\|([^'"`\n]*))?/g; for (const file of files) { const source = stripCommentsAndStrings(readFileSync(file, 'utf8')); let m: RegExpExecArray | null; while ((m = re.exec(source))) { const path = m[1]; if (!occurrences.has(path)) occurrences.set(path, new Set()); occurrences.get(path)!.add(file); } } return occurrences; } // ── Checks ──────────────────────────────────────────────────────────────── function checkMorfoTextsShape( entries: MorfoTextEntry[], components: Map, common: LangBranch ): void { for (const { file, kebab, textKey, rawRef } of entries) { if (!rawRef.startsWith('#?')) { err( file, `texts.${textKey}: must be an absolute idlangref starting with '#?'. Got ${JSON.stringify(rawRef)}.` ); continue; } const body = rawRef.slice(2); const pipe = body.indexOf('|'); const refPath = pipe === -1 ? body : body.slice(0, pipe); const fallback = pipe === -1 ? undefined : body.slice(pipe + 1); if (!fallback) { warn(file, `texts.${textKey}: idlangref ${rawRef} has no fallback after '|'.`); } const segments = refPath.split('.'); if (segments[0] === 'components') { if (segments[1] !== kebab) { err( file, `texts.${textKey}: ref points to ${segments[1]} but the morfo kebab is ${kebab}.` ); continue; } const branch = components.get(kebab); if (!branch) { err( file, `texts.${textKey}: no catalog found at src/uix/langs/components/${kebab}.ts.` ); continue; } const leaf = resolvePath(branch, segments.slice(2)); if (!isLeafRecord(leaf)) { err( file, `texts.${textKey}: ref ${refPath} does not resolve to a LangRecord leaf in components/${kebab}.ts.` ); } } else if (segments[0] === 'common') { const leaf = resolvePath(common, segments.slice(1)); if (!isLeafRecord(leaf)) { err( file, `texts.${textKey}: ref ${refPath} does not resolve to a LangRecord leaf in langs/common.ts.` ); } } else { // app-scoped refs like `#?app.foo.bar`: cannot validate inside UIX. warn( file, `texts.${textKey}: ref ${refPath} is outside common/components; not validated by UIX.` ); } } } function checkComponentCatalogOrphans( components: Map, morfoEntries: MorfoTextEntry[], sourceOccurrences: Map> ): void { const referenced = new Set(); for (const e of morfoEntries) { const refPath = e.rawRef.slice(2).split('|')[0]; referenced.add(refPath); } for (const path of sourceOccurrences.keys()) referenced.add(path); for (const [kebab, branch] of components) { const leaves = collectLeafPaths(branch); for (const leafPath of leaves) { const full = `components.${kebab}.${leafPath.join('.')}`; if (!referenced.has(full)) { warn( join(LANGS_COMPONENTS, `${kebab}.ts`), `Orphan: ${full} is not referenced by any morfo.texts entry or source file.` ); } } } } function checkSourceIdlangrefs( occurrences: Map>, components: Map, common: LangBranch ): void { for (const [path, files] of occurrences) { const segments = path.split('.'); if (segments[0] === 'components') { const kebab = segments[1]; const branch = components.get(kebab); if (!branch) continue; // not a UIX-owned component const leaf = resolvePath(branch, segments.slice(2)); if (!isLeafRecord(leaf)) { for (const file of files) { err(file, `idlangref ${path} does not resolve to a leaf in components/${kebab}.ts.`); } } } else if (segments[0] === 'common') { const leaf = resolvePath(common, segments.slice(1)); if (!isLeafRecord(leaf)) { for (const file of files) { err(file, `idlangref ${path} does not resolve to a leaf in langs/common.ts.`); } } } } } // ── Main ────────────────────────────────────────────────────────────────── function main() { const common = loadCommonCatalog(); const components = loadComponentCatalogs(); const morfoEntries = scanMorfoTexts(); const sourceOccurrences = scanIdlangrefsInSource(); checkMorfoTextsShape(morfoEntries, components, common); checkSourceIdlangrefs(sourceOccurrences, components, common); checkComponentCatalogOrphans(components, morfoEntries, sourceOccurrences); const errors = findings.filter((f) => f.severity === 'error'); const warnings = findings.filter((f) => f.severity === 'warn'); for (const f of findings) { const rel = f.source.replace(REPO + '\\', '').replace(REPO + '/', ''); const tag = f.severity === 'error' ? 'ERROR' : 'WARN'; console.log(`${tag} ${rel} ${f.message}`); } console.log(); console.log( `Summary: ${morfoEntries.length} morfo text refs · ${components.size} component catalogs · ${errors.length} errors · ${warnings.length} warnings.` ); if (errors.length > 0) process.exit(1); } main();