280 lines
7.6 KiB
JavaScript
280 lines
7.6 KiB
JavaScript
|
/**
|
|||
|
* @typedef {import('hast').Element} Element
|
|||
|
* @typedef {import('hast').ElementContent} ElementContent
|
|||
|
* @typedef {import('hast').Properties} Properties
|
|||
|
* @typedef {import('hast').Root} Root
|
|||
|
*
|
|||
|
* @typedef {import('hast-util-is-element').Test} Test
|
|||
|
*/
|
|||
|
|
|||
|
/**
|
|||
|
* @typedef {'after' | 'append' | 'before' | 'prepend' | 'wrap'} Behavior
|
|||
|
* Behavior.
|
|||
|
*
|
|||
|
* @callback Build
|
|||
|
* Generate content.
|
|||
|
* @param {Readonly<Element>} element
|
|||
|
* Current heading.
|
|||
|
* @returns {Array<ElementContent> | ElementContent}
|
|||
|
* Content.
|
|||
|
*
|
|||
|
* @callback BuildProperties
|
|||
|
* Generate properties.
|
|||
|
* @param {Readonly<Element>} element
|
|||
|
* Current heading.
|
|||
|
* @returns {Properties}
|
|||
|
* Properties.
|
|||
|
*
|
|||
|
* @typedef Options
|
|||
|
* Configuration.
|
|||
|
* @property {Behavior | null | undefined} [behavior='prepend']
|
|||
|
* How to create links (default: `'prepend'`).
|
|||
|
* @property {Readonly<ElementContent> | ReadonlyArray<ElementContent> | Build | null | undefined} [content]
|
|||
|
* Content to insert in the link (default: if `'wrap'` then `undefined`,
|
|||
|
* otherwise `<span class="icon icon-link"></span>`);
|
|||
|
* if `behavior` is `'wrap'` and `Build` is passed, its result replaces the
|
|||
|
* existing content, otherwise the content is added after existing content.
|
|||
|
* @property {Readonly<ElementContent> | ReadonlyArray<ElementContent> | Build | null | undefined} [group]
|
|||
|
* Content to wrap the heading and link with, if `behavior` is `'after'` or
|
|||
|
* `'before'` (optional).
|
|||
|
* @property {Readonly<Properties> | BuildProperties | null | undefined} [headingProperties]
|
|||
|
* Extra properties to set on the heading (optional).
|
|||
|
* @property {Readonly<Properties> | BuildProperties | null | undefined} [properties]
|
|||
|
* Extra properties to set on the link when injecting (default:
|
|||
|
* `{ariaHidden: true, tabIndex: -1}` if `'append'` or `'prepend'`, otherwise
|
|||
|
* `undefined`).
|
|||
|
* @property {Test | null | undefined} [test]
|
|||
|
* Extra test for which headings are linked (optional).
|
|||
|
*/
|
|||
|
|
|||
|
/**
|
|||
|
* @template T
|
|||
|
* Kind.
|
|||
|
* @typedef {(
|
|||
|
* T extends Record<any, any>
|
|||
|
* ? {-readonly [k in keyof T]: Cloneable<T[k]>}
|
|||
|
* : T
|
|||
|
* )} Cloneable
|
|||
|
* Deep clone.
|
|||
|
*
|
|||
|
* See: <https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1237#issuecomment-1345515448>
|
|||
|
*/
|
|||
|
|
|||
|
import structuredClone from '@ungap/structured-clone'
|
|||
|
import {headingRank} from 'hast-util-heading-rank'
|
|||
|
import {convertElement} from 'hast-util-is-element'
|
|||
|
import {SKIP, visit} from 'unist-util-visit'
|
|||
|
|
|||
|
/** @type {Element} */
|
|||
|
const contentDefaults = {
|
|||
|
type: 'element',
|
|||
|
tagName: 'span',
|
|||
|
properties: {className: ['icon', 'icon-link']},
|
|||
|
children: []
|
|||
|
}
|
|||
|
|
|||
|
/** @type {Options} */
|
|||
|
const emptyOptions = {}
|
|||
|
|
|||
|
/**
|
|||
|
* Add links from headings back to themselves.
|
|||
|
*
|
|||
|
* ###### Notes
|
|||
|
*
|
|||
|
* This plugin only applies to headings with `id`s.
|
|||
|
* Use `rehype-slug` to generate `id`s for headings that don’t have them.
|
|||
|
*
|
|||
|
* Several behaviors are supported:
|
|||
|
*
|
|||
|
* * `'prepend'` (default) — inject link before the heading text
|
|||
|
* * `'append'` — inject link after the heading text
|
|||
|
* * `'wrap'` — wrap the whole heading text with the link
|
|||
|
* * `'before'` — insert link before the heading
|
|||
|
* * `'after'` — insert link after the heading
|
|||
|
*
|
|||
|
* @param {Readonly<Options> | null | undefined} [options]
|
|||
|
* Configuration (optional).
|
|||
|
* @returns
|
|||
|
* Transform.
|
|||
|
*/
|
|||
|
export default function rehypeAutolinkHeadings(options) {
|
|||
|
const settings = options || emptyOptions
|
|||
|
let properties = settings.properties
|
|||
|
const headingOroperties = settings.headingProperties
|
|||
|
const behavior = settings.behavior || 'prepend'
|
|||
|
const content = settings.content
|
|||
|
const group = settings.group
|
|||
|
const is = convertElement(settings.test)
|
|||
|
|
|||
|
/** @type {import('unist-util-visit').Visitor<Element>} */
|
|||
|
let method
|
|||
|
|
|||
|
if (behavior === 'after' || behavior === 'before') {
|
|||
|
method = around
|
|||
|
} else if (behavior === 'wrap') {
|
|||
|
method = wrap
|
|||
|
} else {
|
|||
|
method = inject
|
|||
|
|
|||
|
if (!properties) {
|
|||
|
properties = {ariaHidden: 'true', tabIndex: -1}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Transform.
|
|||
|
*
|
|||
|
* @param {Root} tree
|
|||
|
* Tree.
|
|||
|
* @returns {undefined}
|
|||
|
* Nothing.
|
|||
|
*/
|
|||
|
return function (tree) {
|
|||
|
visit(tree, 'element', function (node, index, parent) {
|
|||
|
if (headingRank(node) && node.properties.id && is(node, index, parent)) {
|
|||
|
Object.assign(node.properties, toProperties(headingOroperties, node))
|
|||
|
return method(node, index, parent)
|
|||
|
}
|
|||
|
})
|
|||
|
}
|
|||
|
|
|||
|
/** @type {import('unist-util-visit').Visitor<Element>} */
|
|||
|
function inject(node) {
|
|||
|
const children = toChildren(content || contentDefaults, node)
|
|||
|
node.children[behavior === 'prepend' ? 'unshift' : 'push'](
|
|||
|
create(node, toProperties(properties, node), children)
|
|||
|
)
|
|||
|
|
|||
|
return [SKIP]
|
|||
|
}
|
|||
|
|
|||
|
/** @type {import('unist-util-visit').Visitor<Element>} */
|
|||
|
function around(node, index, parent) {
|
|||
|
/* c8 ignore next -- uncommon */
|
|||
|
if (typeof index !== 'number' || !parent) return
|
|||
|
|
|||
|
const children = toChildren(content || contentDefaults, node)
|
|||
|
const link = create(node, toProperties(properties, node), children)
|
|||
|
let nodes = behavior === 'before' ? [link, node] : [node, link]
|
|||
|
|
|||
|
if (group) {
|
|||
|
const grouping = toNode(group, node)
|
|||
|
|
|||
|
if (grouping && !Array.isArray(grouping) && grouping.type === 'element') {
|
|||
|
grouping.children = nodes
|
|||
|
nodes = [grouping]
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
parent.children.splice(index, 1, ...nodes)
|
|||
|
|
|||
|
return [SKIP, index + nodes.length]
|
|||
|
}
|
|||
|
|
|||
|
/** @type {import('unist-util-visit').Visitor<Element>} */
|
|||
|
function wrap(node) {
|
|||
|
/** @type {Array<ElementContent>} */
|
|||
|
let before = node.children
|
|||
|
/** @type {Array<ElementContent> | ElementContent} */
|
|||
|
let after = []
|
|||
|
|
|||
|
if (typeof content === 'function') {
|
|||
|
before = []
|
|||
|
after = content(node)
|
|||
|
} else if (content) {
|
|||
|
after = clone(content)
|
|||
|
}
|
|||
|
|
|||
|
node.children = [
|
|||
|
create(
|
|||
|
node,
|
|||
|
toProperties(properties, node),
|
|||
|
Array.isArray(after) ? [...before, ...after] : [...before, after]
|
|||
|
)
|
|||
|
]
|
|||
|
|
|||
|
return [SKIP]
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Deep clone.
|
|||
|
*
|
|||
|
* @template T
|
|||
|
* Kind.
|
|||
|
* @param {T} thing
|
|||
|
* Thing to clone.
|
|||
|
* @returns {Cloneable<T>}
|
|||
|
* Cloned thing.
|
|||
|
*/
|
|||
|
function clone(thing) {
|
|||
|
// Cast because it’s mutable now.
|
|||
|
return /** @type {Cloneable<T>} */ (structuredClone(thing))
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Create an `a`.
|
|||
|
*
|
|||
|
* @param {Readonly<Element>} node
|
|||
|
* Related heading.
|
|||
|
* @param {Properties | undefined} properties
|
|||
|
* Properties to set on the link.
|
|||
|
* @param {Array<ElementContent>} children
|
|||
|
* Content.
|
|||
|
* @returns {Element}
|
|||
|
* Link.
|
|||
|
*/
|
|||
|
function create(node, properties, children) {
|
|||
|
return {
|
|||
|
type: 'element',
|
|||
|
tagName: 'a',
|
|||
|
properties: {...properties, href: '#' + node.properties.id},
|
|||
|
children
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Turn into children.
|
|||
|
*
|
|||
|
* @param {Readonly<ElementContent> | ReadonlyArray<ElementContent> | Build} value
|
|||
|
* Content.
|
|||
|
* @param {Readonly<Element>} node
|
|||
|
* Related heading.
|
|||
|
* @returns {Array<ElementContent>}
|
|||
|
* Children.
|
|||
|
*/
|
|||
|
function toChildren(value, node) {
|
|||
|
const result = toNode(value, node)
|
|||
|
return Array.isArray(result) ? result : [result]
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Turn into a node.
|
|||
|
*
|
|||
|
* @param {Readonly<ElementContent> | ReadonlyArray<ElementContent> | Build} value
|
|||
|
* Content.
|
|||
|
* @param {Readonly<Element>} node
|
|||
|
* Related heading.
|
|||
|
* @returns {Array<ElementContent> | ElementContent}
|
|||
|
* Node.
|
|||
|
*/
|
|||
|
function toNode(value, node) {
|
|||
|
if (typeof value === 'function') return value(node)
|
|||
|
return clone(value)
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Turn into properties.
|
|||
|
*
|
|||
|
* @param {Readonly<Properties> | BuildProperties | null | undefined} value
|
|||
|
* Properties.
|
|||
|
* @param {Readonly<Element>} node
|
|||
|
* Related heading.
|
|||
|
* @returns {Properties}
|
|||
|
* Properties.
|
|||
|
*/
|
|||
|
function toProperties(value, node) {
|
|||
|
if (typeof value === 'function') return value(node)
|
|||
|
return value ? clone(value) : {}
|
|||
|
}
|