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:00–08:45 · MEI · R 204'); unchanged('# Eins\n\n## Zwei\n\n### Drei\n\n#### Vier'); assert.match(markdownToHtml('## Deutsch'), /

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'), '

a
b

'); }); 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**'), '

fett

'); }); test('emphasis markers hug their text', () => { // `** fett **` is four literal stars in every renderer there is. const html = '

Merke: fett rest

'; 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'), //); }); 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(' & nicht fett'); assert.equal(html.includes(''), false); assert.match(html, /<script>/); }); 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 = '

fett unterstrichen rot

'; 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('
eins
zwei
')), 'eins\n\nzwei'); assert.equal(markdownFromDom(parseHtml('

')), ''); }); 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:00–08: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:50–09: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:00–08:45 · MEI · R 204', '## 3. LF07 — 10:35–11:20 · Sb · R 108 (Vertretung)', '## 5. Englisch — 12:15–13: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 = ''; 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('')), '- eins'); assert.equal(markdownFromDom(parseHtml('
  1. eins

')), '1. eins'); }); test('what execCommand produces round-trips', () => { // styleWithCSS is turned off, so bold and italic arrive as tags — but // ``/``, not ``/``. assert.equal(markdownFromDom(parseHtml('

fett und kursiv

')), '**fett** und _kursiv_'); // A heading made by formatBlock, and the empty paragraph left behind. assert.equal(markdownFromDom(parseHtml('

Deutsch


')), '## Deutsch'); }); test('a task list keeps its state through the DOM the editor builds', () => { const html = '
  • offen
  • ' + '
  • fertig
'; assert.equal(markdownFromDom(parseHtml(html)), '- [ ] offen\n- [x] fertig'); }); test('the editor\'s trailing escape line never reaches the file', () => { // A table at the end of a contenteditable element is a dead end, so the // editor keeps an empty paragraph after it. It must serialize to nothing, // or every note with a table would grow a blank line on each save. const html = markdownToHtml('| a | b |\n| --- | --- |\n| 1 | 2 |') + '


'; assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |'); }); test('an empty cell stays an empty cell', () => { // The editor puts a
in blank cells so the caret can reach them. unchanged('| a | b |\n| --- | --- |\n| | 2 |'); assert.match(markdownToHtml('| a |\n| --- |\n| |'), /
<\/td>/); }); test('a row the editor appended round-trips', () => { const html = '' + '
ab
12


'; assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |'); }); test('a paste from Apple Notes keeps its structure and is not all bold', () => { // What WebKit actually puts on the clipboard: one wrapper span carrying the // *computed* style of everything copied — including `font-weight: 700` — // with the real blocks nested inside it. Read naively that makes the whole // note bold and flattens every line into one paragraph. const html = '' + '
Erörterung

These, Argument, Fazit
' + '
  • Gegenargument nicht vergessen
  • Fazit knapp halten
'; assert.equal( markdownFromDom(parseHtml(html)), '**Erörterung**\n\nThese, Argument, Fazit\n\n- Gegenargument nicht vergessen\n- Fazit knapp halten', ); }); test('a container\'s font never swallows the blocks inside it', () => { // The general rule behind the case above: an element holding blocks is a // container whatever its tag, and a container's style is not emphasis. const html = '

Deutsch

Text

'; assert.equal(markdownFromDom(parseHtml(html)), '## Deutsch\n\nText'); }); test('a styled span around a single run is still emphasis', () => { // The case the style check exists for, which must keep working. assert.equal(markdownFromDom(parseHtml('

fett rest

')), '**fett** rest'); assert.equal(markdownFromDom(parseHtml('

kursiv

')), '_kursiv_'); }); test('no word is ever lost, whatever the markup', () => { // The property that matters more than any particular shape: a note is // allowed to lose its formatting, never its words. const html = '
Erste Zeile
' + '

Zitat

' + '
Zelle
'; const markdown = markdownFromDom(parseHtml(html)); for (const word of ['Erste', 'Zeile', 'Zitat', 'Zelle']) { assert.ok(markdown.includes(word), `lost "${word}" in: ${markdown}`); } });