diff --git a/index.js b/index.js index b24f24d..d619a92 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,11 @@ /** * @typedef {import('./lib/components.js').Components} Components * @typedef {import('./lib/components.js').ExtraProps} ExtraProps + * @typedef {import('./lib/index.js').CreateEvaluater} CreateEvaluater * @typedef {import('./lib/index.js').ElementAttributeNameCase} ElementAttributeNameCase + * @typedef {import('./lib/index.js').EvaluateExpression} EvaluateExpression + * @typedef {import('./lib/index.js').EvaluateProgram} EvaluateProgram + * @typedef {import('./lib/index.js').Evaluater} Evaluater * @typedef {import('./lib/index.js').Fragment} Fragment * @typedef {import('./lib/index.js').Jsx} Jsx * @typedef {import('./lib/index.js').JsxDev} JsxDev diff --git a/lib/index.js b/lib/index.js index c6d7205..91f47db 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,10 +1,33 @@ +// Register MDX nodes in mdast: +/// +/// +/// + /** + * @typedef {import('estree').Identifier} Identifier + * @typedef {import('estree').Literal} Literal + * @typedef {import('estree').MemberExpression} MemberExpression + * @typedef {import('estree').Expression} Expression + * @typedef {import('estree').Program} Program + * * @typedef {import('hast').Element} Element * @typedef {import('hast').Nodes} Nodes * @typedef {import('hast').Parents} Parents + * @typedef {import('hast').Root} Root + * @typedef {import('hast').Text} Text + * + * @typedef {import('mdast-util-mdx-expression').MdxFlowExpressionHast} MdxFlowExpression + * @typedef {import('mdast-util-mdx-expression').MdxTextExpressionHast} MdxTextExpression + * + * @typedef {import('mdast-util-mdx-jsx').MdxJsxFlowElementHast} MdxJsxFlowElement + * @typedef {import('mdast-util-mdx-jsx').MdxJsxTextElementHast} MdxJsxTextElement + * + * @typedef {import('mdast-util-mdxjs-esm').MdxjsEsmHast} MdxjsEsm * * @typedef {import('property-information').Schema} Schema * + * @typedef {import('unist').Position} Position + * * @typedef {import('./components.js').Components} Components */ @@ -25,12 +48,40 @@ * @returns {JSX.Element} * Result. * + * @callback CreateEvaluater + * Create an evaluator that turns ESTree ASTs from embedded MDX into values. + * @returns {Evaluater} + * Evaluater. + * * @typedef {'html' | 'react'} ElementAttributeNameCase * Casing to use for attribute names. * * HTML casing is for example `class`, `stroke-linecap`, `xml:lang`. * React casing is for example `className`, `strokeLinecap`, `xmlLang`. * + * @callback EvaluateExpression + * Turn an MDX expression into a value. + * @param {Expression} expression + * ESTree expression. + * @returns {unknown} + * Result of expression. + * + * @callback EvaluateProgram + * Turn an MDX program (export/import statements) into a value. + * @param {Program} expression + * ESTree program. + * @returns {unknown} + * Result of program; + * should likely be `undefined` as ESM changes the scope but doesn’t yield + * something. + * + * @typedef Evaluater + * Evaluator that turns ESTree ASTs from embedded MDX into values. + * @property {EvaluateExpression} evaluateExpression + * Evaluate an expression. + * @property {EvaluateProgram} evaluateProgram + * Evaluate a program. + * * @typedef {[string, Value]} Field * Property field. * @@ -46,7 +97,7 @@ * @param {string | undefined} [key] * Dynamicly generated key to use. * @returns {JSX.Element} - * An element from your framework. + * Element from your framework. * * @callback JsxDev * Create a development element. @@ -64,9 +115,9 @@ * @param {undefined} self * Nothing (this is used by frameworks that have components, we don’t). * @returns {JSX.Element} - * An element from your framework. + * Element from your framework. * - * @typedef {{children?: Array | Child, node?: Element | undefined, [prop: string]: Array | Child | Element | Value | undefined}} Props + * @typedef {{children?: Array | Child, node?: Element | MdxJsxFlowElement | MdxJsxTextElement | undefined, [prop: string]: Array | Child | Element | MdxJsxFlowElement | MdxJsxTextElement | Value | undefined}} Props * Properties and children. * * @typedef RegularFields @@ -75,6 +126,8 @@ * Components to use (optional). * @property {ElementAttributeNameCase | null | undefined} [elementAttributeNameCase='react'] * Specify casing to use for attribute names (default: `'react'`). + * @property {CreateEvaluater | null | undefined} [createEvaluater] + * Create an evaluator that turns ESTree ASTs into values (optional). * @property {string | null | undefined} [filePath] * File path to the original source file (optional). * @@ -171,6 +224,8 @@ * Create something in development or production. * @property {ElementAttributeNameCase} elementAttributeNameCase * Casing to use for attribute names. + * @property {Evaluater | undefined} evaluater + * Evaluator that turns ESTree ASTs into values. * @property {string | undefined} filePath * File path. * @property {boolean} ignoreInvalidStyle @@ -211,6 +266,8 @@ */ import {stringify as commas} from 'comma-separated-tokens' +import {ok as assert} from 'devlop' +import {name as isIdentifierName} from 'estree-util-is-identifier-name' import {whitespace} from 'hast-util-whitespace' import {find, hastToReact, html, svg} from 'property-information' import {stringify as spaces} from 'space-separated-tokens' @@ -241,6 +298,8 @@ const tableElements = new Set(['table', 'tbody', 'thead', 'tfoot', 'tr']) const tableCellElement = new Set(['td', 'th']) +const docs = 'https://github.com/syntax-tree/hast-util-to-jsx-runtime' + /** * Transform a hast tree to preact, react, solid, svelte, vue, etc., * with an automatic JSX runtime. @@ -289,6 +348,7 @@ export function toJsxRuntime(tree, options) { components: options.components || {}, create, elementAttributeNameCase: options.elementAttributeNameCase || 'react', + evaluater: options.createEvaluater ? options.createEvaluater() : undefined, filePath, ignoreInvalidStyle: options.ignoreInvalidStyle || false, passKeys: options.passKeys !== false, @@ -327,67 +387,233 @@ export function toJsxRuntime(tree, options) { * Child, optional. */ function one(state, node, key) { - if (node.type === 'element' || node.type === 'root') { - const parentSchema = state.schema - let schema = parentSchema - - if ( - node.type === 'element' && - node.tagName.toLowerCase() === 'svg' && - parentSchema.space === 'html' - ) { - schema = svg - state.schema = schema - } + if (node.type === 'element') { + return element(state, node, key) + } - state.ancestors.push(node) + if (node.type === 'mdxFlowExpression' || node.type === 'mdxTextExpression') { + return mdxExpression(state, node) + } - let children = createChildren(state, node) - const props = createProperties(state, state.ancestors) - let type = state.Fragment + if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') { + return mdxJsxElement(state, node, key) + } - state.ancestors.pop() + if (node.type === 'mdxjsEsm') { + return mdxEsm(state, node) + } - if (node.type === 'element') { - if (children && tableElements.has(node.tagName)) { - children = children.filter(function (child) { - return typeof child === 'string' ? !whitespace(child) : true - }) - } + if (node.type === 'root') { + return root(state, node, key) + } - if (own.call(state.components, node.tagName)) { - const key = /** @type {keyof JSX.IntrinsicElements} */ (node.tagName) - type = state.components[key] + if (node.type === 'text') { + return text(state, node) + } +} - // If this is swapped out for a component: - if ( - typeof type !== 'string' && - type !== state.Fragment && - state.passNode - ) { - props.node = node - } - } else { - type = node.tagName - } - } +/** + * Handle element. + * + * @param {State} state + * Info passed around. + * @param {Element} node + * Current node. + * @param {string | undefined} key + * Key. + * @returns {Child | undefined} + * Child, optional. + */ +function element(state, node, key) { + const parentSchema = state.schema + let schema = parentSchema - if (children.length > 0) { - const value = children.length > 1 ? children : children[0] + if (node.tagName.toLowerCase() === 'svg' && parentSchema.space === 'html') { + schema = svg + state.schema = schema + } - if (value) { - props.children = value - } - } + state.ancestors.push(node) - // Restore parent schema. - state.schema = parentSchema + const type = findComponentFromName(state, node.tagName, false) + const props = createElementProps(state, node) + let children = createChildren(state, node) - return state.create(node, type, props, key) + if (tableElements.has(node.tagName)) { + children = children.filter(function (child) { + return typeof child === 'string' ? !whitespace(child) : true + }) } - if (node.type === 'text') { - return node.value + addNode(state, props, type, node) + addChildren(props, children) + + // Restore. + state.ancestors.pop() + state.schema = parentSchema + + return state.create(node, type, props, key) +} + +/** + * Handle MDX expression. + * + * @param {State} state + * Info passed around. + * @param {MdxFlowExpression | MdxTextExpression} node + * Current node. + * @returns {Child | undefined} + * Child, optional. + */ +function mdxExpression(state, node) { + if (node.data && node.data.estree && state.evaluater) { + const program = node.data.estree + const expression = program.body[0] + assert(expression.type === 'ExpressionStatement') + + // Assume result is a child. + return /** @type {Child | undefined} */ ( + state.evaluater.evaluateExpression(expression.expression) + ) + } + + crashEstree(state, node.position) +} + +/** + * Handle MDX ESM. + * + * @param {State} state + * Info passed around. + * @param {MdxjsEsm} node + * Current node. + * @returns {Child | undefined} + * Child, optional. + */ +function mdxEsm(state, node) { + if (node.data && node.data.estree && state.evaluater) { + // Assume result is a child. + return /** @type {Child | undefined} */ ( + state.evaluater.evaluateProgram(node.data.estree) + ) + } + + crashEstree(state, node.position) +} + +/** + * Handle MDX JSX. + * + * @param {State} state + * Info passed around. + * @param {MdxJsxFlowElement | MdxJsxTextElement} node + * Current node. + * @param {string | undefined} key + * Key. + * @returns {Child | undefined} + * Child, optional. + */ +function mdxJsxElement(state, node, key) { + const parentSchema = state.schema + let schema = parentSchema + + if (node.name === 'svg' && parentSchema.space === 'html') { + schema = svg + state.schema = schema + } + + state.ancestors.push(node) + + const type = + node.name === null + ? state.Fragment + : findComponentFromName(state, node.name, true) + const props = createJsxElementProps(state, node) + const children = createChildren(state, node) + + addNode(state, props, type, node) + addChildren(props, children) + + // Restore. + state.ancestors.pop() + state.schema = parentSchema + + return state.create(node, type, props, key) +} + +/** + * Handle root. + * + * @param {State} state + * Info passed around. + * @param {Root} node + * Current node. + * @param {string | undefined} key + * Key. + * @returns {Child | undefined} + * Child, optional. + */ +function root(state, node, key) { + /** @type {Props} */ + const props = {} + + addChildren(props, createChildren(state, node)) + + return state.create(node, state.Fragment, props, key) +} + +/** + * Handle text. + * + * @param {State} _ + * Info passed around. + * @param {Text} node + * Current node. + * @returns {Child | undefined} + * Child, optional. + */ +function text(_, node) { + return node.value +} + +/** + * Add `node` to props. + * + * @param {State} state + * Info passed around. + * @param {Props} props + * Props. + * @param {unknown} type + * Type. + * @param {Element | MdxJsxFlowElement | MdxJsxTextElement} node + * Node. + * @returns {undefined} + * Nothing. + */ +function addNode(state, props, type, node) { + // If this is swapped out for a component: + if (typeof type !== 'string' && type !== state.Fragment && state.passNode) { + props.node = node + } +} + +/** + * Add children to props. + * + * @param {Props} props + * Props. + * @param {Array} children + * Children. + * @returns {undefined} + * Nothing. + */ +function addChildren(props, children) { + if (children.length > 0) { + const value = children.length > 1 ? children : children[0] + + if (value) { + props.children = value + } } } @@ -443,108 +669,169 @@ function developmentCreate(filePath, jsxDEV) { } /** - * Create children. + * Create props from an element. * * @param {State} state * Info passed around. - * @param {Parents} node + * @param {Element} node * Current element. - * @returns {Array} - * Children. + * @returns {Props} + * Props. */ -function createChildren(state, node) { - /** @type {Array} */ - const children = [] - let index = -1 - /** @type {Map} */ - // Note: test this when Solid doesn’t want to merge my upcoming PR. - /* c8 ignore next */ - const countsByTagName = state.passKeys ? new Map() : emptyMap +function createElementProps(state, node) { + /** @type {Props} */ + const props = {} + /** @type {string | undefined} */ + let alignValue + /** @type {string} */ + let prop - while (++index < node.children.length) { - const child = node.children[index] - /** @type {string | undefined} */ - let key + for (prop in node.properties) { + if (prop !== 'children' && own.call(node.properties, prop)) { + const result = createProperty(state, prop, node.properties[prop]) + + if (result) { + const [key, value] = result - if (state.passKeys && child.type === 'element') { - const count = countsByTagName.get(child.tagName) || 0 - key = child.tagName + '-' + count - countsByTagName.set(child.tagName, count + 1) + if ( + state.tableCellAlignToStyle && + key === 'align' && + typeof value === 'string' && + tableCellElement.has(node.tagName) + ) { + alignValue = value + } else { + props[key] = value + } + } } + } - const result = one(state, child, key) - if (result !== undefined) children.push(result) + if (alignValue) { + // Assume style is an object. + const style = /** @type {Style} */ (props.style || (props.style = {})) + style[state.stylePropertyNameCase === 'css' ? 'text-align' : 'textAlign'] = + alignValue } - return children + return props } /** - * Handle properties. + * Create props from a JSX element. * * @param {State} state * Info passed around. - * @param {Array} ancestors - * Stack of parents. + * @param {MdxJsxFlowElement | MdxJsxTextElement} node + * Current JSX element. * @returns {Props} - * Props for runtime. + * Props. */ -function createProperties(state, ancestors) { - const node = ancestors[ancestors.length - 1] +function createJsxElementProps(state, node) { /** @type {Props} */ const props = {} - /** @type {string} */ - let prop - if ('properties' in node && node.properties) { - /** @type {string | undefined} */ - let alignValue - - for (prop in node.properties) { - if (prop !== 'children' && own.call(node.properties, prop)) { - const result = createProperty( - state, - ancestors, - prop, - node.properties[prop] + for (const attribute of node.attributes) { + if (attribute.type === 'mdxJsxExpressionAttribute') { + if (attribute.data && attribute.data.estree && state.evaluater) { + const program = attribute.data.estree + const expression = program.body[0] + assert(expression.type === 'ExpressionStatement') + const objectExpression = expression.expression + assert(objectExpression.type === 'ObjectExpression') + const property = objectExpression.properties[0] + assert(property.type === 'SpreadElement') + + Object.assign( + props, + state.evaluater.evaluateExpression(property.argument) ) + } else { + crashEstree(state, node.position) + } + } else { + // For JSX, the author is responsible of passing in the correct values. + const name = attribute.name + /** @type {unknown} */ + let value - if (result) { - const [key, value] = result - - if ( - state.tableCellAlignToStyle && - key === 'align' && - typeof value === 'string' && - tableCellElement.has(node.tagName) - ) { - alignValue = value - } else { - props[key] = value - } + if (attribute.value && typeof attribute.value === 'object') { + if ( + attribute.value.data && + attribute.value.data.estree && + state.evaluater + ) { + const program = attribute.value.data.estree + const expression = program.body[0] + assert(expression.type === 'ExpressionStatement') + value = state.evaluater.evaluateExpression(expression.expression) + } else { + crashEstree(state, node.position) } + } else { + value = attribute.value === null ? true : attribute.value } - } - if (alignValue) { - // Assume style is an object. - const style = /** @type {Style} */ (props.style || (props.style = {})) - style[ - state.stylePropertyNameCase === 'css' ? 'text-align' : 'textAlign' - ] = alignValue + // Assume a prop. + props[name] = /** @type {Props[keyof Props]} */ (value) } } return props } +/** + * Create children. + * + * @param {State} state + * Info passed around. + * @param {Parents} node + * Current element. + * @returns {Array} + * Children. + */ +function createChildren(state, node) { + /** @type {Array} */ + const children = [] + let index = -1 + /** @type {Map} */ + // Note: test this when Solid doesn’t want to merge my upcoming PR. + /* c8 ignore next */ + const countsByName = state.passKeys ? new Map() : emptyMap + + while (++index < node.children.length) { + const child = node.children[index] + /** @type {string | undefined} */ + let key + + if (state.passKeys) { + const name = + child.type === 'element' + ? child.tagName + : child.type === 'mdxJsxFlowElement' || + child.type === 'mdxJsxTextElement' + ? child.name + : undefined + + if (name) { + const count = countsByName.get(name) || 0 + key = name + '-' + count + countsByName.set(name, count + 1) + } + } + + const result = one(state, child, key) + if (result !== undefined) children.push(result) + } + + return children +} + /** * Handle a property. * * @param {State} state * Info passed around. - * @param {Array} ancestors - * Stack of parents. * @param {string} prop * Key. * @param {Array | boolean | number | string | null | undefined} value @@ -552,7 +839,7 @@ function createProperties(state, ancestors) { * @returns {Field | undefined} * Field for runtime, optional. */ -function createProperty(state, ancestors, prop, value) { +function createProperty(state, prop, value) { const info = find(state.schema, prop) // Ignore nullish and `NaN` values. @@ -573,9 +860,7 @@ function createProperty(state, ancestors, prop, value) { // React only accepts `style` as object. if (info.property === 'style') { let styleObject = - typeof value === 'object' - ? value - : parseStyle(state, ancestors, String(value)) + typeof value === 'object' ? value : parseStyle(state, String(value)) if (state.stylePropertyNameCase === 'css') { styleObject = transformStylesToCssCasing(styleObject) @@ -597,8 +882,6 @@ function createProperty(state, ancestors, prop, value) { * * @param {State} state * Info passed around. - * @param {Array} ancestors - * Stack of nodes. * @param {string} value * CSS declarations. * @returns {Style} @@ -606,7 +889,7 @@ function createProperty(state, ancestors, prop, value) { * @throws * Throws `VFileMessage` when CSS cannot be parsed. */ -function parseStyle(state, ancestors, value) { +function parseStyle(state, value) { /** @type {Style} */ const result = {} @@ -617,14 +900,13 @@ function parseStyle(state, ancestors, value) { if (!state.ignoreInvalidStyle) { const cause = /** @type {Error} */ (error) const message = new VFileMessage('Cannot parse `style` attribute', { - ancestors, + ancestors: state.ancestors, cause, - source: 'hast-util-to-jsx-runtime', - ruleId: 'style' + ruleId: 'style', + source: 'hast-util-to-jsx-runtime' }) message.file = state.filePath || undefined - message.url = - 'https://github.com/syntax-tree/hast-util-to-jsx-runtime#cannot-parse-style-attribute' + message.url = docs + '#cannot-parse-style-attribute' throw message } @@ -655,6 +937,92 @@ function parseStyle(state, ancestors, value) { } } +/** + * Create a JSX name from a string. + * + * @param {State} state + * To do. + * @param {string} name + * Name. + * @param {boolean} allowExpression + * Allow member expressions and identifiers. + * @returns {unknown} + * To do. + */ +function findComponentFromName(state, name, allowExpression) { + /** @type {Identifier | Literal | MemberExpression} */ + let result + + if (!allowExpression) { + result = {type: 'Literal', value: name} + } else if (name.includes('.')) { + const identifiers = name.split('.') + let index = -1 + /** @type {Identifier | Literal | MemberExpression | undefined} */ + let node + + while (++index < identifiers.length) { + /** @type {Identifier | Literal} */ + const prop = isIdentifierName(identifiers[index]) + ? {type: 'Identifier', name: identifiers[index]} + : {type: 'Literal', value: identifiers[index]} + node = node + ? { + type: 'MemberExpression', + object: node, + property: prop, + computed: Boolean(index && prop.type === 'Literal'), + optional: false + } + : prop + } + + assert(node, 'always a result') + result = node + } else { + result = + isIdentifierName(name) && !/^[a-z]/.test(name) + ? {type: 'Identifier', name} + : {type: 'Literal', value: name} + } + + // Only literals can be passed in `components` currently. + // No identifiers / member expressions. + if (result.type === 'Literal') { + const name = /** @type {keyof JSX.IntrinsicElements} */ (result.value) + + return own.call(state.components, name) ? state.components[name] : name + } + + // Assume component. + if (state.evaluater) { + return state.evaluater.evaluateExpression(result) + } + + crashEstree(state) +} + +/** + * @param {State} state + * @param {Position | undefined} [place] + * @returns {never} + */ +function crashEstree(state, place) { + const message = new VFileMessage( + 'Cannot handle MDX estrees without `createEvaluater`', + { + ancestors: state.ancestors, + place, + ruleId: 'mdx-estree', + source: 'hast-util-to-jsx-runtime' + } + ) + message.file = state.filePath || undefined + message.url = docs + '#cannot-handle-mdx-estrees-without-createevaluater' + + throw message +} + /** * Transform a DOM casing style object to a CSS casing style object. * diff --git a/package.json b/package.json index 3dc1acb..392225f 100644 --- a/package.json +++ b/package.json @@ -35,10 +35,16 @@ "index.js" ], "dependencies": { + "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "style-to-object": "^1.0.0", @@ -51,6 +57,7 @@ "@types/react-dom": "^18.0.0", "c8": "^8.0.0", "esbuild": "^0.19.0", + "estree-util-visit": "^2.0.0", "hastscript": "^8.0.0", "prettier": "^3.0.0", "react": "^18.0.0", @@ -58,6 +65,7 @@ "remark-cli": "^12.0.0", "remark-gfm": "^4.0.0", "remark-preset-wooorm": "^9.0.0", + "sval": "^0.4.0", "type-coverage": "^2.0.0", "typescript": "^5.0.0", "xo": "^0.56.0" @@ -112,10 +120,7 @@ } ], "prettier": true, - "#": "`n` is wrong", "rules": { - "max-depth": "off", - "n/file-extension-in-import": "off", "unicorn/prefer-at": "off", "unicorn/prefer-string-replace-all": "off" } diff --git a/readme.md b/readme.md index 3f6a0b8..b8aa852 100644 --- a/readme.md +++ b/readme.md @@ -21,7 +21,11 @@ with an automatic JSX runtime. * [`toJsxRuntime(tree, options)`](#tojsxruntimetree-options) * [`Options`](#options) * [`Components`](#components) + * [`CreateEvaluater`](#createevaluater) * [`ElementAttributeNameCase`](#elementattributenamecase) + * [`EvaluateExpression`](#evaluateexpression) + * [`EvaluateProgram`](#evaluateprogram) + * [`Evaluater`](#evaluater) * [`ExtraProps`](#extraprops) * [`Fragment`](#fragment) * [`Jsx`](#jsx) @@ -161,6 +165,14 @@ The automatic JSX runtime, in production, needs these functions. To solve the error, make sure you are importing the correct runtime functions (for example, `'react/jsx-runtime'`), and pass `jsx` and `jsxs`. +###### `` Cannot handle MDX estrees without `createEvaluater` `` + +This error is thrown when MDX nodes are passed that represent JavaScript +programs or expressions. + +Supporting JavaScript can be unsafe and requires a different project. +To support JavaScript, pass a `createEvaluater` function in `options`. + ###### ``Cannot parse `style` attribute`` This error is thrown when a `style` attribute is found on an element, which @@ -171,7 +183,7 @@ CSS, and pass it as an object. But when broken CSS is used, such as `style="color:red; /*"`, we crash. To solve the error, make sure authors write valid CSS. -Alternatively, pass `options.ignoreInvalidStyle: true` to swallow theses +Alternatively, pass `options.ignoreInvalidStyle: true` to swallow these errors. ### `Options` @@ -196,6 +208,8 @@ Configuration (TypeScript type). ([`ElementAttributeNameCase`][api-element-attribute-name-case], default: `'react'`) — specify casing to use for attribute names +* `createEvaluater` ([`CreateEvaluater`][api-create-evaluater], optional) + — create an evaluator that turns ESTree ASTs into values * `filePath` (`string`, optional) — file path to the original source file, passed in source info to `jsxDEV` when using the automatic runtime with `development: true` @@ -244,6 +258,19 @@ type Component = | ((props: ComponentProps) => JSX.Element | string | null | undefined) ``` +### `CreateEvaluater` + +Create an evaluator that turns ESTree ASTs from embedded MDX into values +(TypeScript type). + +###### Parameters + +There are no parameters. + +###### Returns + +Evaluater ([`Evaluater`][api-evaluater]). + ### `ElementAttributeNameCase` Casing to use for attribute names (TypeScript type). @@ -257,6 +284,46 @@ React casing is for example `className`, `strokeLinecap`, `xmlLang`. type ElementAttributeNameCase = 'html' | 'react' ``` +### `EvaluateExpression` + +Turn an MDX expression into a value (TypeScript type). + +###### Parameters + +* `expression` (`Expression` from `@types/estree`) + — estree expression + +###### Returns + +Result of expression (`unknown`). + +### `EvaluateProgram` + +Turn an MDX program (export/import statements) into a value (TypeScript type). + +###### Parameters + +* `program` (`Program` from `@types/estree`) + — estree program + +###### Returns + +Result of program (`unknown`); +should likely be `undefined` as ESM changes the scope but doesn’t yield +something. + +### `Evaluater` + +Evaluator that turns ESTree ASTs from embedded MDX into values (TypeScript +type). + +###### Fields + +* `evaluateExpression` ([`EvaluateExpression`][api-evaluate-expression]) + — evaluate an expression +* `evaluateProgram` ([`EvaluateProgram`][api-evaluate-program]) + — evaluate a program + ### `ExtraProps` Extra fields we pass (TypeScript type). @@ -292,7 +359,7 @@ Create a production element (TypeScript type). ###### Returns -An element from your framework (`JSX.Element`). +Element from your framework (`JSX.Element`). ### `JsxDev` @@ -316,7 +383,7 @@ Create a development element (TypeScript type). ###### Returns -An element from your framework (`JSX.Element`). +Element from your framework (`JSX.Element`). ### `Props` @@ -567,7 +634,11 @@ followed by browsers such as Chrome, Firefox, and Safari. This package is fully typed with [TypeScript][]. It exports the additional types [`Components`][api-components], +[`CreateEvaluater`][api-create-evaluater], [`ElementAttributeNameCase`][api-element-attribute-name-case], +[`EvaluateExpression`][api-evaluate-expression], +[`EvaluateProgram`][api-evaluate-program], +[`Evaluater`][api-evaluater], [`ExtraProps`][api-extra-props], [`Fragment`][api-fragment], [`Jsx`][api-jsx], @@ -681,8 +752,16 @@ abide by its terms. [api-components]: #components +[api-create-evaluater]: #createevaluater + [api-element-attribute-name-case]: #elementattributenamecase +[api-evaluate-expression]: #evaluateexpression + +[api-evaluate-program]: #evaluateprogram + +[api-evaluater]: #evaluater + [api-extra-props]: #extraprops [api-fragment]: #fragment diff --git a/test/index.js b/test/index.js index 1fb61f9..474a091 100644 --- a/test/index.js +++ b/test/index.js @@ -1,4 +1,8 @@ /** + * @typedef {import('estree').Expression} Expression + * @typedef {import('estree').Program} Program + * + * @typedef {import('hast-util-to-jsx-runtime').CreateEvaluater} CreateEvaluater * @typedef {import('hast-util-to-jsx-runtime').Fragment} Fragment * @typedef {import('hast-util-to-jsx-runtime').Jsx} Jsx * @typedef {import('hast-util-to-jsx-runtime').JsxDev} JsxDev @@ -8,13 +12,19 @@ import assert from 'node:assert/strict' import test from 'node:test' +import {visit} from 'estree-util-visit' import {h, s} from 'hastscript' import {toJsxRuntime} from 'hast-util-to-jsx-runtime' +import * as sval from 'sval' import React from 'react' import * as dev from 'react/jsx-dev-runtime' import * as prod from 'react/jsx-runtime' import {renderToStaticMarkup} from 'react-dom/server' +/** @type {import('sval')['default']} */ +// @ts-expect-error: ESM types are wrong. +const Sval = sval.default + /** @type {{Fragment: Fragment, jsx: Jsx, jsxs: Jsx}} */ // @ts-expect-error: the react types are missing. const production = {Fragment: prod.Fragment, jsx: prod.jsx, jsxs: prod.jsxs} @@ -675,9 +685,7 @@ test('react specific: `align` to `style`', async function (t) { '' ) - assert.deepEqual(foundProps, { - style: {'text-align': 'center'} - }) + assert.deepEqual(foundProps, {style: {'text-align': 'center'}}) } ) @@ -701,9 +709,612 @@ test('react specific: `align` to `style`', async function (t) { '' ) - assert.deepEqual(foundProps, { - style: {textAlign: 'center'} - }) + assert.deepEqual(foundProps, {style: {textAlign: 'center'}}) } ) }) + +test('mdx: jsx', async function (t) { + await t.test('should transform MDX JSX (text)', async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + {type: 'mdxJsxTextElement', name: 'a', attributes: [], children: []}, + production + ) + ), + '' + ) + }) + + await t.test('should transform MDX JSX (flow)', async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + {type: 'mdxJsxTextElement', name: 'h1', attributes: [], children: []}, + production + ) + ), + '

' + ) + }) + + await t.test('should transform MDX JSX (fragment)', async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + { + type: 'mdxJsxTextElement', + name: null, + attributes: [], + children: [{type: 'text', value: 'a'}] + }, + production + ) + ), + 'a' + ) + }) + + await t.test('should transform MDX JSX (fragment)', async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + {type: 'mdxJsxTextElement', name: null, attributes: [], children: []}, + production + ) + ), + '' + ) + }) + + await t.test( + 'should transform MDX JSX (attribute, w/o value)', + async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + { + type: 'mdxJsxTextElement', + name: 'a', + attributes: [ + {type: 'mdxJsxAttribute', name: 'hidden', value: null} + ], + children: [] + }, + production + ) + ), + '' + ) + } + ) + + await t.test( + 'should transform MDX JSX (attribute, w/ value)', + async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + { + type: 'mdxJsxTextElement', + name: 'a', + attributes: [{type: 'mdxJsxAttribute', name: 'x', value: 'y'}], + children: [] + }, + production + ) + ), + '' + ) + } + ) + + await t.test('should transform MDX JSX (SVG)', async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + { + type: 'mdxJsxTextElement', + name: 'svg', + attributes: [], + children: [ + s('g', { + colorInterpolationFilters: 'sRGB', + xmlSpace: 'preserve', + xmlnsXLink: 'http://www.w3.org/1999/xlink', + xLinkArcRole: 'http://www.example.com' + }) + ] + }, + production + ) + ), + '' + ) + }) + + await t.test('should transform MDX JSX (literal)', async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + {type: 'mdxJsxTextElement', name: 'a', attributes: [], children: []}, + {...production, components: {a: 'b'}} + ) + ), + '' + ) + }) + + await t.test('should transform MDX JSX (namespace)', async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + { + type: 'mdxJsxTextElement', + name: 'a:b', + attributes: [], + children: [] + }, + { + ...production, + components: { + // @ts-expect-error: untyped. + 'a:b': 'b' + } + } + ) + ), + '' + ) + }) + + await t.test('should throw on identifier by default', async function () { + assert.throws(function () { + toJsxRuntime( + { + type: 'mdxJsxFlowElement', + name: 'A', + attributes: [], + children: [] + }, + production + ) + }, /Cannot handle MDX estrees without `createEvaluater`/) + }) + + await t.test('should transform MDX JSX (component)', async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + { + type: 'root', + children: [ + { + type: 'mdxjsEsm', + value: "export const A = 'b'", + data: { + estree: { + type: 'Program', + body: [ + { + type: 'ExportNamedDeclaration', + declaration: { + type: 'VariableDeclaration', + declarations: [ + { + type: 'VariableDeclarator', + id: {type: 'Identifier', name: 'A'}, + init: {type: 'Literal', value: 'b'} + } + ], + kind: 'const' + }, + specifiers: [], + source: null + } + ], + sourceType: 'module', + comments: [] + } + } + }, + { + type: 'mdxJsxFlowElement', + name: 'A', + attributes: [], + children: [] + } + ] + }, + {...production, createEvaluater} + ) + ), + '' + ) + }) + + await t.test( + 'should transform MDX JSX (member expression, non-identifier)', + async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + { + type: 'root', + children: [ + { + type: 'mdxjsEsm', + value: "export const a = {'b-c': 'c'}", + data: { + estree: { + type: 'Program', + body: [ + { + type: 'ExportNamedDeclaration', + declaration: { + type: 'VariableDeclaration', + declarations: [ + { + type: 'VariableDeclarator', + id: {type: 'Identifier', name: 'a'}, + init: { + type: 'ObjectExpression', + properties: [ + { + type: 'Property', + method: false, + shorthand: false, + computed: false, + key: {type: 'Literal', value: 'b-c'}, + value: {type: 'Literal', value: 'c'}, + kind: 'init' + } + ] + } + } + ], + kind: 'const' + }, + specifiers: [], + source: null + } + ], + sourceType: 'module', + comments: [] + } + } + }, + { + type: 'mdxJsxFlowElement', + name: 'a.b-c', + attributes: [], + children: [] + } + ] + }, + {...production, createEvaluater} + ) + ), + '' + ) + } + ) + + await t.test( + 'should throw on expression attribute by default', + async function () { + assert.throws(function () { + toJsxRuntime( + { + type: 'mdxJsxFlowElement', + name: 'a', + attributes: [{type: 'mdxJsxExpressionAttribute', value: '...x'}], + children: [] + }, + production + ) + }, /Cannot handle MDX estrees without `createEvaluater`/) + } + ) + + await t.test( + 'should throw on attribute value expression by default', + async function () { + assert.throws(function () { + toJsxRuntime( + { + type: 'mdxJsxFlowElement', + name: 'a', + attributes: [ + { + type: 'mdxJsxAttribute', + name: 'x', + value: {type: 'mdxJsxAttributeValueExpression', value: '1'} + } + ], + children: [] + }, + production + ) + }, /Cannot handle MDX estrees without `createEvaluater`/) + } + ) + + await t.test( + 'should support expression attribute w/ `createEvaluater`', + async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + { + type: 'mdxJsxTextElement', + name: 'a', + attributes: [ + { + type: 'mdxJsxExpressionAttribute', + value: "...{x: 'y'}", + data: { + estree: { + type: 'Program', + body: [ + { + type: 'ExpressionStatement', + expression: { + type: 'ObjectExpression', + properties: [ + { + type: 'SpreadElement', + argument: { + type: 'ObjectExpression', + properties: [ + { + type: 'Property', + method: false, + shorthand: false, + computed: false, + key: {type: 'Identifier', name: 'x'}, + value: {type: 'Literal', value: 'y'}, + kind: 'init' + } + ] + } + } + ] + } + } + ], + sourceType: 'module', + comments: [] + } + } + } + ], + children: [] + }, + {...production, createEvaluater} + ) + ), + '' + ) + } + ) + + await t.test( + 'should support attribute value expression w/ `createEvaluater`', + async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + { + type: 'mdxJsxTextElement', + name: 'a', + attributes: [ + { + type: 'mdxJsxAttribute', + name: 'x', + value: { + type: 'mdxJsxAttributeValueExpression', + value: "'y'", + data: { + estree: { + type: 'Program', + body: [ + { + type: 'ExpressionStatement', + expression: {type: 'Literal', value: 'y'} + } + ], + sourceType: 'module', + comments: [] + } + } + } + } + ], + children: [] + }, + {...production, createEvaluater} + ) + ), + '' + ) + } + ) +}) + +test('mdx: expression', async function (t) { + await t.test('should throw on expression by default', async function () { + assert.throws(function () { + toJsxRuntime({type: 'mdxFlowExpression', value: "'a'"}, production) + }, /Cannot handle MDX estrees without `createEvaluater`/) + }) + + await t.test( + 'should support expression w/ `createEvaluater`', + async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + { + type: 'mdxFlowExpression', + value: "'a'", + data: { + estree: { + type: 'Program', + body: [ + { + type: 'ExpressionStatement', + expression: {type: 'Literal', value: 'a'} + } + ], + sourceType: 'module', + comments: [] + } + } + }, + {...production, createEvaluater} + ) + ), + 'a' + ) + } + ) +}) + +test('mdx: ESM', async function (t) { + await t.test('should throw on ESM by default', async function () { + assert.throws(function () { + toJsxRuntime( + {type: 'mdxjsEsm', value: "export const a = 'a'"}, + production + ) + }, /Cannot handle MDX estrees without `createEvaluater`/) + }) + + await t.test('should support ESM w/ `createEvaluater`', async function () { + assert.equal( + renderToStaticMarkup( + toJsxRuntime( + { + type: 'root', + children: [ + { + type: 'mdxjsEsm', + value: "export const a = 'b'", + data: { + estree: { + type: 'Program', + body: [ + { + type: 'ExportNamedDeclaration', + declaration: { + type: 'VariableDeclaration', + declarations: [ + { + type: 'VariableDeclarator', + id: {type: 'Identifier', name: 'a'}, + init: {type: 'Literal', value: 'b'} + } + ], + kind: 'const' + }, + specifiers: [], + source: null + } + ], + sourceType: 'module', + comments: [] + } + } + }, + { + type: 'mdxFlowExpression', + value: 'a', + data: { + estree: { + type: 'Program', + body: [ + { + type: 'ExpressionStatement', + expression: {type: 'Identifier', name: 'a'} + } + ], + sourceType: 'module', + comments: [] + } + } + } + ] + }, + {...production, createEvaluater} + ) + ), + 'b' + ) + }) +}) + +/** + * @type {CreateEvaluater} + */ +function createEvaluater() { + const interpreter = new Sval({sandBox: true}) + + return { + evaluateExpression(expression) { + /** @type {Program} */ + const program = { + type: 'Program', + body: [ + { + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'MemberExpression', + object: {type: 'Identifier', name: 'exports'}, + property: { + type: 'Identifier', + name: '_evaluateExpressionValue' + }, + computed: false, + optional: false + }, + right: expression + } + } + ], + sourceType: 'module' + } + + interpreter.run(program) + const value = /** @type {unknown} */ ( + // type-coverage:ignore-next-line + interpreter.exports._evaluateExpressionValue + ) + // type-coverage:ignore-next-line + interpreter.exports._evaluateExpressionValue = undefined + return value + }, + /** + * @returns {undefined} + */ + evaluateProgram(program) { + visit(program, function (node, key, index, parents) { + // Sval doesn’t support exports yet. + if (node.type === 'ExportNamedDeclaration' && node.declaration) { + const parent = parents[parents.length - 1] + assert(parent) + assert(typeof key === 'string') + assert(typeof index === 'number') + // @ts-expect-error: fine. + parent[key][index] = node.declaration + } + }) + + interpreter.run(program) + } + } +}