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..c88a5a0 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, useEffect, useState} from 'react' import remarkParse from 'remark-parse' import remarkRehype from 'remark-rehype' import {unified} from 'unified' @@ -149,26 +151,99 @@ 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) +} + +/** + * 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) @@ -176,6 +251,19 @@ export function Markdown(options) { .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, 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..d1ff26c 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,47 @@ 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 are 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<JSX.Element>`). + +### `MarkdownHooks` + +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 [`MarkdownAsync`][api-markdown-async]. + ###### Parameters * `options` ([`Options`][api-options]) @@ -779,6 +825,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..a8bc328 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(<Markdown children="a" />), '<p>a</p>') }) @@ -1078,3 +1086,85 @@ test('react-markdown', async function (t) { } }) }) + +test('MarkdownAsync', async function (t) { + await t.test('should support `MarkdownAsync` (1)', async function () { + assert.throws(function () { + renderToStaticMarkup(<MarkdownAsync children={'a'} />) + }, /A component suspended while responding to synchronous input/) + }) + + await t.test('should support `MarkdownAsync` (2)', async function () { + return new Promise(function (resolve, reject) { + renderToPipeableStream(<MarkdownAsync children={'a'} />) + .pipe( + concatStream({encoding: 'u8'}, function (data) { + assert.equal(decoder.decode(data), '<p>a</p>') + resolve() + }) + ) + .on('error', reject) + }) + }) + + await t.test( + 'should support async plugins w/ `MarkdownAsync` (`rehype-starry-night`)', + async function () { + return new Promise(function (resolve) { + renderToPipeableStream( + <MarkdownAsync + children={'```js\nconsole.log(3.14)'} + rehypePlugins={[rehypeStarryNight]} + /> + ).pipe( + concatStream({encoding: 'u8'}, function (data) { + assert.equal( + decoder.decode(data), + '<pre><code class="language-js"><span class="pl-en">console</span>.<span class="pl-c1">log</span>(<span class="pl-c1">3.14</span>)\n</code></pre>' + ) + resolve() + }) + ) + }) + } + ) +}) + +// Note: hooks are not supported on the “server”. +test('MarkdownHooks', async function (t) { + await t.test('should support `MarkdownHooks` (1)', async function () { + assert.equal(renderToStaticMarkup(<MarkdownHooks children={'a'} />), '') + }) + + await t.test('should support `MarkdownHooks` (2)', async function () { + return new Promise(function (resolve, reject) { + renderToPipeableStream(<MarkdownHooks children={'a'} />) + .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( + <MarkdownHooks + children={'```js\nconsole.log(3.14)'} + rehypePlugins={[rehypeStarryNight]} + /> + ).pipe( + concatStream({encoding: 'u8'}, function (data) { + assert.equal(decoder.decode(data), '') + resolve() + }) + ) + }) + } + ) +})