UNPKG

@beqa/babel-plugin-transform-react-slots

Version:

The JSX to slot function transpilation plugin for babel

402 lines (361 loc) 11.9 kB
import { types as t, type NodePath } from "@babel/core"; import { declare } from "@babel/helper-plugin-utils"; import { type PathToValue, goThroughMemberExpression, getValuesFromVarDeclarator, getJSXElement, ANY_PROPERTY, jsxNameToCallee, skipTS, isUnbreaking, } from "./utils"; import syntaxJSX from "@babel/plugin-syntax-jsx"; import rawCodeStore from "./rawCodeStore"; import { UnsupportedMemberExpressionError, UnsupportedSyntaxError, VarDeclarationError, } from "./errors"; // TODO: move constants to a shared package const LIB_SOURCE = "@beqa/react-slots"; const IMPORTED_NODE = "useSlot"; const SLOT_OBJECT_NAME = "slot"; const DISABLED_REGEX = /^\s*@disable-transform-react-slots\W/; const DEFAULT_CONTENT_WRAPPER = "default-content-wrapper"; function isDisabled( comments: (t.CommentLine | t.CommentBlock)[] | null | undefined, program: t.Program, ) { if (!comments) { return false; } const programStart = program.directives.length ? program.directives[0]?.start || 0 : program.body.length ? program.body[0]?.start || 0 : 0; let currentIndex = 0; while ( comments[currentIndex] && comments[currentIndex]!.start !== undefined && comments[currentIndex]!.start! <= programStart ) { if ( comments[currentIndex]?.type === "CommentLine" && DISABLED_REGEX.test(comments[currentIndex]!.value) ) { return true; } ++currentIndex; } if (comments[currentIndex] && comments[currentIndex]?.start === undefined) { throw new Error( "Could not read comments for @beqa/react-slots while looking for a `@disable-transform-react-slots` pragma. Please open a new issue on our Github repo.", ); } return false; } /** Find all useSlot imports (It's possible for the lib to be imported multiple times) */ function findImports( path: NodePath<t.Program>, ): [t.ImportSpecifier[], t.ImportNamespaceSpecifier[]] { const importNodes = path.node.body.filter((node) => { return ( t.isImportDeclaration(node) && node.source.value === LIB_SOURCE && node.importKind !== "type" && node.importKind !== "typeof" ); }) as t.ImportDeclaration[]; let imports = []; let namespaceImports = []; for (let node of importNodes) { for (let specifier of node.specifiers) { if (t.isImportNamespaceSpecifier(specifier)) { namespaceImports.push(specifier); } else if ( t.isImportSpecifier(specifier) && (specifier.imported as any).name === IMPORTED_NODE && specifier.importKind !== "typeof" && specifier.importKind !== "type" ) { imports.push(specifier); } } } return [imports, namespaceImports]; } /** * Find all useSlot or alias call expressions. * Will mutate callExpressions array to include new references **/ function findCallExpressions( referencePaths: NodePath<t.Node>[], pathToUseSlot: PathToValue = [], callExpressions: NodePath<t.CallExpression>[], ) { for (const ref of referencePaths) { const path = pathToUseSlot.slice(); let nodePath; try { nodePath = goThroughMemberExpression(ref, path); } catch (err) { if (err instanceof UnsupportedMemberExpressionError) { err.throw( `Unsupported syntax: Member expression accessing a \`useSlot\` value can only use dot notation or bracket notation (iff the value is a string literal). Allowed syntax is: \`ReactSlots.useSlot\` or \`ReactSlots["useSlot"]\`.`, ); } throw err; } if (nodePath === null) { continue; } let parentPath = skipTS(nodePath.parentPath)!; if (isUnbreaking(parentPath)) { continue; } // TODO: also allow object variable declarations `let x = {a: {b: useSlot}}` if ( t.isCallExpression(parentPath.node) && skipTS(parentPath.get("callee") as NodePath<t.Node>)?.node === nodePath.node ) { if (path.length !== 0) { // Call expression on an ancestor of useSlot new UnsupportedSyntaxError(parentPath.node.loc).throw( "Unsupported syntax: Object that holds the nested `useSlot` value was used as a function. Did you mean to do " + "`" + rawCodeStore .get() .slice( parentPath.node.callee.start || 0, parentPath.node.callee.end || 0, ) + "." + path.reverse().join(".") + "()" + "`?", ); } callExpressions.push(parentPath as NodePath<t.CallExpression>); } else if (t.isVariableDeclarator(parentPath.node)) { try { const newIdentifiers = getValuesFromVarDeclarator( parentPath as NodePath<t.VariableDeclarator>, path, ); if (newIdentifiers.size) { for (const [newIdentifier, newPath] of newIdentifiers) { findCallExpressions( newIdentifier.scope.bindings[newIdentifier.node.name]! .referencePaths, newPath, callExpressions, ); } } } catch (err) { if (err instanceof VarDeclarationError) { err.throw( "Unsupported syntax: You must only use `let` or `const` variable declarations with `useSlot`, instead encountered " + err.kind + ".", ); } throw err; } } else { // error new UnsupportedSyntaxError(parentPath.node.loc).throw( "Unsupported syntax: `useSlot` or an object holding a nested `useSlot` value used inside " + parentPath.node.type + ".", ); } } } function findJSXElements( referencePaths: NodePath<t.Node>[], pathToSlottable: PathToValue, // [ANY_Property, "slot"] jsxElements: NodePath<t.JSXElement>[], ) { for (let ref of referencePaths) { const path = pathToSlottable.slice(); if (t.isJSXIdentifier(ref.node)) { const jsxElement = getJSXElement(ref as NodePath<t.JSXIdentifier>, path); if (jsxElement) { jsxElements.push(jsxElement); } } else { let nodePath; try { nodePath = goThroughMemberExpression(ref, path); } catch (err) { if (err instanceof UnsupportedMemberExpressionError) { err.throw( 'Unsupported syntax: Member expression accessing a slottable node can only use dot notation or bracket notation (iff the value is a string literal). eg: `useSlot().slot.default` or `useSlot()["slot"].default`.', ); } } if (nodePath === null || nodePath === undefined) { continue; } let parentPath = skipTS(nodePath.parentPath)!; if ( t.isCallExpression(parentPath.node) && path.length === 0 && skipTS(parentPath.get("callee") as NodePath<t.Node>)?.node === nodePath.node ) { // Using the call signature of slot element continue; } if (isUnbreaking(parentPath)) { // eg: ReactSlots.useSlot(); or slot.default; (no assignment) hurts no one continue; } if (t.isVariableDeclarator(parentPath.node)) { try { const newIdentifiers = getValuesFromVarDeclarator( parentPath as NodePath<t.VariableDeclarator>, path, ); if (newIdentifiers.size) { for (const [newIdentifier, newPath] of newIdentifiers) { const { scope, node } = newIdentifier; findJSXElements( scope.bindings[node.name]!.referencePaths, newPath, jsxElements, ); } } } catch (err) { if (err instanceof VarDeclarationError) { err.throw( "Unsupported syntax: You must only use `let` or `const` variable declarations for slottable elements, instead encountered " + err.kind, ); } throw err; } } else { new UnsupportedSyntaxError(parentPath.node.loc).throw( "Unsupported syntax: A slottable element or an object holding a nested slottable element used inside " + parentPath.node.type, ); } } } } function transformJSXElements(element: NodePath<t.JSXElement>) { const defaultContent = element.node.children; const props = element.node.openingElement.attributes.map((attr) => { if (t.isJSXSpreadAttribute(attr)) { return t.spreadElement(attr.argument); } return t.objectProperty( t.isJSXNamespacedName(attr.name) ? t.identifier(`${attr.name.namespace}:${attr.name.name}`) : t.identifier(attr.name.name), // I don't think attr.value can ever be JSXEmptyExpression or // attr.value.expression can be undefined // but it's there in ts declarations so we are handling it. t.isJSXExpressionContainer(attr.value) ? t.isJSXEmptyExpression(attr.value.expression) ? t.identifier("undefined") : attr.value.expression : attr.value === null || attr.value === undefined ? t.booleanLiteral(true) : attr.value, ); }); const callExpression = t.callExpression( jsxNameToCallee(element.node.openingElement.name), [ defaultContent.length ? t.jsxElement( t.jsxOpeningElement(t.jsxIdentifier(DEFAULT_CONTENT_WRAPPER), []), t.jsxClosingElement(t.jsxIdentifier(DEFAULT_CONTENT_WRAPPER)), defaultContent, ) : t.jsxElement( t.jsxOpeningElement( t.jsxIdentifier(DEFAULT_CONTENT_WRAPPER), [], true, ), null, [], true, ), (props.length && t.objectExpression(props)) as t.ObjectExpression, ].filter(Boolean), ); element.replaceWith( t.isJSXElement(element.parent) || t.isJSXFragment(element.parent) ? t.inherits(t.jSXExpressionContainer(callExpression), element.node) : t.inherits(callExpression, element.node), ); } export = declare((api) => { api.assertVersion(7); const jsxElementSet = new Set<t.JSXElement>(); return { name: "transform-react-slots", inherits: syntaxJSX, pre(file) { rawCodeStore.set(file.code); jsxElementSet.clear(); }, post() { rawCodeStore.set(""); }, visitor: { Program: { enter(path, state) { if (isDisabled(state.file.ast.comments, path.node)) { return; } const [imports, namespaceImports] = findImports(path); // Exit early if no useSlot imports if (!imports.length && !namespaceImports.length) { return; } const callExpressions: NodePath<t.CallExpression>[] = []; for (const importNode of imports) { findCallExpressions( path.scope.bindings[importNode.local.name]!.referencePaths, [], callExpressions, ); } for (const importNode of namespaceImports) { findCallExpressions( path.scope.bindings[importNode.local.name]!.referencePaths, [IMPORTED_NODE], callExpressions, ); } const jsxElements: NodePath<t.JSXElement>[] = []; findJSXElements( callExpressions, [ANY_PROPERTY, SLOT_OBJECT_NAME], jsxElements, ); jsxElements.forEach((path) => { jsxElementSet.add(path.node); }); }, }, JSXElement: { enter(path) { if (jsxElementSet.has(path.node)) { transformJSXElements(path); } }, }, }, }; });