/** * @import {Element, Nodes, Parents, Root} from 'hast' * @import {Root as MdastRoot} from 'mdast' * @import {ComponentType, JSX, ReactElement, ReactNode} from 'react' * @import {Options as RemarkRehypeOptions} from 'remark-rehype' * @import {BuildVisitor} from 'unist-util-visit' * @import {PluggableList, Processor} from 'unified' */ /** * @callback AllowElement * Filter elements. * @param {Readonly} element * Element to check. * @param {number} index * Index of `element` in `parent`. * @param {Readonly | undefined} parent * Parent of `element`. * @returns {boolean | null | undefined} * Whether to allow `element` (default: `false`). */ /** * @typedef ExtraProps * Extra fields we pass. * @property {Element | undefined} [node] * passed when `passNode` is on. */ /** * @typedef {{ * [Key in keyof JSX.IntrinsicElements]?: ComponentType | keyof JSX.IntrinsicElements * }} Components * Map tag names to components. */ /** * @typedef Deprecation * Deprecation. * @property {string} from * Old field. * @property {string} id * ID in readme. * @property {keyof Options} [to] * New field. */ /** * @typedef Options * Configuration. * @property {AllowElement | null | undefined} [allowElement] * Filter elements (optional); * `allowedElements` / `disallowedElements` is used first. * @property {ReadonlyArray | null | undefined} [allowedElements] * Tag names to allow (default: all tag names); * cannot combine w/ `disallowedElements`. * @property {string | null | undefined} [children] * Markdown. * @property {Components | null | undefined} [components] * Map tag names to components. * @property {ReadonlyArray | null | undefined} [disallowedElements] * Tag names to disallow (default: `[]`); * cannot combine w/ `allowedElements`. * @property {PluggableList | null | undefined} [rehypePlugins] * List of rehype plugins to use. * @property {PluggableList | null | undefined} [remarkPlugins] * List of remark plugins to use. * @property {Readonly | null | undefined} [remarkRehypeOptions] * Options to pass through to `remark-rehype`. * @property {boolean | null | undefined} [skipHtml=false] * Ignore HTML in markdown completely (default: `false`). * @property {boolean | null | undefined} [unwrapDisallowed=false] * Extract (unwrap) what’s in disallowed elements (default: `false`); * normally when say `strong` is not allowed, it and it’s children are dropped, * with `unwrapDisallowed` the element itself is replaced by its children. * @property {UrlTransform | null | undefined} [urlTransform] * Change URLs (default: `defaultUrlTransform`) */ /** * @typedef HooksOptionsOnly * Configuration specifically for {@linkcode MarkdownHooks}. * @property {ReactNode | null | undefined} [fallback] * Content to render while the processor processing the markdown (optional). */ /** * @typedef {Options & HooksOptionsOnly} HooksOptions * Configuration for {@linkcode MarkdownHooks}; * extends the regular {@linkcode Options} with a `fallback` prop. */ /** * @callback UrlTransform * Transform all URLs. * @param {string} url * URL. * @param {string} key * Property name (example: `'href'`). * @param {Readonly} node * Node. * @returns {string | null | undefined} * Transformed URL (optional). */ 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 {useEffect, useState} from 'react' import remarkParse from 'remark-parse' import remarkRehype from 'remark-rehype' import {unified} from 'unified' import {visit} from 'unist-util-visit' import {VFile} from 'vfile' const changelog = 'https://github.com/remarkjs/react-markdown/blob/main/changelog.md' /** @type {PluggableList} */ const emptyPlugins = [] /** @type {Readonly} */ const emptyRemarkRehypeOptions = {allowDangerousHtml: true} const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i // Mutable because we `delete` any time it’s used and a message is sent. /** @type {ReadonlyArray>} */ const deprecations = [ {from: 'astPlugins', id: 'remove-buggy-html-in-markdown-parser'}, {from: 'allowDangerousHtml', id: 'remove-buggy-html-in-markdown-parser'}, { from: 'allowNode', id: 'replace-allownode-allowedtypes-and-disallowedtypes', to: 'allowElement' }, { from: 'allowedTypes', id: 'replace-allownode-allowedtypes-and-disallowedtypes', to: 'allowedElements' }, {from: 'className', id: 'remove-classname'}, { from: 'disallowedTypes', id: 'replace-allownode-allowedtypes-and-disallowedtypes', to: 'disallowedElements' }, {from: 'escapeHtml', id: 'remove-buggy-html-in-markdown-parser'}, {from: 'includeElementIndex', id: '#remove-includeelementindex'}, { from: 'includeNodeIndex', id: 'change-includenodeindex-to-includeelementindex' }, {from: 'linkTarget', id: 'remove-linktarget'}, {from: 'plugins', id: 'change-plugins-to-remarkplugins', to: 'remarkPlugins'}, {from: 'rawSourcePos', id: '#remove-rawsourcepos'}, {from: 'renderers', id: 'change-renderers-to-components', to: 'components'}, {from: 'source', id: 'change-source-to-children', to: 'children'}, {from: 'sourcePos', id: '#remove-sourcepos'}, {from: 'transformImageUri', id: '#add-urltransform', to: 'urlTransform'}, {from: 'transformLinkUri', id: '#add-urltransform', to: 'urlTransform'} ] /** * 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 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 * 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. * * 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 * Props. * @returns {ReactNode} * React node. */ 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( function () { let cancelled = false const file = createFile(options) processor.run(processor.parse(file), file, function (error, tree) { if (!cancelled) { setError(error) setTree(tree) } }) /** * @returns {undefined} * Nothing. */ return function () { cancelled = true } }, [ options.children, options.rehypePlugins, options.remarkPlugins, options.remarkRehypeOptions ] ) if (error) throw error return tree ? post(tree, options) : options.fallback } /** * 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 processor = unified() .use(remarkParse) .use(remarkPlugins) .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') { file.value = children } else { unreachable( 'Unexpected value `' + children + '` for `children` prop, expected `string`' ) } 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)) { unreachable( 'Unexpected `' + deprecation.from + '` prop, ' + (deprecation.to ? 'use `' + deprecation.to + '` instead' : 'remove it') + ' (see <' + changelog + '#' + deprecation.id + '> for more info)' ) } } if (allowedElements && disallowedElements) { unreachable( 'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other' ) } visit(tree, transform) return toJsxRuntime(tree, { Fragment, components, ignoreInvalidStyle: true, jsx, jsxs, passKeys: true, passNode: true }) /** @type {BuildVisitor} */ function transform(node, index, parent) { if (node.type === 'raw' && parent && typeof index === 'number') { if (skipHtml) { parent.children.splice(index, 1) } else { parent.children[index] = {type: 'text', value: node.value} } return index } if (node.type === 'element') { /** @type {string} */ let key for (key in urlAttributes) { if ( Object.hasOwn(urlAttributes, key) && Object.hasOwn(node.properties, key) ) { const value = node.properties[key] const test = urlAttributes[key] if (test === null || test.includes(node.tagName)) { node.properties[key] = urlTransform(String(value || ''), key, node) } } } } if (node.type === 'element') { let remove = allowedElements ? !allowedElements.includes(node.tagName) : disallowedElements ? disallowedElements.includes(node.tagName) : false if (!remove && allowElement && typeof index === 'number') { remove = !allowElement(node, index, parent) } if (remove && parent && typeof index === 'number') { if (unwrapDisallowed && node.children) { parent.children.splice(index, 1, ...node.children) } else { parent.children.splice(index, 1) } return index } } } } /** * Make a URL safe. * * @satisfies {UrlTransform} * @param {string} value * URL. * @returns {string} * Safe URL. */ export function defaultUrlTransform(value) { // Same as: // // But without the `encode` part. const colon = value.indexOf(':') const questionMark = value.indexOf('?') const numberSign = value.indexOf('#') const slash = value.indexOf('/') if ( // If there is no protocol, it’s relative. colon === -1 || // If the first colon is after a `?`, `#`, or `/`, it’s not a protocol. (slash !== -1 && colon > slash) || (questionMark !== -1 && colon > questionMark) || (numberSign !== -1 && colon > numberSign) || // It is a protocol, it should be allowed. safeProtocol.test(value.slice(0, colon)) ) { return value } return '' }