diff --git a/index.js b/index.js index 1c36187..ff903ce 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,9 @@ /** - * @typedef {import('hast-util-to-jsx-runtime').Components} Components * @typedef {import('hast-util-to-jsx-runtime').ExtraProps} ExtraProps + * @typedef {import('./lib/index.js').AllowElement} AllowElement + * @typedef {import('./lib/index.js').Components} Components * @typedef {import('./lib/index.js').Options} Options + * @typedef {import('./lib/index.js').UrlTransform} UrlTransform */ export {Markdown as default, defaultUrlTransform} from './lib/index.js' diff --git a/lib/index.js b/lib/index.js index 2ebf91f..335d849 100644 --- a/lib/index.js +++ b/lib/index.js @@ -7,7 +7,7 @@ * @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('hast-util-to-jsx-runtime').Components} JsxRuntimeComponents * @typedef {import('remark-rehype').Options} RemarkRehypeOptions * @typedef {import('unist-util-visit').BuildVisitor} Visitor * @typedef {import('unified').PluggableList} PluggableList @@ -15,7 +15,7 @@ /** * @callback AllowElement - * Decide if `element` should be allowed. + * Filter elements. * @param {Readonly} element * Element to check. * @param {number} index @@ -25,6 +25,9 @@ * @returns {boolean | null | undefined} * Whether to allow `element` (default: `false`). * + * @typedef {Partial} Components + * Map tag names to components. + * * @typedef Deprecation * Deprecation. * @property {string} from @@ -37,20 +40,20 @@ * @typedef Options * Configuration. * @property {AllowElement | null | undefined} [allowElement] - * Function called to check if an element is allowed (when truthy) or not, - * `allowedElements` or `disallowedElements` is used first! + * Filter elements (optional); + * `allowedElements` / `disallowedElements` is used first. * @property {ReadonlyArray | null | undefined} [allowedElements] - * Tag names to allow (cannot combine w/ `disallowedElements`), all tag names - * are allowed by default. + * Tag names to allow (default: all tag names); + * cannot combine w/ `disallowedElements`. * @property {string | null | undefined} [children] - * Markdown to parse. + * Markdown. * @property {string | null | undefined} [className] - * Wrap the markdown in a `div` with this class name. - * @property {Partial | null | undefined} [components] - * Map tag names to React components. + * Wrap in a `div` with this class name. + * @property {Components | null | undefined} [components] + * Map tag names to components. * @property {ReadonlyArray | null | undefined} [disallowedElements] - * Tag names to disallow (cannot combine w/ `allowedElements`), all tag names - * are allowed by default. + * Tag names to disallow (default: `[]`); + * cannot combine w/ `allowedElements`. * @property {PluggableList | null | undefined} [rehypePlugins] * List of rehype plugins to use. * @property {PluggableList | null | undefined} [remarkPlugins] @@ -60,16 +63,16 @@ * @property {boolean | null | undefined} [skipHtml=false] * Ignore HTML in markdown completely (default: `false`). * @property {boolean | null | undefined} [unwrapDisallowed=false] - * Extract (unwrap) the children of not allowed elements (default: `false`); - * normally when say `strong` is disallowed, it and it’s children are dropped, + * Extract (unwrap) what’s in disallowed elements (default: `false`); + * normally when say `strong` is not allowed, it and it’s children are dropped, * with `unwrapDisallowed` the element itself is replaced by its children. * @property {UrlTransform | null | undefined} [urlTransform] * Change URLs (default: `defaultUrlTransform`) * * @callback UrlTransform - * Transform URLs. + * Transform all URLs. * @param {string} url - * URL to transform. + * URL. * @param {string} key * Property name (example: `'href'`). * @param {Readonly} node @@ -139,8 +142,7 @@ const deprecations = [ * Component to render markdown. * * @param {Readonly} options - * Configuration (required). - * Note: React types require that props are passed. + * Props. * @returns {JSX.Element} * React element. */ diff --git a/package.json b/package.json index 0953edc..188d85b 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", + "micromark-util-sanitize-uri": "^2.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", diff --git a/readme.md b/readme.md index 0d37553..7df6a92 100644 --- a/readme.md +++ b/readme.md @@ -18,13 +18,13 @@ React component to render markdown. ## Feature highlights -* [x] **[safe][security] by default** +* [x] **[safe][section-security] by default** (no `dangerouslySetInnerHTML` or XSS attacks) -* [x] **[components][]** +* [x] **[components][section-components]** (pass your own component to use instead of `

` for `## hi`) -* [x] **[plugins][]** +* [x] **[plugins][section-plugins]** (many plugins you can pick and choose from) -* [x] **[compliant][syntax]** +* [x] **[compliant][section-syntax]** (100% to CommonMark, 100% to GFM with a plugin) ## Contents @@ -34,8 +34,13 @@ React component to render markdown. * [Install](#install) * [Use](#use) * [API](#api) - * [`props`](#props) - * [`uriTransformer`](#uritransformer) + * [`Markdown`](#markdown) + * [`defaultUrlTransform(url)`](#defaulturltransformurl) + * [`AllowElement`](#allowelement) + * [`Components`](#components) + * [`ExtraProps`](#extraprops) + * [`Options`](#options) + * [`UrlTransform`](#urltransform) * [Examples](#examples) * [Use a plugin](#use-a-plugin) * [Use a plugin with options](#use-a-plugin-with-options) @@ -57,25 +62,23 @@ React component to render markdown. This package is a [React][] component that can be given a string of markdown that it’ll safely render to React elements. -You can pass plugins to change how markdown is transformed to React elements and -pass components that will be used instead of normal HTML elements. +You can pass plugins to change how markdown is transformed and pass components +that will be used instead of normal HTML elements. -* to learn markdown, see this [cheatsheet and tutorial][cheat] +* to learn markdown, see this [cheatsheet and tutorial][commonmark-help] * to try out `react-markdown`, see [our demo][demo] ## When should I use this? There are other ways to use markdown in React out there so why use this one? -The two main reasons are that they often rely on `dangerouslySetInnerHTML` or -have bugs with how they handle markdown. -`react-markdown` uses a syntax tree to build the virtual dom which allows for -updating only the changing DOM instead of completely overwriting. -`react-markdown` is 100% CommonMark compliant and has plugins to support other -syntax extensions (such as GFM). - -These features are supported because we use [unified][], specifically [remark][] -for markdown and [rehype][] for HTML, which are popular tools to transform -content with plugins. +The three main reasons are that they often rely on `dangerouslySetInnerHTML`, +have bugs with how they handle markdown, or don’t let you swap elements for +components. +`react-markdown` builds a virtual DOM, so React only replaces what changed, +from a syntax tree. +That’s supported because we use [unified][], specifically [remark][] for +markdown and [rehype][] for HTML, which are popular tools to transform content +with plugins. This package focusses on making it easy for beginners to safely use markdown in React. @@ -87,7 +90,7 @@ If you instead want to use JavaScript and JSX *inside* markdown files, use ## Install This package is [ESM only][esm]. -In Node.js (version 12.20+, 14.14+, or 16.0+), install with [npm][]: +In Node.js (version 16+), install with [npm][]: ```sh npm install react-markdown @@ -96,14 +99,14 @@ npm install react-markdown In Deno with [`esm.sh`][esmsh]: ```js -import Markdown from 'https://esm.sh/react-markdown@7' +import Markdown from 'https://esm.sh/react-markdown@8' ``` In browsers with [`esm.sh`][esmsh]: ```html ``` @@ -113,10 +116,12 @@ A basic hello world: ```jsx import React from 'react' -import Markdown from 'react-markdown' import ReactDom from 'react-dom' +import Markdown from 'react-markdown' -ReactDom.render(# Hello, *world*!, document.body) +const markdown = '# Hi, *Pluto*!' + +ReactDom.render({markdown}, document.body) ```
@@ -124,15 +129,15 @@ ReactDom.render(# Hello, *world*!, document.body) ```jsx

- Hello, world! + Hi, Pluto!

```
Here is an example that shows passing the markdown as a string and how -to use a plugin ([`remark-gfm`][gfm], which adds support for strikethrough, -tables, tasklists and URLs directly): +to use a plugin ([`remark-gfm`][remark-gfm], which adds support for +footnotes, strikethrough, tables, tasklists and URLs directly): ```jsx import React from 'react' @@ -140,10 +145,10 @@ import ReactDom from 'react-dom' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' -const markdown = `Just a link: https://reactjs.com.` +const markdown = `Just a link: www.nasa.gov.` ReactDom.render( - , + {markdown}, document.body ) ``` @@ -153,7 +158,7 @@ ReactDom.render( ```jsx

- Just a link: https://reactjs.com. + Just a link: www.nasa.gov.

``` @@ -162,67 +167,148 @@ ReactDom.render( ## API This package exports the following identifier: -[`uriTransformer`][uri-transformer]. -The default export is `Markdown`. - -### `props` - -* `allowElement` (`(element, index, parent) => boolean?`, optional)\ - function called to check if an element is allowed (when truthy) or not, - `allowedElements` or `disallowedElements` is used first! -* `allowedElements` (`Array`, optional)\ - tag names to allow (cannot combine w/ `disallowedElements`), all tag names - are allowed by default -* `children` (`string`, optional)\ - markdown to parse -* `className` (`string?`)\ - wrap the markdown in a `div` with this class name -* `components` (`Record`, optional)\ - map tag names to React components -* `disallowedElements` (`Array`, optional)\ - tag names to disallow (cannot combine w/ `allowedElements`), all tag names - are allowed by default -* `rehypePlugins` (`Array`, optional)\ - list of [rehype plugins][rehype-plugins] to use -* `remarkPlugins` (`Array`, optional)\ - list of [remark plugins][remark-plugins] to use -* `remarkRehypeOptions` (`Object?`, optional)\ - options to pass through to [`remark-rehype`][remark-rehype] -* `skipHtml` (`boolean`, default: `false`)\ - ignore HTML in markdown completely -* `transformImageUri` (`(src, alt, title) => string`, default: - [`uriTransformer`][uri-transformer])\ - change URLs on images; - pass `false` to allow all URLs, which is unsafe (see [security][]) -* `transformLinkUri` (`(href, children, title) => string`, default: - [`uriTransformer`][uri-transformer])\ - change URLs on links; - pass `false` to allow all URLs, which is unsafe (see [security][]) -* `unwrapDisallowed` (`boolean`, default: `false`)\ - extract (unwrap) the children of not allowed elements; - normally when say `strong` is disallowed, it and it’s children are dropped, +[`defaultUrlTransform`][api-default-url-transform]. +The default export is [`Markdown`][api-markdown]. + +### `Markdown` + +Component to render markdown. + +###### Parameters + +* `options` ([`Options`][api-options]) + — props + +###### Returns + +React element (`JSX.Element`). + +### `defaultUrlTransform(url)` + +Make a URL safe. + +###### Parameters + +* `url` (`string`) + — URL + +###### Returns + +Safe URL (`string`). + +### `AllowElement` + +Filter elements (TypeScript type). + +###### Fields + +* `node` ([`Element` from `hast`][hast-element]) + — element to check +* `index` (`number | undefined`) + — index of `element` in `parent` +* `parent` ([`Node` from `hast`][hast-node]) + — parent of `element` + +###### Returns + +Whether to allow `element` (`boolean`, optional). + +### `Components` + +Map tag names to components (TypeScript type). + +###### Type + +```ts +import type {Element} from 'hast' + +type Components = Partial<{ + [TagName in keyof JSX.IntrinsicElements]: + // Class component: + | (new (props: JSX.IntrinsicElements[TagName] & ExtraProps) => JSX.ElementClass) + // Function component: + | ((props: JSX.IntrinsicElements[TagName] & ExtraProps) => JSX.Element | string | null | undefined) + // Tag name: + | keyof JSX.IntrinsicElements +}> +``` + +### `ExtraProps` + +Extra fields we pass to components (TypeScript type). + +###### Fields + +* `node` ([`Element` from `hast`][hast-element], optional) + — original node + +### `Options` + +Configuration (TypeScript type). + +###### Fields + +* `allowElement` ([`AllowElement`][api-allow-element], optional) + — filter elements; + `allowedElements` / `disallowedElements` is used first +* `allowedElements` (`Array`, default: all tag names) + — tag names to allow; + cannot combine w/ `disallowedElements` +* `children` (`string`, optional) + — markdown +* `className` (`string`, optional) + — wrap in a `div` with this class name +* `components` ([`Components`][api-components], optional) + — map tag names to components +* `disallowedElements` (`Array`, default: `[]`) + — tag names to disallow; + cannot combine w/ `allowedElements` +* `rehypePlugins` (`Array`, optional) + — list of [rehype plugins][rehype-plugins] to use +* `remarkPlugins` (`Array`, optional) + — list of [remark plugins][remark-plugins] to use +* `remarkRehypeOptions` ([`Options` from + `remark-rehype`][remark-rehype-options], optional) + — options to pass through to `remark-rehype` +* `skipHtml` (`boolean`, default: `false`) + — ignore HTML in markdown completely +* `unwrapDisallowed` (`boolean`, default: `false`) + — extract (unwrap) what’s in disallowed elements; + normally when say `strong` is not allowed, it and it’s children are dropped, with `unwrapDisallowed` the element itself is replaced by its children +* `urlTransform` ([`UrlTransform`][api-url-transform], default: + [`defaultUrlTransform`][api-default-url-transform]) + — change URLs + +### `UrlTransform` -### `uriTransformer` +Transform URLs (TypeScript type). -Our default URL transform, which you can overwrite (see props above). -It’s given a URL and cleans it, by allowing only `http:`, `https:`, `mailto:`, -and `tel:` URLs, absolute paths (`/example.png`), and hashes (`#some-place`). +###### Fields -See the [source code here][uri]. +* `url` (`string`) + — URL +* `key` (`string`, example: `'href'`) + — property name +* `node` ([`Element` from `hast`][hast-element]) + — element to check + +###### Returns + +Transformed URL (`string`, optional). ## Examples ### Use a plugin This example shows how to use a remark plugin. -In this case, [`remark-gfm`][gfm], which adds support for strikethrough, tables, -tasklists and URLs directly: +In this case, [`remark-gfm`][remark-gfm], which adds support for strikethrough, +tables, tasklists and URLs directly: ```jsx import React from 'react' -import Markdown from 'react-markdown' import ReactDom from 'react-dom' +import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' const markdown = `A paragraph with *emphasis* and **strong importance**. @@ -240,7 +326,7 @@ A table: ` ReactDom.render( - , + {markdown}, document.body ) ``` @@ -259,21 +345,21 @@ ReactDom.render( https://reactjs.org.

-
    +
    • Lists
    • -
    • - todo +
    • + todo
    • -
    • - done +
    • + done

    A table:

    - - + +
    abab
    @@ -287,17 +373,20 @@ ReactDom.render( This example shows how to use a plugin and give it options. To do that, use an array with the plugin at the first place, and the options second. -[`remark-gfm`][gfm] has an option to allow only double tildes for strikethrough: +[`remark-gfm`][remark-gfm] has an option to allow only double tildes for +strikethrough: ```jsx import React from 'react' -import Markdown from 'react-markdown' import ReactDom from 'react-dom' +import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' +const markdown = 'This ~is not~ strikethrough, but ~~this is~~!' + ReactDom.render( - This ~is not~ strikethrough, but ~~this is~~! + {markdown} , document.body ) @@ -322,6 +411,8 @@ In this case, we apply syntax highlighting with the seriously super amazing [`react-syntax-highlighter`][react-syntax-highlighter] by [**@conorhastings**][conor]: + + ```jsx import React from 'react' import ReactDom from 'react-dom' @@ -380,25 +471,24 @@ ReactDom.render( ### Use remark and rehype plugins (math) -This example shows how a syntax extension (through [`remark-math`][math]) +This example shows how a syntax extension (through [`remark-math`][remark-math]) is used to support math in markdown, and a transform plugin -([`rehype-katex`][katex]) to render that math. +([`rehype-katex`][rehype-katex]) to render that math. ```jsx import React from 'react' import ReactDom from 'react-dom' import Markdown from 'react-markdown' -import remarkMath from 'remark-math' import rehypeKatex from 'rehype-katex' - +import remarkMath from 'remark-math' import 'katex/dist/katex.min.css' // `rehype-katex` does not import the CSS for you +const markdown = `The lift coefficient ($C_L$) is a dimensionless coefficient.` + ReactDom.render( - , + + {markdown} + , document.body ) ``` @@ -409,14 +499,12 @@ ReactDom.render( ```jsx

    The lift coefficient ( - - - - {/* … */} - - + + + {/* … */} + + ) is a dimensionless coefficient. @@ -452,15 +540,23 @@ extensions. ## Types This package is fully typed with [TypeScript][]. -It exports `Options` and `Components` types, which specify the interface of the -accepted props and components. +It exports the additional types +[`AllowElement`][api-allow-element], +[`ExtraProps`][api-extra-props], +[`Components`][api-components], +[`Options`][api-options], and +[`UrlTransform`][api-url-transform]. ## Compatibility -Projects maintained by the unified collective are compatible with all maintained +Projects maintained by the unified collective are compatible with maintained versions of Node.js. -As of now, that is Node.js 12.20+, 14.14+, and 16.0+. -Our projects sometimes work with older versions, but this is not guaranteed. + +When we cut a new major release, we drop support for unmaintained versions of +Node. +This means we try to keep the current release line, `react-markdown@^8`, +compatible with Node.js 12. + They work in all modern browsers (essentially: everything not IE 11). You can use a bundler (such as esbuild, webpack, or Rollup) to use this package in your project, and use its options (or plugins) to add support for legacy @@ -501,7 +597,7 @@ because it is dangerous and defeats the purpose of this library. However, if you are in a trusted environment (you trust the markdown), and can spare the bundle size (±60kb minzipped), then you can use -[`rehype-raw`][raw]: +[`rehype-raw`][rehype-raw]: ```jsx import React from 'react' @@ -509,14 +605,14 @@ import ReactDom from 'react-dom' import Markdown from 'react-markdown' import rehypeRaw from 'rehype-raw' -const input = `

    +const markdown = `
    Some *emphasis* and strong!
    ` ReactDom.render( - , + {markdown}, document.body ) ``` @@ -525,15 +621,17 @@ ReactDom.render( Show equivalent JSX ```jsx -
    -

    Some emphasis and strong!

    +
    +

    + Some emphasis and strong! +

    ``` **Note**: HTML in markdown is still bound by how [HTML works in -CommonMark][cm-html]. +CommonMark][commonmark-html]. Make sure to use blank lines around block-level HTML that again contains markdown! @@ -543,7 +641,6 @@ You can also change the things that come from markdown: ```jsx