|
| 1 | +import type { Element, ElementContent, Root, RootContent, Text } from 'hast' |
| 2 | +import type { Plugin } from 'unified' |
| 3 | +import { visit } from 'unist-util-visit' |
| 4 | + |
| 5 | +const MCDP_ICON_URL = 'https://github.com/FuncFusion/mc-dp-icons-assets/blob/main/icons/current' |
| 6 | +const NOTE_SEPARATOR = ' — ' |
| 7 | + |
| 8 | +/** Resolves `mcdp:<name>` to an icon in the MCDP set (github.com/FuncFusion/mc-dp-icons-assets). */ |
| 9 | +export function resolveTreeIconUrl(src: string): string { |
| 10 | + if (!src.startsWith('mcdp:')) return src |
| 11 | + return `${MCDP_ICON_URL}/${src.slice('mcdp:'.length)}.svg?raw=true` |
| 12 | +} |
| 13 | + |
| 14 | +function isWhitespaceText(node: RootContent): boolean { |
| 15 | + return node.type === 'text' && node.value.trim().length === 0 |
| 16 | +} |
| 17 | + |
| 18 | +function isElement(node: RootContent | undefined, tagName: string): node is Element { |
| 19 | + return node?.type === 'element' && node.tagName === tagName |
| 20 | +} |
| 21 | + |
| 22 | +function isParagraphWithText(node: RootContent | undefined, exp: RegExp): boolean { |
| 23 | + if (!isElement(node, 'p') || node.children.length !== 1) return false |
| 24 | + const [child] = node.children |
| 25 | + return child.type === 'text' && exp.test(child.value) |
| 26 | +} |
| 27 | + |
| 28 | +function nextContentIndex(children: RootContent[], from: number): number { |
| 29 | + let index = from |
| 30 | + while (index < children.length && isWhitespaceText(children[index])) index += 1 |
| 31 | + return index |
| 32 | +} |
| 33 | + |
| 34 | +/** The fence's closing `:::` lazily continues into the last list item, so strip it from there. */ |
| 35 | +function stripTrailingFence(node: Element): boolean { |
| 36 | + for (let index = node.children.length - 1; index >= 0; index -= 1) { |
| 37 | + const child = node.children[index] |
| 38 | + |
| 39 | + if (child.type === 'text') { |
| 40 | + if (child.value.trim().length === 0) continue |
| 41 | + |
| 42 | + const match = /^([\s\S]*?)\s*:::\s*$/.exec(child.value) |
| 43 | + if (!match) return false |
| 44 | + |
| 45 | + child.value = match[1] |
| 46 | + return true |
| 47 | + } |
| 48 | + |
| 49 | + if (child.type === 'element') return stripTrailingFence(child) |
| 50 | + |
| 51 | + return false |
| 52 | + } |
| 53 | + |
| 54 | + return false |
| 55 | +} |
| 56 | + |
| 57 | +/** Splits a trailing ` — note` off the label into its own muted span. */ |
| 58 | +function splitNote(children: ElementContent[]): ElementContent[] { |
| 59 | + const textIndex = children.findIndex( |
| 60 | + child => child.type === 'text' && child.value.includes(NOTE_SEPARATOR) |
| 61 | + ) |
| 62 | + if (textIndex < 0) return children |
| 63 | + |
| 64 | + const text = children[textIndex] as Text |
| 65 | + const separatorIndex = text.value.indexOf(NOTE_SEPARATOR) |
| 66 | + const before = text.value.slice(0, separatorIndex) |
| 67 | + const after = text.value.slice(separatorIndex + NOTE_SEPARATOR.length) |
| 68 | + |
| 69 | + const note: Element = { |
| 70 | + type: 'element', |
| 71 | + tagName: 'span', |
| 72 | + properties: { className: ['tree-note'] }, |
| 73 | + children: [{ type: 'text', value: after }, ...children.slice(textIndex + 1)], |
| 74 | + } |
| 75 | + |
| 76 | + return [...children.slice(0, textIndex), { type: 'text', value: before }, note] |
| 77 | +} |
| 78 | + |
| 79 | +function transformItem(item: Element): Element { |
| 80 | + const sublists: Element[] = [] |
| 81 | + let label: ElementContent[] = [] |
| 82 | + |
| 83 | + for (const child of item.children) { |
| 84 | + if (isElement(child, 'ul') || isElement(child, 'ol')) { |
| 85 | + sublists.push(transformList(child)) |
| 86 | + } else if (isElement(child, 'p')) { |
| 87 | + // Loose lists wrap item content in paragraphs. |
| 88 | + label.push(...child.children) |
| 89 | + } else { |
| 90 | + label.push(child) |
| 91 | + } |
| 92 | + } |
| 93 | + |
| 94 | + while (label.length > 0 && isWhitespaceText(label[0])) label.shift() |
| 95 | + while (label.length > 0 && isWhitespaceText(label[label.length - 1])) label.pop() |
| 96 | + |
| 97 | + let floating = false |
| 98 | + const first = label[0] |
| 99 | + if (first?.type === 'text' && first.value.startsWith('~')) { |
| 100 | + floating = true |
| 101 | + first.value = first.value.slice(1).trimStart() |
| 102 | + if (first.value.length === 0) label.shift() |
| 103 | + } |
| 104 | + |
| 105 | + const icon = label[0] |
| 106 | + if (isElement(icon, 'img')) { |
| 107 | + icon.properties = { |
| 108 | + ...icon.properties, |
| 109 | + className: ['tree-icon'], |
| 110 | + src: resolveTreeIconUrl(String(icon.properties.src ?? '')), |
| 111 | + alt: icon.properties.alt ?? '', |
| 112 | + } |
| 113 | + const afterIcon = label[1] |
| 114 | + if (afterIcon?.type === 'text') afterIcon.value = afterIcon.value.trimStart() |
| 115 | + } |
| 116 | + |
| 117 | + label = splitNote(label) |
| 118 | + |
| 119 | + return { |
| 120 | + type: 'element', |
| 121 | + tagName: 'li', |
| 122 | + properties: { className: floating ? ['tree-item', 'tree-item-floating'] : ['tree-item'] }, |
| 123 | + children: [ |
| 124 | + { |
| 125 | + type: 'element', |
| 126 | + tagName: 'span', |
| 127 | + properties: { className: ['tree-label'] }, |
| 128 | + children: label, |
| 129 | + }, |
| 130 | + ...sublists, |
| 131 | + ], |
| 132 | + } |
| 133 | +} |
| 134 | + |
| 135 | +function transformList(list: Element): Element { |
| 136 | + return { |
| 137 | + type: 'element', |
| 138 | + tagName: 'ul', |
| 139 | + properties: {}, |
| 140 | + children: list.children.filter(child => isElement(child, 'li')).map(transformItem), |
| 141 | + } |
| 142 | +} |
| 143 | + |
| 144 | +/** |
| 145 | + * Turns a `:::tree` fence around a nested list into a tree diagram. |
| 146 | + * Items starting with `~` get a dashed branch, a leading image becomes the item's icon, |
| 147 | + * and text after ` — ` becomes a muted note. |
| 148 | + */ |
| 149 | +const rehypeTree: Plugin<[], Root> = () => { |
| 150 | + return (tree: Root) => { |
| 151 | + visit(tree, 'element', (node: Element, index, parent) => { |
| 152 | + if (!parent || typeof index !== 'number') return |
| 153 | + if (!isParagraphWithText(node, /^\s*:::\s*tree\s*$/i)) return |
| 154 | + |
| 155 | + const listIndex = nextContentIndex(parent.children, index + 1) |
| 156 | + const list = parent.children[listIndex] |
| 157 | + if (!isElement(list, 'ul')) return |
| 158 | + |
| 159 | + let closingIndex = nextContentIndex(parent.children, listIndex + 1) |
| 160 | + if (!isParagraphWithText(parent.children[closingIndex], /^\s*:::\s*$/)) { |
| 161 | + if (!stripTrailingFence(list)) return |
| 162 | + closingIndex = listIndex |
| 163 | + } |
| 164 | + |
| 165 | + const treeNode: Element = { |
| 166 | + type: 'element', |
| 167 | + tagName: 'div', |
| 168 | + properties: { className: ['tree'] }, |
| 169 | + children: [transformList(list)], |
| 170 | + } |
| 171 | + |
| 172 | + parent.children.splice(index, closingIndex - index + 1, treeNode) |
| 173 | + return index + 1 |
| 174 | + }) |
| 175 | + } |
| 176 | +} |
| 177 | + |
| 178 | +export default rehypeTree |
0 commit comments