1
1
/**
2
2
* @import {Element, ElementContent, Nodes, Parents, Root} from 'hast'
3
+ * @import {Root as MdastRoot} from 'mdast'
3
4
* @import {ComponentProps, ElementType, ReactElement} from 'react'
4
5
* @import {Options as RemarkRehypeOptions} from 'remark-rehype'
5
6
* @import {BuildVisitor} from 'unist-util-visit'
6
- * @import {PluggableList} from 'unified'
7
+ * @import {PluggableList, Processor } from 'unified'
7
8
*/
8
9
9
10
/**
@@ -95,6 +96,7 @@ import {unreachable} from 'devlop'
95
96
import { toJsxRuntime } from 'hast-util-to-jsx-runtime'
96
97
import { urlAttributes } from 'html-url-attributes'
97
98
import { Fragment , jsx , jsxs } from 'react/jsx-runtime'
99
+ import { createElement , useEffect , useState } from 'react'
98
100
import remarkParse from 'remark-parse'
99
101
import remarkRehype from 'remark-rehype'
100
102
import { unified } from 'unified'
@@ -149,33 +151,119 @@ const deprecations = [
149
151
/**
150
152
* Component to render markdown.
151
153
*
154
+ * This is a synchronous component.
155
+ * When using async plugins,
156
+ * see {@linkcode MarkdownAsync} or {@linkcode MarkdownHooks}.
157
+ *
152
158
* @param {Readonly<Options> } options
153
159
* Props.
154
160
* @returns {ReactElement }
155
161
* React element.
156
162
*/
157
163
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 ) {
164
242
const rehypePlugins = options . rehypePlugins || emptyPlugins
165
243
const remarkPlugins = options . remarkPlugins || emptyPlugins
166
244
const remarkRehypeOptions = options . remarkRehypeOptions
167
245
? { ...options . remarkRehypeOptions , ...emptyRemarkRehypeOptions }
168
246
: emptyRemarkRehypeOptions
169
- const skipHtml = options . skipHtml
170
- const unwrapDisallowed = options . unwrapDisallowed
171
- const urlTransform = options . urlTransform || defaultUrlTransform
172
247
173
248
const processor = unified ( )
174
249
. use ( remarkParse )
175
250
. use ( remarkPlugins )
176
251
. use ( remarkRehype , remarkRehypeOptions )
177
252
. use ( rehypePlugins )
178
253
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 || ''
179
267
const file = new VFile ( )
180
268
181
269
if ( typeof children === 'string' ) {
@@ -188,11 +276,27 @@ export function Markdown(options) {
188
276
)
189
277
}
190
278
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
196
300
197
301
for ( const deprecation of deprecations ) {
198
302
if ( Object . hasOwn ( options , deprecation . from ) ) {
@@ -212,26 +316,28 @@ export function Markdown(options) {
212
316
}
213
317
}
214
318
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
+ }
218
324
219
325
// Wrap in `div` if there’s a class name.
220
- if ( className ) {
221
- hastTree = {
326
+ if ( options . className ) {
327
+ tree = {
222
328
type : 'element' ,
223
329
tagName : 'div' ,
224
- properties : { className} ,
330
+ properties : { className : options . className } ,
225
331
// Assume no doctypes.
226
332
children : /** @type {Array<ElementContent> } */ (
227
- hastTree . type === 'root' ? hastTree . children : [ hastTree ]
333
+ tree . type === 'root' ? tree . children : [ tree ]
228
334
)
229
335
}
230
336
}
231
337
232
- visit ( hastTree , transform )
338
+ visit ( tree , transform )
233
339
234
- return toJsxRuntime ( hastTree , {
340
+ return toJsxRuntime ( tree , {
235
341
Fragment,
236
342
// @ts -expect-error
237
343
// React components are allowed to return numbers,
0 commit comments