#!/usr/bin/env node
/**
* Un-fold React elements that were wrapped into a `` `values` entry.
*
* `wrap-trans.mjs` turned an icon beside a label into an interpolation:
*
* }}>
* Approve
*
* // catalog: "{{value0}} Approve"
*
* i18next interpolation stringifies its values, so a React element becomes the
* literal text `[object Object]` on screen. Elements belong in the children,
* addressed by an `` tag — never in `values`.
*
* A decorative icon does not belong in the message at all, so this rewrites the
* element to a plain sibling and the copy to a single `t()` call:
*
*
* {t("task:approve")}
* // catalog: "Approve"
*
* Only `` elements whose `values` hold JSX are touched; a string-valued
* `{{valueN}}` interpolates correctly and is left alone.
*
* Usage: node scripts/fix-folded-elements.mjs [--write] [...]
*/
import { parseSync } from "@babel/core";
import fs from "node:fs";
import path from "node:path";
const ROOT = path.resolve(import.meta.dirname, "..");
const EN = path.join(ROOT, "src", "locales", "en");
const WRITE = process.argv.includes("--write");
const TARGETS = process.argv.slice(2).filter((a) => !a.startsWith("--"));
if (!TARGETS.length) {
console.error("usage: fix-folded-elements.mjs [--write] [...]");
process.exit(2);
}
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);
}
}
const containsJsx = (node) => {
let found = false;
walk(node, (n) => {
if (n.type === "JSXElement" || n.type === "JSXFragment") found = true;
});
return found;
};
function listFiles(dir, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (["node_modules", "dist"].includes(entry.name)) continue;
listFiles(full, out);
} else if (/\.tsx$/.test(entry.name) && !/\.test\.tsx$/.test(entry.name)) out.push(full);
}
return out;
}
const catalogs = {};
function catalogFor(ns) {
if (!catalogs[ns]) {
const file = path.join(EN, `${ns}.json`);
catalogs[ns] = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
}
return catalogs[ns];
}
const report = { rewritten: 0, files: 0, declined: [] };
const touchedNamespaces = new Set();
/** Strip `{{valueN}}` (and any tag that wrapped only it) from a message. */
function stripFolded(message, names) {
let out = message;
for (const name of names) {
// A tag whose entire content was the placeholder goes with it.
out = out.replace(new RegExp(`<(\\d+)>\\s*\\{\\{${name}\\}\\}\\s*\\1>`, "g"), "");
out = out.replace(new RegExp(`\\{\\{${name}\\}\\}`, "g"), "");
}
return out.replace(/\s{2,}/g, " ").trim();
}
/** `values` entries holding JSX — those are what render as "[object Object]". */
function foldedElementProps(node) {
const attr = node.openingElement.attributes.find(
(a) => a.type === "JSXAttribute" && a.name?.name === "values",
);
const object = attr?.value?.expression;
if (object?.type !== "ObjectExpression") return null;
const folded = object.properties.filter(
(p) =>
p.type === "ObjectProperty" && /^value\d+$/.test(String(p.key?.name)) && containsJsx(p.value),
);
if (!folded.length) return null;
return { folded, others: object.properties.filter((p) => !folded.includes(p)) };
}
/** A `{t(...)}` child IS the copy; the replacement t() call supersedes it. */
function carriesCopy(child) {
let found = false;
walk(child, (n) => {
if (n.type === "CallExpression" && n.callee?.type === "Identifier" && n.callee.name === "t")
found = true;
});
return found;
}
/**
* How to rewrite one ``, or null when it should be left to a human.
* Returns the source edit plus the message the catalog should end up with.
*/
function planRewrite(node, original, rel) {
const keyAttr = node.openingElement.attributes.find(
(a) => a.type === "JSXAttribute" && a.name?.name === "i18nKey",
);
if (keyAttr?.value?.type !== "StringLiteral") return null;
const props = foldedElementProps(node);
if (!props) return null;
const qualified = keyAttr.value.value;
const [ns, key] = qualified.includes(":") ? qualified.split(":") : ["common", qualified];
const line = original.slice(0, node.start).split("\n").length;
const decline = (why) => {
report.declined.push(`${rel}:${line} ${qualified} (${why})`);
return null;
};
// Any other interpolation must survive, so a mixed case needs a human.
if (props.others.length) return decline("other values present");
const kept = node.children.filter(
(c) => (c.type === "JSXElement" || c.type === "JSXExpressionContainer") && !carriesCopy(c),
);
if (!kept.length) return decline("no element child to keep");
const message = catalogFor(ns)[key];
if (typeof message !== "string") return decline("message not in catalog");
const stripped = stripFolded(
message,
props.folded.map((p) => String(p.key.name)),
);
if (!stripped) return decline("nothing left after stripping");
return {
ns,
key,
message: stripped,
edit: {
start: node.start,
end: node.end,
text: `${kept.map((c) => original.slice(c.start, c.end)).join("\n ")}\n {t("${qualified}")}`,
},
};
}
function transform(file) {
const original = fs.readFileSync(file, "utf8");
if (!original.includes(" {
if (node.type !== "JSXElement" || node.openingElement?.name?.name !== "Trans") return;
const plan = planRewrite(node, original, path.relative(ROOT, file));
if (!plan) return;
edits.push(plan.edit);
catalogs[plan.ns][plan.key] = plan.message;
touchedNamespaces.add(plan.ns);
report.rewritten += 1;
});
if (!edits.length) return;
edits.sort((a, b) => b.start - a.start);
let out = original;
let lastStart = Infinity;
for (const e of edits) {
if (e.end > lastStart) continue;
out = out.slice(0, e.start) + e.text + out.slice(e.end);
lastStart = e.start;
}
report.files += 1;
if (WRITE) fs.writeFileSync(file, out);
}
for (const target of TARGETS) {
const abs = path.isAbsolute(target) ? target : path.join(ROOT, target);
if (!fs.existsSync(abs)) continue;
for (const file of fs.statSync(abs).isDirectory() ? listFiles(abs) : [abs]) transform(file);
}
if (WRITE) {
for (const ns of touchedNamespaces) {
const sorted = Object.fromEntries(
Object.entries(catalogs[ns]).sort(([a], [b]) => a.localeCompare(b)),
);
// Match the existing catalogs' escaping so the diff stays scoped to real
// message changes rather than re-encoding every non-ASCII character.
const json = JSON.stringify(sorted, null, 2).replace(
/[\u0080-\uffff]/g,
(ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`,
);
fs.writeFileSync(path.join(EN, `${ns}.json`), json + "\n");
}
}
if (report.declined.length) {
console.log(`Declined ${report.declined.length} — fix by hand:`);
for (const d of report.declined) console.log(` ${d}`);
}
console.log(
JSON.stringify({ rewritten: report.rewritten, files: report.files, wrote: WRITE }, null, 2),
);