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)
+ }
+ }
+}