You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
380 lines
13 KiB
380 lines
13 KiB
/**
|
|
* 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<string, string>;
|
|
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<string, LangBranch> {
|
|
const map = new Map<string, LangBranch>();
|
|
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<string, unknown>)[seg];
|
|
}
|
|
return cur;
|
|
}
|
|
|
|
function isLeafRecord(v: unknown): v is LangLeaf {
|
|
return (
|
|
typeof v === 'object' &&
|
|
v !== null &&
|
|
!Array.isArray(v) &&
|
|
Object.values(v as Record<string, unknown>).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(/<!--[\s\S]*?-->/g, '');
|
|
}
|
|
|
|
function scanIdlangrefsInSource(): Map<string, Set<string>> {
|
|
const occurrences = new Map<string, Set<string>>(); // 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<string, LangBranch>,
|
|
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<string, LangBranch>,
|
|
morfoEntries: MorfoTextEntry[],
|
|
sourceOccurrences: Map<string, Set<string>>
|
|
): void {
|
|
const referenced = new Set<string>();
|
|
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<string, Set<string>>,
|
|
components: Map<string, LangBranch>,
|
|
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();
|