Files
2026-08-01 23:11:18 +02:00

131 lines
5.0 KiB
JavaScript

/**
* Decide whether a literal reached from the JSX is display copy, and in what
* position — `"attr"` (a bare attribute value, so the replacement needs braces),
* `"expr"` (already inside an expression), `"module-scope"`, or null for data.
*
* Extracted from externalize-strings.mjs to keep that file under the 600-line
* limit. `DISPLAY_PROPS` / `DISPLAY_ATTRS` / `FUNCTION_TYPES` are injected
* because they are that module's policy, not this one's.
*/
/**
* A string literal is only display copy when something renders it. Literals
* reached through these node types are data — comparisons, object/array keys,
* enum arguments — and translating them changes behavior rather than wording.
*/
const NON_DISPLAY_PARENTS = new Set([
"BinaryExpression",
"SwitchCase",
"ImportDeclaration",
"ExportNamedDeclaration",
"TSLiteralType",
"TSEnumMember",
]);
let policy = { DISPLAY_PROPS: new Set(), DISPLAY_ATTRS: new Set(), FUNCTION_TYPES: new Set() };
/** Wire in the caller's display-prop policy before using displayContext(). */
export function configureDisplayContext(next) {
policy = next;
}
/** True when the literal sits inside any function, i.e. not at module scope. */
const insideFunction = (ancestors) => ancestors.some((a) => policy.FUNCTION_TYPES.has(a.type));
/** Node types a literal may pass through on its way up to the JSX and still be copy. */
const PRESENTATIONAL = new Set([
"ConditionalExpression",
"LogicalExpression",
"ParenthesizedExpression",
"TSAsExpression",
"TSNonNullExpression",
]);
/**
* The nearest ancestor that actually decides what this literal is, skipping the
* presentational wrappers (`cond ? … : …`, `err || …`, casts) the literal may sit
* inside. Returns null when the chain passes through something that consumes the
* string as data — a call argument, a comparison, a bare array element.
*/
function displayAnchor(node, ancestors) {
for (let i = ancestors.length - 1; i >= 0; i--) {
const a = ancestors[i];
if (PRESENTATIONAL.has(a.type)) {
// The *test* of a conditional is a comparison operand, not a rendered branch.
if (a.type === "ConditionalExpression" && a.test === (ancestors[i + 1] ?? node)) return null;
continue;
}
if (NON_DISPLAY_PARENTS.has(a.type)) return null;
// Any call consumes the string as an argument rather than rendering it.
if (a.type === "CallExpression" || a.type === "NewExpression") return null;
return a;
}
return null;
}
/**
* The JSX attribute that governs this literal, or null.
*
* Stops at the first JSXElement: once the literal is inside an element nested in
* a prop (`actions={<><Button>Copy</Button></>}`), the element renders its own
* children and the outer prop name says nothing about them.
*/
function governingAttribute(ancestors) {
for (let i = ancestors.length - 1; i >= 0; i--) {
const a = ancestors[i];
if (a.type === "JSXElement" || a.type === "JSXFragment") return null;
if (a.type === "JSXAttribute") return a;
}
return null;
}
/**
* Where this literal lands: "attr" (a bare display prop), "expr" (inside a JSX
* expression), "objprop" (a display key in a config object), "module-scope" (a
* display key that would evaluate at import time), or null when it is data.
*
* These are the shapes `mode: "jsx-text-only"` could not see:
*
* {saving ? "Saving..." : "Save"} -> {saving ? t("ns:saving") : t("ns:save")}
* <Field label="Assignee" /> -> <Field label={t("ns:assignee")} />
* { label: "In progress" } -> { label: t("ns:inProgress") }
*/
const propName = (node) => String(node?.name?.name ?? node?.key?.name ?? node?.key?.value);
/** `label="Assignee"` — a bare literal on a display prop. */
function attributeContext(node, anchor) {
if (!policy.DISPLAY_PROPS.has(propName(anchor))) return null;
// A bare `label="x"` needs braces; a `label={cond ? "x" : "y"}` does not.
return anchor.value === node ? "attr" : "expr";
}
/** `{ label: "In progress" }` — a display key in a config object. */
function objectPropertyContext(anchors, anchor) {
if (!policy.DISPLAY_PROPS.has(propName(anchor))) return null;
// A module-scope object evaluates its `t()` at import time, which pins the
// copy to whatever locale was active then and never updates on a switch.
return insideFunction(anchors) ? "objprop" : "module-scope";
}
/** `{saving ? "Saving..." : "Save"}` — inside a JSX expression container. */
function expressionContext(ancestors) {
const attr = governingAttribute(ancestors);
if (!attr) return "expr";
const name = propName(attr);
return policy.DISPLAY_PROPS.has(name) || policy.DISPLAY_ATTRS.has(name) ? "expr" : null;
}
export function displayContext(node, ancestors) {
const anchor = displayAnchor(node, ancestors);
switch (anchor?.type) {
case "JSXAttribute":
return attributeContext(node, anchor);
case "ObjectProperty":
return objectPropertyContext(ancestors, anchor);
case "JSXExpressionContainer":
return expressionContext(ancestors);
default:
return null;
}
}