A table, code block, quote or list as the last element of a contenteditable element is a dead end: there is no node after it to put the caret in, and no key that makes one, so the note simply cannot be continued below it. Every engine behaves this way. The editor now keeps an empty paragraph after such a block — it serializes to nothing, so it never reaches the file, which a test pins down. Tables got the rest of what they were missing while the cause was in view: Tab walks the cells and a Tab out of the last one adds a row, the toolbar button adds a row when the cursor is already in a table (a phone has no Tab key, and it renames itself so it says which it will do), and Ctrl/Cmd+Enter opens a paragraph after whatever block the cursor is in. Empty cells are given a break, because a `<td></td>` with nothing in it cannot be clicked into in Gecko — a blank cell was uneditable. Driven in Firefox against a page that reproduces the dead end first.
243 lines
9.8 KiB
TypeScript
243 lines
9.8 KiB
TypeScript
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'), /<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, /<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 = '<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: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 = '<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');
|
||
});
|
||
|
||
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 |') + '<p><br></p>';
|
||
assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |');
|
||
});
|
||
|
||
test('an empty cell stays an empty cell', () => {
|
||
// The editor puts a <br> in blank cells so the caret can reach them.
|
||
unchanged('| a | b |\n| --- | --- |\n| | 2 |');
|
||
assert.match(markdownToHtml('| a |\n| --- |\n| |'), /<td><br><\/td>/);
|
||
});
|
||
|
||
test('a row the editor appended round-trips', () => {
|
||
const html = '<table><thead><tr><th>a</th><th>b</th></tr></thead>' +
|
||
'<tbody><tr><td>1</td><td>2</td></tr><tr><td><br></td><td><br></td></tr></tbody></table>';
|
||
assert.equal(markdownFromDom(parseHtml(html)), '| a | b |\n| --- | --- |\n| 1 | 2 |\n| | |');
|
||
});
|