/** * @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('./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 | undefined} * hast node. * * @typedef {Partial>} 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 *

* * ``` * * 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 {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 | 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} all * Transform the children of an mdast parent to hast. * @property {(from: MdastNodes, to: Type) => HastElement | Type} applyData * Honor the `data` of `from`, and generate an element instead of `node`. * @property {Map} definitionById * Definitions by their identifier. * @property {Map} footnoteById * Footnote definitions by their identifier. * @property {Map} footnoteCounts * Counts for how often the same footnote was called. * @property {Array} 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 | undefined} one * Transform an mdast node to hast. * @property {Options} options * Configuration. * @property {(from: MdastNodes, node: HastNodes) => undefined} patch * Copy a node’s positional info. * @property {(nodes: Array, loose?: boolean | undefined) => Array} 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} */ const definitionById = new Map() /** @type {Map} */ const footnoteById = new Map() /** @type {Map} */ 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: . 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 | 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 doesn’t understand… result.children = state.all(node) // @ts-expect-error: TS doesn’t understand… return result } // @ts-expect-error: it’s 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} * Resulting hast nodes. */ function all(parent) { /** @type {Array} */ 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 node’s 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} */ // @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} nodes * List of nodes to wrap. * @param {boolean | undefined} [loose=false] * Whether to add line endings at start and end (default: `false`). * @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 } /** * 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) }