Merge: write the notes as formatted text, not Markdown syntax
The notes app showed a textarea of raw Markdown, which is the wrong thing to hand someone taking notes during a lesson. It now shows the note formatted with a toolbar above it, while the file on disk stays Markdown — that is what the indexer reads and what outlives this app. The property the whole thing rests on is that the round trip settles: one pass may tidy a note, a second must change nothing. A note the editor cannot hold unchanged opens in the Markdown view and says so rather than being quietly reduced. Includes the table dead end found on the first real use: a table at the end of a contenteditable element cannot be typed past, in any engine.
This commit is contained in:
20
CLAUDE.md
20
CLAUDE.md
@@ -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` /
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
519
src/http/app/editor.js
Normal file
519
src/http/app/editor.js
Normal file
@@ -0,0 +1,519 @@
|
||||
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);
|
||||
ensureTrailingParagraph();
|
||||
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);
|
||||
ensureTrailingParagraph();
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A line after the last block, so there is somewhere to go.
|
||||
*
|
||||
* A table, a code block or a rule at the very end 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 — the note simply cannot be continued. Every engine
|
||||
* behaves this way, and every editor works around it the same way. The
|
||||
* paragraph is empty, so it serializes to nothing and never reaches the file.
|
||||
*/
|
||||
const TRAILING_TRAP = /^(TABLE|PRE|HR|BLOCKQUOTE|UL|OL)$/;
|
||||
|
||||
function ensureTrailingParagraph() {
|
||||
const last = rich.lastElementChild;
|
||||
if (!last || !TRAILING_TRAP.test(last.nodeName)) return;
|
||||
const paragraph = document.createElement('p');
|
||||
paragraph.appendChild(document.createElement('br'));
|
||||
rich.appendChild(paragraph);
|
||||
}
|
||||
|
||||
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> ');
|
||||
}
|
||||
|
||||
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> ');
|
||||
}
|
||||
|
||||
/**
|
||||
* A table, or one more row of the table already under the cursor.
|
||||
*
|
||||
* Two jobs on one button because a phone has no Tab key, and adding a row is
|
||||
* what anyone wants far more often than a second table inside the first.
|
||||
* `reflect` renames the button so it says which one it will do.
|
||||
*/
|
||||
function insertTable() {
|
||||
const table = tableAt();
|
||||
if (table) {
|
||||
addRow(table);
|
||||
return;
|
||||
}
|
||||
const head = '<tr><th><br></th><th><br></th></tr>';
|
||||
const row = '<tr><td><br></td><td><br></td></tr>';
|
||||
document.execCommand(
|
||||
'insertHTML',
|
||||
false,
|
||||
'<table><thead>' + head + '</thead><tbody>' + row + row + '</tbody></table><p><br></p>',
|
||||
);
|
||||
}
|
||||
|
||||
function cellAt() {
|
||||
let node = selectionNode();
|
||||
while (node && node !== rich) {
|
||||
if (node.nodeType === 1 && (node.nodeName === 'TD' || node.nodeName === 'TH')) return node;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function tableAt() {
|
||||
let node = selectionNode();
|
||||
while (node && node !== rich) {
|
||||
if (node.nodeType === 1 && node.nodeName === 'TABLE') return node;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** One more row, as wide as the table, with the caret in its first cell. */
|
||||
function addRow(table) {
|
||||
const rows = table.querySelectorAll('tr');
|
||||
const width = Math.max(1, ...Array.from(rows, (row) => row.children.length));
|
||||
const body = table.querySelector('tbody') ?? table;
|
||||
const row = document.createElement('tr');
|
||||
for (let i = 0; i < width; i++) {
|
||||
const cell = document.createElement('td');
|
||||
// An empty cell with nothing in it cannot be clicked into in Gecko;
|
||||
// the break gives the caret somewhere to stand.
|
||||
cell.appendChild(document.createElement('br'));
|
||||
row.appendChild(cell);
|
||||
}
|
||||
body.appendChild(row);
|
||||
placeCaret(row.firstElementChild);
|
||||
}
|
||||
|
||||
function placeCaret(node) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
range.collapse(true);
|
||||
const selection = document.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
// --- input -----------------------------------------------------------
|
||||
|
||||
function notify() {
|
||||
ensureTrailingParagraph();
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Tab walks the cells, and a Tab out of the last one adds a row. This is
|
||||
// what every table anywhere does, and without it the only way to add a
|
||||
// row on a keyboard would be the toolbar.
|
||||
if (event.key === 'Tab' && !modifier) {
|
||||
const cell = cellAt();
|
||||
if (cell) {
|
||||
event.preventDefault();
|
||||
const cells = Array.from(cell.closest('table').querySelectorAll('th, td'));
|
||||
const next = cells[cells.indexOf(cell) + (event.shiftKey ? -1 : 1)];
|
||||
if (next) placeCaret(next);
|
||||
else if (!event.shiftKey) addRow(cell.closest('table'));
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// The way out of anything: a new paragraph after the block the cursor is
|
||||
// in, however deep in a table or a quote it sits.
|
||||
if (event.key === 'Enter' && modifier) {
|
||||
event.preventDefault();
|
||||
let block = selectionNode();
|
||||
while (block && block.parentNode !== rich) block = block.parentNode;
|
||||
const paragraph = document.createElement('p');
|
||||
paragraph.appendChild(document.createElement('br'));
|
||||
if (block) block.after(paragraph);
|
||||
else rich.appendChild(paragraph);
|
||||
placeCaret(paragraph);
|
||||
notify();
|
||||
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)));
|
||||
}
|
||||
|
||||
const table = toolbar.querySelector('[data-command="table"]');
|
||||
if (table) {
|
||||
const inside = Boolean(tableAt());
|
||||
const label = inside ? 'Zeile anfügen' : 'Tabelle';
|
||||
table.setAttribute('aria-label', label);
|
||||
table.title = inside ? label + ' (oder Tab in der letzten Zelle)' : label;
|
||||
}
|
||||
}
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
@@ -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><></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">• —</button>
|
||||
<button type="button" data-command="ol" aria-pressed="false" aria-label="Nummerierte Liste" title="Nummerierte Liste">1. —</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>
|
||||
|
||||
652
src/http/app/markdown.js
Normal file
652
src/http/app/markdown.js
Normal file
@@ -0,0 +1,652 @@
|
||||
/*
|
||||
* 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++) {
|
||||
// A break in an empty cell: a `<td></td>` with nothing in it cannot be
|
||||
// clicked into, so a blank cell would be uneditable. It serializes
|
||||
// back to an empty cell.
|
||||
out += '<' + tag + '>' + (inlineToHtml(row[i] ?? '') || '<br>') + '</' + 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, '"');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// --- 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 ?? []);
|
||||
}
|
||||
242
test/app-markdown.test.ts
Normal file
242
test/app-markdown.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
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| | |');
|
||||
});
|
||||
112
test/mini-dom.ts
Normal file
112
test/mini-dom.ts
Normal 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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
Reference in New Issue
Block a user