blog.hangerthem.com/lib/rehype-definition.tsx
2026-08-02 22:39:12 +02:00

144 lines
4.4 KiB
TypeScript

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 <br> 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 <dl> element with a <dt> for the term and a <dd> 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
})
}