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() { /**