#!/usr/bin/env node /** * Replace English pluralization smuggled through an interpolation with real * i18next plural keys. * * `externalize-strings.mjs` turns * * `${n} task${n === 1 ? "" : "s"}` * * into `t("ns:task", { length: n, value1: n === 1 ? "" : "s" })` with the * catalog message `{{length}} task{{value1}}`. That renders correctly in English * and is untranslatable everywhere else: the plural rule is baked into the call * site, and languages with three or six plural forms have nowhere to put them. * * i18next already does this — a key with `_one`/`_other` suffixes selected by a * `count` value. So this rewrites the pair into * * t("ns:task", { count: n }) * // ns:task_one = "{{count}} task" * // ns:task_other = "{{count}} tasks" * * Only calls whose morphology argument is a conditional between two short * lowercase string literals are touched; anything else is reported and left * alone. `check-inline-plurals.mjs` fails the build if one reappears. * * LIMITATION: the flat key is deleted in favour of the `_one`/`_other` pair, so a * second call site sharing that key loses it — and NOTHING catches that for you. * `check-i18n-keys.mjs` treats a bare `ns:key` as satisfied by `_one`/`_other` * (see `isSatisfied`), while i18next does NOT fall back to the base key when the * call passes no `count` — so the other call site renders the raw key at runtime. * After running this, grep the converted keys for call sites without a `count` * argument and give them their own key. * * Usage: node scripts/fix-inline-plurals.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-inline-plurals.mjs [--write] [...]"); process.exit(2); } /** English plural morphemes — the only strings this codemod will absorb. */ const MORPHEME = /^(s|es|'s|)$/; function walk(node, visit, parent = null) { if (!node || typeof node.type !== "string") return; visit(node, parent); 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, node); } else if (child && typeof child.type === "string") walk(child, visit, node); } } function listFiles(target, out = []) { const abs = path.isAbsolute(target) ? target : path.join(ROOT, target); if (!fs.existsSync(abs)) return out; if (!fs.statSync(abs).isDirectory()) { if (/\.tsx?$/.test(abs)) out.push(abs); return out; } for (const e of fs.readdirSync(abs, { withFileTypes: true })) { const full = path.join(abs, e.name); if (e.isDirectory()) { if (["node_modules", "dist", "e2e", "locales"].includes(e.name)) continue; listFiles(full, out); } else if (/\.tsx?$/.test(e.name) && !/\.test\.tsx?$/.test(e.name)) out.push(full); } return out; } const catalogs = {}; function catalog(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]; } /** * Which branch of `n === 1 ? a : b` is the singular one. * `=== 1` / `== 1` puts singular first; `!== 1`, `> 1`, `>= 2` invert it. */ function singularBranch(test) { if (test.type !== "BinaryExpression") return null; const { operator, left, right } = test; const literal = [left, right].find((n) => n.type === "NumericLiteral"); const counted = [left, right].find((n) => n.type !== "NumericLiteral"); if (!literal || !counted) return null; // `===`/`!==` are commutative, so operand order does not matter. `>`/`>=` are // not: `1 > n` means the opposite of `n > 1`, and treating them alike would // silently swap the singular and plural messages. const literalOnRight = right === literal; if (literal.value === 1 && /^===?$/.test(operator)) return { which: "consequent", counted }; if (literal.value === 1 && /^!==?$/.test(operator)) return { which: "alternate", counted }; if (!literalOnRight) return null; if (literal.value === 1 && operator === ">") return { which: "alternate", counted }; if (literal.value === 2 && operator === ">=") return { which: "alternate", counted }; return null; } /** A `{ valueN: n === 1 ? "" : "s" }` property — an English plural ending. */ function isMorphemeProp(prop) { if (prop.type !== "ObjectProperty") return false; const v = prop.value; if (v.type !== "ConditionalExpression") return false; if (v.consequent.type !== "StringLiteral" || v.alternate.type !== "StringLiteral") return false; return MORPHEME.test(v.consequent.value) && MORPHEME.test(v.alternate.value); } /** The `{ … }` argument of a t() call, or null. */ function optionsArg(call) { const arg = call.arguments[1]; return arg?.type === "ObjectExpression" ? arg : null; } const report = { rewritten: 0, files: 0, declined: [] }; const touched = new Set(); /** The existing value that already renders the count, so it can become {{count}}. */ function numberPropFor(options, morph, countExpr, source) { return options.properties.find( (p) => p.type === "ObjectProperty" && p !== morph && source.slice(p.value.start, p.value.end) === countExpr, ); } /** * The message with the morpheme slot marked and the number renamed to * `{{count}}` — i18next selects a plural form from `count` and nothing else. * Returns null when no placeholder renders the number. */ function withCountPlaceholder(message, morphName, numberProp) { let base = message.replaceAll(`{{${morphName}}}`, "@@MORPH@@"); if (numberProp) { const numberName = String(numberProp.key.name ?? numberProp.key.value); base = base.replaceAll(`{{${numberName}}}`, "{{count}}"); } return base.includes("{{count}}") ? base : null; } /** The rewritten values list: `count` first, then whatever else was passed. */ function remainingValues(options, morph, numberProp, countExpr, source) { const keep = options.properties.filter((p) => p !== morph && p !== numberProp); const parts = keep.map((p) => source.slice(p.start, p.end)); parts.unshift(countExpr === "count" ? "count" : `count: ${countExpr}`); return parts; } /** * Plan the rewrite of one t() call, or null when it carries no inline plural. * Returns the source edit plus the `_one`/`_other` messages for the catalog. */ function planCall(call, source, rel) { if (call.callee.type !== "Identifier" || call.callee.name !== "t") return null; const keyNode = call.arguments[0]; if (keyNode?.type !== "StringLiteral") return null; const options = optionsArg(call); if (!options) return null; const morph = options.properties.find(isMorphemeProp); if (!morph) return null; const qualified = keyNode.value; const [ns, key] = qualified.includes(":") ? qualified.split(":") : ["common", qualified]; const line = source.slice(0, call.start).split("\n").length; const decline = (why) => { report.declined.push(`${rel}:${line} ${qualified} (${why})`); return null; }; const message = catalog(ns)[key]; if (typeof message !== "string") return decline("message not in catalog"); const morphName = String(morph.key.name ?? morph.key.value); if (!message.includes(`{{${morphName}}}`)) return decline(`message has no {{${morphName}}}`); const picked = singularBranch(morph.value.test); if (!picked) return decline("cannot tell which branch is singular"); const singular = morph.value[picked.which].value; const plural = morph.value[picked.which === "consequent" ? "alternate" : "consequent"].value; // The number driving the plural. i18next selects the form from `count`. const countExpr = source.slice(picked.counted.start, picked.counted.end); const numberProp = numberPropFor(options, morph, countExpr, source); const base = withCountPlaceholder(message, morphName, numberProp); if (!base) return decline("no placeholder renders the count"); const parts = remainingValues(options, morph, numberProp, countExpr, source); return { ns, key, one: base.replace("@@MORPH@@", singular), other: base.replace("@@MORPH@@", plural), edit: { start: options.start, end: options.end, text: `{ ${parts.join(", ")} }`, }, }; } function transform(file) { const source = fs.readFileSync(file, "utf8"); if (!source.includes('? "') && !source.includes("? '")) return; let ast; try { ast = parseSync(source, { filename: file, babelrc: false, configFile: false, sourceType: "module", parserOpts: { plugins: ["typescript", "jsx"] }, }); } catch { return; } const rel = path.relative(ROOT, file); const edits = []; walk(ast, (node) => { if (node.type !== "CallExpression") return; const plan = planCall(node, source, rel); if (!plan) return; const cat = catalog(plan.ns); delete cat[plan.key]; cat[`${plan.key}_one`] = plan.one; cat[`${plan.key}_other`] = plan.other; touched.add(plan.ns); edits.push(plan.edit); report.rewritten += 1; }); if (!edits.length) return; edits.sort((a, b) => b.start - a.start); let out = source; for (const e of edits) out = out.slice(0, e.start) + e.text + out.slice(e.end); report.files += 1; if (WRITE) fs.writeFileSync(file, out); } for (const target of TARGETS) for (const file of listFiles(target)) transform(file); if (WRITE) { for (const ns of touched) { 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({ ...report, declined: report.declined.length, wrote: WRITE }, null, 2));