index.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. /**
  2. * @import {Element, Nodes, Parents, Root} from 'hast'
  3. * @import {Root as MdastRoot} from 'mdast'
  4. * @import {ComponentType, JSX, ReactElement, ReactNode} from 'react'
  5. * @import {Options as RemarkRehypeOptions} from 'remark-rehype'
  6. * @import {BuildVisitor} from 'unist-util-visit'
  7. * @import {PluggableList, Processor} from 'unified'
  8. */
  9. /**
  10. * @callback AllowElement
  11. * Filter elements.
  12. * @param {Readonly<Element>} element
  13. * Element to check.
  14. * @param {number} index
  15. * Index of `element` in `parent`.
  16. * @param {Readonly<Parents> | undefined} parent
  17. * Parent of `element`.
  18. * @returns {boolean | null | undefined}
  19. * Whether to allow `element` (default: `false`).
  20. */
  21. /**
  22. * @typedef ExtraProps
  23. * Extra fields we pass.
  24. * @property {Element | undefined} [node]
  25. * passed when `passNode` is on.
  26. */
  27. /**
  28. * @typedef {{
  29. * [Key in keyof JSX.IntrinsicElements]?: ComponentType<JSX.IntrinsicElements[Key] & ExtraProps> | keyof JSX.IntrinsicElements
  30. * }} Components
  31. * Map tag names to components.
  32. */
  33. /**
  34. * @typedef Deprecation
  35. * Deprecation.
  36. * @property {string} from
  37. * Old field.
  38. * @property {string} id
  39. * ID in readme.
  40. * @property {keyof Options} [to]
  41. * New field.
  42. */
  43. /**
  44. * @typedef Options
  45. * Configuration.
  46. * @property {AllowElement | null | undefined} [allowElement]
  47. * Filter elements (optional);
  48. * `allowedElements` / `disallowedElements` is used first.
  49. * @property {ReadonlyArray<string> | null | undefined} [allowedElements]
  50. * Tag names to allow (default: all tag names);
  51. * cannot combine w/ `disallowedElements`.
  52. * @property {string | null | undefined} [children]
  53. * Markdown.
  54. * @property {Components | null | undefined} [components]
  55. * Map tag names to components.
  56. * @property {ReadonlyArray<string> | null | undefined} [disallowedElements]
  57. * Tag names to disallow (default: `[]`);
  58. * cannot combine w/ `allowedElements`.
  59. * @property {PluggableList | null | undefined} [rehypePlugins]
  60. * List of rehype plugins to use.
  61. * @property {PluggableList | null | undefined} [remarkPlugins]
  62. * List of remark plugins to use.
  63. * @property {Readonly<RemarkRehypeOptions> | null | undefined} [remarkRehypeOptions]
  64. * Options to pass through to `remark-rehype`.
  65. * @property {boolean | null | undefined} [skipHtml=false]
  66. * Ignore HTML in markdown completely (default: `false`).
  67. * @property {boolean | null | undefined} [unwrapDisallowed=false]
  68. * Extract (unwrap) what’s in disallowed elements (default: `false`);
  69. * normally when say `strong` is not allowed, it and it’s children are dropped,
  70. * with `unwrapDisallowed` the element itself is replaced by its children.
  71. * @property {UrlTransform | null | undefined} [urlTransform]
  72. * Change URLs (default: `defaultUrlTransform`)
  73. */
  74. /**
  75. * @typedef HooksOptionsOnly
  76. * Configuration specifically for {@linkcode MarkdownHooks}.
  77. * @property {ReactNode | null | undefined} [fallback]
  78. * Content to render while the processor processing the markdown (optional).
  79. */
  80. /**
  81. * @typedef {Options & HooksOptionsOnly} HooksOptions
  82. * Configuration for {@linkcode MarkdownHooks};
  83. * extends the regular {@linkcode Options} with a `fallback` prop.
  84. */
  85. /**
  86. * @callback UrlTransform
  87. * Transform all URLs.
  88. * @param {string} url
  89. * URL.
  90. * @param {string} key
  91. * Property name (example: `'href'`).
  92. * @param {Readonly<Element>} node
  93. * Node.
  94. * @returns {string | null | undefined}
  95. * Transformed URL (optional).
  96. */
  97. import {unreachable} from 'devlop'
  98. import {toJsxRuntime} from 'hast-util-to-jsx-runtime'
  99. import {urlAttributes} from 'html-url-attributes'
  100. import {Fragment, jsx, jsxs} from 'react/jsx-runtime'
  101. import {useEffect, useState} from 'react'
  102. import remarkParse from 'remark-parse'
  103. import remarkRehype from 'remark-rehype'
  104. import {unified} from 'unified'
  105. import {visit} from 'unist-util-visit'
  106. import {VFile} from 'vfile'
  107. const changelog =
  108. 'https://github.com/remarkjs/react-markdown/blob/main/changelog.md'
  109. /** @type {PluggableList} */
  110. const emptyPlugins = []
  111. /** @type {Readonly<RemarkRehypeOptions>} */
  112. const emptyRemarkRehypeOptions = {allowDangerousHtml: true}
  113. const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i
  114. // Mutable because we `delete` any time it’s used and a message is sent.
  115. /** @type {ReadonlyArray<Readonly<Deprecation>>} */
  116. const deprecations = [
  117. {from: 'astPlugins', id: 'remove-buggy-html-in-markdown-parser'},
  118. {from: 'allowDangerousHtml', id: 'remove-buggy-html-in-markdown-parser'},
  119. {
  120. from: 'allowNode',
  121. id: 'replace-allownode-allowedtypes-and-disallowedtypes',
  122. to: 'allowElement'
  123. },
  124. {
  125. from: 'allowedTypes',
  126. id: 'replace-allownode-allowedtypes-and-disallowedtypes',
  127. to: 'allowedElements'
  128. },
  129. {from: 'className', id: 'remove-classname'},
  130. {
  131. from: 'disallowedTypes',
  132. id: 'replace-allownode-allowedtypes-and-disallowedtypes',
  133. to: 'disallowedElements'
  134. },
  135. {from: 'escapeHtml', id: 'remove-buggy-html-in-markdown-parser'},
  136. {from: 'includeElementIndex', id: '#remove-includeelementindex'},
  137. {
  138. from: 'includeNodeIndex',
  139. id: 'change-includenodeindex-to-includeelementindex'
  140. },
  141. {from: 'linkTarget', id: 'remove-linktarget'},
  142. {from: 'plugins', id: 'change-plugins-to-remarkplugins', to: 'remarkPlugins'},
  143. {from: 'rawSourcePos', id: '#remove-rawsourcepos'},
  144. {from: 'renderers', id: 'change-renderers-to-components', to: 'components'},
  145. {from: 'source', id: 'change-source-to-children', to: 'children'},
  146. {from: 'sourcePos', id: '#remove-sourcepos'},
  147. {from: 'transformImageUri', id: '#add-urltransform', to: 'urlTransform'},
  148. {from: 'transformLinkUri', id: '#add-urltransform', to: 'urlTransform'}
  149. ]
  150. /**
  151. * Component to render markdown.
  152. *
  153. * This is a synchronous component.
  154. * When using async plugins,
  155. * see {@linkcode MarkdownAsync} or {@linkcode MarkdownHooks}.
  156. *
  157. * @param {Readonly<Options>} options
  158. * Props.
  159. * @returns {ReactElement}
  160. * React element.
  161. */
  162. export function Markdown(options) {
  163. const processor = createProcessor(options)
  164. const file = createFile(options)
  165. return post(processor.runSync(processor.parse(file), file), options)
  166. }
  167. /**
  168. * Component to render markdown with support for async plugins
  169. * through async/await.
  170. *
  171. * Components returning promises are supported on the server.
  172. * For async support on the client,
  173. * see {@linkcode MarkdownHooks}.
  174. *
  175. * @param {Readonly<Options>} options
  176. * Props.
  177. * @returns {Promise<ReactElement>}
  178. * Promise to a React element.
  179. */
  180. export async function MarkdownAsync(options) {
  181. const processor = createProcessor(options)
  182. const file = createFile(options)
  183. const tree = await processor.run(processor.parse(file), file)
  184. return post(tree, options)
  185. }
  186. /**
  187. * Component to render markdown with support for async plugins through hooks.
  188. *
  189. * This uses `useEffect` and `useState` hooks.
  190. * Hooks run on the client and do not immediately render something.
  191. * For async support on the server,
  192. * see {@linkcode MarkdownAsync}.
  193. *
  194. * @param {Readonly<HooksOptions>} options
  195. * Props.
  196. * @returns {ReactNode}
  197. * React node.
  198. */
  199. export function MarkdownHooks(options) {
  200. const processor = createProcessor(options)
  201. const [error, setError] = useState(
  202. /** @type {Error | undefined} */ (undefined)
  203. )
  204. const [tree, setTree] = useState(/** @type {Root | undefined} */ (undefined))
  205. useEffect(
  206. function () {
  207. let cancelled = false
  208. const file = createFile(options)
  209. processor.run(processor.parse(file), file, function (error, tree) {
  210. if (!cancelled) {
  211. setError(error)
  212. setTree(tree)
  213. }
  214. })
  215. /**
  216. * @returns {undefined}
  217. * Nothing.
  218. */
  219. return function () {
  220. cancelled = true
  221. }
  222. },
  223. [
  224. options.children,
  225. options.rehypePlugins,
  226. options.remarkPlugins,
  227. options.remarkRehypeOptions
  228. ]
  229. )
  230. if (error) throw error
  231. return tree ? post(tree, options) : options.fallback
  232. }
  233. /**
  234. * Set up the `unified` processor.
  235. *
  236. * @param {Readonly<Options>} options
  237. * Props.
  238. * @returns {Processor<MdastRoot, MdastRoot, Root, undefined, undefined>}
  239. * Result.
  240. */
  241. function createProcessor(options) {
  242. const rehypePlugins = options.rehypePlugins || emptyPlugins
  243. const remarkPlugins = options.remarkPlugins || emptyPlugins
  244. const remarkRehypeOptions = options.remarkRehypeOptions
  245. ? {...options.remarkRehypeOptions, ...emptyRemarkRehypeOptions}
  246. : emptyRemarkRehypeOptions
  247. const processor = unified()
  248. .use(remarkParse)
  249. .use(remarkPlugins)
  250. .use(remarkRehype, remarkRehypeOptions)
  251. .use(rehypePlugins)
  252. return processor
  253. }
  254. /**
  255. * Set up the virtual file.
  256. *
  257. * @param {Readonly<Options>} options
  258. * Props.
  259. * @returns {VFile}
  260. * Result.
  261. */
  262. function createFile(options) {
  263. const children = options.children || ''
  264. const file = new VFile()
  265. if (typeof children === 'string') {
  266. file.value = children
  267. } else {
  268. unreachable(
  269. 'Unexpected value `' +
  270. children +
  271. '` for `children` prop, expected `string`'
  272. )
  273. }
  274. return file
  275. }
  276. /**
  277. * Process the result from unified some more.
  278. *
  279. * @param {Nodes} tree
  280. * Tree.
  281. * @param {Readonly<Options>} options
  282. * Props.
  283. * @returns {ReactElement}
  284. * React element.
  285. */
  286. function post(tree, options) {
  287. const allowedElements = options.allowedElements
  288. const allowElement = options.allowElement
  289. const components = options.components
  290. const disallowedElements = options.disallowedElements
  291. const skipHtml = options.skipHtml
  292. const unwrapDisallowed = options.unwrapDisallowed
  293. const urlTransform = options.urlTransform || defaultUrlTransform
  294. for (const deprecation of deprecations) {
  295. if (Object.hasOwn(options, deprecation.from)) {
  296. unreachable(
  297. 'Unexpected `' +
  298. deprecation.from +
  299. '` prop, ' +
  300. (deprecation.to
  301. ? 'use `' + deprecation.to + '` instead'
  302. : 'remove it') +
  303. ' (see <' +
  304. changelog +
  305. '#' +
  306. deprecation.id +
  307. '> for more info)'
  308. )
  309. }
  310. }
  311. if (allowedElements && disallowedElements) {
  312. unreachable(
  313. 'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other'
  314. )
  315. }
  316. visit(tree, transform)
  317. return toJsxRuntime(tree, {
  318. Fragment,
  319. components,
  320. ignoreInvalidStyle: true,
  321. jsx,
  322. jsxs,
  323. passKeys: true,
  324. passNode: true
  325. })
  326. /** @type {BuildVisitor<Root>} */
  327. function transform(node, index, parent) {
  328. if (node.type === 'raw' && parent && typeof index === 'number') {
  329. if (skipHtml) {
  330. parent.children.splice(index, 1)
  331. } else {
  332. parent.children[index] = {type: 'text', value: node.value}
  333. }
  334. return index
  335. }
  336. if (node.type === 'element') {
  337. /** @type {string} */
  338. let key
  339. for (key in urlAttributes) {
  340. if (
  341. Object.hasOwn(urlAttributes, key) &&
  342. Object.hasOwn(node.properties, key)
  343. ) {
  344. const value = node.properties[key]
  345. const test = urlAttributes[key]
  346. if (test === null || test.includes(node.tagName)) {
  347. node.properties[key] = urlTransform(String(value || ''), key, node)
  348. }
  349. }
  350. }
  351. }
  352. if (node.type === 'element') {
  353. let remove = allowedElements
  354. ? !allowedElements.includes(node.tagName)
  355. : disallowedElements
  356. ? disallowedElements.includes(node.tagName)
  357. : false
  358. if (!remove && allowElement && typeof index === 'number') {
  359. remove = !allowElement(node, index, parent)
  360. }
  361. if (remove && parent && typeof index === 'number') {
  362. if (unwrapDisallowed && node.children) {
  363. parent.children.splice(index, 1, ...node.children)
  364. } else {
  365. parent.children.splice(index, 1)
  366. }
  367. return index
  368. }
  369. }
  370. }
  371. }
  372. /**
  373. * Make a URL safe.
  374. *
  375. * @satisfies {UrlTransform}
  376. * @param {string} value
  377. * URL.
  378. * @returns {string}
  379. * Safe URL.
  380. */
  381. export function defaultUrlTransform(value) {
  382. // Same as:
  383. // <https://github.com/micromark/micromark/blob/929275e/packages/micromark-util-sanitize-uri/dev/index.js#L34>
  384. // But without the `encode` part.
  385. const colon = value.indexOf(':')
  386. const questionMark = value.indexOf('?')
  387. const numberSign = value.indexOf('#')
  388. const slash = value.indexOf('/')
  389. if (
  390. // If there is no protocol, it’s relative.
  391. colon === -1 ||
  392. // If the first colon is after a `?`, `#`, or `/`, it’s not a protocol.
  393. (slash !== -1 && colon > slash) ||
  394. (questionMark !== -1 && colon > questionMark) ||
  395. (numberSign !== -1 && colon > numberSign) ||
  396. // It is a protocol, it should be allowed.
  397. safeProtocol.test(value.slice(0, colon))
  398. ) {
  399. return value
  400. }
  401. return ''
  402. }