diff --git a/.claude/skills/library/SKILL.md b/.claude/skills/library/SKILL.md index 766722b..a3eead2 100644 --- a/.claude/skills/library/SKILL.md +++ b/.claude/skills/library/SKILL.md @@ -31,6 +31,13 @@ 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-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. + 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. scripts/library/ build.mjs (index + site), site.mjs, seed.mjs, @@ -102,6 +109,19 @@ There is no server and the app holds no token. `submissionLink` pre-fills the re StateMate reaches the library two ways: `/library [words]`, and the `search_library` agent tool (synchronous, like every tool — the first call starts the download and says to ask again). +### Essays + +**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 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. +- **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). + ### Busy beavers and the standard format `writeStandardTM` in [js/interop/standard-tm.js](js/interop/standard-tm.js) is the inverse of `readStandardTM`, exact on its output: start state is A, the halt is a state that accepts and has no moves, and anything the notation cannot say (an S move, a wildcard, a non-digit symbol, an accepting working state) answers `null` rather than an approximation. `analyzeDocument` stores it as `facts.standard`, so every one-tape TM listing carries its code — Copy, "View on bbchallenge.org" (`bbchallengeUrl`, `&status=halt` when it halts) — and pasting a code into search finds the machine. **A TM's size (states × symbols) is read off that code** (`standardSize` in card-html.js), never from the drawn state count, which includes the halt state — guessing whether to subtract it was wrong whenever the halting analysis ran out of budget. A collection that is mostly TMs draws an "At a glance" table (size, steps, non-blank, code). `library.config.json`'s `featured` list puts collections first on the home page. diff --git a/css/library.css b/css/library.css index b18826f..f43dd79 100644 --- a/css/library.css +++ b/css/library.css @@ -488,11 +488,84 @@ .lib-inline-link { padding: 0; border: none; background: none; color: var(--accent); font: inherit; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } .lib-inline-link.is-update { color: var(--gold); } +/* ── essays ── + An entry's or a collection's long form (js/library/article.js), rendered + from the same Markdown as the website's page and set the same way: a + reading column in the serif, the contents in the margin, figures boxed and + numbered, notes at the foot. The markup carries the website's class names + (board, fact, essay-*), so every rule here is scoped to the essay's body + and inked from the app's theme. A {{fact}} — a number the library computed + — is set in the mono with a dotted rule under it, so the machine's answers + read apart from the author's words. */ +.lib-essay-meta { font-family: var(--mono); font-size: .66rem; color: var(--text3); } +.lib-essay-grid { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 48px; align-items: start; } +.lib-essay-grid.is-no-toc { grid-template-columns: minmax(0, 1fr); } +.lib-essay-toc { position: sticky; top: 12px; display: flex; flex-direction: column; gap: 10px; padding-top: 6px; } +.lib-essay-toc[hidden] { display: none; } +.lib-essay-toc ol { margin: 0; padding: 0; list-style: none; counter-reset: toc; display: flex; flex-direction: column; gap: 7px; } +.lib-essay-toc li { counter-increment: toc; display: grid; grid-template-columns: 22px 1fr; } +.lib-essay-toc li::before { content: counter(toc) "."; font-family: var(--mono); font-size: .64rem; color: var(--text3); padding-top: 3px; } +#v-library .lib-essay-toc-link { all: unset; cursor: pointer; font-family: var(--serif); font-size: .98rem; line-height: 1.3; color: var(--text2); } +#v-library .lib-essay-toc-link:hover { color: var(--accent); } +#v-library .lib-essay-toc-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; } +.lib-essay-body { max-width: 680px; font-size: 1.18rem; line-height: 1.66; } +.lib-essay-body > p:first-child::first-letter { float: left; font-size: 3.3em; line-height: .86; padding: 6px 8px 0 0; color: var(--text); } +.lib-essay-body h2, .lib-essay-body h3 { font-family: var(--serif); font-weight: 400; color: var(--text); } +/* Anything the contents or a note scrolls to clears the Library's sticky status bar. */ +.lib-essay-body [id] { scroll-margin-top: 56px; } +.lib-essay-body h2 { font-size: 1.55rem; margin: 1.6em 0 .5em; letter-spacing: -.005em; } +.lib-essay-body h2:first-child { margin-top: 0; } +.lib-essay-body h3 { font-size: 1.22rem; font-style: italic; margin: 1.3em 0 .4em; } +.lib-essay-body p { margin: 0 0 .9em; } +.lib-essay-body ul, .lib-essay-body ol { margin: 0 0 1em; padding-left: 1.3em; } +.lib-essay-body li { margin: .25em 0; } +.lib-essay-body blockquote { margin: 1.2em 0; padding: 2px 0 2px 20px; border-left: 2px solid var(--h, var(--accent)); color: var(--text2); font-style: italic; } +.lib-essay-body code { font-family: var(--mono); font-size: .76em; padding: 1px 5px; border-radius: 3px; background: var(--lib-well); border: 1px solid var(--border); overflow-wrap: anywhere; } +.lib-essay-body .textlink-inline { color: var(--text); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); } +.lib-essay-body .textlink-inline:hover { color: var(--accent); border-bottom-color: var(--accent); } +.lib-essay-body .essay-math { margin: 1em 0; overflow-x: auto; } +.lib-essay-body .essay-code { margin: 1em 0; padding: 12px 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--lib-well); overflow-x: auto; line-height: 1.5; } +.lib-essay-body .essay-code code { border: 0; padding: 0; background: none; font-size: .76rem; } +.lib-essay-body .fact { font-family: var(--mono); font-size: .78em; color: var(--text); border-bottom: 1px dotted var(--h, var(--accent)); cursor: help; white-space: nowrap; } +.lib-essay-body .fact.is-missing { color: var(--red); border-bottom-color: var(--red); } +/* In a table the cell already sets the face and size; a fact only adds its rule. + A header's small caps must not reach into its math: S(n) is not S(N). */ +.lib-essay-body .board .fact { font-size: inherit; } +.lib-essay-body .board th .katex { text-transform: none; letter-spacing: normal; font-size: 1.5em; } +.lib-essay-body .essay-fig { margin: 1.8em 0; display: flex; flex-direction: column; gap: 10px; } +.lib-essay-body .figcaption-text { font-family: var(--serif); font-style: italic; font-size: 1rem; line-height: 1.4; color: var(--text2); } +.lib-essay-body .fig-n { font-style: normal; font-family: var(--mono); font-size: .68rem; letter-spacing: .06em; color: var(--text3); text-transform: uppercase; margin-right: 4px; } +.lib-essay-fig { aspect-ratio: var(--fig-aspect, 16 / 10); border-radius: 8px; } +.lib-essay-fig > svg { display: block; width: 100%; height: 100%; } +.lib-essay-fig.is-chart { background-image: none; } +.lib-essay-fig.is-drawing { background-image: linear-gradient(100deg, transparent 30%, color-mix(in srgb, var(--text) 5%, transparent) 50%, transparent 70%); background-size: 200% 100%; animation: lib-essay-drawing 1.4s linear infinite; } +@keyframes lib-essay-drawing { from { background-position: 100% 0; } to { background-position: -100% 0; } } +.lib-essay-body .essay-st .rn-1 { fill: color-mix(in srgb, var(--text) 70%, transparent); } +.lib-essay-body .st-head { fill: none; stroke: var(--h, var(--accent)); stroke-width: 1.2; stroke-linejoin: round; opacity: .9; vector-effect: non-scaling-stroke; } +.lib-essay-body .gr-grid { stroke: var(--border); stroke-width: 1; } +.lib-essay-body .gr-axis { stroke: var(--text3); stroke-width: 1; } +.lib-essay-body .gr-tick { fill: var(--text3); font-family: var(--mono); font-size: 11px; } +.lib-essay-body .gr-line { fill: none; stroke: var(--h, var(--accent)); stroke-width: 1.8; stroke-linejoin: round; } +.lib-essay-body .gr-end { fill: var(--h, var(--accent)); stroke: var(--lib-well); stroke-width: 2; } +.lib-essay-body .table-wrap { overflow-x: auto; margin: 1.2em 0; } +.lib-essay-body .board { width: 100%; border-collapse: collapse; font-size: .84rem; font-variant-numeric: tabular-nums; } +.lib-essay-body .board th, .lib-essay-body .board td { padding: 8px 12px 8px 0; border-bottom: 1px solid var(--border); text-align: left; color: var(--text); font-family: var(--sans); } +.lib-essay-body .board th { font-family: var(--mono); font-size: .6rem; font-weight: 400; letter-spacing: .14em; text-transform: uppercase; color: var(--text3); } +.lib-essay-body .board .num { text-align: right; font-family: var(--mono); font-size: .78rem; } +.lib-essay-body .fn-ref { font-family: var(--mono); font-size: .62em; line-height: 0; margin-left: 1px; } +.lib-essay-body .fn-ref a, .lib-essay-body .fn-back { color: var(--accent); text-decoration: none; } +.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; } + @media (max-width: 1100px) { .lib-mast.has-frontis { grid-template-columns: minmax(0, 1fr); } .lib-frontis { max-width: 620px; } .lib-families { grid-template-columns: repeat(3, minmax(0, 1fr)); } .lib-entry-body { grid-template-columns: minmax(0, 1fr); } + .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; } } @media (max-width: 900px) { @@ -508,6 +581,6 @@ } @media (prefers-reduced-motion: reduce) { - .lib-shimmer { animation: none; } + .lib-shimmer, .lib-essay-fig.is-drawing { animation: none; } .lib-fig, .lib-live .sk-e, .lib-live .sk-ah, .lib-live .sk-n { transition: none; } } diff --git a/js/library-ui.js b/js/library-ui.js index e78d837..fb0b9ed 100644 --- a/js/library-ui.js +++ b/js/library-ui.js @@ -48,8 +48,11 @@ 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 { - cachedLibrary, fetchEntryText, listMyLibrary, loadLibrary, noteRecentlyOpened, recentLibraryIds, removeFromMyLibrary, + cachedLibrary, fetchEntryText, fetchEssayText, listMyLibrary, loadLibrary, noteRecentlyOpened, recentLibraryIds, removeFromMyLibrary, saveToMyLibrary, sourceIsOutdated, stampSource, updatesFor } from './library/client.js'; import { @@ -759,6 +762,7 @@ function pageEntry(id) { if (LIBRARY_LICENSES[e.license]) byline.push(e.license.replace(/-/g, ' ').replace(' 4.0', ' 4.0').replace('CC BY', 'CC BY')); if (e.added) byline.push(`added ${relTime(e.added)}`); if (wasUpdated(e)) byline.push(`updated ${relTime(e.updated)}`); + if (e.essay) byline.push(button(`${e.essay.minutes} min essay ↓`, () => page.querySelector?.('.lib-essay')?.scrollIntoView?.({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'start' }), 'lib-textbtn', { title: 'Read the essay about this machine' })); page.append(h('div', { class: 'lib-entry-head' }, h('p', { class: 'lib-kicker' }, h('i', { class: 'lib-dot', 'aria-hidden': 'true' }), [machineLabel(e.machine), e.languageClass].filter(Boolean).join(' · ')), @@ -791,6 +795,8 @@ function pageEntry(id) { aside.append(codeSec); page.append(h('div', { class: 'lib-entry-body' }, h('div', { class: 'lib-entry-main' }, stage.node, tryIt(e, stage)), aside)); + // An essay reads straight after the showcase it is about. + if (e.essay) page.append(essaySection(e.essay, e)); // A Turing machine is known by what it does from a blank tape. if (e.behaviour || e.standard) page.append(behaviourSection(e)); const notes = h('div', { class: 'lib-prose' }); @@ -808,7 +814,8 @@ function pageEntry(id) { math.textContent = latex; triggerMath(math); } catch { math.textContent = ''; } - const text = paragraphs(d.doc?.meta?.library?.readme); + // An essay is the long form of the notes, and says more than they do. + const text = e.essay ? null : paragraphs(d.doc?.meta?.library?.readme); // Typeset where the paragraphs end up: KaTeX may still be loading, and a // retry aimed at the emptied wrapper would typeset nothing. if (text) { notes.append(...Array.from(text.children)); typeset(notes); notes.closest('section')?.removeAttribute('hidden'); } @@ -818,6 +825,128 @@ function pageEntry(id) { return page; } +// ── Essays ──────────────────────────────────────────────────────── +// +// An entry's or a collection's long form (js/library/article.js): fetched and +// checked against the index like the machine's own file, rendered by the same +// code the website is built with, and asked the same questions — a {{fact}} +// is read off the index (essay.js), so the numbers in the prose are the +// numbers the badges were earned by. What the app does differently is only +// what a page inside an app has to: figures drawn when read, in a worker +// (essay-draw.js), rather than when built; ids prefixed, since the essay +// shares a document with the whole app; and in-page links handled here, +// because the address bar's hash carries share links and library routes, and +// a footnote must not overwrite either. + +const ESSAY_ID = 'lib-essay-'; + +function essaySection(essay, self) { + const body = h('div', { class: 'lib-essay-body lib-prose' }, h('p', { class: 'lib-muted', text: 'Fetching the essay…' })); + const toc = h('nav', { class: 'lib-essay-toc', 'aria-label': 'Contents', hidden: true }); + const columns = h('div', { class: 'lib-essay-grid is-no-toc' }, toc, body); + const sec = h('section', { class: 'lib-shelf lib-essay' }, + 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 => { + const slots = []; + const art = renderArticle(md, { ...essayContext(idx, self, slots), idPrefix: ESSAY_ID, newTab: true }); + body.innerHTML = art.html; + fillEssaySlots(body, slots); + typeset(body); + const heads = art.toc.filter(t => t.level === 2); + if (heads.length > 2) { + toc.append(h('p', { class: 'lib-aside-title', text: 'Contents' }), + h('ol', {}, heads.map(t => { + const item = h('button', { type: 'button', class: 'lib-essay-toc-link', on: { click: () => scrollToEssayId(t.id) } }); + item.innerHTML = t.html; + return h('li', {}, item); + }))); + toc.removeAttribute('hidden'); + columns.classList.remove('is-no-toc'); + } + }, () => { + body.textContent = ''; + body.append(h('p', { class: 'lib-muted', text: 'The essay could not be fetched. It is read from the library, like the machine’s file; try again when you are online.' })); + }); + return sec; +} + +/** + * What an essay may ask for, answered for the app. A figure the app draws + * with nodes rather than markup — a row of plates, or a run still being + * drawn in the worker — goes in as an empty slot and is filled after the + * essay's markup is in place. + */ +function essayContext(idx, self, slots) { + const byId = new Map(idx.entries.map(e => [e.id, e])); + const slot = (fill, attrs = '') => { + slots.push(fill); + return `
`; + }; + const figure = (name, args) => { + if (name === 'machines') { + const list = args._.map(id => byId.get(id)).filter(Boolean); + return list.length ? { html: slot(() => grid(list)), wide: true } : null; + } + const e = args.id ? byId.get(args.id) : self; + const spec = essayFigureSpec(name, args, e); + if (!spec) return null; + const fam = e.category || 'special'; + if (spec.kind === 'diagram') { + const sk = unpackSketch(e.sketch); + if (!sk) return null; + const H = Math.round(640 / sketchAspect(sk)); + return { html: `
${drawSketch(sk, { w: 640, h: H, label: `Diagram of ${e.title}` })}
` }; + } + const aspect = spec.kind === 'growth' ? '680 / 300' : spec.aspect; + const cls = spec.kind === 'growth' ? 'is-chart' : 'is-run'; + return { + html: `
${slot(node => { + drawEssayFigure(spec.kind, spec.code, spec.opts).then(svg => { + node.parentNode?.classList?.remove('is-drawing'); + if (svg) node.outerHTML = svg; + else node.replaceWith(h('span', { class: 'lib-muted', text: 'This figure could not be drawn.' })); + }); + return null; + }, ' aria-label="Drawing the figure from the machine…"')}
` + }; + }; + return { + fact: essayFacts(idx, self), + figure, + link: id => { + const to = essayLinkTarget(idx, id); + return to ? `#${to.kind === 'entry' ? 'library' : 'collection'}=${id}` : null; + } + }; +} + +/** Put the nodes the markup left room for into their slots. */ +function fillEssaySlots(body, slots) { + for (const node of Array.from(body.querySelectorAll?.('[data-essay-slot]') || [])) { + const made = slots[Number(node.dataset.essaySlot)]?.(node); + if (made) node.replaceWith(made); + } +} + +/** A link inside an essay: to another listing, to a heading or a note, or out. */ +function essayClick(ev) { + const a = ev.target?.closest?.('a'); + const href = a?.getAttribute?.('href') || ''; + if (!href.startsWith('#')) return; // out of the app: the link opens its own tab + ev.preventDefault(); + const route = parseLibraryHash(href); + if (route?.action === 'show') go('entry', route.id); + else if (route?.action === 'collection') go('collection', route.id); + else scrollToEssayId(href.slice(1)); +} + +function scrollToEssayId(id) { + if (!id.startsWith(ESSAY_ID)) return; + document.getElementById?.(id)?.scrollIntoView?.({ behavior: reducedMotion() ? 'auto' : 'smooth', block: 'start' }); +} + /** What the library checked, in sentences, each with its detail. */ function verifiedList(e) { if (!e.badges.length) return null; @@ -1269,6 +1398,7 @@ function pageCollection(id) { h('p', { class: 'lib-kicker', text: `Collection · ${entries.length} machine${entries.length === 1 ? '' : 's'}${c.curator ? ` · curated by @${c.curator}` : ''}` }), h('h2', { class: 'lib-display', text: c.title }), c.blurb ? texLine('p', 'lib-lede', c.blurb) : null, + c.essay ? h('p', { class: 'lib-byline', text: `${c.essay.minutes} min essay` }) : null, h('div', { class: 'lib-actions' }, button('Save all offline', async () => { let n = 0; @@ -1280,6 +1410,7 @@ function pageCollection(id) { renderPage(); }, 'btn-g'), button('Copy link', () => exportCopyText(webAppLink({ action: 'collection', id: c.id }), 'Link to this collection copied'), 'btn-g'))), + c.essay ? essaySection(c.essay, null) : null, behaviourTable(entries), h('section', { class: 'lib-shelf' }, sectionHead('The machines'), grid(entries))); } diff --git a/js/library/article-figures.js b/js/library/article-figures.js new file mode 100644 index 0000000..80b166d --- /dev/null +++ b/js/library/article-figures.js @@ -0,0 +1,180 @@ +// ══════════════════════════════════════════════════════════════════ +// AN ESSAY'S FIGURES, DRAWN FROM THE MACHINE +// ══════════════════════════════════════════════════════════════════ +// What `::: spacetime` and `::: growth` draw in an article (article.js). Each +// is computed from the machine's standard code at build time — the essay says +// what to look at, the machine supplies the picture — and returned as an SVG +// string inked by classes, like every other figure in the library +// (sketch.js), so it is right in the light scheme and the dark one. +// +// Import-free. A run is bounded by `maxSteps`: BB(5) is 47 million steps and +// takes about half a second here; a machine that has not halted by the budget +// is drawn up to it, and the caption's numbers come from the library's own +// analysis rather than from this loop. + +function tableOf(code) { + const groups = String(code || '').split('_').filter(Boolean); + if (!groups.length || groups[0].length % 3) return null; + const K = groups[0].length / 3; + const table = []; + for (const g of groups) { + if (g.length !== K * 3) return null; + const row = []; + for (let s = 0; s < K; s++) { + const m = /^(\d)([LR])([A-Z])$/.exec(g.slice(s * 3, s * 3 + 3)); + if (!m) { row.push(null); continue; } + const next = m[3].charCodeAt(0) - 65; + row.push({ write: Number(m[1]), move: m[2] === 'L' ? -1 : 1, next: next < groups.length ? next : -1 }); + } + table.push(row); + } + return table; +} + +/** + * Run a standard-format machine from a blank tape, calling `see(t, tape, head, + * ones)` before step t for every t that `want(t)` accepts. Returns the run's + * end: { steps, ones, halted, lo, hi } with lo/hi the cells visited. + */ +export function runStandard(code, { maxSteps = 1e8, want = () => false, see = () => {} } = {}) { + const table = tableOf(code); + if (!table) return null; + const N = 1 << 20; + const tape = new Uint8Array(N); + let head = N >> 1, state = 0, t = 0, ones = 0, lo = head, hi = head, halted = false; + while (t < maxSteps) { + if (want(t)) see(t, tape, head, ones); + const op = table[state]?.[tape[head]]; + if (!op) { halted = true; break; } + ones += (op.write ? 1 : 0) - (tape[head] ? 1 : 0); + tape[head] = op.write; + head += op.move; + t++; + if (head < lo) lo = head; + if (head > hi) hi = head; + if (op.next < 0) { halted = true; break; } + if (head <= 0 || head >= N - 1) break; + state = op.next; + } + see(t, tape, head, ones); + return { steps: t, ones, halted, lo: lo - (N >> 1), hi: hi - (N >> 1) }; +} + +const r1 = v => Math.round(v * 10) / 10; +const count = n => Number(n).toLocaleString('en-US'); + +/** + * The run's first `steps` steps as a space-time diagram: time runs down, one + * row per sampled step, a cell inked when it holds a non-blank symbol, and the + * head's path drawn over the top in the family's hue. + */ +export function spacetimeSvg(code, { steps = 2000, w = 680, h = 420 } = {}) { + const rows = []; + const every = Math.max(1, Math.ceil(steps / h)); + const end = runStandard(code, { + maxSteps: steps, + want: t => t % every === 0, + see: (t, tape, head) => { + const on = []; + let a = -1; + // Store the tape as runs of non-blank cells, relative to the start. + const mid = tape.length >> 1; + for (let x = mid - 4096; x <= mid + 4096; x++) { + const v = tape[x]; + if (v && a < 0) a = x; + if (!v && a >= 0) { on.push([a - mid, x - mid, tape[a]]); a = -1; } + } + rows.push({ t, head: head - mid, on }); + } + }); + if (!end) return null; + let lo = 0, hi = 0; + for (const r of rows) { lo = Math.min(lo, r.head); hi = Math.max(hi, r.head); for (const [a, b] of r.on) { lo = Math.min(lo, a); hi = Math.max(hi, b - 1); } } + const pad = 14, span = hi - lo + 1; + const cw = (w - 2 * pad) / span, rh = (h - 2 * pad) / rows.length; + const X = c => pad + (c - lo) * cw, Y = i => pad + i * rh; + const out = [``]; + rows.forEach((r, i) => { + for (const [a, b, v] of r.on) out.push(``); + }); + // The head's path, only when every step has its row: sampled, it would join + // positions many steps apart and draw a zigzag the machine never made. + if (every === 1) out.push(``); + out.push(''); + return out.join(''); +} + +/** + * Non-blank cells on the tape against time, over the whole run (or up to the + * budget), with time on a log scale by default — so a run that spends 64% of + * itself in its last stage still shows its first ones. + */ +export function growthSvg(code, { maxSteps = 1e8, w = 680, h = 300, scale = 'log', yScale = 'linear' } = {}) { + const pts = []; + const log = scale === 'log'; + // Sample points: log-spaced, dense enough that each stage's plateau is drawn. + const marks = new Set(); + if (log) for (let k = 0; k <= 2400; k++) marks.add(Math.floor(Math.pow(10, k / 300))); + let every = 0; + const end0 = log ? null : runStandard(code, { maxSteps }); + if (!log) every = Math.max(1, Math.floor((end0?.steps || maxSteps) / 1200)); + let peak = 0; + const end = runStandard(code, { + maxSteps, + want: t => (log ? marks.has(t) : t % every === 0), + see: (t, _tape, _h, ones) => { pts.push([t, ones]); if (ones > peak) peak = ones; } + }); + if (!end || pts.length < 2) return null; + const T = Math.max(end.steps, 10); + const ylog = yScale === 'log'; + const Ymax = ylog ? 10 ** Math.ceil(Math.log10(Math.max(peak, 10))) : niceCeil(Math.max(peak, end.ones, 1)); + const L = 64, R = 16, Tp = 14, B = 34; + const xOf = t => L + (log ? Math.log10(Math.max(t, 1)) / Math.log10(T) : t / T) * (w - L - R); + const yOf = v => h - B - (ylog ? Math.log10(Math.max(v, 1)) / Math.log10(Ymax) : v / Ymax) * (h - Tp - B); + const out = [``]; + // Axes: gridlines at powers of ten along time, four along cells. + const xt = log ? Array.from({ length: Math.floor(Math.log10(T)) + 1 }, (_, k) => 10 ** k) : niceTicks(T); + for (const t of xt) { + const x = r1(xOf(t)); + out.push(``); + out.push(`${log ? tenTo(t) : count(t)}`); + } + for (const v of ylog ? Array.from({ length: Math.log10(Ymax) + 1 }, (_, k) => 10 ** k) : niceTicks(Ymax)) { + const y = r1(yOf(v)); + out.push(``); + out.push(`${count(v)}`); + } + out.push(``); + // A step curve: the count holds until the next sample. + let d = ''; + pts.forEach(([t, v], i) => { const x = r1(xOf(t)), y = r1(yOf(v)); d += i ? `H${x}V${y}` : `M${x} ${y}`; }); + out.push(``); + if (end.halted) out.push(``); + out.push(''); + return out.join(''); +} + +function tenTo(t) { + const k = Math.round(Math.log10(t)); + const sup = String(k).split('').map(c => '⁰¹²³⁴⁵⁶⁷⁸⁹'[+c]).join(''); + return k === 0 ? '1' : k === 1 ? '10' : `10${sup}`; +} +function niceCeil(v) { + const p = 10 ** Math.floor(Math.log10(v)); + for (const m of [1, 1.25, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]) if (m * p >= v) return m * p; + return 10 * p; +} +function niceTicks(max) { + const p = 10 ** Math.floor(Math.log10(max / 4)); + const step = [1, 2, 2.5, 5, 10].map(m => m * p).find(x => max / x <= 6) || max / 4; + const out = []; + for (let v = 0; v <= max + 1e-9; v += step) out.push(v); + return out; +} + +/** A figure by kind — what the worker and the build both call. */ +export function drawStandardFigure(kind, code, opts = {}) { + if (kind === 'spacetime') return spacetimeSvg(code, opts); + if (kind === 'growth') return growthSvg(code, opts); + return null; +} diff --git a/js/library/article.js b/js/library/article.js new file mode 100644 index 0000000..9bcea7d --- /dev/null +++ b/js/library/article.js @@ -0,0 +1,266 @@ +// ══════════════════════════════════════════════════════════════════ +// ARTICLES — A MACHINE'S ESSAY +// ══════════════════════════════════════════════════════════════════ +// An entry's long-form write-up: a Markdown file beside the machine +// (machines/…/bb5.md beside bb5.automaton), or beside a collection +// (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: +// +// ## 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: +// +// {{steps}} {{ones}} {{steps turing/busy-beaver/bb4}} +// A fact the build computed — the same numbers as the badges. An essay +// quoting 47,176,870 cannot drift from the run that earned it. +// +// ::: spacetime steps=3000 +// Caption, in the same Markdown. +// ::: +// A figure drawn from the machine at build time. Numbered, captioned. +// +// 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. + +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'; + +/** `steps=3000 id=turing/x` → { steps: '3000', id: 'turing/x' }; a bare word is `_`. */ +function parseArgs(s) { + const out = { _: [] }; + for (const m of String(s || '').matchAll(/(\w+)=("[^"]*"|\S+)|(\S+)/g)) { + if (m[1]) out[m[1]] = m[2].replace(/^"|"$/g, ''); + else out._.push(m[3]); + } + return out; +} + +// ── Blocks ──────────────────────────────────────────────────────── + +/** + * Markdown → a list of blocks. Line-based and forgiving: anything it does not + * recognise is a paragraph, so a typo shows up as text rather than vanishing. + */ +export function parseArticle(md) { + const lines = String(md || '').slice(0, MAX_CHARS).replace(/\r\n?/g, '\n').split('\n'); + const blocks = []; + const notes = new Map(); + let i = 0; + const blank = l => !l.trim(); + const starts = l => /^(#{1,3}\s|>\s?|[-*]\s|\d+\.\s|:::|```|\$\$|\|)/.test(l.trim()) || /^\[\^[^\]]+\]:/.test(l); + while (i < lines.length) { + const line = lines[i]; + const t = line.trim(); + if (blank(line)) { i++; continue; } + let m; + if ((m = /^(#{1,3})\s+(.*)$/.exec(t))) { + // A page has one title and it is the entry's, so # is a section too. + blocks.push({ kind: 'heading', level: Math.max(2, m[1].length), text: m[2].trim() }); + i++; continue; + } + if ((m = /^\[\^([^\]]+)\]:\s*(.*)$/.exec(line))) { + const body = [m[2]]; + i++; + while (i < lines.length && /^\s{2,}\S/.test(lines[i])) body.push(lines[i++].trim()); + notes.set(m[1], body.join(' ')); + continue; + } + if (t.startsWith(':::')) { + const head = t.slice(3).trim(); + const sp = head.search(/\s/); + const name = (sp < 0 ? head : head.slice(0, sp)).toLowerCase(); + const args = parseArgs(sp < 0 ? '' : head.slice(sp)); + const cap = []; + i++; + while (i < lines.length && lines[i].trim() !== ':::') cap.push(lines[i++]); + i++; // the closing ::: + blocks.push({ kind: 'figure', name, args, caption: cap.join(' ').trim() }); + continue; + } + if (t.startsWith('```')) { + const body = []; + i++; + while (i < lines.length && !lines[i].trim().startsWith('```')) body.push(lines[i++]); + i++; + blocks.push({ kind: 'code', text: body.join('\n') }); + continue; + } + if (t.startsWith('$$')) { + const body = [t]; + if (!(t.length > 2 && t.endsWith('$$') && t !== '$$')) { + i++; + while (i < lines.length && !lines[i].trim().endsWith('$$')) body.push(lines[i++]); + if (i < lines.length) body.push(lines[i]); + } + i++; + blocks.push({ kind: 'math', text: body.join('\n') }); + continue; + } + if (t.startsWith('|') && i + 1 < lines.length && /^\|?\s*:?-{3,}/.test(lines[i + 1].trim())) { + const cells = l => l.trim().replace(/^\||\|$/g, '').split('|').map(c => c.trim()); + const head = cells(line); + const align = cells(lines[i + 1]).map(c => (c.endsWith(':') ? 'num' : '')); + const rows = []; + i += 2; + while (i < lines.length && lines[i].trim().startsWith('|')) rows.push(cells(lines[i++])); + blocks.push({ kind: 'table', head, align, rows }); + continue; + } + if (/^>\s?/.test(t)) { + const body = []; + while (i < lines.length && /^>\s?/.test(lines[i].trim())) body.push(lines[i++].trim().replace(/^>\s?/, '')); + blocks.push({ kind: 'quote', text: body.join(' ') }); + continue; + } + if ((m = /^([-*]|\d+\.)\s+/.exec(t))) { + const ordered = /\d/.test(m[1]); + const items = []; + while (i < lines.length && !blank(lines[i])) { + const l = lines[i].trim(); + const im = /^([-*]|\d+\.)\s+(.*)$/.exec(l); + if (im) items.push(im[2]); + else if (items.length) items[items.length - 1] += ' ' + l; + i++; + } + blocks.push({ kind: 'list', ordered, items }); + continue; + } + const body = []; + while (i < lines.length && !blank(lines[i]) && (!body.length || !starts(lines[i]))) body.push(lines[i++].trim()); + blocks.push({ kind: 'para', text: body.join(' ') }); + } + return { blocks, notes }; +} + +// ── Inline ──────────────────────────────────────────────────────── + +/** + * One line of Markdown → HTML. Code spans and math are set aside first, so a + * `*` inside `$a*b$` stays an asterisk for KaTeX, and everything is escaped + * before any tag is written. + */ +function inline(src, ctx) { + const kept = []; + const keep = html => `\u0000${kept.push(html) - 1}\u0000`; + let s = String(src || ''); + s = s.replace(/`([^`]+)`/g, (_, c) => keep(`${esc(c)}`)); + s = s.replace(/\$\$[\s\S]+?\$\$|\$[^$\n]+\$/g, m => keep(esc(m))); + s = s.replace(/\{\{\s*(\w+)(?:\s+([\w./-]+))?\s*\}\}/g, (_, name, ref) => keep(ctx.factHtml(name, ref))); + s = s.replace(/\[\^([^\]]+)\]/g, (_, id) => keep(ctx.noteRef(id))); + s = esc(s); + s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text, href) => { + const url = ctx.href(href.replace(/&/g, '&')); + if (!url) return text; + const away = ctx.newTab && /^https?:/.test(url) ? ' target="_blank" rel="noopener noreferrer"' : ''; + return `${text}`; + }); + s = s.replace(/\*\*([^*]+)\*\*/g, '$1'); + s = s.replace(/(^|[^\w*])\*([^*\s][^*]*?)\*(?=[^\w*]|$)/g, '$1$2'); + return s.replace(/\u0000(\d+)\u0000/g, (_, n) => kept[+n]); +} + +// ── Rendering ───────────────────────────────────────────────────── + +/** + * Markdown → { html, toc, figures, warnings }. + * + * fact(name, ref) → { value, say } | null — `value` is printed, `say` is + * the tooltip naming where it came from. + * figure(name, args) → { html, alt } | null — the drawing, or null for a + * figure this machine cannot draw. + * link(ref) → a URL for `lib:`, or null. + * idPrefix put before every id the essay writes (headings, + * figures, notes) — in the app the essay shares a + * document with everything else, and "contents" is + * not a safe id to hand out. + * 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 } = {}) { + const { blocks, notes } = parseArticle(md); + const pid = s => esc(idPrefix + s); + const warnings = []; + const noteOrder = []; + const ctx = { + newTab, + factHtml(name, ref) { + const f = fact(name, ref); + if (!f) { warnings.push(`{{${name}${ref ? ' ' + ref : ''}}} is not a fact the library knows.`); return `[[${esc(name)}?]]`; } + return `${esc(f.value)}`; + }, + noteRef(id) { + if (!notes.has(id)) { warnings.push(`Footnote [^${id}] has no text.`); return ''; } + let n = noteOrder.indexOf(id) + 1; + if (!n) n = noteOrder.push(id); + return `${n}`; + }, + href(h) { + if (/^lib:/.test(h)) { + const url = link(h.slice(4)); + if (!url) warnings.push(`Link "${h}" names nothing in the library.`); + return url; + } + if (/^(https?:\/\/|#)/.test(h)) return h; + warnings.push(`Link "${h}" is neither http(s) nor lib:.`); + return null; + } + }; + const toc = []; + const used = new Set(); + let figN = 0; + const out = []; + for (const b of blocks) { + if (b.kind === 'heading') { + let id = slugOf(b.text); + while (used.has(id)) id += '-'; + used.add(id); + const html = inline(b.text, ctx); + toc.push({ id: idPrefix + id, level: b.level, html }); + out.push(`${html}`); + } else if (b.kind === 'para') { + out.push(`

${inline(b.text, ctx)}

`); + } else if (b.kind === 'quote') { + out.push(`
${inline(b.text, ctx)}
`); + } else if (b.kind === 'list') { + const tag = b.ordered ? 'ol' : 'ul'; + out.push(`<${tag}>${b.items.map(x => `
  • ${inline(x, ctx)}
  • `).join('')}`); + } else if (b.kind === 'code') { + out.push(`
    ${esc(b.text)}
    `); + } else if (b.kind === 'math') { + out.push(`
    ${esc(b.text)}
    `); + } else if (b.kind === 'table') { + const cls = j => (b.align[j] ? ' class="num"' : ''); + out.push(`
    ${b.head.map((c, j) => `${inline(c, ctx)}`).join('')}${b.rows.map(r => `${r.map((c, j) => `${inline(c, ctx)}`).join('')}`).join('')}
    `); + } else if (b.kind === 'figure') { + const f = figure(b.name, b.args); + if (!f) { warnings.push(`::: ${b.name} is not a figure this machine can draw.`); continue; } + figN++; + out.push(`
    ${f.html}${b.caption ? `
    Figure ${figN}. ${inline(b.caption, ctx)}
    ` : ''}
    `); + } + } + if (noteOrder.length) { + out.push(`
      ${noteOrder.map(id => `
    1. ${inline(notes.get(id), ctx)} ↩
    2. `).join('')}
    `); + } + // A heading's own html carries its footnote markers; the contents list does not want them. + return { html: out.join('\n'), toc: toc.map(x => ({ ...x, html: x.html.replace(//g, '') })), figures: figN, warnings }; +} + +/** Roughly how long the essay takes to read: words at 230 a minute. */ +export function readingMinutes(md) { + const words = String(md || '').replace(/:::[\s\S]*?:::/g, ' ').replace(/\$\$[\s\S]*?\$\$/g, ' ').split(/\s+/).filter(Boolean).length; + return Math.max(1, Math.round(words / 230)); +} diff --git a/js/library/client.js b/js/library/client.js index 6f0870e..3cfdecf 100644 --- a/js/library/client.js +++ b/js/library/client.js @@ -225,6 +225,17 @@ export async function fetchEntryText(entry, index, { fetchImpl = globalThis.fetc throw lastError || new Error('The machine could not be downloaded.'); } +/** + * The text of an essay the index lists (`entry.essay` or `collection.essay`): + * fetched, verified and cached exactly as a machine's file is — from the file + * cache by hash, else jsDelivr at the index's commit, else the site. So an + * essay read once reads again offline, and a download that is not the listed + * text is refused. Never from My Library, which keeps machines, not prose. + */ +export function fetchEssayText(essay, index, opts = {}) { + return fetchEntryText({ id: `essay:${essay.path}`, path: essay.path, hash: essay.hash }, index, opts); +} + /** A fetched entry's text → a document stamped with where it came from. */ export function stampSource(text, entry) { const doc = JSON.parse(text); diff --git a/js/library/essay-draw.js b/js/library/essay-draw.js new file mode 100644 index 0000000..fdd633a --- /dev/null +++ b/js/library/essay-draw.js @@ -0,0 +1,73 @@ +// ══════════════════════════════════════════════════════════════════ +// AN ESSAY'S FIGURES, IN THE APP +// ══════════════════════════════════════════════════════════════════ +// The website draws an essay's figures when it is built; the app draws them +// when the essay is read, from the same code (article-figures.js), in a worker +// — one, started on first use, since figures are drawn one page at a time. +// A drawn figure is kept by what it was drawn from, so going back to an essay +// does not run BB(5) again. +// +// Where there is no Worker (the test DOM, a CSP that refuses one) the figure +// is drawn here instead, after a tick: slower to arrive, never missing. + +import { drawStandardFigure } from './article-figures.js'; + +const drawn = new Map(); // key → Promise +let worker = null; +let refused = false; +let nextId = 0; +const waiting = new Map(); // id → resolve + +function spawn() { + if (worker || refused || typeof Worker === 'undefined') return worker; + try { + // This exact form is what Vite recognises to emit a worker bundle. + worker = new Worker(new URL('./essay-figures.worker.js', import.meta.url), { type: 'module' }); + worker.onmessage = ({ data }) => { waiting.get(data.id)?.(data.svg); waiting.delete(data.id); }; + worker.onerror = () => { + // Whatever was in flight is drawn here instead; later figures skip the worker. + refused = true; + try { worker.terminate(); } catch { /* already gone */ } + worker = null; + for (const [id, resolve] of waiting) resolve(undefined); + waiting.clear(); + }; + } catch { + refused = true; + worker = null; + } + return worker; +} + +function drawHere(kind, code, opts) { + return new Promise(resolve => setTimeout(() => { + try { resolve(drawStandardFigure(kind, code, opts)); } catch { resolve(null); } + }, 0)); +} + +/** A figure's SVG, drawn off the main thread when it can be; null if it cannot be drawn. */ +export function drawEssayFigure(kind, code, opts = {}) { + const key = JSON.stringify([kind, code, opts]); + if (!drawn.has(key)) { + const w = spawn(); + const job = w + ? new Promise(resolve => { + const id = ++nextId; + waiting.set(id, resolve); + w.postMessage({ id, kind, code, opts }); + }).then(svg => (svg === undefined ? drawHere(kind, code, opts) : svg)) + : drawHere(kind, code, opts); + // A failure is not remembered: the next look tries again. + drawn.set(key, job.then(svg => { if (!svg) drawn.delete(key); return svg; })); + } + return drawn.get(key); +} + +/** Test seam. */ +export function _resetEssayFiguresForTests() { + drawn.clear(); + waiting.clear(); + try { worker?.terminate(); } catch { /* gone */ } + worker = null; + refused = false; +} diff --git a/js/library/essay-figures.worker.js b/js/library/essay-figures.worker.js new file mode 100644 index 0000000..af1cfe6 --- /dev/null +++ b/js/library/essay-figures.worker.js @@ -0,0 +1,12 @@ +// Draws an essay's figures off the main thread. A growth chart runs its +// machine to the halt — BB(5) is 47 million steps, about half a second — and +// the Library view should not stop answering the mouse while it does. +// article-figures.js is import-free, so this worker loads nothing else. + +import { drawStandardFigure } from './article-figures.js'; + +self.onmessage = ({ data }) => { + let svg = null; + try { svg = drawStandardFigure(data.kind, data.code, data.opts); } catch { /* drawn as missing */ } + self.postMessage({ id: data.id, svg }); +}; diff --git a/js/library/essay.js b/js/library/essay.js new file mode 100644 index 0000000..33c50f9 --- /dev/null +++ b/js/library/essay.js @@ -0,0 +1,104 @@ +// ══════════════════════════════════════════════════════════════════ +// WHAT AN ESSAY MAY ASK OF THE LIBRARY +// ══════════════════════════════════════════════════════════════════ +// An essay (article.js) is the author's words; this is the half that is not. +// A {{fact}} is read off the index — the analysis that earned the badges — and +// a figure's arguments are checked here before anything draws them. The +// website's build (scripts/library/site.mjs) and the app's Library view +// (library-ui.js) both ask through this module, so an essay says the same +// numbers on both faces and a figure is drawn from the same run. +// +// Imports card-html.js (for a TM's size read off its code) and article.js, +// neither of which touches the DOM. + +import { standardSize } from './card-html.js'; +import { ARTICLE_MAX_CHARS, renderArticle } from './article.js'; + +const count = n => Number(n).toLocaleString('en-US'); + +/** Every fact an essay can name, and where each comes from. */ +export const ESSAY_FACTS = { + steps: e => e.behaviour?.verdict === 'halts' && { value: count(e.behaviour.steps), say: `Steps from a blank tape to the halt, counted by the library running ${e.title}` }, + ones: e => e.behaviour?.ones !== undefined && { value: count(e.behaviour.ones), say: `Non-blank cells at the halt, counted by the library running ${e.title}` }, + cells: e => e.behaviour?.cells !== undefined && { value: count(e.behaviour.cells), say: `Cells ${e.title} visits before it halts, counted by the library` }, + states: e => ({ value: count(e.stats.states), say: `States in ${e.title} as drawn` }), + transitions: e => ({ value: count(e.stats.transitions), say: `Transitions in ${e.title} as drawn` }), + size: e => { const z = standardSize(e.standard); return z && { value: `${z.states} × ${z.symbols}`, say: `States × symbols of ${e.title}, read off its standard code` }; }, + standard: e => e.standard && { value: e.standard, say: `${e.title} in the standard text format` }, + title: e => ({ value: e.title, say: e.id }) +}; + +/** + * The `fact` callback for renderArticle: `{{steps}}` asks `self` (the entry + * the essay belongs to; null for a collection's essay), `{{steps }}` asks + * another entry. Null for a fact the library does not know. + */ +export function essayFacts(index, self = null) { + const byId = new Map((index?.entries || []).map(e => [e.id, e])); + return (name, ref) => { + const e = ref ? byId.get(ref) : self; + return (e && Object.hasOwn(ESSAY_FACTS, name) && ESSAY_FACTS[name](e)) || null; + }; +} + +/** Where a `lib:` link goes: an entry, a collection, or nowhere. */ +export function essayLinkTarget(index, id) { + if ((index?.entries || []).some(e => e.id === id)) return { kind: 'entry', id }; + if ((index?.collections || []).some(c => c.id === id)) return { kind: 'collection', id }; + return null; +} + +// The largest run a figure may ask for. A space-time diagram is drawn row by +// row, and past this many steps its rows are samples of samples; a growth +// chart runs the machine to its halt, bounded like the library's own analysis. +export const SPACETIME_MAX_STEPS = 200000; +export const GROWTH_MAX_STEPS = 1e8; + +/** + * A figure directive (`::: spacetime steps=4029 h=440`) → what to draw, with + * its arguments parsed and clamped, or null when the machine cannot be drawn + * that way. `entry` is the machine the figure is of. Both faces draw from + * this, so an argument means the same thing on each. + */ +export function essayFigureSpec(name, args, entry) { + if (name === 'spacetime' || name === 'growth') { + if (!entry?.standard) return null; + if (name === 'spacetime') { + const steps = clampInt(args.steps, 2000, 1, SPACETIME_MAX_STEPS); + const h = clampInt(args.h, 420, 160, 900); + return { kind: 'spacetime', code: entry.standard, opts: { steps, w: 680, h }, aspect: `680 / ${h}` }; + } + return { + kind: 'growth', code: entry.standard, + opts: { maxSteps: GROWTH_MAX_STEPS, scale: args.scale === 'linear' ? 'linear' : 'log', yScale: args.y === 'log' ? 'log' : 'linear' } + }; + } + if (name === 'diagram') return entry ? { kind: 'diagram' } : null; + return null; +} + +function clampInt(v, fallback, lo, hi) { + const n = Math.round(Number(v)); + return Number.isFinite(n) && v !== undefined ? Math.min(hi, Math.max(lo, n)) : fallback; +} + +/** + * What is wrong with an essay, as the build reports it on the entry: a fact + * the library does not know, a figure the machine cannot draw, a link to + * nothing, a note never written, a file past the length the renderer reads. + * Nothing is drawn — the check asks only whether it could be — so this is + * cheap enough to run on every build, and an author sees it on their pull + * request instead of on the published page. + */ +export function essayWarnings(md, index, self = null) { + const byId = new Map((index?.entries || []).map(e => [e.id, e])); + const could = { html: '' }; + const figure = (name, args) => { + if (name === 'machines') return args._.length && args._.every(id => byId.has(id)) ? could : null; + return essayFigureSpec(name, args, args.id ? byId.get(args.id) : self) ? could : null; + }; + const { warnings } = renderArticle(md, { fact: essayFacts(index, self), figure, link: id => (essayLinkTarget(index, id) ? '#' : null) }); + const out = warnings.map(w => `Essay: ${w}`); + if (String(md).length > ARTICLE_MAX_CHARS) out.push(`Essay: longer than ${ARTICLE_MAX_CHARS.toLocaleString('en-US')} characters; the rest is not shown.`); + return out; +} diff --git a/js/library/index-model.js b/js/library/index-model.js index ca64cd3..1a1adf5 100644 --- a/js/library/index-model.js +++ b/js/library/index-model.js @@ -107,7 +107,8 @@ export function normalizeIndex(raw) { title: str(c?.title, 120) || 'Collection', blurb: str(c?.blurb, 1000), curator: str(c?.curator, 60), - entries: arr(c?.entries).filter(id => ids.has(id)) + entries: arr(c?.entries).filter(id => ids.has(id)), + essay: essayRef(c?.essay) })).filter(c => isLibraryId(c.id)); return { format: INDEX_FORMAT, @@ -166,10 +167,24 @@ function normalizeEntry(e) { forkOf: isLibraryId(e.forkOf) ? e.forkOf : null, remixes: arr(e.remixes).filter(isLibraryId), collections: arr(e.collections).filter(isLibraryId), - duplicateOf: isLibraryId(e.duplicateOf) ? e.duplicateOf : null + duplicateOf: isLibraryId(e.duplicateOf) ? e.duplicateOf : null, + essay: essayRef(e.essay) }; } +/** + * An entry's or a collection's essay, as the build lists it: a Markdown file + * in the library (`machines/…/bb5.md`), the hash its download is checked + * against, and minutes to read. Null when there is none, or when what is + * listed could not be one — the path ends up in a fetch URL. + */ +function essayRef(x) { + if (!x || typeof x !== 'object') return null; + const path = str(x.path, 300); + if (!/^[A-Za-z0-9._/-]+\.md$/.test(path) || path.includes('..') || path.includes('//')) return null; + return { path, hash: str(x.hash, 32), minutes: Math.max(1, Math.min(240, num(x.minutes) || 1)) }; +} + /** A machine's packed shape (js/library/sketch.js packSketch): points and index pairs, nothing else. */ function validSketch(k) { if (!k || !Array.isArray(k.n) || !Array.isArray(k.e) || !k.n.length || k.n.length > 60 || k.e.length > 240) return false; diff --git a/library-template/CONTRIBUTING.md b/library-template/CONTRIBUTING.md index 1e108df..9a506cc 100644 --- a/library-template/CONTRIBUTING.md +++ b/library-template/CONTRIBUTING.md @@ -14,6 +14,15 @@ - **An exercise:** build it in an exercise tab and submit with "An exercise" chosen; it keeps its checker. - **A collection:** open a pull request; maintainers review those by hand. +- **An essay about your machine:** 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:)` links to another entry or collection. 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. 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/build.mjs b/scripts/library/build.mjs index 325a54d..5c82ca4 100644 --- a/scripts/library/build.mjs +++ b/scripts/library/build.mjs @@ -6,7 +6,8 @@ // with the app's own engine, and writes what the app and the website read: // // /index.json the catalogue (js/library/index-model.js) -// /machines/** the published entry files, verbatim +// /machines/** the published entry files, verbatim, and their essays +// /collections/.md a collection's essay, verbatim // /art//.svg each entry's pictures (analyze.js cardArt) // /**.html + assets/ the website (scripts/library/site.mjs) // @@ -31,6 +32,8 @@ // machines//.automaton entries; the id is the path under // machines/ without the extension // collections/.json { title, blurb, curator, entries: [id] } +// .md beside either optional: its essay (js/library/article.js), +// listed in the index as `essay` // library.config.json optional: { site, repo, maintainers, featured } import './env.mjs'; @@ -47,6 +50,8 @@ import { withMachine } from '../../js/exercise/grade.js'; import { canonicalCodeOf, contentHash } from '../../js/library/hash.js'; import { INDEX_FORMAT, INDEX_VERSION, LIBRARY_REPO, LIBRARY_SITE_URL, isLibraryId } from '../../js/library/config.js'; import { normalizeIndex } from '../../js/library/index-model.js'; +import { readingMinutes } from '../../js/library/article.js'; +import { essayWarnings } from '../../js/library/essay.js'; import { frontispieceOf, writeSite } from './site.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -206,8 +211,10 @@ export async function buildLibrary(opts) { entry.art.push({ kind: v.kind, path }); artifacts.push({ path, text: v.svg }); } + const article = await articleBeside(file.replace(/\.automaton$/, '.md')); + if (article) entry.essay = essayRef(rel.replace(/\.automaton$/, '.md'), article); entries.push(entry); - sources.set(id, { target: a.target, doc }); + sources.set(id, { target: a.target, doc, article }); } const byId = new Map(entries.map(e => [e.id, e])); @@ -264,6 +271,7 @@ export async function buildLibrary(opts) { // ── collections ── const collections = []; + const collectionArticles = new Map(); // id → its essay's Markdown, for the website for (const file of await walk(join(root, 'collections'), '.json')) { const id = posix(relative(join(root, 'collections'), file)).replace(/\.json$/, ''); const c = await readJson(file, null); @@ -276,7 +284,12 @@ export async function buildLibrary(opts) { if (missing.length) r.errors.push(`Lists entries that do not exist: ${missing.join(', ')}.`); const kept = listed.filter(x => byId.has(x)); kept.forEach(x => byId.get(x).collections.push(id)); - collections.push({ id, title: String(c.title || id), blurb: String(c.blurb || ''), curator: String(c.curator || ''), entries: kept }); + const essay = await articleBeside(file.replace(/\.json$/, '.md')); + collections.push({ + id, title: String(c.title || id), blurb: String(c.blurb || ''), curator: String(c.curator || ''), entries: kept, + ...(essay ? { essay: essayRef(posix(relative(root, file)).replace(/\.json$/, '.md'), essay) } : {}) + }); + if (essay) collectionArticles.set(id, essay); } // ── what is published ── @@ -294,6 +307,21 @@ export async function buildLibrary(opts) { return !m || pubIds.has(m[1]); }; + // ── essays ── + // Checked against what is published, since an essay may name any entry: a + // fact the library does not know, a figure the machine cannot draw, a link + // to nothing. Warnings, not errors — the essay is prose around a machine + // that passed, and it is still worth reading with a sentence to fix. + const essayIndex = { entries: published, collections }; + for (const e of published) { + const md = sources.get(e.id)?.article; + if (md) resultOf(e.id).warnings.push(...essayWarnings(md, essayIndex, e)); + } + for (const c of collections) { + const md = collectionArticles.get(c.id); + if (md) resultOf(`collection:${c.id}`)?.warnings.push(...essayWarnings(md, essayIndex, null)); + } + // ── the frontispiece ── // Chosen once, here, on the drawings the website will show, and written into // the index — so the app's Discover opens on the machine the website's home @@ -334,11 +362,29 @@ export async function buildLibrary(opts) { // Round-trip through the reader the app uses, so an index the app would // refuse cannot be published. normalizeIndex(JSON.parse(JSON.stringify(raw))); - return { raw, results, artifacts: artifacts.filter(keepArtifact), sources, config: { ...config, repo, site } }; + return { raw, results, artifacts: artifacts.filter(keepArtifact), sources, collectionArticles, config: { ...config, repo, site } }; } // ── What a website listing reads off the file ───────────────────── +/** + * The essay beside a machine or a collection (bb5.md beside bb5.automaton), + * or '' when there is none. Its own file, so the prose is diffed and reviewed + * as prose and editing it never changes the machine's bytes or its hash. + */ +async function articleBeside(file) { + try { return await readFile(file, 'utf8'); } catch { return ''; } +} + +/** + * What the index says about an essay: where its file is, the hash the app + * checks a download against (as it does a machine's file), and how long it + * takes to read — enough for the app to say there is one before fetching it. + */ +function essayRef(path, text) { + return { path, hash: contentHash(text), minutes: readingMinutes(text) }; +} + /** * What only the machine's file can say, for its page on the website * (site.mjs): the diagram with its names and labels, its formal definition — @@ -346,7 +392,7 @@ export async function buildLibrary(opts) { * typeset — its examples decided, and the author's notes. Each part is * optional; a page without one draws from the index. */ -export function listingOf({ target, doc }, entry) { +export function listingOf({ target, doc, article }, entry) { const out = {}; try { out.diagram = namedDiagram(target); } catch { /* the index's sketch */ } try { out.latex = withMachine(target, () => buildFormalDefLatex()); } catch { /* no definition */ } @@ -359,6 +405,7 @@ export function listingOf({ target, doc }, entry) { try { out.runFrames = runFramesOf(target, 90); } catch { /* none */ } } out.readme = libraryMetaOf(doc).readme; + if (article) out.article = article; // The machine's code, as the app's listing shows it — blocks and all, names // left off, since the code is what names the machine. if (!doc?.exercise) out.code = canonicalCodeOf(doc); @@ -417,10 +464,13 @@ export async function writeLibrary(opts, built) { for (const a of built.artifacts) await put(out, a.path, a.text); // Only what was published: a file that failed its check is not listed, and // is not served either. - for (const e of built.raw.entries) { - const dest = join(out, ...e.path.split('/')); + // An essay is served beside its machine or collection, from the same place, + // so the app fetches it the way it fetches the machine's file. + const served = [...built.raw.entries.map(e => e.path), ...[...built.raw.entries, ...built.raw.collections].map(x => x.essay?.path).filter(Boolean)]; + for (const path of served) { + const dest = join(out, ...path.split('/')); await mkdir(dirname(dest), { recursive: true }); - await copyFile(join(library, ...e.path.split('/')), dest); + await copyFile(join(library, ...path.split('/')), dest); } // Pages serves Jekyll by default, which drops paths starting with an // underscore and rewrites nothing we want rewritten. @@ -443,7 +493,8 @@ export async function writeLibrary(opts, built) { 'hash.js': join(HERE, '../../js/library/hash.js'), '../interop/smtf.js': join(HERE, '../../js/interop/smtf.js') }, - listings + listings, + collectionArticles: built.collectionArticles }); } } diff --git a/scripts/library/guard.mjs b/scripts/library/guard.mjs index 879313c..02b2e3a 100644 --- a/scripts/library/guard.mjs +++ b/scripts/library/guard.mjs @@ -12,6 +12,12 @@ // credit the account that opens the pull request; collections and the config // are the maintainers' to change. // +// An essay beside a machine (bb5.md beside bb5.automaton) carries no credit of +// its own — it is Markdown — so it is credited as its machine is: the +// machine's author, or a maintainer, may write it, and nobody else may attach +// one to their machine. A collection's essay is the collection's, and so the +// maintainers'. +// // Pull requests opened by the submission workflow are exempt: that workflow // sets the credit from the issue's author itself (issue-to-entry.mjs), which // is the check this one would otherwise be repeating. @@ -45,6 +51,24 @@ export function maintainersAt(root, base) { } } +const isEssay = path => /^machines\/.+\.md$/.test(path || ''); + +/** + * Whether `who` may write the essay at `path`: whoever the machine beside it + * credits — as the base branch has it, or, for a machine this change adds, as + * the change has it (the check below it already holds that to `who`). + */ +async function essayProblems(root, base, path, who) { + const machine = path.replace(/\.md$/, '.automaton'); + let atBase = ''; + try { atBase = git(['show', `${base}:${machine}`], root); } catch { atBase = ''; } + const now = await readFile(resolve(root, machine), 'utf8').catch(() => ''); + const owner = creditOf(atBase) || creditOf(now); + if (!owner) return [`\`${path}\`: an essay sits beside the machine it is about, and there is no \`${machine}\`.`]; + if (owner !== who) return [`\`${path}\` is the essay of \`${machine}\`, credited to @${owner}; only they or a maintainer can write it.`]; + return []; +} + /** Problems with the change, as sentences. Empty when it may go in. */ export async function guardChanges({ root, base, author, maintainers = [] }) { const who = String(author || '').toLowerCase(); @@ -61,6 +85,10 @@ export async function guardChanges({ root, base, author, maintainers = [] }) { problems.push(`\`${after || before}\`: collections and the library's config are changed by maintainers.`); continue; } + if (isEssay(before) || isEssay(after)) { + for (const path of new Set([before, after].filter(isEssay))) problems.push(...await essayProblems(root, base, path, who)); + continue; + } let old = ''; if (status !== 'A') { try { old = git(['show', `${base}:${before}`], root); } catch { old = ''; } } const now = status === 'D' ? '' : await readFile(resolve(root, after), 'utf8').catch(() => ''); diff --git a/scripts/library/site.mjs b/scripts/library/site.mjs index b3230d1..62b019b 100644 --- a/scripts/library/site.mjs +++ b/scripts/library/site.mjs @@ -34,6 +34,9 @@ import { bbchallengeUrl } from '../../js/interop/standard-tm.js'; import { FRONTIS_NOTE, MAST_LEDE, cardPicture, figureHtml, frontispieceWhat, plateHtml, rankBadges, standardSize } from '../../js/library/card-html.js'; import { drawLanguage, drawRun, drawSketch, framesFromStandard, languageRows, sketchAspect, unpackSketch } from '../../js/library/sketch.js'; 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'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -322,6 +325,69 @@ function entryStage(e, listing, root) { `; } +// ── Essays ──────────────────────────────────────────────────────── + +/** + * What an essay (article.js) may ask of the library: facts from the analysis + * that earned the badges, figures drawn from the machine, links to other + * entries. `self` is the entry the essay belongs to, or null for a + * collection's; `{{steps turing/busy-beaver/bb4}}` asks another entry. + */ +function essayContext(self, index, depth, listings) { + const byId = new Map(index.entries.map(x => [x.id, x])); + const root = up(depth); + const pick = ref => (ref ? byId.get(ref) : self); + const well = (inner, aspect, fam, run = false) => `
    ${inner}
    `; + const figure = (name, args) => { + if (name === 'machines') { + const list = args._.map(id => byId.get(id)).filter(Boolean); + return list.length ? { html: plates(list, depth), wide: true } : null; + } + const e = pick(args.id); + const spec = essayFigureSpec(name, args, e); + if (!spec) return null; + if (spec.kind === 'diagram') { + const named = listings.get(e.id)?.diagram; + const sk = unpackSketch(e.sketch); + const H = named?.h || (sk ? Math.round(640 / sketchAspect(sk)) : 0); + const svg = named?.svg || (sk && drawSketch(sk, { w: 640, h: H, label: `Diagram of ${e.title}` })); + return svg ? { html: well(svg, `640 / ${H}`, e.category) } : null; + } + const svg = drawStandardFigure(spec.kind, spec.code, spec.opts); + if (!svg) return null; + return spec.kind === 'growth' + ? { html: `
    ${svg}
    ` } + : { html: well(svg, spec.aspect, e.category, true) }; + }; + return { + fact: essayFacts(index, self), + figure, + link: id => { + const to = essayLinkTarget(index, id); + return to ? `${root}${to.kind === 'entry' ? 'm' : 'c'}/${enc(id)}/` : null; + } + }; +} + +/** + * An essay as a section of its page: the contents in the margin, the text in + * a reading column. Warnings (a fact the library does not know, a figure the + * machine cannot draw) are printed by the build and never hide the text. + */ +function essayHtml(md, ctx, where) { + const art = renderArticle(md, ctx); + for (const w of art.warnings) console.log(` essay ${where}: ${w}`); + const mins = readingMinutes(md); + const aside = `${mins} min read${art.figures ? ` · ${plural(art.figures, 'figure')}` : ''}`; + const toc = art.toc.filter(t => t.level === 2); + return `
    ${sectionHead('Essay', aside, 'essay')} +
    + ${toc.length > 2 ? `` : ''} +
    ${art.html}
    +
    +
    `; +} + /** The author's examples, decided by the machine when the site was built. */ function examplesHtml(listing) { const rows = listing?.examples || []; @@ -366,7 +432,7 @@ ${same.length ? `

    The same language, drawn differently`; } -function entryPage(e, index, config, listing) { +function entryPage(e, index, config, listing, listings = new Map()) { const depth = 1 + e.id.split('/').length; const root = up(depth); const req = { action: 'open', id: e.id }; @@ -426,15 +492,16 @@ function entryPage(e, index, config, listing) { ${factsHtml} + ${listing?.article ? essayHtml(listing.article, essayContext(e, index, depth, listings), e.id) : ''} ${e.behaviour || e.standard ? behaviourHtml(e, listing) : ''} - ${notes.length ? `
    ${sectionHead('Notes')}
    ${notes.map(p => `

    ${esc(p)}

    `).join('')}
    ` : ''} + ${notes.length && !listing?.article ? `
    ${sectionHead('Notes')}
    ${notes.map(p => `

    ${esc(p)}

    `).join('')}
    ` : ''} ${relatedHtml(e, index, depth)} `; return layout({ title: `${e.title} — ${e.machine} · AutomataStudio Library`, description: e.blurb || `A ${e.machine} with ${plural(e.stats.states, 'state')}, verified by the AutomataStudio engine.`, depth, body, canonical: `m/${enc(e.id)}/`, image: pic ? enc(pic.path) : null, config, - math: !!listing?.latex || hasTex(e.blurb) || hasTex(listing?.readme) + math: !!listing?.latex || hasTex(e.blurb) || hasTex(listing?.readme) || hasTex(listing?.article) }); } @@ -469,7 +536,7 @@ function behaviourTable(list, depth) { return `
    ${sectionHead('At a glance')}
    ${rows}
    MachineSizeStepsNon-blankStandard format
    `; } -function collectionPage(c, index, config) { +function collectionPage(c, index, config, article = '', listings = new Map()) { const depth = 1 + c.id.split('/').length; const byId = new Map(index.entries.map(e => [e.id, e])); const list = c.entries.map(id => byId.get(id)).filter(Boolean); @@ -482,10 +549,11 @@ function collectionPage(c, index, config) { ${c.blurb ? `

    ${esc(c.blurb)}

    ` : ''} +${article ? essayHtml(article, essayContext(null, index, depth, listings), `collection ${c.id}`) : ''} ${behaviourTable(list, depth)}
    ${sectionHead('The machines')}${plates(list, depth)}
    `; - return layout({ title: `${c.title} · AutomataStudio Library`, description: c.blurb || c.title, depth, body, canonical: `c/${enc(c.id)}/`, config, nav: 'collections', math: hasTex(c.blurb) }); + return layout({ title: `${c.title} · AutomataStudio Library`, description: c.blurb || c.title, depth, body, canonical: `c/${enc(c.id)}/`, config, nav: 'collections', math: hasTex(c.blurb) || hasTex(article) }); } // ── Submitting, and what the marks mean ─────────────────────────── @@ -534,11 +602,11 @@ async function put(out, path, text) { * a name is a path relative to it, so '../interop/smtf.js' lands beside it; * `listings` maps an entry id to what build.mjs read off its file. */ -export async function writeSite(out, index, config, { assets = {}, listings = new Map() } = {}) { +export async function writeSite(out, index, config, { assets = {}, listings = new Map(), collectionArticles = new Map() } = {}) { 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))); + 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)); - for (const c of index.collections) await put(out, `c/${c.id}/index.html`, collectionPage(c, index, config)); + for (const c of index.collections) await put(out, `c/${c.id}/index.html`, collectionPage(c, index, config, collectionArticles.get(c.id), listings)); await put(out, 'submit/index.html', submitPage(config)); await put(out, '404.html', notFoundPage(config)); await put(out, 'assets/site.css', await readFile(join(HERE, 'site', 'site.css'), 'utf8')); diff --git a/scripts/library/site/site.css b/scripts/library/site/site.css index 1cbfb52..7ca2cd2 100644 --- a/scripts/library/site/site.css +++ b/scripts/library/site/site.css @@ -411,6 +411,69 @@ body.is-searching .discover { display: none; } .more-menu a { padding: 7px 10px; border-radius: 5px; font-size: .84rem; color: var(--text); text-decoration: none; } .more-menu a:hover, .more-menu a:focus-visible { background: var(--surface2); } +/* ── essays ── + An entry's long-form write-up (js/library/article.js), set like a chapter + of the book the rest of the page already looks like: a reading column in + the serif, the contents in the margin, figures boxed and numbered with + italic captions, notes at the foot. A number the library computed — a + {{fact}} — is set in the mono with a hairline under it, so a reader can + tell the machine's answers from the author's words. */ +.essay-meta { font-family: var(--mono); font-size: .7rem; color: var(--text3); } +.essay-grid { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 56px; align-items: start; } +.essay-grid.no-toc { grid-template-columns: minmax(0, 1fr); } +.essay-toc { position: sticky; top: 84px; display: flex; flex-direction: column; gap: 10px; padding-top: 6px; } +.essay-toc ol { margin: 0; padding: 0; list-style: none; counter-reset: toc; display: flex; flex-direction: column; gap: 7px; } +.essay-toc li { counter-increment: toc; display: grid; grid-template-columns: 22px 1fr; font-family: var(--serif); font-size: 1rem; line-height: 1.3; } +.essay-toc li::before { content: counter(toc) "."; font-family: var(--mono); font-size: .68rem; color: var(--text3); padding-top: 3px; } +.essay-toc a { color: var(--text2); text-decoration: none; } +.essay-toc a:hover { color: var(--accent); } +.essay-body { max-width: 680px; font-size: 1.2rem; line-height: 1.66; } +.essay-body > p:first-child::first-letter { float: left; font-size: 3.4em; line-height: .86; padding: 6px 8px 0 0; color: var(--text); } +.essay-body h2, .essay-body h3 { font-family: var(--serif); font-weight: 400; color: var(--text); scroll-margin-top: 80px; } +.essay-body h2 { font-size: 1.6rem; margin: 1.6em 0 .5em; letter-spacing: -.005em; } +.essay-body h2:first-child { margin-top: 0; } +.essay-body h3 { font-size: 1.25rem; font-style: italic; margin: 1.3em 0 .4em; } +.essay-body p { margin: 0 0 .9em; } +.essay-body ul, .essay-body ol { margin: 0 0 1em; padding-left: 1.3em; } +.essay-body li { margin: .25em 0; } +.essay-body blockquote { margin: 1.2em 0; padding: 2px 0 2px 20px; border-left: 2px solid var(--h, var(--accent)); color: var(--text2); font-style: italic; } +.essay-body code { font-size: .78em; padding: 1px 5px; border-radius: 3px; background: var(--well); border: 1px solid var(--border); overflow-wrap: anywhere; } +.essay-body a.textlink-inline { color: var(--text); text-decoration: none; border-bottom: 1px solid var(--accent-border); } +.essay-body a.textlink-inline:hover { color: var(--accent); border-bottom-color: var(--accent); } +.essay-math { margin: 1em 0; overflow-x: auto; } +.essay-code { margin: 1em 0; padding: 12px 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--well); overflow-x: auto; font-size: .92rem; line-height: 1.5; } +.essay-code code { border: 0; padding: 0; background: none; font-size: .78rem; } +.fact { font-family: var(--mono); font-size: .8em; color: var(--text); border-bottom: 1px dotted var(--h, var(--accent)); cursor: help; white-space: nowrap; } +.fact.is-missing { color: var(--red); border-bottom-color: var(--red); } +/* In a table the cell already sets the face and size; a fact only adds its rule. + A header's small caps must not reach into its math: S(n) is not S(N). */ +.board .fact { font-size: inherit; } +.board th .katex { text-transform: none; letter-spacing: normal; font-size: 1.5em; } +.essay-fig { margin: 1.8em 0; display: flex; flex-direction: column; gap: 10px; } +.essay-fig.is-wide { max-width: none; } +.essay-fig .figcaption-text { font-size: 1.02rem; } +.fig-n { font-style: normal; font-family: var(--mono); font-size: .72rem; letter-spacing: .06em; color: var(--text3); text-transform: uppercase; margin-right: 4px; } +.fig.is-essay { aspect-ratio: var(--fig-aspect, 16 / 10); border-radius: 8px; } +.fig.is-essay.is-chart { aspect-ratio: 680 / 300; background-image: none; } +.essay-st .rn-1 { fill: color-mix(in srgb, var(--text) 70%, transparent); } +.st-head { fill: none; stroke: var(--h, var(--accent)); stroke-width: 1.2; stroke-linejoin: round; opacity: .9; vector-effect: non-scaling-stroke; } +.gr-grid { stroke: var(--border); stroke-width: 1; } +.gr-axis { stroke: var(--text3); stroke-width: 1; } +.gr-tick { fill: var(--text3); font-family: var(--mono); font-size: 11px; } +.gr-line { fill: none; stroke: var(--h, var(--accent)); stroke-width: 1.8; stroke-linejoin: round; } +.gr-end { fill: var(--h, var(--accent)); stroke: var(--well); stroke-width: 2; } +.essay-table { margin: 1.2em 0; font-size: .92rem; } +.essay-table td { font-family: var(--sans); } +.essay-table td.num { font-family: var(--mono); font-size: .84rem; } +.fn-ref { font-family: var(--mono); font-size: .62em; line-height: 0; margin-left: 1px; } +.fn-ref a { color: var(--accent); text-decoration: none; } +.essay-notes { margin-top: 2.2em; padding-top: 14px; border-top: 1px solid var(--border); font-size: .98rem; line-height: 1.5; color: var(--text2); } +.essay-notes ol { margin: 0; padding-left: 1.3em; } +.essay-notes li { margin: .4em 0; } +.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; } + /* ── tables ── */ .table-wrap { overflow-x: auto; } .board { width: 100%; border-collapse: collapse; font-size: .86rem; font-variant-numeric: tabular-nums; } @@ -452,6 +515,9 @@ body.is-searching .discover { display: none; } @media (max-width: 1100px) { .families { grid-template-columns: repeat(3, minmax(0, 1fr)); } .entry-body { grid-template-columns: minmax(0, 1fr); gap: 32px; } + .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; } } @media (max-width: 760px) { diff --git a/tests/essay.test.js b/tests/essay.test.js new file mode 100644 index 0000000..2d362a9 --- /dev/null +++ b/tests/essay.test.js @@ -0,0 +1,170 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseArticle, readingMinutes, renderArticle } from '../js/library/article.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 { normalizeIndex } from '../js/library/index-model.js'; +import { INDEX_FORMAT } from '../js/library/config.js'; + +// An essay is the author's words around answers the library computed. What is +// pinned here is the line between the two — a {{fact}} is read off the index +// and never typed, a figure is drawn from the machine and its arguments are +// bounded — and the one thing an essay must never do, which is put an +// author's markup on the page. + +const BB2 = '1RB1LB_1LA1RZ'; +const entry = (over = {}) => ({ + id: 'turing/busy-beaver/bb2', title: 'BB(2) champion', category: 'tm', standard: BB2, + stats: { states: 3, transitions: 4 }, behaviour: { verdict: 'halts', steps: 6, ones: 4, cells: 4 }, ...over +}); +const index = { entries: [entry(), entry({ id: 'turing/x', title: 'X', behaviour: { verdict: 'halts', steps: 47176870, ones: 4098 } })], collections: [{ id: 'busy-beavers', title: 'BB' }] }; + +// ── Markdown ────────────────────────────────────────────────────── + +test('an essay\'s blocks: headings, lists, tables, math, code, figures, notes', () => { + const { blocks, notes } = parseArticle([ + '# One', 'A paragraph', 'that wraps.', '', '- a', '- b', '', '1. x', '2. y', '', + '| A | B |', '| --- | ---: |', '| 1 | 2 |', '', '$$ x^2 $$', '', '```', 'code *not em*', '```', '', + '> quoted', '', '::: spacetime steps=40', 'Its caption.', ':::', '', '[^n]: A note', ' that continues.' + ].join('\n')); + assert.deepEqual(blocks.map(b => b.kind), ['heading', 'para', 'list', 'list', 'table', 'math', 'code', 'quote', 'figure']); + assert.equal(blocks[0].level, 2, 'a page has one title and it is the entry\'s, so # is a section'); + assert.equal(blocks[1].text, 'A paragraph that wraps.'); + assert.equal(blocks[3].ordered, true); + assert.deepEqual(blocks[4].align, ['', 'num']); + assert.deepEqual(blocks[8], { kind: 'figure', name: 'spacetime', args: { _: [], steps: '40' }, caption: 'Its caption.' }); + assert.equal(notes.get('n'), 'A note that continues.'); +}); + +test('nothing an author writes reaches the page as markup', () => { + const { html, warnings } = renderArticle([ + 'Hello & ', + '', + '[bad](javascript:alert(1)) [data](data:text/html,x) [ok](https://example.org/a?b=1&c=2)', + '', + '| x |', '| --- |', '| "quoted" |' + ].join('\n')); + assert.doesNotMatch(html, //); + assert.match(html, /<script>alert\(1\)<\/script> & <img/); + assert.doesNotMatch(html, /href="(javascript|data):/, 'only http(s), # and lib: links become links'); + assert.match(html, /href="https:\/\/example\.org\/a\?b=1&c=2"/); + assert.equal(warnings.filter(w => /neither http/.test(w)).length, 2); +}); + +test('code and math are set aside before emphasis, so neither is rewritten', () => { + const { html } = renderArticle('Run `a*b*c {{steps}}` and $a*b*c$ but *this* is emphasis.', { fact: () => ({ value: '6', say: '' }) }); + assert.match(html, /a\*b\*c \{\{steps\}\}<\/code>/, 'a code span is literal: no emphasis, no fact'); + assert.match(html, /\$a\*b\*c\$/, 'math reaches KaTeX untouched'); + assert.match(html, /this<\/em>/); +}); + +test('a fact the library knows is printed and marked; one it does not is visible and reported', () => { + const { html, warnings } = renderArticle('It runs {{steps}} steps, {{colour}} and {{steps turing/x}}.', { fact: essayFacts(index, entry()) }); + assert.match(html, /6<\/span>/); + assert.match(html, /\[\[colour\?\]\]<\/span>/); + assert.match(html, />47,176,870 { + const fact = essayFacts(index, entry()); + for (const name of ['constructor', 'toString', '__proto__', 'hasOwnProperty']) assert.equal(fact(name), null, name); + assert.equal(fact('steps', 'no/such/entry'), null); + assert.equal(essayFacts(index, null)('steps'), null, 'a collection\'s essay has no machine of its own'); + assert.equal(essayFacts(index, entry({ behaviour: { verdict: 'never' } }))('steps'), null, 'no halt, no step count'); +}); + +test('footnotes are numbered in the order they are cited, and a missing one is reported', () => { + const { html, warnings } = renderArticle('First[^b], then[^a], again[^b], and[^gone].\n\n[^a]: Note A.\n[^b]: Note B.'); + const refs = [...html.matchAll(/class="fn-ref"[^>]*>(\d) `${m[1]}${m[2]}`); + assert.deepEqual(refs, ['b1', 'a2', 'b1']); + assert.match(html, /
  • Note B\.[\s\S]*
  • Note A\./); + assert.deepEqual(warnings, ['Footnote [^gone] has no text.']); +}); + +test('every id carries the prefix, and only web links open a new tab', () => { + const { html, toc } = renderArticle('## Start here\n\nSee[^1] [out](https://x.org) and [in](#lib-essay-start-here).\n\n::: f\n:::\n\n[^1]: n', { + figure: () => ({ html: '' }), 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" target="_blank" rel="noopener noreferrer"/); + assert.doesNotMatch(html, /href="#lib-essay-start-here" target/); +}); + +test('figures are numbered by the ones drawn; one that cannot be drawn is skipped and reported', () => { + const { html, figures, warnings } = renderArticle('::: a\nFirst.\n:::\n\n::: nope\nGone.\n:::\n\n::: a\nSecond.\n:::', { + figure: name => (name === 'a' ? { html: '' } : null) + }); + assert.equal(figures, 2); + assert.match(html, /Figure 1\.<\/span> First\.[\s\S]*Figure 2\.<\/span> Second\./); + assert.doesNotMatch(html, /Gone/); + assert.deepEqual(warnings, ['::: nope is not a figure this machine can draw.']); +}); + +test('the contents list takes the headings without their footnote markers', () => { + const { toc } = renderArticle('## Why[^1]\n\n### Detail\n\n[^1]: n'); + assert.deepEqual(toc.map(t => [t.level, t.html]), [[2, 'Why'], [3, 'Detail']]); +}); + +test('reading time counts the words, not the figures or the display math', () => { + assert.equal(readingMinutes(''), 1); + assert.equal(readingMinutes(Array(460).fill('word').join(' ')), 2); + assert.equal(readingMinutes(`${Array(230).fill('w').join(' ')}\n::: f\n${Array(900).fill('c').join(' ')}\n:::\n$$ ${Array(900).fill('x').join(' ')} $$`), 1); +}); + +// ── Figures ─────────────────────────────────────────────────────── + +test('the figures\' simulator agrees with the library on a busy beaver', () => { + assert.deepEqual(runStandard(BB2), { steps: 6, ones: 4, halted: true, lo: -2, hi: 1 }); + assert.deepEqual(runStandard('1RB1LC_1RC1RB_1RD0LE_1LA1LD_1RZ0LA', { maxSteps: 10000 }).halted, false, 'bounded: BB(5) is not run to its halt here'); + assert.equal(runStandard('nonsense'), null); +}); + +test('a space-time diagram draws the head only when every step has its row', () => { + assert.match(spacetimeSvg(BB2, { steps: 6, h: 420 }), /class="st-head"/); + const sampled = spacetimeSvg('1RB1LC_1RC1RB_1RD0LE_1LA1LD_1RZ0LA', { steps: 4000, h: 400 }); + assert.doesNotMatch(sampled, /st-head/, 'sampled rows would join positions many steps apart'); + assert.doesNotMatch(sampled, /fill="#|stroke="#/, 'inked by classes, never a baked colour'); +}); + +test('a growth chart marks the halt, on a log scale by default', () => { + const svg = growthSvg(BB2); + assert.match(svg, /class="gr-end"/); + assert.match(svg, /steps on a log scale/); + assert.match(growthSvg(BB2, { yScale: 'log' }), /class="gr-line"/); + assert.equal(drawStandardFigure('pie', BB2), null); +}); + +test('a figure\'s arguments are parsed and bounded before anything is drawn', () => { + const e = entry(); + assert.deepEqual(essayFigureSpec('spacetime', { _: [], steps: '4029', h: '440' }, e).opts, { steps: 4029, w: 680, h: 440 }); + assert.equal(essayFigureSpec('spacetime', { _: [], steps: '1e12' }, e).opts.steps, SPACETIME_MAX_STEPS); + assert.equal(essayFigureSpec('spacetime', { _: [], h: '99999' }, e).opts.h, 900); + assert.equal(essayFigureSpec('spacetime', { _: [], steps: 'lots' }, e).opts.steps, 2000, 'not a number: the default'); + assert.deepEqual(essayFigureSpec('growth', { _: [], y: 'log' }, e).opts, { maxSteps: 1e8, scale: 'log', yScale: 'log' }); + assert.equal(essayFigureSpec('growth', { _: [] }, entry({ standard: null })), null, 'a machine with no standard code has no run to draw'); + assert.equal(essayFigureSpec('diagram', { _: [] }, null), null); + assert.equal(essayFigureSpec('teapot', { _: [] }, e), null); +}); + +test('a lib: link names an entry or a collection, or nothing', () => { + assert.deepEqual(essayLinkTarget(index, 'turing/x'), { kind: 'entry', id: 'turing/x' }); + assert.deepEqual(essayLinkTarget(index, 'busy-beavers'), { kind: 'collection', id: 'busy-beavers' }); + assert.equal(essayLinkTarget(index, 'turing/y'), null); +}); + +// ── The index ───────────────────────────────────────────────────── + +test('the index keeps an essay only where one could be', () => { + const raw = e => normalizeIndex({ format: INDEX_FORMAT, entries: [{ id: 'a/b', essay: e }], collections: [{ id: 'c', essay: e }] }); + const ok = raw({ path: 'machines/a/b.md', hash: 'abc', minutes: 5 }); + assert.deepEqual(ok.entries[0].essay, { path: 'machines/a/b.md', hash: 'abc', minutes: 5 }); + assert.deepEqual(ok.collections[0].essay, { path: 'machines/a/b.md', hash: 'abc', minutes: 5 }); + for (const path of ['../secret.md', 'machines//b.md', 'machines/a/b.automaton', 'machines/a b.md', 'https://evil.test/x.md']) { + assert.equal(raw({ path, hash: 'abc' }).entries[0].essay, null, path); + } + assert.equal(raw(undefined).entries[0].essay, null); + assert.equal(raw({ path: 'm/x.md', minutes: 1e9 }).entries[0].essay.minutes, 240); +}); diff --git a/tests/library.test.js b/tests/library.test.js index 3baef74..7b481e2 100644 --- a/tests/library.test.js +++ b/tests/library.test.js @@ -102,6 +102,14 @@ function findAll(node, pred, out = []) { return out; } +const ESSAY_BB2 = [ + 'The smallest champion.', '', '## How it runs', '', + 'It halts after {{steps}} steps with {{ones}} ones.[^1] It is listed in [a collection](lib:parity).', '', + '::: spacetime steps=6', 'Its whole run.', ':::', '', '## Its size', '', 'It is {{size}}.', '', '## Its code', '', 'Read {{standard}}.', '', + '[^1]: Counted by the library.' +].join('\n'); +const ESSAY_PARITY = 'Two machines, {{states finite/dfa/even-ones}} states each, one language apart.'; + // ── A small library on disk, built once ─────────────────────────── let built = null; @@ -125,6 +133,11 @@ async function builtLibrary() { await put('machines/turing/busy-beaver/bb2.automaton', docFromStandardTM('1RB1LB_1LA1RZ', { title: 'BB(2) champion', author: 'alice', tags: ['busy-beaver'] })); await put('machines/turing/non-halting/cycler.automaton', docFromStandardTM('0LB1RZ_1RA1RA_1RC1RB', { title: 'Two cells, forever', author: 'alice' })); await put('collections/parity.json', { title: 'Parity', blurb: 'Counting mod 2.', curator: 'alice', entries: ['finite/dfa/even-ones', 'finite/dfa/liar', 'finite/dfa/odd-ones'] }); + // Essays: beside a machine, beside a collection, and beside a machine that fails. + await put('machines/turing/busy-beaver/bb2.md', ESSAY_BB2); + await put('collections/parity.md', ESSAY_PARITY); + await put('machines/finite/dfa/liar.md', 'Never published: its machine failed.'); + await put('machines/finite/dfa/odd-ones.md', 'It runs {{steps}} steps; see [elsewhere](lib:no/such/entry).\n\n::: spacetime\nA run it does not have.\n:::'); const out = await buildLibrary({ library: root, commit: '', site: 'https://example.test/lib/' }); built = { root, ...out, index: context.normalizeIndex(JSON.parse(JSON.stringify(out.raw))) }; return built; @@ -307,6 +320,51 @@ test('the published site serves only the files the index lists', async () => { await assert.rejects(readFile(join(out, 'machines/finite/dfa/liar.automaton'), 'utf8'), 'a file that failed is not served'); }); +test('an essay is listed beside its machine or collection, hashed, and published only with what passed', async () => { + const b = await builtLibrary(); + const bb2 = b.index.entries.find(e => e.id === 'turing/busy-beaver/bb2'); + assert.deepEqual(bb2.essay, { path: 'machines/turing/busy-beaver/bb2.md', hash: context.contentHash(ESSAY_BB2), minutes: 1 }); + assert.equal(b.index.collections.find(c => c.id === 'parity').essay.path, 'collections/parity.md'); + assert.equal(b.index.entries.find(e => e.id === 'finite/dfa/even-ones').essay, null, 'no file, no essay'); + assert.equal(bb2.hash, context.contentHash(await readFile(join(b.root, bb2.path), 'utf8')), 'the machine\'s own hash is the machine\'s alone'); + const out = await mkdtemp(join(tmpdir(), 'as-out-')); + await writeLibrary({ library: b.root, out, noSite: true }, b); + assert.equal(await readFile(join(out, 'machines/turing/busy-beaver/bb2.md'), 'utf8'), ESSAY_BB2); + assert.equal(await readFile(join(out, 'collections/parity.md'), 'utf8'), ESSAY_PARITY); + await assert.rejects(readFile(join(out, 'machines/finite/dfa/liar.md'), 'utf8'), 'an essay beside a failing machine is not served'); +}); + +test('the build reports what is wrong with an essay on its entry, and still publishes it', async () => { + const b = await builtLibrary(); + const r = b.results.find(x => x.id === 'finite/dfa/odd-ones'); + assert.deepEqual(r.errors, []); + assert.deepEqual(r.warnings.filter(w => w.startsWith('Essay:')), [ + 'Essay: {{steps}} is not a fact the library knows.', + 'Essay: Link "lib:no/such/entry" names nothing in the library.', + 'Essay: ::: spacetime is not a figure this machine can draw.' + ]); + assert.ok(b.index.entries.some(e => e.id === 'finite/dfa/odd-ones' && e.essay), 'a sentence to fix is not a reason to unpublish'); + assert.match(reportMarkdown(b.results), /⚠️ Essay: \{\{steps\}\} is not a fact/); + const clean = b.results.find(x => x.id === 'turing/busy-beaver/bb2'); + assert.deepEqual(clean.warnings.filter(w => w.startsWith('Essay:')), [], 'every fact, figure and link in BB(2)\'s essay resolves'); +}); + +test('the website sets an essay on its listing, with the library\'s numbers in it', 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, 'm/turing/busy-beaver/bb2/index.html'), 'utf8'); + assert.match(page, /
    /); + assert.match(page, /It halts after ]*>6<\/span> steps with ]*>4<\/span> ones\./); + assert.match(page, /It is ]*>2 × 2<\/span>/); + assert.match(page, /href="\.\.\/\.\.\/\.\.\/\.\.\/c\/parity\/"/, 'a lib: link is a link to the collection\'s page, four levels up from m/turing/busy-beaver/bb2/'); + assert.match(page, /Figure 1\.<\/span> Its whole run\./); + assert.match(page, /