From 7065ec9b1e08a7fcc3c64fb29efac2964d4bd079 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 15:35:38 +0000 Subject: [PATCH 1/2] feat(library): write essays in the app, with a live preview and full Markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Submit page gets an essay editor, and essays get all of Markdown. Editor (library-ui.js, essayEditor): - Write / Split / Preview, Obsidian's split: the Markdown beside the page it makes, scrolled together; Ctrl/Cmd+E toggles reading. - "Open .md file…" or a file dropped on the editor loads an essay already written (Markdown or plain text, up to 1 MB); "Save as .md" writes it back out; replacing a draft can be undone. - The preview is the real renderer with this machine's own facts, its figures drawn in the worker, [[links]] checked against the index, and the warnings the PR check would give listed underneath. - Drafts are kept per machine in localStorage; an author updating their entry starts from its published essay. Markdown (article.js, now on markdown-it): - CommonMark and GFM (tables, task lists, strikethrough, autolinks), footnotes, definition lists, ==mark==, ~sub~, ^sup^, smart quotes; Obsidian's > [!note] callouts (foldable) and [[id|text]] links; YAML front matter dropped; the shallowest heading becomes an h2. - Math, {{facts}} and ::: figures are plugins; $5 and $10 is not math. - Safety stays the renderer's: links must be http(s), mailto, # or lib:, images https; raw HTML only as bare allow-listed tags; balanceHtml keeps an author's unclosed or stray tags inside the essay. - Loaded lazily in the app (its own ~120 KB chunk). Transport: - The essay rides inside the submitted document (meta.library.essay), so it shares the machine's compressed link instead of the issue URL. - issue-to-entry.mjs writes it beside the machine as .md and strips it from the machine file; the form's own Essay field (last, so headings in an essay cannot end it early) wins for hand-filled forms; an update with no essay keeps the published one; one past 60,000 characters is refused. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L8hKpV2hiVoJMZ9f6X4Zwu --- .claude/skills/library/SKILL.md | 16 +- css/library.css | 53 ++ js/library-ui.js | 251 +++++++- js/library/article.js | 565 ++++++++++++------ js/library/essay-check.js | 27 + js/library/essay.js | 27 +- js/library/submit.js | 28 +- .../.github/ISSUE_TEMPLATE/submit-machine.yml | 5 + library-template/CONTRIBUTING.md | 10 +- package-lock.json | 174 +++++- package.json | 6 + scripts/library/build.mjs | 2 +- scripts/library/issue-to-entry.mjs | 22 +- scripts/library/site/site.css | 25 + tests/essay.test.js | 105 +++- tests/library.test.js | 143 ++++- 16 files changed, 1179 insertions(+), 280 deletions(-) create mode 100644 js/library/essay-check.js diff --git a/.claude/skills/library/SKILL.md b/.claude/skills/library/SKILL.md index a3eead2..9d4be24 100644 --- a/.claude/skills/library/SKILL.md +++ b/.claude/skills/library/SKILL.md @@ -31,12 +31,15 @@ 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-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 +116,14 @@ 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. +- **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..adb72c0 100644 --- a/css/library.css +++ b/css/library.css @@ -557,6 +557,57 @@ .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; } +.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-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 +617,8 @@ .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); } } @media (max-width: 900px) { diff --git a/js/library-ui.js b/js/library-ui.js index fb0b9ed..d6e80e1 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,12 @@ function pageEntry(id) { // a footnote must not overwrite either. const ESSAY_ID = 'lib-essay-'; +const PREVIEW_ID = 'lib-md-'; + +// 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')); 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 +859,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 +954,7 @@ function essayClick(ev) { } function scrollToEssayId(id) { - if (!id.startsWith(ESSAY_ID)) return; + if (!id.startsWith(ESSAY_ID) && !id.startsWith(PREVIEW_ID)) return; document.getElementById?.(id)?.scrollIntoView?.({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'start' }); } @@ -1518,6 +1529,230 @@ 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 panes = h('div', { class: `lib-md-panes is-${mode}` }, input, preview); + 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; + 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); + }); + } + + 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), + 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.' })); +} + /** * 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 +1762,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 +1815,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 +1860,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..de518b3 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,349 @@ function parseArgs(s) { return out; } -// ── Blocks ──────────────────────────────────────────────────────── +// ── Raw HTML: a short list of bare tags ─────────────────────────── + +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('|<\/?[A-Za-z][^>]*>/g; const bareTag = t => { const m = /^<\/?([A-Za-z][A-Za-z0-9]*)\s*\/?>$/.exec(t); @@ -221,12 +221,13 @@ function figurePlugin(md) { const { name, args, caption } = t[i].meta; const f = env.ctx.figure(name, args); if (!f) { env.warnings.push(`::: ${name} is not a figure this machine can draw.`); return ''; } + if (f.plain) return f.html; // not a figure of the essay's: unnumbered, uncaptioned const n = ++env.figures; return `
${f.html}${caption ? `
Figure ${n}. ${inlineIn(md, caption, env)}
` : ''}
\n`; }; } -const CALLOUT_ALIAS = { summary: 'abstract', tldr: 'abstract', hint: 'tip', important: 'tip', check: 'success', done: 'success', help: 'question', faq: 'question', caution: 'warning', attention: 'warning', fail: 'failure', missing: 'failure', error: 'danger', cite: 'quote' }; +export const CALLOUT_ALIAS = { summary: 'abstract', tldr: 'abstract', hint: 'tip', important: 'tip', check: 'success', done: 'success', help: 'question', faq: 'question', caution: 'warning', attention: 'warning', fail: 'failure', missing: 'failure', error: 'danger', cite: 'quote' }; /** `> [!type] Title`, Obsidian's callouts; `-` folds one closed, `+` open. */ function calloutPlugin(md) { @@ -358,6 +359,11 @@ function linkPolicyPlugin(md) { /** How the rest is drawn: the website's classes, and raw HTML through the list above. */ function rendererPlugin(md) { const rules = md.renderer.rules; + // A dollar sign that is not math — "$5 and $10", or a written \$ — gets an + // element of its own. KaTeX's auto-render, which typesets the page after it + // is drawn, matches delimiters only within one run of text, so a price + // stays a price instead of becoming "5 and " in italics. + rules.text = (t, i) => esc(t[i].content).replace(/\$/g, '$'); const warnHtml = (html, env) => { const ok = safeHtml(html); if (ok !== null) return ok; @@ -423,12 +429,14 @@ function markdown() { * figures, notes) — in the app the essay shares a * document with everything else, and "contents" is * not a safe id to hand out. + * anchorPrefix what a `#heading` link is given, so it reaches the + * heading it names (the idPrefix unless told otherwise). * newTab open http(s) links in a new tab, as the app does. * * What cannot be answered is kept visible and reported in `warnings`, which * the build prints: a missing fact reads as [[steps?]], never as a blank. */ -export function renderArticle(md, { fact = () => null, figure = () => null, link = () => null, idPrefix = '', newTab = false } = {}) { +export function renderArticle(md, { fact = () => null, figure = () => null, link = () => null, idPrefix = '', anchorPrefix = idPrefix, newTab = false } = {}) { const src = stripFrontMatter(md).slice(0, ARTICLE_MAX_CHARS); const warnings = []; const ctx = { @@ -443,7 +451,9 @@ export function renderArticle(md, { fact = () => null, figure = () => null, link if (!url) warnings.push(`Link "${h}" names nothing in the library.`); return url; } - if (/^(https?:\/\/|mailto:|#)/i.test(h)) return h; + // A #heading means this essay's heading, which carries the prefix. + if (h.startsWith('#')) return `#${anchorPrefix}${h.slice(1)}`; + if (/^(https?:\/\/|mailto:)/i.test(h)) return h; warnings.push(`Link "${h}" is neither http(s), mailto, # nor lib:.`); return null; }, @@ -453,8 +463,10 @@ export function renderArticle(md, { fact = () => null, figure = () => null, link const p = markdown(); const html = balanceHtml(p.render(src, env)); // A footnote cited and never written is left as the text [^x]; say so. - const defined = new Set([...src.matchAll(/^\[\^([^\]\s]+)\]:/gm)].map(m => m[1])); - for (const id of new Set([...src.matchAll(/\[\^([^\]\s]+)\](?!:)/g)].map(m => m[1]))) { + // Code is left out of the search: an essay may show the syntax in `[^x]`. + const prose = src.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1[ \t]*$/gm, '').replace(/(`+)[^`]*?\1/g, ''); + const defined = new Set([...prose.matchAll(/^\[\^([^\]\s]+)\]:/gm)].map(m => m[1])); + for (const id of new Set([...prose.matchAll(/\[\^([^\]\s]+)\](?!:)/g)].map(m => m[1]))) { if (!defined.has(id)) warnings.push(`Footnote [^${id}] has no text.`); } // A heading's own html carries its footnote markers; the contents list does not want them. diff --git a/js/library/essay-guide.js b/js/library/essay-guide.js new file mode 100644 index 0000000..c4c611c --- /dev/null +++ b/js/library/essay-guide.js @@ -0,0 +1,76 @@ +// ══════════════════════════════════════════════════════════════════ +// THE GUIDE TO WRITING AN ESSAY +// ══════════════════════════════════════════════════════════════════ +// essay-guide.md is the one text; this renders it. It is written in the essay +// format itself, and every ```example block in it is shown twice — the source, +// and what the real renderer (article.js) makes of it, with a real machine's +// facts and figures — so the guide cannot describe a feature the renderer does +// not have without its own example showing the gap. The website builds it +// into /writing/ (site.mjs); the app shows it beside the essay editor. +// +// tests/essay.test.js holds it to the code: it renders with no warnings, and +// it names every fact, figure, callout kind and HTML tag the renderer accepts. + +import { renderArticle } from './article.js'; +import { essayFacts, essayLinkTarget } from './essay.js'; + +/** + * The machine the examples are about: BB(2), the library's own entry when the + * index has it — so an example's facts and links are the listing's — and + * otherwise these numbers, which are that entry's. + */ +export const GUIDE_SAMPLE_ID = 'turing/busy-beaver/bb2'; +export const GUIDE_SAMPLE = { + id: GUIDE_SAMPLE_ID, title: 'BB(2) champion', category: 'tm', machine: 'ITM', standard: '1RB1LB_1LA1RZ', + stats: { states: 3, transitions: 4 }, behaviour: { verdict: 'halts', steps: 6, ones: 4, cells: 4 } +}; + +/** The index the guide's examples resolve against: the real one, with the sample in it for sure. */ +export function guideIndex(index) { + const entries = index?.entries || []; + return entries.some(e => e.id === GUIDE_SAMPLE_ID) + ? index + : { ...(index || {}), entries: [...entries, GUIDE_SAMPLE], collections: index?.collections || [] }; +} + +export function guideSample(index) { + return guideIndex(index).entries.find(e => e.id === GUIDE_SAMPLE_ID); +} + +const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + +/** Every ```example (or ````example, for one that holds a fence) block, in order. */ +const EXAMPLE_RE = /^(`{3,})example[ \t]*\n([\s\S]*?)\n\1[ \t]*$/gm; + +/** + * The guide → { html, toc, warnings, examples }. `ctx` is what the page's + * essays get — `figure` and `link` — and the facts are the sample's. Each + * example is rendered on its own, with ids of its own, and shown beside its + * source; the rest is rendered as one essay, contents list and all. + */ +export function renderGuide(md, { index = null, figure = () => null, link = null, idPrefix = '', newTab = false } = {}) { + const idx = guideIndex(index); + const sample = guideSample(index); + const fact = essayFacts(idx, sample); + const linkTo = link || (id => (essayLinkTarget(idx, id) ? `#${id}` : null)); + const sources = []; + const withSlots = String(md || '').replace(EXAMPLE_RE, (_, _fence, body) => { + sources.push(body); + return `::: example ${sources.length - 1}\n:::`; + }); + const warnings = []; + const guideFigure = (name, args) => { + if (name !== 'example') return figure(name, args); + const n = Number(args._[0]); + const src = sources[n]; + // An example's ids are its own; its #links reach the guide's headings. + const out = renderArticle(src, { fact, figure, link: linkTo, idPrefix: `${idPrefix}ex${n}-`, anchorPrefix: idPrefix, newTab }); + warnings.push(...out.warnings.map(w => `Example ${n + 1}: ${w}`)); + return { + plain: true, + html: `
${esc(src)}
${out.html}
\n` + }; + }; + const art = renderArticle(withSlots, { fact, figure: guideFigure, link: linkTo, idPrefix, newTab }); + return { html: art.html, toc: art.toc, warnings: [...art.warnings, ...warnings], examples: sources.length }; +} diff --git a/js/library/essay-guide.md b/js/library/essay-guide.md new file mode 100644 index 0000000..88e022e --- /dev/null +++ b/js/library/essay-guide.md @@ -0,0 +1,224 @@ +An essay is the long form of a machine's page: what it does, why it is interesting, where it comes from, and what to look at while it runs. It is Markdown, it sits beside the machine it is about, and the library fills in what it can answer better than you can — the numbers, the figures, the links. This page is everything an essay can hold, each example shown with what it becomes. + +## Where an essay lives + +An essay is a Markdown file beside its machine — `machines/turing/busy-beaver/bb5.md` beside `bb5.automaton` — or beside a collection, as `collections/busy-beavers.md` beside `busy-beavers.json`. It is its own file, so editing the prose never changes the machine, its hash or its badges. + +On the machine's page it reads after the showcase — the figure, the definition, *Try it* — with a contents list in the margin once it has three or more sections. It replaces the short *Write-up*, which is shown only when there is no essay. + +There are two ways to send one. + +- **From the app.** *More ▸ Library ▸ Submit a machine* has an essay editor. Type, or press **Open .md file…** — or drop a file on the editor — to use one you have already written, in Obsidian or anywhere else. **Save as .md** writes it back out. The editor keeps a draft for each machine as you type, and **Undo** brings back a draft that opening a file replaced. Sending an update of your own entry starts from the essay it already has. +- **By pull request.** Add the `.md` file beside your machine in the library repository. Only a machine's author, or a maintainer, can add or change its essay. + +In the editor, **Write**, **Split** and **Preview** choose the layout; Split puts the Markdown beside the page it makes, scrolled together. Ctrl E (⌘ E on a Mac) switches between writing and reading. The preview is the real page, drawn with your machine's own numbers and figures, and anything the library could not answer is listed under it — the same list the pull request's check will post. + +## Text + +Paragraphs are separated by a blank line; a single line break inside a paragraph is just a space. End a line with two spaces or a backslash to break it. + +```example +Emphasis is *italic* or _italic_, **bold**, and ***both***. +~~Struck through~~, ==highlighted==, H~2~O and x^2^. +Inline `code` keeps its characters exactly: `a*b*c`. +"Quotes" -- dashes... and (c) are set properly. +``` + +## Headings + +Start a line with `#` through `######`. The page's own title is the machine's name, so your shallowest heading becomes the page's second level: an essay written with `#` sections and one written with `##` read the same. Every heading gets a link target, and the level-two headings make the contents list. + +```example +## Why it halts + +### The last stage +``` + +## Lists + +```example +- A bullet +- Another, with a nested list + - inside it + 1. or numbered + +3. A numbered list can start anywhere +4. and counts on + +- [x] A task that is done +- [ ] and one that is not +``` + +## Quotes and rules + +```example +> A quotation, set apart from the text. + +--- + +Three dashes on a line of their own draw a rule. +``` + +## Links and images + +A link can go to a web page, an e-mail address, a heading in the essay, or another entry or collection in the library. Library links are checked when the essay is built: a link to something that is not in the library is reported, and shown as plain text. + +```example +[bbchallenge.org](https://bbchallenge.org), or just https://bbchallenge.org. +The champion of [two states](lib:turing/busy-beaver/bb2), +or Obsidian's way: [[turing/busy-beaver/bb2]] or [[turing/busy-beaver/bb2|with your own words]]. +A link to [a section](#links-and-images) of this essay. +``` + +An image is `![what it shows](https://…)`. Its address must be `https://` — an essay cannot load an image over a plain connection or from a file on your computer — and the text in brackets is what is shown if it cannot be loaded, and what a screen reader says. A link may go to `http(s)://`, `mailto:`, a `#heading` or `lib:`; any other kind of link is shown as its text. + +## Tables + +A row of dashes under the first row makes a table. A colon on the right of the dashes aligns that column right — the way to set numbers — and colons on both sides centre it. + +```example +| Stage | Ones | mod 3 | +|:--|--:|:-:| +| 1 | 6 | 0 | +| 14 | 12,284 | 2 | +``` + +## Code + +Fence code with three backticks, and name its language if you like. Four spaces of indentation also make a block. + +````example +```js +const halts = steps < budget; +``` +```` + +## Footnotes + +A footnote is cited with `[^name]` and written anywhere with `[^name]: …`. Notes are numbered in the order they are cited and collected at the end of the essay, each with a way back to where it was cited. A note cited but never written is reported. + +```example +Radó posed the game in 1962.[^rado] + +[^rado]: T. Radó, “On non-computable functions”, *Bell System Technical Journal* 41 (1962). +``` + +## Definition lists + +```example +Busy beaver +: The halting machine with the most steps for its size. + +Σ(n) +: The most 1s any halting n-state machine leaves. +``` + +## Callouts + +A quotation that starts with `[!kind]` is a callout, the way Obsidian writes them. The rest of that first line is its title; without one, the kind is the title. A `-` after the kind folds it closed, a `+` folds it open. + +```example +> [!tip] Try this +> Run it for 90 steps and watch the head. + +> [!warning]- The proof is long +> It took years, and a proof assistant, to finish. +``` + +The kinds are `note`, `info`, `todo`, `abstract` (also `summary`, `tldr`), `tip` (also `hint`, `important`), `success` (also `check`, `done`), `question` (also `help`, `faq`), `warning` (also `caution`, `attention`), `failure` (also `fail`, `missing`), `danger` (also `error`), `bug`, `example` and `quote` (also `cite`). Each has its colour; any other kind is drawn as a plain one. + +## Mathematics + +Mathematics is written in LaTeX and typeset by KaTeX: `$…$` or `\(…\)` in a sentence, `$$…$$` or `\[…\]` displayed. Nothing inside it is read as Markdown, so an underscore is a subscript and never emphasis. + +```example +The map is $g(3k) = 5k + 6$, and it stops on $3k + 2$: + +$$ g(3k+1) = 5k + 9 $$ +``` + +A dollar sign followed by a space, or a closing one followed by a digit, is a dollar sign: "$5 and $10" stays prices. To be sure, write `\$` for a dollar sign. + +## Facts the library fills in + +Write `{{steps}}` rather than typing a number, and the library puts in the number it counted when it ran your machine — the same analysis that earns the badges, so the essay cannot drift from the machine. A fact is set apart from your words, and hovering it says where it came from. Name another entry to ask about that one instead: `{{steps turing/busy-beaver/bb2}}`. + +```example +It halts after {{steps}} steps and leaves {{ones}} ones, having visited {{cells}} cells. +In the standard format, {{standard}} is a {{size}} machine with {{states}} states as drawn. +``` + +| Fact | What it is | When it is there | +| --- | --- | --- | +| `{{steps}}` | steps from a blank tape to the halt | a one-tape Turing machine that halts | +| `{{ones}}` | non-blank cells at the halt | a Turing machine whose run was settled | +| `{{cells}}` | cells visited before it halts | a Turing machine whose run was settled | +| `{{states}}` | states, as drawn | every machine | +| `{{transitions}}` | transitions, as drawn | every machine | +| `{{size}}` | states × symbols, read off its code | a machine in the standard format | +| `{{standard}}` | its code in the standard format | a machine in the standard format | +| `{{title}}` | its name in the library | every machine | + +A fact the library does not know is shown in red as `[[name?]]` and reported. In the editor's preview, a run longer than the preview has time for shows as `…` until the library builds it. + +## Figures from the machine + +A figure is drawn from the machine itself when the essay is shown. Open it with `:::` and the figure's name, write its caption on the lines that follow — in Markdown, facts and all — and close it with `:::` alone. Figures are numbered in order. + +```example +::: spacetime steps=6 h=160 +Its whole run: {{steps}} steps from a blank tape, time running down. +::: +``` + +| Figure | What it draws | Settings | +| --- | --- | --- | +| `::: spacetime` | the tape at each step, time running down; the head's path when every step fits | `steps=` how many (2,000 unless you say; at most 200,000), `h=` height (420; 160 to 900) | +| `::: growth` | non-blank cells against steps, over the whole run | `y=log` for a log scale on the count; `scale=linear` for plain steps (the default is a log scale) | +| `::: diagram` | the machine's state diagram | — | +| `::: machines` | cards for the entries you name, as a row | the ids, separated by spaces | + +`spacetime` and `growth` need a Turing machine the library can write in the standard format; `growth` runs it to its halt, up to 100 million steps. Add `id=` to any of the first three to draw another entry instead: `::: spacetime id=turing/busy-beaver/bb2`. A figure that cannot be drawn is left out and reported. + +## HTML + +A few bare tags are allowed where Markdown has no way to say something: ``, `
` and ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, `
`, `
`, `

`, `

`, `` and `
` — with no attributes. Anything else is shown as the text it is, and reported. An essay is published from someone's submission and read inside the app, so it cannot carry scripts, styles or anything that reaches outside the page; a tag left open is closed at the end of the essay, so it can never spill into the page around it. + +```example +Press Space to step. + +
+The long derivation + +Folded away until it is opened. + +
+``` + +## Front matter and limits + +A file that starts with YAML front matter — a block between `---` lines at the very top, as Obsidian and most site tools write it — is read without it; it is the file's bookkeeping, not prose. + +An essay can be up to 60,000 characters, and the editor opens files of up to 1 MB. A longer essay is refused when it is submitted. + +## What the check reports + +When an essay is built — in the editor's preview, and on the pull request — anything the library could not answer is listed rather than hidden: + +- a fact it does not know, or cannot know for this machine; +- a figure the machine cannot draw; +- a library link to something that is not in the library; +- a link or image address of a kind an essay cannot use; +- HTML that is not a bare allowed tag; +- a footnote cited but never written. + +These are warnings. The essay is still published, with the problem showing; fix them in an update. + +## Writing a good one + +- **Start with what is interesting.** The first paragraph is what most readers read. Say what the machine does that is worth a page. +- **Let the library say the numbers.** A fact cannot go out of date, and it tells the reader the number was checked. +- **Show, then explain.** A space-time diagram or a growth chart, captioned with what to look at, does more than a paragraph about the same thing. +- **Say where things come from.** Footnotes are for sources: the paper, the person, the year. +- **Put shared history on the collection.** Where several machines share a story — the busy beaver game, a textbook chapter — tell it once, in the collection's essay, and keep each machine's essay about that machine. +- **Check what you claim.** Run the machine. If a sentence says something happens at step 4,029, the space-time diagram should show it. diff --git a/library-template/CONTRIBUTING.md b/library-template/CONTRIBUTING.md index 335dd29..fd789e8 100644 --- a/library-template/CONTRIBUTING.md +++ b/library-template/CONTRIBUTING.md @@ -15,18 +15,15 @@ chosen; it keeps its checker. - **A collection:** open a pull request; maintainers review those by hand. - **An essay about your machine:** write it in the app — Submit a machine has - an essay editor with a live preview, and can open a `.md` you already wrote - (from Obsidian or anywhere) — or open a pull request adding a Markdown file - beside it: `machines/…/bb5.md` beside `bb5.automaton`. It is shown on the - machine's page in the app and on the website. Write `{{steps}}`, `{{ones}}`, - `{{states}}` or `{{steps }}` rather than typing a number: - the library fills in what it computed. `::: spacetime`, `::: growth`, - `::: diagram` and `::: machines …` draw figures from the machine, and - `[text](lib:)` or `[[id]]` links to another entry or collection. All of - Markdown works — tables, footnotes, task lists, `$…$` math, `> [!note]` - callouts; raw HTML is limited to a few bare tags such as ``. The pull request's - report lists anything the library could not answer. Only a machine's author - (or a maintainer) can add or change its essay. + an essay editor with a live preview and a guide beside it, and can open a + `.md` you already wrote (from Obsidian or anywhere) — or open a pull request + adding a Markdown file beside it: `machines/…/bb5.md` beside + `bb5.automaton`. All of Markdown works, and the library fills in facts + (`{{steps}}`), draws figures (`::: spacetime`) and checks links + (`[[id]]`). **Everything an essay can hold is on the library website's + *Writing an essay* page (`/writing/`), each example shown with what it + becomes.** Only a machine's author (or a maintainer) can add or change its + essay. Keep machines to what they need to be — the library takes files up to 1.5 MB and 2,000 states. diff --git a/scripts/library/site.mjs b/scripts/library/site.mjs index 62b019b..3abcc39 100644 --- a/scripts/library/site.mjs +++ b/scripts/library/site.mjs @@ -37,6 +37,7 @@ import { TEX_DELIMITERS, hasTex } from '../../js/tex.js'; import { readingMinutes, renderArticle } from '../../js/library/article.js'; import { drawStandardFigure } from '../../js/library/article-figures.js'; import { essayFacts, essayFigureSpec, essayLinkTarget } from '../../js/library/essay.js'; +import { guideSample, renderGuide } from '../../js/library/essay-guide.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -136,7 +137,7 @@ ${body} ${THEME_SCRIPT} @@ -388,6 +389,36 @@ function essayHtml(md, ctx, where) { `; } +/** + * How to write an essay: js/library/essay-guide.md, with each example shown + * beside what it becomes — drawn by this build's own renderer, facts and + * figures of a real entry included — so the page cannot promise what the + * renderer does not do. + */ +function writingPage(index, config, listings, guide) { + const depth = 1; + const self = guideSample(index); + const ctx = essayContext(self, index, depth, listings); + const art = renderGuide(guide, { index, figure: ctx.figure, link: ctx.link }); + for (const w of art.warnings) console.log(` writing guide: ${w}`); + const toc = art.toc.filter(t => t.level === 2); + const body = `
+
+

Contributing

+

Writing an essay

+

A machine's page can carry an essay — Markdown beside the machine, with the numbers and the figures filled in by the library. Everything an essay can hold, each example shown with what it becomes.

+ +
+
+
+ +
${art.html}
+
+
+
`; + return layout({ title: 'Writing an essay · AutomataStudio Library', description: 'How to write an essay for a machine in the AutomataStudio library: Markdown, facts the library fills in, figures drawn from the machine.', depth, body, canonical: 'writing/', config, nav: 'submit', math: true }); +} + /** The author's examples, decided by the machine when the site was built. */ function examplesHtml(listing) { const rows = listing?.examples || []; @@ -575,6 +606,9 @@ function submitPage(config) {

No app to hand? Fill in the form yourself and paste a share link or attach the .automaton file. Machines are published under CC BY 4.0 or CC0 — you choose — and credited to the GitHub account that submits them.

+
${sectionHead('An essay', '', 'essay')} +

A machine's page can carry an essay: Markdown written in the app's submit form — or opened from a .md file you already have — with facts and figures filled in by the library. Writing an essay shows everything one can hold.

+
${sectionHead('What the marks mean', '', 'badges')}
${Object.values(BADGES).map(b => `
${esc(b.label)}
${esc(b.say)}
`).join('')}
@@ -603,6 +637,8 @@ async function put(out, path, text) { * `listings` maps an entry id to what build.mjs read off its file. */ export async function writeSite(out, index, config, { assets = {}, listings = new Map(), collectionArticles = new Map() } = {}) { + const guide = await readFile(join(HERE, '../../js/library/essay-guide.md'), 'utf8'); + await put(out, 'writing/index.html', writingPage(index, config, listings, guide)); await put(out, 'index.html', homePage(index, config, listings)); for (const e of index.entries) await put(out, `m/${e.id}/index.html`, entryPage(e, index, config, listings.get(e.id), listings)); await put(out, 'collections/index.html', collectionsPage(index, config)); @@ -619,7 +655,7 @@ export async function writeSite(out, index, config, { assets = {}, listings = ne await mkdir(dirname(to), { recursive: true }); await copyFile(from, to); } - const urls = ['', 'collections/', 'submit/', + const urls = ['', 'collections/', 'submit/', 'writing/', ...index.entries.map(e => `m/${enc(e.id)}/`), ...index.collections.map(c => `c/${enc(c.id)}/`)]; await put(out, 'sitemap.xml', `\n\n${urls.map(u => ` ${esc(config.site + u)}`).join('\n')}\n\n`); } diff --git a/scripts/library/site/site.css b/scripts/library/site/site.css index c837bba..166eb8e 100644 --- a/scripts/library/site/site.css +++ b/scripts/library/site/site.css @@ -473,6 +473,14 @@ body.is-searching .discover { display: none; } .essay-notes li::marker { font-family: var(--mono); font-size: .8em; color: var(--text3); } .fn-back { color: var(--text3); text-decoration: none; font-size: .9em; } .essay-body .plates { margin: 0; } +/* The writing guide's examples: the Markdown, and what it becomes, side by side. */ +.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; } +.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; } +.essay-body .essay-example-out { padding: 12px 16px; min-width: 0; font-size: .96em; } +.essay-body .essay-example-out > :first-child { margin-top: 0; } +.essay-body .essay-example-out > :last-child { margin-bottom: 0; } +.essay-body .essay-example-out > p:first-child::first-letter { float: none; font-size: inherit; padding: 0; } +.essay-body .essay-example-out .essay-fig { margin: .4em 0; } .essay-body hr { border: 0; border-top: 1px solid var(--border2); margin: 2em 0; } .essay-body mark { background: color-mix(in srgb, var(--gold) 28%, transparent); color: inherit; padding: 0 2px; border-radius: 2px; } .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(--well); } @@ -543,6 +551,8 @@ body.is-searching .discover { display: none; } .essay-grid { grid-template-columns: minmax(0, 1fr); gap: 20px; } .essay-toc { position: static; } .essay-toc ol { flex-direction: row; flex-wrap: wrap; gap: 6px 18px; } + .essay-body .essay-example { grid-template-columns: minmax(0, 1fr); } + .essay-body .essay-example > .essay-example-src { border-right: 0; border-bottom: 1px solid var(--border); } } @media (max-width: 760px) { diff --git a/tests/essay.test.js b/tests/essay.test.js index bc77286..0fe5a79 100644 --- a/tests/essay.test.js +++ b/tests/essay.test.js @@ -1,8 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { balanceHtml, readingMinutes, renderArticle } from '../js/library/article.js'; +import { readFileSync } from 'node:fs'; +import { ARTICLE_MAX_CHARS, CALLOUT_ALIAS, HTML_OK, balanceHtml, readingMinutes, renderArticle } from '../js/library/article.js'; +import { GUIDE_SAMPLE, renderGuide } from '../js/library/essay-guide.js'; import { drawStandardFigure, growthSvg, runStandard, spacetimeSvg } from '../js/library/article-figures.js'; -import { SPACETIME_MAX_STEPS, essayFacts, essayFigureSpec, essayLinkTarget } from '../js/library/essay.js'; +import { ESSAY_FACTS, GROWTH_MAX_STEPS, SPACETIME_MAX_STEPS, essayFacts, essayFigureSpec, essayLinkTarget } from '../js/library/essay.js'; import { normalizeIndex } from '../js/library/index-model.js'; import { INDEX_FORMAT } from '../js/library/config.js'; @@ -96,7 +98,9 @@ test('code and math are set aside before emphasis, so neither is rewritten', () assert.match(html, /\$a_1 \* b_2\$/, 'math reaches KaTeX untouched'); assert.match(html, /\\\(x_i\\\)/); assert.match(html, /this<\/em>/); - assert.match(html, /\$5 and \$10 are prices/, 'a price is not math'); + assert.match(html, /\$<\/span>5 and \$<\/span>10 are prices/, + 'a price is not math — and each of its dollars is in an element of its own, so KaTeX\'s auto-render, which reads one run of text at a time, cannot pair them either'); + assert.match(render('A written \\$3.').html, /A written \$<\/span>3\./, '\\$ is a dollar sign'); assert.match(html, /
\$\$\n\\sum_\{i=1\}\^n i\n\$\$<\/div>/); }); @@ -126,12 +130,12 @@ test('footnotes are numbered in the order they are cited, written once, and a mi }); test('every id carries the prefix, and only web links open a new tab', () => { - const { html, toc } = render('## Start here\n\nSee[^1] [out](https://x.org) and [in](#lib-essay-start-here).\n\n::: f\n:::\n\n[^1]: n', { idPrefix: 'lib-essay-', newTab: true }); + const { html, toc } = render('## Start here\n\nSee[^1] [out](https://x.org) and [in](#start-here).\n\n::: f\n:::\n\n[^1]: n', { idPrefix: 'lib-essay-', newTab: true }); const ids = [...html.matchAll(/ id="([^"]+)"/g)].map(m => m[1]); assert.deepEqual(ids.sort(), ['lib-essay-figure-1', 'lib-essay-fn-1', 'lib-essay-fnref-1', 'lib-essay-start-here']); assert.equal(toc[0].id, 'lib-essay-start-here'); assert.match(html, /href="https:\/\/x\.org" class="textlink-inline" target="_blank" rel="noopener noreferrer"/); - assert.doesNotMatch(html, /href="#lib-essay-start-here"[^>]*target/); + assert.match(html, /href="#lib-essay-start-here" class="textlink-inline">in<\/a>/, 'an in-page link keeps its tab, and reaches the prefixed heading'); }); test('figures are numbered by the ones drawn; one that cannot be drawn is skipped and reported', () => { @@ -209,3 +213,47 @@ test('the index keeps an essay only where one could be', () => { assert.equal(raw(undefined).entries[0].essay, null); assert.equal(raw({ path: 'm/x.md', minutes: 1e9 }).entries[0].essay.minutes, 240); }); + +// ── The writing guide ───────────────────────────────────────────── +// essay-guide.md is the documentation for all of the above, and these hold it +// to the code: every example in it renders cleanly, and every fact, figure, +// callout kind, HTML tag and limit the code has is in it. + +const GUIDE = readFileSync(new URL('../js/library/essay-guide.md', import.meta.url), 'utf8'); +const guideFigure = (name, args) => { + const spec = essayFigureSpec(name, args, GUIDE_SAMPLE); + if (!spec || spec.kind === 'diagram') return null; + const svg = drawStandardFigure(spec.kind, spec.code, spec.opts); + return svg && { html: svg }; +}; + +test('the writing guide renders, examples and all, with nothing the library cannot answer', () => { + const g = renderGuide(GUIDE, { figure: guideFigure }); + assert.deepEqual(g.warnings, []); + assert.ok(g.examples >= 12, 'every kind of thing is shown, not only described'); + assert.match(g.html, /
]*>/);
+  assert.match(g.html, /It halts after ]*>6<\/span> steps/, 'an example\'s facts are the sample machine\'s');
+  assert.ok(g.toc.length >= 15);
+});
+
+test('the writing guide documents everything the renderer accepts, and its real limits', () => {
+  for (const name of Object.keys(ESSAY_FACTS)) assert.ok(GUIDE.includes(`{{${name}}}`), `the fact {{${name}}}`);
+  for (const kind of ['spacetime', 'growth', 'diagram', 'machines']) assert.ok(GUIDE.includes(`::: ${kind}`), `the figure ::: ${kind}`);
+  for (const kind of new Set([...Object.keys(CALLOUT_ALIAS), ...Object.values(CALLOUT_ALIAS), 'note', 'info', 'todo', 'bug', 'example'])) {
+    assert.ok(GUIDE.includes(`\`${kind}\``), `the callout kind ${kind}`);
+  }
+  for (const tag of HTML_OK) assert.ok(GUIDE.includes(`<${tag}>`), `the HTML tag <${tag}>`);
+  assert.ok(GUIDE.includes(ARTICLE_MAX_CHARS.toLocaleString('en-US')), 'the length limit');
+  assert.ok(GUIDE.includes(SPACETIME_MAX_STEPS.toLocaleString('en-US')), 'the space-time limit');
+  assert.ok(GUIDE.includes(`${GROWTH_MAX_STEPS / 1e6} million`), 'the growth chart\'s budget');
+  assert.equal(essayFigureSpec('spacetime', { _: [] }, GUIDE_SAMPLE).opts.steps, 2000);
+  assert.ok(/2,000 unless you say/.test(GUIDE) && /\(420; 160 to 900\)/.test(GUIDE), 'the defaults it states are the defaults');
+});
+
+test('a #heading link reaches the heading, whatever the ids are prefixed with', () => {
+  const { html } = renderArticle('## Far below\n\nSee [it](#far-below).', { idPrefix: 'lib-essay-' });
+  assert.match(html, /id="lib-essay-far-below"/);
+  assert.match(html, /href="#lib-essay-far-below"/);
+  const g = renderGuide('## Target\n\n```example\n[back up](#target)\n```', { idPrefix: 'g-' });
+  assert.match(g.html, /href="#g-target"/, 'a guide example\'s link reaches the guide\'s heading');
+});
diff --git a/tests/library.test.js b/tests/library.test.js
index 33cbc8c..04540e9 100644
--- a/tests/library.test.js
+++ b/tests/library.test.js
@@ -1506,6 +1506,42 @@ test('an author updating their entry starts from the essay it already has', asyn
   context.saveEssayDraft('w0|turing/busy-beaver/bb2', '');
 });
 
+test('the website has a page on writing an essay, built from the guide', async () => {
+  const b = await builtLibrary();
+  const out = await mkdtemp(join(tmpdir(), 'as-site-'));
+  await writeLibrary({ library: b.root, out }, b);
+  const page = await readFile(join(out, 'writing/index.html'), 'utf8');
+  assert.match(page, /

Writing an essay<\/h1>/); + assert.match(page, /
/); + assert.match(page, /It halts after ]*>6<\/span> steps/, 'the examples are BB(2)\'s, from the index'); + assert.match(page, /