From 8128a21a5f8dffffba0181670ca3220d8f59164d Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Fri, 14 Feb 2025 15:38:24 +0100 Subject: [PATCH 1/6] 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. --- index.js | 7 ++- lib/index.js | 144 ++++++++++++++++++++++++++++++++++++++++++--------- package.json | 3 ++ readme.md | 51 +++++++++++++++++- test.jsx | 98 +++++++++++++++++++++++++++++++++-- 5 files changed, 274 insertions(+), 29 deletions(-) diff --git a/index.js b/index.js index 174bffe..629aec0 100644 --- a/index.js +++ b/index.js @@ -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' diff --git a/lib/index.js b/lib/index.js index 529639c..265f3bf 100644 --- a/lib/index.js +++ b/lib/index.js @@ -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, use, useEffect, useState} from 'react' import remarkParse from 'remark-parse' import remarkRehype from 'remark-rehype' import {unified} from 'unified' @@ -108,6 +110,7 @@ const changelog = const emptyPlugins = [] /** @type {Readonly} */ const emptyRemarkRehypeOptions = {allowDangerousHtml: true} +const resolved = /** @type {Promise} */ (Promise.resolve()) const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i // Mutable because we `delete` any time it’s used and a message is sent. @@ -149,26 +152,88 @@ const deprecations = [ /** * Component to render markdown. * + * This is a synchronous component. + * When using async plugins, + * see {@linkcode MarkdownAsync} or {@linkcode MarkdownHooks}. + * * @param {Readonly} 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 + * Props. + * @returns {Promise} + * 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 + * Props. + * @returns {ReactElement} + * React element. + */ +export function MarkdownHooks(options) { + const processor = createProcessor(options) + const [promise, setPromise] = useState( + /** @type {Promise} */ (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 + * Props. + * @returns {Processor} + * 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) @@ -176,6 +241,19 @@ export function Markdown(options) { .use(remarkRehype, remarkRehypeOptions) .use(rehypePlugins) + return processor +} + +/** + * Set up the virtual file. + * + * @param {Readonly} options + * Props. + * @returns {VFile} + * Result. + */ +function createFile(options) { + const children = options.children || '' const file = new VFile() if (typeof children === 'string') { @@ -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 + * 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 +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} */ ( - 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, diff --git a/package.json b/package.json index ccf0e05..4ab537f 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/readme.md b/readme.md index 05080a9..3fb00ff 100644 --- a/readme.md +++ b/readme.md @@ -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) @@ -166,7 +168,10 @@ 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]. @@ -174,6 +179,46 @@ The default export is [`Markdown`][api-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`). + +### `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]) @@ -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 diff --git a/test.jsx b/test.jsx index ccc0b7b..fd84776 100644 --- a/test.jsx +++ b/test.jsx @@ -7,21 +7,29 @@ import assert from 'node:assert/strict' import test from 'node:test' -import {renderToStaticMarkup} from 'react-dom/server' -import Markdown from 'react-markdown' +import concatStream from 'concat-stream' +import {renderToPipeableStream, renderToStaticMarkup} from 'react-dom/server' +import Markdown, {MarkdownAsync, MarkdownHooks} from 'react-markdown' import rehypeRaw from 'rehype-raw' +import rehypeStarryNight from 'rehype-starry-night' import remarkGfm from 'remark-gfm' import remarkToc from 'remark-toc' import {visit} from 'unist-util-visit' -test('react-markdown', async function (t) { +const decoder = new TextDecoder() + +test('react-markdown (core)', async function (t) { await t.test('should expose the public api', async function () { assert.deepEqual(Object.keys(await import('react-markdown')).sort(), [ + 'MarkdownAsync', + 'MarkdownHooks', 'default', 'defaultUrlTransform' ]) }) +}) +test('Markdown', async function (t) { await t.test('should work', function () { assert.equal(renderToStaticMarkup(), '

a

') }) @@ -1078,3 +1086,87 @@ test('react-markdown', async function (t) { } }) }) + +test('MarkdownAsync', async function (t) { + await t.test('should support `MarkdownAsync` (1)', async function () { + assert.throws(function () { + renderToStaticMarkup() + }, /A component suspended while responding to synchronous input/) + }) + + await t.test('should support `MarkdownAsync` (2)', async function () { + return new Promise(function (resolve, reject) { + renderToPipeableStream() + .pipe( + concatStream({encoding: 'u8'}, function (data) { + assert.equal(decoder.decode(data), '

a

') + resolve() + }) + ) + .on('error', reject) + }) + }) + + await t.test( + 'should support async plugins w/ `MarkdownAsync` (`rehype-starry-night`)', + async function () { + return new Promise(function (resolve) { + renderToPipeableStream( + + ).pipe( + concatStream({encoding: 'u8'}, function (data) { + assert.equal( + decoder.decode(data), + '
console.log(3.14)\n
' + ) + resolve() + }) + ) + }) + } + ) +}) + +// Note: hooks are not supported on the “server”. +test('MarkdownHooks', async function (t) { + await t.test('should support `MarkdownHooks` (1)', async function () { + assert.throws(function () { + renderToStaticMarkup() + }, /A component suspended while responding to synchronous input/) + }) + + await t.test('should support `MarkdownHooks` (2)', async function () { + return new Promise(function (resolve, reject) { + renderToPipeableStream() + .pipe( + concatStream({encoding: 'u8'}, function (data) { + assert.equal(decoder.decode(data), '') + resolve() + }) + ) + .on('error', reject) + }) + }) + + await t.test( + 'should support async plugins w/ `MarkdownHooks` (`rehype-starry-night`)', + async function () { + return new Promise(function (resolve) { + renderToPipeableStream( + + ).pipe( + concatStream({encoding: 'u8'}, function (data) { + assert.equal(decoder.decode(data), '') + resolve() + }) + ) + }) + } + ) +}) From 609b60a37facc335fd797e63f1248d999db053fb Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Fri, 14 Feb 2025 16:47:18 +0100 Subject: [PATCH 2/6] some more --- lib/index.js | 5 +++-- readme.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/index.js b/lib/index.js index 265f3bf..f83bc8d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -171,7 +171,7 @@ export function Markdown(options) { * Component to render markdown with support for async plugins * through async/await. * - * Components returning promises is supported on the server. + * Components returning promises are supported on the server. * For async support on the client, * see {@linkcode MarkdownHooks}. * @@ -190,7 +190,8 @@ export async function MarkdownAsync(options) { /** * Component to render markdown with support for async plugins through hooks. * - * Hooks run on the client. + * 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}. * diff --git a/readme.md b/readme.md index 3fb00ff..d1ff26c 100644 --- a/readme.md +++ b/readme.md @@ -198,7 +198,7 @@ React element (`JSX.Element`). Component to render markdown with support for async plugins through async/await. -Components returning promises is supported on the server. +Components returning promises are supported on the server. For async support on the client, see [`MarkdownHooks`][api-markdown-hooks]. @@ -215,7 +215,8 @@ Promise to a React element (`Promise`). Component to render markdown with support for async plugins through hooks. -Hooks run on the client. +This uses `useEffect` and `useState` hooks. +Hooks run on the client and do not immediately render something. For async support on the server, see [`MarkdownAsync`][api-markdown-async]. From 51e0b2bfe5534c5934e68f9ae686cc0d387a680f Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Tue, 18 Feb 2025 11:17:37 +0100 Subject: [PATCH 3/6] Remove `use` use --- lib/index.js | 15 +++++++++------ test.jsx | 4 +--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/index.js b/lib/index.js index f83bc8d..519f3dc 100644 --- a/lib/index.js +++ b/lib/index.js @@ -96,7 +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 {createElement, useEffect, useState} from 'react' import remarkParse from 'remark-parse' import remarkRehype from 'remark-rehype' import {unified} from 'unified' @@ -110,7 +110,6 @@ const changelog = const emptyPlugins = [] /** @type {Readonly} */ const emptyRemarkRehypeOptions = {allowDangerousHtml: true} -const resolved = /** @type {Promise} */ (Promise.resolve()) const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i // Mutable because we `delete` any time it’s used and a message is sent. @@ -202,20 +201,24 @@ export async function MarkdownAsync(options) { */ export function MarkdownHooks(options) { const processor = createProcessor(options) - const [promise, setPromise] = useState( - /** @type {Promise} */ (resolved) + const [error, setError] = useState( + /** @type {Error | undefined} */ (undefined) ) + const [tree, setTree] = useState(/** @type {Root | undefined} */ (undefined)) useEffect( /* c8 ignore next 4 -- hooks are client-only. */ function () { const file = createFile(options) - setPromise(processor.run(processor.parse(file), file)) + processor.run(processor.parse(file), file, function (error, tree) { + setError(error) + setTree(tree) + }) }, [options.children] ) - const tree = use(promise) + if (error) throw error /* c8 ignore next -- hooks are client-only. */ return tree ? post(tree, options) : createElement(Fragment) diff --git a/test.jsx b/test.jsx index fd84776..a8bc328 100644 --- a/test.jsx +++ b/test.jsx @@ -1133,9 +1133,7 @@ test('MarkdownAsync', async function (t) { // Note: hooks are not supported on the “server”. test('MarkdownHooks', async function (t) { await t.test('should support `MarkdownHooks` (1)', async function () { - assert.throws(function () { - renderToStaticMarkup() - }, /A component suspended while responding to synchronous input/) + assert.equal(renderToStaticMarkup(), '') }) await t.test('should support `MarkdownHooks` (2)', async function () { From 2ab6dacf2fa13d3793e046bc9fde623034e4e96b Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Tue, 18 Feb 2025 11:17:54 +0100 Subject: [PATCH 4/6] Remove dependencies array --- lib/index.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/index.js b/lib/index.js index 519f3dc..080d73f 100644 --- a/lib/index.js +++ b/lib/index.js @@ -214,8 +214,7 @@ export function MarkdownHooks(options) { setError(error) setTree(tree) }) - }, - [options.children] + } ) if (error) throw error From e8594ae228bc056e0ea63b4c7408fc89a05fc978 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Tue, 18 Feb 2025 11:19:26 +0100 Subject: [PATCH 5/6] . --- lib/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/index.js b/lib/index.js index 080d73f..144b1bc 100644 --- a/lib/index.js +++ b/lib/index.js @@ -207,7 +207,7 @@ export function MarkdownHooks(options) { const [tree, setTree] = useState(/** @type {Root | undefined} */ (undefined)) useEffect( - /* c8 ignore next 4 -- hooks are client-only. */ + /* c8 ignore next 7 -- hooks are client-only. */ function () { const file = createFile(options) processor.run(processor.parse(file), file, function (error, tree) { @@ -217,6 +217,7 @@ export function MarkdownHooks(options) { } ) + /* c8 ignore next -- hooks are client-only. */ if (error) throw error /* c8 ignore next -- hooks are client-only. */ From b371a1211f93528487cc0c9fbfcc48fd36bee274 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Thu, 20 Feb 2025 12:48:08 +0100 Subject: [PATCH 6/6] Add a somewhat reasonable dependency array --- lib/index.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/index.js b/lib/index.js index 144b1bc..c88a5a0 100644 --- a/lib/index.js +++ b/lib/index.js @@ -214,7 +214,13 @@ export function MarkdownHooks(options) { setError(error) setTree(tree) }) - } + }, + [ + options.children, + options.rehypePlugins, + options.remarkPlugins, + options.remarkRehypeOptions + ] ) /* c8 ignore next -- hooks are client-only. */