/** * Just enough DOM to run the notes editor's serializer under `node --test`. * * `markdownFromDom` walks a tree with `nodeType`, `nodeName`, `childNodes`, * `textContent` and `getAttribute` — nothing else, and nothing that writes — * which is what lets the round-trip be tested here rather than only in a * browser. The round-trip is the part of the editor that can quietly destroy a * lesson's notes, so testing it is not optional; adding a headless browser or a * DOM library to do it would be a much larger dependency than these 60 lines. * * It parses only the HTML `markdownToHtml` emits: known tags, quoted * attributes, no comments, no CDATA, no implied end tags. */ export interface MiniNode { nodeType: 1 | 3; nodeName: string; childNodes: MiniNode[]; textContent: string; getAttribute(name: string): string | null; } const VOID = new Set(['BR', 'HR', 'INPUT', 'IMG', 'META', 'LINK']); const TAG = /<(\/)?([a-zA-Z][a-zA-Z0-9]*)((?:\s+[^\s=/>]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/)?>/g; class Element implements MiniNode { readonly nodeType = 1 as const; readonly nodeName: string; readonly childNodes: MiniNode[] = []; private readonly attributes: Map; constructor(name: string, attributes: Map) { this.nodeName = name.toUpperCase(); this.attributes = attributes; } get textContent(): string { return this.childNodes.map((child) => child.textContent).join(''); } getAttribute(name: string): string | null { return this.attributes.get(name.toLowerCase()) ?? null; } } class Text implements MiniNode { readonly nodeType = 3 as const; readonly nodeName = '#text'; readonly childNodes: MiniNode[] = []; textContent: string; constructor(value: string) { this.textContent = value; } getAttribute(): null { return null; } } /** A fragment whose `childNodes` are the parsed top-level nodes. */ export function parseHtml(html: string): MiniNode { const root = new Element('body', new Map()); const stack: Element[] = [root]; let index = 0; TAG.lastIndex = 0; for (let match = TAG.exec(html); match; match = TAG.exec(html)) { if (match.index > index) addText(stack.at(-1)!, html.slice(index, match.index)); index = TAG.lastIndex; const name = match[2]!.toUpperCase(); if (match[1]) { // A close tag: unwind to it, ignoring one that was never opened. const at = stack.findLastIndex((element) => element.nodeName === name); if (at > 0) stack.length = at; continue; } const element = new Element(name, attributesOf(match[3] ?? '')); stack.at(-1)!.childNodes.push(element); if (!VOID.has(name) && !match[4]) stack.push(element); } if (index < html.length) addText(stack.at(-1)!, html.slice(index)); return root; } function addText(parent: Element, value: string): void { if (!value) return; parent.childNodes.push(new Text(decode(value))); } function attributesOf(source: string): Map { const attributes = new Map(); const pattern = /([^\s=/>]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g; for (let match = pattern.exec(source); match; match = pattern.exec(source)) { // A bare attribute (`checked`) is present with an empty value, which is // what the DOM reports too. attributes.set(match[1]!.toLowerCase(), decode(match[2] ?? match[3] ?? match[4] ?? '')); } return attributes; } function decode(value: string): string { return value .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&'); }