Files
Schulcloud-MCP/test/app-markdown.test.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

223 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import { markdownFromDom, markdownToHtml } from '../src/http/app/markdown.js';
import { parseHtml } from './mini-dom.ts';
/**
* The notes editor's Markdown bridge.
*
* The editor shows a note as formatted text and writes it back as Markdown, so
* every save runs the note through `markdownToHtml` and `markdownFromDom`. If
* that pair loses anything, it loses a lesson — these notes are the only record
* of what was actually said in the room, and there is no second copy to restore
* from. Hence the shape of almost every test here: put Markdown in, get the
* same Markdown back.
*/
/** Markdown → HTML → Markdown, the trip a note takes on every edit. */
function back(markdown: string): string {
return markdownFromDom(parseHtml(markdownToHtml(markdown)));
}
/**
* Asserts the trip is idempotent, and returns what it settles on.
*
* One pass may tidy — `*a*` becomes `_a_`, a ragged table lines up — and that
* is allowed, because the editor only rewrites a file the person has edited.
* A second pass changing anything is not: it would mean every save mangles the
* note a little further, which is how a term's notes rot into nothing.
*/
function settles(markdown: string): string {
const once = back(markdown);
assert.equal(back(once), once, 'the round trip is not stable');
return once;
}
/** Markdown that must survive the trip exactly as written. */
function unchanged(markdown: string): void {
assert.equal(settles(markdown), markdown);
}
test('headings keep their level', () => {
unchanged('## 1. Deutsch — 08:0008:45 · MEI · R 204');
unchanged('# Eins\n\n## Zwei\n\n### Drei\n\n#### Vier');
assert.match(markdownToHtml('## Deutsch'), /<h2>Deutsch<\/h2>/);
});
test('a paragraph keeps its line breaks without turning them into paragraphs', () => {
// How a note actually gets typed: shift-enter within a thought, enter
// between them.
unchanged('Erste Zeile\nzweite Zeile\n\nNeuer Absatz');
assert.equal(markdownToHtml('a\nb'), '<p>a<br>b</p>');
});
test('emphasis round-trips, and normalises to one spelling', () => {
unchanged('**fett** und _kursiv_ und ~~gestrichen~~');
assert.equal(settles('*kursiv*'), '_kursiv_');
assert.equal(settles('__fett__'), '**fett**');
assert.equal(markdownToHtml('**fett**'), '<p><strong>fett</strong></p>');
});
test('emphasis markers hug their text', () => {
// `** fett **` is four literal stars in every renderer there is.
const html = '<p>Merke:<strong> fett </strong>rest</p>';
assert.equal(markdownFromDom(parseHtml(html)), 'Merke: **fett** rest');
});
test('inline code keeps what is inside it literal', () => {
unchanged('Der Platzhalter `**nicht fett**` bleibt stehen.');
unchanged('`a | b`');
assert.equal(settles('``ein ` backtick``'), '``ein ` backtick``');
});
test('links keep their target, and a dangerous scheme is dropped', () => {
unchanged('[Arbeitsblatt](https://example.org/ab.pdf)');
// The label survives; only the target goes. A note is never worth less than
// its words, and nothing here should render a tappable `javascript:`.
assert.equal(back('[hier](javascript:alert)'), 'hier');
// A target with parentheses in it is a link, not a broken one.
unchanged('[Erörterung](https://de.wikipedia.org/wiki/Erörterung_(Textsorte))');
});
test('bullet lists nest', () => {
unchanged('- eins\n- zwei\n - zwei a\n - zwei b\n- drei');
});
test('numbered lists keep their numbering', () => {
unchanged('1. eins\n2. zwei\n3. drei');
// A list that starts elsewhere keeps its first number and renumbers the rest.
assert.equal(settles('3. drei\n4. vier'), '3. drei\n4. vier');
});
test('task lists keep their boxes', () => {
unchanged('- [ ] offen\n- [x] erledigt');
assert.match(markdownToHtml('- [x] fertig'), /<input type="checkbox" contenteditable="false" checked>/);
});
test('tables round-trip and line up', () => {
unchanged('| Präfix | Adressen |\n| --- | --- |\n| /24 | 254 |\n| /25 | 126 |');
// A ragged table is tidied once, then left alone.
assert.equal(settles('|a|b|\n|-|-|\n|1|2|'), '| a | b |\n| --- | --- |\n| 1 | 2 |');
});
test('a pipe inside a cell stays inside the cell', () => {
const md = '| Zeichen | Bedeutung |\n| --- | --- |\n| \\| | oder |';
assert.equal(settles(md), md);
});
test('blockquotes and rules survive', () => {
unchanged('> Merksatz des Lehrers\n> über zwei Zeilen');
unchanged('---');
});
test('fenced code keeps its language and its contents verbatim', () => {
unchanged('```bash\nip route add 10.0.0.0/8 via 10.1.1.1\n```');
// Indentation inside a fence is content, not structure.
unchanged('```\nif x:\n y = 1\n```');
});
test('underscores inside words are not emphasis', () => {
unchanged('snake_case_name bleibt ein Wort');
// `__` is bold in Markdown, though, and is normalised to the one spelling.
assert.equal(settles('__wirklich fett__'), '**wirklich fett**');
});
test('a note cannot smuggle HTML into the editor', () => {
const html = markdownToHtml('<script>alert(1)</script> & <b>nicht fett</b>');
assert.equal(html.includes('<script'), false);
assert.equal(html.includes('<b>'), false);
assert.match(html, /&lt;script&gt;/);
});
test('markup the editor does not model keeps its words', () => {
// What a paste from a web page leaves behind: the tags mean nothing here,
// the text means everything.
const html = '<p><span style="font-weight: 700">fett</span> <u>unterstrichen</u> <font color="red">rot</font></p>';
assert.equal(markdownFromDom(parseHtml(html)), '**fett** unterstrichen rot');
});
test('a browser\'s own line divs become paragraphs', () => {
// contenteditable produces these on every Enter, in every engine.
assert.equal(markdownFromDom(parseHtml('<div>eins</div><div>zwei</div>')), 'eins\n\nzwei');
assert.equal(markdownFromDom(parseHtml('<div><br></div>')), '');
});
test('text that looks like Markdown is escaped, and comes back as text', () => {
unchanged('2 \\* 3 \\* 4');
unchanged('\\- kein Listenpunkt');
unchanged('\\# keine Überschrift');
assert.equal(back('Gewicht \\_in kg\\_'), 'Gewicht \\_in kg\\_');
});
test('a whole day note survives unchanged', () => {
// The shape the app writes and the indexer reads back: one `##` per lesson,
// prose, a list, a subheading and a table underneath.
const note = [
'## 1. Deutsch — 08:0008:45 · MEI · R 204',
'',
'Dreischritt: These, Argument mit Beleg, Fazit.',
'',
'### Aufbau',
'',
'- Gegenargument nicht vergessen',
' - kam letztes Jahr in der Arbeit dran',
'- **Fazit** knapp halten',
'',
'## 2. LF07 — 08:5009:35 · Sb · R 108',
'',
'| Präfix | Nutzbare Adressen |',
'| --- | --- |',
'| /24 | 254 |',
'| /25 | 126 |',
'',
'> Kommt so in der Arbeit dran.',
].join('\n');
unchanged(note);
});
test('the lesson headings the indexer keys on come back verbatim', () => {
// `lessonHeading` writes these and `subjectFromHeading` reads the subject
// back out of them. An editor that rewrote the dash or the separator would
// file a day's notes under nothing.
for (const heading of [
'## 1. Deutsch — 08:0008:45 · MEI · R 204',
'## 3. LF07 — 10:3511:20 · Sb · R 108 (Vertretung)',
'## 5. Englisch — 12:1513:00',
]) {
unchanged(heading);
}
});
test('an empty note is empty, not a paragraph', () => {
assert.equal(markdownToHtml(''), '');
assert.equal(back(''), '');
assert.equal(back('\n\n \n'), '');
});
test('a list the browser nested as a sibling keeps its items', () => {
// What several engines produce when Tab indents a bullet: the nested list
// beside the items rather than inside one. Skipping it would drop
// everything under it without a trace.
const html = '<ul><li>eins</li><ul><li>eins a</li></ul><li>zwei</li></ul>';
assert.equal(markdownFromDom(parseHtml(html)), '- eins\n - eins a\n- zwei');
});
test('an item whose text the browser wrapped in a div is still one line', () => {
assert.equal(markdownFromDom(parseHtml('<ul><li><div>eins</div></li></ul>')), '- eins');
assert.equal(markdownFromDom(parseHtml('<ol><li><p>eins</p></li></ol>')), '1. eins');
});
test('what execCommand produces round-trips', () => {
// styleWithCSS is turned off, so bold and italic arrive as tags — but
// `<b>`/`<i>`, not `<strong>`/`<em>`.
assert.equal(markdownFromDom(parseHtml('<p><b>fett</b> und <i>kursiv</i></p>')), '**fett** und _kursiv_');
// A heading made by formatBlock, and the empty paragraph left behind.
assert.equal(markdownFromDom(parseHtml('<h2>Deutsch</h2><p><br></p>')), '## Deutsch');
});
test('a task list keeps its state through the DOM the editor builds', () => {
const html = '<ul><li class="task"><input type="checkbox" contenteditable="false">offen</li>' +
'<li class="task"><input type="checkbox" contenteditable="false" checked>fertig</li></ul>';
assert.equal(markdownFromDom(parseHtml(html)), '- [ ] offen\n- [x] fertig');
});