site/node_modules/mdast-util-to-hast/lib/state.js
2024-10-14 08:09:33 +02:00

474 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @typedef {import('hast').Element} HastElement
* @typedef {import('hast').ElementContent} HastElementContent
* @typedef {import('hast').Nodes} HastNodes
* @typedef {import('hast').Properties} HastProperties
* @typedef {import('hast').RootContent} HastRootContent
* @typedef {import('hast').Text} HastText
*
* @typedef {import('mdast').Definition} MdastDefinition
* @typedef {import('mdast').FootnoteDefinition} MdastFootnoteDefinition
* @typedef {import('mdast').Nodes} MdastNodes
* @typedef {import('mdast').Parents} MdastParents
*
* @typedef {import('vfile').VFile} VFile
*
* @typedef {import('./footer.js').FootnoteBackContentTemplate} FootnoteBackContentTemplate
* @typedef {import('./footer.js').FootnoteBackLabelTemplate} FootnoteBackLabelTemplate
*/
/**
* @callback Handler
* Handle a node.
* @param {State} state
* Info passed around.
* @param {any} node
* mdast node to handle.
* @param {MdastParents | undefined} parent
* Parent of `node`.
* @returns {Array<HastElementContent> | HastElementContent | undefined}
* hast node.
*
* @typedef {Partial<Record<MdastNodes['type'], Handler>>} Handlers
* Handle nodes.
*
* @typedef Options
* Configuration (optional).
* @property {boolean | null | undefined} [allowDangerousHtml=false]
* Whether to persist raw HTML in markdown in the hast tree (default:
* `false`).
* @property {string | null | undefined} [clobberPrefix='user-content-']
* Prefix to use before the `id` property on footnotes to prevent them from
* *clobbering* (default: `'user-content-'`).
*
* Pass `''` for trusted markdown and when you are careful with
* polyfilling.
* You could pass a different prefix.
*
* DOM clobbering is this:
*
* ```html
* <p id="x"></p>
* <script>alert(x) // `x` now refers to the `p#x` DOM element</script>
* ```
*
* The above example shows that elements are made available by browsers, by
* their ID, on the `window` object.
* This is a security risk because you might be expecting some other variable
* at that place.
* It can also break polyfills.
* Using a prefix solves these problems.
* @property {VFile | null | undefined} [file]
* Corresponding virtual file representing the input document (optional).
* @property {FootnoteBackContentTemplate | string | null | undefined} [footnoteBackContent]
* Content of the backreference back to references (default: `defaultFootnoteBackContent`).
*
* The default value is:
*
* ```js
* function defaultFootnoteBackContent(_, rereferenceIndex) {
* const result = [{type: 'text', value: '↩'}]
*
* if (rereferenceIndex > 1) {
* result.push({
* type: 'element',
* tagName: 'sup',
* properties: {},
* children: [{type: 'text', value: String(rereferenceIndex)}]
* })
* }
*
* return result
* }
* ```
*
* This content is used in the `a` element of each backreference (the `↩`
* links).
* @property {FootnoteBackLabelTemplate | string | null | undefined} [footnoteBackLabel]
* Label to describe the backreference back to references (default:
* `defaultFootnoteBackLabel`).
*
* The default value is:
*
* ```js
* function defaultFootnoteBackLabel(referenceIndex, rereferenceIndex) {
* return (
* 'Back to reference ' +
* (referenceIndex + 1) +
* (rereferenceIndex > 1 ? '-' + rereferenceIndex : '')
* )
* }
* ```
*
* Change it when the markdown is not in English.
*
* This label is used in the `ariaLabel` property on each backreference
* (the `↩` links).
* It affects users of assistive technology.
* @property {string | null | undefined} [footnoteLabel='Footnotes']
* Textual label to use for the footnotes section (default: `'Footnotes'`).
*
* Change it when the markdown is not in English.
*
* This label is typically hidden visually (assuming a `sr-only` CSS class
* is defined that does that) and so affects screen readers only.
* If you do have such a class, but want to show this section to everyone,
* pass different properties with the `footnoteLabelProperties` option.
* @property {HastProperties | null | undefined} [footnoteLabelProperties={className: ['sr-only']}]
* Properties to use on the footnote label (default: `{className:
* ['sr-only']}`).
*
* Change it to show the label and add other properties.
*
* This label is typically hidden visually (assuming an `sr-only` CSS class
* is defined that does that) and so affects screen readers only.
* If you do have such a class, but want to show this section to everyone,
* pass an empty string.
* You can also add different properties.
*
* > 👉 **Note**: `id: 'footnote-label'` is always added, because footnote
* > calls use it with `aria-describedby` to provide an accessible label.
* @property {string | null | undefined} [footnoteLabelTagName='h2']
* HTML tag name to use for the footnote label element (default: `'h2'`).
*
* Change it to match your document structure.
*
* This label is typically hidden visually (assuming a `sr-only` CSS class
* is defined that does that) and so affects screen readers only.
* If you do have such a class, but want to show this section to everyone,
* pass different properties with the `footnoteLabelProperties` option.
* @property {Handlers | null | undefined} [handlers]
* Extra handlers for nodes (optional).
* @property {Array<MdastNodes['type']> | null | undefined} [passThrough]
* List of custom mdast node types to pass through (keep) in hast (note that
* the node itself is passed, but eventual children are transformed)
* (optional).
* @property {Handler | null | undefined} [unknownHandler]
* Handler for all unknown nodes (optional).
*
* @typedef State
* Info passed around.
* @property {(node: MdastNodes) => Array<HastElementContent>} all
* Transform the children of an mdast parent to hast.
* @property {<Type extends HastNodes>(from: MdastNodes, to: Type) => HastElement | Type} applyData
* Honor the `data` of `from`, and generate an element instead of `node`.
* @property {Map<string, MdastDefinition>} definitionById
* Definitions by their identifier.
* @property {Map<string, MdastFootnoteDefinition>} footnoteById
* Footnote definitions by their identifier.
* @property {Map<string, number>} footnoteCounts
* Counts for how often the same footnote was called.
* @property {Array<string>} footnoteOrder
* Identifiers of order when footnote calls first appear in tree order.
* @property {Handlers} handlers
* Applied handlers.
* @property {(node: MdastNodes, parent: MdastParents | undefined) => Array<HastElementContent> | HastElementContent | undefined} one
* Transform an mdast node to hast.
* @property {Options} options
* Configuration.
* @property {(from: MdastNodes, node: HastNodes) => undefined} patch
* Copy a nodes positional info.
* @property {<Type extends HastRootContent>(nodes: Array<Type>, loose?: boolean | undefined) => Array<HastText | Type>} wrap
* Wrap `nodes` with line endings between each node, adds initial/final line endings when `loose`.
*/
import structuredClone from '@ungap/structured-clone'
import {visit} from 'unist-util-visit'
import {position} from 'unist-util-position'
import {handlers as defaultHandlers} from './handlers/index.js'
const own = {}.hasOwnProperty
/** @type {Options} */
const emptyOptions = {}
/**
* Create `state` from an mdast tree.
*
* @param {MdastNodes} tree
* mdast node to transform.
* @param {Options | null | undefined} [options]
* Configuration (optional).
* @returns {State}
* `state` function.
*/
export function createState(tree, options) {
const settings = options || emptyOptions
/** @type {Map<string, MdastDefinition>} */
const definitionById = new Map()
/** @type {Map<string, MdastFootnoteDefinition>} */
const footnoteById = new Map()
/** @type {Map<string, number>} */
const footnoteCounts = new Map()
/** @type {Handlers} */
// @ts-expect-error: the root handler returns a root.
// Hard to type.
const handlers = {...defaultHandlers, ...settings.handlers}
/** @type {State} */
const state = {
all,
applyData,
definitionById,
footnoteById,
footnoteCounts,
footnoteOrder: [],
handlers,
one,
options: settings,
patch,
wrap
}
visit(tree, function (node) {
if (node.type === 'definition' || node.type === 'footnoteDefinition') {
const map = node.type === 'definition' ? definitionById : footnoteById
const id = String(node.identifier).toUpperCase()
// Mimick CM behavior of link definitions.
// See: <https://github.com/syntax-tree/mdast-util-definitions/blob/9032189/lib/index.js#L20-L21>.
if (!map.has(id)) {
// @ts-expect-error: node type matches map.
map.set(id, node)
}
}
})
return state
/**
* Transform an mdast node into a hast node.
*
* @param {MdastNodes} node
* mdast node.
* @param {MdastParents | undefined} [parent]
* Parent of `node`.
* @returns {Array<HastElementContent> | HastElementContent | undefined}
* Resulting hast node.
*/
function one(node, parent) {
const type = node.type
const handle = state.handlers[type]
if (own.call(state.handlers, type) && handle) {
return handle(state, node, parent)
}
if (state.options.passThrough && state.options.passThrough.includes(type)) {
if ('children' in node) {
const {children, ...shallow} = node
const result = structuredClone(shallow)
// @ts-expect-error: TS doesnt understand…
result.children = state.all(node)
// @ts-expect-error: TS doesnt understand…
return result
}
// @ts-expect-error: its custom.
return structuredClone(node)
}
const unknown = state.options.unknownHandler || defaultUnknownHandler
return unknown(state, node, parent)
}
/**
* Transform the children of an mdast node into hast nodes.
*
* @param {MdastNodes} parent
* mdast node to compile
* @returns {Array<HastElementContent>}
* Resulting hast nodes.
*/
function all(parent) {
/** @type {Array<HastElementContent>} */
const values = []
if ('children' in parent) {
const nodes = parent.children
let index = -1
while (++index < nodes.length) {
const result = state.one(nodes[index], parent)
// To do: see if we van clean this? Can we merge texts?
if (result) {
if (index && nodes[index - 1].type === 'break') {
if (!Array.isArray(result) && result.type === 'text') {
result.value = trimMarkdownSpaceStart(result.value)
}
if (!Array.isArray(result) && result.type === 'element') {
const head = result.children[0]
if (head && head.type === 'text') {
head.value = trimMarkdownSpaceStart(head.value)
}
}
}
if (Array.isArray(result)) {
values.push(...result)
} else {
values.push(result)
}
}
}
}
return values
}
}
/**
* Copy a nodes positional info.
*
* @param {MdastNodes} from
* mdast node to copy from.
* @param {HastNodes} to
* hast node to copy into.
* @returns {undefined}
* Nothing.
*/
function patch(from, to) {
if (from.position) to.position = position(from)
}
/**
* Honor the `data` of `from` and maybe generate an element instead of `to`.
*
* @template {HastNodes} Type
* Node type.
* @param {MdastNodes} from
* mdast node to use data from.
* @param {Type} to
* hast node to change.
* @returns {HastElement | Type}
* Nothing.
*/
function applyData(from, to) {
/** @type {HastElement | Type} */
let result = to
// Handle `data.hName`, `data.hProperties, `data.hChildren`.
if (from && from.data) {
const hName = from.data.hName
const hChildren = from.data.hChildren
const hProperties = from.data.hProperties
if (typeof hName === 'string') {
// Transforming the node resulted in an element with a different name
// than wanted:
if (result.type === 'element') {
result.tagName = hName
}
// Transforming the node resulted in a non-element, which happens for
// raw, text, and root nodes (unless custom handlers are passed).
// The intent of `hName` is to create an element, but likely also to keep
// the content around (otherwise: pass `hChildren`).
else {
/** @type {Array<HastElementContent>} */
// @ts-expect-error: assume no doctypes in `root`.
const children = 'children' in result ? result.children : [result]
result = {type: 'element', tagName: hName, properties: {}, children}
}
}
if (result.type === 'element' && hProperties) {
Object.assign(result.properties, structuredClone(hProperties))
}
if (
'children' in result &&
result.children &&
hChildren !== null &&
hChildren !== undefined
) {
result.children = hChildren
}
}
return result
}
/**
* Transform an unknown node.
*
* @param {State} state
* Info passed around.
* @param {MdastNodes} node
* Unknown mdast node.
* @returns {HastElement | HastText}
* Resulting hast node.
*/
function defaultUnknownHandler(state, node) {
const data = node.data || {}
/** @type {HastElement | HastText} */
const result =
'value' in node &&
!(own.call(data, 'hProperties') || own.call(data, 'hChildren'))
? {type: 'text', value: node.value}
: {
type: 'element',
tagName: 'div',
properties: {},
children: state.all(node)
}
state.patch(node, result)
return state.applyData(node, result)
}
/**
* Wrap `nodes` with line endings between each node.
*
* @template {HastRootContent} Type
* Node type.
* @param {Array<Type>} nodes
* List of nodes to wrap.
* @param {boolean | undefined} [loose=false]
* Whether to add line endings at start and end (default: `false`).
* @returns {Array<HastText | Type>}
* Wrapped nodes.
*/
export function wrap(nodes, loose) {
/** @type {Array<HastText | Type>} */
const result = []
let index = -1
if (loose) {
result.push({type: 'text', value: '\n'})
}
while (++index < nodes.length) {
if (index) result.push({type: 'text', value: '\n'})
result.push(nodes[index])
}
if (loose && nodes.length > 0) {
result.push({type: 'text', value: '\n'})
}
return result
}
/**
* Trim spaces and tabs at the start of `value`.
*
* @param {string} value
* Value to trim.
* @returns {string}
* Result.
*/
function trimMarkdownSpaceStart(value) {
let index = 0
let code = value.charCodeAt(index)
while (code === 9 || code === 32) {
index++
code = value.charCodeAt(index)
}
return value.slice(index)
}