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.
This commit is contained in:
MechaCat02
2026-09-19 21:24:42 +02:00
parent 5c0b658855
commit 534b1b0f58
13 changed files with 1690 additions and 33 deletions

View File

@@ -67,6 +67,11 @@ are stale — they were last taken before the notes and class-register work, and
could not be retaken because the live session had lapsed. Every Schulcloud check fails with 401 when the live
session has lapsed — check the container's keepalive log before suspecting code.
The editor's Markdown round trip is unit-tested; the **browser** side of
`editor.js` is not, because nothing here runs one. It was checked by hand in
Firefox against a page that drives the toolbar — WebKit, which is the engine on
the phone this is written on, has still never run it.
Store tests need a database and skip without one:
`TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on
purpose — the generation/diff semantics are entirely SQL, so a mock would test
@@ -145,7 +150,20 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
**files** under `src/http/app/`, copied to `dist/` by `scripts/copy-assets.mjs`
and read relative to `import.meta.dirname` — real HTML, CSS and JS that an
editor and a linter understand, which is also what the CSP requires, since it
forbids inline script.
forbids inline script. `app.js` is an ES **module**; a new asset must be added
to `ASSETS` *and* to the route's regex in `app-page.ts`, or it 404s.
- **`http/app/markdown.js` + `editor.js`** — the note is edited as formatted
text and stored as Markdown, and these two are that translation.
`markdown.js` is the pair `markdownToHtml` / `markdownFromDom`; `editor.js`
drives a `contenteditable` element with `execCommand` (no library: the CSP
allows no outside script and the app has no bundler). **The round trip must
settle**: one pass may tidy a note, a second must change nothing, and
`editor.js` checks exactly that before opening a note formatted — a note that
fails opens in the Markdown view instead. `test/app-markdown.test.ts` covers
it against `test/mini-dom.ts`, ~60 lines of read-only DOM, because losing a
lesson's notes to a lossy serializer is not a bug anyone can recover from.
Pasted HTML goes through Markdown before it reaches the document, which is
where sanitising and formatting are the same operation.
- **`http/web-auth.ts`** — the app's login, which is a different kind of
credential from everything else here: a password a person types, not a token a
program was configured with. scrypt at startup, a signed `HttpOnly` /

View File

@@ -86,6 +86,11 @@ teachers, rooms, cancellations dropped and substitutions marked. Each heading is
indexed as its own lesson, so a search answers "my own note, Deutsch,
18.09.2026" rather than "Friday".
Writing is **formatted, not Markdown**: headings, bold, lists, tick boxes,
quotes, links and tables come from a toolbar, and `MD` shows the Markdown
underneath when you want it. The file on disk stays Markdown either way — that
is what the index reads and what outlives the app.
It saves as you type, keeps a local copy of every keystroke for when the signal
goes, and refuses a save that would overwrite a version it never saw. On a phone
it adds to the home screen and opens standalone.

View File

@@ -12,7 +12,7 @@ should not make twice**, because changing it later means moving files by hand.
| | |
|---|---|
| **Your own lesson notes** | A directory of Markdown files the server reads, indexes and searches beside Schulcloud and WebUntis. Three tools: `list_notes`, `get_note`, `add_note`. |
| **The app at `/app`** | A login, a day-at-a-time notes editor, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. |
| **The app at `/app`** | A login, a day-at-a-time notes editor with a formatting toolbar, and a settings page that replaces the Schulcloud token. Only served when `WEB_PASSWORD` is set. |
| **The WebUntis class register** | `untis_lesson_topics` now takes a subject as well as a period id, and `UNTIS_HISTORY_DAYS` of "what was actually taught" goes into the search index. |
Nothing here changes Schulcloud or WebUntis: both stay read-only. The notes
@@ -241,6 +241,8 @@ write them.
| `Der Server nimmt keine Änderungen an` | `NOTES_READONLY` is on | Remove it and recreate the container |
| Saves refused as a conflict, repeatedly | The note is being changed elsewhere — a sync tool, another device | Choose a version in the banner; if a sync tool keeps rewriting the file, it is fighting the app |
| `EACCES` for `/data/notes` in the logs | A bind-mounted directory the container's user cannot write | `sudo chown -R 1000:1000 /home/pi/Notizen` (match the image's user), then recreate |
| The toolbar is there, the text stays plain | The browser blocked `editor.js` or `markdown.js` | Check the console; both must be served from `/app/`, and `app.js` must load as `type="module"` |
| A note opens in the Markdown view by itself, with a hint | It holds formatting the formatted view cannot keep unchanged | Nothing is wrong and nothing was lost; edit it there, or simplify the note |
| Notes exist but `search` cannot find them | Only a full crawl reads them | `schulcloud refresh --force` |
| `search` finds a day note but names no subject | The lesson headings were rewritten past recognition | Keep `## 1. Deutsch …`; the leading number and the subject are what the index reads |
| `untis_lesson_topics` with a subject finds nothing | The subject code differs from what you typed | Check it against `untis_timetable`; the register uses the school's own codes |

View File

@@ -87,6 +87,41 @@ thing I open in a free period".
`add_note` — the save is refused and you are asked which version wins. It
never silently overwrites.
### Writing, without typing Markdown
The editor shows the note **formatted** — headings as headings, bold as bold,
tables as tables — and the toolbar above it writes the Markdown. Nobody types
`##` or `**` during a lesson.
| Button | What it writes |
| --- | --- |
| `H2` | a lesson heading — the one that makes the lesson separately searchable |
| `H3` | a subheading inside a lesson |
| `F` `K` `S` | **fett**, _kursiv_, ~~durchgestrichen~~ (`Strg`/`Cmd` + B, I) |
| `<>` | inline code (`Strg`/`Cmd` + E) |
| `• —` `1. —` | bullet and numbered lists; nest them with Tab |
| `☐` | a box to tick off |
| `❝` | a quote — the teacher's exact wording |
| `🔗` `▦` | a link (`Strg`/`Cmd` + K) and a table |
| `MD` | the Markdown itself |
`Enter` starts a new paragraph, `Shift+Enter` a new line in the same one. A
paste from a web page or a PDF keeps its structure and loses its fonts, colours
and anything else that is not in the list above — pasted HTML is converted to
Markdown before it reaches the page, which is what keeps a copied page from
bringing its script along.
**The file is still Markdown.** `MD` shows it and lets you edit it directly,
which is the way to write something the toolbar has no button for. There is no
underline, because Markdown cannot store one — `F` or `K` instead.
Opening a note may tidy it once: `*so*` becomes `_so_`, a table typed unevenly
lines up. Nothing is rewritten until you actually change something, and a note
whose formatting the view cannot hold unchanged **opens as Markdown** and says
so rather than being quietly reduced. `test/app-markdown.test.ts` is what holds
that promise up: every construct in this document goes in and comes back out
unchanged.
**Einstellungen** holds the Schulcloud token: how long it has left, and the box
to paste a fresh `jwt` cookie into when it expires (the same thing `schulcloud
token set` and the older `/token` page do). It also shows the index's state and

View File

@@ -726,10 +726,22 @@ console.log('\n== web app ==');
);
check('the shell holds no secret of its own', !shellText.includes(WEB_PASSWORD) && !shellText.includes(TOKEN));
const assets = await Promise.all(
['app.js', 'app.css', 'icon.svg', 'manifest.webmanifest'].map((name) => fetch(`${root}/app/${name}`)),
);
// Every file the shell asks for, including the two modules the editor is
// made of: a missing one leaves a page that loads and cannot type.
const assetNames = ['app.js', 'editor.js', 'markdown.js', 'app.css', 'icon.svg', 'manifest.webmanifest'];
const assets = await Promise.all(assetNames.map((name) => fetch(`${root}/app/${name}`)));
check('the app\'s assets are served', assets.every((response) => response.ok), assets.map((r) => r.status).join(' '));
check(
'the editor\'s modules are served as JavaScript',
assets
.filter((_, index) => assetNames[index].endsWith('.js'))
.every((response) => /javascript/.test(response.headers.get('content-type') ?? '')),
assets.map((r) => r.headers.get('content-type')).join(' | '),
);
check(
'the shell loads the app as a module, so its imports resolve',
/<script type="module" src="app\.js">/.test(shellText) && shellText.includes('data-command="bold"'),
);
const anonymousSession = await (await fetch(`${root}/app/session`)).json();
check('session says "not logged in" rather than failing', anonymousSession.authenticated === false);

View File

@@ -21,6 +21,10 @@ import { createWebAuth, isSecureRequest, sessionAuth, type WebAuth } from './web
* security policy forbids inline script anyway — so the only thing gained by
* embedding them would be a build step that no longer copies them, and the
* only thing lost would be every tool that reads them.
*
* `app.js` is an ES module and imports the other two, which is also what lets
* `markdown.js` — the Markdown the editor reads and writes — be tested under
* `node --test` rather than only in a browser.
*/
/** No outside resources at all, and no inline script. Nothing here needs either. */
@@ -42,6 +46,8 @@ const ASSETS: Record<string, { file: string; type: string }> = {
'/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' },
'/app.css': { file: 'app.css', type: 'text/css; charset=utf-8' },
'/app.js': { file: 'app.js', type: 'text/javascript; charset=utf-8' },
'/editor.js': { file: 'editor.js', type: 'text/javascript; charset=utf-8' },
'/markdown.js': { file: 'markdown.js', type: 'text/javascript; charset=utf-8' },
'/icon.svg': { file: 'icon.svg', type: 'image/svg+xml' },
'/manifest.webmanifest': { file: 'manifest.webmanifest', type: 'application/manifest+json' },
};
@@ -85,10 +91,13 @@ export function createAppRouter(config: Config): AppSurface | undefined {
// The shell is public: it is the login screen, and it holds nothing. Every
// byte of data it goes on to show comes from /api, behind the session.
router.get(/^\/(index\.html|app\.css|app\.js|icon\.svg|manifest\.webmanifest)?$/, (req: Request, res: Response) => {
const entry = ASSETS[req.path] ?? ASSETS['/']!;
res.type(entry.type).send(asset(entry.file));
});
router.get(
/^\/(index\.html|app\.css|app\.js|editor\.js|markdown\.js|icon\.svg|manifest\.webmanifest)?$/,
(req: Request, res: Response) => {
const entry = ASSETS[req.path] ?? ASSETS['/']!;
res.type(entry.type).send(asset(entry.file));
},
);
router.post('/login', express.json({ limit: '4kb' }), (req: Request, res: Response) => {
const password = (req.body as { password?: unknown } | undefined)?.password;

View File

@@ -93,8 +93,129 @@ header { border-bottom: 1px solid var(--line); }
.daybar-centre strong { font-size: 1.05rem; }
.daybar-centre input { border: 0; background: none; color: var(--muted); font: inherit; font-size: 0.85rem; }
/* --- the toolbar ------------------------------------------------------- */
/*
* One row that scrolls sideways rather than wrapping into two: a second row
* would take a line of editor away from every note to hold buttons that are
* used once a lesson, and a thumb swipes a row far more easily than it hunts
* through a grid.
*/
.toolbar {
display: flex;
align-items: center;
gap: 0.25rem;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 0.15rem;
}
.toolbar::-webkit-scrollbar { display: none; }
.toolbar button {
flex: 0 0 auto;
min-width: 2.5rem;
height: 2.5rem;
padding: 0 0.5rem;
font-size: 0.9rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.toolbar button[aria-pressed="true"] {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--card));
}
.toolbar button code { font-family: ui-monospace, monospace; font-size: 0.85rem; }
.sep { flex: 0 0 auto; width: 1px; height: 1.5rem; background: var(--line); margin: 0 0.15rem; }
/* --- the editor -------------------------------------------------------- */
/*
* The formatted document. It is the note as it will read, not as it is stored
* — the file underneath is still Markdown, and `MD` in the toolbar shows it.
*/
.editor {
flex: 1;
min-height: 12rem;
overflow-y: auto;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--bg);
color: var(--fg);
font-size: 1rem;
line-height: 1.55;
/* A long URL or a wide table must not push the page sideways. */
overflow-wrap: break-word;
}
.editor:focus-visible { outline: 2px solid var(--accent); outline-offset: -1px; }
.editor.empty::before {
content: attr(data-placeholder);
color: var(--muted);
pointer-events: none;
}
.editor > :first-child { margin-top: 0; }
.editor > :last-child { margin-bottom: 0; }
.editor p { margin: 0 0 0.75rem; }
/* A lesson heading is the note's structure — each one is indexed as its own
lesson — so it is given a rule to sit on rather than just a larger size. */
.editor h2 {
margin: 1.25rem 0 0.5rem;
padding-bottom: 0.2rem;
border-bottom: 1px solid var(--line);
font-size: 1.1rem;
}
.editor h1 { font-size: 1.25rem; margin: 1.25rem 0 0.5rem; }
.editor h3, .editor h4, .editor h5, .editor h6 { margin: 1rem 0 0.35rem; font-size: 1rem; }
.editor ul, .editor ol { margin: 0 0 0.75rem; padding-left: 1.4rem; }
.editor li { margin: 0.15rem 0; }
.editor li.task { list-style: none; margin-left: -1.2rem; }
.editor li.task input { margin-right: 0.4rem; }
.editor blockquote {
margin: 0 0 0.75rem;
padding-left: 0.75rem;
border-left: 3px solid var(--line);
color: var(--muted);
}
.editor code {
padding: 0.1em 0.3em;
border-radius: 0.25rem;
background: var(--card);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9em;
}
.editor pre {
margin: 0 0 0.75rem;
padding: 0.6rem 0.75rem;
border-radius: 0.5rem;
background: var(--card);
overflow-x: auto;
}
.editor pre code { padding: 0; background: none; }
.editor hr { border: 0; border-top: 1px solid var(--line); margin: 1rem 0; }
.editor a { color: var(--accent); }
/* A table wider than the phone scrolls inside the note rather than stretching
it: the caret has to stay reachable. */
.editor table { display: block; overflow-x: auto; border-collapse: collapse; margin: 0 0 0.75rem; font-size: 0.9rem; }
.editor th, .editor td { border: 1px solid var(--line); padding: 0.3rem 0.5rem; text-align: left; min-width: 3rem; }
.editor th { background: var(--card); }
textarea {
flex: 1;
min-height: 12rem;
@@ -104,8 +225,8 @@ textarea {
border-radius: 0.5rem;
background: var(--bg);
color: var(--fg);
/* Monospace: the notes are Markdown, and headings and list markers have to
line up to be read back as structure. */
/* Monospace: this is the Markdown view, and headings and list markers have
to line up to be read back as structure. */
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.95rem;
line-height: 1.5;

View File

@@ -1,4 +1,4 @@
'use strict';
import { createEditor } from './editor.js';
/*
* The notes app.
@@ -20,6 +20,10 @@
* - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert",
* "Offline — lokal gesichert". A silent editor over a flaky connection is
* indistinguishable from one that is losing your work.
*
* What the person sees is formatted text with a toolbar; what is written to
* disk is Markdown. `editor.js` is the whole of that translation — everything
* here deals in Markdown strings and never touches the document.
*/
const AUTOSAVE_MS = 2500;
@@ -41,7 +45,10 @@ const ui = {
dayDate: document.getElementById('day-date'),
dayStatus: document.getElementById('day-status'),
conflict: document.getElementById('day-conflict'),
toolbar: document.getElementById('toolbar'),
editor: document.getElementById('editor'),
source: document.getElementById('source'),
editorHint: document.getElementById('editor-hint'),
save: document.getElementById('save'),
fill: document.getElementById('fill'),
lessonsHint: document.getElementById('lessons-hint'),
@@ -68,6 +75,28 @@ const day = {
timer: 0,
};
/**
* The formatted editor over the two elements that hold a note.
*
* It owns the document and the toolbar; this file only ever asks it for
* Markdown and hands it Markdown back.
*/
const editor = createEditor({
rich: ui.editor,
source: ui.source,
toolbar: ui.toolbar,
onInput: markDirty,
onModeChange: (mode) => {
// Switching by hand is not a warning, so the automatic one goes away.
hint(mode === 'source' ? 'Markdown-Ansicht. „MD" führt zurück.' : '');
},
});
function hint(message) {
ui.editorHint.textContent = message;
ui.editorHint.hidden = !message;
}
// --- plumbing ------------------------------------------------------------
async function api(path, options) {
@@ -129,9 +158,10 @@ function draftKey(date) {
return DRAFT_PREFIX + date;
}
function saveDraft() {
function saveDraft(text) {
try {
localStorage.setItem(draftKey(day.date), JSON.stringify({ text: ui.editor.value, at: Date.now() }));
const value = text === undefined ? editor.getMarkdown() : text;
localStorage.setItem(draftKey(day.date), JSON.stringify({ text: value, at: Date.now() }));
} catch (error) {
// A full or disabled localStorage must not break typing; the server copy
// is still the real one.
@@ -172,7 +202,8 @@ async function loadDay(date) {
day.conflicted = false;
ui.conflict.hidden = true;
ui.dayDate.value = date;
ui.editor.disabled = true;
editor.setEnabled(false);
hint('');
setStatus('Wird geladen …');
let info;
@@ -185,8 +216,8 @@ async function loadDay(date) {
// no notes at all — and reporting that as "offline" would send someone
// looking at their signal instead of at NOTES_DIR.
if (error.status) {
ui.editor.value = '';
ui.editor.disabled = true;
editor.setMarkdown('');
editor.setEnabled(false);
setStatus(error.message, 'error');
ui.lessonsHint.textContent = '';
return;
@@ -194,8 +225,8 @@ async function loadDay(date) {
// No status: the request never arrived. Fall back to whatever this device
// has, rather than an empty editor that looks like a day with no notes.
const draft = readDraft(date);
ui.editor.disabled = false;
ui.editor.value = draft ? draft.text : '';
editor.setEnabled(true);
editor.setMarkdown(draft ? draft.text : '');
day.saved = '';
day.modifiedAt = null;
day.dirty = Boolean(draft);
@@ -217,10 +248,25 @@ async function loadDay(date) {
// is just the last save echoed back and offering it would be noise.
const useDraft = draft && draft.text !== server && draft.text.trim() !== '';
ui.editor.value = useDraft ? draft.text : server;
ui.editor.disabled = false;
day.saved = info.exists ? info.text : '';
day.dirty = ui.editor.value !== day.saved;
editor.setEnabled(true);
// The server's text first, and what the editor makes of it is the baseline.
// Opening a note the editor would tidy — a table typed unevenly, `*` for
// italics — must not count as an edit, or simply looking at a day would
// rewrite the file.
const loaded = editor.setMarkdown(server);
day.saved = info.exists ? editor.getMarkdown() : '';
if (useDraft) editor.setMarkdown(draft.text);
day.dirty = editor.getMarkdown() !== day.saved;
// One note in a hundred: something the formatted view cannot hold without
// changing it. It opens as Markdown rather than being quietly reduced.
hint(
!loaded.faithful
? 'Diese Notiz enthält Formatierung, die die formatierte Ansicht nicht unverändert halten kann — deshalb Markdown.'
: editor.mode === 'source'
? 'Markdown-Ansicht. „MD" führt zurück.'
: '',
);
if (useDraft) {
setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn');
@@ -250,8 +296,9 @@ function describeLessons(info) {
}
function markDirty() {
day.dirty = ui.editor.value !== day.saved;
saveDraft();
const text = editor.getMarkdown();
day.dirty = text !== day.saved;
saveDraft(text);
if (day.conflicted) return;
if (day.dirty) setStatus('Nicht gespeichert …');
window.clearTimeout(day.timer);
@@ -261,7 +308,7 @@ function markDirty() {
async function saveDay(automatic) {
window.clearTimeout(day.timer);
if (!day.dirty && automatic) return;
const text = ui.editor.value;
const text = editor.getMarkdown();
setStatus('Wird gespeichert …');
try {
@@ -424,17 +471,14 @@ ui.dayDate.addEventListener('change', () => {
if (ui.dayDate.value) void loadDay(ui.dayDate.value);
});
ui.editor.addEventListener('input', markDirty);
ui.save.addEventListener('click', () => void saveDay(false));
ui.fill.addEventListener('click', () => {
// Appended, never merged into place: the person's own text is not something
// to reorder, and a heading in the wrong order is trivial to move.
const separator = ui.editor.value.trim() ? '\n\n' : '';
ui.editor.value = ui.editor.value.replace(/\s*$/, '') + separator + day.missing;
editor.append(day.missing);
day.missing = '';
ui.fill.hidden = true;
markDirty();
});
// A phone locking, the app going to the background, or the tab closing: all of

400
src/http/app/editor.js Normal file
View File

@@ -0,0 +1,400 @@
import { markdownFromDom, markdownToHtml } from './markdown.js';
/*
* The formatted editor.
*
* Notes are written in a lesson, with a thumb, on a phone. Typing `##` and
* `**` while a teacher talks is not note-taking, so what this shows is the
* formatted text and a toolbar — and what it writes to disk is still Markdown,
* because that is what the indexer reads and what outlives this app.
*
* `contenteditable` plus `document.execCommand` rather than a framework or an
* editor library: the content security policy allows no outside script, and
* this app has no build step to bundle one in. execCommand is deprecated on
* paper and universally implemented in practice — including on iOS Safari,
* which is the browser that actually matters here — and it brings selection
* handling, native undo and the software keyboard's own behaviour with it.
* A hand-written selection engine would be a much larger thing to get wrong.
*
* Whatever the browser leaves behind in the document is the serializer's
* problem, not this file's: `markdownFromDom` is deliberately tolerant, and
* everything typed, pasted or produced by a command is reduced to the
* supported subset on the way to the file.
*/
export function createEditor(options) {
const rich = options.rich;
const source = options.source;
const toolbar = options.toolbar;
const onInput = options.onInput ?? (() => {});
const onModeChange = options.onModeChange ?? (() => {});
let mode = 'rich';
// Which view the person last chose. Moving to another day should not undo
// that choice, so only a note the formatted view cannot hold overrides it.
let preferred = 'rich';
let enabled = true;
// execCommand's default is to write inline styles (`<span style="font-weight:
// bold">`). Tags survive the trip to Markdown far more reliably, and the
// serializer would only have to undo the styles anyway.
try {
document.execCommand('styleWithCSS', false, false);
} catch {
// Not every engine has it, and none of them needs it to work.
}
// --- reading and writing --------------------------------------------
function getMarkdown() {
return mode === 'source' ? source.value.trim() : markdownFromDom(rich);
}
function setMarkdown(text) {
const value = String(text ?? '');
// A note whose formatting this editor cannot hold opens as Markdown
// rather than being quietly rewritten into something smaller.
const faithful = isStable(value);
setMode(faithful ? preferred : 'source', { silent: true });
source.value = value;
rich.innerHTML = markdownToHtml(value);
updatePlaceholder();
return { faithful };
}
/**
* Whether the round trip settles.
*
* One pass may tidy the note — `*a*` becomes `_a_`, a ragged table lines up
* — and that is fine, because a note is only ever rewritten once it has been
* edited. A second pass that changes something again is not fine: it means
* this editor does not understand the note, and every save would erode it a
* little further. That is the case where the Markdown view is the honest
* answer.
*/
function isStable(text) {
const once = markdownFromDom(parse(text));
return markdownFromDom(parse(once)) === once;
}
function parse(text) {
const holder = document.createElement('div');
holder.innerHTML = markdownToHtml(text);
return holder;
}
// --- the two modes ---------------------------------------------------
function setMode(next, config) {
if (next === mode) return;
// Carry the text across, so a toggle never costs a word.
if (next === 'source') source.value = markdownFromDom(rich);
else rich.innerHTML = markdownToHtml(source.value);
mode = next;
rich.hidden = mode !== 'rich';
source.hidden = mode !== 'source';
toolbar.querySelectorAll('[data-command]').forEach((button) => {
if (button.dataset.command !== 'mode') button.disabled = mode === 'source';
});
const toggle = toolbar.querySelector('[data-command="mode"]');
if (toggle) toggle.setAttribute('aria-pressed', String(mode === 'source'));
updatePlaceholder();
if (!config || !config.silent) onModeChange(mode);
}
function setEnabled(value) {
enabled = value;
rich.contentEditable = value ? 'true' : 'false';
source.disabled = !value;
toolbar.querySelectorAll('button').forEach((button) => {
button.disabled = !value || (mode === 'source' && button.dataset.command !== 'mode');
});
}
function updatePlaceholder() {
rich.classList.toggle('empty', rich.textContent.trim() === '' && rich.children.length <= 1);
}
// --- commands --------------------------------------------------------
const commands = {
bold: () => document.execCommand('bold'),
italic: () => document.execCommand('italic'),
strike: () => document.execCommand('strikeThrough'),
h2: () => toggleBlock('H2'),
h3: () => toggleBlock('H3'),
quote: () => toggleBlock('BLOCKQUOTE'),
ul: () => document.execCommand('insertUnorderedList'),
ol: () => document.execCommand('insertOrderedList'),
task: insertTask,
code: insertCode,
link: insertLink,
table: insertTable,
mode: () => {
preferred = mode === 'rich' ? 'source' : 'rich';
setMode(preferred);
},
};
/** A second press on the same button goes back to ordinary text. */
function toggleBlock(tag) {
const current = blockAt();
document.execCommand('formatBlock', false, current === tag ? 'P' : tag);
}
function blockAt() {
let node = selectionNode();
while (node && node !== rich) {
if (node.nodeType === 1 && /^(P|DIV|H[1-6]|BLOCKQUOTE|LI|PRE|TD|TH)$/.test(node.nodeName)) return node.nodeName;
node = node.parentNode;
}
return '';
}
function selectionNode() {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0) return undefined;
const node = selection.getRangeAt(0).startContainer;
return rich.contains(node) ? node : undefined;
}
/**
* A checkbox item.
*
* Built as a list first, so the browser handles the splitting and merging
* of the item the cursor is in, and then given its box.
*/
function insertTask() {
const item = itemAt();
if (item && firstCheckbox(item)) {
// Already a task: take the box away rather than adding a second.
firstCheckbox(item).remove();
return;
}
if (!item) document.execCommand('insertUnorderedList');
const target = itemAt();
if (!target || firstCheckbox(target)) return;
target.classList.add('task');
target.insertBefore(checkbox(false), target.firstChild);
}
function itemAt() {
let node = selectionNode();
while (node && node !== rich) {
if (node.nodeType === 1 && node.nodeName === 'LI') return node;
node = node.parentNode;
}
return undefined;
}
function checkbox(checked) {
const box = document.createElement('input');
box.type = 'checkbox';
box.contentEditable = 'false';
// The attribute, not just the property: the serializer reads the
// document, and a property set by a click leaves no trace in it.
if (checked) box.setAttribute('checked', '');
return box;
}
function firstCheckbox(item) {
const first = item.firstElementChild;
return first && first.nodeName === 'INPUT' && first.type === 'checkbox' ? first : null;
}
function insertCode() {
const selection = document.getSelection();
const text = selection ? selection.toString() : '';
// `insertHTML` and not a wrapping node, so the caret lands inside the
// new element and the browser records one undo step.
document.execCommand('insertHTML', false, '<code>' + escapeHtml(text || 'Code') + '</code>&nbsp;');
}
function insertLink() {
const selection = document.getSelection();
const label = selection ? selection.toString() : '';
const href = window.prompt('Adresse des Links', 'https://');
if (!href || href === 'https://') return;
if (!/^(https?:|mailto:|tel:)/i.test(href)) {
window.alert('Nur http, https, mailto und tel.');
return;
}
if (label) document.execCommand('createLink', false, href);
else document.execCommand('insertHTML', false, '<a href="' + escapeHtml(href) + '">' + escapeHtml(href) + '</a>&nbsp;');
}
function insertTable() {
const head = '<tr><th>&nbsp;</th><th>&nbsp;</th></tr>';
const row = '<tr><td>&nbsp;</td><td>&nbsp;</td></tr>';
document.execCommand(
'insertHTML',
false,
'<table><thead>' + head + '</thead><tbody>' + row + row + '</tbody></table><p><br></p>',
);
}
// --- input -----------------------------------------------------------
function notify() {
updatePlaceholder();
onInput();
}
rich.addEventListener('input', notify);
source.addEventListener('input', notify);
// A checkbox is the one control inside the document: its state has to reach
// the markup, or the save would not see it.
rich.addEventListener('change', (event) => {
const target = event.target;
if (!target || target.nodeName !== 'INPUT' || target.type !== 'checkbox') return;
if (target.checked) target.setAttribute('checked', '');
else target.removeAttribute('checked');
notify();
});
/**
* Pasted content goes through Markdown before it reaches the document.
*
* A paste from a web page or a Word document carries fonts, colours,
* classes and occasionally script. Converting it to Markdown and parsing it
* back reduces it to exactly what this editor supports — the same subset the
* file will hold — and is the one place where sanitising and formatting are
* the same operation.
*/
rich.addEventListener('paste', (event) => {
if (!enabled || mode !== 'rich') return;
const data = event.clipboardData;
if (!data) return;
const html = data.getData('text/html');
const text = data.getData('text/plain');
if (!html && !text) return;
event.preventDefault();
let markdown;
if (html) {
const holder = document.createElement('div');
// Never assigned to a live document: this element is detached, and
// what comes out of it is Markdown, not markup.
holder.innerHTML = html;
markdown = markdownFromDom(holder);
} else {
markdown = text;
}
document.execCommand('insertHTML', false, markdownToHtml(markdown));
notify();
});
rich.addEventListener('keydown', (event) => {
const modifier = event.metaKey || event.ctrlKey;
if (modifier && !event.altKey) {
const key = event.key.toLowerCase();
const shortcut = { b: 'bold', i: 'italic', k: 'link', e: 'code' }[key];
if (shortcut) {
event.preventDefault();
run(shortcut);
return;
}
}
// Enter at the end of a task item continues the list as tasks; the
// browser would give the new item no box.
if (event.key === 'Enter' && !event.shiftKey) {
const item = itemAt();
if (item && firstCheckbox(item)) {
window.setTimeout(() => {
const next = itemAt();
if (next && next !== item && !firstCheckbox(next) && next.textContent.trim() === '') {
next.classList.add('task');
next.insertBefore(checkbox(false), next.firstChild);
}
}, 0);
}
}
});
// --- the toolbar -----------------------------------------------------
function run(name) {
const command = commands[name];
if (!command) return;
if (name !== 'mode') {
if (!enabled || mode !== 'rich') return;
rich.focus();
}
command();
notify();
reflect();
}
toolbar.addEventListener('mousedown', (event) => {
// The selection must survive the press, or every command would apply to
// nothing. Touch devices fire this too, ahead of the click.
if (event.target.closest('[data-command]')) event.preventDefault();
});
toolbar.addEventListener('click', (event) => {
const button = event.target.closest('[data-command]');
if (!button) return;
event.preventDefault();
run(button.dataset.command);
});
/** Which buttons are "on" for the cursor's position. */
function reflect() {
if (mode !== 'rich') return;
const block = blockAt();
const states = {
bold: query('bold'),
italic: query('italic'),
strike: query('strikeThrough'),
h2: block === 'H2',
h3: block === 'H3',
quote: block === 'BLOCKQUOTE',
ul: query('insertUnorderedList'),
ol: query('insertOrderedList'),
};
for (const [name, active] of Object.entries(states)) {
const button = toolbar.querySelector('[data-command="' + name + '"]');
if (button) button.setAttribute('aria-pressed', String(Boolean(active)));
}
}
function query(command) {
try {
return document.queryCommandState(command);
} catch {
return false;
}
}
document.addEventListener('selectionchange', () => {
if (selectionNode()) reflect();
});
// --- what the app calls ----------------------------------------------
return {
getMarkdown,
setMarkdown,
setEnabled,
get mode() {
return mode;
},
focus() {
(mode === 'rich' ? rich : source).focus();
},
/** Adds Markdown at the end — how the day's missing lessons arrive. */
append(markdown) {
const current = getMarkdown();
const next = (current ? current.replace(/\s*$/, '') + '\n\n' : '') + markdown;
if (mode === 'source') source.value = next;
else rich.innerHTML = markdownToHtml(next);
notify();
},
};
}
function escapeHtml(value) {
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

View File

@@ -45,8 +45,38 @@
<p id="day-status" class="status" role="status" aria-live="polite"></p>
<p id="day-conflict" class="conflict" role="alert" hidden></p>
<textarea id="editor" spellcheck="true" autocapitalize="sentences"
placeholder="Noch nichts für diesen Tag." aria-label="Notizen des Tages"></textarea>
<!-- The toolbar writes the Markdown so nobody has to type it. Every button
carries a word as well as a glyph, because the glyph is the thing a
screen reader cannot read and a stranger cannot guess. -->
<div id="toolbar" class="toolbar" role="toolbar" aria-label="Formatierung">
<button type="button" data-command="h2" aria-pressed="false" aria-label="Stunde (Überschrift)" title="Stunde (Überschrift)"><b>H2</b></button>
<button type="button" data-command="h3" aria-pressed="false" aria-label="Zwischenüberschrift" title="Zwischenüberschrift"><b>H3</b></button>
<span class="sep" aria-hidden="true"></span>
<button type="button" data-command="bold" aria-pressed="false" aria-label="Fett" title="Fett (Strg+B)"><b>F</b></button>
<button type="button" data-command="italic" aria-pressed="false" aria-label="Kursiv" title="Kursiv (Strg+I)"><i>K</i></button>
<button type="button" data-command="strike" aria-pressed="false" aria-label="Durchgestrichen" title="Durchgestrichen"><s>S</s></button>
<button type="button" data-command="code" aria-label="Code" title="Code (Strg+E)"><code>&lt;&gt;</code></button>
<span class="sep" aria-hidden="true"></span>
<button type="button" data-command="ul" aria-pressed="false" aria-label="Aufzählung" title="Aufzählung">&nbsp;</button>
<button type="button" data-command="ol" aria-pressed="false" aria-label="Nummerierte Liste" title="Nummerierte Liste">1.&nbsp;</button>
<button type="button" data-command="task" aria-label="Kästchen zum Abhaken" title="Kästchen zum Abhaken"></button>
<button type="button" data-command="quote" aria-pressed="false" aria-label="Zitat" title="Zitat"></button>
<span class="sep" aria-hidden="true"></span>
<button type="button" data-command="link" aria-label="Link" title="Link (Strg+K)">🔗</button>
<button type="button" data-command="table" aria-label="Tabelle" title="Tabelle"></button>
<span class="sep" aria-hidden="true"></span>
<button type="button" data-command="mode" aria-pressed="false" aria-label="Markdown bearbeiten" title="Markdown bearbeiten">MD</button>
</div>
<p id="editor-hint" class="hint" role="status" hidden></p>
<!-- The formatted document, and the same note as Markdown. Exactly one of
the two is visible; both hold the whole note. -->
<div id="editor" class="editor" contenteditable="true" spellcheck="true" autocapitalize="sentences"
role="textbox" aria-multiline="true" aria-label="Notizen des Tages"
data-placeholder="Noch nichts für diesen Tag."></div>
<textarea id="source" class="source" hidden spellcheck="false" autocapitalize="off"
aria-label="Notizen des Tages als Markdown"></textarea>
<div class="actions">
<button type="button" id="save">Speichern</button>
@@ -82,6 +112,6 @@
</main>
</div>
<script src="app.js"></script>
<script type="module" src="app.js"></script>
</body>
</html>

647
src/http/app/markdown.js Normal file
View File

@@ -0,0 +1,647 @@
/*
* Markdown in, formatted text out, and back again.
*
* The notes are Markdown files — that is what the indexer reads, what
* `subjectFromHeading` takes a lesson apart with, and what survives this
* project. The editor shows them as formatted text anyway, so this module is
* the hinge: `markdownToHtml` on the way into the editor, `markdownFromDom` on
* the way back out to the file.
*
* Three properties matter more than completeness, because what passes through
* here is the only record of what was said in a lesson:
*
* - **Round-trip stability.** `fromDom(toHtml(x))` may tidy `x` once — `*a*`
* becomes `_a_`, a ragged table lines up — but doing it again must change
* nothing. `editor.js` checks exactly that before it opens a note in
* formatted mode, and falls back to the Markdown view when it does not hold.
* - **Nothing is dropped.** An element this module does not model keeps its
* words and loses its tag. A note is better off plain than short.
* - **No HTML is trusted.** `markdownToHtml` escapes everything that is not a
* construct it produced itself, so a note containing `<script>` is text, not
* script. Pasted HTML never reaches the document either: it is converted to
* Markdown first and parsed back, which reduces it to the subset below.
*
* The subset is what these notes are made of: headings, paragraphs, bold,
* italic, strikethrough, code (inline and fenced), links, bullet / numbered /
* task lists with nesting, blockquotes, tables and rules. Underline is
* deliberately absent — Markdown has no way to write it, so the toolbar does
* not offer what the file cannot keep.
*/
/**
* Where a code span sat while the emphasis rules ran over the line.
*
* A control character, because it is the one thing a note cannot contain: the
* store strips NUL out of extracted text, and nothing types one.
*/
const PLACEHOLDER = '\u0000';
const PLACEHOLDERS = /\u0000(\d+)\u0000/g;
/** Ordered and bullet items, with their indentation and marker. */
const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
/** How far a continuation line must be indented to belong to the item above. */
const CONTINUATION = 2;
// --- Markdown → HTML -----------------------------------------------------
/**
* A note's body as HTML for the editor.
*
* The output is the only HTML the editor ever starts from, which is what makes
* the serializer's job finite.
*/
export function markdownToHtml(markdown) {
const lines = String(markdown ?? '')
.replace(/\r\n?/g, '\n')
.split('\n')
.map(expandLeadingTabs);
return parseBlocks(lines);
}
/**
* Tabs only in the indentation, and only there.
*
* Indentation is measured in columns to decide what nests inside what, so a
* tab has to become a known number of spaces first. Tabs inside the text are
* left alone — in a code block they are content.
*/
function expandLeadingTabs(line) {
const match = /^[ \t]+/.exec(line);
if (!match) return line;
return match[0].replace(/\t/g, ' ') + line.slice(match[0].length);
}
function parseBlocks(lines) {
const out = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
i++;
continue;
}
const fence = /^ {0,3}(```+|~~~+)\s*([A-Za-z0-9_+#-]*)\s*$/.exec(line);
if (fence) {
const closing = new RegExp('^ {0,3}' + fence[1][0] + '{' + fence[1].length + ',}\\s*$');
const body = [];
i++;
while (i < lines.length && !closing.test(lines[i])) {
body.push(lines[i]);
i++;
}
// An unclosed fence still ends the block; the note is what it is.
i++;
const language = fence[2] ? ' class="language-' + escapeHtml(fence[2]) + '"' : '';
out.push('<pre><code' + language + '>' + escapeHtml(body.join('\n')) + '</code></pre>');
continue;
}
const heading = /^ {0,3}(#{1,6})\s+(.*?)\s*#*$/.exec(line);
if (heading) {
const level = heading[1].length;
out.push('<h' + level + '>' + inlineToHtml(heading[2]) + '</h' + level + '>');
i++;
continue;
}
if (isRule(line)) {
out.push('<hr>');
i++;
continue;
}
if (/^ {0,3}>/.test(line)) {
const body = [];
while (i < lines.length && lines[i].trim()) {
if (/^ {0,3}>/.test(lines[i])) body.push(lines[i].replace(/^ {0,3}> ?/, ''));
// A wrapped line with no `>` still belongs to the quote it follows.
else body.push(lines[i].trim());
i++;
}
out.push('<blockquote>' + parseBlocks(body) + '</blockquote>');
continue;
}
if (startsTable(lines, i)) {
const header = splitRow(lines[i]);
i += 2;
const rows = [];
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
rows.push(splitRow(lines[i]));
i++;
}
out.push(tableToHtml(header, rows));
continue;
}
if (ITEM.test(line)) {
const list = parseList(lines, i);
out.push(list.html);
i = list.next;
continue;
}
// A paragraph, whose single newlines are line breaks rather than
// paragraph breaks. That is how a note reads in a plain editor and how
// Notes.app behaved, and it round-trips exactly — unlike the two
// trailing spaces CommonMark wants, which no one can see.
const paragraph = [];
while (i < lines.length && lines[i].trim() && !startsBlock(lines, i)) {
paragraph.push(lines[i].trim());
i++;
}
out.push('<p>' + paragraph.map(inlineToHtml).join('<br>') + '</p>');
}
return out.join('');
}
/** Everything that interrupts a paragraph. */
function startsBlock(lines, index) {
const line = lines[index];
return (
/^ {0,3}(```+|~~~+)/.test(line) ||
/^ {0,3}#{1,6}\s/.test(line) ||
/^ {0,3}>/.test(line) ||
isRule(line) ||
ITEM.test(line) ||
startsTable(lines, index)
);
}
function isRule(line) {
return /^ {0,3}([-*_])\s*(?:\1\s*){2,}$/.test(line);
}
function startsTable(lines, index) {
if (!lines[index].includes('|')) return false;
const next = lines[index + 1];
return Boolean(next) && /^\s*\|?(\s*:?-{1,}:?\s*\|)+\s*:?-*:?\s*\|?\s*$/.test(next) && next.includes('-');
}
function splitRow(line) {
let value = line.trim();
if (value.startsWith('|')) value = value.slice(1);
if (value.endsWith('|') && !value.endsWith('\\|')) value = value.slice(0, -1);
// Split on pipes that are not escaped, then give the cells their pipes back.
return value.split(/(?<!\\)\|/).map((cell) => cell.trim().replace(/\\\|/g, '|'));
}
function tableToHtml(header, rows) {
const width = Math.max(header.length, ...rows.map((row) => row.length), 1);
const cells = (row, tag) => {
let out = '';
for (let i = 0; i < width; i++) out += '<' + tag + '>' + inlineToHtml(row[i] ?? '') + '</' + tag + '>';
return out;
};
const body = rows.map((row) => '<tr>' + cells(row, 'td') + '</tr>').join('');
return '<table><thead><tr>' + cells(header, 'th') + '</tr></thead><tbody>' + body + '</tbody></table>';
}
/**
* One list, and everything nested inside it.
*
* Continuation is by indentation: a line indented at least two columns past
* the item's own marker belongs to that item, which is what makes nesting and
* multi-paragraph items work without tracking marker widths through the
* recursion. Indentation inside an item is relative, so the nested list parses
* as a list of its own.
*/
function parseList(lines, start) {
const first = ITEM.exec(lines[start]);
const base = first[1].length;
const ordered = /^\d/.test(first[2]);
const startNumber = ordered ? Number.parseInt(first[2], 10) : 1;
const items = [];
let i = start;
while (i < lines.length) {
const match = ITEM.exec(lines[i]);
if (!match) break;
// A shallower item ends this list; a deeper one is swallowed below as
// part of the item above it, so reaching one here means the list is over.
if (match[1].length !== base) break;
if (/^\d/.test(match[2]) !== ordered) break;
const body = [match[3]];
i++;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
// A blank line keeps the item open only if something indented
// follows it; otherwise the list ends here.
const after = lines[i + 1];
if (after && after.trim() && indentOf(after) >= base + CONTINUATION) {
body.push('');
i++;
continue;
}
break;
}
if (indentOf(line) >= base + CONTINUATION) {
body.push(line.slice(base + CONTINUATION));
i++;
continue;
}
if (ITEM.test(line) || startsBlock(lines, i)) break;
// A wrapped line, typed without indentation.
body.push(line.trim());
i++;
}
items.push(body);
}
const tag = ordered ? 'ol' : 'ul';
const open = ordered && startNumber !== 1 ? '<ol start="' + startNumber + '">' : '<' + tag + '>';
return { html: open + items.map(itemToHtml).join('') + '</' + tag + '>', next: i };
}
function itemToHtml(body) {
const task = /^\[([ xX])\]\s+([\s\S]*)$/.exec(body[0] ?? '');
if (task) body = [task[2], ...body.slice(1)];
let inner = parseBlocks(body);
// A tight item: its first paragraph is the item's own text, not a paragraph
// inside it. Unwrapping only the first keeps multi-paragraph items intact.
inner = inner.replace(/^<p>([\s\S]*?)<\/p>/, '$1');
if (!task) return '<li>' + inner + '</li>';
const checked = task[1] !== ' ';
return (
'<li class="task"><input type="checkbox" contenteditable="false"' +
(checked ? ' checked' : '') +
'>' +
inner +
'</li>'
);
}
function indentOf(line) {
return /^[ ]*/.exec(line)[0].length;
}
/**
* Inline Markdown as HTML.
*
* Code spans are taken out first and put back last, so the stars and
* underscores inside `**bold**` written as code stay literal.
*/
function inlineToHtml(text) {
const literals = [];
const park = (html) => {
literals.push(html);
return PLACEHOLDER + (literals.length - 1) + PLACEHOLDER;
};
// Backslash escapes first, or `\*` would still be read as emphasis and a
// backslashed backtick would still open a code span. Parked as literal
// text, they take no further part in anything.
let value = String(text).replace(/\\([\\`*_[\]#>~|+.()-])/g, (all, character) => park(escapeHtml(character)));
value = value.replace(/(`+)([\s\S]*?)\1/g, (all, fence, body) =>
park('<code>' + escapeHtml(body.replace(/^ (.*) $/, '$1')) + '</code>'),
);
value = escapeHtml(value);
// Links before emphasis: a label may contain either, and a URL may contain
// underscores that are not emphasis. One level of balanced parentheses is
// allowed in the target, because real links have them —
// de.wikipedia.org/wiki/Erörterung_(Textsorte).
value = value.replace(/\[([^\]]*)\]\(((?:[^()\s]|\([^()\s]*\))*)\)/g, (all, label, href) => {
const safe = safeUrl(href);
if (!safe) return label;
return '<a href="' + safe + '">' + (label || safe) + '</a>';
});
value = value.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '<strong>$2</strong>');
value = value.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
// A single marker, not part of a double one, and not mid-word for `_` —
// otherwise snake_case_names turn into emphasis.
value = value.replace(/(?<!\*)\*(?!\*)(?=\S)([\s\S]*?\S)\*(?!\*)/g, '<em>$1</em>');
value = value.replace(/(?<![\w_])_(?!_)(?=\S)([\s\S]*?\S)_(?![\w_])/g, '<em>$1</em>');
return value.replace(PLACEHOLDERS, (all, index) => literals[Number(index)]);
}
/**
* A link target, or nothing.
*
* The editor's content comes from the user's own notes, but a note can be
* written by anything — an import, a paste from a web page — so a `javascript:`
* url is refused rather than rendered into a document a finger will tap.
*/
function safeUrl(href) {
const value = href.trim();
if (!value) return '';
if (/^[a-z][a-z0-9+.-]*:/i.test(value) && !/^(https?|mailto|tel):/i.test(value)) return '';
return escapeHtml(value).replace(/"/g, '&quot;');
}
function escapeHtml(value) {
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// --- HTML → Markdown -----------------------------------------------------
/**
* What the editor holds, as the Markdown that will be written to the file.
*
* Deliberately tolerant: browsers put their own tags into a contenteditable
* element (`<div>` for a line, `<span style="font-weight: bold">` after a
* paste, `<font>` on older engines), and none of that may cost a word. An
* element with no meaning here serializes its children.
*
* `root` needs only the read-only parts of the DOM — `nodeType`, `nodeName`,
* `childNodes`, `textContent` and `getAttribute` — so the same function runs
* against a plain tree in the tests.
*/
export function markdownFromDom(root) {
return serializeBlocks(root).replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n').trim();
}
const BLOCK_TAGS = new Set([
'P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
'UL', 'OL', 'BLOCKQUOTE', 'PRE', 'HR', 'TABLE', 'SECTION', 'ARTICLE', 'FIGURE',
]);
function serializeBlocks(node) {
const out = [];
let inline = [];
const flush = () => {
if (inline.length === 0) return;
const text = paragraph(inlineFrom(inline));
if (text) out.push(text);
inline = [];
};
for (const child of children(node)) {
if (child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName)) {
flush();
const block = serializeBlock(child);
if (block) out.push(block);
} else {
inline.push(child);
}
}
flush();
return out.join('\n\n');
}
function serializeBlock(element) {
switch (element.nodeName) {
case 'H1':
case 'H2':
case 'H3':
case 'H4':
case 'H5':
case 'H6': {
const text = inlineFrom(children(element)).replace(/\n+/g, ' ').trim();
if (!text) return '';
return '#'.repeat(Number(element.nodeName[1])) + ' ' + text;
}
case 'HR':
return '---';
case 'PRE': {
const body = element.textContent.replace(/\n$/, '');
const language = languageOf(element);
// A fence longer than any run of backticks inside, or a note about
// Markdown closes its own code block.
const longest = Math.max(2, ...[...body.matchAll(/`+/g)].map((match) => match[0].length));
const fence = '`'.repeat(longest + 1);
return fence + language + '\n' + body + '\n' + fence;
}
case 'BLOCKQUOTE': {
const inner = serializeBlocks(element).trim();
if (!inner) return '';
return inner.split('\n').map((line) => (line ? '> ' + line : '>')).join('\n');
}
case 'UL':
case 'OL':
return serializeList(element);
case 'TABLE':
return serializeTable(element);
case 'DIV':
case 'SECTION':
case 'ARTICLE':
case 'FIGURE':
// A browser's line wrapper, or a real container. Both are handled by
// asking what is inside.
return hasBlockChild(element) ? serializeBlocks(element) : paragraph(inlineFrom(children(element)));
default:
return paragraph(inlineFrom(children(element)));
}
}
function serializeList(list, depth = 0) {
const ordered = list.nodeName === 'OL';
const start = Number.parseInt(list.getAttribute('start') ?? '', 10);
let number = Number.isFinite(start) && start > 0 ? start : 1;
const out = [];
// Two columns per level, matching what the parser takes back apart.
const indent = ' '.repeat(CONTINUATION);
const shift = (block) => block.split('\n').map((line) => (line ? indent + line : '')).join('\n');
for (const item of children(list)) {
if (item.nodeType !== 1) continue;
if (item.nodeName === 'UL' || item.nodeName === 'OL') {
// A list as a *sibling* of the items rather than inside one. Several
// engines produce this when Tab indents a bullet, and skipping it
// would silently drop everything the person nested.
const nested = shift(serializeList(item, depth + 1));
if (out.length > 0) out[out.length - 1] += '\n' + nested;
else out.push(nested);
continue;
}
if (item.nodeName !== 'LI') continue;
const checkbox = firstCheckbox(item);
const marker = ordered ? number++ + '.' : '-';
const box = checkbox ? (checkbox.getAttribute('checked') === null ? '[ ] ' : '[x] ') : '';
// The item's own text, then whatever blocks hang under it.
const leading = [];
const blocks = [];
for (const child of children(item)) {
if (child === checkbox) continue;
if (child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName)) blocks.push(child);
else if (blocks.length === 0) leading.push(child);
// Inline content after a nested list is rare and reads as part of it.
else blocks.push(child);
}
// An item whose text the browser wrapped in a div or a p: that is the
// item's own line, not a block underneath it.
if (leading.length === 0 && blocks.length > 0 && (blocks[0].nodeName === 'DIV' || blocks[0].nodeName === 'P')) {
if (!hasBlockChild(blocks[0])) leading.push(...children(blocks.shift()));
}
const head = paragraph(inlineFrom(leading));
const rest = blocks
.map((child) =>
child.nodeType === 1 && (child.nodeName === 'UL' || child.nodeName === 'OL')
? serializeList(child, depth + 1)
: serializeBlock(child),
)
.filter(Boolean);
const first = marker + ' ' + box + head.split('\n').join('\n' + indent);
out.push([first, ...rest.map(shift)].join('\n'));
}
return out.join('\n');
}
function serializeTable(table) {
const rows = [];
const walk = (node) => {
for (const child of children(node)) {
if (child.nodeType !== 1) continue;
if (child.nodeName === 'TR') rows.push(child);
else walk(child);
}
};
walk(table);
if (rows.length === 0) return '';
const cells = rows.map((row) =>
children(row)
.filter((cell) => cell.nodeType === 1 && (cell.nodeName === 'TD' || cell.nodeName === 'TH'))
.map((cell) => inlineFrom(children(cell)).replace(/\n+/g, ' ').replace(/\|/g, '\\|').trim()),
);
const width = Math.max(...cells.map((row) => row.length));
const line = (row) => '| ' + Array.from({ length: width }, (_, i) => row[i] ?? '').join(' | ') + ' |';
// A header row is required by the syntax: a table whose first row is data
// would otherwise lose that row entirely.
return [line(cells[0]), '|' + ' --- |'.repeat(width), ...cells.slice(1).map(line)].join('\n');
}
function inlineFrom(nodes) {
return nodes.map(inlineNode).join('');
}
function inlineNode(node) {
if (node.nodeType === 3) return escapeText(node.textContent);
if (node.nodeType !== 1) return '';
switch (node.nodeName) {
case 'BR':
return '\n';
case 'IMG':
// No note here has an image; one arriving by paste says so rather
// than vanishing.
return node.getAttribute('alt') ? '[' + escapeText(node.getAttribute('alt')) + ']' : '';
case 'INPUT':
// Only ever a task checkbox, and `serializeList` has already read it.
return '';
case 'CODE': {
const body = node.textContent;
if (!body) return '';
const longest = Math.max(0, ...[...body.matchAll(/`+/g)].map((match) => match[0].length));
const fence = '`'.repeat(longest + 1);
const pad = body.startsWith('`') || body.endsWith('`') ? ' ' : '';
return fence + pad + body + pad + fence;
}
case 'A': {
const label = inlineFrom(children(node));
const href = (node.getAttribute('href') ?? '').trim();
if (!href) return label;
if (!label.trim()) return href;
return '[' + label + '](' + href + ')';
}
case 'STRONG':
case 'B':
return emphasise(inlineFrom(children(node)), '**');
case 'EM':
case 'I':
return emphasise(inlineFrom(children(node)), '_');
case 'DEL':
case 'S':
case 'STRIKE':
return emphasise(inlineFrom(children(node)), '~~');
case 'SPAN':
case 'FONT': {
// What a paste leaves behind. The tag says nothing; the style might.
const style = node.getAttribute('style') ?? '';
const inner = inlineFrom(children(node));
if (/font-weight:\s*(bold|[6-9]00)/i.test(style)) return emphasise(inner, '**');
if (/font-style:\s*italic/i.test(style)) return emphasise(inner, '_');
return inner;
}
default:
return inlineFrom(children(node));
}
}
/** Markers hug their text: `** bold **` is four literal stars, not emphasis. */
function emphasise(inner, marker) {
const parts = /^(\s*)([\s\S]*?)(\s*)$/.exec(inner);
if (!parts[2]) return inner;
// Already carrying the same marker (nested `<b><b>`, or a paste): once is enough.
if (parts[2].startsWith(marker) && parts[2].endsWith(marker)) return inner;
return parts[1] + marker + parts[2] + marker + parts[3];
}
/**
* A run of inline content as one paragraph.
*
* Line starts are escaped here rather than in `escapeText`, because whether a
* `-` opens a list depends on where in the line it sits.
*/
function paragraph(text) {
return text
.split('\n')
.map((line) =>
line
.replace(/^(\s*)([#>]|[-*+](?=\s))/, '$1\\$2')
// The backslash goes before the dot, never before the digit: a
// backslash in front of anything but punctuation is a literal
// backslash, and `\1.` would be written into the file as it looks.
.replace(/^(\s*\d{1,9})([.)](?=\s))/, '$1\\$2'),
)
.join('\n')
.replace(/^\n+|\n+$/g, '');
}
function escapeText(value) {
return String(value)
.replace(/\\/g, '\\\\')
.replace(/([`*[\]])/g, '\\$1')
// Only where it could be read as emphasis: `snake_case` stays readable.
.replace(/(^|[^\w_])_/g, '$1\\_')
.replace(/_($|[^\w_])/g, '\\_$1')
.replace(/~~/g, '\\~\\~')
// A lone `<` only matters when it could open a tag.
.replace(/<(?=[a-zA-Z/!])/g, '\\<');
}
function languageOf(pre) {
for (const child of children(pre)) {
if (child.nodeType === 1 && child.nodeName === 'CODE') {
const match = /language-([A-Za-z0-9_+#-]+)/.exec(child.getAttribute('class') ?? '');
if (match) return match[1];
}
}
return '';
}
function firstCheckbox(item) {
for (const child of children(item)) {
if (child.nodeType === 1 && child.nodeName === 'INPUT' && child.getAttribute('type') === 'checkbox') return child;
}
return undefined;
}
function hasBlockChild(element) {
return children(element).some((child) => child.nodeType === 1 && BLOCK_TAGS.has(child.nodeName));
}
function children(node) {
return Array.prototype.slice.call(node.childNodes ?? []);
}

222
test/app-markdown.test.ts Normal file
View File

@@ -0,0 +1,222 @@
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');
});

112
test/mini-dom.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* 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, '&');
}