Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
552 lines
35 KiB
Diff
552 lines
35 KiB
Diff
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 ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/settings', 'Settings', '', 'settings']];
|
|
+ const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, '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 `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true"><use href="/icons.svg#i-${icon}"/></svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
|
|
@@ -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(`<section class="panel" aria-labelledby="doc-p-${esc(p)}"><h2 id="doc-p-${esc(p)}">${txt(p)} <span class="count">${files.length}</span></h2>${code ? `<p class="small warn">Not read: <code>${txt(code)}</code>.</p>` : files.length ? `<ul class="s1-plain">${files.map(f => `<li><a href="#/docs/${enc(p)}/${enc(f.path)}">${txt(f.path)}</a></li>`).join('')}</ul>` : '<p class="muted">No spec or docs files.</p>'}</section>`);
|
|
+ }
|
|
+ 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 <code>--send</code> 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 <code>--send</code> behind the sign-in proxy; this one only reads.'}`;
|
|
+ return `${head(`Edit ${txt(s.path)}`, `Project ${txt(s.project)}, opened at commit <code id="doc-commit">${esc(s.commit.slice(0, 12))}</code>. ${readLine()}`)}
|
|
+ <p class="small"><a href="#/docs">All documents</a></p>
|
|
+ <div class="doc-edit"><section class="panel" aria-labelledby="doc-src-title"><h2 id="doc-src-title">Markdown source</h2>
|
|
+ <form id="doc-form"><label for="doc-text" class="sr-only">Markdown source of ${txt(s.path)}</label>
|
|
+ <textarea id="doc-text" class="doc-text" data-field="doc-text" rows="26" spellcheck="false" autocomplete="off" aria-describedby="doc-status"${canEdit && !unsafe && !out() ? '' : ' readonly'}>\n${unsafe ? '' : esc(s.text)}</textarea>
|
|
+ <p class="small muted">${stateNote}</p>
|
|
+ <button type="submit" id="doc-save" disabled>Save to the project</button> <button type="button" class="btn" id="doc-reload">Reload from the project and discard the draft</button>
|
|
+ <p role="status" id="doc-status">${txt(s.status)}</p></form></section>
|
|
+ <section class="panel" aria-labelledby="doc-prev-title"><h2 id="doc-prev-title">Preview</h2><p class="small muted">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.</p><div id="doc-preview" class="md-preview"></div></section></div>`;
|
|
+ }
|
|
+ // 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')}<div class="empty nf"><b>${why || 'No page at this address.'}</b><p>Nothing is at <code>${txt(location.hash || '#/')}</code>. <a href="#/">Go to the Board</a> or press <kbd>Ctrl K</kbd> to search.</p></div>`; }
|
|
const loadingView = (source = null) => `${head('Reading…')}<div class="skel-rows" aria-busy="true"><span class="sr-only">${source ? `Reading the ${esc(source.toLowerCase())}.` : 'Reading from the bus.'}</span><span class="skel"></span><span class="skel"></span><span class="skel"></span><span class="skel"></span></div>`;
|
|
@@ -675,7 +767,7 @@
|
|
$('fresh-bus').innerHTML = state === 'refused' ? `${esc(source)} <span class="warn">not read: refused</span>` : state === 'failed' ? `${esc(source)} <span class="warn">not read: the read failed</span>`
|
|
: `${esc(source)} read <b>${esc(stamp(readAt))}</b>${readAt ? ` (${esc(ago(readAt))})` : ''}${state === 'stale' ? ' · <span class="warn">stale: the last read failed</span>' : ''}`;
|
|
}
|
|
- 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 <details> 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 @@
|
|
<html lang="en" data-design="console" data-palette="harbor">
|
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
|
|
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/shell.css">
|
|
-<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/bus.js" defer></script></head>
|
|
+<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/bus.js" defer></script></head>
|
|
<body>
|
|
<a class="skip" href="#main">Skip to content</a>
|
|
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
|
|
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 });
|
|
+})();
|