mirror of
https://github.com/Funkoala14/knowledgebase_law.git
synced 2025-06-09 00:28:15 +08:00
445 lines
12 KiB
JavaScript
445 lines
12 KiB
JavaScript
|
/**
|
|||
|
* @import {Element, Nodes, Parents, Root} from 'hast'
|
|||
|
* @import {Root as MdastRoot} from 'mdast'
|
|||
|
* @import {ComponentType, JSX, ReactElement, ReactNode} from 'react'
|
|||
|
* @import {Options as RemarkRehypeOptions} from 'remark-rehype'
|
|||
|
* @import {BuildVisitor} from 'unist-util-visit'
|
|||
|
* @import {PluggableList, Processor} from 'unified'
|
|||
|
*/
|
|||
|
|
|||
|
/**
|
|||
|
* @callback AllowElement
|
|||
|
* Filter elements.
|
|||
|
* @param {Readonly<Element>} element
|
|||
|
* Element to check.
|
|||
|
* @param {number} index
|
|||
|
* Index of `element` in `parent`.
|
|||
|
* @param {Readonly<Parents> | undefined} parent
|
|||
|
* Parent of `element`.
|
|||
|
* @returns {boolean | null | undefined}
|
|||
|
* Whether to allow `element` (default: `false`).
|
|||
|
*/
|
|||
|
|
|||
|
/**
|
|||
|
* @typedef ExtraProps
|
|||
|
* Extra fields we pass.
|
|||
|
* @property {Element | undefined} [node]
|
|||
|
* passed when `passNode` is on.
|
|||
|
*/
|
|||
|
|
|||
|
/**
|
|||
|
* @typedef {{
|
|||
|
* [Key in keyof JSX.IntrinsicElements]?: ComponentType<JSX.IntrinsicElements[Key] & ExtraProps> | keyof JSX.IntrinsicElements
|
|||
|
* }} Components
|
|||
|
* Map tag names to components.
|
|||
|
*/
|
|||
|
|
|||
|
/**
|
|||
|
* @typedef Deprecation
|
|||
|
* Deprecation.
|
|||
|
* @property {string} from
|
|||
|
* Old field.
|
|||
|
* @property {string} id
|
|||
|
* ID in readme.
|
|||
|
* @property {keyof Options} [to]
|
|||
|
* New field.
|
|||
|
*/
|
|||
|
|
|||
|
/**
|
|||
|
* @typedef Options
|
|||
|
* Configuration.
|
|||
|
* @property {AllowElement | null | undefined} [allowElement]
|
|||
|
* Filter elements (optional);
|
|||
|
* `allowedElements` / `disallowedElements` is used first.
|
|||
|
* @property {ReadonlyArray<string> | null | undefined} [allowedElements]
|
|||
|
* Tag names to allow (default: all tag names);
|
|||
|
* cannot combine w/ `disallowedElements`.
|
|||
|
* @property {string | null | undefined} [children]
|
|||
|
* Markdown.
|
|||
|
* @property {Components | null | undefined} [components]
|
|||
|
* Map tag names to components.
|
|||
|
* @property {ReadonlyArray<string> | null | undefined} [disallowedElements]
|
|||
|
* 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]
|
|||
|
* List of remark plugins to use.
|
|||
|
* @property {Readonly<RemarkRehypeOptions> | null | undefined} [remarkRehypeOptions]
|
|||
|
* Options to pass through to `remark-rehype`.
|
|||
|
* @property {boolean | null | undefined} [skipHtml=false]
|
|||
|
* Ignore HTML in markdown completely (default: `false`).
|
|||
|
* @property {boolean | null | undefined} [unwrapDisallowed=false]
|
|||
|
* 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`)
|
|||
|
*/
|
|||
|
|
|||
|
/**
|
|||
|
* @typedef HooksOptionsOnly
|
|||
|
* Configuration specifically for {@linkcode MarkdownHooks}.
|
|||
|
* @property {ReactNode | null | undefined} [fallback]
|
|||
|
* Content to render while the processor processing the markdown (optional).
|
|||
|
*/
|
|||
|
|
|||
|
/**
|
|||
|
* @typedef {Options & HooksOptionsOnly} HooksOptions
|
|||
|
* Configuration for {@linkcode MarkdownHooks};
|
|||
|
* extends the regular {@linkcode Options} with a `fallback` prop.
|
|||
|
*/
|
|||
|
|
|||
|
/**
|
|||
|
* @callback UrlTransform
|
|||
|
* Transform all URLs.
|
|||
|
* @param {string} url
|
|||
|
* URL.
|
|||
|
* @param {string} key
|
|||
|
* Property name (example: `'href'`).
|
|||
|
* @param {Readonly<Element>} node
|
|||
|
* Node.
|
|||
|
* @returns {string | null | undefined}
|
|||
|
* Transformed URL (optional).
|
|||
|
*/
|
|||
|
|
|||
|
import {unreachable} from 'devlop'
|
|||
|
import {toJsxRuntime} from 'hast-util-to-jsx-runtime'
|
|||
|
import {urlAttributes} from 'html-url-attributes'
|
|||
|
import {Fragment, jsx, jsxs} from 'react/jsx-runtime'
|
|||
|
import {useEffect, useState} from 'react'
|
|||
|
import remarkParse from 'remark-parse'
|
|||
|
import remarkRehype from 'remark-rehype'
|
|||
|
import {unified} from 'unified'
|
|||
|
import {visit} from 'unist-util-visit'
|
|||
|
import {VFile} from 'vfile'
|
|||
|
|
|||
|
const changelog =
|
|||
|
'https://github.com/remarkjs/react-markdown/blob/main/changelog.md'
|
|||
|
|
|||
|
/** @type {PluggableList} */
|
|||
|
const emptyPlugins = []
|
|||
|
/** @type {Readonly<RemarkRehypeOptions>} */
|
|||
|
const emptyRemarkRehypeOptions = {allowDangerousHtml: true}
|
|||
|
const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i
|
|||
|
|
|||
|
// Mutable because we `delete` any time it’s used and a message is sent.
|
|||
|
/** @type {ReadonlyArray<Readonly<Deprecation>>} */
|
|||
|
const deprecations = [
|
|||
|
{from: 'astPlugins', id: 'remove-buggy-html-in-markdown-parser'},
|
|||
|
{from: 'allowDangerousHtml', id: 'remove-buggy-html-in-markdown-parser'},
|
|||
|
{
|
|||
|
from: 'allowNode',
|
|||
|
id: 'replace-allownode-allowedtypes-and-disallowedtypes',
|
|||
|
to: 'allowElement'
|
|||
|
},
|
|||
|
{
|
|||
|
from: 'allowedTypes',
|
|||
|
id: 'replace-allownode-allowedtypes-and-disallowedtypes',
|
|||
|
to: 'allowedElements'
|
|||
|
},
|
|||
|
{from: 'className', id: 'remove-classname'},
|
|||
|
{
|
|||
|
from: 'disallowedTypes',
|
|||
|
id: 'replace-allownode-allowedtypes-and-disallowedtypes',
|
|||
|
to: 'disallowedElements'
|
|||
|
},
|
|||
|
{from: 'escapeHtml', id: 'remove-buggy-html-in-markdown-parser'},
|
|||
|
{from: 'includeElementIndex', id: '#remove-includeelementindex'},
|
|||
|
{
|
|||
|
from: 'includeNodeIndex',
|
|||
|
id: 'change-includenodeindex-to-includeelementindex'
|
|||
|
},
|
|||
|
{from: 'linkTarget', id: 'remove-linktarget'},
|
|||
|
{from: 'plugins', id: 'change-plugins-to-remarkplugins', to: 'remarkPlugins'},
|
|||
|
{from: 'rawSourcePos', id: '#remove-rawsourcepos'},
|
|||
|
{from: 'renderers', id: 'change-renderers-to-components', to: 'components'},
|
|||
|
{from: 'source', id: 'change-source-to-children', to: 'children'},
|
|||
|
{from: 'sourcePos', id: '#remove-sourcepos'},
|
|||
|
{from: 'transformImageUri', id: '#add-urltransform', to: 'urlTransform'},
|
|||
|
{from: 'transformLinkUri', id: '#add-urltransform', to: 'urlTransform'}
|
|||
|
]
|
|||
|
|
|||
|
/**
|
|||
|
* Component to render markdown.
|
|||
|
*
|
|||
|
* This is a synchronous component.
|
|||
|
* When using async plugins,
|
|||
|
* see {@linkcode MarkdownAsync} or {@linkcode MarkdownHooks}.
|
|||
|
*
|
|||
|
* @param {Readonly<Options>} options
|
|||
|
* Props.
|
|||
|
* @returns {ReactElement}
|
|||
|
* React element.
|
|||
|
*/
|
|||
|
export function Markdown(options) {
|
|||
|
const processor = createProcessor(options)
|
|||
|
const file = createFile(options)
|
|||
|
return post(processor.runSync(processor.parse(file), file), options)
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Component to render markdown with support for async plugins
|
|||
|
* through async/await.
|
|||
|
*
|
|||
|
* Components returning promises are supported on the server.
|
|||
|
* For async support on the client,
|
|||
|
* see {@linkcode MarkdownHooks}.
|
|||
|
*
|
|||
|
* @param {Readonly<Options>} options
|
|||
|
* Props.
|
|||
|
* @returns {Promise<ReactElement>}
|
|||
|
* Promise to a React element.
|
|||
|
*/
|
|||
|
export async function MarkdownAsync(options) {
|
|||
|
const processor = createProcessor(options)
|
|||
|
const file = createFile(options)
|
|||
|
const tree = await processor.run(processor.parse(file), file)
|
|||
|
return post(tree, options)
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Component to render markdown with support for async plugins through hooks.
|
|||
|
*
|
|||
|
* This uses `useEffect` and `useState` hooks.
|
|||
|
* Hooks run on the client and do not immediately render something.
|
|||
|
* For async support on the server,
|
|||
|
* see {@linkcode MarkdownAsync}.
|
|||
|
*
|
|||
|
* @param {Readonly<HooksOptions>} options
|
|||
|
* Props.
|
|||
|
* @returns {ReactNode}
|
|||
|
* React node.
|
|||
|
*/
|
|||
|
export function MarkdownHooks(options) {
|
|||
|
const processor = createProcessor(options)
|
|||
|
const [error, setError] = useState(
|
|||
|
/** @type {Error | undefined} */ (undefined)
|
|||
|
)
|
|||
|
const [tree, setTree] = useState(/** @type {Root | undefined} */ (undefined))
|
|||
|
|
|||
|
useEffect(
|
|||
|
function () {
|
|||
|
let cancelled = false
|
|||
|
const file = createFile(options)
|
|||
|
|
|||
|
processor.run(processor.parse(file), file, function (error, tree) {
|
|||
|
if (!cancelled) {
|
|||
|
setError(error)
|
|||
|
setTree(tree)
|
|||
|
}
|
|||
|
})
|
|||
|
|
|||
|
/**
|
|||
|
* @returns {undefined}
|
|||
|
* Nothing.
|
|||
|
*/
|
|||
|
return function () {
|
|||
|
cancelled = true
|
|||
|
}
|
|||
|
},
|
|||
|
[
|
|||
|
options.children,
|
|||
|
options.rehypePlugins,
|
|||
|
options.remarkPlugins,
|
|||
|
options.remarkRehypeOptions
|
|||
|
]
|
|||
|
)
|
|||
|
|
|||
|
if (error) throw error
|
|||
|
|
|||
|
return tree ? post(tree, options) : options.fallback
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Set up the `unified` processor.
|
|||
|
*
|
|||
|
* @param {Readonly<Options>} options
|
|||
|
* Props.
|
|||
|
* @returns {Processor<MdastRoot, MdastRoot, Root, undefined, undefined>}
|
|||
|
* Result.
|
|||
|
*/
|
|||
|
function createProcessor(options) {
|
|||
|
const rehypePlugins = options.rehypePlugins || emptyPlugins
|
|||
|
const remarkPlugins = options.remarkPlugins || emptyPlugins
|
|||
|
const remarkRehypeOptions = options.remarkRehypeOptions
|
|||
|
? {...options.remarkRehypeOptions, ...emptyRemarkRehypeOptions}
|
|||
|
: emptyRemarkRehypeOptions
|
|||
|
|
|||
|
const processor = unified()
|
|||
|
.use(remarkParse)
|
|||
|
.use(remarkPlugins)
|
|||
|
.use(remarkRehype, remarkRehypeOptions)
|
|||
|
.use(rehypePlugins)
|
|||
|
|
|||
|
return processor
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Set up the virtual file.
|
|||
|
*
|
|||
|
* @param {Readonly<Options>} options
|
|||
|
* Props.
|
|||
|
* @returns {VFile}
|
|||
|
* Result.
|
|||
|
*/
|
|||
|
function createFile(options) {
|
|||
|
const children = options.children || ''
|
|||
|
const file = new VFile()
|
|||
|
|
|||
|
if (typeof children === 'string') {
|
|||
|
file.value = children
|
|||
|
} else {
|
|||
|
unreachable(
|
|||
|
'Unexpected value `' +
|
|||
|
children +
|
|||
|
'` for `children` prop, expected `string`'
|
|||
|
)
|
|||
|
}
|
|||
|
|
|||
|
return file
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Process the result from unified some more.
|
|||
|
*
|
|||
|
* @param {Nodes} tree
|
|||
|
* Tree.
|
|||
|
* @param {Readonly<Options>} options
|
|||
|
* Props.
|
|||
|
* @returns {ReactElement}
|
|||
|
* React element.
|
|||
|
*/
|
|||
|
function post(tree, options) {
|
|||
|
const allowedElements = options.allowedElements
|
|||
|
const allowElement = options.allowElement
|
|||
|
const components = options.components
|
|||
|
const disallowedElements = options.disallowedElements
|
|||
|
const skipHtml = options.skipHtml
|
|||
|
const unwrapDisallowed = options.unwrapDisallowed
|
|||
|
const urlTransform = options.urlTransform || defaultUrlTransform
|
|||
|
|
|||
|
for (const deprecation of deprecations) {
|
|||
|
if (Object.hasOwn(options, deprecation.from)) {
|
|||
|
unreachable(
|
|||
|
'Unexpected `' +
|
|||
|
deprecation.from +
|
|||
|
'` prop, ' +
|
|||
|
(deprecation.to
|
|||
|
? 'use `' + deprecation.to + '` instead'
|
|||
|
: 'remove it') +
|
|||
|
' (see <' +
|
|||
|
changelog +
|
|||
|
'#' +
|
|||
|
deprecation.id +
|
|||
|
'> for more info)'
|
|||
|
)
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
if (allowedElements && disallowedElements) {
|
|||
|
unreachable(
|
|||
|
'Unexpected combined `allowedElements` and `disallowedElements`, expected one or the other'
|
|||
|
)
|
|||
|
}
|
|||
|
|
|||
|
visit(tree, transform)
|
|||
|
|
|||
|
return toJsxRuntime(tree, {
|
|||
|
Fragment,
|
|||
|
components,
|
|||
|
ignoreInvalidStyle: true,
|
|||
|
jsx,
|
|||
|
jsxs,
|
|||
|
passKeys: true,
|
|||
|
passNode: true
|
|||
|
})
|
|||
|
|
|||
|
/** @type {BuildVisitor<Root>} */
|
|||
|
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}
|
|||
|
}
|
|||
|
|
|||
|
return index
|
|||
|
}
|
|||
|
|
|||
|
if (node.type === 'element') {
|
|||
|
/** @type {string} */
|
|||
|
let key
|
|||
|
|
|||
|
for (key in urlAttributes) {
|
|||
|
if (
|
|||
|
Object.hasOwn(urlAttributes, key) &&
|
|||
|
Object.hasOwn(node.properties, key)
|
|||
|
) {
|
|||
|
const value = node.properties[key]
|
|||
|
const test = urlAttributes[key]
|
|||
|
if (test === null || test.includes(node.tagName)) {
|
|||
|
node.properties[key] = urlTransform(String(value || ''), key, node)
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
if (node.type === 'element') {
|
|||
|
let remove = allowedElements
|
|||
|
? !allowedElements.includes(node.tagName)
|
|||
|
: disallowedElements
|
|||
|
? disallowedElements.includes(node.tagName)
|
|||
|
: false
|
|||
|
|
|||
|
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
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Make a URL safe.
|
|||
|
*
|
|||
|
* @satisfies {UrlTransform}
|
|||
|
* @param {string} value
|
|||
|
* URL.
|
|||
|
* @returns {string}
|
|||
|
* Safe URL.
|
|||
|
*/
|
|||
|
export function defaultUrlTransform(value) {
|
|||
|
// Same as:
|
|||
|
// <https://github.com/micromark/micromark/blob/929275e/packages/micromark-util-sanitize-uri/dev/index.js#L34>
|
|||
|
// But without the `encode` part.
|
|||
|
const colon = value.indexOf(':')
|
|||
|
const questionMark = value.indexOf('?')
|
|||
|
const numberSign = value.indexOf('#')
|
|||
|
const slash = value.indexOf('/')
|
|||
|
|
|||
|
if (
|
|||
|
// If there is no protocol, it’s relative.
|
|||
|
colon === -1 ||
|
|||
|
// If the first colon is after a `?`, `#`, or `/`, it’s not a protocol.
|
|||
|
(slash !== -1 && colon > slash) ||
|
|||
|
(questionMark !== -1 && colon > questionMark) ||
|
|||
|
(numberSign !== -1 && colon > numberSign) ||
|
|||
|
// It is a protocol, it should be allowed.
|
|||
|
safeProtocol.test(value.slice(0, colon))
|
|||
|
) {
|
|||
|
return value
|
|||
|
}
|
|||
|
|
|||
|
return ''
|
|||
|
}
|