import type { Root, Element, ElementContent, Text } from 'hast' import type { Plugin } from 'unified' import { visit } from 'unist-util-visit' /** * Checks whether a line starts with a colon marker (optionally preceded by * whitespace) and, if so, strips it and returns the remaining content. * @param line The line (array of ElementContent) to check. * @returns Whether the line is a colon-prefixed definition line, and its content with the marker stripped. */ const stripLeadingColon = ( line: ElementContent[], ): { isDefinitionLine: 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 { isDefinitionLine: true, content: remainder ? [{ type: 'text', value: remainder } as Text, ...rest] : rest, } } } return { isDefinitionLine: 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 shaped like: * * Term * : Definition A for term. * : Definition B for term, on a new line. * * into a
element with a
for the term and a
per colon-prefixed * line. The term must sit on its own line, and every subsequent non-empty * line must start with ":" — otherwise the paragraph is left untouched, so * ordinary sentences containing a colon (e.g. "My passions: A, B and C") * are never affected. */ 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 lines = splitIntoLines(node.children) // Need at least a term line and one definition line. if (lines.length < 2) return const [termLine, ...restLines] = lines if (termLine.length === 0) return // The term line itself must not be a colon-prefixed line. if (stripLeadingColon(termLine).isDefinitionLine) return const definitions: ElementContent[][] = [] for (const line of restLines) { if (line.length === 0) continue const { isDefinitionLine, content } = stripLeadingColon(line) // Any non-colon-prefixed line means this isn't the definition format. if (!isDefinitionLine) return if (content.length > 0) definitions.push(content) } if (definitions.length === 0) return const dlNode: Element = { type: 'element', tagName: 'dl', properties: {}, children: [ { type: 'element', tagName: 'dt', properties: {}, children: termLine, }, ...definitions.map( (defChildren): Element => ({ type: 'element', tagName: 'dd', properties: {}, children: defChildren, }), ), ], } parent.children[index] = dlNode }) }