/* ===================================================================== Inline markup — a deliberately small syntax, chosen over contenteditable because it round-trips exactly and cannot carry pasted styling. `code` → [[Ctrl]] → **strong** → *emphasis* → [text](url) → {{part:id}} → "Part 5", resolved from the live numbering parse() and serialise() are inverses; the round-trip test relies on it. ===================================================================== */ const ESC = { '&': '&', '<': '<', '>': '>' }; const escape = (s) => s.replace(/[&<>]/g, (c) => ESC[c]); /** markup → HTML. `refs` maps a part id to its number. */ export function parse(text, refs = new Map()) { const slots = []; const stash = (html) => `${slots.push(html) - 1}`; let s = text; // code first: nothing inside a code span is markup s = s.replace(/`([^`]+)`/g, (_, body) => stash(`${escape(body)}`)); s = s.replace(/\[\[([^\]]+)\]\]/g, (_, body) => stash(`${escape(body)}`)); s = s.replace(/\{\{part:([A-Za-z0-9_-]+)\}\}/g, (_, id) => { const n = refs.get(id); return stash(n ? `Part ${n}` : '?'); }); s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, href) => stash(`${escape(label)}`)); s = escape(s); s = s.replace(/\*\*([^*]+)\*\*/g, '$1'); s = s.replace(/(^|[^*])\*([^*]+)\*/g, '$1$2'); s = s.replace(/\n/g, '
'); // inverse of the sentinel in serialise() return s.replace(/(\d+)/g, (_, i) => slots[Number(i)]); } const UNESC = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" }; const unescape = (s) => s.replace(/&(amp|lt|gt|quot|#39);/g, (m) => UNESC[m]); /** HTML → markup, for importing an existing sheet. */ export function serialise(html) { let s = html.replace(/ | /g, '\u00A0'); s = s.replace(/(.*?)<\/code>/gs, (_, b) => '`' + unescape(b) + '`'); s = s.replace(/(.*?)<\/kbd>/gs, (_, b) => '[[' + unescape(b) + ']]'); s = s.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gs, (_, h, b) => `[${unescape(b)}](${h})`); s = s.replace(/(.*?)<\/strong>/gs, '**$1**'); s = s.replace(/(.*?)<\/em>/gs, '*$1*'); // a sentinel, so the whitespace collapse below cannot swallow the break s = s.replace(//g, '\u0000'); s = s.replace(/<[^>]+>/g, ''); // anything left is decoration // collapse ordinary whitespace but never a non-breaking space, which is // meaningful typography the author put there on purpose return unescape(s) .replace(/[^\S\u00A0]+/g, ' ') .replace(/ ?\u0000 ?/g, '\n') .trim(); } /** Part ids referenced by a document, so the UI can flag broken links. */ export function refsUsed(text) { return [...text.matchAll(/\{\{part:([A-Za-z0-9_-]+)\}\}/g)].map((m) => m[1]); }