Compare commits
3 Commits
feat/notes
...
feat/forma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c259d97f04 | ||
|
|
534b1b0f58 | ||
|
|
5c0b658855 |
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
|
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.
|
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:
|
Store tests need a database and skip without one:
|
||||||
`TEST_DATABASE_URL=postgresql://… npm test`. They use a real Postgres on
|
`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
|
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`
|
**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
|
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
|
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
|
- **`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
|
credential from everything else here: a password a person types, not a token a
|
||||||
program was configured with. scrypt at startup, a signed `HttpOnly` /
|
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,
|
indexed as its own lesson, so a search answers "my own note, Deutsch,
|
||||||
18.09.2026" rather than "Friday".
|
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
|
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
|
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.
|
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`. |
|
| **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. |
|
| **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
|
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 |
|
| `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 |
|
| 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 |
|
| `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` |
|
| 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 |
|
| `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 |
|
| `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
|
`add_note` — the save is refused and you are asked which version wins. It
|
||||||
never silently overwrites.
|
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
|
**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
|
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
|
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));
|
check('the shell holds no secret of its own', !shellText.includes(WEB_PASSWORD) && !shellText.includes(TOKEN));
|
||||||
|
|
||||||
const assets = await Promise.all(
|
// Every file the shell asks for, including the two modules the editor is
|
||||||
['app.js', 'app.css', 'icon.svg', 'manifest.webmanifest'].map((name) => fetch(`${root}/app/${name}`)),
|
// 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 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();
|
const anonymousSession = await (await fetch(`${root}/app/session`)).json();
|
||||||
check('session says "not logged in" rather than failing', anonymousSession.authenticated === false);
|
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
|
* 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
|
* embedding them would be a build step that no longer copies them, and the
|
||||||
* only thing lost would be every tool that reads them.
|
* 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. */
|
/** 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' },
|
'/index.html': { file: 'index.html', type: 'text/html; charset=utf-8' },
|
||||||
'/app.css': { file: 'app.css', type: 'text/css; 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' },
|
'/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' },
|
'/icon.svg': { file: 'icon.svg', type: 'image/svg+xml' },
|
||||||
'/manifest.webmanifest': { file: 'manifest.webmanifest', type: 'application/manifest+json' },
|
'/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
|
// 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.
|
// 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(
|
||||||
const entry = ASSETS[req.path] ?? ASSETS['/']!;
|
/^\/(index\.html|app\.css|app\.js|editor\.js|markdown\.js|icon\.svg|manifest\.webmanifest)?$/,
|
||||||
res.type(entry.type).send(asset(entry.file));
|
(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) => {
|
router.post('/login', express.json({ limit: '4kb' }), (req: Request, res: Response) => {
|
||||||
const password = (req.body as { password?: unknown } | undefined)?.password;
|
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 strong { font-size: 1.05rem; }
|
||||||
.daybar-centre input { border: 0; background: none; color: var(--muted); font: inherit; font-size: 0.85rem; }
|
.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 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 {
|
textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 12rem;
|
min-height: 12rem;
|
||||||
@@ -104,8 +225,8 @@ textarea {
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
/* Monospace: the notes are Markdown, and headings and list markers have to
|
/* Monospace: this is the Markdown view, and headings and list markers have
|
||||||
line up to be read back as structure. */
|
to line up to be read back as structure. */
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
'use strict';
|
import { createEditor } from './editor.js';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The notes app.
|
* The notes app.
|
||||||
@@ -20,6 +20,10 @@
|
|||||||
* - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert",
|
* - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert",
|
||||||
* "Offline — lokal gesichert". A silent editor over a flaky connection is
|
* "Offline — lokal gesichert". A silent editor over a flaky connection is
|
||||||
* indistinguishable from one that is losing your work.
|
* 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;
|
const AUTOSAVE_MS = 2500;
|
||||||
@@ -41,7 +45,10 @@ const ui = {
|
|||||||
dayDate: document.getElementById('day-date'),
|
dayDate: document.getElementById('day-date'),
|
||||||
dayStatus: document.getElementById('day-status'),
|
dayStatus: document.getElementById('day-status'),
|
||||||
conflict: document.getElementById('day-conflict'),
|
conflict: document.getElementById('day-conflict'),
|
||||||
|
toolbar: document.getElementById('toolbar'),
|
||||||
editor: document.getElementById('editor'),
|
editor: document.getElementById('editor'),
|
||||||
|
source: document.getElementById('source'),
|
||||||
|
editorHint: document.getElementById('editor-hint'),
|
||||||
save: document.getElementById('save'),
|
save: document.getElementById('save'),
|
||||||
fill: document.getElementById('fill'),
|
fill: document.getElementById('fill'),
|
||||||
lessonsHint: document.getElementById('lessons-hint'),
|
lessonsHint: document.getElementById('lessons-hint'),
|
||||||
@@ -68,6 +75,28 @@ const day = {
|
|||||||
timer: 0,
|
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 ------------------------------------------------------------
|
// --- plumbing ------------------------------------------------------------
|
||||||
|
|
||||||
async function api(path, options) {
|
async function api(path, options) {
|
||||||
@@ -129,9 +158,10 @@ function draftKey(date) {
|
|||||||
return DRAFT_PREFIX + date;
|
return DRAFT_PREFIX + date;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDraft() {
|
function saveDraft(text) {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
// A full or disabled localStorage must not break typing; the server copy
|
// A full or disabled localStorage must not break typing; the server copy
|
||||||
// is still the real one.
|
// is still the real one.
|
||||||
@@ -172,7 +202,8 @@ async function loadDay(date) {
|
|||||||
day.conflicted = false;
|
day.conflicted = false;
|
||||||
ui.conflict.hidden = true;
|
ui.conflict.hidden = true;
|
||||||
ui.dayDate.value = date;
|
ui.dayDate.value = date;
|
||||||
ui.editor.disabled = true;
|
editor.setEnabled(false);
|
||||||
|
hint('');
|
||||||
setStatus('Wird geladen …');
|
setStatus('Wird geladen …');
|
||||||
|
|
||||||
let info;
|
let info;
|
||||||
@@ -185,8 +216,8 @@ async function loadDay(date) {
|
|||||||
// no notes at all — and reporting that as "offline" would send someone
|
// no notes at all — and reporting that as "offline" would send someone
|
||||||
// looking at their signal instead of at NOTES_DIR.
|
// looking at their signal instead of at NOTES_DIR.
|
||||||
if (error.status) {
|
if (error.status) {
|
||||||
ui.editor.value = '';
|
editor.setMarkdown('');
|
||||||
ui.editor.disabled = true;
|
editor.setEnabled(false);
|
||||||
setStatus(error.message, 'error');
|
setStatus(error.message, 'error');
|
||||||
ui.lessonsHint.textContent = '';
|
ui.lessonsHint.textContent = '';
|
||||||
return;
|
return;
|
||||||
@@ -194,8 +225,8 @@ async function loadDay(date) {
|
|||||||
// No status: the request never arrived. Fall back to whatever this device
|
// 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.
|
// has, rather than an empty editor that looks like a day with no notes.
|
||||||
const draft = readDraft(date);
|
const draft = readDraft(date);
|
||||||
ui.editor.disabled = false;
|
editor.setEnabled(true);
|
||||||
ui.editor.value = draft ? draft.text : '';
|
editor.setMarkdown(draft ? draft.text : '');
|
||||||
day.saved = '';
|
day.saved = '';
|
||||||
day.modifiedAt = null;
|
day.modifiedAt = null;
|
||||||
day.dirty = Boolean(draft);
|
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.
|
// is just the last save echoed back and offering it would be noise.
|
||||||
const useDraft = draft && draft.text !== server && draft.text.trim() !== '';
|
const useDraft = draft && draft.text !== server && draft.text.trim() !== '';
|
||||||
|
|
||||||
ui.editor.value = useDraft ? draft.text : server;
|
editor.setEnabled(true);
|
||||||
ui.editor.disabled = false;
|
// The server's text first, and what the editor makes of it is the baseline.
|
||||||
day.saved = info.exists ? info.text : '';
|
// Opening a note the editor would tidy — a table typed unevenly, `*` for
|
||||||
day.dirty = ui.editor.value !== day.saved;
|
// 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) {
|
if (useDraft) {
|
||||||
setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn');
|
setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn');
|
||||||
@@ -250,8 +296,9 @@ function describeLessons(info) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markDirty() {
|
function markDirty() {
|
||||||
day.dirty = ui.editor.value !== day.saved;
|
const text = editor.getMarkdown();
|
||||||
saveDraft();
|
day.dirty = text !== day.saved;
|
||||||
|
saveDraft(text);
|
||||||
if (day.conflicted) return;
|
if (day.conflicted) return;
|
||||||
if (day.dirty) setStatus('Nicht gespeichert …');
|
if (day.dirty) setStatus('Nicht gespeichert …');
|
||||||
window.clearTimeout(day.timer);
|
window.clearTimeout(day.timer);
|
||||||
@@ -261,7 +308,7 @@ function markDirty() {
|
|||||||
async function saveDay(automatic) {
|
async function saveDay(automatic) {
|
||||||
window.clearTimeout(day.timer);
|
window.clearTimeout(day.timer);
|
||||||
if (!day.dirty && automatic) return;
|
if (!day.dirty && automatic) return;
|
||||||
const text = ui.editor.value;
|
const text = editor.getMarkdown();
|
||||||
setStatus('Wird gespeichert …');
|
setStatus('Wird gespeichert …');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -424,17 +471,14 @@ ui.dayDate.addEventListener('change', () => {
|
|||||||
if (ui.dayDate.value) void loadDay(ui.dayDate.value);
|
if (ui.dayDate.value) void loadDay(ui.dayDate.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.editor.addEventListener('input', markDirty);
|
|
||||||
ui.save.addEventListener('click', () => void saveDay(false));
|
ui.save.addEventListener('click', () => void saveDay(false));
|
||||||
|
|
||||||
ui.fill.addEventListener('click', () => {
|
ui.fill.addEventListener('click', () => {
|
||||||
// Appended, never merged into place: the person's own text is not something
|
// 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.
|
// to reorder, and a heading in the wrong order is trivial to move.
|
||||||
const separator = ui.editor.value.trim() ? '\n\n' : '';
|
editor.append(day.missing);
|
||||||
ui.editor.value = ui.editor.value.replace(/\s*$/, '') + separator + day.missing;
|
|
||||||
day.missing = '';
|
day.missing = '';
|
||||||
ui.fill.hidden = true;
|
ui.fill.hidden = true;
|
||||||
markDirty();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// A phone locking, the app going to the background, or the tab closing: all of
|
// 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-status" class="status" role="status" aria-live="polite"></p>
|
||||||
<p id="day-conflict" class="conflict" role="alert" hidden></p>
|
<p id="day-conflict" class="conflict" role="alert" hidden></p>
|
||||||
|
|
||||||
<textarea id="editor" spellcheck="true" autocapitalize="sentences"
|
<!-- The toolbar writes the Markdown so nobody has to type it. Every button
|
||||||
placeholder="Noch nichts für diesen Tag." aria-label="Notizen des Tages"></textarea>
|
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">
|
<div class="actions">
|
||||||
<button type="button" id="save">Speichern</button>
|
<button type="button" id="save">Speichern</button>
|
||||||
@@ -82,6 +112,6 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script type="module" src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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