474 lines
15 KiB
JavaScript
474 lines
15 KiB
JavaScript
|
/**
|
|||
|
* @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 node’s 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 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<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 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<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)
|
|||
|
}
|