@beqa/babel-plugin-transform-react-slots
Version:
The JSX to slot function transpilation plugin for babel
285 lines (251 loc) • 8.08 kB
text/typescript
import { types as t, type NodePath } from "@babel/core";
import {
JSXNamespacedNameError,
UnsupportedMemberExpressionError,
VarDeclarationError,
} from "./errors";
/** A wildcard for PathToValue to match any property on an object */
export const ANY_PROPERTY = Symbol("AnyProperty");
/**
* Array of strings representation of a member expression,
* Where the leftmost item is the value and everything next to it
* is a parent of the previous value:
* @example a.b.c -> ["b", "a"]
*/
export type PathToValue = (string | symbol)[];
function isIdentifierWithName(
node: t.Node | null | undefined,
name: PathToValue[number],
): node is t.Identifier {
return (
t.isIdentifier(node) && (name === ANY_PROPERTY ? true : node.name === name)
);
}
export function isJSXIdentifierWithName(
node: t.Node | null | undefined,
name: PathToValue[number],
): node is t.JSXIdentifier {
return (
t.isJSXIdentifier(node) &&
(name === ANY_PROPERTY ? true : node.name === name)
);
}
function isStringLiteralWithValue(
node: t.Node | null | undefined,
value: PathToValue[number],
): node is t.StringLiteral {
return (
t.isStringLiteral(node) &&
(value === ANY_PROPERTY ? true : node.value === value)
);
}
export function skipTS(
nodePath: NodePath<t.Node> | null,
): NodePath<t.Node> | null {
if (nodePath === null) {
return null;
}
if (
t.isTSAsExpression(nodePath.node) ||
t.isTSTypeAssertion(nodePath.node) ||
t.isTSSatisfiesExpression(nodePath.node) ||
t.isTSInstantiationExpression(nodePath.node)
) {
return skipTS(nodePath.parentPath);
}
return nodePath;
}
/**
* checks if a node is an allowed expression or statement (doesn't break our logic)
*/
export function isUnbreaking(nodePath: NodePath<t.Node>): boolean {
if (
t.isLogicalExpression(nodePath.node) ||
t.isIfStatement(nodePath.node) ||
t.isExpressionStatement(nodePath.node)
) {
return true;
}
return false;
}
/**
* If referenced identifier is an object in a Member expression,
* check if this expression is accessing the value or an ancestor of the value.
* If so, return the parent node of the expression.
* Empty array means the value is accessed.
* If the expression is accessing a different path, null is returned.
*
* @throws {UnsupportedMemberExpressionError}
*
* @example ```ts
* // z holds our value in x.y.z
* getMemberExpressionParent(getX('let declaration = x.y'), ['z', 'y'])); // return: VariableDeclarator, path: ['z']
* getMemberExpressionParent(getX('let declaration = x.y.z'), ['z', 'y'])); // return: VariableDeclarator, path: []
* ```
*/
export function goThroughMemberExpression(
identifier: NodePath<t.Node>,
pathToValue: PathToValue,
): NodePath<t.Node> | null {
let parent = skipTS(identifier.parentPath);
let child = skipTS(identifier);
if (parent === null) {
return null;
}
while (t.isMemberExpression(parent.node)) {
const name = pathToValue.at(-1);
if (name === undefined) {
// Member expression is trying to access a value deeper than specified in pathToValue
return null;
}
if (
(!parent.node.computed &&
isIdentifierWithName(parent.node.property, name)) ||
(parent.node.computed &&
isStringLiteralWithValue(parent.node.property, name))
) {
pathToValue.pop();
child = parent;
parent = skipTS(parent.parentPath)!;
} else if (
(!parent.node.computed && t.isIdentifier(parent.node.property)) ||
t.isStringLiteral(parent.node.property)
) {
// It's still a type of property that does not break our logic, but the paths have diverged
return null;
} else {
// computed property / Expression
// eg: ReactSlots[useSlot] or ReactSlots["use" + "Slot"]
throw new UnsupportedMemberExpressionError(parent.node.loc);
}
}
return child;
}
/**
* Get which identifiers in the object pattern hold the value (if any).
* If no identifiers, return empty array.
*/
function getValuesFromObjectPattern(
objectPattern: NodePath<t.ObjectPattern>,
pathToValue: PathToValue,
identifiers: Map<NodePath<t.Identifier>, PathToValue>,
): void {
let restHasPath = true;
for (let prop of objectPattern.get("properties")) {
if (t.isRestElement(prop.node)) {
if (restHasPath || pathToValue.at(-1) === ANY_PROPERTY) {
const identifier = prop.get("argument") as NodePath<t.Identifier>;
identifiers.set(identifier, pathToValue.slice());
}
break;
}
if (
pathToValue.length > 0 &&
t.isObjectProperty(prop.node) &&
isIdentifierWithName(prop.node.key, pathToValue.at(-1)!)
) {
restHasPath = false;
const name = pathToValue.pop()!;
if (t.isIdentifier(prop.node.value)) {
identifiers.set(
prop.get("value") as NodePath<t.Identifier>,
pathToValue.slice(),
);
pathToValue.push(name);
continue;
}
if (t.isObjectPattern(prop.node.value)) {
getValuesFromObjectPattern(
prop.get("value") as NodePath<t.ObjectPattern>,
pathToValue,
identifiers,
);
pathToValue.push(name);
}
}
}
}
/**
* **Mutates the pathToValue array**.
*
* Get values from variable declarator.
* Supports direct assignment and object destructuring.
* Returned map holds a new pathToValue for every specific identifier.
* `pathToValue` that was passed as an argument will be mutated to be the same as `pathToValue`
* for the last (rightmost) identifier
*
* @throws {VarDeclarationError}
*/
export function getValuesFromVarDeclarator(
varDeclarator: NodePath<t.VariableDeclarator>,
pathToValue: PathToValue,
): Map<NodePath<t.Identifier>, PathToValue> {
const identifiers = new Map<NodePath<t.Identifier>, PathToValue>();
const varDeclaration = varDeclarator.parent as t.VariableDeclaration;
if (!["const", "let"].includes(varDeclaration.kind)) {
throw new VarDeclarationError(varDeclaration.kind, varDeclaration.loc);
}
if (t.isIdentifier(varDeclarator.node.id)) {
identifiers.set(
varDeclarator.get("id") as NodePath<t.Identifier>,
pathToValue.slice(),
);
} else if (t.isObjectPattern(varDeclarator.node.id)) {
getValuesFromObjectPattern(
varDeclarator.get("id") as NodePath<t.ObjectPattern>,
pathToValue,
identifiers,
);
}
// Array expression is currently unsupported. Silently ignore
return identifiers;
}
/**
* ** Modifies pathToSlottable array **
*
* Validates wether the expression accesses slottable node in a jsx element.
* If so, returns the parent of the expression, otherwise null;
*/
export function getJSXElement(
path: NodePath<t.JSXIdentifier>,
pathToSlottable: PathToValue,
): NodePath<t.JSXElement> | null {
let parent = path.parentPath;
while (t.isJSXMemberExpression(parent.node)) {
const name = pathToSlottable.at(-1);
if (name === undefined) {
// JSX member expression is trying to access some value deeper than specified in pathToSlottable
return null;
}
if (!isJSXIdentifierWithName(parent.node.property, name)) {
return null;
}
pathToSlottable.pop();
parent = parent.parentPath!;
}
if (pathToSlottable.length > 0) {
// JSX element is ancestor of the slottable node
return null;
}
if (t.isJSXClosingElement(parent.node)) {
return null;
}
return parent.parentPath as NodePath<t.JSXElement>;
}
/** @throws {JSXNamespacedNameError} */
export function jsxNameToCallee(
el: t.JSXIdentifier | t.JSXMemberExpression | t.JSXNamespacedName,
): t.MemberExpression | t.Identifier {
if (t.isJSXIdentifier(el)) {
return t.identifier(el.name);
}
if (t.isJSXNamespacedName(el)) {
// Doesn't check JSXNamespacedName because those aren't available in react and slots can't be namespaced
throw new JSXNamespacedNameError(el.loc);
}
return t.memberExpression(
jsxNameToCallee(el.object as any),
t.identifier(el.property.name),
false,
);
}