diff --git a/.claude/skills/library/SKILL.md b/.claude/skills/library/SKILL.md index a3eead2..77ae7b6 100644 --- a/.claude/skills/library/SKILL.md +++ b/.claude/skills/library/SKILL.md @@ -31,12 +31,17 @@ js/library/ client.js the index and entry files fetched, verified and kept offline. submit.js the submission document, the pre-check, the issue URL. requests.js import-free. automata-studio:// links queued until boot ends. - article.js import-free. An essay's Markdown → HTML: the dialect, facts, - figures, footnotes. Shared by the website build and the view. + article.js An essay's Markdown → HTML: markdown-it and the library's + plugins (facts, figures, math, callouts, [[links]]), the HTML + allowlist and the tag balancer. Shared by the website build, + the view and the editor; the app loads it lazily. article-figures.js import-free. The figures an essay draws from a machine's standard code: space-time diagrams, growth charts. essay.js what an essay may ask of the index: {{facts}}, figure - arguments (bounded), lib: links, and the build's warnings. + arguments (bounded), lib: links. Light: no markdown-it. + essay-check.js the build's warnings for an essay (imports the renderer). + essay-guide.md how to write an essay: the documentation, in the essay format. + essay-guide.js renders it, each example beside what it becomes. essay-draw.js the view's figures, drawn in essay-figures.worker.js. js/library-ui.js the view: discover, browse, entry pages, collections, My Library, submit, about. @@ -113,11 +118,15 @@ StateMate reaches the library two ways: `/library [words]`, and the `search_libr **A machine or a collection can carry an essay**: a Markdown file beside it (`machines/…/bb5.md` beside `bb5.automaton`, `collections/busy-beavers.md` beside its `.json`). Its own file, so prose is diffed and reviewed as prose, and editing it never changes the machine's bytes, hash or id. The index lists it as `essay: { path, hash, minutes }` on the entry or collection (`essayRef` in index-model.js refuses a path that could not be one — it ends up in a fetch URL); `writeLibrary` serves it beside its machine, and only when the machine was published. -- **The dialect is [js/library/article.js](js/library/article.js)**, small on purpose: `##`/`###` (a `#` is a section too — a page has one title, the entry's), paragraphs, lists, quotes, tables, fenced code, `$…$`/`$$…$$`, `[^n]` notes, links to `http(s)`, `#` or `lib:`. Code spans and math are set aside **before** anything else, and everything is escaped before any tag is written: nothing an author writes reaches the page as markup. Anything it cannot answer stays visible and is returned in `warnings`. +- **The Markdown is all of it**, parsed by markdown-it in [js/library/article.js](js/library/article.js): CommonMark, GitHub's tables, strikethrough, task lists and bare-URL links, footnotes, definition lists, `==mark==`, `~sub~`/`^sup^`, typographic quotes — plus Obsidian's `> [!note] Title` callouts (`-`/`+` fold them) and `[[id|text]]` links, `$…$`/`$$…$$`/`\(…\)`/`\[…\]` math left intact for KaTeX (a price like `$5 and $10` is not math — Pandoc's rule), `{{facts}}` and `:::` figures. The shallowest heading becomes an h2, since the page's h1 is the entry's title, so `#` and `##` essays read the same; YAML front matter (`---` … `---` at the top, as Obsidian writes it) is dropped. +- **Safety is the renderer's, not the author's.** Text is escaped by markdown-it; `validateLink` is opened only so that a core rule (`essay_links`) can judge every link — written or linkified — and **report** the ones it drops: a link must be `http(s)`, `mailto`, `#` or `lib:`, an image `https://` (no trackers over plain HTTP, no local files). Raw HTML passes only as **bare tags from `HTML_OK`** — ``, `
`, `` and friends, with no attributes — and anything else is shown as the text it is. Then **`balanceHtml`** closes what the author left open and drops closing tags with nothing to close: on the website the essay is written into the page's own markup, and a stray `` would otherwise close the page's. Anything the renderer cannot answer stays visible and is returned in `warnings`. - **The one promise holds inside the prose.** `{{steps}}`, `{{ones}}`, `{{cells}}`, `{{states}}`, `{{transitions}}`, `{{size}}`, `{{standard}}`, `{{title}}` — and `{{steps }}` for another entry — are read off the index by `essayFacts` (essay.js), the analysis that earned the badges, never typed. They are set in the mono with a dotted rule so a reader can tell the machine's answers from the author's words. A name is looked up with `Object.hasOwn`: `{{constructor}}` is not a fact. - **Figures are drawn from the machine**: `::: spacetime steps= h=`, `::: growth y=log scale=linear`, `::: diagram id=`, `::: machines …`, each followed by its caption and a closing `:::`. `essayFigureSpec` parses and **bounds** the arguments (200,000 steps for a space-time diagram; a growth chart runs to the halt under 10⁸) for both faces. The website draws them at build time; the view draws them in a worker (essay-draw.js — one worker, started on first use, results kept by input so going back does not rerun BB(5)), falling back to drawing in place where there is no Worker. A space-time diagram draws the head's path only when every step has its row: sampled, it joins positions many steps apart into a zigzag the machine never made. - **The build checks every essay** against what is published (`essayWarnings`): an unknown fact, a figure the machine cannot draw, a `lib:` link to nothing, a note never written. They are **warnings** on the entry's report — the PR comment — not errors: a sentence to fix is no reason to unpublish a machine that passed. -- **An essay is credited as its machine is** (`guard.mjs`): it is Markdown, with no credit of its own, so the machine's author or a maintainer may write it and nobody else may attach one to their machine; an essay with no machine beside it is refused. A collection's essay is the maintainers', like the collection. Essays arrive by pull request — the issue form carries the short write-up (`meta.library.readme`), which an essay replaces on the page. +- **An essay is credited as its machine is** (`guard.mjs`): it is Markdown, with no credit of its own, so the machine's author or a maintainer may write it and nobody else may attach one to their machine; an essay with no machine beside it is refused. A collection's essay is the maintainers', like the collection. +- **Essays are written in the app, or sent by pull request.** The Submit page has an **essay editor** (`essayEditor` in library-ui.js): Write / Split / Preview — Obsidian's split, the Markdown beside the page it makes, scrolled together — with `Ctrl/⌘ E` toggling reading, **Open .md file…** (or a file dropped on the panes; `readEssayFile` takes Markdown or plain text up to 1 MB, dropping a BOM and CRLFs), **Save as .md**, and Undo for a draft a file replaced. The preview is the real renderer with the real answers: facts from this machine's analysis (`essaySelf`, run once per machine, with placeholders for a title or account the form does not have yet — the analysis gives no facts without them), figures from the worker, `[[links]]` checked against the index, and the warnings listed under it. A step count the dialog's budget cannot reach shows as a pending `…`, counted by CI. The draft is kept per machine in localStorage (`essayDraft`/`saveEssayDraft`), and an author sending back their own entry starts from its published essay. +- **The documentation is [js/library/essay-guide.md](js/library/essay-guide.md)**, written in the essay format itself: every ```` ```example ```` block is shown as its source beside what the real renderer makes of it (`renderGuide` in essay-guide.js, with BB(2) — the index's entry, or `GUIDE_SAMPLE` — as the machine the examples are about). The website builds it into `/writing/` (`writingPage` in site.mjs, linked from Submit and the footer); the editor's **Guide** button shows it in the preview's place, loaded with Vite's `?raw` (a test swaps the loader: Node has no `?raw`). [tests/essay.test.js](tests/essay.test.js) holds it to the code: it renders with **no warnings**, and it names every `ESSAY_FACTS` fact, every figure, every `CALLOUT_ALIAS` kind, every `HTML_OK` tag and the real limits — a feature added without its documentation fails there. +- **An essay travels inside the submitted document** (`meta.library.essay`, `buildSubmissionDoc`), not in the issue form's address: the form's URL is capped at `ISSUE_URL_MAX` and a few paragraphs would fill it, while the document rides the compressed share link — or the clipboard, when that is too long, as the machine already did. `issue-to-entry.mjs` takes it out (the form's own **Essay** field wins, for a form filled in by hand), refuses one past `ARTICLE_MAX_CHARS`, writes it beside the machine as `.md` — which the submission workflow's `machines/**` picks up — and leaves the machine file without it. An update that brings no essay keeps the published one. The Essay field is **last** in the form on purpose: `parseIssueForm` starts each section once, so a `### Licence` heading inside an essay, coming after the real one, stays in the essay. - **In the view** (`essaySection` in library-ui.js) the essay is fetched by `fetchEssayText` — the same verified, cached path as a machine's file, so a tampered download is refused and an essay read once reads offline. Two things differ from the website because the essay shares a document with the app: **every id is prefixed** (`lib-essay-`, via `idPrefix`), and **in-page links are handled in the view** — a footnote or a `lib:` link (written as `#library=`/`#collection=`) must not write the address bar's hash, which carries share links. Figures and plates go in as empty `data-essay-slot` nodes and are filled once the markup is in place. Anything scrolled to carries `scroll-margin-top`, or the sticky status bar covers it. [tests/essay.test.js](tests/essay.test.js) pins the dialect, the escaping, the fact lookup, the figure bounds and the index's `essayRef`; [tests/library.test.js](tests/library.test.js) the build, the report's warnings, the guard, the website's page and the view (a tampered essay is refused, ids are prefixed, the essay comes before Behaviour and replaces the notes). diff --git a/css/library.css b/css/library.css index f43dd79..ade75b2 100644 --- a/css/library.css +++ b/css/library.css @@ -557,6 +557,71 @@ .lib-essay-body .essay-notes { margin-top: 2.2em; padding-top: 14px; border-top: 1px solid var(--border); font-size: .96rem; line-height: 1.5; color: var(--text2); } .lib-essay-body .essay-notes ol { margin: 0; padding-left: 1.3em; } .lib-essay-body .lib-plates { margin: 0; } +/* The writing guide's examples: the Markdown, and what it becomes, side by side. */ +.lib-essay-body .essay-example { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0; margin: 1.1em 0 1.6em; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } +.lib-essay-body .essay-example > .essay-example-src { margin: 0; border: 0; border-right: 1px solid var(--border); border-radius: 0; white-space: pre-wrap; overflow-wrap: anywhere; } +.lib-essay-body .essay-example-out { padding: 12px 16px; min-width: 0; font-size: .96em; } +.lib-essay-body .essay-example-out > :first-child { margin-top: 0; } +.lib-essay-body .essay-example-out > :last-child { margin-bottom: 0; } +.lib-essay-body .essay-example-out > p:first-child::first-letter { float: none; font-size: inherit; padding: 0; } +.lib-essay-body .essay-example-out .essay-fig { margin: .4em 0; } +.lib-essay-body hr { border: 0; border-top: 1px solid var(--border2); margin: 2em 0; } +.lib-essay-body mark { background: color-mix(in srgb, var(--gold) 28%, transparent); color: inherit; padding: 0 2px; border-radius: 2px; } +.lib-essay-body kbd { font-family: var(--mono); font-size: .72em; padding: 1px 6px; border: 1px solid var(--border2); border-bottom-width: 2px; border-radius: 4px; background: var(--lib-well); } +.lib-essay-body del, .lib-essay-body s { color: var(--text2); } +.lib-essay-body dl { margin: 0 0 1em; } +.lib-essay-body dt { font-weight: 600; } +.lib-essay-body dd { margin: 0 0 .6em 1.3em; color: var(--text2); } +.lib-essay-body img.essay-img { display: block; max-width: 100%; height: auto; margin: 1.2em auto; border-radius: 6px; border: 1px solid var(--border); } +.lib-essay-body li.task-item { list-style: none; margin-left: -1.3em; } +.lib-essay-body .task-box { margin: 0 .45em 0 0; vertical-align: middle; accent-color: var(--h, var(--accent)); } +.lib-essay-body details:not(.callout) { margin: 1em 0; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; } +.lib-essay-body details:not(.callout) > summary { cursor: pointer; color: var(--text2); } +/* Obsidian's callouts: a tinted panel, a titled head, the kind's colour on its edge. */ +.lib-essay-body .callout { --c: var(--accent); margin: 1.3em 0; border: 1px solid color-mix(in srgb, var(--c) 30%, var(--border)); border-left: 3px solid var(--c); border-radius: 6px; background: color-mix(in srgb, var(--c) 6%, transparent); } +.lib-essay-body .callout-title { padding: 8px 14px; font-family: var(--sans); font-size: .82rem; font-weight: 600; letter-spacing: .01em; color: var(--c); } +.lib-essay-body summary.callout-title { cursor: pointer; } +.lib-essay-body .callout-body { padding: 0 14px 4px; font-size: .96em; } +.lib-essay-body .callout-body > :last-child { margin-bottom: .7em; } +.lib-essay-body .callout-tip, .lib-essay-body .callout-success { --c: var(--green); } +.lib-essay-body .callout-warning, .lib-essay-body .callout-question { --c: var(--gold); } +.lib-essay-body .callout-danger, .lib-essay-body .callout-failure, .lib-essay-body .callout-bug { --c: var(--red); } +.lib-essay-body .callout-example, .lib-essay-body .callout-abstract { --c: var(--violet); } +.lib-essay-body .callout-quote { --c: var(--text3); } +.lib-essay-body .fact.is-pending { font-size: 1em; color: var(--text3); border-bottom-style: dashed; } +.lib-essay-body .board td.center, .lib-essay-body .board th.center { text-align: center; } + +/* ── the essay editor ── + Write, Split or Preview: Obsidian's split, the Markdown beside the page it + makes. The preview is the listing's own essay styles, so what it shows is + what will be published. A file dragged over the panes lights their edge. */ +.lib-essay-field { gap: 8px; } +.lib-md-editor { display: flex; flex-direction: column; gap: 8px; min-width: 0; } +.lib-md-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; } +.lib-md-modes { display: inline-flex; border: 1px solid var(--border2); border-radius: 6px; overflow: hidden; } +#v-library .lib-md-mode { all: unset; cursor: pointer; padding: 4px 12px; font-family: var(--mono); font-size: .64rem; letter-spacing: .1em; text-transform: uppercase; color: var(--text2); } +#v-library .lib-md-mode + .lib-md-mode { border-left: 1px solid var(--border2); } +#v-library .lib-md-mode.is-on { background: var(--accent-soft, color-mix(in srgb, var(--accent) 12%, transparent)); color: var(--accent); } +#v-library .lib-md-mode:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.lib-md-files { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 16px; } +.lib-md-status { font-family: var(--mono); font-size: .64rem; color: var(--text3); } +.lib-md-panes { display: grid; gap: 0; height: min(64vh, 620px); min-height: 280px; border: 1px solid var(--border2); border-radius: 8px; overflow: hidden; resize: vertical; transition: border-color var(--transition-base); } +.lib-md-panes.is-split { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +.lib-md-panes.is-write, .lib-md-panes.is-preview { grid-template-columns: minmax(0, 1fr); } +.lib-md-panes.is-write .lib-md-preview, .lib-md-panes.is-preview .lib-md-input { display: none; } +.lib-md-guide { display: none; overflow: auto; padding: 16px 22px 28px; max-width: none; font-size: 1rem; } +.lib-md-panes.has-guide .lib-md-preview { display: none; } +.lib-md-panes.has-guide .lib-md-guide { display: block; } +.lib-md-guide .essay-example { grid-template-columns: minmax(0, 1fr); } +.lib-md-guide .essay-example > .essay-example-src { border-right: 0; border-bottom: 1px solid var(--border); } +#v-library .lib-md-modes .lib-md-guide-btn { border-left: 1px solid var(--border2); } +.lib-md-panes.is-drop { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); } +#v-library .lib-md-input { box-sizing: border-box; width: 100%; height: 100%; min-height: 0; margin: 0; padding: 14px 16px; border: 0; border-radius: 0; resize: none; font-family: var(--mono) !important; font-size: .82rem; line-height: 1.65; tab-size: 2; background: var(--lib-well); color: var(--text); } +.lib-md-panes.is-split .lib-md-input { border-right: 1px solid var(--border2); } +.lib-md-preview { overflow: auto; padding: 16px 22px 28px; max-width: none; font-size: 1.06rem; background: var(--bg2, transparent); } +.lib-md-preview > p:first-child::first-letter { float: none; font-size: inherit; padding: 0; } +.lib-md-warnings { margin: 0; padding: 8px 12px 8px 28px; border: 1px solid color-mix(in srgb, var(--gold) 35%, var(--border)); border-radius: 6px; font-size: .78rem; line-height: 1.5; color: var(--text2); } +.lib-md-warnings[hidden] { display: none; } @media (max-width: 1100px) { .lib-mast.has-frontis { grid-template-columns: minmax(0, 1fr); } @@ -566,6 +631,10 @@ .lib-essay-grid { grid-template-columns: minmax(0, 1fr); gap: 20px; } .lib-essay-toc { position: static; } .lib-essay-toc ol { flex-direction: row; flex-wrap: wrap; gap: 6px 18px; } + .lib-md-panes.is-split { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); } + .lib-md-panes.is-split .lib-md-input { border-right: 0; border-bottom: 1px solid var(--border2); } + .lib-essay-body .essay-example { grid-template-columns: minmax(0, 1fr); } + .lib-essay-body .essay-example > .essay-example-src { border-right: 0; border-bottom: 1px solid var(--border); } } @media (max-width: 900px) { diff --git a/js/library-ui.js b/js/library-ui.js index fb0b9ed..d0f52e8 100644 --- a/js/library-ui.js +++ b/js/library-ui.js @@ -48,7 +48,6 @@ import { BADGES, DIFFICULTIES, LIBRARY_FAMILIES, SORTS, dfaAccepts, entryById, libraryFacets, queryLibrary, pickFrontispiece, recentEntries, remixAncestry, resolveSort, sameLanguageAs, wasUpdated } from './library/index-model.js'; -import { renderArticle } from './library/article.js'; import { essayFacts, essayFigureSpec, essayLinkTarget } from './library/essay.js'; import { drawEssayFigure } from './library/essay-draw.js'; import { @@ -58,7 +57,7 @@ import { import { LIBRARY_LICENSES, decideRaw, languageFingerprint, liveDiagram, minimalDfaOf, namedDiagram, runFramesOf, targetFromDoc, traceWord } from './library/analyze.js'; -import { precheckSubmission, rememberLogin, submissionDefaults, submissionLink } from './library/submit.js'; +import { essayDraft, precheckSubmission, rememberLogin, saveEssayDraft, submissionDefaults, submissionLink, updateOf } from './library/submit.js'; // ── State ───────────────────────────────────────────────────────── @@ -79,6 +78,9 @@ const L = { submitCheck: null, docs: new Map(), // hash → { text, target, doc } runTimer: null, // Try it's playback on the diagram + essaySelf: null, // the essay editor's view of the machine on the canvas (essaySelf) + essayMode: null, // 'write' | 'split' | 'preview', remembered while the app is open + openEssayFile: null, // the editor's file loader (picker and drop), for tests shown: null // Browse's { key, n }: how many plates are out, for the search they were shown for }; @@ -119,6 +121,9 @@ export function _resetLibraryUiForTests() { L.submit = null; L.submitFor = null; L.submitCheck = null; + L.essaySelf = null; + L.essayMode = null; + L.openEssayFile = null; L.docs.clear(); clearInterval(L.runTimer); L.runTimer = null; @@ -839,6 +844,20 @@ function pageEntry(id) { // a footnote must not overwrite either. const ESSAY_ID = 'lib-essay-'; +const PREVIEW_ID = 'lib-md-'; +const GUIDE_ID = 'lib-guide-'; + +// The renderer carries markdown-it, which nothing else in the app needs, so it +// is loaded the first time an essay is read or written rather than at boot. +let articleModule = null; +const loadArticle = () => (articleModule ||= import('./library/article.js')); + +// The writing guide: js/library/essay-guide.md, the same text the website's +// /writing/ page is built from, with its renderer. Loaded the first time the +// editor's Guide is opened. A test swaps the loader (Node has no ?raw import). +let guideLoader = () => Promise.all([import('./library/essay-guide.md?raw'), import('./library/essay-guide.js')]) + .then(([text, mod]) => ({ md: text.default, renderGuide: mod.renderGuide, guideSample: mod.guideSample })); +export function _setEssayGuideLoaderForTests(fn) { guideLoader = fn; } function essaySection(essay, self) { const body = h('div', { class: 'lib-essay-body lib-prose' }, h('p', { class: 'lib-muted', text: 'Fetching the essay…' })); @@ -848,7 +867,7 @@ function essaySection(essay, self) { sectionHead('Essay', h('span', { class: 'lib-essay-meta', text: `${essay.minutes} min read` })), columns); body.addEventListener('click', essayClick); const idx = index(); - fetchEssayText(essay, idx).then(md => { + Promise.all([fetchEssayText(essay, idx), loadArticle()]).then(([md, { renderArticle }]) => { const slots = []; const art = renderArticle(md, { ...essayContext(idx, self, slots), idPrefix: ESSAY_ID, newTab: true }); body.innerHTML = art.html; @@ -943,7 +962,7 @@ function essayClick(ev) { } function scrollToEssayId(id) { - if (!id.startsWith(ESSAY_ID)) return; + if (![ESSAY_ID, PREVIEW_ID, GUIDE_ID].some(p => id.startsWith(p))) return; document.getElementById?.(id)?.scrollIntoView?.({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'start' }); } @@ -1518,6 +1537,269 @@ function texPreview(cls) { return box; } +// ── The essay editor ────────────────────────────────────────────── +// +// Write, Split or Preview — the split is Obsidian's: the Markdown on the left, +// the page it will make on the right, redrawn a moment after typing stops, +// scrolled along with the text. The preview is the real renderer with the +// real answers: {{facts}} from this machine's own analysis, figures drawn +// from it in the worker, [[links]] checked against the index — so what it +// shows is what the listing will show, and what it cannot answer is listed +// under it, as the pull request's check will list it. +// +// A written essay can be opened instead of typed (the button, or a file +// dropped on the editor), saved back out as a .md, and is kept as a draft per +// machine as it is typed. Replacing a draft with a file can be undone. + +const ESSAY_FILE_MAX = 1_000_000; +const ESSAY_ACCEPT = '.md,.markdown,.mdown,.mkd,.mdx,.txt,text/markdown,text/x-markdown,text/plain'; +const ESSAY_MODES = [['write', 'Write'], ['split', 'Split'], ['preview', 'Preview']]; + +/** Whether a file is one the editor reads: Markdown or plain text, by name or type. */ +export function isEssayFile(file) { + if (!file) return false; + if (/\.(md|markdown|mdown|mkd|mdx|txt)$/i.test(file.name || '')) return true; + return /^text\/(markdown|x-markdown|plain)$/.test(file.type || ''); +} + +/** + * A file's text for the editor, or a sentence saying why not. Too large to + * be an essay, not text, or unreadable: the draft is left as it was. + */ +export async function readEssayFile(file) { + if (!isEssayFile(file)) return { error: `${file?.name || 'That file'} is not a Markdown or text file.` }; + if (file.size > ESSAY_FILE_MAX) return { error: `${file.name} is ${Math.round(file.size / 1024)} KB — too large to be an essay.` }; + try { + const text = String(await file.text()).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'); + return { text, name: file.name }; + } catch { + return { error: `${file.name} could not be read.` }; + } +} + +/** + * This machine as an essay's `{{facts}}` see it — an entry built from the + * pre-check's analysis — and the entry of the author's own it would update. + * Run once per machine: the analysis is the expensive part of the pre-check. + */ +function essaySelf(f) { + const key = `${activeWorkspaceId}|${App.meta?.library?.source?.id || ''}|${App.states.length}|${App.transitions.length}`; + if (L.essaySelf?.key === key) return L.essaySelf; + // Placeholders for what the form may not have yet: the analysis gives no + // facts for a card without a title or an account, and an essay's preview + // should not wait on either. + let pre = null; + try { + pre = precheckSubmission({ ...f, title: f.title || 'Untitled', login: f.login || 'preview', license: LIBRARY_LICENSES[f.license] ? f.license : 'CC-BY-4.0', essay: '', agreed: true }, index()); + } catch { pre = null; } + const facts = pre?.analysis?.facts; + const entry = facts ? { + id: App.meta?.library?.source?.id || '(this machine)', + title: f.title || facts.title || 'This machine', category: facts.category, stats: facts.stats, + behaviour: facts.behaviour, standard: facts.standard, sketch: facts.sketch + } : null; + let update = null; + try { update = index() ? updateOf(f, index()) : null; } catch { update = null; } + L.essaySelf = { key, entry, update }; + return L.essaySelf; +} + +const PENDING_FACTS = new Set(['steps', 'ones', 'cells']); + +function essayEditor(f, draftKey) { + const idx = index(); + let mode = L.essayMode || 'split'; + let undo = null; + const input = h('textarea', { + class: 'inp lib-md-input', spellcheck: 'true', 'aria-label': 'Essay, in Markdown', + placeholder: '## How it works\n\nWrite in Markdown — or open a .md file, or drop one here.\n\nIt halts after {{steps}} steps.\n\n::: spacetime steps=2000\nThe first steps from a blank tape.\n:::' + }); + input.value = f.essay || ''; + const preview = h('div', { class: 'lib-md-preview lib-essay-body lib-prose', 'aria-label': 'Preview' }); + const guide = h('div', { class: 'lib-md-guide lib-essay-body lib-prose', 'aria-label': 'Writing an essay' }); + const panes = h('div', { class: `lib-md-panes is-${mode}` }, input, preview, guide); + let guideOpen = false; + let guideDrawn = false; + const warnings = h('ul', { class: 'lib-md-warnings', hidden: true }); + const status = h('span', { class: 'lib-md-status' }); + const picker = h('input', { type: 'file', accept: ESSAY_ACCEPT, hidden: true, 'aria-hidden': 'true', tabindex: '-1' }); + const undoBtn = button('Undo', () => { if (undo !== null) { setText(undo, 'Restored your draft'); undo = null; undoBtn.setAttribute('hidden', ''); } }, 'lib-textbtn', { hidden: true }); + const modeBtns = ESSAY_MODES.map(([m, label]) => button(label, () => setMode(m), `lib-md-mode${m === mode ? ' is-on' : ''}`, { 'aria-pressed': String(m === mode), data: { mode: m } })); + + const words = () => (input.value.match(/\S+/g) || []).length; + const say = (text) => { status.textContent = text || (input.value.trim() ? `${words().toLocaleString('en-US')} words` : 'Optional'); }; + + let timer = null; + let drawn = null; + const draw = async () => { + const md = input.value; + if (md === drawn) return; + drawn = md; + if (!md.trim()) { + preview.innerHTML = ''; + preview.append(h('p', { class: 'lib-muted', text: 'The preview of your essay appears here, as it will read on the machine’s page.' })); + warnings.setAttribute('hidden', ''); + return; + } + const { renderArticle } = await loadArticle(); + if (input.value !== md) return; // typed on while the renderer loaded + const self = essaySelf(f).entry; + const slots = []; + const ctx = essayContext(idx || { entries: [], collections: [] }, self, slots); + const fact = (name, ref) => { + const got = ctx.fact(name, ref); + if (got || ref || !self?.standard || !PENDING_FACTS.has(name) || self.behaviour?.verdict === 'halts' || self.behaviour?.verdict === 'never') return got; + // A run too long for this dialog's budget is counted by the library's CI. + return { value: '…', say: 'Counted when the library builds; the preview runs a shorter budget.', pending: true }; + }; + const art = renderArticle(md, { ...ctx, fact, idPrefix: PREVIEW_ID, newTab: true }); + const top = preview.scrollTop; + preview.innerHTML = art.html; + fillEssaySlots(preview, slots); + typeset(preview); + preview.scrollTop = top; + warnings.innerHTML = ''; + if (art.warnings.length) { + warnings.append(...art.warnings.map(w => h('li', { text: w }))); + warnings.removeAttribute('hidden'); + } else warnings.setAttribute('hidden', ''); + }; + const redraw = (now = false) => { + clearTimeout(timer); + if (now) draw(); + else timer = setTimeout(draw, 200); + }; + const changed = () => { + f.essay = input.value; + L.submitCheck = null; + saveEssayDraft(draftKey, input.value); + say(); + redraw(); + }; + const setText = (text, message) => { + input.value = text; + changed(); + redraw(true); + say(message); + }; + const setMode = m => { + mode = m; + L.essayMode = m; + guideOpen = false; + guideBtn.classList.remove('is-on'); + guideBtn.setAttribute('aria-pressed', 'false'); + panes.className = `lib-md-panes is-${m}`; + for (const b of modeBtns) { + const on = b.dataset.mode === m; + b.classList.toggle('is-on', on); + b.setAttribute('aria-pressed', String(on)); + } + if (m !== 'write') redraw(true); + (m === 'preview' ? preview : input).focus?.(); + }; + const open = async file => { + const got = await readEssayFile(file); + if (got.error) { say(got.error); showStatus(got.error); return; } + if (input.value.trim() && input.value !== got.text) { + undo = input.value; + undoBtn.removeAttribute('hidden'); + } + setText(got.text, `Opened ${got.name}`); + }; + L.openEssayFile = open; // test seam: what the picker and a drop both call + + input.addEventListener('input', changed); + // Obsidian's toggle between editing and reading. + input.addEventListener('keydown', ev => { + if ((ev.ctrlKey || ev.metaKey) && !ev.shiftKey && !ev.altKey && String(ev.key).toLowerCase() === 'e') { + ev.preventDefault(); + setMode(mode === 'preview' ? 'write' : 'preview'); + } + }); + preview.addEventListener('keydown', ev => { + if ((ev.ctrlKey || ev.metaKey) && String(ev.key).toLowerCase() === 'e') { ev.preventDefault(); setMode('write'); } + }); + // Scrolled together, by proportion: a line of Markdown is not a line of page. + input.addEventListener('scroll', () => { + if (mode !== 'split') return; + const range = input.scrollHeight - input.clientHeight; + const r = range > 0 ? input.scrollTop / range : 0; + preview.scrollTop = r * Math.max(0, preview.scrollHeight - preview.clientHeight); + }); + preview.addEventListener('click', essayClick); + picker.addEventListener('change', () => { const file = picker.files?.[0]; picker.value = ''; if (file) open(file); }); + for (const target of [panes]) { + target.addEventListener('dragover', ev => { + if (!Array.from(ev.dataTransfer?.types || []).includes('Files')) return; + ev.preventDefault(); + panes.classList.add('is-drop'); + }); + target.addEventListener('dragleave', () => panes.classList.remove('is-drop')); + target.addEventListener('drop', ev => { + const file = ev.dataTransfer?.files?.[0]; + panes.classList.remove('is-drop'); + if (!file) return; + ev.preventDefault(); + open(file); + }); + } + + // The guide takes the preview's place beside the Markdown, so the reference + // sits next to what is being written; any layout button puts the preview back. + const drawGuide = async () => { + if (guideDrawn) return; + guideDrawn = true; + guide.append(h('p', { class: 'lib-muted', text: 'Loading the guide…' })); + try { + const { md, renderGuide, guideSample } = await guideLoader(); + const gidx = idx || { entries: [], collections: [] }; + const slots = []; + const ctx = essayContext(gidx, guideSample(gidx), slots); + const art = renderGuide(md, { index: gidx, figure: ctx.figure, link: ctx.link, idPrefix: GUIDE_ID, newTab: true }); + guide.innerHTML = art.html; + fillEssaySlots(guide, slots); + typeset(guide); + } catch { + guideDrawn = false; + guide.innerHTML = ''; + guide.append(h('p', { class: 'lib-muted', text: 'The guide could not be loaded.' })); + } + }; + const toggleGuide = () => { + if (guideOpen) { setMode(mode); return; } + guideOpen = true; + panes.className = 'lib-md-panes is-split has-guide'; + for (const b of modeBtns) { b.classList.remove('is-on'); b.setAttribute('aria-pressed', 'false'); } + guideBtn.classList.add('is-on'); + guideBtn.setAttribute('aria-pressed', 'true'); + drawGuide(); + }; + const guideBtn = button('Guide', () => toggleGuide(), 'lib-md-mode lib-md-guide-btn', { 'aria-pressed': 'false', title: 'How to write an essay: everything it can hold, with examples' }); + guide.addEventListener('click', essayClick); + + const slug = () => (f.title || 'essay').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-|-$/g, '').toLowerCase() || 'essay'; + const toolbar = h('div', { class: 'lib-md-toolbar' }, + h('div', { class: 'lib-md-modes', role: 'group', 'aria-label': 'Editor layout' }, modeBtns, guideBtn), + h('div', { class: 'lib-md-files' }, + button('Open .md file…', () => picker.click(), 'lib-textbtn', { title: 'Use an essay you have already written — Markdown or plain text' }), + button('Save as .md', () => { if (input.value.trim()) exportDownload(`${slug()}.md`, input.value, 'text/markdown'); else showStatus('There is no essay to save yet'); }, 'lib-textbtn'), + undoBtn, status, picker)); + + // The author's own entry, sent back as an update, starts from the essay it has. + const update = idx ? essaySelf(f).update : null; + if (!input.value.trim() && update?.essay) { + say('Loading the essay published with your entry…'); + fetchEssayText(update.essay, idx).then(text => { + if (input.value.trim()) return; + setText(text, 'Started from the essay published with your entry'); + }, () => say()); + } else say(); + redraw(true); + + return h('div', { class: 'lib-md-editor' }, toolbar, panes, warnings, + h('p', { class: 'lib-field-hint', text: 'Markdown, all of it: headings, tables, footnotes, task lists, $…$ math, > [!note] callouts and [[library/id]] links. {{steps}}, {{ones}} and {{size}} are filled in by the library; ::: spacetime, ::: growth, ::: diagram and ::: machines draw figures from the machine. Ctrl/⌘ E switches between writing and reading. Guide shows all of it, with examples.' })); +} + /** * The form's fields, read from the machine on the canvas — again whenever that * is a different machine than the one they were read from, so a tab switch @@ -1527,6 +1809,7 @@ function submitFields() { const key = `${activeWorkspaceId}|${App.meta?.library?.source?.id || ''}`; if (!L.submit || L.submitFor !== key) { L.submit = submissionDefaults(); + L.submit.essay = essayDraft(key); L.submitFor = key; L.submitCheck = null; } @@ -1579,8 +1862,9 @@ function pageSubmit() { page.append(h('div', { class: 'lib-form' }, field('Title', title), field('Description', blurb, 'Shown on the card and in search results. LaTeX between $…$ is typeset.'), blurbPreview, - field('Write-up', readme, 'Blank lines separate paragraphs. LaTeX is typeset: $…$ inline, $$…$$ displayed.'), + field('Write-up', readme, 'A few short paragraphs, shown as Notes on the listing when there is no essay. LaTeX is typeset: $…$ inline, $$…$$ displayed.'), readmePreview, + h('div', { class: 'lib-field lib-essay-field' }, h('span', { class: 'lib-field-label', text: 'Essay' }), essayEditor(f, L.submitFor)), h('div', { class: 'lib-form-row' }, field('Tags', tags), field('Level', level), field('Chapter', chapter)), field('Remix of', forkOf, forkHint), h('div', { class: 'lib-form-row' }, field('GitHub username', login), field('Display name', name), field('Submitting', kind)), @@ -1623,7 +1907,9 @@ function pageSubmit() { // the remix it really is (an update is not a remix of itself). const filed = { ...f, updates: c.update?.id || '', forkOf: c.doc.meta.library.forkOf || '' }; const res = await submissionLink(filed, c.doc, index()?.repo || undefined, index()?.submit || null); - if (!res.included) exportCopyText(res.link, 'The machine is too large for the form’s address — its link is on your clipboard: paste it into “Machine”'); + if (!res.included) exportCopyText(res.link, f.essay?.trim() + ? 'The machine and its essay are too long for the form’s address — their link is on your clipboard: paste it into “Machine”' + : 'The machine is too large for the form’s address — its link is on your clipboard: paste it into “Machine”'); window.open(res.url, '_blank', 'noopener'); }; page.append(h('div', { class: 'lib-actions' }, diff --git a/js/library/article.js b/js/library/article.js index 9bcea7d..599277d 100644 --- a/js/library/article.js +++ b/js/library/article.js @@ -6,15 +6,18 @@ // (collections/busy-beavers.md). Its own file, so prose is written, diffed and // reviewed as prose, and editing the essay never changes the machine's bytes. // -// The dialect is small on purpose — a book's, not a blog's: +// The Markdown is all of it — CommonMark and GitHub's extensions, parsed by +// markdown-it: headings, emphasis, ~~strikethrough~~, lists and task lists, +// quotes, tables with alignment, fenced code, links and bare URLs, images, +// footnotes, definition lists, ==highlight==, H~2~O and x^2^, and typographic +// quotes. On top of it, what an author coming from Obsidian expects — +// `> [!note] Title` callouts (foldable with `-`/`+`) and `[[id|text]]` links — +// and three things only this library can do, each answered by the library +// rather than the author: // -// ## Heading, ### Subheading paragraphs, > quotes, - and 1. lists -// **strong**, *emphasis*, `code` [text](https://…), [text](lib:turing/busy-beaver/bb4) -// $inline$ and $$display$$ math | tables | with a --- rule under the head | -// a footnote[^1] … [^1]: its text ``` fenced code ``` -// -// and two things only this library can do, both answered by the machine rather -// than by the author: +// $inline$ $$display$$ \(…\) \[…\] +// Math, left intact for KaTeX: an underscore in `$a_1$` is a subscript, +// never emphasis. // // {{steps}} {{ones}} {{steps turing/busy-beaver/bb4}} // A fact the build computed — the same numbers as the badges. An essay @@ -23,21 +26,35 @@ // ::: spacetime steps=3000 // Caption, in the same Markdown. // ::: -// A figure drawn from the machine at build time. Numbered, captioned. +// A figure drawn from the machine. Numbered, captioned. +// +// **Nothing an author writes reaches a page as markup it did not earn.** Text +// is escaped; a link must be http(s), mailto, # or lib:; an image must be +// https; raw HTML is allowed only as bare tags from a short list (, +//
, , …) with no attributes, and anything else is shown as the +// text it is. Essays are published from other people's pull requests, and the +// page they land on is the app. // -// Import-free and DOM-free: the renderer takes `fact` and `figure` callbacks, -// so the website's build and (later) the app's Library view share it. +// DOM-free: the renderer takes `fact`, `figure` and `link` callbacks, so the +// website's build and the app's Library view — and its editor's preview — +// share it. What cannot be answered is kept visible and reported. + +import MarkdownIt from 'markdown-it'; +import footnote from 'markdown-it-footnote'; +import deflist from 'markdown-it-deflist'; +import sub from 'markdown-it-sub'; +import sup from 'markdown-it-sup'; +import mark from 'markdown-it-mark'; export const ARTICLE_MAX_CHARS = 60000; -const MAX_CHARS = ARTICLE_MAX_CHARS; function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); } -export const slugOf = s => String(s).toLowerCase().replace(/<[^>]*>/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'section'; +export const slugOf = s => String(s).toLowerCase().replace(/<[^>]*>/g, '').replace(/&[a-z#0-9]+;/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'section'; -/** `steps=3000 id=turing/x` → { steps: '3000', id: 'turing/x' }; a bare word is `_`. */ +/** `steps=3000 id=turing/x` → { steps: '3000', id: 'turing/x' }; a bare word is in `_`. */ function parseArgs(s) { const out = { _: [] }; for (const m of String(s || '').matchAll(/(\w+)=("[^"]*"|\S+)|(\S+)/g)) { @@ -47,128 +64,355 @@ function parseArgs(s) { return out; } -// ── Blocks ──────────────────────────────────────────────────────── +// ── Raw HTML: a short list of bare tags ─────────────────────────── + +export const HTML_OK = new Set(['sub', 'sup', 'kbd', 'mark', 'br', 'hr', 'details', 'summary', 'ins', 'del', 's', 'u', 'b', 'i', 'em', 'strong', 'small', 'cite', 'q', 'dfn', 'var', 'samp', 'code', 'abbr', 'p', 'div', 'span', 'center']); +const TAG_RE = /|<\/?[A-Za-z][^>]*>/g; +const bareTag = t => { + const m = /^<\/?([A-Za-z][A-Za-z0-9]*)\s*\/?>$/.exec(t); + return m && HTML_OK.has(m[1].toLowerCase()); +}; + +/** Raw HTML as written, if every tag in it is a bare allowed one; else null. */ +function safeHtml(html) { + let ok = true; + const out = String(html).replace(TAG_RE, t => { + if (t.startsWith('