import React, { cloneElement } from 'react' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { JSXElement } from 'estree-jsx' import Evaluate from 'eval-estree-expression' import type { Node, Parent, Root, RootContent } from 'mdast' import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx' import { Fragment, ReactNode } from 'react' import { DynamicEsmComponent } from './dynamic-esm-component.js' import { extractComponentInfo, parseEsmImports } from './esm-parser.js' import { htmlToMdxAst } from './html/html-to-mdx-ast.js' import { validHtmlElements, nativeTags } from './html/valid-html-elements.js' export type MyRootContent = RootContent | Root declare module 'mdast' { export interface HProperties { id?: string } export interface Data { hProperties?: HProperties } } export type RenderNode = ( node: MyRootContent, transform: (node: MyRootContent) => ReactNode, ) => ReactNode | undefined export interface SafeMdxError { message: string line?: number schemaPath?: string } export type ComponentPropsSchema = Record export type CreateElementFunction = ( type: any, props?: any, ...children: ReactNode[] ) => ReactNode export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({ components, markdown = '', mdast = null as any, renderNode, componentPropsSchema, createElement, allowClientEsmImports = false, addMarkdownLineNumbers = false, }: { components?: ComponentsMap markdown?: string mdast: MyRootContent renderNode?: RenderNode componentPropsSchema?: ComponentPropsSchema createElement?: CreateElementFunction allowClientEsmImports?: boolean addMarkdownLineNumbers?: boolean }) { const visitor = new MdastToJsx({ markdown, mdast, components, renderNode, componentPropsSchema, createElement, allowClientEsmImports, addMarkdownLineNumbers, }) const result = visitor.run() return result }) export class MdastToJsx { mdast: MyRootContent str: string jsxStr: string = '' c: ComponentsMap errors: SafeMdxError[] = [] renderNode?: RenderNode componentPropsSchema?: ComponentPropsSchema createElement: CreateElementFunction esmImports: Map = new Map() allowClientEsmImports: boolean addMarkdownLineNumbers: boolean constructor({ markdown: code = '', mdast, components = {} as ComponentsMap, renderNode, componentPropsSchema, createElement = React.createElement, allowClientEsmImports = false, addMarkdownLineNumbers = false, }: { markdown?: string mdast: MyRootContent components?: ComponentsMap renderNode?: ( node: MyRootContent, transform: (node: MyRootContent) => ReactNode, ) => ReactNode | undefined componentPropsSchema?: ComponentPropsSchema createElement?: CreateElementFunction allowClientEsmImports?: boolean addMarkdownLineNumbers?: boolean }) { this.str = code this.mdast = mdast this.renderNode = renderNode this.componentPropsSchema = componentPropsSchema this.createElement = createElement this.allowClientEsmImports = allowClientEsmImports this.addMarkdownLineNumbers = addMarkdownLineNumbers this.c = { ...Object.fromEntries( nativeTags.map((tag) => { return [tag, tag] }), ), ...components, } } addLineNumberToProps( props: Record | undefined, node: MyRootContent, ): Record { if (!this.addMarkdownLineNumbers) { return props || {} } const lineNumber = node.position?.start?.line if (lineNumber) { return { ...props, 'data-markdown-line': lineNumber, } } return props || {} } validateComponentProps( componentName: string, props: Record, line?: number, ): void { if ( !this.componentPropsSchema || !this.componentPropsSchema[componentName] ) { return } const schema = this.componentPropsSchema[componentName] let result = schema['~standard'].validate(props) if (result instanceof Promise) { // Ignore async validation errors as requested return } else { if (result.issues) { result.issues.forEach((issue) => { const propPath = issue.path?.join('.') || 'unknown' this.errors.push({ message: `Invalid props for component "${componentName}" at "${propPath}": ${issue.message}`, line, schemaPath: issue.path?.join('.'), }) }) } } } mapMdastChildren(node: any) { const res = node.children ?.flatMap((child) => this.mdastTransformer(child, node.type)) .filter(Boolean) if (Array.isArray(res)) { if (!res.length) { return null } else if (res.length === 1) { return res[0] } else { return res.map((x, i) => React.isValidElement(x) ? cloneElement(x, { key: i }) : x, ) } } return res || null } mapJsxChildren(node: any) { const res = node.children ?.flatMap((child, i) => this.jsxTransformer(child)) .filter(Boolean) if (Array.isArray(res)) { if (!res.length) { return null } else if (res.length === 1) { return res[0] } else { return res.map((x, i) => React.isValidElement(x) ? cloneElement(x, { key: i }) : x, ) } } return res || null } jsxTransformer(node: MyRootContent): ReactNode { if (!node) { return [] } switch (node.type) { case 'mdxJsxTextElement': case 'mdxJsxFlowElement': { if (!node.name) { return [] } // Check if this is an ESM imported component (only if allowed) const esmImportInfo = this.allowClientEsmImports ? this.esmImports.get(node.name) : null let Component if (esmImportInfo) { // Handle ESM imported component const { importUrl, componentName } = extractComponentInfo(esmImportInfo) Component = DynamicEsmComponent let attrsList = this.getJsxAttrs(node, (err) => { this.errors.push(err) }) let attrs = Object.fromEntries(attrsList) return this.createElement( Component, this.addLineNumberToProps( { ...attrs, importUrl, componentName }, node, ), this.mapJsxChildren(node), ) } else { Component = accessWithDot(this.c, node.name) if (!Component) { this.errors.push({ message: `Unsupported jsx component ${node.name}`, line: node.position?.start?.line, }) return null } } let attrsList = this.getJsxAttrs(node, (err) => { this.errors.push(err) }) let attrs = Object.fromEntries(attrsList) // Validate component props with schema if available this.validateComponentProps( node.name, attrs, node.position?.start?.line, ) return this.createElement( Component, this.addLineNumberToProps(attrs, node), this.mapJsxChildren(node), ) } default: { return this.mdastTransformer(node, 'mdxJsxFlowElement') } } } transformJsxElement( jsxElement: JSXElement, onError?: (err: SafeMdxError) => void, line?: number, ): ReactNode { try { // Handle JSX opening element if (jsxElement.openingElement) { const tagName = jsxElement.openingElement.name?.type === 'JSXIdentifier' ? jsxElement.openingElement.name.name : null if (!tagName) { onError?.({ message: 'JSX element missing component name', line: line, }) return null } // Check if this is an ESM imported component (only if allowed) const esmImportInfo = this.allowClientEsmImports ? this.esmImports.get(tagName) : null let Component if (esmImportInfo) { // Handle ESM imported component const { importUrl, componentName } = extractComponentInfo(esmImportInfo) Component = DynamicEsmComponent } else { // Get the component from the regular component map Component = accessWithDot(this.c, tagName) if (!Component) { onError?.({ message: `Unsupported jsx component ${tagName} in attribute`, line: line, }) return null } } // Extract attributes const props: Record = {} if (jsxElement.openingElement.attributes) { for (const attr of jsxElement.openingElement.attributes) { if ( attr.type === 'JSXAttribute' && attr.name?.type === 'JSXIdentifier' && attr.name.name ) { if (attr.value) { if (attr.value.type === 'Literal') { props[attr.name.name] = attr.value.value } else if ( attr.value.type === 'JSXExpressionContainer' ) { if ( attr.value.expression.type === 'Literal' ) { props[attr.name.name] = attr.value.expression.value } } } else { props[attr.name.name] = true } } } } // Extract children const children: ReactNode[] = [] if (jsxElement.children) { for (const child of jsxElement.children) { if (child.type === 'JSXText') { children.push(child.value) } else if (child.type === 'JSXElement') { const childElement = this.transformJsxElement(child) if (childElement) { children.push(childElement) } } } } // Handle ESM imported components by adding required props if (esmImportInfo) { const { importUrl, componentName } = extractComponentInfo(esmImportInfo) return this.createElement( Component, { ...props, importUrl, componentName }, ...children, ) } else { return this.createElement(Component, props, ...children) } } } catch (error) { // Return null if transformation fails onError?.({ message: `Failed to transform JSX element: ${ error instanceof Error ? error.message : 'Unknown error' }`, line: line, }) return null } return null } getJsxAttrs( node: MdxJsxFlowElement | MdxJsxTextElement, onError: (err: SafeMdxError) => void = console.error, ) { let attrsList: [string, any][] = [] for (const attr of node.attributes) { if (attr.type === 'mdxJsxExpressionAttribute') { // Handle spread expressions like {...{key: '1'}} if (attr.data?.estree) { try { const program = attr.data.estree if ( program.body?.length > 0 && program.body[0].type === 'ExpressionStatement' ) { const expression = program.body[0].expression try { const result = Evaluate.evaluate.sync(expression) // Handle spread syntax - merge the evaluated object if ( typeof result === 'object' && result != null ) { const entries = Object.entries(result) attrsList.push(...entries) } } catch (error) { onError({ message: `Failed to evaluate expression attribute: ${attr.value .replace(/\n+/g, ' ') .replace(/ +/g, ' ')}. ${ error instanceof Error ? error.message : String(error) }`, line: attr.position?.start?.line, }) } } } catch (error) { onError({ message: `Failed to evaluate expression attribute: ${attr.value .replace(/\n+/g, ' ') .replace(/ +/g, ' ')}. ${ error instanceof Error ? error.message : String(error) }`, line: attr.position?.start?.line, }) } } else { onError({ message: `Expressions in jsx props are not supported (${attr.value .replace(/\n+/g, ' ') .replace(/ +/g, ' ')})`, line: attr.position?.start?.line, }) } continue } if (attr.type !== 'mdxJsxAttribute') { onError({ message: `non mdxJsxAttribute attribute is not supported: ${attr}`, line: node.position?.start?.line, }) continue } const v = attr.value if (typeof v === 'string' || typeof v === 'number') { attrsList.push([attr.name, v]) continue } if (v === null) { attrsList.push([attr.name, true]) continue } if (v?.type === 'mdxJsxAttributeValueExpression') { // Manual parsing fallback for simple values if (v.value === 'true') { attrsList.push([attr.name, true]) continue } if (v.value === 'false') { attrsList.push([attr.name, false]) continue } if (v.value === 'null') { attrsList.push([attr.name, null]) continue } if (v.value === 'undefined') { attrsList.push([attr.name, undefined]) continue } if (v.data?.estree) { try { // Extract the expression from the Program body const program = v.data.estree if ( program.body?.length > 0 && program.body[0].type === 'ExpressionStatement' ) { const expression = program.body[0].expression // Check if this is a JSX element if (expression.type === 'JSXElement') { // Transform JSX element to React element const jsxElement = this.transformJsxElement( expression, onError, attr.position?.start?.line, ) if (jsxElement) { attrsList.push([attr.name, jsxElement]) continue } } try { // Evaluate the expression synchronously const result = Evaluate.evaluate.sync(expression) attrsList.push([attr.name, result]) continue } catch (error) { onError({ message: `Failed to evaluate expression attribute: ${ attr.name }={${v.value}}. ${ error instanceof Error ? error.message : String(error) }`, line: attr.position?.start?.line, }) } } } catch (error) { // Fall back to the original manual parsing for backwards compatibility } } onError({ message: `Expressions in jsx prop not evaluated: (${attr.name}={${v.value}})`, line: attr.position?.start?.line, }) } } return attrsList } run() { const res = this.mdastTransformer(this.mdast, 'root') as ReactNode if (Array.isArray(res) && res.length === 1) { return res[0] } return res } mdastTransformer(node: MyRootContent, parentType: string): ReactNode { if (!node) { return [] } // Check for custom transformer first, giving it higher priority if (this.renderNode) { const customResult = this.renderNode( node, (n: MyRootContent) => this.mdastTransformer(n, node.type), ) if (customResult !== undefined) { return customResult } } switch (node.type) { case 'mdxjsEsm': { // Parse ESM imports and merge into our imports map (only if allowed) if (this.allowClientEsmImports) { const parsedImports = parseEsmImports(node, (err) => this.errors.push(err), ) parsedImports.forEach((value, key) => { this.esmImports.set(key, value) }) } return [] } case 'mdxJsxTextElement': case 'mdxJsxFlowElement': { const start = node.position?.start?.offset const end = node.position?.end?.offset const text = this.str.slice(start, end) try { this.jsxStr = text const result = this.jsxTransformer(node) if (Array.isArray(result)) { console.log(`Unexpected array result`) } else if (result) { return result } } finally { this.jsxStr = '' } return [] } case 'mdxFlowExpression': case 'mdxTextExpression': { if (!node.value) { return [] } // Check if we have an estree AST if (node.data?.estree) { try { // Extract the expression from the Program body const program = node.data.estree if ( program.body?.length > 0 && program.body[0].type === 'ExpressionStatement' ) { const expression = program.body[0].expression try { // Evaluate the expression synchronously const result = Evaluate.evaluate.sync(expression) return result } catch (error) { this.errors.push({ message: `Failed to evaluate expression: ${ node.value }. ${ error instanceof Error ? error.message : String(error) }`, line: node.position?.start?.line, }) } } } catch (error) { this.errors.push({ message: `Failed to evaluate expression: ${ node.value }. ${ error instanceof Error ? error.message : String(error) }`, line: node.position?.start?.line, }) } } return [] } case 'yaml': { if (!node.value) { return [] } return [] } case 'heading': { const level = node.depth const Tag = this.c[`h${level}`] ?? `h${level}` return this.createElement( Tag, this.addLineNumberToProps(node.data?.hProperties, node), this.mapMdastChildren(node), ) } case 'paragraph': { return this.createElement( this.c.p, this.addLineNumberToProps(node.data?.hProperties, node), this.mapMdastChildren(node), ) } case 'blockquote': { return this.createElement( this.c.blockquote, this.addLineNumberToProps(node.data?.hProperties, node), this.mapMdastChildren(node), ) } case 'thematicBreak': { return this.createElement( this.c.hr, this.addLineNumberToProps(node.data?.hProperties, node), ) } case 'code': { if (!node.value) { return [] } const language = node.lang || '' const code = node.value const codeBlock = (className?: string) => this.createElement( this.c.pre, this.addLineNumberToProps(node.data?.hProperties, node), this.createElement(this.c.code, { className }, code), ) if (language) { return codeBlock(`language-${language}`) } return codeBlock() } case 'list': { if (node.ordered) { return this.createElement( this.c.ol, this.addLineNumberToProps( { start: node.start!, ...node.data?.hProperties }, node, ), this.mapMdastChildren(node), ) } return this.createElement( this.c.ul, this.addLineNumberToProps(node.data?.hProperties, node), this.mapMdastChildren(node), ) } case 'listItem': { // https://github.com/syntax-tree/mdast-util-gfm-task-list-item#syntax-tree if (node?.checked != null) { return this.createElement( this.c.li, this.addLineNumberToProps( { 'data-checked': node.checked, ...node.data?.hProperties, }, node, ), this.mapMdastChildren(node), ) } return this.createElement( this.c.li, this.addLineNumberToProps(node.data?.hProperties, node), this.mapMdastChildren(node), ) } case 'text': { if (!node.value) { return [] } if (node.data?.hProperties) { return this.createElement( this.c.span, this.addLineNumberToProps(node.data.hProperties, node), node.value, ) } return node.value } case 'image': { const src = node.url || '' const alt = node.alt || '' const title = node.title || '' return this.createElement( this.c.img, this.addLineNumberToProps( { src, alt, title, ...node.data?.hProperties, }, node, ), ) } case 'link': { const href = node.url || '' const title = node.title || '' return this.createElement( this.c.a, this.addLineNumberToProps( { href, title, ...node.data?.hProperties }, node, ), this.mapMdastChildren(node), ) } case 'strong': { return this.createElement( this.c.strong, this.addLineNumberToProps(node.data?.hProperties, node), this.mapMdastChildren(node), ) } case 'emphasis': { return this.createElement( this.c.em, this.addLineNumberToProps(node.data?.hProperties, node), this.mapMdastChildren(node), ) } case 'delete': { return this.createElement( this.c.del, this.addLineNumberToProps(node.data?.hProperties, node), this.mapMdastChildren(node), ) } case 'inlineCode': { if (!node.value) { return [] } return this.createElement( this.c.code, this.addLineNumberToProps(node.data?.hProperties, node), node.value, ) } case 'break': { return this.createElement( this.c.br, this.addLineNumberToProps(node.data?.hProperties, node), ) } case 'root': { if (node.data?.hProperties) { return this.createElement( this.c.div, this.addLineNumberToProps(node.data.hProperties, node), this.mapMdastChildren(node), ) } return this.createElement( Fragment, null, this.mapMdastChildren(node), ) } case 'table': { const [head, ...body] = React.Children.toArray( this.mapMdastChildren(node), ) return this.createElement( this.c.table, this.addLineNumberToProps(node.data?.hProperties, node), head && this.createElement(this.c.thead, null, head), !!body?.length && this.createElement(this.c.tbody, null, body), ) } case 'tableRow': { return this.createElement( this.c.tr, this.addLineNumberToProps( { className: '', ...node.data?.hProperties }, node, ), this.mapMdastChildren(node), ) } case 'tableCell': { let content = this.mapMdastChildren(node) return this.createElement( this.c.td, this.addLineNumberToProps( { className: '', ...node.data?.hProperties }, node, ), content, ) } case 'definition': { return [] } case 'linkReference': { let href = '' let title = '' mdastBfs(this.mdast, (child: any) => { if ( child.type === 'definition' && child.identifier === node.identifier ) { href = child.url || '' title = child.title || '' } }) return this.createElement( this.c.a, this.addLineNumberToProps( { href, title, ...node.data?.hProperties }, node, ), this.mapMdastChildren(node), ) } case 'footnoteReference': { return [] } case 'footnoteDefinition': { return [] } case 'html': { const start = node.position?.start?.offset const end = node.position?.end?.offset const text = this.str.slice(start, end) if (!text) { return [] } // Parse HTML to MDX AST using the new approach - always returns an array const mdxAst = htmlToMdxAst({ html: text, parentType: parentType || 'root', convertTagName: ({ tagName }) => { const lowerTag = tagName.toLowerCase() // Only keep valid HTML elements if (validHtmlElements.has(lowerTag)) { return lowerTag } // Return empty string for non-HTML elements return '' } }) // Process the MDX AST nodes return mdxAst.map(child => this.mdastTransformer(child, 'html')) } case 'imageReference': { return [] } default: { mdastBfs(node, (node) => { delete node.position }) throw new Error( `cannot convert node` + JSON.stringify(node, null, 2), ) return [] } } } } function isTruthy(val: T | undefined | null | false): val is T { return Boolean(val) } function accessWithDot(obj, path: string) { return path .split('.') .map((x) => x.trim()) .filter(Boolean) .reduce((o, i) => o[i], obj) } export function mdastBfs( node: Parent | Node, cb?: (node: Node | Parent) => any, ) { const queue = [node] const result: any[] = [] while (queue.length) { const node = queue.shift() let r = cb && node ? cb(node) : node if (Array.isArray(r)) { queue.push(...r) } else if (r) { result.push(r) } if (node && 'children' in node && node.children) { queue.push(...(node.children as any)) } } return result } function safeJsonParse(str: string) { try { return JSON.parse(str) } catch (err) { return null } } type ComponentsMap = { [k in (typeof nativeTags)[number]]?: any } & { [key: string]: any }