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.
194 lines
6.7 KiB
194 lines
6.7 KiB
/**
|
|
* 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}|<fallback>'`
|
|
* 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();
|