6cd89a78d3
Co-authored-by: Kandev Agent <agent@kandev.dev>
109 lines
4.3 KiB
JavaScript
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} })`;
|
|
}
|