Skip to content

Commit

Permalink
Add support for async plugins
Browse files Browse the repository at this point in the history
This commit adds 2 new components that support
turning markdown into react nodes,
asynchronously.
There are different ways to support async things in React.
Component with hooks only run on the client.
Components yielding promises are not supported on the client.
To support different scenarios and the different ways the future
could develop,
these choices are made explicit to users.
Users can choose whether `MarkdownAsync` or `MarkdownHooks` fits
their use case.

Closes GH-680.
Closes GH-682.
  • Loading branch information
wooorm committed Feb 14, 2025
1 parent 78d08de commit 8128a21
Show file tree
Hide file tree
Showing 5 changed files with 274 additions and 29 deletions.
7 changes: 6 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,9 @@
* @typedef {import('./lib/index.js').UrlTransform} UrlTransform
*/

export {Markdown as default, defaultUrlTransform} from './lib/index.js'
export {
MarkdownAsync,
MarkdownHooks,
Markdown as default,
defaultUrlTransform
} from './lib/index.js'
144 changes: 120 additions & 24 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* @import {Element, ElementContent, Nodes, Parents, Root} from 'hast'
* @import {Root as MdastRoot} from 'mdast'
* @import {ComponentProps, ElementType, ReactElement} from 'react'
* @import {Options as RemarkRehypeOptions} from 'remark-rehype'
* @import {BuildVisitor} from 'unist-util-visit'
* @import {PluggableList} from 'unified'
* @import {PluggableList, Processor} from 'unified'
*/

/**
Expand Down Expand Up @@ -95,6 +96,7 @@ import {unreachable} from 'devlop'
import {toJsxRuntime} from 'hast-util-to-jsx-runtime'
import {urlAttributes} from 'html-url-attributes'
import {Fragment, jsx, jsxs} from 'react/jsx-runtime'
import {createElement, use, useEffect, useState} from 'react'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import {unified} from 'unified'
Expand All @@ -108,6 +110,7 @@ const changelog =
const emptyPlugins = []
/** @type {Readonly<RemarkRehypeOptions>} */
const emptyRemarkRehypeOptions = {allowDangerousHtml: true}
const resolved = /** @type {Promise<undefined>} */ (Promise.resolve())
const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i

// Mutable because we `delete` any time it’s used and a message is sent.
Expand Down Expand Up @@ -149,33 +152,108 @@ const deprecations = [
/**
* Component to render markdown.
*
* This is a synchronous component.
* When using async plugins,
* see {@linkcode MarkdownAsync} or {@linkcode MarkdownHooks}.
*
* @param {Readonly<Options>} options
* Props.
* @returns {ReactElement}
* React element.
*/
export function Markdown(options) {
const allowedElements = options.allowedElements
const allowElement = options.allowElement
const children = options.children || ''
const className = options.className
const components = options.components
const disallowedElements = options.disallowedElements
const processor = createProcessor(options)
const file = createFile(options)
return post(processor.runSync(processor.parse(file), file), options)
}

/**
* Component to render markdown with support for async plugins
* through async/await.
*
* Components returning promises is supported on the server.
* For async support on the client,
* see {@linkcode MarkdownHooks}.
*
* @param {Readonly<Options>} options
* Props.
* @returns {Promise<ReactElement>}
* Promise to a React element.
*/
export async function MarkdownAsync(options) {
const processor = createProcessor(options)
const file = createFile(options)
const tree = await processor.run(processor.parse(file), file)
return post(tree, options)
}

/**
* Component to render markdown with support for async plugins through hooks.
*
* Hooks run on the client.
* For async support on the server,
* see {@linkcode MarkdownAsync}.
*
* @param {Readonly<Options>} options
* Props.
* @returns {ReactElement}
* React element.
*/
export function MarkdownHooks(options) {
const processor = createProcessor(options)
const [promise, setPromise] = useState(
/** @type {Promise<Root | undefined>} */ (resolved)
)

useEffect(
/* c8 ignore next 4 -- hooks are client-only. */
function () {
const file = createFile(options)
setPromise(processor.run(processor.parse(file), file))
},
[options.children]
)

const tree = use(promise)

/* c8 ignore next -- hooks are client-only. */
return tree ? post(tree, options) : createElement(Fragment)
}

/**
* Set up the `unified` processor.
*
* @param {Readonly<Options>} options
* Props.
* @returns {Processor<MdastRoot, MdastRoot, Root, undefined, undefined>}
* Result.
*/
function createProcessor(options) {
const rehypePlugins = options.rehypePlugins || emptyPlugins
const remarkPlugins = options.remarkPlugins || emptyPlugins
const remarkRehypeOptions = options.remarkRehypeOptions
? {...options.remarkRehypeOptions, ...emptyRemarkRehypeOptions}
: emptyRemarkRehypeOptions
const skipHtml = options.skipHtml
const unwrapDisallowed = options.unwrapDisallowed
const urlTransform = options.urlTransform || defaultUrlTransform

const processor = unified()
.use(remarkParse)
.use(remarkPlugins)
.use(remarkRehype, remarkRehypeOptions)
.use(rehypePlugins)

return processor
}

/**
* Set up the virtual file.
*
* @param {Readonly<Options>} options
* Props.
* @returns {VFile}
* Result.
*/
function createFile(options) {
const children = options.children || ''
const file = new VFile()

if (typeof children === 'string') {
Expand All @@ -188,11 +266,27 @@ export function Markdown(options) {
)
}

if (allowedElements && disallowedElements) {
unreachable(
'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other'
)
}
return file
}

/**
* Process the result from unified some more.
*
* @param {Nodes} tree
* Tree.
* @param {Readonly<Options>} options
* Props.
* @returns {ReactElement}
* React element.
*/
function post(tree, options) {
const allowedElements = options.allowedElements
const allowElement = options.allowElement
const components = options.components
const disallowedElements = options.disallowedElements
const skipHtml = options.skipHtml
const unwrapDisallowed = options.unwrapDisallowed
const urlTransform = options.urlTransform || defaultUrlTransform

for (const deprecation of deprecations) {
if (Object.hasOwn(options, deprecation.from)) {
Expand All @@ -212,26 +306,28 @@ export function Markdown(options) {
}
}

const mdastTree = processor.parse(file)
/** @type {Nodes} */
let hastTree = processor.runSync(mdastTree, file)
if (allowedElements && disallowedElements) {
unreachable(
'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other'
)
}

// Wrap in `div` if there’s a class name.
if (className) {
hastTree = {
if (options.className) {
tree = {
type: 'element',
tagName: 'div',
properties: {className},
properties: {className: options.className},
// Assume no doctypes.
children: /** @type {Array<ElementContent>} */ (
hastTree.type === 'root' ? hastTree.children : [hastTree]
tree.type === 'root' ? tree.children : [tree]
)
}
}

visit(hastTree, transform)
visit(tree, transform)

return toJsxRuntime(hastTree, {
return toJsxRuntime(tree, {
Fragment,
// @ts-expect-error
// React components are allowed to return numbers,
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
],
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"hast-util-to-jsx-runtime": "^2.0.0",
"html-url-attributes": "^3.0.0",
Expand All @@ -65,12 +66,14 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"c8": "^10.0.0",
"concat-stream": "^2.0.0",
"esbuild": "^0.25.0",
"eslint-plugin-react": "^7.0.0",
"prettier": "^3.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rehype-raw": "^7.0.0",
"rehype-starry-night": "^2.0.0",
"remark-cli": "^12.0.0",
"remark-gfm": "^4.0.0",
"remark-preset-wooorm": "^11.0.0",
Expand Down
51 changes: 50 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ React component to render markdown.
* [Use](#use)
* [API](#api)
* [`Markdown`](#markdown)
* [`MarkdownAsync`](#markdownasync)
* [`MarkdownHooks`](#markdownhooks)
* [`defaultUrlTransform(url)`](#defaulturltransformurl)
* [`AllowElement`](#allowelement)
* [`Components`](#components)
Expand Down Expand Up @@ -166,14 +168,57 @@ createRoot(document.body).render(

## API

This package exports the following identifier:
This package exports the identifiers
[`MarkdownAsync`][api-markdown-async],
[`MarkdownHooks`][api-markdown-hooks],
and
[`defaultUrlTransform`][api-default-url-transform].
The default export is [`Markdown`][api-markdown].

### `Markdown`

Component to render markdown.

This is a synchronous component.
When using async plugins,
see [`MarkdownAsync`][api-markdown-async] or
[`MarkdownHooks`][api-markdown-hooks].

###### Parameters

* `options` ([`Options`][api-options])
— props

###### Returns

React element (`JSX.Element`).

### `MarkdownAsync`

Component to render markdown with support for async plugins
through async/await.

Components returning promises is supported on the server.
For async support on the client,
see [`MarkdownHooks`][api-markdown-hooks].

###### Parameters

* `options` ([`Options`][api-options])
— props

###### Returns

Promise to a React element (`Promise<JSX.Element>`).

### `MarkdownHooks`

Component to render markdown with support for async plugins through hooks.

Hooks run on the client.
For async support on the server,
see [`MarkdownAsync`][api-markdown-async].

###### Parameters

* `options` ([`Options`][api-options])
Expand Down Expand Up @@ -779,6 +824,10 @@ abide by its terms.

[api-markdown]: #markdown

[api-markdown-async]: #markdownasync

[api-markdown-hooks]: #markdownhooks

[api-options]: #options

[api-url-transform]: #urltransform
Expand Down
Loading

0 comments on commit 8128a21

Please sign in to comment.