Files
2026-10-14 04:20:24 +02:00

335 lines
17 KiB
JavaScript

// Copyright (C) 2026 half144 and PS5 React contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution term: see LICENSE-ATTRIBUTION.
// Babel plugin: `className` on any JSX element and `tw` templates from '@ps5-react/core' become
// literal style objects. Conditional classes become conditional style entries; state variants read
// the element's prop of the same name, except focused/pressed on a focusable element without that
// prop, which read its own focus state through a style function. Animation classes turn a core
// View/Text/Image into its motion component with literal motion props. Nothing class-related
// survives into the bundle.
import {ClassError} from './compile.mjs';
import {byPriority, motionProps, splitTarget} from './motion.mjs';
import {UtilityError} from './utilities.mjs';
const CORE = '@ps5-react/core';
const MOTION = '__ps5Motion';
const MOTION_PROPS = ['initial', 'animate', 'exit', 'transition'];
const WHILE = {focused: 'whileFocus', pressed: 'whilePress'};
export class TailwindError extends Error {
constructor(message, node, token) {
super(message);
this.reason = message;
this.loc = locate(node, token);
}
}
/** Line/column of `token` inside a single-line string, else of the node itself. */
function locate(node, token) {
if (!node?.loc) return null;
const {line, column} = node.loc.start;
const text = node.type === 'TemplateElement' ? node.value.raw : node.value;
const offset = typeof text === 'string' && token ? text.indexOf(token) : -1;
const sameLine = offset >= 0 && !text.slice(0, offset).includes('\n');
return {line, column: sameLine ? column + (node.type === 'StringLiteral' ? 1 : 0) + offset : column};
}
const PURE = {
Identifier: () => true,
StringLiteral: () => true, NumericLiteral: () => true, BooleanLiteral: () => true, NullLiteral: () => true,
MemberExpression: (n, pure) => pure(n.object) && (!n.computed || pure(n.property)),
UnaryExpression: (n, pure) => n.operator !== 'delete' && pure(n.argument),
BinaryExpression: (n, pure) => pure(n.left) && pure(n.right),
LogicalExpression: (n, pure) => pure(n.left) && pure(n.right),
ConditionalExpression: (n, pure) => pure(n.test) && pure(n.consequent) && pure(n.alternate),
TemplateLiteral: (n, pure) => n.expressions.every(pure),
};
const isPure = node => PURE[node.type]?.(node, isPure) ?? false;
const isPlainStyle = node => node.type === 'ObjectExpression' || node.type === 'ArrayExpression' ||
node.type.endsWith('Literal');
export default function tailwindPlugin({types: t}, {compiler}) {
const ownStates = new WeakMap();
/**
* For a focusable element: the focus props it leaves to its own state, and the style function's
* parameter that holds that state. Null for other elements.
*/
function ownState(element) {
if (ownStates.has(element.node)) return ownStates.get(element.node);
const attribute = name => element.node.attributes.find(a => t.isJSXAttribute(a) && a.name.name === name);
const focusable = attribute('focusable');
const name = element.node.name;
const own = (focusable && !t.isBooleanLiteral(focusable.value?.expression, {value: false})) ||
attribute('onPress') || (t.isJSXIdentifier(name) && coreImport(element.scope, name.name) === 'Pressable')
? {props: new Set(Object.keys(WHILE).filter(prop => !attribute(prop))),
param: element.scope.generateUidIdentifier('state')}
: null;
ownStates.set(element.node, own);
return own;
}
const reads = (node, id) => {
let found = false;
t.traverseFast(node, n => { found ||= t.isIdentifier(n, {name: id.name}); });
return found;
};
/** `state => [...compiled, style]`, calling the explicit style when it is (or may be) a function. */
function stateStyle(style, current, param) {
const state = () => t.cloneNode(param);
let user = current;
if (current && t.isFunction(current)) user = t.callExpression(current, [state()]);
else if (current && !isPlainStyle(current)) {
if (!isPure(current)) throw new TailwindError('the style prop is read inside the style function ' +
'that focus classes compile to; move the expression into a variable first', current);
user = t.conditionalExpression(t.binaryExpression('===', t.unaryExpression('typeof', current),
t.stringLiteral('function')), t.callExpression(t.cloneNode(current, true), [state()]),
t.cloneNode(current, true));
}
const entries = [...t.isArrayExpression(style) ? style.elements : t.isNullLiteral(style) ? [] : [style],
...user ? [user] : []];
return t.arrowFunctionExpression([state()],
entries.length === 1 ? entries[0] : t.arrayExpression(entries));
}
/** Class expression → segments: strings (with their node) and `{test, consequent, alternate}`. */
function collect(path, seen = new Set()) {
const node = path.node;
if (path.isStringLiteral()) return [{classes: node.value, node}];
if (path.isNullLiteral() || path.isIdentifier({name: 'undefined'}) ||
path.isBooleanLiteral({value: false})) return [];
if (path.isTemplateLiteral()) {
const quasis = path.get('quasis');
return quasis.flatMap((quasi, i) => {
const text = quasi.node.value.cooked;
const expression = path.get('expressions')[i];
if (expression && ((text && !/\s$/.test(text)) ||
/^\S/.test(quasis[i + 1].node.value.cooked))) {
throw new TailwindError('class names must be whole words; `bg-${...}` cannot be compiled. ' +
'Write complete classes, e.g. ${active ? "bg-sky-800" : "bg-slate-800"}', quasi.node);
}
return [{classes: text, node: quasi.node}, ...(expression ? collect(expression, seen) : [])];
});
}
if (path.isConditionalExpression()) return [{test: node.test,
consequent: collect(path.get('consequent'), seen), alternate: collect(path.get('alternate'), seen)}];
if (path.isLogicalExpression({operator: '&&'}))
return [{test: node.left, consequent: collect(path.get('right'), seen), alternate: []}];
if (path.isIdentifier()) {
const binding = path.scope.getBinding(node.name);
const init = binding?.path.isVariableDeclarator() && binding.kind === 'const' && binding.path.get('init');
if (init?.node && !seen.has(binding)) return collect(init, new Set(seen).add(binding));
}
throw new TailwindError('className must be known at build time: a string, a template literal, ' +
'`cond ? "a" : "b"`, `cond && "a"`, or a const holding one of these', node);
}
function parse(segments) {
const statics = segments.filter(s => 'classes' in s);
for (const {classes, node} of statics) {
try {
compiler.parse(classes);
} catch (error) {
if (error instanceof ClassError) throw new TailwindError(error.message, node, error.token);
throw error;
}
}
return compiler.parse(statics.map(s => s.classes).join(' '));
}
function finalize(fragment, context, node) {
try {
return compiler.finalize(fragment, context);
} catch (error) {
if (error instanceof ClassError) throw new TailwindError(error.message, node);
throw error;
}
}
/**
* Builds the style expression for segments; `context` is the enclosing static fragment. When
* `animated`, opacity and transforms leave the style for a motion target: the static part, the
* layers that conditions and variants spread over it, and the keys those layers change.
*/
function build(segments, element, imports, node, context = {}, animated = false) {
const {base, variants, imports: fonts, motion} = parse(segments);
fonts.forEach(path => imports.add(path));
const scope = {...context, ...base, $transform: {...context.$transform, ...base.$transform},
$gradient: {...context.$gradient, ...base.$gradient}};
const entries = [];
const [target, rest] = animated ? splitTarget(base) : [{}, base];
const layers = [];
const dynamic = new Set();
const style = finalize(rest, scope, node);
if (Object.keys(style).length) entries.push(t.valueToNode(style));
for (const segment of segments.filter(s => 'test' in s)) {
const [yes, no] = [segment.consequent, segment.alternate].map(s => {
const result = s.length ? build(s, element, imports, node, scope, animated) : null;
if (result?.motion) throw new TailwindError('animation classes must be static; ' +
'move them out of the conditional part of className', node);
[...result?.dynamic ?? [], ...Object.keys(result?.target ?? {})].forEach(key => dynamic.add(key));
return result;
});
const [consequent, alternate] = [yes, no].map(r => r?.style ?? t.nullLiteral());
if (!t.isNullLiteral(consequent) || !t.isNullLiteral(alternate))
entries.push(t.conditionalExpression(segment.test, consequent, alternate));
const [a, b] = [yes, no].map(r => r && targetExpression(r.target, r.layers));
if (a || b) layers.push({expression: t.conditionalExpression(t.cloneNode(segment.test, true),
a ?? t.nullLiteral(), b ?? t.nullLiteral())});
}
const variantLayers = [];
for (const {props, fragment} of variants) {
if (!element) throw new TailwindError(`variants such as ${props[0]}: need a JSX element prop; ` +
'tw templates take base classes only', node);
const tests = props.map(prop => variantTest(element, prop, node));
const test = tests.reduce((a, b) => t.logicalExpression('&&', a, b));
const [values, styles] = animated ? splitTarget(fragment) : [{}, fragment];
const variantStyle = finalize(styles, scope, node);
if (Object.keys(variantStyle).length)
entries.push(t.conditionalExpression(test, t.valueToNode(variantStyle), t.nullLiteral()));
if (Object.keys(values).length) variantLayers.push({props, test, values});
}
for (const layer of variantLayers.sort(byPriority)) {
Object.keys(layer.values).forEach(key => dynamic.add(key));
layers.push(layer);
}
const expression = !entries.length ? t.nullLiteral()
: entries.length === 1 ? entries[0] : t.arrayExpression(entries);
return {style: expression, base, target, layers, dynamic, motion};
}
/** The static target with its layers spread over it; null when there is nothing to animate. */
function targetExpression(target, layers) {
const literal = t.valueToNode(target);
if (!layers.length) return Object.keys(target).length ? literal : null;
const [first] = layers;
if (layers.length === 1 && first.values) return t.conditionalExpression(t.cloneNode(first.test, true),
t.valueToNode({...target, ...first.values}), literal);
return t.objectExpression([...literal.properties, ...layers.map(layer => t.spreadElement(
layer.expression ??
t.conditionalExpression(t.cloneNode(layer.test, true), t.valueToNode(layer.values), t.nullLiteral())))]);
}
function variantTest(element, prop, node) {
const attribute = element.node.attributes.find(a => t.isJSXAttribute(a) && a.name.name === prop);
const own = ownState(element);
if (!attribute && own?.props.has(prop)) return t.memberExpression(t.cloneNode(own.param), t.identifier(prop));
if (!attribute) throw new TailwindError(`${prop}: variant needs a ${prop}={...} prop on this element` +
(prop in WHILE ? ' or a focusable element (focusable, onPress, or Pressable)' : ''), node);
if (attribute.value === null) return t.booleanLiteral(true);
const value = t.isJSXExpressionContainer(attribute.value) ? attribute.value.expression : attribute.value;
if (!isPure(value)) throw new TailwindError(`the ${prop} prop is read twice by ${prop}: classes; ` +
'move the expression into a variable first', value);
return t.cloneNode(value, true);
}
/** The name a binding imports from '@ps5-react/core', or null. */
function coreImport(scope, name) {
const binding = scope.getBinding(name);
if (binding?.kind !== 'module' || binding.path.parent.source.value !== CORE) return null;
return binding.path.node.imported?.name ?? null;
}
/** Rebuilds `segments` with motion targets; null when the animation classes animate nothing. */
function animate(segments, element, imports, node, scale) {
const result = build(segments, element, imports, node, {}, true);
const {height} = result.base;
let props;
try {
props = motionProps(result.motion, {target: result.target, dynamic: result.dynamic,
height: typeof height === 'number' ? height / scale : undefined});
} catch (error) {
if (!(error instanceof UtilityError)) throw error;
const segment = segments.find(s => s.classes?.split(/\s+/).includes(error.token));
throw new TailwindError(`${error.token}: ${error.message}`, segment?.node ?? node, error.token);
}
if (!props) return null;
const name = element.node.name;
const primitive = t.isJSXIdentifier(name) && coreImport(element.scope, name.name);
if (!['View', 'Text', 'Image'].includes(primitive)) throw new TailwindError('animation classes ' +
`need View, Text or Image from ${CORE}; for your component use motion.create`, node);
// A layer of focus state alone becomes whileFocus/whilePress, which the motion element resolves.
const own = ownState(element);
const whiles = {};
const layers = result.layers.filter(layer => {
const prop = layer.props?.length === 1 && own?.props.has(layer.props[0]) && layer.props[0];
if (prop) whiles[WHILE[prop]] = layer.values;
return !prop;
});
const animateTarget = targetExpression(props.animate, layers);
if (own && animateTarget && reads(animateTarget, own.param)) throw new TailwindError('focused: and ' +
'pressed: animation classes stacked with other variants or inside conditional classes need ' +
'an explicit focused={...} or pressed={...} prop', node);
const reserved = [...MOTION_PROPS, ...Object.keys(whiles)];
for (const attribute of element.node.attributes) {
if (t.isJSXAttribute(attribute) && reserved.includes(attribute.name.name))
throw new TailwindError(`the ${attribute.name.name} prop conflicts with animation classes; ` +
'use motion props or classes, not both', attribute);
}
const motionName = t.jsxMemberExpression(t.jsxIdentifier(MOTION), t.jsxIdentifier(primitive));
element.node.name = motionName;
const closing = element.parent.closingElement;
if (closing) closing.name = t.cloneNode(motionName);
const values = {...props, animate: animateTarget ?? undefined, ...whiles};
for (const key of reserved.filter(key => values[key] !== undefined)) {
const value = t.isNode(values[key]) ? values[key] : t.valueToNode(values[key]);
element.node.attributes.push(t.jsxAttribute(t.jsxIdentifier(key), t.jsxExpressionContainer(value)));
}
return result.style;
}
return {
name: 'ps5-react-tailwind',
visitor: {
Program: {
enter(_, state) {
state.fontImports = new Set();
state.motion = false;
},
exit(path, state) {
for (const font of state.fontImports)
path.unshiftContainer('body', t.importDeclaration([], t.stringLiteral(font)));
if (state.motion) path.unshiftContainer('body', t.importDeclaration([t.importSpecifier(
t.identifier(MOTION), t.identifier('motion'))], t.stringLiteral(CORE)));
},
},
JSXAttribute(path, state) {
if (path.node.name.name !== 'className') return;
const element = path.parentPath;
const value = path.get('value');
const expression = value.isJSXExpressionContainer() ? value.get('expression') : value;
const segments = collect(expression);
const plain = build(segments, element, state.fontImports, path.node);
const animated = plain.motion &&
animate(segments, element, state.fontImports, path.node, compiler.scale);
state.motion ||= Boolean(animated);
const existing = element.get('attributes').find(a => a.isJSXAttribute() && a.node.name.name === 'style');
const current = existing && (existing.node.value.expression ?? existing.node.value);
const own = ownState(element);
let style = animated ?? plain.style;
if (own && (reads(style, own.param) || (current && !isPlainStyle(current))))
style = stateStyle(style, current, own.param);
else if (current) style = t.arrayExpression([style, current]);
if (existing) {
existing.node.value = t.jsxExpressionContainer(style);
path.remove();
} else if (t.isNullLiteral(style)) {
path.remove();
} else {
path.replaceWith(t.jsxAttribute(t.jsxIdentifier('style'), t.jsxExpressionContainer(style)));
}
},
TaggedTemplateExpression(path, state) {
const tag = path.get('tag');
if (!tag.isIdentifier()) return;
if (coreImport(path.scope, tag.node.name) !== 'tw') return;
const {style, motion} = build(collect(path.get('quasi')), null, state.fontImports, path.node);
if (motion) throw new TailwindError('animation classes need a JSX element; ' +
'tw templates take style classes only', path.node);
path.replaceWith(t.isNullLiteral(style) ? t.objectExpression([]) : style);
},
},
};
}