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. ClosesGH-680. ClosesGH-682. ClosesGH-890. ClosesGH-891. Reviewed-by: Christian Murphy <christian.murphy.42@gmail.com> Reviewed-by: Remco Haszing <remcohaszing@gmail.com>
1 parent 78d08de commit 6ce120e

5 files changed

Lines changed: 283 additions & 29 deletions

File tree

‎index.js‎

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

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

‎lib/index.js‎

Lines changed: 130 additions & 24 deletions
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
importremarkParsefrom'remark-parse'
99101
importremarkRehypefrom'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
exportfunctionMarkdown(options){
158-
constallowedElements=options.allowedElements
159-
constallowElement=options.allowElement
160-
constchildren=options.children||''
161-
constclassName=options.className
162-
constcomponents=options.components
163-
constdisallowedElements=options.disallowedElements
164+
constprocessor=createProcessor(options)
165+
constfile=createFile(options)
166+
returnpost(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+
exportasyncfunctionMarkdownAsync(options){
183+
constprocessor=createProcessor(options)
184+
constfile=createFile(options)
185+
consttree=awaitprocessor.run(processor.parse(file),file)
186+
returnpost(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+
exportfunctionMarkdownHooks(options){
203+
constprocessor=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+
constfile=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)throwerror
228+
229+
/* c8 ignore next -- hooks are client-only. */
230+
returntree ? 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+
functioncreateProcessor(options){
164242
constrehypePlugins=options.rehypePlugins||emptyPlugins
165243
constremarkPlugins=options.remarkPlugins||emptyPlugins
166244
constremarkRehypeOptions=options.remarkRehypeOptions
167245
? {...options.remarkRehypeOptions, ...emptyRemarkRehypeOptions}
168246
: emptyRemarkRehypeOptions
169-
constskipHtml=options.skipHtml
170-
constunwrapDisallowed=options.unwrapDisallowed
171-
consturlTransform=options.urlTransform||defaultUrlTransform
172247

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

254+
returnprocessor
255+
}
256+
257+
/**
258+
* Set up the virtual file.
259+
*
260+
* @param {Readonly<Options>} options
261+
* Props.
262+
* @returns {VFile}
263+
* Result.
264+
*/
265+
functioncreateFile(options){
266+
constchildren=options.children||''
179267
constfile=newVFile()
180268

181269
if(typeofchildren==='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+
returnfile
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+
functionpost(tree,options){
293+
constallowedElements=options.allowedElements
294+
constallowElement=options.allowElement
295+
constcomponents=options.components
296+
constdisallowedElements=options.disallowedElements
297+
constskipHtml=options.skipHtml
298+
constunwrapDisallowed=options.unwrapDisallowed
299+
consturlTransform=options.urlTransform||defaultUrlTransform
196300

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

215-
constmdastTree=processor.parse(file)
216-
/** @type {Nodes} */
217-
lethastTree=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-
returntoJsxRuntime(hastTree,{
340+
returntoJsxRuntime(tree,{
235341
Fragment,
236342
// @ts-expect-error
237343
// React components are allowed to return numbers,

‎package.json‎

Lines changed: 3 additions & 0 deletions
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‎

Lines changed: 51 additions & 1 deletion
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)