diff --git a/packages/webui/src/public/bus.css b/packages/webui/src/public/bus.css index b55cea526..6b2b00ec9 100644 --- a/packages/webui/src/public/bus.css +++ b/packages/webui/src/public/bus.css @@ -42,3 +42,12 @@ table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;col /* Human message bodies keep their line breaks and wrap long literal text. */ #message-list .s1-message { white-space: pre-wrap; overflow-wrap: anywhere; } + +/* Row 148: the spec and docs editor. The source and the preview sit side by side when there is room. */ +.doc-edit{display:grid;gap:16px;grid-template-columns:repeat(auto-fit,minmax(min(100%,26rem),1fr));align-items:start} +.doc-text{display:block;width:100%;box-sizing:border-box;min-height:24rem;resize:vertical;font-family:var(--mono);font-size:13px;line-height:1.5;color:var(--text);background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:10px;tab-size:4} +.doc-text[readonly]{background:var(--canvas)} +.md-preview{overflow-wrap:anywhere}.md-preview h1,.md-preview h2,.md-preview h3,.md-preview h4,.md-preview h5,.md-preview h6{margin:14px 0 6px} +.md-preview pre{overflow:auto;padding:10px;background:var(--raised);border:1px solid var(--line);border-radius:var(--r)} +.md-preview blockquote{margin:8px 0;padding:2px 12px;border-left:3px solid var(--border);color:var(--muted)} +.md-preview .md-target,.md-preview .md-image{color:var(--muted);font-family:var(--mono);font-size:12px} diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 592e8d205..abf786a8c 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -73,6 +73,7 @@ used.push(r.at); return r; }; + get.note = at => { if (at) used.push(at); }; // a view drawn from its own state names when that state was read get.oldest = () => used.filter(Boolean).sort()[0] || null; return get; } @@ -238,7 +239,7 @@ // ---------------------------------------------------------------- views let inboxCount = null, messageCount = null; function sections(route) { - const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `${inboxCount.open}${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `${messageCount}`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/settings', 'Settings', '', 'settings']]; + const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `${inboxCount.open}${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `${messageCount}`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']]; $('sections').innerHTML = items.map(([h, l, extra, icon]) => { const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message'))); return `
  • ${l}${extra ? ` ${extra}` : ''}
  • `; @@ -629,6 +630,97 @@ ${trailList(rows, filter)}`; } + // Row 148 (#1642, ruling sage-0507): the spec and docs editor. A textarea edits the Markdown source and + // a save writes exactly what is in it. The preview is markdown-preview.js: elements and text nodes, + // never markup, so nothing in a file runs. A draft lives in `docStates`, not the DOM, so a redraw, a + // failed save or a visit to another page loses nothing; only Reload discards it, and a file with no + // unsaved change is read again whenever it is opened. Each save names the commit the editor opened; + // a newer commit refuses it (`stale-base`) and nothing is written. An unknown outcome is never retried + // by the page. A textarea reads CRLF as LF, so the draft and the save are LF. + let canEdit = false, docKey = null; + const docStates = new Map(); + const dirty = s => s.text !== s.original || s.saving; + const lf = v => String(v).replace(/\r\n?/g, '\n'); + const DOC_PATH = /^(spec\/north-star\.md|docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md)$/; + async function docsView(get) { + const projects = (await get.api('business')).business?.projects, names = Array.isArray(projects) ? projects.filter(p => typeof p === 'string' && SAFE.test(p)) : []; + const blocks = []; + for (const p of names) { + let files = [], code = null; + try { files = (await get.api(`project-files?project=${enc(p)}`)).files; } + catch (err) { if (err.code === 'signed-out' || refusal(err)) throw err; code = err.code; } + files = Array.isArray(files) ? files.filter(f => typeof f?.path === 'string' && DOC_PATH.test(f.path)) : []; + blocks.push(`

    ${txt(p)} ${files.length}

    ${code ? `

    Not read: ${txt(code)}.

    ` : files.length ? `` : '

    No spec or docs files.

    '}
    `); + } + return `${head('Docs', `${readLine()} The spec and the files under docs/ of each project. ${canEdit ? 'Open one to edit it; a save makes one commit in the project with you as the author.' : 'Saving needs a Console started with --send behind the sign-in proxy; this one only reads.'}`)} + ${blocks.join('') || empty('This business declares no projects.', 'A project shows here when the business file names it.')}`; + } + async function docView(get, project, file) { + if (!SAFE.test(project)) return notFound(`${txt(project)} is not a project name.`); + if (!DOC_PATH.test(file)) return notFound(`${txt(file)} is not a spec or docs file.`); + docKey = JSON.stringify([project, file]); + for (const [k, v] of docStates) if (k !== docKey && !dirty(v)) docStates.delete(k); + let s = docStates.get(docKey); + if (!s || !dirty(s)) { + const r = await get.api(`project-file?project=${enc(project)}&path=${enc(file)}`), now = docStates.get(docKey); + // Typing during the read wins over what was read. + s = now && dirty(now) ? now : { project, path: file, commit: r.commit, raw: r.content, original: lf(r.content), text: lf(r.content), status: '', saving: false, at: r.at }; + docStates.set(docKey, s); + } + get.note(s.at); + const unsafe = s.raw.includes('\0'); // a textarea can't hold it, so a save would not write what was read + const stateNote = unsafe ? 'This file holds a NUL character, which the editor cannot keep. Edit it where it lives.' + : `${s.raw.includes('\r') ? 'This file uses CRLF line endings; a save writes LF. ' : ''}${canEdit ? '' : 'Saving needs a Console started with --send behind the sign-in proxy; this one only reads.'}`; + return `${head(`Edit ${txt(s.path)}`, `Project ${txt(s.project)}, opened at commit ${esc(s.commit.slice(0, 12))}. ${readLine()}`)} +

    All documents

    +

    Markdown source

    +
    + +

    ${stateNote}

    + +

    ${txt(s.status)}

    +

    Preview

    Headings, lists, quotes, code and links are drawn. Raw HTML and anything else shows as text. A link shows where it goes; an image shows its address and is never loaded.

    `; + } + // The parts of the editor that follow the draft, set in place so typing never redraws the page. + function paintDoc() { + const s = docStates.get(docKey), box = $('doc-preview'); + if (!s || !box) return; + // A preview that throws must not skip the lines below, or leave a save stuck on "Saving…". + try { + if (window.MosaicMarkdown) box.replaceChildren(window.MosaicMarkdown.render(s.text)); + else box.textContent = 'The preview did not load. The source is unchanged.'; + } catch { box.textContent = 'The preview failed. The source is unchanged.'; } + const save = $('doc-save'), status = $('doc-status'), commit = $('doc-commit'); + if (save) save.disabled = !saveable(s); + if (status) status.textContent = s.saving ? 'Saving…' : s.status; + if (commit) commit.textContent = s.commit.slice(0, 12); + } + const saveable = s => canEdit && !out() && !s.saving && s.text !== s.original && !s.raw.includes('\0'); + document.addEventListener('input', e => { const s = docStates.get(docKey); if (e.target.id !== 'doc-text' || !s) return; s.text = e.target.value; s.status = ''; paintDoc(); }); + document.addEventListener('click', e => { if (e.target.id !== 'doc-reload') return; const s = docStates.get(docKey); if (s?.saving) return; docStates.delete(docKey); render(true); }); + document.addEventListener('submit', async e => { + if (e.target.id !== 'doc-form') return; + e.preventDefault(); + const s = docStates.get(docKey); + if (!s || !saveable(s)) return; + const sent = { project: s.project, path: s.path, base: s.commit, content: s.text }, unknown = 'Outcome unknown: the save may have gone through. Copy your text, then reload the file to check before saving again. Draft kept.'; + s.saving = true; s.status = ''; + let res = null, body = null; + try { + paintDoc(); + try { res = await fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' }); } catch { /* the outcome is unknown */ } + if (res && res.type !== 'opaqueredirect') { try { body = await res.json(); } catch { /* unknown */ } } + if (res?.type === 'opaqueredirect') s.status = 'Sign in again. Draft kept.'; + else if (res?.ok && /^[0-9a-f]{40}$/.test(body?.commit ?? '') && body.path === s.path) { + s.commit = body.commit; s.original = sent.content; s.status = `Saved as commit ${body.commit.slice(0, 12)}.`; + } else if (body?.sent === false) s.status = `${typeof body.message === 'string' && body.message ? body.message : 'Not saved.'} Draft kept.`; + else s.status = unknown; + } finally { s.saving = false; } // a throw above must not leave Save off and Reload refused + paintDoc(); + announce(s.status); + if (res?.type === 'opaqueredirect') signOut(); + }); + // Not found and states function notFound(why) { return `${head('Not found')}
    ${why || 'No page at this address.'}

    Nothing is at ${txt(location.hash || '#/')}. Go to the Board or press Ctrl K to search.

    `; } const loadingView = (source = null) => `${head('Reading…')}
    ${source ? `Reading the ${esc(source.toLowerCase())}.` : 'Reading from the bus.'}
    `; @@ -675,7 +767,7 @@ $('fresh-bus').innerHTML = state === 'refused' ? `${esc(source)} not read: refused` : state === 'failed' ? `${esc(source)} not read: the read failed` : `${esc(source)} read ${esc(stamp(readAt))}${readAt ? ` (${esc(ago(readAt))})` : ''}${state === 'stale' ? ' · stale: the last read failed' : ''}`; } - const SOURCES = { queue: 'Queue', business: 'Business', settings: 'Settings' }; + const SOURCES = { queue: 'Queue', business: 'Business', settings: 'Settings', docs: 'Docs' }; // ---------------------------------------------------------------- router // #/ and anything that is not a #/ route is the control board; the rest is this file's. @@ -694,6 +786,8 @@ if (a === 'queue' && n === 2) return /^[1-9]\d{0,5}$/.test(b) ? queueRowView(get, b) : notFound(`${txt(b)} is not a queue row id.`); if (a === 'business' && n === 1) return businessView(get); if (a === 'settings' && n === 1) return settingsView(get); + if (a === 'docs' && n === 1) return docsView(get); + if (a === 'docs' && n === 3) return docView(get, b, c); if (a === 'trail' && n === 3 && b === 'task') return TASK.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a task reference.`); if (a === 'trail' && n === 3 && b === 'decision') return SAFE.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a decision id.`); if (a === 'trail' && n === 3 && b === 'message') return SAFE.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a message id.`); @@ -701,7 +795,8 @@ } let gen = 0, shown = null, timer; const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true'; - function schedule() { clearTimeout(timer); if (!paused() && !out()) timer = setTimeout(() => render(false), 10000); } + // A file open in the editor is never refreshed from under its draft; Reload is the person's to press. + function schedule() { clearTimeout(timer); if (!paused() && !out() && !routeOf().startsWith('/docs/')) timer = setTimeout(() => render(false), 10000); } // Keep focus, open
    and the copy status across a refresh of the same page. // In the send form, the focused field and its caret too (its values come from `draft`). function keep() { @@ -770,6 +865,7 @@ freshBus(state, SOURCES[path.split('/')[1]]); const k = !navigated && shown === route ? keep() : null; $('bus-view').innerHTML = html; shown = route; + if (path.startsWith('/docs/')) paintDoc(); sections(path); const h1 = $('bus-view').querySelector('h1'); document.title = `${h1 ? h1.textContent : 'Console'} · Mosaic Console`; @@ -823,7 +919,7 @@ // Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and // tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus // back where it was. It only navigates. - const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/settings', 'Settings']]; + const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/docs', 'Docs'], ['#/settings', 'Settings']]; let pkAll = [], pkShown = [], pkAt = 0, pkBack = null; function pkItems() { const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` })); @@ -917,10 +1013,11 @@ // Whether this server takes a send here. The form is drawn on the next Inbox render; the Board's // Message buttons are drawn from the roles already read. fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => { + canEdit = c?.edit === true; // row 148: implies send if (c?.send !== true) return; canSend = true; const path = routeOf().split('?')[0]; - if (path.startsWith('/inbox')||path==='/settings') render(false); else if (path === '/' || path === '') paintHeld(heldErr); + if (path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr); }).catch(() => {}); window.addEventListener('hashchange', () => render(true)); $('refresh').addEventListener('click', () => render(false)); diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index f5cd674ff..81a1ffb68 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -2,7 +2,7 @@ Mosaic Console - +
    MosaicConsole diff --git a/packages/webui/src/public/markdown-preview.js b/packages/webui/src/public/markdown-preview.js new file mode 100644 index 000000000..e444ea56d --- /dev/null +++ b/packages/webui/src/public/markdown-preview.js @@ -0,0 +1,338 @@ +// Row 148 (#1642, ruling sage-0507): the Markdown preview for the spec and docs editor. +// Every node is made with createElement and every string reaches the page as text (textContent or a +// text node), so nothing a file says becomes markup and there is no sanitizer to get wrong. The test +// in tests/markdown-preview.test.mjs reads this file and fails if it names a string-to-markup sink. +// +// Rendered: headings, paragraphs, lists, block quotes, code blocks, inline code, emphasis and links. +// Everything else, raw HTML, tables, thematic breaks and the rest, shows as its source text. +// A link is made only for an http:, https: or relative target and is followed by the target in +// text; any other target leaves the whole `[text](target)` as source text. An image shows its alt +// text and URL and is never loaded. This is not a CommonMark engine: where it differs it keeps text. +(() => { + const MAX_DEPTH = 12; // block nesting and inline nesting beyond this stay source text + const SPAN = 2000; // the farthest one inline opener looks ahead for its closer + // SPAN alone does not bound a render: n unclosed openers each look SPAN ahead, so n x SPAN. Every + // scan that fails charges the characters it looked at to `work`; once `work` reaches `budget` (32 per + // character of the text, at least 1 Mi) each later opener is refused its scan and stays source text. + // A scan that succeeds consumes what it looked at, so the position it moves to pays for it. + const BUDGET_PER_CHAR = 32, BUDGET_FLOOR = 1 << 20; + let work = 0, budget = 0; + const PUNCT = /[!-/:-@[-`{-~]/; + // The `s` flag on FENCE, ATX and ITEM: without it `.` stops at U+2028 and U+2029, which the split on + // "\n" leaves inside a line, `$` then fails, and the engine retries `(.*)` from every offset of a long + // run in front of it, which is quadratic in the run. With `s` the first try matches to the end. + const FENCE = /^( {0,3})(`{3,}|~{3,})(.*)$/s; + const ATX = /^ {0,3}(#{1,6})(?:[ \t]+(.*))?$/s; + const SETEXT = /^ {0,3}(=+|-+)[ \t]*$/; + // A thematic break is up to 3 spaces, one of - * _, then only that mark, spaces and tabs, with at least + // 3 marks. A walk, not a regex: `(?:[ \t]*\1){2,}` recurses once per mark, and V8 throws RangeError + // ("Maximum call stack size exceeded") on one line of about 2 MiB of marks. + const isRule = line => { + let i = 0, marks = 0; + while (i < 3 && line[i] === ' ') i++; + const mark = line[i]; + if (mark !== '-' && mark !== '*' && mark !== '_') return false; + for (; i < line.length; i++) { + if (line[i] === mark) marks++; + else if (!blank(line[i])) return false; + } + return marks >= 3; + }; + const QUOTE = /^ {0,3}> ?/; + const ITEM = /^( {0,3})([-+*]|\d{1,9}[.)])([ \t]+|$)(.*)$/s; + + const make = (doc, tag, text) => { + const node = doc.createElement(tag); + if (text !== undefined) node.textContent = text; + return node; + }; + const isOrdered = marker => marker >= '0' && marker <= '9'; + const blank = c => c === ' ' || c === '\t'; + // Trailing blanks, and a closing run of hashes after a blank, cut by a walk from the end. A regex + // for either retries from every blank of a long blank run, which is quadratic in the run. + const trimBlanksEnd = s => { let e = s.length; while (e > 0 && blank(s[e - 1])) e--; return s.slice(0, e); }; + const stripClosingHashes = s => { + let e = s.length; + while (e > 0 && s[e - 1] === '#') e--; + if (e === s.length) return s; + return e === 0 || blank(s[e - 1]) ? trimBlanksEnd(s.slice(0, e)) : s; + }; + + // The one gate for a link: the same function decides the href and whether a link is made at all. + // No whitespace, control, backslash or bidirectional-format character (a browser would read or + // hide it), no scheme other than http or https, and no "//" start that would name another host. + function linkTarget(raw) { + const target = String(raw); + if (!target || target.length > SPAN || /[\u0000-\u0020\u007f-\u009f\\\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/.test(target)) return null; + if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(target)) return /^https?:\/\/[^/?#]/i.test(target) ? target : null; + return target.startsWith('//') ? null : target; + } + + // ------------------------------------------------------------------ inline + // The first index at or after `from` where a run of exactly `size` copies of `ch` starts, within + // SPAN characters; used for code spans. + function findRun(text, from, ch, size) { + if (work >= budget) return -1; + const end = Math.min(text.length, from + SPAN); + let j = from; + while (j < end) { + if (text[j] !== ch) { j++; continue; } + let run = 1; + while (text[j + run] === ch) run++; + if (run === size) return j; + j += run; + } + work += j - from; + return -1; + } + // The first index of `ch` from `from` to `last` inclusive, or -1. Unlike indexOf on the whole text, + // one search looks at no more than `last - from + 1` characters, not the rest of the text. The + // caller charges a miss; the render's total is capped by `budget`, not by this bound. + const findWithin = (text, ch, from, last) => { + const found = text.slice(from, last + 1).indexOf(ch); + return found < 0 ? -1 : from + found; + }; + // `[label](destination "title")` at `at`, or null. Brackets and parentheses balance; an escape + // hides one character; each step looks at most SPAN characters past where the one before ended. + // A miss charges the characters it looked at, up to `reached`. + function linkAt(text, at) { + if (work >= budget) return null; + const miss = reached => { work += reached - at + 1; return null; }; + const limit = Math.min(text.length, at + SPAN); + let depth = 0, j = at; + for (; j < limit; j++) { + const c = text[j]; + if (c === '\\') { j++; continue; } + if (c === '[') depth++; + else if (c === ']' && --depth === 0) break; + } + if (j >= limit || text[j + 1] !== '(') return miss(j); + const label = text.slice(at + 1, j); + let k = j + 2; + const space = () => { while (k < limit + SPAN && /[ \t\n]/.test(text[k] ?? '')) k++; }; + space(); + let dest = ''; + if (text[k] === '<') { + const close = findWithin(text, '>', k, k + SPAN); + if (close < 0) return miss(k + SPAN); + if (/[\n<]/.test(text.slice(k + 1, close))) return miss(close); + dest = text.slice(k + 1, close); k = close + 1; + } else { + const from = k; + let parens = 0; + for (; k < text.length && k - from <= SPAN; k++) { + const c = text[k]; + if (c === '\\' && PUNCT.test(text[k + 1] ?? '')) { k++; continue; } + if (c === '(') parens++; + else if (c === ')') { if (parens === 0) break; parens--; } + else if (/\s/.test(c)) break; + } + dest = text.slice(from, k); + } + space(); + const open = text[k]; + if (open === '"' || open === "'" || open === '(') { + const close = findWithin(text, open === '(' ? ')' : open, k + 1, k + SPAN); + if (close < 0) return miss(k + SPAN); + k = close + 1; space(); + } + if (text[k] !== ')') return miss(k); + return { label, dest: dest.replace(/\\([!-/:-@[-`{-~])/g, '$1'), end: k + 1 }; + } + // The closing run of an emphasis opener: its start and length, or null. A closer follows + // a non-space and (for "_") is not inside a word; a run of the opener's own size, or three, closes. + function closerOf(text, from, ch, use) { + if (work >= budget) return null; + const end = Math.min(text.length, from + SPAN); + let j = from; + while (j < end) { + const c = text[j]; + if (c === '\\') { j += 2; continue; } + if (c === '`') { + let run = 1; + while (text[j + run] === '`') run++; + const close = findRun(text, j + run, '`', run); + j = close < 0 ? j + run : close + run; + continue; + } + if (c !== ch) { j++; continue; } + let run = 1; + while (text[j + run] === ch) run++; + const prev = text[j - 1] ?? ' ', next = text[j + run] ?? ' '; + if (j > from && !/\s/.test(prev) && (run === use || run === 3) && (ch === '*' || !/[A-Za-z0-9]/.test(next))) return { start: j, run }; + j += run; + } + work += j - from; + return null; + } + function inline(text, parent, doc, depth, inLink) { + let pending = ''; + const flush = () => { if (pending) { parent.appendChild(doc.createTextNode(pending)); pending = ''; } }; + const n = text.length; + let i = 0; + while (i < n) { + const c = text[i]; + if (c === '\\' && i + 1 < n && PUNCT.test(text[i + 1])) { pending += text[i + 1]; i += 2; continue; } + if (c === '`') { + let run = 1; + while (text[i + run] === '`') run++; + const close = findRun(text, i + run, '`', run); + if (close < 0) { pending += '`'.repeat(run); i += run; continue; } + let code = text.slice(i + run, close).replace(/\n/g, ' '); + if (code.length > 2 && code.startsWith(' ') && code.endsWith(' ') && code.trim()) code = code.slice(1, -1); + flush(); parent.appendChild(make(doc, 'code', code)); + i = close + run; continue; + } + if (c === '!' && text[i + 1] === '[') { + const image = depth < MAX_DEPTH ? linkAt(text, i + 1) : null; + if (image) { flush(); parent.appendChild(make(doc, 'span', `[image: ${image.label}] ${image.dest}`)); parent.lastChild.className = 'md-image'; i = image.end; continue; } + } + if (c === '[' && !inLink && depth < MAX_DEPTH) { + const link = linkAt(text, i), target = link && linkTarget(link.dest); + if (link && target) { + flush(); + const a = make(doc, 'a'); + a.setAttribute('href', target); + a.setAttribute('rel', 'noopener noreferrer'); + a.setAttribute('target', '_blank'); + inline(link.label, a, doc, depth + 1, true); + parent.appendChild(a); + parent.appendChild(doc.createTextNode(' ')); + const shown = make(doc, 'span', `(${target})`); + shown.className = 'md-target'; + parent.appendChild(shown); + i = link.end; continue; + } + if (link) { pending += text.slice(i, link.end); i = link.end; continue; } // any other target: source text + } + if ((c === '*' || c === '_') && depth < MAX_DEPTH) { + let run = 1; + while (text[i + run] === c) run++; + const before = text[i - 1], after = text[i + run]; + const opens = after !== undefined && !/\s/.test(after) && (c === '*' || !(before && /[A-Za-z0-9]/.test(before))); + const use = Math.min(run, 2), close = opens ? closerOf(text, i + run, c, use) : null; + if (close) { + flush(); + const node = make(doc, use === 2 ? 'strong' : 'em'); + inline(text.slice(i + use, close.start + close.run - use), node, doc, depth + 1, inLink); + parent.appendChild(node); + i = close.start + close.run; continue; + } + pending += c.repeat(run); i += run; continue; + } + pending += c; i++; + } + flush(); + } + + // ------------------------------------------------------------------ blocks + const fenceOf = line => { + const m = FENCE.exec(line); + return m && !(m[2][0] === '`' && m[3].includes('`')) ? { ch: m[2][0], size: m[2].length, indent: m[1].length } : null; + }; + const closesFence = (line, fence) => { + const t = line.trimStart(), run = /^(`+|~+)[ \t]*$/.exec(t); + return line.length - t.length <= 3 && run && run[1][0] === fence.ch && run[1].length >= fence.size; + }; + // A list marker line. When it would interrupt a paragraph it needs content, and an ordered one must start at 1. + const itemAt = (line, interrupt) => { + const m = ITEM.exec(line); + if (!m) return null; + if (interrupt && (!m[4].trim() || (isOrdered(m[2][0]) && parseInt(m[2], 10) !== 1))) return null; + return m; + }; + function parse(lines, depth, out, doc, tight) { + const deep = depth >= MAX_DEPTH, n = lines.length; + // Past the depth limit a quote or list marker is just text. + const starts = line => fenceOf(line) || ATX.test(line) || (!deep && (QUOTE.test(line) || (!isRule(line) && itemAt(line, true)))); + let i = 0; + while (i < n) { + const line = lines[i]; + if (!line.trim()) { i++; continue; } + const fence = fenceOf(line); + if (fence) { + const body = []; + for (i++; i < n && !closesFence(lines[i], fence); i++) body.push(lines[i].slice(Math.min(fence.indent, lines[i].length - lines[i].trimStart().length))); + i++; // the closing fence; a fence still open at the end runs to the end + const pre = make(doc, 'pre'); + pre.appendChild(make(doc, 'code', body.join('\n'))); + out.appendChild(pre); continue; + } + const atx = ATX.exec(line); + if (atx) { + const heading = make(doc, 'h' + atx[1].length); + inline(stripClosingHashes((atx[2] ?? '').trim()).trim(), heading, doc, 0, false); + out.appendChild(heading); i++; continue; + } + if (!deep && QUOTE.test(line)) { + const inner = []; + while (i < n) { + const l = lines[i]; + if (QUOTE.test(l)) inner.push(l.replace(QUOTE, '')); + else if (l.trim() && inner.length && inner.at(-1).trim() && !starts(l)) inner.push(l); // a lazy continuation line + else break; + i++; + } + const quote = make(doc, 'blockquote'); + parse(inner, depth + 1, quote, doc, false); + out.appendChild(quote); continue; + } + const first = !deep && !isRule(line) && itemAt(line, false); + if (first) { + const ordered = isOrdered(first[2][0]), sameList = l => { + const m = !isRule(l) && itemAt(l, false); + return m && ordered === isOrdered(m[2][0]) && m[2].slice(-1) === first[2].slice(-1); + }; + const list = make(doc, ordered ? 'ol' : 'ul'), items = []; + if (ordered && parseInt(first[2], 10) !== 1) list.setAttribute('start', String(parseInt(first[2], 10))); + let loose = false; + while (i < n && sameList(lines[i])) { + const m = itemAt(lines[i], false), empty = !m[4].trim(); + const spaces = empty || m[3].length > 4 ? 1 : m[3].length, offset = m[1].length + m[2].length + spaces; + const body = [empty ? '' : m[3].slice(spaces) + m[4]]; + let blanks = 0; + for (i++; i < n; i++) { + const l = lines[i]; + if (!l.trim()) { blanks++; body.push(''); continue; } + if (l.length - l.trimStart().length >= offset) { if (blanks) loose = true; blanks = 0; body.push(l.slice(offset)); continue; } + if (!blanks && !starts(l) && !(!isRule(l) && itemAt(l, false))) { body.push(l.trimStart()); continue; } // a lazy continuation line + break; + } + while (body.length && !body.at(-1).trim()) body.pop(); + items.push(body); + if (blanks && i < n && sameList(lines[i])) loose = true; + } + for (const body of items) { + const li = make(doc, 'li'); + parse(body, depth + 1, li, doc, !loose); + list.appendChild(li); + } + out.appendChild(list); continue; + } + // A paragraph, or a heading when a setext underline follows. A thematic break lands here as text. + const para = [line.trimStart()]; + let level = 0; + for (i++; i < n && lines[i].trim() && !starts(lines[i]); i++) { + const underline = SETEXT.exec(lines[i]); + if (underline) { level = underline[1][0] === '=' ? 1 : 2; i++; break; } + para.push(lines[i].trimStart()); + } + const text = para.map(trimBlanksEnd).join('\n'); + if (level) { const heading = make(doc, 'h' + level); inline(text, heading, doc, 0, false); out.appendChild(heading); } + else if (tight) inline(text, out, doc, 0, false); + else { const p = make(doc, 'p'); inline(text, p, doc, 0, false); out.appendChild(p); } + } + } + // `meter`, when given, receives the render's look-ahead account: {work, budget}. The page does not + // pass one; the tests do, so they can assert on the count and never on the clock. + function render(source, doc = document, meter = null) { + const fragment = doc.createDocumentFragment(); + const text = String(source).replace(/\r\n?/g, '\n'); + work = 0; budget = Math.max(BUDGET_FLOOR, BUDGET_PER_CHAR * text.length); + const lines = text.split('\n').map(l => l.replace(/^[ \t]+/, w => w.replace(/\t/g, ' '))); + parse(lines, 0, fragment, doc, false); + if (meter) { meter.work = work; meter.budget = budget; } + return fragment; + } + window.MosaicMarkdown = Object.freeze({ render, linkTarget }); +})();