Write notes as formatted text, store them as Markdown

The editor was a textarea holding raw Markdown, which is the wrong thing
to hand someone taking notes during a lesson: nobody types `##` and `**`
while a teacher is talking. It now shows the note formatted and puts a
toolbar above it — headings, bold, lists, tick boxes, quotes, links,
tables — while the file on disk stays exactly what it was, because that
is what the indexer reads and what outlives this app.

`markdown.js` is the whole translation: `markdownToHtml` on the way in,
`markdownFromDom` on the way out. The property that matters is that the
round trip settles — one pass may tidy a note, a second must change
nothing — because these notes are the only record of what was said in
the room and there is nothing to restore a lossy save from. `editor.js`
checks exactly that before opening a note formatted, and a note it
cannot hold unchanged opens in the Markdown view and says so instead of
being quietly reduced.

No editor library: the content security policy allows no outside script
and the app has no bundler, so this is `contenteditable` and
`execCommand` with a tolerant serializer behind it — an element it does
not model keeps its words and loses its tag. Pasted HTML is converted to
Markdown before it reaches the document, which is the one place where
sanitising and formatting are the same operation.

Tested against `test/mini-dom.ts`, sixty lines of read-only DOM, rather
than a headless browser or a DOM dependency; the toolbar itself was
driven by hand in Firefox. WebKit has still never run it.
This commit is contained in:
MechaCat02
2026-09-19 21:24:42 +02:00
parent 5c0b658855
commit 534b1b0f58
13 changed files with 1690 additions and 33 deletions

View File

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

View File

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

View File

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

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

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

View File

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

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

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