/** * 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, '>'); /** Inline marks, innermost first so a bold word inside italics survives both. */ function inlineToHtml(text) { return escapeHtml(text) .replace(/<u>(.+?)<\/u>/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/~~(.+?)~~/g, '$1') .replace(/(^|[^*])\*([^*]+)\*/g, '$1$2'); } /** * 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 list = null; let fence = null; // sproget, mens vi er inde i en ```-blok const closeList = () => { if (list) { out.push(``); list = null; } }; for (const line of lines) { // Hegnede blokke først: alt indeni er tekst, ikke Markdown, og en linje der // begynder med - inde i et shell-script er ikke et punkt i en liste. const fenced = /^```(\w*)\s*$/.exec(line); if (fence !== null) { if (fenced) { out.push(''); fence = null; } else { out.push(escapeHtml(line) + '\n'); } continue; } if (fenced) { closeList(); fence = fenced[1] ?? ''; out.push(`
`);
			continue;
		}

		const bullet = /^[-*+]\s+(.*)$/.exec(line);
		const numbered = /^\d+\.\s+(.*)$/.exec(line);

		if (bullet || numbered) {
			const want = bullet ? 'ul' : 'ol';
			if (list !== want) {
				closeList();
				out.push(`<${want}>`);
				list = want;
			}
			out.push(`
  • ${inlineToHtml((bullet ?? numbered)[1])}
  • `); continue; } closeList(); 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))}`); 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 ''; const inner = [...node.childNodes].map(inlineToMarkdown).join(''); switch (node.tagName) { 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. */ export function htmlToMarkdown(root) { const lines = []; const walk = (node) => { for (const child of node.childNodes) { if (child.nodeType === Node.TEXT_NODE) { if (child.textContent.trim()) lines.push(child.textContent); continue; } if (child.nodeType !== Node.ELEMENT_NODE) continue; switch (child.tagName) { case 'UL': case 'OL': { let n = 1; for (const li of child.children) { const text = inlineToMarkdown(li).trim(); lines.push(child.tagName === 'UL' ? `- ${text}` : `${n++}. ${text}`); } 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 ([...child.children].some((c) => /^(P|H[1-3]|UL|OL|PRE|BLOCKQUOTE|DIV)$/.test(c.tagName))) { walk(child); } else { lines.push(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') ?? ''; lines.push('```' + lang); for (const l of (child.textContent ?? '').replace(/\n$/, '').split('\n')) { lines.push(l); } lines.push('```'); break; } default: { const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase()); const text = inlineToMarkdown(child); lines.push(block ? block.prefix + text : text); } } } }; walk(root); // 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'); }