kjelsrud.dev/node_modules/mdast-util-to-hast/lib/state.js
2023-07-19 21:31:30 +02:00

577 lines
18 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').Content} HastContent
* @typedef {import('hast').Element} HastElement
* @typedef {import('hast').ElementContent} HastElementContent
* @typedef {import('hast').Properties} HastProperties
* @typedef {import('hast').Root} HastRoot
* @typedef {import('hast').Text} HastText
*
* @typedef {import('mdast').Content} MdastContent
* @typedef {import('mdast').Definition} MdastDefinition
* @typedef {import('mdast').FootnoteDefinition} MdastFootnoteDefinition
* @typedef {import('mdast').Parent} MdastParent
* @typedef {import('mdast').Root} MdastRoot
*/
/**
* @typedef {HastRoot | HastContent} HastNodes
* @typedef {MdastRoot | MdastContent} MdastNodes
* @typedef {Extract<MdastNodes, MdastParent>} MdastParents
*
* @typedef EmbeddedHastFields
* hast fields.
* @property {string | null | undefined} [hName]
* Generate a specific element with this tag name instead.
* @property {HastProperties | null | undefined} [hProperties]
* Generate an element with these properties instead.
* @property {Array<HastElementContent> | null | undefined} [hChildren]
* Generate an element with this content instead.
*
* @typedef {Record<string, unknown> & EmbeddedHastFields} MdastData
* mdast data with embedded hast fields.
*
* @typedef {MdastNodes & {data?: MdastData | null | undefined}} MdastNodeWithData
* mdast node with embedded hast data.
*
* @typedef PointLike
* Point-like value.
* @property {number | null | undefined} [line]
* Line.
* @property {number | null | undefined} [column]
* Column.
* @property {number | null | undefined} [offset]
* Offset.
*
* @typedef PositionLike
* Position-like value.
* @property {PointLike | null | undefined} [start]
* Point-like value.
* @property {PointLike | null | undefined} [end]
* Point-like value.
*
* @callback Handler
* Handle a node.
* @param {State} state
* Info passed around.
* @param {any} node
* mdast node to handle.
* @param {MdastParents | null | undefined} parent
* Parent of `node`.
* @returns {HastElementContent | Array<HastElementContent> | null | undefined}
* hast node.
*
* @callback HFunctionProps
* Signature of `state` for when props are passed.
* @param {MdastNodes | PositionLike | null | undefined} node
* mdast node or unist position.
* @param {string} tagName
* HTML tag name.
* @param {HastProperties} props
* Properties.
* @param {Array<HastElementContent> | null | undefined} [children]
* hast content.
* @returns {HastElement}
* Compiled element.
*
* @callback HFunctionNoProps
* Signature of `state` for when no props are passed.
* @param {MdastNodes | PositionLike | null | undefined} node
* mdast node or unist position.
* @param {string} tagName
* HTML tag name.
* @param {Array<HastElementContent> | null | undefined} [children]
* hast content.
* @returns {HastElement}
* Compiled element.
*
* @typedef HFields
* Info on `state`.
* @property {boolean} dangerous
* Whether HTML is allowed.
* @property {string} clobberPrefix
* Prefix to use to prevent DOM clobbering.
* @property {string} footnoteLabel
* Label to use to introduce the footnote section.
* @property {string} footnoteLabelTagName
* HTML used for the footnote label.
* @property {HastProperties} footnoteLabelProperties
* Properties on the HTML tag used for the footnote label.
* @property {string} footnoteBackLabel
* Label to use from backreferences back to their footnote call.
* @property {(identifier: string) => MdastDefinition | null} definition
* Definition cache.
* @property {Record<string, MdastFootnoteDefinition>} footnoteById
* Footnote definitions by their identifier.
* @property {Array<string>} footnoteOrder
* Identifiers of order when footnote calls first appear in tree order.
* @property {Record<string, number>} footnoteCounts
* Counts for how often the same footnote was called.
* @property {Handlers} handlers
* Applied handlers.
* @property {Handler} unknownHandler
* Handler for any none not in `passThrough` or otherwise handled.
* @property {(from: MdastNodes, node: HastNodes) => void} patch
* Copy a nodes positional info.
* @property {<Type extends HastNodes>(from: MdastNodes, to: Type) => Type | HastElement} applyData
* Honor the `data` of `from`, and generate an element instead of `node`.
* @property {(node: MdastNodes, parent: MdastParents | null | undefined) => HastElementContent | Array<HastElementContent> | null | undefined} one
* Transform an mdast node to hast.
* @property {(node: MdastNodes) => Array<HastElementContent>} all
* Transform the children of an mdast parent to hast.
* @property {<Type extends HastContent>(nodes: Array<Type>, loose?: boolean | null | undefined) => Array<Type | HastText>} wrap
* Wrap `nodes` with line endings between each node, adds initial/final line endings when `loose`.
* @property {(left: MdastNodeWithData | PositionLike | null | undefined, right: HastElementContent) => HastElementContent} augment
* Like `state` but lower-level and usable on non-elements.
* Deprecated: use `patch` and `applyData`.
* @property {Array<string>} passThrough
* List of node types to pass through untouched (except for their children).
*
* @typedef Options
* Configuration (optional).
* @property {boolean | null | undefined} [allowDangerousHtml=false]
* Whether to persist raw HTML in markdown in the hast tree.
* @property {string | null | undefined} [clobberPrefix='user-content-']
* Prefix to use before the `id` attribute on footnotes to prevent it from
* *clobbering*.
* @property {string | null | undefined} [footnoteBackLabel='Back to content']
* Label to use from backreferences back to their footnote call (affects
* screen readers).
* @property {string | null | undefined} [footnoteLabel='Footnotes']
* Label to use for the footnotes section (affects screen readers).
* @property {HastProperties | null | undefined} [footnoteLabelProperties={className: ['sr-only']}]
* Properties to use on the footnote label (note that `id: 'footnote-label'`
* is always added as footnote calls use it with `aria-describedby` to
* provide an accessible label).
* @property {string | null | undefined} [footnoteLabelTagName='h2']
* Tag name to use for the footnote label.
* @property {Handlers | null | undefined} [handlers]
* Extra handlers for nodes.
* @property {Array<string> | 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).
* @property {Handler | null | undefined} [unknownHandler]
* Handler for all unknown nodes.
*
* @typedef {Record<string, Handler>} Handlers
* Handle nodes.
*
* @typedef {HFunctionProps & HFunctionNoProps & HFields} State
* Info passed around.
*/
import {visit} from 'unist-util-visit'
import {position, pointStart, pointEnd} from 'unist-util-position'
import {generated} from 'unist-util-generated'
import {definitions} from 'mdast-util-definitions'
import {handlers} from './handlers/index.js'
const own = {}.hasOwnProperty
/**
* Create `state` from an mdast tree.
*
* @param {MdastNodes} tree
* mdast node to transform.
* @param {Options | null | undefined} [options]
* Configuration.
* @returns {State}
* `state` function.
*/
export function createState(tree, options) {
const settings = options || {}
const dangerous = settings.allowDangerousHtml || false
/** @type {Record<string, MdastFootnoteDefinition>} */
const footnoteById = {}
// To do: next major: add `options` to state, remove:
// `dangerous`, `clobberPrefix`, `footnoteLabel`, `footnoteLabelTagName`,
// `footnoteLabelProperties`, `footnoteBackLabel`, `passThrough`,
// `unknownHandler`.
// To do: next major: move to `state.options.allowDangerousHtml`.
state.dangerous = dangerous
// To do: next major: move to `state.options`.
state.clobberPrefix =
settings.clobberPrefix === undefined || settings.clobberPrefix === null
? 'user-content-'
: settings.clobberPrefix
// To do: next major: move to `state.options`.
state.footnoteLabel = settings.footnoteLabel || 'Footnotes'
// To do: next major: move to `state.options`.
state.footnoteLabelTagName = settings.footnoteLabelTagName || 'h2'
// To do: next major: move to `state.options`.
state.footnoteLabelProperties = settings.footnoteLabelProperties || {
className: ['sr-only']
}
// To do: next major: move to `state.options`.
state.footnoteBackLabel = settings.footnoteBackLabel || 'Back to content'
// To do: next major: move to `state.options`.
state.unknownHandler = settings.unknownHandler
// To do: next major: move to `state.options`.
state.passThrough = settings.passThrough
state.handlers = {...handlers, ...settings.handlers}
// To do: next major: replace utility with `definitionById` object, so we
// only walk once (as we need footnotes too).
state.definition = definitions(tree)
state.footnoteById = footnoteById
/** @type {Array<string>} */
state.footnoteOrder = []
/** @type {Record<string, number>} */
state.footnoteCounts = {}
state.patch = patch
state.applyData = applyData
state.one = oneBound
state.all = allBound
state.wrap = wrap
// To do: next major: remove `augment`.
state.augment = augment
visit(tree, 'footnoteDefinition', (definition) => {
const id = String(definition.identifier).toUpperCase()
// Mimick CM behavior of link definitions.
// See: <https://github.com/syntax-tree/mdast-util-definitions/blob/8290999/index.js#L26>.
if (!own.call(footnoteById, id)) {
footnoteById[id] = definition
}
})
// @ts-expect-error Hush, its fine!
return state
/**
* Finalise the created `right`, a hast node, from `left`, an mdast node.
*
* @param {MdastNodeWithData | PositionLike | null | undefined} left
* @param {HastElementContent} right
* @returns {HastElementContent}
*/
/* c8 ignore start */
// To do: next major: remove.
function augment(left, right) {
// Handle `data.hName`, `data.hProperties, `data.hChildren`.
if (left && 'data' in left && left.data) {
/** @type {MdastData} */
const data = left.data
if (data.hName) {
if (right.type !== 'element') {
right = {
type: 'element',
tagName: '',
properties: {},
children: []
}
}
right.tagName = data.hName
}
if (right.type === 'element' && data.hProperties) {
right.properties = {...right.properties, ...data.hProperties}
}
if ('children' in right && right.children && data.hChildren) {
right.children = data.hChildren
}
}
if (left) {
const ctx = 'type' in left ? left : {position: left}
if (!generated(ctx)) {
// @ts-expect-error: fine.
right.position = {start: pointStart(ctx), end: pointEnd(ctx)}
}
}
return right
}
/* c8 ignore stop */
/**
* Create an element for `node`.
*
* @type {HFunctionProps}
*/
/* c8 ignore start */
// To do: next major: remove.
function state(node, tagName, props, children) {
if (Array.isArray(props)) {
children = props
props = {}
}
// @ts-expect-error augmenting an element yields an element.
return augment(node, {
type: 'element',
tagName,
properties: props || {},
children: children || []
})
}
/* c8 ignore stop */
/**
* Transform an mdast node into a hast node.
*
* @param {MdastNodes} node
* mdast node.
* @param {MdastParents | null | undefined} [parent]
* Parent of `node`.
* @returns {HastElementContent | Array<HastElementContent> | null | undefined}
* Resulting hast node.
*/
function oneBound(node, parent) {
// @ts-expect-error: thats a state :)
return one(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 allBound(parent) {
// @ts-expect-error: thats a state :)
return all(state, parent)
}
}
/**
* Copy a nodes positional info.
*
* @param {MdastNodes} from
* mdast node to copy from.
* @param {HastNodes} to
* hast node to copy into.
* @returns {void}
* 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 {Type | HastElement}
* Nothing.
*/
function applyData(from, to) {
/** @type {Type | HastElement} */
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 is likely to keep the content around (otherwise: pass
// `hChildren`).
else {
result = {
type: 'element',
tagName: hName,
properties: {},
children: []
}
// To do: next major: take the children from the `root`, or inject the
// raw/text/comment or so into the element?
// if ('children' in node) {
// // @ts-expect-error: assume `children` are allowed in elements.
// result.children = node.children
// } else {
// // @ts-expect-error: assume `node` is allowed in elements.
// result.children.push(node)
// }
}
}
if (result.type === 'element' && hProperties) {
result.properties = {...result.properties, ...hProperties}
}
if (
'children' in result &&
result.children &&
hChildren !== null &&
hChildren !== undefined
) {
// @ts-expect-error: assume valid children are defined.
result.children = hChildren
}
}
return result
}
/**
* Transform an mdast node into a hast node.
*
* @param {State} state
* Info passed around.
* @param {MdastNodes} node
* mdast node.
* @param {MdastParents | null | undefined} [parent]
* Parent of `node`.
* @returns {HastElementContent | Array<HastElementContent> | null | undefined}
* Resulting hast node.
*/
// To do: next major: do not expose, keep bound.
export function one(state, node, parent) {
const type = node && node.type
// Fail on non-nodes.
if (!type) {
throw new Error('Expected node, got `' + node + '`')
}
if (own.call(state.handlers, type)) {
return state.handlers[type](state, node, parent)
}
if (state.passThrough && state.passThrough.includes(type)) {
// To do: next major: deep clone.
// @ts-expect-error: types of passed through nodes are expected to be added manually.
return 'children' in node ? {...node, children: all(state, node)} : node
}
if (state.unknownHandler) {
return state.unknownHandler(state, node, parent)
}
return defaultUnknownHandler(state, node)
}
/**
* Transform the children of an mdast node into hast nodes.
*
* @param {State} state
* Info passed around.
* @param {MdastNodes} parent
* mdast node to compile
* @returns {Array<HastElementContent>}
* Resulting hast nodes.
*/
// To do: next major: do not expose, keep bound.
export function all(state, parent) {
/** @type {Array<HastElementContent>} */
const values = []
if ('children' in parent) {
const nodes = parent.children
let index = -1
while (++index < nodes.length) {
const result = one(state, 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 = result.value.replace(/^\s+/, '')
}
if (!Array.isArray(result) && result.type === 'element') {
const head = result.children[0]
if (head && head.type === 'text') {
head.value = head.value.replace(/^\s+/, '')
}
}
}
if (Array.isArray(result)) {
values.push(...result)
} else {
values.push(result)
}
}
}
}
return values
}
/**
* Transform an unknown node.
*
* @param {State} state
* Info passed around.
* @param {MdastNodes} node
* Unknown mdast node.
* @returns {HastText | HastElement}
* Resulting hast node.
*/
function defaultUnknownHandler(state, node) {
const data = node.data || {}
/** @type {HastText | HastElement} */
const result =
'value' in node &&
!(own.call(data, 'hProperties') || own.call(data, 'hChildren'))
? {type: 'text', value: node.value}
: {
type: 'element',
tagName: 'div',
properties: {},
children: all(state, node)
}
state.patch(node, result)
return state.applyData(node, result)
}
/**
* Wrap `nodes` with line endings between each node.
*
* @template {HastContent} Type
* Node type.
* @param {Array<Type>} nodes
* List of nodes to wrap.
* @param {boolean | null | undefined} [loose=false]
* Whether to add line endings at start and end.
* @returns {Array<Type | HastText>}
* Wrapped nodes.
*/
export function wrap(nodes, loose) {
/** @type {Array<Type | HastText>} */
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
}