|
| 1 | +/** |
| 2 | + * Rehype plugin that converts a markdown image followed by a blockquote caption |
| 3 | + * into a semantic <figure> / <figcaption> element pair. |
| 4 | + * |
| 5 | + * Markdown pattern (existing articles): |
| 6 | + *  |
| 7 | + * > **Figure N:** *Caption text* |
| 8 | + * |
| 9 | + * Produces: |
| 10 | + * <figure> |
| 11 | + * <img src="/path/to/image.png" alt="Alt text" /> |
| 12 | + * <figcaption><p><strong>Figure N:</strong> <em>Caption text</em></p></figcaption> |
| 13 | + * </figure> |
| 14 | + */ |
| 15 | + |
| 16 | +/** Returns the index of the next non-whitespace sibling starting from `from`. */ |
| 17 | +function nextNonWhitespace(children, from) { |
| 18 | + for (let childIndex = from; childIndex < children.length; childIndex++) { |
| 19 | + const node = children[childIndex]; |
| 20 | + if (node.type === 'text' && node.value.trim() === '') continue; |
| 21 | + return childIndex; |
| 22 | + } |
| 23 | + return -1; |
| 24 | +} |
| 25 | + |
| 26 | +function transformChildren(node) { |
| 27 | + if (!node.children) return; |
| 28 | + |
| 29 | + const newChildren = []; |
| 30 | + // Track indices that have already been consumed (e.g. a blockquote paired |
| 31 | + // with the preceding image paragraph). |
| 32 | + const consumed = new Set(); |
| 33 | + |
| 34 | + for (let i = 0; i < node.children.length; i++) { |
| 35 | + if (consumed.has(i)) continue; |
| 36 | + |
| 37 | + const child = node.children[i]; |
| 38 | + |
| 39 | + // Recurse first so inner nodes are already transformed |
| 40 | + transformChildren(child); |
| 41 | + |
| 42 | + // Look for a <p> containing only a single <img> |
| 43 | + if (child.type === 'element' && child.tagName === 'p') { |
| 44 | + const realChildren = child.children.filter( |
| 45 | + (c) => !(c.type === 'text' && c.value.trim() === '') |
| 46 | + ); |
| 47 | + |
| 48 | + if ( |
| 49 | + realChildren.length === 1 && |
| 50 | + realChildren[0].type === 'element' && |
| 51 | + realChildren[0].tagName === 'img' |
| 52 | + ) { |
| 53 | + // Find the next meaningful sibling (skip whitespace text nodes) |
| 54 | + const nextIdx = nextNonWhitespace(node.children, i + 1); |
| 55 | + const next = nextIdx !== -1 ? node.children[nextIdx] : null; |
| 56 | + |
| 57 | + if (next && next.type === 'element' && next.tagName === 'blockquote') { |
| 58 | + const figure = { |
| 59 | + type: 'element', |
| 60 | + tagName: 'figure', |
| 61 | + properties: {}, |
| 62 | + children: [ |
| 63 | + realChildren[0], |
| 64 | + { |
| 65 | + type: 'element', |
| 66 | + tagName: 'figcaption', |
| 67 | + properties: {}, |
| 68 | + children: next.children, |
| 69 | + }, |
| 70 | + ], |
| 71 | + }; |
| 72 | + newChildren.push(figure); |
| 73 | + // Mark all nodes between i+1 and nextIdx (inclusive) as consumed |
| 74 | + for (let skipIndex = i + 1; skipIndex <= nextIdx; skipIndex++) consumed.add(skipIndex); |
| 75 | + continue; |
| 76 | + } |
| 77 | + } |
| 78 | + } |
| 79 | + |
| 80 | + newChildren.push(child); |
| 81 | + } |
| 82 | + |
| 83 | + node.children = newChildren; |
| 84 | +} |
| 85 | + |
| 86 | +export function rehypeFigureCaption() { |
| 87 | + return (tree) => { |
| 88 | + transformChildren(tree); |
| 89 | + }; |
| 90 | +} |
0 commit comments