Skip to content

Commit 6ce120e

Browse files
authored
Add support for async plugins
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. Closes GH-890. Closes GH-891. Reviewed-by: Christian Murphy <[email protected]> Reviewed-by: Remco Haszing <[email protected]>
1 parent 78d08de commit 6ce120e

File tree

5 files changed

+283
-29
lines changed

5 files changed

+283
-29
lines changed

index.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,9 @@
66
* @typedef {import('./lib/index.js').UrlTransform} UrlTransform
77
*/
88

9-
export {Markdown as default, defaultUrlTransform} from './lib/index.js'
9+
export {
10+
MarkdownAsync,
11+
MarkdownHooks,
12+
Markdown as default,
13+
defaultUrlTransform
14+
} from './lib/index.js'

lib/index.js

+130-24
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
/**
22
* @import {Element, ElementContent, Nodes, Parents, Root} from 'hast'
3+
* @import {Root as MdastRoot} from 'mdast'
34
* @import {ComponentProps, ElementType, ReactElement} from 'react'
45
* @import {Options as RemarkRehypeOptions} from 'remark-rehype'
56
* @import {BuildVisitor} from 'unist-util-visit'
6-
* @import {PluggableList} from 'unified'
7+
* @import {PluggableList, Processor} from 'unified'
78
*/
89

910
/**
@@ -95,6 +96,7 @@ import {unreachable} from 'devlop'
9596
import {toJsxRuntime} from 'hast-util-to-jsx-runtime'
9697
import {urlAttributes} from 'html-url-attributes'
9798
import {Fragment, jsx, jsxs} from 'react/jsx-runtime'
99+
import {createElement, useEffect, useState} from 'react'
98100
import remarkParse from 'remark-parse'
99101
import remarkRehype from 'remark-rehype'
100102
import {unified} from 'unified'
@@ -149,33 +151,119 @@ const deprecations = [
149151
/**
150152
* Component to render markdown.
151153
*
154+
* This is a synchronous component.
155+
* When using async plugins,
156+
* see {@linkcode MarkdownAsync} or {@linkcode MarkdownHooks}.
157+
*
152158
* @param {Readonly<Options>} options
153159
* Props.
154160
* @returns {ReactElement}
155161
* React element.
156162
*/
157163
export function Markdown(options) {
158-
const allowedElements = options.allowedElements
159-
const allowElement = options.allowElement
160-
const children = options.children || ''
161-
const className = options.className
162-
const components = options.components
163-
const disallowedElements = options.disallowedElements
164+
const processor = createProcessor(options)
165+
const file = createFile(options)
166+
return post(processor.runSync(processor.parse(file), file), options)
167+
}
168+
169+
/**
170+
* Component to render markdown with support for async plugins
171+
* through async/await.
172+
*
173+
* Components returning promises are supported on the server.
174+
* For async support on the client,
175+
* see {@linkcode MarkdownHooks}.
176+
*
177+
* @param {Readonly<Options>} options
178+
* Props.
179+
* @returns {Promise<ReactElement>}
180+
* Promise to a React element.
181+
*/
182+
export async function MarkdownAsync(options) {
183+
const processor = createProcessor(options)
184+
const file = createFile(options)
185+
const tree = await processor.run(processor.parse(file), file)
186+
return post(tree, options)
187+
}
188+
189+
/**
190+
* Component to render markdown with support for async plugins through hooks.
191+
*
192+
* This uses `useEffect` and `useState` hooks.
193+
* Hooks run on the client and do not immediately render something.
194+
* For async support on the server,
195+
* see {@linkcode MarkdownAsync}.
196+
*
197+
* @param {Readonly<Options>} options
198+
* Props.
199+
* @returns {ReactElement}
200+
* React element.
201+
*/
202+
export function MarkdownHooks(options) {
203+
const processor = createProcessor(options)
204+
const [error, setError] = useState(
205+
/** @type {Error | undefined} */ (undefined)
206+
)
207+
const [tree, setTree] = useState(/** @type {Root | undefined} */ (undefined))
208+
209+
useEffect(
210+
/* c8 ignore next 7 -- hooks are client-only. */
211+
function () {
212+
const file = createFile(options)
213+
processor.run(processor.parse(file), file, function (error, tree) {
214+
setError(error)
215+
setTree(tree)
216+
})
217+
},
218+
[
219+
options.children,
220+
options.rehypePlugins,
221+
options.remarkPlugins,
222+
options.remarkRehypeOptions
223+
]
224+
)
225+
226+
/* c8 ignore next -- hooks are client-only. */
227+
if (error) throw error
228+
229+
/* c8 ignore next -- hooks are client-only. */
230+
return tree ? post(tree, options) : createElement(Fragment)
231+
}
232+
233+
/**
234+
* Set up the `unified` processor.
235+
*
236+
* @param {Readonly<Options>} options
237+
* Props.
238+
* @returns {Processor<MdastRoot, MdastRoot, Root, undefined, undefined>}
239+
* Result.
240+
*/
241+
function createProcessor(options) {
164242
const rehypePlugins = options.rehypePlugins || emptyPlugins
165243
const remarkPlugins = options.remarkPlugins || emptyPlugins
166244
const remarkRehypeOptions = options.remarkRehypeOptions
167245
? {...options.remarkRehypeOptions, ...emptyRemarkRehypeOptions}
168246
: emptyRemarkRehypeOptions
169-
const skipHtml = options.skipHtml
170-
const unwrapDisallowed = options.unwrapDisallowed
171-
const urlTransform = options.urlTransform || defaultUrlTransform
172247

173248
const processor = unified()
174249
.use(remarkParse)
175250
.use(remarkPlugins)
176251
.use(remarkRehype, remarkRehypeOptions)
177252
.use(rehypePlugins)
178253

254+
return processor
255+
}
256+
257+
/**
258+
* Set up the virtual file.
259+
*
260+
* @param {Readonly<Options>} options
261+
* Props.
262+
* @returns {VFile}
263+
* Result.
264+
*/
265+
function createFile(options) {
266+
const children = options.children || ''
179267
const file = new VFile()
180268

181269
if (typeof children === 'string') {
@@ -188,11 +276,27 @@ export function Markdown(options) {
188276
)
189277
}
190278

191-
if (allowedElements && disallowedElements) {
192-
unreachable(
193-
'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other'
194-
)
195-
}
279+
return file
280+
}
281+
282+
/**
283+
* Process the result from unified some more.
284+
*
285+
* @param {Nodes} tree
286+
* Tree.
287+
* @param {Readonly<Options>} options
288+
* Props.
289+
* @returns {ReactElement}
290+
* React element.
291+
*/
292+
function post(tree, options) {
293+
const allowedElements = options.allowedElements
294+
const allowElement = options.allowElement
295+
const components = options.components
296+
const disallowedElements = options.disallowedElements
297+
const skipHtml = options.skipHtml
298+
const unwrapDisallowed = options.unwrapDisallowed
299+
const urlTransform = options.urlTransform || defaultUrlTransform
196300

197301
for (const deprecation of deprecations) {
198302
if (Object.hasOwn(options, deprecation.from)) {
@@ -212,26 +316,28 @@ export function Markdown(options) {
212316
}
213317
}
214318

215-
const mdastTree = processor.parse(file)
216-
/** @type {Nodes} */
217-
let hastTree = processor.runSync(mdastTree, file)
319+
if (allowedElements && disallowedElements) {
320+
unreachable(
321+
'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other'
322+
)
323+
}
218324

219325
// Wrap in `div` if there’s a class name.
220-
if (className) {
221-
hastTree = {
326+
if (options.className) {
327+
tree = {
222328
type: 'element',
223329
tagName: 'div',
224-
properties: {className},
330+
properties: {className: options.className},
225331
// Assume no doctypes.
226332
children: /** @type {Array<ElementContent>} */ (
227-
hastTree.type === 'root' ? hastTree.children : [hastTree]
333+
tree.type === 'root' ? tree.children : [tree]
228334
)
229335
}
230336
}
231337

232-
visit(hastTree, transform)
338+
visit(tree, transform)
233339

234-
return toJsxRuntime(hastTree, {
340+
return toJsxRuntime(tree, {
235341
Fragment,
236342
// @ts-expect-error
237343
// React components are allowed to return numbers,

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
],
5050
"dependencies": {
5151
"@types/hast": "^3.0.0",
52+
"@types/mdast": "^4.0.0",
5253
"devlop": "^1.0.0",
5354
"hast-util-to-jsx-runtime": "^2.0.0",
5455
"html-url-attributes": "^3.0.0",
@@ -65,12 +66,14 @@
6566
"@types/react": "^19.0.0",
6667
"@types/react-dom": "^19.0.0",
6768
"c8": "^10.0.0",
69+
"concat-stream": "^2.0.0",
6870
"esbuild": "^0.25.0",
6971
"eslint-plugin-react": "^7.0.0",
7072
"prettier": "^3.0.0",
7173
"react": "^19.0.0",
7274
"react-dom": "^19.0.0",
7375
"rehype-raw": "^7.0.0",
76+
"rehype-starry-night": "^2.0.0",
7477
"remark-cli": "^12.0.0",
7578
"remark-gfm": "^4.0.0",
7679
"remark-preset-wooorm": "^11.0.0",

readme.md

+51-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ React component to render markdown.
3232
* [Use](#use)
3333
* [API](#api)
3434
* [`Markdown`](#markdown)
35+
* [`MarkdownAsync`](#markdownasync)
36+
* [`MarkdownHooks`](#markdownhooks)
3537
* [`defaultUrlTransform(url)`](#defaulturltransformurl)
3638
* [`AllowElement`](#allowelement)
3739
* [`Components`](#components)
@@ -166,14 +168,58 @@ createRoot(document.body).render(
166168

167169
## API
168170

169-
This package exports the following identifier:
171+
This package exports the identifiers
172+
[`MarkdownAsync`][api-markdown-async],
173+
[`MarkdownHooks`][api-markdown-hooks],
174+
and
170175
[`defaultUrlTransform`][api-default-url-transform].
171176
The default export is [`Markdown`][api-markdown].
172177

173178
### `Markdown`
174179

175180
Component to render markdown.
176181

182+
This is a synchronous component.
183+
When using async plugins,
184+
see [`MarkdownAsync`][api-markdown-async] or
185+
[`MarkdownHooks`][api-markdown-hooks].
186+
187+
###### Parameters
188+
189+
* `options` ([`Options`][api-options])
190+
— props
191+
192+
###### Returns
193+
194+
React element (`JSX.Element`).
195+
196+
### `MarkdownAsync`
197+
198+
Component to render markdown with support for async plugins
199+
through async/await.
200+
201+
Components returning promises are supported on the server.
202+
For async support on the client,
203+
see [`MarkdownHooks`][api-markdown-hooks].
204+
205+
###### Parameters
206+
207+
* `options` ([`Options`][api-options])
208+
— props
209+
210+
###### Returns
211+
212+
Promise to a React element (`Promise<JSX.Element>`).
213+
214+
### `MarkdownHooks`
215+
216+
Component to render markdown with support for async plugins through hooks.
217+
218+
This uses `useEffect` and `useState` hooks.
219+
Hooks run on the client and do not immediately render something.
220+
For async support on the server,
221+
see [`MarkdownAsync`][api-markdown-async].
222+
177223
###### Parameters
178224

179225
* `options` ([`Options`][api-options])
@@ -779,6 +825,10 @@ abide by its terms.
779825

780826
[api-markdown]: #markdown
781827

828+
[api-markdown-async]: #markdownasync
829+
830+
[api-markdown-hooks]: #markdownhooks
831+
782832
[api-options]: #options
783833

784834
[api-url-transform]: #urltransform

0 commit comments

Comments
 (0)