// SPDX-FileCopyrightText: 2025-2026 Kat Suricata
// SPDX-License-Identifier: (CC-BY-4.0+ OR Apache-2.0+)
import { toString as hastToString } from 'hast-util-to-string';
import rehypeSlug from 'rehype-slug';
import rehypeStringify from 'rehype-stringify';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import { unified } from 'unified';
import { visit } from 'unist-util-visit';
import type { Element, ElementContent, Root, RootContent } from 'hast';
export interface TocEntry {
id: string;
text: string;
children: TocEntry[];
}
export interface RenderedSpec {
/** HTML before the spec-meta marker. */
head: string;
/** HTML between the spec-meta and live-demo markers. */
intro: string;
/** HTML after the live-demo marker. */
body: string;
toc: TocEntry[];
}
const SPEC_META_MARKER = '';
const LIVE_DEMO_MARKER = '';
function isHeading(node: RootContent, depth: number): node is Element {
return node.type === 'element' && node.tagName === `h${depth}`;
}
// The page chrome (meta bar, live demo) is Svelte. Markers let the route
// split the rendered markdown around those components.
function rehypeMarkers() {
// Markdown keeps newline text nodes between blocks, so the lede paragraph
// is not always the immediate next child. Scan past non-elements.
const nextElement = (children: RootContent[], from: number) =>
children.slice(from + 1).find((c) => c.type === 'element');
return (tree: Root) => {
const children = tree.children;
for (let i = 0; i < children.length; i++) {
const node = children[i];
if (isHeading(node, 1)) {
const next = nextElement(children, i);
if (next?.type === 'element' && next.tagName === 'p') {
next.properties = { ...next.properties, className: ['spec-lede'] };
children.splice(children.indexOf(next) + 1, 0, {
type: 'raw',
value: SPEC_META_MARKER
});
}
}
if (isHeading(node, 2) && node.properties?.id === 'overview') {
const next = nextElement(children, i);
if (next?.type === 'element' && next.tagName === 'p') {
children.splice(children.indexOf(next) + 1, 0, {
type: 'raw',
value: LIVE_DEMO_MARKER
});
}
}
}
};
}
function makeSwatch(hex: string): Element {
return {
type: 'element',
tagName: 'span',
properties: {
className: ['legend-chip'],
style: `background: ${hex};`,
'aria-hidden': 'true'
},
children: []
};
}
function textNode(value: string): ElementContent {
return { type: 'text', value };
}
// Turns the two spec palette tables (Main Colours, ANSI Colours) into card
// grids above the source table, and wraps every table for horizontal scroll.
function rehypePaletteTables() {
return (tree: Root) => {
visit(tree, 'element', (node, index, parent) => {
if (node.tagName !== 'table' || !parent || index === undefined) return;
const headerCells = node.children
.find((c): c is Element => c.type === 'element' && c.tagName === 'thead')
?.children.find((c): c is Element => c.type === 'element' && c.tagName === 'tr')
?.children.filter((c): c is Element => c.type === 'element');
const headers = (headerCells ?? []).map((c) => hastToString(c).trim());
const isPalette = headers[0] === 'Hex Code';
const isAnsi = isPalette && headers.includes('ANSI Colour');
const tbody = node.children.find(
(c): c is Element => c.type === 'element' && c.tagName === 'tbody'
);
const rows =
tbody?.children.filter(
(c): c is Element => c.type === 'element' && c.tagName === 'tr'
) ?? [];
let cards: Element | undefined;
if (isPalette) {
const items: Element[] = rows.map((row) => {
const cells = row.children.filter(
(c): c is Element => c.type === 'element' && c.tagName === 'td'
);
const hex = hastToString(cells[0]).trim();
const name = hastToString(cells[isAnsi ? 2 : 1]).trim();
const note = isAnsi
? hastToString(cells[1]).trim()
: hastToString(cells[2]).trim();
if (isAnsi) {
return {
type: 'element',
tagName: 'div',
properties: { className: ['ansi-chip'] },
children: [
makeSwatch(hex),
{
type: 'element',
tagName: 'span',
properties: {},
children: [
{
type: 'element',
tagName: 'strong',
properties: {},
children: [textNode(name)]
},
{
type: 'element',
tagName: 'br',
properties: {},
children: []
},
{
type: 'element',
tagName: 'small',
properties: {},
children: [textNode(note)]
}
]
}
]
};
}
const cardChildren: ElementContent[] = [
{
type: 'element',
tagName: 'div',
properties: {
className: ['color-card__swatch'],
style: `background: ${hex};`
},
children: []
},
{
type: 'element',
tagName: 'strong',
properties: {},
children: [textNode(name)]
},
{
type: 'element',
tagName: 'span',
properties: { className: ['color-card__code'] },
children: [textNode(hex.toUpperCase())]
}
];
if (note) {
cardChildren.push({
type: 'element',
tagName: 'span',
properties: { className: ['color-card__note'] },
children: [textNode(note)]
});
}
return {
type: 'element',
tagName: 'div',
properties: { className: ['color-card'] },
children: cardChildren
};
});
cards = {
type: 'element',
tagName: 'div',
properties: { className: [isAnsi ? 'ansi-grid' : 'color-grid'] },
children: items
};
}
const wrappedTable: Element = {
type: 'element',
tagName: 'div',
properties: { className: ['table-scroll'] },
children: [node]
};
if (cards) {
parent.children[index] = {
type: 'element',
tagName: 'div',
properties: { className: ['color-lab'] },
children: [cards, wrappedTable]
};
} else {
parent.children[index] = wrappedTable;
}
});
};
}
function collectToc(tree: Root): TocEntry[] {
const flat: { depth: number; id: string; text: string }[] = [];
visit(tree, 'element', (node) => {
const match = /^h([123])$/.exec(node.tagName);
if (!match) return;
const id = node.properties?.id;
if (typeof id !== 'string' || !id) return;
flat.push({ depth: Number(match[1]), id, text: hastToString(node) });
});
const root: TocEntry[] = [];
const stack: { depth: number; entry: TocEntry }[] = [];
for (const item of flat) {
const entry: TocEntry = { id: item.id, text: item.text, children: [] };
while (stack.length && stack[stack.length - 1].depth >= item.depth) stack.pop();
if (stack.length === 0) {
root.push(entry);
} else {
stack[stack.length - 1].entry.children.push(entry);
}
stack.push({ depth: item.depth, entry });
}
return root;
}
export async function renderSpec(markdown: string): Promise {
let toc: TocEntry[] = [];
const file = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSlug)
.use(rehypeMarkers)
.use(rehypePaletteTables)
.use(() => (tree: Root) => {
toc = collectToc(tree);
})
.use(rehypeStringify, { allowDangerousHtml: true })
.process(markdown);
const html = String(file);
const [head, rest] = html.split(SPEC_META_MARKER);
const [intro, body] = (rest ?? '').split(LIVE_DEMO_MARKER);
if (rest === undefined || body === undefined) {
throw new Error(
'Spec markers not found. The markdown must keep an H1 followed by a lede paragraph and an "## Overview" section.'
);
}
return { head, intro: intro ?? '', body: body ?? '', toc };
}