Files
Schulcloud-MCP/test/mini-dom.ts
MechaCat02 534b1b0f58 Write notes as formatted text, store them as Markdown
The editor was a textarea holding raw Markdown, which is the wrong thing
to hand someone taking notes during a lesson: nobody types `##` and `**`
while a teacher is talking. It now shows the note formatted and puts a
toolbar above it — headings, bold, lists, tick boxes, quotes, links,
tables — while the file on disk stays exactly what it was, because that
is what the indexer reads and what outlives this app.

`markdown.js` is the whole translation: `markdownToHtml` on the way in,
`markdownFromDom` on the way out. The property that matters is that the
round trip settles — one pass may tidy a note, a second must change
nothing — because these notes are the only record of what was said in
the room and there is nothing to restore a lossy save from. `editor.js`
checks exactly that before opening a note formatted, and a note it
cannot hold unchanged opens in the Markdown view and says so instead of
being quietly reduced.

No editor library: the content security policy allows no outside script
and the app has no bundler, so this is `contenteditable` and
`execCommand` with a tolerant serializer behind it — an element it does
not model keeps its words and loses its tag. Pasted HTML is converted to
Markdown before it reaches the document, which is the one place where
sanitising and formatting are the same operation.

Tested against `test/mini-dom.ts`, sixty lines of read-only DOM, rather
than a headless browser or a DOM dependency; the toolbar itself was
driven by hand in Firefox. WebKit has still never run it.
2026-09-19 21:24:42 +02:00

113 lines
3.5 KiB
TypeScript

/**
* 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<string, string>;
constructor(name: string, attributes: Map<string, string>) {
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<string, string> {
const attributes = new Map<string, string>();
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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&');
}