120 lines
3.8 KiB
TypeScript
120 lines
3.8 KiB
TypeScript
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 <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 shaped like:
|
|
*
|
|
* Term
|
|
* : Definition A for term.
|
|
* : Definition B for term, on a new line.
|
|
*
|
|
* into a <dl> element with a <dt> for the term and a <dd> 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
|
|
})
|
|
}
|