/** * One-off migration: morfo.translations → morfo.texts + per-component langs. * * For each `src/uix/morfo/components/*.ts` whose declaration includes a * `translations: { ... }` block: * * 1. Extract the literal block as JS data. * 2. Write `src/uix/langs/components/{kebab}.ts` exporting `{camelCase}Langs` * with the same data, typed as `LangNode`. * 3. Replace the morfo's `translations: { ... }` with `texts: { ... }` where * every leaf becomes an idlangref `'#?components.{kebab}.{path}|'` * keyed by the dotted path. * 4. Regenerate `src/uix/langs/components/index.ts` so it spreads every * per-component module under its kebab key into `componentLangs`. * * The translation blocks in current morfos are pure literal data — nested * objects with leaf shape `{ es, en, ... }`. No `v.*` builders or expressions * appear inside. That lets us rely on a brace-balanced text walker instead of * a TS AST. */ import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = join(HERE, '..'); const MORFO_DIR = join(REPO, 'src', 'uix', 'morfo', 'components'); const LANGS_DIR = join(REPO, 'src', 'uix', 'langs', 'components'); interface LeafRecord { readonly [locale: string]: string; } type LangValue = LeafRecord | { [key: string]: LangValue }; function isLeaf(node: LangValue): node is LeafRecord { return Object.values(node).every((v) => typeof v === 'string'); } /** Find the `translations: { … }` block opening offset (after the `{`). */ function findTranslationsBlock(source: string): { start: number; end: number } | null { const match = source.match(/^\ttranslations:\s*\{/m); if (!match) return null; const openBrace = match.index! + match[0].length - 1; // index of '{' let depth = 1; let i = openBrace + 1; while (i < source.length && depth > 0) { const ch = source[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++; } if (depth !== 0) throw new Error('Unbalanced braces in translations block'); return { start: match.index!, end: i }; } function evalLiteralBlock(snippet: string): LangValue { // snippet looks like: `{\n\t\tkey: { es: '…', en: '…' },\n\t\t…\n\t}` // Strip TS comments (// and /* */) and trailing commas to make it valid JS. const cleaned = snippet .replace(/\/\/[^\n]*/g, '') .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/,(\s*[}\]])/g, '$1'); // Use Function to evaluate as JS object literal. // eslint-disable-next-line @typescript-eslint/no-implied-eval const fn = new Function(`return (${cleaned});`); return fn() as LangValue; } function flattenLeaves( node: LangValue, prefix: string[] = [] ): Array<{ path: string[]; leaf: LeafRecord }> { if (isLeaf(node)) return [{ path: prefix, leaf: node }]; return Object.entries(node).flatMap(([k, v]) => flattenLeaves(v as LangValue, [...prefix, k])); } function pickFallback(leaf: LeafRecord): string { return leaf.en ?? leaf.es ?? Object.values(leaf)[0] ?? ''; } function kebabToCamel(kebab: string): string { return kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); } function renderLangsTs(kebab: string, data: LangValue): string { const camel = kebabToCamel(kebab); const body = JSON.stringify(data, null, '\t').replace(/"([a-zA-Z_$][\w$-]*)":/g, (_, k) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k) ? `${k}:` : `'${k}':` ); return `import type { LangNode } from '$libs/langs'; /** * Default strings for the ${kebab} component. Merged under * \`components.${kebab}.*\` by \`ActiveUix\` (via \`componentLangs\` barrel). * * Morfo references entries by absolute idlangref: * \`'#?components.${kebab}.{key}|fallback'\`. */ export const ${camel}Langs = ${body} satisfies LangNode; `; } function renderTextsBlock(kebab: string, data: LangValue, indent: string): string { const leaves = flattenLeaves(data); const lines = leaves.map(({ path, leaf }) => { const dotted = path.join('.'); const fallback = pickFallback(leaf); const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(dotted) ? dotted : `'${dotted}'`; return `${indent}\t${key}: '#?components.${kebab}.${dotted}|${fallback}'`; }); return `${indent}texts: {\n${lines.join(',\n')}\n${indent}}`; } function detectKebab(source: string): string { const m = source.match(/kebab:\s*'([^']+)'/); if (!m) throw new Error('No kebab field'); return m[1]; } function migrateFile(path: string): { kebab: string; data: LangValue } | null { const source = readFileSync(path, 'utf8'); const block = findTranslationsBlock(source); if (!block) return null; const kebab = detectKebab(source); const fullBlock = source.slice(block.start, block.end); // includes "translations: {…}" const innerStart = fullBlock.indexOf('{'); const innerSnippet = fullBlock.slice(innerStart); // "{ … }" const data = evalLiteralBlock(innerSnippet); // Indent of the original block — match the leading tab(s) before "translations:" const indentMatch = fullBlock.match(/^(\t*)translations:/); const indent = indentMatch?.[1] ?? '\t'; const replacement = renderTextsBlock(kebab, data, indent); const newSource = source.slice(0, block.start) + replacement + source.slice(block.end); writeFileSync(path, newSource, 'utf8'); // Write langs/components/{kebab}.ts const langsPath = join(LANGS_DIR, `${kebab}.ts`); writeFileSync(langsPath, renderLangsTs(kebab, data), 'utf8'); return { kebab, data }; } function regenerateBarrel(kebabs: string[]): void { const sorted = [...kebabs].sort(); const imports = sorted.map((k) => `import { ${kebabToCamel(k)}Langs } from './${k}';`).join('\n'); const entries = sorted .map((k) => { const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k) ? k : `'${k}'`; return `\t${key}: ${kebabToCamel(k)}Langs`; }) .join(',\n'); const body = `import type { LangNode } from '$libs/langs'; ${imports} /** * Per-component default strings. Each morfo that uses \`texts:\` with * idlangrefs under \`components.{kebab}.*\` ships an entry here. Merged into * the active langs catalog by \`ActiveUix\`. */ export const componentLangs = { ${entries} } satisfies LangNode; `; writeFileSync(join(LANGS_DIR, 'index.ts'), body, 'utf8'); } function main() { const files = readdirSync(MORFO_DIR) .filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')) .map((f) => join(MORFO_DIR, f)); const migrated: string[] = []; for (const file of files) { try { const result = migrateFile(file); if (result) migrated.push(result.kebab); } catch (err) { console.error(`FAIL ${file}: ${(err as Error).message}`); throw err; } } regenerateBarrel(migrated); console.log(`Migrated ${migrated.length} components:`); for (const k of migrated.sort()) console.log(` - ${k}`); } main();