577 lines
18 KiB
JavaScript
577 lines
18 KiB
JavaScript
/**
|
||
* @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 node’s 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, 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<HastElementContent> | 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<HastElementContent>}
|
||
* 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<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
|
||
}
|