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:
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