-
-
Notifications
You must be signed in to change notification settings - Fork 906
Add support for async plugins #890
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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' | ||
*/ | ||
|
||
/** | ||
|
@@ -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, useEffect, useState} from 'react' | ||
import remarkParse from 'remark-parse' | ||
import remarkRehype from 'remark-rehype' | ||
import {unified} from 'unified' | ||
|
@@ -149,33 +151,119 @@ 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 are 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. | ||
* | ||
* This uses `useEffect` and `useState` hooks. | ||
* Hooks run on the client and do not immediately render something. | ||
* 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 [error, setError] = useState( | ||
/** @type {Error | undefined} */ (undefined) | ||
) | ||
const [tree, setTree] = useState(/** @type {Root | undefined} */ (undefined)) | ||
|
||
useEffect( | ||
/* c8 ignore next 7 -- hooks are client-only. */ | ||
function () { | ||
const file = createFile(options) | ||
processor.run(processor.parse(file), file, function (error, tree) { | ||
setError(error) | ||
setTree(tree) | ||
}) | ||
}, | ||
[ | ||
options.children, | ||
options.rehypePlugins, | ||
options.remarkPlugins, | ||
options.remarkRehypeOptions | ||
] | ||
) | ||
|
||
/* c8 ignore next -- hooks are client-only. */ | ||
if (error) throw error | ||
|
||
/* c8 ignore next -- hooks are client-only. */ | ||
return tree ? post(tree, options) : createElement(Fragment) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You don’t have to return an empty fragment here. It’s sufficient to return undefined. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That would make the types for only this function different from the other ones There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The correct return type for a React component is This has gotten more relevant with your latest changes, as we now no longer use |
||
} | ||
|
||
/** | ||
* 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') { | ||
|
@@ -188,11 +276,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)) { | ||
|
@@ -212,26 +316,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, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The processor is only needed inside the
useEffect
. We can move it there as well. That means the processor isn’t created on every render and doesn’t need to be in theuseEffect
dependency array.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn’t
Object.is
used for the dependencies? And becauseObject.is({}, {}) // false
, that would mean putting a createdprocessor
in dependencies would never be equal?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
React also allows you to omit the array. That is what I did now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we could also improve the situation by better teaching users. For example, this is not how someone would typically use this library:
A more realistic example is this:
This is problematic. The
remarkPlugins
changes for every rerender of<App />
The solution is this to move non-primitive props outside of the React component:This was always a problem, but it’s worse when dealing with asynchronous state.