/** * @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} 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 | null | undefined} [hChildren] * Generate an element with this content instead. * * @typedef {Record & 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 | 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 | 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 | 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} footnoteById * Footnote definitions by their identifier. * @property {Array} footnoteOrder * Identifiers of order when footnote calls first appear in tree order. * @property {Record} 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 node’s positional info. * @property {(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 | null | undefined} one * Transform an mdast node to hast. * @property {(node: MdastNodes) => Array} all * Transform the children of an mdast parent to hast. * @property {(nodes: Array, loose?: boolean | null | undefined) => Array} 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} 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 | 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} 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} */ 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} */ state.footnoteOrder = [] /** @type {Record} */ 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: . if (!own.call(footnoteById, id)) { footnoteById[id] = definition } }) // @ts-expect-error Hush, it’s 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 | null | undefined} * Resulting hast node. */ function oneBound(node, parent) { // @ts-expect-error: that’s 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} * Resulting hast nodes. */ function allBound(parent) { // @ts-expect-error: that’s a state :) return all(state, parent) } } /** * Copy a node’s 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 | 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} * Resulting hast nodes. */ // To do: next major: do not expose, keep bound. export function all(state, parent) { /** @type {Array} */ 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} nodes * List of nodes to wrap. * @param {boolean | null | undefined} [loose=false] * Whether to add line endings at start and end. * @returns {Array} * Wrapped nodes. */ export function wrap(nodes, loose) { /** @type {Array} */ 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 }