Files
kandev/apps/web/scripts/lib/template-literals.mjs
2026-08-01 23:11:18 +02:00

109 lines
4.3 KiB
JavaScript

/**
* Turn a template literal carrying copy into an i18next message.
*
* `i18next/no-literal-string` only inspects plain literals, so a template
* literal is invisible to the guard — these were the last shape holding
* untranslated copy, mostly `aria-label`s, which screen readers do read aloud.
*
* Extracted from externalize-strings.mjs to keep that file under the 600-line
* limit. `keyFor`/`qualify` are injected because they mutate the caller's
* catalog state.
*/
const COERCIONS = new Set(["String", "Number", "Boolean"]);
/**
* A name for the `{{placeholder}}` standing in for an interpolated expression.
* Derived from the expression so the catalog reads `Remove {{label}}` rather
* than `Remove {{value0}}` — a translator has to be able to tell what it is.
*/
export function placeholderName(expr, index) {
const from = (node) => {
switch (node.type) {
case "Identifier":
return node.name;
case "MemberExpression":
return node.property.type === "Identifier" ? node.property.name : null;
case "TSNonNullExpression":
case "TSAsExpression":
return from(node.expression);
case "LogicalExpression":
case "BinaryExpression":
// `index + 1` reads best as `{{index}}`, not `{{value0}}`.
return from(node.left);
case "CallExpression": {
// `String(err)` / `Number(n)` are coercions — the argument is the
// interesting name, not the wrapper.
const callee = node.callee.type === "Identifier" ? node.callee.name : null;
if (COERCIONS.has(callee) && node.arguments.length === 1) return from(node.arguments[0]);
return from(node.callee);
}
default:
return null;
}
};
let raw = from(expr);
if (!raw) return `value${index}`;
// `getFileName(p)` / `formatBytes(n)` name the operation, not the value.
const stripped = raw.replace(/^(get|format|render|to|compute)(?=[A-Z])/, "");
if (stripped !== raw) raw = stripped[0].toLowerCase() + stripped.slice(1);
// `a.b.name` and a bare `name` collide; the caller dedupes by suffixing.
return raw.replace(/[^A-Za-z0-9]/g, "") || `value${index}`;
}
/** English words inside an interpolation are copy, not data — those need keys. */
function interpolationIsCopy(expr, { walk, looksLikeCopy }) {
let copy = false;
walk(expr, (n) => {
if (n.type === "StringLiteral" && looksLikeCopy(n.value)) copy = true;
});
return copy;
}
/**
* `` `Remove ${label}` `` -> the catalog message `Remove {{label}}` plus the
* `{ label }` values object, or null when it should be left to a human.
*
* A template literal is invisible to `i18next/no-literal-string` (the rule only
* inspects plain literals), so these were the last shape carrying untranslated
* copy — mostly `aria-label`s, which screen readers do read aloud.
*/
export function templateToMessage(node, deps) {
if (node.expressions.some((e) => e.type === "TemplateLiteral")) return null; // nested
if (node.expressions.some((e) => interpolationIsCopy(e, deps))) return null; // branches are copy
const prose = node.quasis.map((q) => q.value.cooked ?? "").join(" ");
// Needs real words, not a path/URL/identifier fragment.
if (!/[a-z]{3}/.test(prose) || !/[A-Za-z]{3}/.test(prose.replace(/[/:._-]/g, ""))) return null;
if (/^[\s/:._-]*https?:/.test(prose)) return null;
const used = new Map();
const names = node.expressions.map((expr, i) => {
const base = placeholderName(expr, i);
const seen = used.get(base) ?? 0;
used.set(base, seen + 1);
return seen ? `${base}${seen + 1}` : base;
});
let message = "";
node.quasis.forEach((q, i) => {
message += q.value.cooked ?? "";
if (i < names.length) message += `{{${names[i]}}}`;
});
message = message.trim();
if (!message) return null;
return { message, names };
}
/** Render the `t()` call for a converted template literal. */
export function templateCall(node, ns, { message, names }, source, { keyFor, qualify }) {
const key = qualify(ns, keyFor(ns, message));
if (!names.length) return `t("${key}")`;
const values = names
.map((name, i) => {
const expr = source.slice(node.expressions[i].start, node.expressions[i].end);
return expr === name ? name : `${name}: ${expr}`;
})
.join(", ");
return `t("${key}", { ${values} })`;
}