UNPKG

10.1 kBJavaScriptView Raw
1// Register `Raw` in tree:
2/// <reference types="mdast-util-to-hast" />
3
4/**
5 * @typedef {import('hast').Element} Element
6 * @typedef {import('hast').ElementContent} ElementContent
7 * @typedef {import('hast').Nodes} Nodes
8 * @typedef {import('hast').Parents} Parents
9 * @typedef {import('hast').Root} Root
10 * @typedef {import('hast-util-to-jsx-runtime').Components} JsxRuntimeComponents
11 * @typedef {import('remark-rehype').Options} RemarkRehypeOptions
12 * @typedef {import('unist-util-visit').BuildVisitor<Root>} Visitor
13 * @typedef {import('unified').PluggableList} PluggableList
14 */
15
16/**
17 * @callback AllowElement
18 * Filter elements.
19 * @param {Readonly<Element>} element
20 * Element to check.
21 * @param {number} index
22 * Index of `element` in `parent`.
23 * @param {Readonly<Parents> | undefined} parent
24 * Parent of `element`.
25 * @returns {boolean | null | undefined}
26 * Whether to allow `element` (default: `false`).
27 *
28 * @typedef {Partial<JsxRuntimeComponents>} Components
29 * Map tag names to components.
30 *
31 * @typedef Deprecation
32 * Deprecation.
33 * @property {string} from
34 * Old field.
35 * @property {string} id
36 * ID in readme.
37 * @property {keyof Options} [to]
38 * New field.
39 *
40 * @typedef Options
41 * Configuration.
42 * @property {AllowElement | null | undefined} [allowElement]
43 * Filter elements (optional);
44 * `allowedElements` / `disallowedElements` is used first.
45 * @property {ReadonlyArray<string> | null | undefined} [allowedElements]
46 * Tag names to allow (default: all tag names);
47 * cannot combine w/ `disallowedElements`.
48 * @property {string | null | undefined} [children]
49 * Markdown.
50 * @property {string | null | undefined} [className]
51 * Wrap in a `div` with this class name.
52 * @property {Components | null | undefined} [components]
53 * Map tag names to components.
54 * @property {ReadonlyArray<string> | null | undefined} [disallowedElements]
55 * Tag names to disallow (default: `[]`);
56 * cannot combine w/ `allowedElements`.
57 * @property {PluggableList | null | undefined} [rehypePlugins]
58 * List of rehype plugins to use.
59 * @property {PluggableList | null | undefined} [remarkPlugins]
60 * List of remark plugins to use.
61 * @property {Readonly<RemarkRehypeOptions> | null | undefined} [remarkRehypeOptions]
62 * Options to pass through to `remark-rehype`.
63 * @property {boolean | null | undefined} [skipHtml=false]
64 * Ignore HTML in markdown completely (default: `false`).
65 * @property {boolean | null | undefined} [unwrapDisallowed=false]
66 * Extract (unwrap) what’s in disallowed elements (default: `false`);
67 * normally when say `strong` is not allowed, it and it’s children are dropped,
68 * with `unwrapDisallowed` the element itself is replaced by its children.
69 * @property {UrlTransform | null | undefined} [urlTransform]
70 * Change URLs (default: `defaultUrlTransform`)
71 *
72 * @callback UrlTransform
73 * Transform all URLs.
74 * @param {string} url
75 * URL.
76 * @param {string} key
77 * Property name (example: `'href'`).
78 * @param {Readonly<Element>} node
79 * Node.
80 * @returns {string | null | undefined}
81 * Transformed URL (optional).
82 */
83
84import {unreachable} from 'devlop'
85import {toJsxRuntime} from 'hast-util-to-jsx-runtime'
86import {urlAttributes} from 'html-url-attributes'
87// @ts-expect-error: untyped.
88import {Fragment, jsx, jsxs} from 'react/jsx-runtime'
89import remarkParse from 'remark-parse'
90import remarkRehype from 'remark-rehype'
91import {unified} from 'unified'
92import {visit} from 'unist-util-visit'
93import {VFile} from 'vfile'
94
95const changelog =
96 'https://github.com/remarkjs/react-markdown/blob/main/changelog.md'
97
98/** @type {PluggableList} */
99const emptyPlugins = []
100/** @type {Readonly<RemarkRehypeOptions>} */
101const emptyRemarkRehypeOptions = {allowDangerousHtml: true}
102const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i
103
104// Mutable because we `delete` any time it’s used and a message is sent.
105/** @type {ReadonlyArray<Readonly<Deprecation>>} */
106const deprecations = [
107 {from: 'astPlugins', id: 'remove-buggy-html-in-markdown-parser'},
108 {from: 'allowDangerousHtml', id: 'remove-buggy-html-in-markdown-parser'},
109 {
110 from: 'allowNode',
111 id: 'replace-allownode-allowedtypes-and-disallowedtypes',
112 to: 'allowElement'
113 },
114 {
115 from: 'allowedTypes',
116 id: 'replace-allownode-allowedtypes-and-disallowedtypes',
117 to: 'allowedElements'
118 },
119 {
120 from: 'disallowedTypes',
121 id: 'replace-allownode-allowedtypes-and-disallowedtypes',
122 to: 'disallowedElements'
123 },
124 {from: 'escapeHtml', id: 'remove-buggy-html-in-markdown-parser'},
125 {from: 'includeElementIndex', id: '#remove-includeelementindex'},
126 {
127 from: 'includeNodeIndex',
128 id: 'change-includenodeindex-to-includeelementindex'
129 },
130 {from: 'linkTarget', id: 'remove-linktarget'},
131 {from: 'plugins', id: 'change-plugins-to-remarkplugins', to: 'remarkPlugins'},
132 {from: 'rawSourcePos', id: '#remove-rawsourcepos'},
133 {from: 'renderers', id: 'change-renderers-to-components', to: 'components'},
134 {from: 'source', id: 'change-source-to-children', to: 'children'},
135 {from: 'sourcePos', id: '#remove-sourcepos'},
136 {from: 'transformImageUri', id: '#add-urltransform', to: 'urlTransform'},
137 {from: 'transformLinkUri', id: '#add-urltransform', to: 'urlTransform'}
138]
139
140/**
141 * Component to render markdown.
142 *
143 * @param {Readonly<Options>} options
144 * Props.
145 * @returns {JSX.Element}
146 * React element.
147 */
148export function Markdown(options) {
149 const allowedElements = options.allowedElements
150 const allowElement = options.allowElement
151 const children = options.children || ''
152 const className = options.className
153 const components = options.components
154 const disallowedElements = options.disallowedElements
155 const rehypePlugins = options.rehypePlugins || emptyPlugins
156 const remarkPlugins = options.remarkPlugins || emptyPlugins
157 const remarkRehypeOptions = options.remarkRehypeOptions
158 ? {...options.remarkRehypeOptions, ...emptyRemarkRehypeOptions}
159 : emptyRemarkRehypeOptions
160 const skipHtml = options.skipHtml
161 const unwrapDisallowed = options.unwrapDisallowed
162 const urlTransform = options.urlTransform || defaultUrlTransform
163
164 const processor = unified()
165 .use(remarkParse)
166 .use(remarkPlugins)
167 .use(remarkRehype, remarkRehypeOptions)
168 .use(rehypePlugins)
169
170 const file = new VFile()
171
172 if (typeof children === 'string') {
173 file.value = children
174 } else {
175 unreachable(
176 'Unexpected value `' +
177 children +
178 '` for `children` prop, expected `string`'
179 )
180 }
181
182 if (allowedElements && disallowedElements) {
183 unreachable(
184 'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other'
185 )
186 }
187
188 for (const deprecation of deprecations) {
189 if (Object.hasOwn(options, deprecation.from)) {
190 unreachable(
191 'Unexpected `' +
192 deprecation.from +
193 '` prop, ' +
194 (deprecation.to
195 ? 'use `' + deprecation.to + '` instead'
196 : 'remove it') +
197 ' (see <' +
198 changelog +
199 '#' +
200 deprecation.id +
201 '> for more info)'
202 )
203 }
204 }
205
206 const mdastTree = processor.parse(file)
207 /** @type {Nodes} */
208 let hastTree = processor.runSync(mdastTree, file)
209
210 // Wrap in `div` if there’s a class name.
211 if (className) {
212 hastTree = {
213 type: 'element',
214 tagName: 'div',
215 properties: {className},
216 // Assume no doctypes.
217 children: /** @type {Array<ElementContent>} */ (
218 hastTree.type === 'root' ? hastTree.children : [hastTree]
219 )
220 }
221 }
222
223 visit(hastTree, transform)
224
225 return toJsxRuntime(hastTree, {
226 Fragment,
227 components,
228 ignoreInvalidStyle: true,
229 jsx,
230 jsxs,
231 passKeys: true,
232 passNode: true
233 })
234
235 /** @type {Visitor} */
236 function transform(node, index, parent) {
237 if (node.type === 'raw' && parent && typeof index === 'number') {
238 if (skipHtml) {
239 parent.children.splice(index, 1)
240 } else {
241 parent.children[index] = {type: 'text', value: node.value}
242 }
243
244 return index
245 }
246
247 if (node.type === 'element') {
248 /** @type {string} */
249 let key
250
251 for (key in urlAttributes) {
252 if (
253 Object.hasOwn(urlAttributes, key) &&
254 Object.hasOwn(node.properties, key)
255 ) {
256 const value = node.properties[key]
257 const test = urlAttributes[key]
258 if (test === null || test.includes(node.tagName)) {
259 node.properties[key] = urlTransform(String(value || ''), key, node)
260 }
261 }
262 }
263 }
264
265 if (node.type === 'element') {
266 let remove = allowedElements
267 ? !allowedElements.includes(node.tagName)
268 : disallowedElements
269 ? disallowedElements.includes(node.tagName)
270 : false
271
272 if (!remove && allowElement && typeof index === 'number') {
273 remove = !allowElement(node, index, parent)
274 }
275
276 if (remove && parent && typeof index === 'number') {
277 if (unwrapDisallowed && node.children) {
278 parent.children.splice(index, 1, ...node.children)
279 } else {
280 parent.children.splice(index, 1)
281 }
282
283 return index
284 }
285 }
286 }
287}
288
289/**
290 * Make a URL safe.
291 *
292 * @satisfies {UrlTransform}
293 * @param {string} value
294 * URL.
295 * @returns {string}
296 * Safe URL.
297 */
298export function defaultUrlTransform(value) {
299 // Same as:
300 // <https://github.com/micromark/micromark/blob/929275e/packages/micromark-util-sanitize-uri/dev/index.js#L34>
301 // But without the `encode` part.
302 const colon = value.indexOf(':')
303 const questionMark = value.indexOf('?')
304 const numberSign = value.indexOf('#')
305 const slash = value.indexOf('/')
306
307 if (
308 // If there is no protocol, it’s relative.
309 colon < 0 ||
310 // If the first colon is after a `?`, `#`, or `/`, it’s not a protocol.
311 (slash > -1 && colon > slash) ||
312 (questionMark > -1 && colon > questionMark) ||
313 (numberSign > -1 && colon > numberSign) ||
314 // It is a protocol, it should be allowed.
315 safeProtocol.test(value.slice(0, colon))
316 ) {
317 return value
318 }
319
320 return ''
321}