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(),
''
)
})
+ 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\n- b
\n
'
- )
+ assert.equal(
+ asHtml(
+
+ }
+ }}
+ remarkPlugins={[remarkGfm]}
+ />
+ ),
+ '\n\n- b
\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- a
\n
'
- )
+ assert.equal(
+ asHtml(
+
+ }
+ }}
+ />
+ ),
+ '\n- a
\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(
-
- }
- }}
- />
- ),
- ''
- )
+ assert.equal(
+ asHtml(
+
+ }
+ }}
+ />
+ ),
+ ''
+ )
- 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() {
/**