#!/usr/bin/env node /** * Repoint stale `` tags in `` catalog messages at the right children. * * The wording in these messages is correct; only the indices drifted, because * editing a `` element's children renumbers every child after the edit * while the catalog keeps the old numbers. So the fix is a remap, not a rewrite: * take the tags in the order they appear in the message and point them at the * element children in the order they appear in the JSX. * * Declines — and reports — anything where that correspondence is not obviously * right: a different number of tags than element children, or nested tags. Those * need a human to decide which element each phrase belongs to. * * Usage: node scripts/fix-trans-indices.mjs [--write] [ ...] */ import { parseSync } from "@babel/core"; import fs from "node:fs"; import path from "node:path"; const ROOT = path.resolve(import.meta.dirname, ".."); /** Overridable so a test can point the remap at a fixture catalog. */ const EN = process.env.KANDEV_I18N_EN_DIR ?? path.join(ROOT, "src", "locales", "en"); const WRITE = process.argv.includes("--write"); const args = process.argv.slice(2).filter((a) => !a.startsWith("--")); const TARGETS = args.length ? args : ["components", "app"]; const catalogs = {}; for (const file of fs.readdirSync(EN)) { if (!file.endsWith(".json")) continue; catalogs[file.replace(/\.json$/, "")] = JSON.parse(fs.readFileSync(path.join(EN, file), "utf8")); } /** Every catalog entry a call-site key may resolve to (plurals included). */ function entriesFor(qualified) { const [ns, key] = qualified.includes(":") ? qualified.split(":") : ["common", qualified]; const cat = catalogs[ns]; if (!cat) return []; return [key, `${key}_one`, `${key}_other`].filter((k) => k in cat).map((k) => [ns, k]); } function walk(node, visit) { if (!node || typeof node.type !== "string") return; visit(node); for (const key of Object.keys(node)) { if (key === "loc" || key.endsWith("Comments")) continue; const child = node[key]; if (Array.isArray(child)) { for (const c of child) if (c && typeof c.type === "string") walk(c, visit); } else if (child && typeof child.type === "string") walk(child, visit); } } /** JSX drops text that is whitespace-only and spans a line break. */ const runtimeChildren = (element) => element.children.filter( (c) => !(c.type === "JSXText" && c.value.trim() === "" && c.value.includes("\n")), ); function listFiles(dir, out, errors) { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (error) { errors.push(`${dir}: ${error.code ?? error.message}`); return out; } for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (["node_modules", "dist", "e2e", "locales"].includes(entry.name)) continue; listFiles(full, out, errors); } else if (/\.tsx$/.test(entry.name) && !/\.test\.tsx$/.test(entry.name)) out.push(full); } return out; } /** * Resolve arguments to the de-duplicated file list to visit. * * A directory is walked as before; a file is taken as named, because filtering a * path someone typed is how the previous version came to report a clean run over * input it never opened. A path that is neither is a hard error — this walked * directories only, so a file argument threw ENOTDIR and a missing one was * skipped silently, both leaving `{"fixed": 0}` looking like a verdict. */ function collectFiles(targets) { const found = []; const errors = []; for (const target of targets) { const abs = path.isAbsolute(target) ? target : path.join(ROOT, target); let stat; try { stat = fs.statSync(abs); } catch (error) { errors.push(`${target}: ${error.code ?? error.message}`); continue; } if (stat.isDirectory()) listFiles(abs, found, errors); else if (stat.isFile()) found.push(abs); else errors.push(`${target}: not a file or directory`); } const seen = new Set(); const files = []; for (const file of found) { const key = path.resolve(file); if (seen.has(key)) continue; seen.add(key); files.push(file); } return { files, errors }; } const { files: FILES, errors: PATH_ERRORS } = collectFiles(TARGETS); if (PATH_ERRORS.length) { for (const error of PATH_ERRORS) console.error(`fix-trans-indices: cannot read ${error}`); process.exit(2); } const report = { fixed: 0, alreadyOk: 0, declinedCount: 0, declinedNested: 0 }; const declined = []; const touched = new Set(); /** * Repoint one catalog message's tags at `elementIdx`, or record why it can't be * done automatically. */ function remapMessage(ns, catKey, children, elementIdx, where) { const msg = catalogs[ns][catKey]; if (typeof msg !== "string") return; const opens = [...msg.matchAll(/<(\d+)>/g)].map((m) => Number(m[1])); if (!opens.length) return; if (opens.every((i) => children[i]?.type === "JSXElement")) { report.alreadyOk += 1; return; } // Nested tags need a human: the remap below assumes a flat sequence. if (/<(\d+)>(?:(?!<\/\1>).)*<\d+>/s.test(msg)) { report.declinedNested += 1; declined.push(`${where} ${ns}:${catKey} — nested tags`); return; } const distinct = [...new Set(opens)]; if (distinct.length !== elementIdx.length) { report.declinedCount += 1; declined.push( `${where} ${ns}:${catKey} — ${distinct.length} tag(s) but ` + `${elementIdx.length} element child(ren)`, ); return; } const remap = new Map(distinct.map((old, i) => [old, elementIdx[i]])); const next = msg.replace(/<(\/?)(\d+)>/g, (m, slash, n) => { const to = remap.get(Number(n)); return to === undefined ? m : `<${slash}${to}>`; }); if (next !== msg) { catalogs[ns][catKey] = next; touched.add(ns); report.fixed += 1; } } for (const file of FILES) { const src = fs.readFileSync(file, "utf8"); if (!src.includes(" { if (node.type !== "JSXElement" || node.openingElement?.name?.name !== "Trans") return; const keyAttr = node.openingElement.attributes.find( (a) => a.type === "JSXAttribute" && a.name?.name === "i18nKey", ); const key = keyAttr?.value?.type === "StringLiteral" ? keyAttr.value.value : null; if (!key) return; const rel = path.relative(ROOT, file); const line = src.slice(0, node.start).split("\n").length; const children = runtimeChildren(node); const elementIdx = children .map((c, i) => (c.type === "JSXElement" ? i : -1)) .filter((i) => i >= 0); for (const [ns, catKey] of entriesFor(key)) { remapMessage(ns, catKey, children, elementIdx, `${rel}:${line}`); } }); } if (WRITE) { for (const ns of touched) { const sorted = Object.fromEntries( Object.entries(catalogs[ns]).sort(([a], [b]) => a.localeCompare(b)), ); fs.writeFileSync(path.join(EN, `${ns}.json`), JSON.stringify(sorted, null, 2) + "\n"); } } console.log(`scanned ${FILES.length} file(s) from ${TARGETS.length} argument(s)`); if (declined.length) { console.log(`Declined ${declined.length} message(s) — fix these by hand:`); for (const d of declined) console.log(` ${d}`); console.log(""); } console.log(JSON.stringify({ scanned: FILES.length, ...report, wrote: WRITE }, null, 2));