/** * @typedef {import('estree-jsx').Node} Node * @typedef {import('estree-jsx').Expression} Expression * @typedef {import('estree-jsx').ObjectExpression} ObjectExpression * @typedef {import('estree-jsx').Property} Property * @typedef {import('estree-jsx').ImportSpecifier} ImportSpecifier * @typedef {import('estree-jsx').SpreadElement} SpreadElement * @typedef {import('estree-jsx').MemberExpression} MemberExpression * @typedef {import('estree-jsx').Literal} Literal * @typedef {import('estree-jsx').Identifier} Identifier * @typedef {import('estree-jsx').JSXAttribute} JSXAttribute * @typedef {import('estree-jsx').JSXMemberExpression} JSXMemberExpression * @typedef {import('estree-jsx').JSXNamespacedName} JSXNamespacedName * @typedef {import('estree-jsx').JSXIdentifier} JSXIdentifier * * @typedef {'automatic' | 'classic'} Runtime * How to transform JSX. * * @typedef Options * Configuration. * * > 👉 **Note**: you can also configure `runtime`, `importSource`, `pragma`, * > and `pragmaFrag` from within files through comments. * @property {Runtime | null | undefined} [runtime='classic'] * Choose the runtime. * * Comment form: `@jsxRuntime theRuntime`. * @property {string | null | undefined} [importSource='react'] * Place to import `jsx`, `jsxs`, `jsxDEV`, and `Fragment` from, when the * effective runtime is automatic. * * Comment form: `@jsxImportSource theSource`. * * > 👉 **Note**: `/jsx-runtime` or `/jsx-dev-runtime` is appended to this * > provided source. * > In CJS, that can resolve to a file (as in `theSource/jsx-runtime.js`), * > but for ESM an export map needs to be set up to point to files: * > * > ```js * > // … * > "exports": { * > // … * > "./jsx-runtime": "./path/to/jsx-runtime.js", * > "./jsx-dev-runtime": "./path/to/jsx-runtime.js" * > // … * > ``` * @property {string | null | undefined} [pragma='React.createElement'] * Identifier or member expression to call when the effective runtime is * classic. * * Comment form: `@jsx identifier`. * @property {string | null | undefined} [pragmaFrag='React.Fragment'] * Identifier or member expression to use as a symbol for fragments when the * effective runtime is classic. * * Comment form: `@jsxFrag identifier`. * @property {boolean | null | undefined} [development=false] * When in the automatic runtime, whether to import * `theSource/jsx-dev-runtime.js`, use `jsxDEV`, and pass location info when * available. * * This helps debugging but adds a lot of code that you don’t want in * production. * @property {string | null | undefined} [filePath] * File path to the original source file. * * Passed in location info to `jsxDEV` when using the automatic runtime with * `development: true`. * * @typedef Annotations * State where info from comments is gathered. * @property {Runtime | undefined} [jsxRuntime] * Runtime. * @property {string | undefined} [jsx] * JSX identifier (`pragma`). * @property {string | undefined} [jsxFrag] * JSX identifier of fragment (`pragmaFrag`). * @property {string | undefined} [jsxImportSource] * Where to import an automatic JSX runtime from. * * @typedef Imports * State of used identifiers from the automatic runtime. * @property {boolean | undefined} [fragment] * Symbol of `Fragment`. * @property {boolean | undefined} [jsx] * Symbol of `jsx`. * @property {boolean | undefined} [jsxs] * Symbol of `jsxs`. * @property {boolean | undefined} [jsxDEV] * Symbol of `jsxDEV`. */ import {walk} from 'estree-walker' import {name as isIdentifierName} from 'estree-util-is-identifier-name' const regex = /@(jsx|jsxFrag|jsxImportSource|jsxRuntime)\s+(\S+)/g /** * Turn JSX in `tree` into function calls: `` -> `h('x')`! * * ###### Algorithm * * In almost all cases, this utility is the same as the Babel plugin, except that * they work on slightly different syntax trees. * * Some differences: * * * no pure annotations things * * `this` is not a component: `` -> `h('this')`, not `h(this)` * * namespaces are supported: `` -> `h('a:b', {'c:d': true})`, * which throws by default in Babel or can be turned on with `throwIfNamespace` * * no `useSpread`, `useBuiltIns`, or `filter` options * * @template {Node} Tree * Node type. * @param {Tree} tree * Tree to transform (typically `Program`). * @param {Options | null | undefined} [options={}] * Configuration (optional). * @returns {Tree} * Given, modified, `tree`. */ // To do next major: do not return the given Node. export function buildJsx(tree, options) { const config = options || {} let automatic = config.runtime === 'automatic' /** @type {Annotations} */ const annotations = {} /** @type {Imports} */ const imports = {} walk(tree, { // @ts-expect-error: hush, `estree-walker` is broken. enter(/** @type {Node} */ node) { if (node.type === 'Program') { const comments = node.comments || [] let index = -1 while (++index < comments.length) { regex.lastIndex = 0 let match = regex.exec(comments[index].value) while (match) { // @ts-expect-error: indexable. annotations[match[1]] = match[2] match = regex.exec(comments[index].value) } } if (annotations.jsxRuntime) { if (annotations.jsxRuntime === 'automatic') { automatic = true if (annotations.jsx) { throw new Error('Unexpected `@jsx` pragma w/ automatic runtime') } if (annotations.jsxFrag) { throw new Error( 'Unexpected `@jsxFrag` pragma w/ automatic runtime' ) } } else if (annotations.jsxRuntime === 'classic') { automatic = false if (annotations.jsxImportSource) { throw new Error( 'Unexpected `@jsxImportSource` w/ classic runtime' ) } } else { throw new Error( 'Unexpected `jsxRuntime` `' + annotations.jsxRuntime + '`, expected `automatic` or `classic`' ) } } } }, // @ts-expect-error: hush, `estree-walker` is broken. // eslint-disable-next-line complexity leave(/** @type {Node} */ node) { if (node.type === 'Program') { /** @type {Array} */ const specifiers = [] if (imports.fragment) { specifiers.push({ type: 'ImportSpecifier', imported: {type: 'Identifier', name: 'Fragment'}, local: {type: 'Identifier', name: '_Fragment'} }) } if (imports.jsx) { specifiers.push({ type: 'ImportSpecifier', imported: {type: 'Identifier', name: 'jsx'}, local: {type: 'Identifier', name: '_jsx'} }) } if (imports.jsxs) { specifiers.push({ type: 'ImportSpecifier', imported: {type: 'Identifier', name: 'jsxs'}, local: {type: 'Identifier', name: '_jsxs'} }) } if (imports.jsxDEV) { specifiers.push({ type: 'ImportSpecifier', imported: {type: 'Identifier', name: 'jsxDEV'}, local: {type: 'Identifier', name: '_jsxDEV'} }) } if (specifiers.length > 0) { node.body.unshift({ type: 'ImportDeclaration', specifiers, source: { type: 'Literal', value: (annotations.jsxImportSource || config.importSource || 'react') + (config.development ? '/jsx-dev-runtime' : '/jsx-runtime') } }) } } if (node.type !== 'JSXElement' && node.type !== 'JSXFragment') { return } /** @type {Array} */ const children = [] let index = -1 // Figure out `children`. while (++index < node.children.length) { const child = node.children[index] if (child.type === 'JSXExpressionContainer') { // Ignore empty expressions. if (child.expression.type !== 'JSXEmptyExpression') { children.push(child.expression) } } else if (child.type === 'JSXText') { const value = child.value // Replace tabs w/ spaces. .replace(/\t/g, ' ') // Use line feeds, drop spaces around them. .replace(/ *(\r?\n|\r) */g, '\n') // Collapse multiple line feeds. .replace(/\n+/g, '\n') // Drop final line feeds. .replace(/\n+$/, '') // Drop first line feeds. .replace(/^\n+/, '') // Replace line feeds with spaces. .replace(/\n/g, ' ') // Ignore collapsible text. if (value) { children.push(create(child, {type: 'Literal', value})) } } else { // @ts-expect-error JSX{Element,Fragment} have already been compiled, // and `JSXSpreadChild` is not supported in Babel either, so ignore // it. children.push(child) } } /** @type {MemberExpression | Literal | Identifier} */ let name /** @type {Array} */ let fields = [] /** @type {Array} */ const objects = [] /** @type {Array} */ let parameters = [] /** @type {Expression | undefined} */ let key // Do the stuff needed for elements. if (node.type === 'JSXElement') { name = toIdentifier(node.openingElement.name) // If the name could be an identifier, but start with a lowercase letter, // it’s not a component. if (name.type === 'Identifier' && /^[a-z]/.test(name.name)) { name = create(name, {type: 'Literal', value: name.name}) } /** @type {boolean | undefined} */ let spread const attributes = node.openingElement.attributes let index = -1 // Place props in the right order, because we might have duplicates // in them and what’s spread in. while (++index < attributes.length) { const attribute = attributes[index] if (attribute.type === 'JSXSpreadAttribute') { if (fields.length > 0) { objects.push({type: 'ObjectExpression', properties: fields}) fields = [] } objects.push(attribute.argument) spread = true } else { const prop = toProperty(attribute) if ( automatic && prop.key.type === 'Identifier' && prop.key.name === 'key' ) { if (spread) { throw new Error( 'Expected `key` to come before any spread expressions' ) } // @ts-expect-error I can’t see object patterns being used as // attribute values? 🤷‍♂️ key = prop.value } else { fields.push(prop) } } } } // …and fragments. else if (automatic) { imports.fragment = true name = {type: 'Identifier', name: '_Fragment'} } else { name = toMemberExpression( annotations.jsxFrag || config.pragmaFrag || 'React.Fragment' ) } if (automatic) { if (children.length > 0) { fields.push({ type: 'Property', key: {type: 'Identifier', name: 'children'}, value: children.length > 1 ? {type: 'ArrayExpression', elements: children} : children[0], kind: 'init', method: false, shorthand: false, computed: false }) } } else { parameters = children } if (fields.length > 0) { objects.push({type: 'ObjectExpression', properties: fields}) } /** @type {Expression | undefined} */ let props /** @type {MemberExpression | Literal | Identifier} */ let callee if (objects.length > 1) { // Don’t mutate the first object, shallow clone instead. if (objects[0].type !== 'ObjectExpression') { objects.unshift({type: 'ObjectExpression', properties: []}) } props = { type: 'CallExpression', callee: toMemberExpression('Object.assign'), arguments: objects, optional: false } } else if (objects.length > 0) { props = objects[0] } if (automatic) { parameters.push(props || {type: 'ObjectExpression', properties: []}) if (key) { parameters.push(key) } else if (config.development) { parameters.push({type: 'Identifier', name: 'undefined'}) } const isStaticChildren = children.length > 1 if (config.development) { imports.jsxDEV = true callee = { type: 'Identifier', name: '_jsxDEV' } parameters.push({type: 'Literal', value: isStaticChildren}) /** @type {ObjectExpression} */ const source = { type: 'ObjectExpression', properties: [ { type: 'Property', method: false, shorthand: false, computed: false, kind: 'init', key: {type: 'Identifier', name: 'fileName'}, value: { type: 'Literal', value: config.filePath || '' } } ] } if (node.loc) { source.properties.push( { type: 'Property', method: false, shorthand: false, computed: false, kind: 'init', key: {type: 'Identifier', name: 'lineNumber'}, value: {type: 'Literal', value: node.loc.start.line} }, { type: 'Property', method: false, shorthand: false, computed: false, kind: 'init', key: {type: 'Identifier', name: 'columnNumber'}, value: {type: 'Literal', value: node.loc.start.column + 1} } ) } parameters.push(source, {type: 'ThisExpression'}) } else if (isStaticChildren) { imports.jsxs = true callee = {type: 'Identifier', name: '_jsxs'} } else { imports.jsx = true callee = {type: 'Identifier', name: '_jsx'} } } // Classic. else { // There are props or children. if (props || parameters.length > 0) { parameters.unshift(props || {type: 'Literal', value: null}) } callee = toMemberExpression( annotations.jsx || config.pragma || 'React.createElement' ) } parameters.unshift(name) // Types of `estree-walker` are wrong this.replace( create(node, { type: 'CallExpression', callee, arguments: parameters, optional: false }) ) } }) return tree } /** * @param {JSXAttribute} node * @returns {Property} */ function toProperty(node) { /** @type {Expression} */ let value if (node.value) { if (node.value.type === 'JSXExpressionContainer') { // @ts-expect-error `JSXEmptyExpression` is not allowed in props. value = node.value.expression } // Literal or call expression. else { // @ts-expect-error: JSX{Element,Fragment} are already compiled to // `CallExpression`. value = node.value // @ts-expect-error Remove `raw` so we don’t get character references in // strings. delete value.raw } } // Boolean prop. else { value = {type: 'Literal', value: true} } return create(node, { type: 'Property', key: toIdentifier(node.name), value, kind: 'init', method: false, shorthand: false, computed: false }) } /** * @param {JSXMemberExpression | JSXNamespacedName | JSXIdentifier} node * @returns {MemberExpression | Identifier | Literal} */ function toIdentifier(node) { /** @type {MemberExpression | Identifier | Literal} */ let replace if (node.type === 'JSXMemberExpression') { // `property` is always a `JSXIdentifier`, but it could be something that // isn’t an ES identifier name. const id = toIdentifier(node.property) replace = { type: 'MemberExpression', object: toIdentifier(node.object), property: id, computed: id.type === 'Literal', optional: false } } else if (node.type === 'JSXNamespacedName') { replace = { type: 'Literal', value: node.namespace.name + ':' + node.name.name } } // Must be `JSXIdentifier`. else { replace = isIdentifierName(node.name) ? {type: 'Identifier', name: node.name} : {type: 'Literal', value: node.name} } return create(node, replace) } /** * @param {string} id * @returns {Identifier | Literal | MemberExpression} */ function toMemberExpression(id) { const identifiers = id.split('.') let index = -1 /** @type {Identifier | Literal | MemberExpression | undefined} */ let result while (++index < identifiers.length) { /** @type {Identifier | Literal} */ const prop = isIdentifierName(identifiers[index]) ? {type: 'Identifier', name: identifiers[index]} : {type: 'Literal', value: identifiers[index]} result = result ? { type: 'MemberExpression', object: result, property: prop, computed: Boolean(index && prop.type === 'Literal'), optional: false } : prop } // @ts-expect-error: always a result. return result } /** * @template {Node} T * @param {Node} from * @param {T} node * @returns {T} */ function create(from, node) { const fields = ['start', 'end', 'loc', 'range', 'comments'] let index = -1 while (++index < fields.length) { const field = fields[index] if (field in from) { // @ts-expect-error: indexable. node[field] = from[field] } } return node }