From 434627686e21d4bcfb4301417e0da2bb851d4391 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 27 Sep 2023 13:08:38 +0200 Subject: [PATCH] Remove support for passing custom props to components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, this project automatically passed different extra props to particular components. Those props are sometimes useful to some people, but not always useful to everyone. When overwriting components, these props are no longer passed: * `inline` on `code`: — create a plugin or use `pre` for the block * `level` on `h1`, `h2`, `h3`, `h4`, `h5`, `h6` — check `node.tagName` instead * `checked` on `li` — check `task-list-item` class or check `props.children` * `index` on `li` — create a plugin * `ordered` on `li` — create a plugin or check the parent * `depth` on `ol`, `ul` — create a plugin * `ordered` on `ol`, `ul` — check `node.tagName` instead * `isHeader` on `td`, `th` — check `node.tagName` instead * `isHeader` on `tr` — create a plugin or check children When using options, these props are no longer passed: * `includeElementIndex`: `index` (create a plugin) * `rawSourcePos`: `sourcePosition` (use `node.position`) * `sourcePos`: `data-sourcepos` (create a plugin) --- changelog.md | 99 +++++++ index.js | 9 +- lib/ast-to-react.js | 431 ---------------------------- lib/complex-types.d.ts | 36 --- lib/{react-markdown.js => index.js} | 259 +++++++++++++---- lib/rehype-filter.js | 66 ----- lib/uri-transformer.js | 50 ---- package.json | 14 +- readme.md | 74 +---- test/test.jsx | 317 +++++++++----------- 10 files changed, 449 insertions(+), 906 deletions(-) delete mode 100644 lib/ast-to-react.js delete mode 100644 lib/complex-types.d.ts rename lib/{react-markdown.js => index.js} (54%) delete mode 100644 lib/rehype-filter.js delete mode 100644 lib/uri-transformer.js diff --git a/changelog.md b/changelog.md index 481cf7ce..5fd55f18 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,105 @@ All notable changes will be documented in this file. +## 9.0.0 - unreleased + +### Remove `includeElementIndex` option + +The `includeElementIndex` option was removed, so `index` is never passed to +components. +Write a plugin to pass `index`: + +
+Show example of plugin + +```jsx +import {visit} from 'unist-util-visit' + +function rehypePluginAddingIndex() { + /** + * @param {import('hast').Root} tree + * @returns {undefined} + */ + return function (tree) { + visit(tree, function (node, index) { + if (node.type === 'element' && typeof index === 'number') { + node.properties === index + } + }) + } +} +``` + +### Remove `rawSourcePos` option + +The `rawSourcePos` option was removed, so `sourcePos` is never passed to +components. +All components are passed `node`, so you can get `node.position` from them. + +### Remove `sourcePos` option + +The `sourcePos` option was removed, so `data-sourcepos` is never passed to +elements. +Write a plugin to pass `index`: + +
+Show example of plugin + +```jsx +import {stringifyPosition} from 'unist-util-stringify-position' +import {visit} from 'unist-util-visit' + +function rehypePluginAddingIndex() { + /** + * @param {import('hast').Root} tree + * @returns {undefined} + */ + return function (tree) { + visit(tree, function (node) { + if (node.type === 'element') { + node.properties.dataSourcepos = stringifyPosition(node.position) + } + }) + } +} +``` + +### Remove extra props passed to certain components + +When overwriting components, these props are no longer passed: + +* `inline` on `code`: + — create a plugin or use `pre` for the block +* `level` on `h1`, `h2`, `h3`, `h4`, `h5`, `h6` + — check `node.tagName` instead +* `checked` on `li` + — check `task-list-item` class or check `props.children` +* `index` on `li` + — create a plugin +* `ordered` on `li` + — create a plugin or check the parent +* `depth` on `ol`, `ul` + — create a plugin +* `ordered` on `ol`, `ul` + — check `node.tagName` instead +* `isHeader` on `td`, `th` + — check `node.tagName` instead +* `isHeader` on `tr` + — create a plugin or check children + +## 8.0.7 - 2023-04-12 + +* [`c289176`](https://github.com/remarkjs/react-markdown/commit/c289176) + Fix performance for keys + by [**@wooorm**](https://github.com/wooorm) + in [#738](https://github.com/remarkjs/react-markdown/pull/738) +* [`9034dbd`](https://github.com/remarkjs/react-markdown/commit/9034dbd) + Fix types in syntax highlight example + by [**@dlqqq**](https://github.com/dlqqq) + in [#736](https://github.com/remarkjs/react-markdown/pull/736) + +**Full Changelog**: + ## 8.0.6 - 2023-03-20 * [`33ab015`](https://github.com/remarkjs/react-markdown/commit/33ab015) diff --git a/index.js b/index.js index 6e83f36e..eb6590e0 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,7 @@ /** - * @typedef {import('./lib/react-markdown.js').Options} Options - * @typedef {import('./lib/ast-to-react.js').Components} Components + * @typedef {import('hast-util-to-jsx-runtime').Components} Components + * @typedef {import('hast-util-to-jsx-runtime').ExtraProps} ExtraProps + * @typedef {import('./lib/index.js').Options} Options */ -export {uriTransformer} from './lib/uri-transformer.js' - -export {ReactMarkdown as default} from './lib/react-markdown.js' +export {ReactMarkdown as default, uriTransformer} from './lib/index.js' diff --git a/lib/ast-to-react.js b/lib/ast-to-react.js deleted file mode 100644 index 4e3731b0..00000000 --- a/lib/ast-to-react.js +++ /dev/null @@ -1,431 +0,0 @@ -/// - -/** - * @typedef {import('react').ComponentPropsWithoutRef} ComponentPropsWithoutRef - * @template {import('react').ElementType} T - */ - -/** - * @typedef {import('react').ComponentType} ComponentType - * @template T - */ - -/** - * @typedef {import('hast').Element} Element - * @typedef {import('hast').Parents} Parents - * @typedef {import('hast').Root} Root - * - * @typedef {import('property-information').Schema} Schema - * - * @typedef {import('react').ReactNode} ReactNode - * - * @typedef {import('unist').Position} Position - * - * @typedef {import('./complex-types.js').ReactMarkdownProps} ReactMarkdownProps - * @typedef {import('./complex-types.js').NormalComponents} NormalComponents - * @typedef {import('./react-markdown.js').Options} Options - */ - -/** - * @typedef State - * Info passed around. - * @property {Readonly} options - * Configuration. - * @property {Schema} schema - * Schema. - * @property {number} listDepth - * Depth. - * - * @typedef {ComponentPropsWithoutRef<'code'> & ReactMarkdownProps & {inline?: boolean}} CodeProps - * Props passed to components for `code`. - * to do: always pass `inline`? - * @typedef {ComponentPropsWithoutRef<'h1'> & ReactMarkdownProps & {level: number}} HeadingProps - * Props passed to components for `h1`, `h2`, etc. - * @typedef {ComponentPropsWithoutRef<'li'> & ReactMarkdownProps & {checked: boolean | null, index: number, ordered: boolean}} LiProps - * Props passed to components for `li`. - * to do: use `undefined`. - * @typedef {ComponentPropsWithoutRef<'ol'> & ReactMarkdownProps & {depth: number, ordered: true}} OrderedListProps - * Props passed to components for `ol`. - * @typedef {ComponentPropsWithoutRef<'td'> & ReactMarkdownProps & {isHeader: false}} TableDataCellProps - * Props passed to components for `td`. - * @typedef {ComponentPropsWithoutRef<'th'> & ReactMarkdownProps & {isHeader: true}} TableHeaderCellProps - * Props passed to components for `th`. - * @typedef {ComponentPropsWithoutRef<'tr'> & ReactMarkdownProps & {isHeader: boolean}} TableRowProps - * Props passed to components for `tr`. - * @typedef {ComponentPropsWithoutRef<'ul'> & ReactMarkdownProps & {depth: number, ordered: false}} UnorderedListProps - * Props passed to components for `ul`. - * - * @typedef SpecialComponents - * @property {ComponentType | keyof JSX.IntrinsicElements} code - * @property {ComponentType | keyof JSX.IntrinsicElements} h1 - * @property {ComponentType | keyof JSX.IntrinsicElements} h2 - * @property {ComponentType | keyof JSX.IntrinsicElements} h3 - * @property {ComponentType | keyof JSX.IntrinsicElements} h4 - * @property {ComponentType | keyof JSX.IntrinsicElements} h5 - * @property {ComponentType | keyof JSX.IntrinsicElements} h6 - * @property {ComponentType | keyof JSX.IntrinsicElements} li - * @property {ComponentType | keyof JSX.IntrinsicElements} ol - * @property {ComponentType | keyof JSX.IntrinsicElements} td - * @property {ComponentType | keyof JSX.IntrinsicElements} th - * @property {ComponentType | keyof JSX.IntrinsicElements} tr - * @property {ComponentType | keyof JSX.IntrinsicElements} ul - * - * @typedef {Partial & SpecialComponents>} Components - * Components. - */ - -import React from 'react' -import {stringify as commas} from 'comma-separated-tokens' -import {whitespace} from 'hast-util-whitespace' -import {find, hastToReact, svg} from 'property-information' -import {stringify as spaces} from 'space-separated-tokens' -import style from 'style-to-object' -import {stringifyPosition} from 'unist-util-stringify-position' -import {uriTransformer} from './uri-transformer.js' - -const own = {}.hasOwnProperty - -// The table-related elements that must not contain whitespace text according -// to React. -const tableElements = new Set(['table', 'thead', 'tbody', 'tfoot', 'tr']) - -/** - * @param {State} state - * Info passed around. - * @param {Readonly} node - * Node to transform. - * @returns {Array} - * Nodes. - */ -export function childrenToReact(state, node) { - /** @type {Array} */ - const children = [] - let childIndex = -1 - - while (++childIndex < node.children.length) { - const child = node.children[childIndex] - - if (child.type === 'element') { - children.push(toReact(state, child, childIndex, node)) - } else if (child.type === 'text') { - // Currently, a warning is triggered by react for *any* white space in - // tables. - // So we drop it. - // See: . - // See: . - // See: . - // See: . - if ( - node.type !== 'element' || - !tableElements.has(node.tagName) || - !whitespace(child) - ) { - children.push(child.value) - } - } else if (child.type === 'raw' && !state.options.skipHtml) { - // Default behavior is to show (encoded) HTML. - children.push(child.value) - } - } - - return children -} - -/** - * @param {State} state - * Info passed around. - * @param {Readonly} node - * Node to transform. - * @param {number} index - * Position of `node` in `parent`. - * @param {Readonly} parent - * Parent of `node`. - * @returns {ReactNode} - * Node. - */ -function toReact(state, node, index, parent) { - const options = state.options - const transform = - options.transformLinkUri === undefined - ? uriTransformer - : options.transformLinkUri - const parentSchema = state.schema - // Assume a known HTML/SVG element. - const name = /** @type {keyof JSX.IntrinsicElements} */ (node.tagName) - /** @type {Record} */ - const properties = {} - let schema = parentSchema - /** @type {string} */ - let property - - if (parentSchema.space === 'html' && name === 'svg') { - schema = svg - state.schema = schema - } - - if (node.properties) { - for (property in node.properties) { - if (own.call(node.properties, property)) { - addProperty(state, properties, property, node.properties[property]) - } - } - } - - if (name === 'ol' || name === 'ul') { - state.listDepth++ - } - - const children = childrenToReact(state, node) - - if (name === 'ol' || name === 'ul') { - state.listDepth-- - } - - // Restore parent schema. - state.schema = parentSchema - - /** @type {ComponentType | string} */ - const component = - options.components && own.call(options.components, name) - ? options.components[name] || name - : name - const basic = typeof component === 'string' || component === React.Fragment - - if (!basic && typeof component !== 'function') { - throw new Error( - 'Unexpected value `' + - component + - '` for `' + - name + - '`, expected component or tag name' - ) - } - - properties.key = index - - if (name === 'a' && transform) { - properties.href = transform( - String(properties.href || ''), - node.children, - // To do: pass `undefined`. - typeof properties.title === 'string' ? properties.title : null - ) - } - - if ( - !basic && - name === 'code' && - parent.type === 'element' && - parent.tagName !== 'pre' - ) { - properties.inline = true - } - - if ( - !basic && - (name === 'h1' || - name === 'h2' || - name === 'h3' || - name === 'h4' || - name === 'h5' || - name === 'h6') - ) { - properties.level = Number.parseInt(name.charAt(1), 10) - } - - if (name === 'img' && options.transformImageUri) { - properties.src = options.transformImageUri( - String(properties.src || ''), - String(properties.alt || ''), - // To do: pass `undefined`. - typeof properties.title === 'string' ? properties.title : null - ) - } - - if (!basic && name === 'li' && parent.type === 'element') { - const input = getInputElement(node) - properties.checked = - // To do: pass `undefined`. - input ? Boolean(input.properties.checked) : null - properties.index = getElementsBeforeCount(parent, node) - properties.ordered = parent.tagName === 'ol' - } - - if (!basic && (name === 'ol' || name === 'ul')) { - properties.ordered = name === 'ol' - properties.depth = state.listDepth - } - - if (name === 'td' || name === 'th') { - if (properties.align) { - let style = /** @type {Record | undefined} */ ( - properties.style - ) - - if (!style) { - style = {} - properties.style = style - } - - style.textAlign = String(properties.align) - - delete properties.align - } - - if (!basic) { - properties.isHeader = name === 'th' - } - } - - if (!basic && name === 'tr' && parent.type === 'element') { - properties.isHeader = Boolean(parent.tagName === 'thead') - } - - // If `sourcePos` is given, pass source information (line/column info from markdown source). - if (options.sourcePos) { - properties['data-sourcepos'] = stringifyPosition(node) - } - - if (!basic && options.rawSourcePos) { - properties.sourcePosition = node.position - } - - // If `includeElementIndex` is given, pass node index info to components. - if (!basic && options.includeElementIndex) { - properties.index = getElementsBeforeCount(parent, node) - properties.siblingCount = getElementsBeforeCount(parent) - } - - if (!basic) { - properties.node = node - } - - // Ensure no React warnings are emitted for void elements w/ children. - return children.length > 0 - ? React.createElement(component, properties, children) - : React.createElement(component, properties) -} - -/** - * @param {Readonly} node - * Node to check. - * @returns {Element | undefined} - * `input` element, if found. - */ -function getInputElement(node) { - let index = -1 - - while (++index < node.children.length) { - const child = node.children[index] - - if (child.type === 'element' && child.tagName === 'input') { - return child - } - } -} - -/** - * @param {Readonly} parent - * Node. - * @param {Readonly} [node] - * Node in parent (optional). - * @returns {number} - * Siblings before `node`. - */ -function getElementsBeforeCount(parent, node) { - let index = -1 - let count = 0 - - while (++index < parent.children.length) { - const child = parent.children[index] - if (child === node) break - if (child.type === 'element') count++ - } - - return count -} - -/** - * @param {State} state - * Info passed around. - * @param {Record} props - * Properties. - * @param {string} prop - * Property. - * @param {unknown} value - * Value. - * @returns {undefined} - * Nothing. - */ -function addProperty(state, props, prop, value) { - const info = find(state.schema, prop) - let result = value - - // Ignore nullish and `NaN` values. - // eslint-disable-next-line no-self-compare - if (result === null || result === undefined || result !== result) { - return - } - - // Accept `array`. - // Most props are space-separated. - if (Array.isArray(result)) { - result = info.commaSeparated ? commas(result) : spaces(result) - } - - if (info.property === 'style' && typeof result === 'string') { - result = parseStyle(result) - } - - if (info.space && info.property) { - props[ - own.call(hastToReact, info.property) - ? hastToReact[info.property] - : info.property - ] = result - } else if (info.attribute) { - props[info.attribute] = result - } -} - -/** - * @param {string} value - * Style. - * @returns {Record} - * Style. - */ -function parseStyle(value) { - /** @type {Record} */ - const result = {} - - try { - style(value, iterator) - } catch { - // Silent. - } - - return result - - /** - * @param {string} name - * Name. - * @param {string} v - * Value. - */ - function iterator(name, v) { - const k = name.slice(0, 4) === '-ms-' ? `ms-${name.slice(4)}` : name - result[k.replace(/-([a-z])/g, styleReplacer)] = v - } -} - -/** - * @param {unknown} _ - * Whole match. - * @param {string} $1 - * Letter. - * @returns {string} - * Replacement. - */ -function styleReplacer(_, $1) { - return $1.toUpperCase() -} diff --git a/lib/complex-types.d.ts b/lib/complex-types.d.ts deleted file mode 100644 index b460319a..00000000 --- a/lib/complex-types.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -// File for types which are not handled correctly in JSDoc mode. -import type {Element} from 'hast' -import type {ComponentPropsWithoutRef, ComponentType, ReactNode} from 'react' -import type {Position} from 'unist' - -/** - * Props passed to components. - */ -export type ReactMarkdownProps = { - /** - * Passed when `options.sourcePos` is given. - */ - 'data-sourcepos': string | undefined - /** - * Passed when `options.includeElementIndex` is given - */ - index?: number - /** - * Original hast node. - */ - node: Element - /** - * Passed when `options.includeElementIndex` is given - */ - siblingCount?: number - /** - * Passed when `options.rawSourcePos` is given - */ - sourcePosition?: Position | undefined -} - -export type NormalComponents = { - [TagName in keyof JSX.IntrinsicElements]: - | keyof JSX.IntrinsicElements - | ComponentType & ReactMarkdownProps> -} diff --git a/lib/react-markdown.js b/lib/index.js similarity index 54% rename from lib/react-markdown.js rename to lib/index.js index 4d08e366..6c79f953 100644 --- a/lib/react-markdown.js +++ b/lib/index.js @@ -1,11 +1,16 @@ +// Register `Raw` in tree: +/// + /** * @typedef {import('hast').Element} Element * @typedef {import('hast').ElementContent} ElementContent + * @typedef {import('hast').Nodes} Nodes * @typedef {import('hast').Parents} Parents + * @typedef {import('hast').Root} Root + * @typedef {import('hast-util-to-jsx-runtime').Components} Components * @typedef {import('remark-rehype').Options} RemarkRehypeOptions - * @typedef {import('react').ReactElement<{}>} ReactElement + * @typedef {import('unist-util-visit').BuildVisitor} Visitor * @typedef {import('unified').PluggableList} PluggableList - * @typedef {import('./ast-to-react.js').Components} Components */ /** @@ -39,17 +44,11 @@ * Markdown to parse. * @property {string | null | undefined} [className] * Wrap the markdown in a `div` with this class name. - * @property {Components | null | undefined} [components] + * @property {Partial | null | undefined} [components] * Map tag names to React components. * @property {ReadonlyArray | null | undefined} [disallowedElements] * Tag names to disallow (cannot combine w/ `allowedElements`), all tag names * are allowed by default. - * @property {boolean | null | undefined} [includeElementIndex=false] - * Pass the `index` (number of elements before it) and `siblingCount` (number - * of elements in parent) as props to all components (default: `false`). - * @property {boolean | null | undefined} [rawSourcePos=false] - * Pass a `sourcePosition` prop to all components with their position - * (default: `false`). * @property {PluggableList | null | undefined} [rehypePlugins] * List of rehype plugins to use. * @property {PluggableList | null | undefined} [remarkPlugins] @@ -58,9 +57,6 @@ * Options to pass through to `remark-rehype`. * @property {boolean | null | undefined} [skipHtml=false] * Ignore HTML in markdown completely (default: `false`). - * @property {boolean | null | undefined} [sourcePos=false] - * Pass a `data-sourcepos` prop to all components with a serialized position - * (default: `false`). * @property {TransformLink | false | null | undefined} [transformLinkUri] * Change URLs on images (default: `uriTransformer`); * pass `false` to allow all URLs, which is unsafe @@ -97,20 +93,27 @@ * Transformed URL (optional). */ -import React from 'react' +import {toJsxRuntime} from 'hast-util-to-jsx-runtime' import PropTypes from 'prop-types' -import {html} from 'property-information' +// @ts-expect-error: untyped. +import {Fragment, jsx, jsxs} from 'react/jsx-runtime' import remarkParse from 'remark-parse' import remarkRehype from 'remark-rehype' import {unified} from 'unified' +import {visit} from 'unist-util-visit' import {VFile} from 'vfile' -import {childrenToReact} from './ast-to-react.js' -import rehypeFilter from './rehype-filter.js' + +const safeProtocols = ['http', 'https', 'mailto', 'tel'] const own = {}.hasOwnProperty const changelog = 'https://github.com/remarkjs/react-markdown/blob/main/changelog.md' +/** @type {PluggableList} */ +const emptyPlugins = [] +/** @type {Readonly} */ +const emptyRemarkRehypeOptions = {allowDangerousHtml: true} + // Mutable because we `delete` any time it’s used and a message is sent. /** @type {Record>} */ const deprecated = { @@ -129,13 +132,13 @@ const deprecated = { to: 'disallowedElements' }, escapeHtml: {id: 'remove-buggy-html-in-markdown-parser'}, - includeNodeIndex: { - id: 'change-includenodeindex-to-includeelementindex', - to: 'includeElementIndex' - }, + includeElementIndex: {id: '#remove-includeelementindex-option'}, + includeNodeIndex: {id: 'change-includenodeindex-to-includeelementindex'}, plugins: {id: 'change-plugins-to-remarkplugins', to: 'remarkPlugins'}, + rawSourcePos: {id: '#remove-rawsourcepos-option'}, renderers: {id: 'change-renderers-to-components', to: 'components'}, - source: {id: 'change-source-to-children', to: 'children'} + source: {id: 'change-source-to-children', to: 'children'}, + sourcePos: {id: '#remove-sourcepos-option'} } /** @@ -144,11 +147,58 @@ const deprecated = { * @param {Readonly} options * Configuration (required). * Note: React types require that props are passed. - * @returns {ReactElement} + * @returns {JSX.Element} * React element. */ export function ReactMarkdown(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 rehypePlugins = options.rehypePlugins || emptyPlugins + const remarkPlugins = options.remarkPlugins || emptyPlugins + const remarkRehypeOptions = options.remarkRehypeOptions + ? {...options.remarkRehypeOptions, ...emptyRemarkRehypeOptions} + : emptyRemarkRehypeOptions + const skipHtml = options.skipHtml + const transformImageUri = + options.transformImageUri === undefined + ? uriTransformer + : options.transformImageUri + const transformLinkUri = + options.transformLinkUri === undefined + ? uriTransformer + : options.transformLinkUri + const unwrapDisallowed = options.unwrapDisallowed + + const processor = unified() + .use(remarkParse) + .use(remarkPlugins) + .use(remarkRehype, remarkRehypeOptions) + .use(rehypePlugins) + + const file = new VFile() + + if (typeof children === 'string') { + file.value = children + } else { + console.warn( + '[react-markdown] Warning: please pass a string as `children` (not: `' + + children + + '`)' + ) + } + + if (allowedElements && disallowedElements) { + throw new TypeError( + 'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other' + ) + } + for (const key in deprecated) { + // To do: use `Object.hasOwn`. if (own.call(deprecated, key) && own.call(options, key)) { const deprecation = deprecated[key] console.warn( @@ -160,46 +210,93 @@ export function ReactMarkdown(options) { } } - const processor = unified() - .use(remarkParse) - .use(options.remarkPlugins || []) - .use(remarkRehype, { - ...options.remarkRehypeOptions, - allowDangerousHtml: true - }) - .use(options.rehypePlugins || []) - .use(rehypeFilter, options) - - const file = new VFile() + const mdastTree = processor.parse(file) + /** @type {Nodes} */ + let hastTree = processor.runSync(mdastTree, file) - if (typeof options.children === 'string') { - file.value = options.children - } else if (options.children !== null && options.children !== undefined) { - console.warn( - `[react-markdown] Warning: please pass a string as \`children\` (not: \`${options.children}\`)` - ) + // Wrap in `div` if there’s a class name. + if (className) { + hastTree = { + type: 'element', + tagName: 'div', + properties: {className}, + // Assume no doctypes. + children: /** @type {Array} */ ( + hastTree.type === 'root' ? hastTree.children : [hastTree] + ) + } } - const hastTree = processor.runSync(processor.parse(file), file) + visit(hastTree, transform) - if (hastTree.type !== 'root') { - throw new TypeError( - 'Unexpected `' + hastTree.type + '` node, expected `root`' - ) - } + return toJsxRuntime(hastTree, { + Fragment, + components, + ignoreInvalidStyle: true, + jsx, + jsxs, + passKeys: true, + passNode: true + }) - /** @type {ReactElement} */ - let result = React.createElement( - React.Fragment, - {}, - childrenToReact({options, schema: html, listDepth: 0}, hastTree) - ) + /** @type {Visitor} */ + 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} + } - if (options.className) { - result = React.createElement('div', {className: options.className}, result) - } + return index + } - return result + if (transformLinkUri && node.type === 'element' && node.tagName === 'a') { + node.properties.href = transformLinkUri( + String(node.properties.href || ''), + node.children, + // To do: pass `undefined`. + typeof node.properties.title === 'string' ? node.properties.title : null + ) + } + + if ( + transformImageUri && + node.type === 'element' && + node.tagName === 'img' + ) { + node.properties.src = transformImageUri( + String(node.properties.src || ''), + String(node.properties.alt || ''), + // To do: pass `undefined`. + typeof node.properties.title === 'string' ? node.properties.title : null + ) + } + + if (node.type === 'element') { + let remove = false + + if (allowedElements) { + remove = !allowedElements.includes(node.tagName) + } else if (disallowedElements) { + remove = disallowedElements.includes(node.tagName) + } + + 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 + } + } + } } ReactMarkdown.propTypes = { @@ -252,11 +349,57 @@ ReactMarkdown.propTypes = { ]) ), // Transform options: - sourcePos: PropTypes.bool, - rawSourcePos: PropTypes.bool, skipHtml: PropTypes.bool, - includeElementIndex: PropTypes.bool, transformLinkUri: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]), transformImageUri: PropTypes.func, components: PropTypes.object } + +/** + * Make a URL safe. + * + * @param {string} value + * URL. + * @returns {string} + * Safe URL. + */ +export function uriTransformer(value) { + const url = (value || '').trim() + const first = url.charAt(0) + + if (first === '#' || first === '/') { + return url + } + + const colon = url.indexOf(':') + if (colon === -1) { + return url + } + + let index = -1 + + while (++index < safeProtocols.length) { + const protocol = safeProtocols[index] + + if ( + colon === protocol.length && + url.slice(0, protocol.length).toLowerCase() === protocol + ) { + return url + } + } + + index = url.indexOf('?') + if (index !== -1 && colon > index) { + return url + } + + index = url.indexOf('#') + if (index !== -1 && colon > index) { + return url + } + + // To do: is there an alternative? + // eslint-disable-next-line no-script-url + return 'javascript:void(0)' +} diff --git a/lib/rehype-filter.js b/lib/rehype-filter.js deleted file mode 100644 index 03121bad..00000000 --- a/lib/rehype-filter.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * @typedef {import('hast').Element} Element - * @typedef {import('hast').Root} Root - * @typedef {import('./react-markdown.js').Options} Options - */ - -import {visit} from 'unist-util-visit' - -/** - * Filter nodes. - * - * @param {Readonly} options - * Configuration (required). - * @returns - * Transform (optional). - */ -export default function rehypeFilter(options) { - if ( - options.allowElement || - options.allowedElements || - options.disallowedElements - ) { - if (options.allowedElements && options.disallowedElements) { - throw new TypeError( - 'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other' - ) - } - - /** - * Transform. - * - * @param {Root} tree - * Tree. - * @returns {undefined} - * Nothing. - */ - return function (tree) { - visit(tree, 'element', function (node, index, parent) { - /** @type {boolean | undefined} */ - let remove - - if (options.allowedElements) { - remove = !options.allowedElements.includes(node.tagName) - } else if (options.disallowedElements) { - remove = options.disallowedElements.includes(node.tagName) - } - - if (!remove && options.allowElement && typeof index === 'number') { - remove = !options.allowElement(node, index, parent) - } - - if (remove && parent && typeof index === 'number') { - if (options.unwrapDisallowed && node.children) { - parent.children.splice(index, 1, ...node.children) - } else { - parent.children.splice(index, 1) - } - - return index - } - - return undefined - }) - } - } -} diff --git a/lib/uri-transformer.js b/lib/uri-transformer.js deleted file mode 100644 index eca407fb..00000000 --- a/lib/uri-transformer.js +++ /dev/null @@ -1,50 +0,0 @@ -const protocols = ['http', 'https', 'mailto', 'tel'] - -/** - * Make a URL safe. - * - * @param {string} value - * URL. - * @returns {string} - * Safe URL. - */ -export function uriTransformer(value) { - const url = (value || '').trim() - const first = url.charAt(0) - - if (first === '#' || first === '/') { - return url - } - - const colon = url.indexOf(':') - if (colon === -1) { - return url - } - - let index = -1 - - while (++index < protocols.length) { - const protocol = protocols[index] - - if ( - colon === protocol.length && - url.slice(0, protocol.length).toLowerCase() === protocol - ) { - return url - } - } - - index = url.indexOf('?') - if (index !== -1 && colon > index) { - return url - } - - index = url.indexOf('#') - if (index !== -1 && colon > index) { - return url - } - - // To do: is there an alternative? - // eslint-disable-next-line no-script-url - return 'javascript:void(0)' -} diff --git a/package.json b/package.json index c6df64f6..44e7bb7a 100644 --- a/package.json +++ b/package.json @@ -81,18 +81,12 @@ "dependencies": { "@types/hast": "^3.0.0", "@types/prop-types": "^15.0.0", - "@types/unist": "^3.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", "mdast-util-to-hast": "^13.0.0", "prop-types": "^15.0.0", - "property-information": "^6.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-object": "^0.4.0", "unified": "^11.0.0", - "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, @@ -161,9 +155,9 @@ "atLeast": 100, "detail": true, "ignoreCatch": true, - "#": "below is ignored because some proptypes will `any`", + "#": "below is ignored because some proptypes will `any`; to do: remove prop-types?", "ignoreFiles": [ - "lib/react-markdown.d.ts" + "lib/index.d.ts" ], "strict": true }, @@ -191,13 +185,13 @@ "test/**/*.jsx" ], "rules": { - "n/file-extension-in-import": "off", "no-unused-vars": "off" } } ], "prettier": true, "rules": { + "n/file-extension-in-import": "off", "unicorn/prefer-string-replace-all": "off" } } diff --git a/readme.md b/readme.md index acb8265d..1b6f5500 100644 --- a/readme.md +++ b/readme.md @@ -182,11 +182,6 @@ The default export is `ReactMarkdown`. * `disallowedElements` (`Array`, optional)\ tag names to disallow (cannot combine w/ `allowedElements`), all tag names are allowed by default -* `includeElementIndex` (`boolean`, default: `false`)\ - pass the `index` (number of elements before it) and `siblingCount` (number - of elements in parent) as props to all components -* `rawSourcePos` (`boolean`, default: `false`)\ - pass a `sourcePosition` prop to all components with their [position][] * `rehypePlugins` (`Array`, optional)\ list of [rehype plugins][rehype-plugins] to use * `remarkPlugins` (`Array`, optional)\ @@ -195,8 +190,6 @@ The default export is `ReactMarkdown`. options to pass through to [`remark-rehype`][remark-rehype] * `skipHtml` (`boolean`, default: `false`)\ ignore HTML in markdown completely -* `sourcePos` (`boolean`, default: `false`)\ - pass a `data-sourcepos` prop to all components with a serialized position * `transformImageUri` (`(src, alt, title) => string`, default: [`uriTransformer`][uri-transformer])\ change URLs on images; @@ -349,9 +342,9 @@ ReactDom.render( children={markdown} components={{ code(props) { - const {children, className, inline, node, ...rest} = props + const {children, className, node, ...rest} = props const match = /language-(\w+)/.exec(className || '') - return !inline && match ? ( + return match ? ( a), '

a

') + assert.equal(asHtml(), '

a

') }) await t.test('should warn w/ `source`', function () { @@ -33,7 +33,7 @@ test('react-markdown', async function (t) { console.warn = capture // @ts-expect-error: check how the runtime handles untyped `source`. - assert.equal(asHtml(b), '

b

') + assert.equal(asHtml(), '') assert.equal( message, '[react-markdown] Warning: please use `children` instead of `source` (see for more info)' @@ -86,10 +86,10 @@ test('react-markdown', async function (t) { console.warn = capture // @ts-expect-error: check how the runtime handles invalid `children`. - assert.equal(asHtml(), '') + assert.equal(asHtml(), '') assert.equal( message, - '[react-markdown] Warning: please pass a string as `children` (not: `false`)' + '[react-markdown] Warning: please pass a string as `children` (not: `true`)' ) console.error = error @@ -119,8 +119,11 @@ test('react-markdown', async function (t) { console.warn = capture - // @ts-expect-error: check how the runtime handles deprecated `allowDangerousHtml`. - assert.equal(asHtml(a), '

a

') + assert.equal( + // @ts-expect-error: check how the runtime handles deprecated `allowDangerousHtml`. + asHtml(), + '

a

' + ) assert.equal( message, '[react-markdown] Warning: please remove `allowDangerousHtml` (see for more info)' @@ -139,11 +142,30 @@ test('react-markdown', async function (t) { await t.test('should support `className`', function () { assert.equal( - asHtml(a), + asHtml(), '

a

' ) }) + await t.test('should support `className` (if w/o root)', function () { + assert.equal( + asHtml( + + ), + '
' + ) + + function plugin() { + /** + * @returns {Root} + */ + return function () { + // @ts-expect-error: check how non-roots are handled. + return {type: 'comment', value: 'things!'} + } + } + }) + await t.test('should support a block quote', function () { assert.equal( asHtml(), @@ -505,13 +527,6 @@ test('react-markdown', async function (t) { assert.equal(actual, '

abc

') }) - await t.test('should support `sourcePos`', function () { - assert.equal( - asHtml(), - '

a

' - ) - }) - await t.test( 'should support `allowedElements` (drop unlisted nodes)', function () { @@ -622,6 +637,7 @@ test('react-markdown', async function (t) { components={{ p(props) { const {node, ...rest} = props + assert.deepEqual(rest, {children: 'a'}) return
} }} @@ -632,6 +648,12 @@ test('react-markdown', async function (t) { }) await t.test('should fail on an invalid component', function () { + const warn = console.warn + /** @type {unknown} */ + let message + + console.error = capture + assert.throws(function () { asHtml( ) - }, /Unexpected value `123` for `h1`, expected component or tag name/) - }) + }, /Element type is invalid/) - await t.test('should support `null`, `undefined` in components', function () { - assert.equal( - asHtml( - - ), - '

a

' - ) + console.error = warn + + assert.match(String(message), /Warning: React.jsx: type is invalid/) + + /** + * @param {unknown} d + * @returns {undefined} + */ + function capture(d) { + message = d + } }) - await t.test('should support `components` (headings; `level`)', function () { + await t.test('should support `components` (headings)', function () { let calls = 0 assert.equal( @@ -677,18 +695,18 @@ test('react-markdown', async function (t) { assert.equal(calls, 2) /** - * @param {HeadingProps} props + * @param {JSX.IntrinsicElements['h1'] & ExtraProps} props */ function heading(props) { - const {level, node, ...rest} = props - assert.equal(typeof level, 'number') + const {node, ...rest} = props + assert(node) + assert(node.tagName === 'h1' || node.tagName === 'h2') calls++ - const H = `h${level}` - return + return } }) - await t.test('should support `components` (code; `inline`)', function () { + await t.test('should support `components` (code)', function () { let calls = 0 assert.equal( asHtml( @@ -696,9 +714,9 @@ test('react-markdown', async function (t) { children={'```\na\n```\n\n\tb\n\n`c`'} components={{ code(props) { - const {inline, node, ...rest} = props - // To do: this should always be boolean on `code`? - assert(inline === undefined || typeof inline === 'boolean') + const {node, ...rest} = props + assert(node) + assert(node.tagName === 'code') calls++ return } @@ -711,90 +729,80 @@ test('react-markdown', async function (t) { assert.equal(calls, 3) }) - await t.test( - 'should support `components` (li; `checked`, `index`, `ordered`)', - function () { - let calls = 0 + await t.test('should support `components` (li)', function () { + let calls = 0 - assert.equal( - asHtml( - = 0, true) - calls++ - return
  • - } - }} - remarkPlugins={[remarkGfm]} - /> - ), - '
      \n
    • a
    • \n
    \n
      \n
    1. b
    2. \n
    ' - ) + assert.equal( + asHtml( + + } + }} + remarkPlugins={[remarkGfm]} + /> + ), + '
      \n
    • a
    • \n
    \n
      \n
    1. b
    2. \n
    ' + ) - assert.equal(calls, 2) - } - ) + assert.equal(calls, 2) + }) - await t.test( - 'should support `components` (ol; `depth`, `ordered`)', - function () { - let calls = 0 + await t.test('should support `components` (ol)', function () { + let calls = 0 - assert.equal( - asHtml( - - } - }} - /> - ), - '
      \n
    1. a
    2. \n
    ' - ) + assert.equal( + asHtml( + + } + }} + /> + ), + '
      \n
    1. a
    2. \n
    ' + ) - assert.equal(calls, 1) - } - ) + assert.equal(calls, 1) + }) - await t.test( - 'should support `components` (ul; `depth`, `ordered`)', - function () { - let calls = 0 + await t.test('should support `components` (ul)', function () { + let calls = 0 - assert.equal( - asHtml( - - } - }} - /> - ), - '
      \n
    • a
    • \n
    ' - ) + assert.equal( + asHtml( + + } + }} + /> + ), + '
      \n
    • a
    • \n
    ' + ) - assert.equal(calls, 1) - } - ) + assert.equal(calls, 1) + }) - await t.test('should support `components` (tr; `isHeader`)', function () { + await t.test('should support `components` (tr)', function () { let calls = 0 assert.equal( @@ -803,8 +811,9 @@ test('react-markdown', async function (t) { children={'|a|\n|-|\n|b|'} components={{ tr(props) { - const {isHeader, node, ...rest} = props - assert.equal(typeof isHeader, 'boolean') + const {node, ...rest} = props + assert(node) + assert(node.tagName === 'tr') calls++ return } @@ -818,7 +827,7 @@ test('react-markdown', async function (t) { assert.equal(calls, 2) }) - await t.test('should support `components` (td, th; `isHeader`)', function () { + await t.test('should support `components` (td, th)', function () { let tdCalls = 0 let thCalls = 0 @@ -828,14 +837,16 @@ test('react-markdown', async function (t) { children={'|a|\n|-|\n|b|'} components={{ td(props) { - const {isHeader, node, ...rest} = props - assert.equal(isHeader, false) + const {node, ...rest} = props + assert(node) + assert(node.tagName === 'td') tdCalls++ return }, th(props) { - const {isHeader, node, ...rest} = props - assert.equal(isHeader, true) + const {node, ...rest} = props + assert(node) + assert(node.tagName === 'th') thCalls++ return } @@ -890,60 +901,6 @@ test('react-markdown', async function (t) { assert.equal(calls, 1) }) - await t.test( - 'should support `rawSourcePos` (pass `sourcePosition` to components)', - function () { - let calls = 0 - assert.equal( - asHtml( - - } - }} - /> - ), - '

    a

    ' - ) - - assert.equal(calls, 1) - } - ) - - await t.test( - 'should support `includeElementIndex` (pass `index` to components)', - function () { - let calls = 0 - assert.equal( - asHtml( - {rest.children}

    - } - }} - /> - ), - '

    a

    ' - ) - assert.equal(calls, 1) - } - ) - await t.test('should support plugins (`remark-gfm`)', function () { assert.equal( asHtml(), @@ -1201,10 +1158,8 @@ test('react-markdown', async function (t) { } }) - await t.test('should fail on a plugin replacing `root`', function () { - assert.throws(function () { - asHtml() - }, /Unexpected `comment` node, expected `root/) + await t.test('should not fail on a plugin replacing `root`', function () { + assert.equal(asHtml(), '') function plugin() { /**