// 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 }; }