/**
* Between what you see and what is stored.
*
* The editor is rich text; the note on disk is Markdown. That is not a compromise
* anybody has to live with silently — it is the reason the export is trustworthy:
* the file a note becomes is the file it already was. So these two functions are
* the seam, and they have to agree with each other exactly, or a note changes
* shape a little every time it is opened and saved.
*
* The set of styles is deliberately small and matches what Apple Notes offers,
* because that is the vocabulary somebody coming from there already has:
*
* Titel → #
* Overskrift → ##
* Underrubrik → ###
* Brødtekst → nothing
* Monotype → four spaces
* Punktliste → -
* Nummereret → 1.
* Blokcitat → >
*
* Bold, italic and strikethrough have Markdown of their own. Underline does not —
* there is no such thing in any Markdown dialect — so it is stored as inline HTML,
* which every Markdown reader passes through untouched. It is the one place where
* the file is not pure Markdown, and it is that rather than dropping a style the
* source program has.
*/
const BLOCKS = [
{ style: 'title', tag: 'h1', prefix: '# ' },
{ style: 'heading', tag: 'h2', prefix: '## ' },
{ style: 'subheading', tag: 'h3', prefix: '### ' },
{ style: 'quote', tag: 'blockquote', prefix: '> ' },
{ style: 'mono', tag: 'pre', prefix: ' ' }
];
const escapeHtml = (s) =>
s.replace(/&/g, '&').replace(//g, '>');
/**
* En værdi, der skal stå inde i en attribut.
*
* escapeHtml er skrevet til tekst mellem to tags, hvor et anførselstegn ikke
* betyder noget, og lader dem derfor stå. Inde i en attribut lukker de den — og
* så står resten af strengen som markup. Det er den ene forskel, og den er hele
* forskellen mellem tekst og et onerror.
*/
const attr = (s) => escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''');
/**
* Billeder løftes ud af teksten, før noget andet rører den.
*
* De stod før først i kæden, og så kørte de fire resterende regler hen over en
* streng, der nu indeholdt rigtig HTML — ind i den `alt`, der lige var lavet.
* Det gik godt, så længe ingen af erstatningerne indeholdt et anførselstegn, og
* det er en betingelse, der stod skrevet ingen steder: den dag nogen tilføjer en
* regel for links, er den brudt, og det er præcis den vej, XSS-en kom ind ad.
*
* Værre endnu var undslupningen. `escapeHtml` kørte på hele teksten, og `attr`
* kørte den igen på `alt` — så et og-tegn blev til en dobbelt undslupning, der
* voksede ved hver eneste åbning og gemning. En note med "Kaffe & te" i et
* billede blev længere, hver gang nogen så på den.
*
* En pladsholder løser begge dele. Billedet tages ud af den rå tekst, resten
* undslippes og formateres som altid, og billedet lægges tilbage til sidst med en
* `alt`, der er undsluppet nøjagtig én gang. Rækkefølgen af de øvrige regler kan
* så ikke længere betyde noget.
*
* Nul-tegnet som mærke, fordi det er det ene tegn, en note ikke kan indeholde:
* det overlever hverken en contenteditable eller en Markdown-fil.
*/
const IMAGE = /!\[([^\]]*)\]\((\/api\/v1\/attachments\/[0-9a-f-]+)\)/g;
// [[a note by title]], the spelling every note app settled on. Non-greedy, so two
// links on one line stay two.
const NOTELINK = /\[\[([^\]\n]+?)\]\]/g;
const MARK = "\u0000";
/** Inline marks, innermost first so a bold word inside italics survives both. */
function inlineToHtml(text) {
// Lifted out before anything escapes or marks the text, and dropped back in
// whole at the end — the same placeholder trick the image already used, now for
// two kinds of thing the surrounding markup must not touch.
const pieces = [];
const lift = (html) => {
pieces.push(html);
return `${MARK}${pieces.length - 1}${MARK}`;
};
// Kun vores egen adresse bliver til et billede. Et `` fra en
// indsat tekst ville ellers hente fra en fremmed vært, når noten åbnes, og
// fortælle den, at den er blevet læst — og en note er det sidste sted, man vil
// have en sporingspixel. Alt andet står som den tekst, det er.
let lifted = (text ?? '').replace(IMAGE, (_, alt, src) => lift(``));
// A [[note]] is a link you can follow, not the brackets it was typed as. Made an
// atomic token — contenteditable="false" — so it moves and deletes as one thing,
// and it carries the title it points at in data-note; the editor reads that on a
// click. htmlToMarkdown turns it back into [[title]] exactly.
lifted = lifted.replace(
NOTELINK,
(_, title) =>
lift(
`${escapeHtml(title)}`
)
);
const marked = escapeHtml(lifted)
.replace(/<u>(.+?)<\/u>/g, '$1')
.replace(/`([^`]+)`/g, '
$1')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/~~(.+?)~~/g, '$1')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1$2');
return marked.replace(new RegExp(MARK + '(\\d+)' + MARK, 'g'), (_, i) => pieces[Number(i)] ?? '');
}
/**
* markdownToHtml renders a note for the editor.
*
* Every line becomes exactly one block. Markdown would normally join consecutive
* lines into one paragraph; here it must not, because the person typed a line and
* expects to get it back — an editor that silently reflows what somebody wrote is
* an editor they stop trusting.
*/
export function markdownToHtml(markdown) {
const lines = (markdown ?? '').split('\n');
const out = [];
let fence = null; // sproget, mens vi er inde i en ```-blok
// Listeniveauerne, der står åbne lige nu — ét mærke pr. dybde.
//
// En stak frem for én `list`-variabel, fordi en punktliste kan ligge inde i en
// anden. Den flade udgave kunne kun beskrive ét niveau, så et indrykket punkt
// blev lagt ved siden af sit ophav og kom tilbage som søskende: indrykningen
// var væk efter én gemning.
//
// Underlisten lægges *inde i* sit `
`);
continue;
}
// Indrykning bærer niveauet: to mellemrum, eller en tabulator, er ét trin.
const item = /^([ \t]*)(?:([-*+])|(\d+)\.)\s+(.*)$/.exec(line);
if (item) {
const indent = item[1].replace(/\t/g, ' ').length;
const depth = Math.floor(indent / 2);
const want = item[2] ? 'ul' : 'ol';
// Dybere end vi står: luk ned til niveauet. Én ad gangen, så hvert
// `` lander inde i det ``, det hører til.
closeList(depth + 1);
// Samme dybde, men den anden slags liste — en punktliste efterfulgt af en
// nummereret er to lister, ikke én med blandede mærker.
if (stack.length === depth + 1 && stack[depth] !== want) {
closeList(depth);
}
while (stack.length < depth + 1) {
// Nummeret tælles ikke om. Skrev nogen 10., 11., er det dét, der står i
// filen, og en liste, der begynder ved ti, skal begynde ved ti — ellers
// gør en gemning uden ændringer noten om.
const start = item[3] && item[3] !== '1' && stack.length === depth ? ` start="${Number(item[3])}"` : '';
out.push(`<${want}${want === 'ol' ? start : ''}>`);
stack.push(want);
liOpen = false;
}
closeItem();
out.push(` ${inlineToHtml(item[4])}`);
liOpen = true;
continue;
}
closeList();
// Et blokcitat, der rummer mere end én linje, læses som ét citat med
// indholdet indeni — samme vej tilbage som htmlToMarkdown skrev det. Uden
// det blev en liste i et citat til seks citater med et bindestreg i hver.
if (/^>(?: |$)/.test(line)) {
const inner = [];
while (i < lines.length && /^>(?: |$)/.test(lines[i])) {
inner.push(lines[i].replace(/^> ?/, ''));
i++;
}
i--;
out.push(`${markdownToHtml(inner.join('\n'))}
`);
continue;
}
const block = BLOCKS.find((b) =>
b.prefix === ' ' ? line.startsWith(' ') : line.startsWith(b.prefix)
);
if (block) {
out.push(`<${block.tag}>${inlineToHtml(line.slice(block.prefix.length))}${block.tag}>`);
continue;
}
// An empty line is still a line. Without the break the browser collapses the
// block to nothing and the caret has nowhere to stand.
out.push(`${line.trim() === '' ? '
' : inlineToHtml(line)}
`);
}
closeList();
if (fence !== null) out.push(' ');
return out.join('');
}
/** The inline marks, read back off the DOM. */
function inlineToMarkdown(node) {
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
if (node.nodeType !== Node.ELEMENT_NODE) return '';
// A note link goes back to the brackets it came from, by the title it carries
// rather than by the text it shows — the two are the same today, but the title
// is the thing that must survive the round trip.
if (node.tagName === 'A' && node.classList?.contains('notelink')) {
return `[[${node.getAttribute('data-note') ?? node.textContent}]]`;
}
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
switch (node.tagName) {
// Et billede har ingen tekst i sig, så uden det her læses det som ingenting
// — og den første gemning ville tage hvert billede ud af noten uden at
// nogen havde rørt det.
case 'IMG':
return ` ?? ''})`;
case 'STRONG':
case 'B':
return inner ? `**${inner}**` : '';
case 'EM':
case 'I':
return inner ? `*${inner}*` : '';
case 'S':
case 'STRIKE':
case 'DEL':
return inner ? `~~${inner}~~` : '';
case 'U':
return inner ? `${inner}` : '';
case 'CODE':
return inner ? `\`${inner}\`` : '';
case 'SPAN': {
// The browser's own way of saying bold or italic when it is writing styles
// rather than tags. Read as well as tags, because `styleWithCSS` is not
// honoured everywhere and a note must not lose a format on the way out.
const style = node.getAttribute('style') ?? '';
if (/font-weight:\s*(bold|[6-9]00)/.test(style)) return inner ? `**${inner}**` : '';
if (/font-style:\s*italic/.test(style)) return inner ? `*${inner}*` : '';
if (/text-decoration[^;]*line-through/.test(style)) return inner ? `~~${inner}~~` : '';
if (/text-decoration[^;]*underline/.test(style)) return inner ? `${inner}` : '';
return inner;
}
case 'BR':
return '';
default:
return inner;
}
}
/**
* htmlToMarkdown reads the editor back.
*
* Written against the DOM rather than against a string of HTML, because that is
* what contenteditable actually produces — and what it produces is not tidy. The
* browser will nest, split and re-wrap as somebody types, and a regular expression
* over that is a guess.
*/
/** The tags that are a block of their own, and must never be read as inline text. */
const BLOCK_TAGS = /^(P|H[1-6]|UL|OL|LI|PRE|BLOCKQUOTE|DIV)$/;
/** Whether an element holds blocks, in which case reading it as one line loses them. */
const holdsBlocks = (el) => [...el.children].some((c) => BLOCK_TAGS.test(c.tagName));
/**
* Teksten i et blok, med linjeskiftene i behold.
*
* `textContent` er ikke nok, og det er ikke en detalje: et for at kunne sidde i
* hjørnet af den, og ellers stod ordet "Kopiér" i hver eneste kodeblok efter hver
* gemning.
*
* Rekursivt, fordi indholdet ligger i et inde i blokken: en filtrering over
* 'ens egne børn ser kun det ene element og spørger det om en textContent,
* der allerede har tabt linjeskiftet.
*/
export function blockText(node) {
let out = '';
for (const n of node.childNodes) {
if (n.nodeType !== Node.ELEMENT_NODE) {
out += n.textContent ?? '';
continue;
}
if (n.classList?.contains('copy')) continue;
if (n.tagName === 'BR') {
out += '\n';
continue;
}
out += blockText(n);
}
return out;
}
export function htmlToMarkdown(root) {
const lines = [];
/**
* @param node hvad der læses
* @param emit hvor linjerne lægges. Et blokcitat giver sin egen, som sætter
* `> ` foran alt, hvad der kommer indefra — sådan kan et citat
* indeholde afsnit og lister uden at de bliver til én linje.
*/
const walk = (node, emit) => {
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
if (child.textContent.trim()) emit(child.textContent);
continue;
}
if (child.nodeType !== Node.ELEMENT_NODE) continue;
switch (child.tagName) {
case 'UL':
case 'OL': {
list(child, 0, emit);
break;
}
// Et citat kan rumme blokke. Læst som én streng blev "En", "To" til
// "EnTo", og en liste inde i et citat mistede hvert eneste punktmærke
// — det var sådan en note kom tilbage som én lang linje efter en
// gemning, uden at nogen havde rørt teksten.
case 'BLOCKQUOTE': {
if (holdsBlocks(child)) {
walk(child, (l) => emit(l.trim() === '' ? '>' : `> ${l}`));
} else {
emit('> ' + inlineToMarkdown(child));
}
break;
}
case 'DIV':
// The browser's own container when nothing else applies. Read through
// it rather than treating it as a block, or every paste adds a level.
if (holdsBlocks(child)) {
walk(child, emit);
} else {
emit(inlineToMarkdown(child));
}
break;
case 'PRE': {
// Back out as a fence, with the language it came in with. The old
// four-space form is still read on the way in; it is not written
// out any more, because a fence survives being pasted somewhere
// else and an indent does not.
const lang = child.getAttribute('data-lang') ?? '';
emit('```' + lang);
const code = blockText(child);
for (const l of code.replace(/\n$/, '').split('\n')) {
emit(l);
}
emit('```');
break;
}
default: {
const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase());
// En overskrift med en liste i sig er stadig en liste. Samme fælde som
// citatet: læst som én linje forsvandt hvert punktmærke.
if (holdsBlocks(child)) {
walk(child, emit);
break;
}
const text = inlineToMarkdown(child);
emit(block ? block.prefix + text : text);
}
}
}
};
/**
* Én liste, og de lister der ligger i den.
*
* `start` læses med, så en liste, der begynder ved ti, stadig gør det. Talt om
* fra ét gjorde en gemning uden ændringer noten om — den slags er værre end en
* fejl, fordi den sker uden at nogen har rørt noget.
*/
const list = (el, depth, emit) => {
let n = Number(el.getAttribute('start') || 1);
if (!Number.isFinite(n) || n < 1) n = 1;
const pad = ' '.repeat(depth);
for (const li of el.children) {
// En underliste, der ligger som SØSKENDE til punktet frem for inde i det.
// Browserne er uenige om, hvilken af de to former en indrykning laver, og
// de to ser ens ud på skærmen — men den her løkke sprang søskendeformen
// over uden lyd, så et indrykket punkt ville forsvinde ved gemningen.
if (li.tagName === 'UL' || li.tagName === 'OL') {
list(li, depth + 1, emit);
continue;
}
if (li.tagName !== 'LI') continue;
// Punktets egen tekst er alt undtagen de lister, der ligger under det.
const nested = [...li.children].filter((c) => c.tagName === 'UL' || c.tagName === 'OL');
let text;
if (nested.length) {
const clone = li.cloneNode(true);
for (const sub of [...clone.children]) {
if (sub.tagName === 'UL' || sub.tagName === 'OL') sub.remove();
}
text = inlineToMarkdown(clone).trim();
} else {
text = inlineToMarkdown(li).trim();
}
emit(el.tagName === 'UL' ? `${pad}- ${text}` : `${pad}${n++}. ${text}`);
for (const sub of nested) list(sub, depth + 1, emit);
}
};
walk(root, (l) => lines.push(l));
// Trailing blank lines are what an editor accumulates from people pressing
// return at the end; they are not content and they grow every time.
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
return lines.join('\n');
}