import type { Root, Element, ElementContent, Text } from 'hast' import type { Plugin } from 'unified' import { visit } from 'unist-util-visit' /** * Strips a leading match from the first text node in the given children array. * @param children The children array to process. * @param regex The regex pattern to match and strip from the first text node. * @returns The modified children array with the leading match stripped. */ const stripLeadingColon = ( line: ElementContent[], ): { isNewDefinition: boolean; content: ElementContent[] } => { const [first, ...rest] = line if (first?.type === 'text') { const match = first.value.match(/^[ \t]*:[ \t]*/) if (match) { const remainder = first.value.slice(match[0].length) return { isNewDefinition: true, content: remainder ? [{ type: 'text', value: remainder } as Text, ...rest] : rest, } } } return { isNewDefinition: false, content: line } } /** * Splits an array of ElementContent nodes into lines based on text nodes containing newlines and
elements. * @param nodes The array of ElementContent nodes to split into lines. * @returns An array of lines, where each line is an array of ElementContent nodes. */ const splitIntoLines = (nodes: ElementContent[]): ElementContent[][] => { const lines: ElementContent[][] = [[]] for (const child of nodes) { if (child.type === 'text') { const segments = child.value.split('\n') segments.forEach((segment, i) => { if (i > 0) lines.push([]) if (segment.length > 0) { lines[lines.length - 1].push({ type: 'text', value: segment } as Text) } }) } else if (child.type === 'element' && child.tagName === 'br') { lines.push([]) } else { lines[lines.length - 1].push(child) } } return lines } /** * A rehype plugin that transforms paragraphs containing a leading "Term:" into * definition lists. For example, a paragraph like "Term: Definition" will be * transformed into a
element with a
for the term and a
for the * definition. */ export const rehypeDefinition: Plugin<[], Root> = () => (tree: Root) => { visit(tree, 'element', (node: Element, index, parent) => { if (!parent || typeof index !== 'number') return if (node.tagName !== 'p') return const children = node.children let splitIndex = -1 let colonIndex = -1 for (let i = 0; i < children.length; i++) { const child = children[i] if (child.type === 'text') { const idx = child.value.indexOf(':') if (idx !== -1) { splitIndex = i colonIndex = idx break } } } if (splitIndex === -1) return const splitText = children[splitIndex] as Text const beforeColon = splitText.value.slice(0, colonIndex) const afterColon = splitText.value.slice(colonIndex + 1).replace(/^[ \t]+/, '') const termChildren: ElementContent[] = [ ...children.slice(0, splitIndex), ...(beforeColon ? [{ type: 'text', value: beforeColon } as Text] : []), ] const definitionChildren: ElementContent[] = [ ...(afterColon ? [{ type: 'text', value: afterColon } as Text] : []), ...children.slice(splitIndex + 1), ] if (termChildren.length === 0 || definitionChildren.length === 0) return const lines = splitIntoLines(definitionChildren) const definitions: ElementContent[][] = [] for (const line of lines) { const { isNewDefinition, content } = stripLeadingColon(line) if (isNewDefinition || definitions.length === 0) { definitions.push(content) } else { const current = definitions[definitions.length - 1] if (current.length > 0 && content.length > 0) { current.push({ type: 'text', value: ' ' } as Text) } current.push(...content) } } const nonEmptyDefinitions = definitions.filter((def) => def.length > 0) if (nonEmptyDefinitions.length === 0) return const dlNode: Element = { type: 'element', tagName: 'dl', properties: {}, children: [ { type: 'element', tagName: 'dt', properties: {}, children: termChildren, }, ...nonEmptyDefinitions.map( (defChildren): Element => ({ type: 'element', tagName: 'dd', properties: {}, children: defChildren, }), ), ], } parent.children[index] = dlNode }) }