From 38745ae0b37ac9de934b6d5bf31f1ca8f4b830ee Mon Sep 17 00:00:00 2001 From: istos Date: Fri, 31 Jul 2026 16:28:53 +0200 Subject: [PATCH] site: add the reference section, generated from the settings file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third design layout (1c Logbook) had no page. This lands it, and with it the four routes a reader reaches for when the guides have stopped answering: /reference/settings and the three contracts a project implements against. The settings page is parsed, not transcribed. site/build.py grows a second way to make a body — a page may say "generate": "settings" instead of a "from" heading — and manager/core/.env.example is read on every build: its blank-line blocks become the page's entries, a comment block documents the keys directly under it, and the file's own NAME=value lines are the defaults shown. A key with no comment above it, or one set twice, stops the build naming the route; a settings page that disagrees with the settings file is worse than no settings page. Generated bodies render with raw HTML off. The file writes meaning a placeholder, and a parser honouring HTML swallows it — it was never written to be markdown. The reference layout is the article's three columns with the contents gutter given over to a console: every entry on the page once more in the machine register, keys with their defaults on the settings page and headings on a sliced one. It links the same anchors "On this page" carries, so the 1080px step folds the whole column away and the contents strip hands them back — no third copy of anything, and the responsive rules already in the sheet cover it. The contract pages are slices like every other page: the adapter contract and its event schema out of manager/core/adapters/README.md, the driver and local-command contracts out of AGENTS.md. Renaming any of those headings stops the build naming its route. tests/test_site_reference.py holds the promise: add a key to .env.example with its comment, rebuild, and it is on the page with its default, with nothing in site/ edited. Co-Authored-By: Claude Opus 5 --- site/README.md | 54 +++- site/build.py | 223 +++++++++++++- site/pages.json | 43 +++ site/static/site.css | 63 +++- site/templates/reference.html | 139 +++++++++ tests/test_site_build.py | 2 +- tests/test_site_pages.py | 22 +- tests/test_site_reference.py | 560 ++++++++++++++++++++++++++++++++++ 8 files changed, 1082 insertions(+), 24 deletions(-) create mode 100644 site/templates/reference.html create mode 100644 tests/test_site_reference.py diff --git a/site/README.md b/site/README.md index 9cda3c7..5ea49a0 100644 --- a/site/README.md +++ b/site/README.md @@ -2,9 +2,11 @@ A static minisite whose content is *generated* from the files that already define bench. Nothing here is transcribed prose: every page body -is a heading slice of `AGENTS.md`, `README.md`, -`manager/core/.env.example` or `manager/core/adapters/README.md`, and -`pages.json` is the only place that mapping is written down. +is a heading slice of `AGENTS.md`, `README.md` or +`manager/core/adapters/README.md` — or, where the source is not markdown +at all, built from it by a named generator (`manager/core/.env.example` +becomes `/reference/settings`) — and `pages.json` is the only place that +mapping is written down. That is the point. Rename a section in `AGENTS.md` and this build stops, naming the route and the heading it can no longer find. A documented @@ -220,6 +222,48 @@ answers rather than files: - **`lede`** is optional, and only worth setting when the sentence a reader should see differs from the one a search engine should. It is the only prose a page may author. +- **`generate`** replaces `from`/`to` when the source is not markdown. + It names a generator in `build.py`'s `GENERATORS`; `settings` is the + only one, and it turns an env file into a page. A page cannot be both + a slice and a generated page, and a `generate` naming nothing stops + the build listing what exists. + +## The settings page is parsed, not transcribed + +`/reference/settings` is `manager/core/.env.example` read on every +build. The file's own shape is the page's: blocks separated by blank +lines, a comment block documenting the keys directly under it, a comment +block with no keys below it kept as a remark. One `##` entry per group — +so the four `BOARD_AGENT_MODEL*` keys, which share a comment in the +file, share a heading here — opening with the file's own `NAME=value` +lines and followed by that group's comment as prose. + +Two failures rather than two silences: **a key with no comment above it +stops the build**, and so does a key set twice. A settings page that +disagrees with the settings file is worse than no settings page, so +neither can happen quietly. Generated bodies are also rendered with raw +HTML off — a comment writes `` meaning a placeholder, and +a parser honouring HTML would swallow it. + +`tests/test_site_reference.py` holds the promise a reader cares about: +add a key to `.env.example` with its comment, rebuild, and it is on the +page with its default, with nothing in `site/` edited. + +## The reference layout + +`/reference/*` renders in `templates/reference.html` (1c Logbook), which +is the article's three columns with the contents gutter given over to a +**console**: the page's entries once more in the machine register, each +linking to its own anchor. On the settings page those are the keys with +their defaults, one line per key; on a sliced contract page they are its +headings. Nothing about it is per-page authoring — a page whose body has +no entries renders no console and keeps the gutter's links. + +The console is the same anchors "On this page" carries, which is why the +1080px step can fold the entire column away: the contents strip already +hands them back. Everything else on the page — the sidebar, the folded +menus, prev/next, "Edit this page" — is the site's furniture, not the +layout's. The `from` heading itself is dropped — the layout renders the page title — and what remains is promoted by `level - 1`, so a section's `###` @@ -235,6 +279,10 @@ Each of these exits non-zero with a message naming the route: - a `source` file that no longer exists; - a `from` or `to` heading the source no longer contains; - a slice that comes out empty; +- a `generate` naming no generator, or set on a page that is also a + slice or has no source; +- a setting in `.env.example` with no comment above it, one set twice, + or a line there that is neither a comment nor `NAME=value`; - a markdown link to a repo path that does not exist, or that escapes the repo — a dead relative link must never reach the site; - an internal link on any rendered page — a door on the landing page as diff --git a/site/build.py b/site/build.py index a0c034e..adbf066 100644 --- a/site/build.py +++ b/site/build.py @@ -25,6 +25,21 @@ the section's own `###` sub-headings land as the page's `

`s. The `from` heading itself is dropped: the layout renders the page title from the manifest, and a body that repeated it would say it twice. +## The other way a body is made: a generator + +A slice needs a source that is already markdown. `manager/core/.env.example` +is not — it is the settings file, and it documents every key in the +comment above it. A page may therefore say `"generate": "settings"` +instead of `"from"`, and the builder turns that file into markdown +itself (see GENERATORS). It is the same promise by other means: nobody +transcribes a default into this directory, and a key nothing documents +stops the build rather than reaching the site bare. + +Generated bodies are rendered with raw HTML disabled. A settings comment +writes `` meaning a placeholder, and a markdown parser +that honours HTML would swallow it as a tag — the file was never written +to be markdown, so the builder does not let it be misread as any. + Templates are `string.Template`, so placeholders are `$name` and a literal dollar is `$$` — `str.format` was not an option with a stylesheet's worth of braces in play. @@ -102,6 +117,10 @@ STAMPED = {"stylesheet": "static/site.css", "icon": "static/favicon.svg"} ATX = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$") FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})") +# A setting in an env file: the shape shell and .env agree on. Values are +# taken verbatim to the end of the line, including an empty one — an +# empty default is a default, and BOARD_TITLE= says so. +SETTING = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$") CSS_URL = re.compile(r"""url\(\s*["']?([^"')]+)["']?\s*\)""") LINK = re.compile(r"""(?:href|src)=["']([^"']+)["']""", re.IGNORECASE) COMMENT = re.compile(r"\s{2,}#") @@ -218,6 +237,122 @@ def slice_section(text: str, page: dict, source: str) -> str: return promote(body, level - 1) +# ── generated bodies ────────────────────────────────────────────────── + +def env_blocks(text: str, source: str) -> list: + """`manager/core/.env.example` as the blocks a blank line separates: + `{"comment": [str, …], "keys": [(name, value, line), …]}`. + + That is the file's own grouping and the only one there is — the four + `BOARD_AGENT_MODEL*` keys share one comment because they sit under + one, with no blank line between them. Blank comment lines (a bare + `#`) survive as empty strings, because they are the paragraph breaks + inside a comment.""" + blocks: list = [] + block = {"comment": [], "keys": []} + + def flush(): + if block["comment"] or block["keys"]: + blocks.append(block) + + for number, line in enumerate(text.splitlines(), 1): + if not line.strip(): + flush() + block = {"comment": [], "keys": []} + continue + if line.startswith("#"): + # A comment after this block's keys opens the next entry, + # even with no blank line between them. + if block["keys"]: + flush() + block = {"comment": [], "keys": []} + block["comment"].append(line[1:].strip()) + continue + found = SETTING.match(line) + if not found: + raise BuildError( + f"{source}:{number}: {line.strip()!r} is neither a comment " + f"nor a NAME=value setting. The settings page is generated " + f"from this file, so it has to stay one.") + block["keys"].append((found.group(1), found.group(2), line)) + flush() + return blocks + + +def comment_paragraphs(lines: list) -> list: + """A comment block as markdown paragraphs. The bare `#` lines are the + author's paragraph breaks; everything else keeps its own line + endings, which markdown treats as the soft wraps they are.""" + paragraphs, current = [], [] + for line in lines: + if line: + current.append(line) + elif current: + paragraphs.append("\n".join(current)) + current = [] + if current: + paragraphs.append("\n".join(current)) + return paragraphs + + +def quote(paragraphs: list) -> str: + """A comment that documents no key — the file's own preamble, the + note about `checks` — as a blockquote, so the page keeps saying which + words belong to a setting and which stand on their own.""" + return "\n>\n".join("> " + text.replace("\n", "\n> ") + for text in paragraphs) + + +def generate_settings(text: str, page: dict, source: str): + """(markdown, [(console label, heading), …]) for an env file. + + One `##` entry per group the file makes, headed by the key or keys it + documents and opening with those keys exactly as the file writes + them — the default is the line, not a retyping of it. The labels are + what the layout pins beside the page, one per key rather than one per + group, because the question this page answers is about a key.""" + route = page["path"] + body, labels, seen = [], [], {} + for block in env_blocks(text, source): + paragraphs = comment_paragraphs(block["comment"]) + if not block["keys"]: + body.append(quote(paragraphs)) + continue + names = [name for name, _, _ in block["keys"]] + if not paragraphs: + raise BuildError( + f'{route}: {source} sets {", ".join(names)} with no comment ' + f"above it. Every setting on this page is its own " + f"documentation — document it there, or it cannot be " + f"generated here.") + heading = ", ".join(names) + for name, value, line in block["keys"]: + if name in seen: + raise BuildError( + f"{route}: {source} sets {name} twice (under " + f'"{seen[name]}" and "{heading}"). A setting has one ' + f"default and one place that says so.") + seen[name] = heading + labels.append((f"{name}={value}", heading)) + lines = "\n".join(line for _, _, line in block["keys"]) + # Fenced as `env` rather than bare, so the stylesheet can tell a + # default from a code block: one is a value you set, the other is + # a terminal, and the design draws them differently. + body.append(f"## {heading}\n\n```env\n{lines}\n```") + body.extend(paragraphs) + + if not labels: + raise BuildError( + f"{route}: {source} documents no settings at all. A settings " + f"page with nothing on it is a drift, not a page.") + return "\n\n".join(part for part in body if part.strip()), labels + + +# What a page may ask for instead of a `from` heading. The manifest names +# one of these; anything else is a build failure naming what exists. +GENERATORS = {"settings": generate_settings} + + # ── facts read out of the repo ──────────────────────────────────────── def read_version(repo: Path) -> str: @@ -395,9 +530,13 @@ def slugify(text: str) -> str: def render_markdown(body: str, *, page: dict, source: str, manifest: dict, - repo: Path): + repo: Path, allow_html: bool = True): """(html, [(slug, text)] for the h2s) — heading ids and rewritten - links are done on the token stream, not with regexes over HTML.""" + links are done on the token stream, not with regexes over HTML. + + `allow_html` is off for generated bodies: a file that was never + written as markdown says `` meaning a placeholder, and + a parser honouring HTML would drop it into the page as a tag.""" try: from markdown_it import MarkdownIt except ImportError as missing: # pragma: no cover - environment @@ -406,7 +545,8 @@ def render_markdown(body: str, *, page: dict, source: str, manifest: dict, "dependency: python3 -m pip install -r site/requirements.txt" ) from missing - renderer = MarkdownIt("commonmark").enable(["table", "strikethrough"]) + renderer = MarkdownIt("commonmark", {"html": allow_html}).enable( + ["table", "strikethrough"]) tokens = renderer.parse(body) contents, seen = [], {} for index, token in enumerate(tokens): @@ -522,12 +662,51 @@ def render_flow(manifest: dict, current: dict) -> str: return "\n".join(out) +def label(text: str) -> str: + """A heading as a nav label. The backticks a heading in a contract + file wears — "`run` — execute one headless job" — are markdown for + the body, and the body renders them; a list of links is not markdown, + so it would show them as punctuation.""" + return text.replace("`", "") + + def render_contents(contents: list) -> str: if not contents: return "" out = ['On this page'] for slug, text in contents: - out.append(f'{escape(text)}') + out.append(f'' + f"{escape(label(text))}") + return "\n".join(out) + + +def render_console(labels: list, contents: list, page: dict) -> str: + """The reference layout's pinned console: one mono line per entry on + the page, each linking to it. + + A settings page passes its own labels — `BOARD_PORT=26071`, one per + key rather than one per heading, because the question is about a key + — and every other page falls back to its headings. Either way the + anchors come from the rendered body, so a line here cannot point at + a heading the page does not have.""" + anchors = {text: slug for slug, text in contents} + if labels is None: + labels = [(text, text) for _, text in contents] + if not labels: + return "" + out = [] + for text, heading in labels: + slug = anchors.get(heading) + if slug is None: # only reachable if a generator invents a heading + raise BuildError( + f'{page["path"]}: the console lists "{text}" under a heading ' + f'"{heading}" that the page does not have.') + name, sign, value = text.partition("=") + line = f'{escape(label(name))}' + if sign: + line += (f'=' + f'{escape(value)}') + out.append(f'{line}') return "\n".join(out) @@ -574,7 +753,17 @@ def stamp(site: Path) -> dict: def render_page(page: dict, manifest: dict, *, site: Path, repo: Path, stamps: dict = None, facts: dict = None) -> str: source = page.get("source") - if source: + generator = page.get("generate") + labels = None + if source and generator: + # Not markdown in the repo, so not a slice: the builder makes the + # markdown from the file and renders it with HTML off. + markdown, labels = GENERATORS[generator]( + read_source(page, repo), page, source) + body, contents = render_markdown( + markdown, page=page, source=source, manifest=manifest, repo=repo, + allow_html=False) + elif source: body, contents = render_markdown( slice_section(read_source(page, repo), page, source), page=page, source=source, manifest=manifest, repo=repo) @@ -595,7 +784,9 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path, source_url = config["repo_url"] if source: source_url = blob + source - anchor = github_anchor(page["from"]) + # A generated page is the whole file, so there is no section to + # open at — the link lands on the file itself. + anchor = github_anchor(page["from"]) if page.get("from") else "" if anchor: source_url += "#" + anchor @@ -618,6 +809,7 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path, "install_block": facts["install_block"], "body": body, "toc": render_contents(contents), + "console": render_console(labels, contents, page), "nav": render_nav(manifest, page), "sidebar": render_sidebar(manifest, page), "flow": render_flow(manifest, page), @@ -686,14 +878,29 @@ def load_manifest(site: Path) -> dict: raise BuildError( f'{route}: no "source". A page generated from a file names ' f'it; an authored page says "source": null.') - if page["source"] and not page.get("from"): + if page["source"] and not (page.get("from") or page.get("generate")): raise BuildError( f'{route}: "source" is {page["source"]} but there is no ' - f'"from" heading to slice from.') + f'"from" heading to slice from, and no "generate" to build ' + f"the body with.") if not page["source"] and (page.get("from") or page.get("to")): raise BuildError( f'{route}: "source" is null, so "from"/"to" have nothing ' f"to slice. Remove them or name a source.") + if page.get("generate"): + if not page["source"]: + raise BuildError( + f'{route}: "generate" is {page["generate"]} but there is ' + f"no source file to generate the page from.") + if page.get("from") or page.get("to"): + raise BuildError( + f'{route}: a generated page is not a slice, so "from"/' + f'"to" have nothing to do. Remove them, or remove ' + f'"generate".') + if page["generate"] not in GENERATORS: + raise BuildError( + f'{route}: no generator named "{page["generate"]}". ' + f'Known: {", ".join(sorted(GENERATORS))}.') return manifest diff --git a/site/pages.json b/site/pages.json index 500da5a..eb7abef 100644 --- a/site/pages.json +++ b/site/pages.json @@ -111,6 +111,49 @@ "from": "## Agent adapters", "to": "## Drives" }, + { + "path": "/reference/settings/", + "title": "Settings", + "layout": "reference", + "section": "Reference", + "description": "Every BOARD_* setting with its default and what changing it does — generated from manager/core/.env.example, which is where the defaults live.", + "lede": "The whole of manager/core/.env.example, read out of the file itself on every build. Precedence runs process environment, then your manager/local/.env, then the defaults below — so a key you never copied is a key at the value shown here.", + "source": "manager/core/.env.example", + "generate": "settings" + }, + { + "path": "/reference/adapters/", + "title": "The adapter contract", + "layout": "reference", + "section": "Reference", + "description": "The two executables an adapter is — run and wire — the three launch intents, the model passthrough, and the normalized event schema.", + "lede": "What you implement to put a different coding agent behind the board. Two executables, three launch intents, and one event schema; the shipped claude/ and opencode/ adapters are both of these read side by side.", + "source": "manager/core/adapters/README.md", + "from": "## The contract", + "to": "## Writing one" + }, + { + "path": "/reference/driver/", + "title": "The driver contract", + "layout": "reference", + "section": "Reference", + "description": "manager/local/driver/start: the executable that launches your app from a task's worktree, and the four things the board asks of it.", + "lede": "How an app starts is project knowledge, so the board does not hold any of it: it runs one executable you write and owns it as a process group. Four env vars in, one line out when the app is up, and SIGTERM to park it.", + "source": "AGENTS.md", + "from": "## Drives", + "to": "## The activity bar and the archive" + }, + { + "path": "/reference/commands/", + "title": "The local-command contract", + "layout": "reference", + "section": "Reference", + "description": "manager/local/commands/: project chores run against a task's worktree, surfaced as chips on the cards that have a branch.", + "lede": "The driver's contract again, for the chores that belong to one checkout rather than to the app: migrations, reseeds, asset rebuilds. An executable in manager/local/commands/ becomes a chip on every card with a branch.", + "source": "AGENTS.md", + "from": "## Local commands", + "to": "## Updating" + }, { "path": "/404.html", "title": "Not found", diff --git a/site/static/site.css b/site/static/site.css index c24f36f..f746b72 100644 --- a/site/static/site.css +++ b/site/static/site.css @@ -174,7 +174,7 @@ a:hover{color:var(--text);text-decoration:underline} .crumb-here{color:var(--muted)} /* ── generated prose ── */ -.page-article .prose{padding:30px 40px 44px;min-width:0} +.page-article .prose,.page-reference .prose{padding:30px 40px 44px;min-width:0} /* The lede is the article's own sentence; everything after it is the slice. It reads wider and quieter than body copy, as in the design. */ .prose-lede{ @@ -259,6 +259,63 @@ a:hover{color:var(--text);text-decoration:underline} .flow-dir{font-size:var(--t-micro);letter-spacing:.08em;color:var(--dim)} .flow-title{font:600 15px/1.3 var(--display)} +/* ── reference layout (1c Logbook) ── + The article's three columns with the gutter given over to a console: + a reference page is scanned for one name rather than read down, so + every entry on it is listed once more in the machine register, with + the default it actually has. It links the same anchors "On this page" + does, which is why the 1080px step can fold the whole column away — + the contents strip already hands those anchors back. + + Ink, mono and one breathing dot: the console is the board's own + register, and this is a page about the file the board reads. A page + whose body has no entries renders the box with nothing in it, so — + as with the contents strip — the sheet drops it rather than pinning + an empty terminal beside the prose. */ +.console-well{position:sticky;top:20px} +.console{border-radius:12px;overflow:hidden;box-shadow:var(--shadow-ink)} +.console:not(:has(a)){display:none} +.console-bar{ + display:flex;align-items:center;gap:8px;padding:9px 12px; + background:var(--ink-deep);font:11px/1.3 var(--mono);color:var(--ink-dim); +} +.console-dot{ + width:6px;height:6px;border-radius:99px;background:var(--ink-accent); + flex:0 0 auto; +} +.console-name{ + color:var(--ink-muted);font-size:10.5px; + overflow:hidden;text-overflow:ellipsis;white-space:nowrap; +} +.console-body{ + display:flex;flex-direction:column;padding:8px 0; + max-height:min(64vh,520px);overflow:auto; + background:var(--ink); +} +/* One entry per line, and a line that does not fit scrolls inside the + console rather than wrapping: a key broken across two lines stops + looking like a key. */ +.console-line{ + padding:2px 12px;font:var(--t-code)/1.75 var(--mono); + color:var(--ink-muted);white-space:nowrap; +} +.console-line:hover{ + background:var(--ink-line);color:var(--ink-text);text-decoration:none; +} +.console-key{color:var(--ink-accent)} +.console-sign{color:var(--ink-dim)} +.console-value{color:var(--ink-text)} + +/* The flag under a setting's heading: the lines exactly as the file + writes them. It is a value you set, not a terminal that printed + something, so it takes the design's light strip rather than the ink + every other code block on the site wears — which is the whole reason + the generator fences these as `env`. */ +.prose pre:has(code.language-env){ + padding:12px 14px;background:var(--surface);color:var(--text); + border:1px solid var(--border-soft);border-radius:9px;box-shadow:none; +} + /* ── home layout (1b Dockside) ── */ .hero{ display:grid;grid-template-columns:1.05fr .95fr;gap:44px; @@ -487,7 +544,7 @@ a:hover{color:var(--text);text-decoration:underline} .shell{grid-template-columns:minmax(0,1fr)} .side{display:none} .menu{display:block} - .page-article .prose{padding:24px 20px 36px} + .page-article .prose,.page-reference .prose{padding:24px 20px 36px} .doors{grid-template-columns:minmax(0,1fr)} .snag-list{grid-template-columns:minmax(0,1fr);gap:18px} .masthead{padding:12px 18px;gap:12px;flex-wrap:wrap} @@ -532,7 +589,7 @@ a:hover{color:var(--text);text-decoration:underline} .doors{padding:0 16px 24px} .snags{padding:8px 16px 22px} .strip{padding:16px} - .page-article .prose{padding:20px 16px 32px} + .page-article .prose,.page-reference .prose{padding:20px 16px 32px} .menu-summary{padding:9px 14px} .footer{padding:18px 14px} .lost{padding:40px 16px 56px} diff --git a/site/templates/reference.html b/site/templates/reference.html new file mode 100644 index 0000000..9afe382 --- /dev/null +++ b/site/templates/reference.html @@ -0,0 +1,139 @@ + + + + + + +$title · $site_title + + + + + + + + + +
+ 12vectors + / + bench + + docs for $version +
+ +
+ + $site_title + $site_tagline + + + + GitHub ↗ +
+ + + + +
+ + + +
+
$breadcrumb
+

$title

+ + + +

$lede

+$body +$flow +
+ + + +
+ +
+ + + + github.com/12vectors/bench +
+ + + diff --git a/tests/test_site_build.py b/tests/test_site_build.py index 1545260..698e396 100644 --- a/tests/test_site_build.py +++ b/tests/test_site_build.py @@ -42,7 +42,7 @@ BUILDER = builder() # scratch repo without them fails for a reason that has nothing to do # with the test. SOURCES = ["AGENTS.md", "README.md", "manager/core/VERSION", - "manager/core/adapters/README.md"] + "manager/core/adapters/README.md", "manager/core/.env.example"] # A layout with no markup of its own, written into a scratch site when a # test wants to exercise the builder rather than a shipped template. diff --git a/tests/test_site_pages.py b/tests/test_site_pages.py index 25e39b9..1cde53f 100644 --- a/tests/test_site_pages.py +++ b/tests/test_site_pages.py @@ -74,9 +74,11 @@ class BuiltSite(unittest.TestCase): return BUILDER.target_for(self.out, route).read_text("utf-8") def articles(self) -> list: - """Every entry on the flow — the guides and the concepts, which - are exactly the pages this task built.""" - return list(self.flow) + """Every entry on the flow that renders in the 1a article layout + — the guides and the concepts. The reference section rides a + layout of its own; tests/test_site_reference.py is its half of + this file.""" + return [page for page in self.flow if page["layout"] == "article"] class EveryRouteRendersInTheArticleLayout(BuiltSite): @@ -111,14 +113,16 @@ class EveryRouteRendersInTheArticleLayout(BuiltSite): f'{entry["path"]} is missing {furniture}') def test_no_body_is_authored_twice(self): - """Every article names a source file and a heading to cut from. - The lede is the single exception, and it is one sentence in the - manifest — not a body.""" - for entry in self.articles(): + """Every page on the flow names a source file, and either the + heading to cut from or the generator that builds the body out of + it. The lede is the single exception, and it is one sentence in + the manifest — not a body.""" + for entry in self.flow: self.assertTrue(entry.get("source"), f'{entry["path"]} has no source') - self.assertTrue(entry.get("from"), - f'{entry["path"]} has no from heading') + self.assertTrue(entry.get("from") or entry.get("generate"), + f'{entry["path"]} has neither a from heading ' + f"nor a generator") def test_the_lede_is_present_and_is_the_manifests_own_sentence(self): for entry in self.articles(): diff --git a/tests/test_site_reference.py b/tests/test_site_reference.py new file mode 100644 index 0000000..eb4d18b --- /dev/null +++ b/tests/test_site_reference.py @@ -0,0 +1,560 @@ +"""The reference section: /reference/settings and the three contract +pages, in the 1c Logbook layout. + +The other site suites are about slices — a heading renamed in AGENTS.md +stops the build. This one is about the promise a *settings* page makes, +which is stronger and easier to break quietly: the page is not written, +it is read out of `manager/core/.env.example`, so a key added there +appears here with nobody editing site/, and a key documented nowhere +fails the build rather than reaching the site bare. + +The rest is the layout: the pinned console carries the page's own +entries and nothing it invented, and a reference page keeps every piece +of furniture an article has — the sidebar, the folded menus, prev/next +and "Edit this page". + + python3 -m unittest discover -s tests +""" + +import json +import re +import shutil +import tempfile +import unittest +from pathlib import Path + +from tests.test_site_build import (BUILDER, HAS_MARKDOWN_IT, REPO, SITE, + ScratchCase, needs_renderer, run_build) + +ENV_EXAMPLE = "manager/core/.env.example" + +CONSOLE_LINE = re.compile( + r'(.*?)', re.S) +CONSOLE_KEY = re.compile(r'([^<]*)') +CONSOLE_VALUE = re.compile(r'([^<]*)') +HEADING_ID = re.compile(r'

(.*?)

', re.S) +SIDE_HERE = re.compile(r'class="side-link side-here" href="([^"]+)"') +NAV = re.compile(r'', re.S) +CONTENTS = re.compile( + r'', re.S) + + +def settings(text: str) -> dict: + """{name: value} for every NAME=value line in an env file, read the + naive way on purpose — the point of comparing against it is that it + shares no code with the builder's own parser.""" + found = {} + for line in text.splitlines(): + if line.startswith("#") or "=" not in line.strip(): + continue + name, _, value = line.partition("=") + if name.strip() and name.strip() == name: + found[name] = value + return found + + +class BuiltSite(unittest.TestCase): + """The real manifest, built once into a scratch directory.""" + + @classmethod + def setUpClass(cls): + if not HAS_MARKDOWN_IT: + raise unittest.SkipTest("markdown-it-py is not installed") + cls.out = Path(tempfile.mkdtemp(prefix="bench-reference-")).resolve() + result = run_build(REPO, cls.out) + if result.returncode != 0: # not assert: must survive python -O + raise RuntimeError( + f"site/build.py failed:\n{result.stdout}{result.stderr}") + cls.manifest = json.loads( + (SITE / "pages.json").read_text(encoding="utf-8")) + cls.env = (REPO / ENV_EXAMPLE).read_text(encoding="utf-8") + + @classmethod + def tearDownClass(cls): + if hasattr(cls, "out"): + shutil.rmtree(cls.out, ignore_errors=True) + + def page(self, route: str) -> str: + return BUILDER.target_for(self.out, route).read_text("utf-8") + + def entries(self) -> list: + return [page for page in self.manifest["pages"] + if page["layout"] == "reference"] + + +class TheSectionJoinsTheSite(BuiltSite): + """Reference is a section beside Guides and Concepts — in the nav, in + the sidebar and on the reading order the arrows walk.""" + + def test_the_four_routes_are_all_there(self): + """Named one by one rather than counted: a route quietly dropped + from the manifest is exactly the failure this catches.""" + routes = {page["path"] for page in self.entries()} + for route in ("/reference/settings/", "/reference/adapters/", + "/reference/driver/", "/reference/commands/"): + self.assertIn(route, routes) + self.assertTrue(BUILDER.target_for(self.out, route).is_file(), + f"{route} produced no page") + + def test_reference_is_a_section_of_its_own(self): + names = [group["name"] for group in BUILDER.sections(self.manifest)] + self.assertEqual(["Guides", "Concepts", "Reference"], names) + + def test_the_nav_reaches_it_from_a_concept_page(self): + html = self.page("/concepts/stages/") + self.assertIn('href="/reference/settings/">Reference', html) + + def test_a_reference_page_marks_itself_in_the_sidebar(self): + for entry in self.entries(): + here = SIDE_HERE.findall(self.page(entry["path"])) + self.assertTrue(here, f'{entry["path"]} does not mark itself') + self.assertEqual({entry["path"]}, set(here), entry["path"]) + + def test_it_is_on_the_flow_the_arrows_walk(self): + order = [page["path"] for page in BUILDER.flow(self.manifest)] + for entry in self.entries(): + self.assertIn(entry["path"], order) + self.assertEqual("/reference/commands/", order[-1], + "the reference section is not the end of the flow") + + +class TheSettingsPageIsTheEnvFile(BuiltSite): + """Acceptance: every setting in .env.example, exactly once, with the + default the file actually gives it.""" + + def setUp(self): + self.html = self.page("/reference/settings/") + self.expected = settings(self.env) + + def test_it_reads_as_a_settings_file_rather_than_a_short_list(self): + """Guards every test below from passing on an empty page.""" + self.assertGreater(len(self.expected), 15, + "the env example has almost no settings in it") + self.assertIn("BOARD_PORT", self.expected) + self.assertIn("BENCH_SOURCE", self.expected) + + def test_every_setting_appears_exactly_once_in_the_console(self): + listed = [CONSOLE_KEY.search(line).group(1) + for _, line in CONSOLE_LINE.findall(self.html)] + self.assertEqual(sorted(self.expected), sorted(listed), + "the console and the env file disagree about " + "which settings exist") + self.assertEqual(len(listed), len(set(listed)), + "a setting is listed twice") + + def test_each_one_carries_the_default_the_file_gives_it(self): + shown = {} + for _, line in CONSOLE_LINE.findall(self.html): + value = CONSOLE_VALUE.search(line) + shown[CONSOLE_KEY.search(line).group(1)] = \ + value.group(1) if value else None + for name, value in self.expected.items(): + self.assertEqual(value, shown[name], + f"{name}'s default on the page is not the " + f"one in {ENV_EXAMPLE}") + + def test_an_empty_default_is_shown_as_empty_rather_than_dropped(self): + """BOARD_TITLE= is a default: the board falls back to the repo + directory's name. A page that skipped the line would read as a + setting with no default at all.""" + self.assertEqual("", self.expected["BOARD_TITLE"]) + self.assertIn('BOARD_TITLE' + '=' + '', self.html) + + def test_every_console_line_points_at_a_heading_on_the_page(self): + anchors = {slug for slug, _ in HEADING_ID.findall(self.html)} + targets = {slug for slug, _ in CONSOLE_LINE.findall(self.html)} + self.assertTrue(targets) + self.assertEqual(set(), targets - anchors, + "a console line links to an anchor that is not " + "on the page") + + def test_the_keys_are_grouped_the_way_the_file_groups_them(self): + """The four model keys sit under one comment in the file, so they + are one entry on the page — and the three headings below are the + shape "grouped as that file groups them" takes.""" + headings = [text for _, text in HEADING_ID.findall(self.html)] + self.assertIn("BOARD_PORT", headings) + self.assertIn("BOARD_CLAUDE_BIN, BOARD_OPENCODE_BIN", headings) + self.assertIn("BOARD_AGENT_MODEL, BOARD_AGENT_MODEL_WORK, " + "BOARD_AGENT_MODEL_ACT_PR, BOARD_AGENT_MODEL_REVIEW", + headings) + + def test_a_default_is_the_files_own_line_and_says_it_is_one(self): + """The flag under each heading is the line copied out of the + file, not a retyping of the value — and it is fenced as `env` so + the stylesheet can draw a value you set differently from a + terminal that printed something.""" + self.assertIn('
BOARD_AGENT_COMMANDS='
+                      "python3 -m unittest\n
", self.html) + css = (SITE / "static" / "site.css").read_text("utf-8") + self.assertIn("code.language-env", css, + "nothing in the stylesheet tells a default from a " + "code block") + + def test_the_documentation_is_the_files_own_comment(self): + """Not a paraphrase written into site/: sentences out of the + comment blocks, arriving as the markdown they were written as.""" + self.assertIn("Pinned by default so the URL is bookmarkable", + self.html) + self.assertIn("a test runner missing from this list", self.html) + self.assertIn("board: <number> → <stage> " + "(<name>)", self.html) + + def test_a_placeholder_in_a_comment_survives_as_text(self): + """`` is a placeholder, and a markdown parser that + honoured HTML would post it into the page as a tag and show + nothing. Generated bodies are rendered with HTML off.""" + self.assertIn("<git user.name>", self.html) + self.assertNotIn("", self.html) + + def test_a_comment_documenting_no_key_is_kept_as_a_remark(self): + """The note about the `checks` file sits between two settings and + belongs to neither. It stays on the page — it is documentation — + as a blockquote rather than as the next key's description.""" + self.assertIn("
", self.html) + note = self.html.split("
") + self.assertTrue( + any("definition-of-done check" in part for part in note), + "the note about the checks file is not on the page") + + def test_edit_this_page_opens_the_file_itself(self): + """There is no `from` heading to anchor at: the page is the whole + file.""" + blob = self.manifest["site"]["blob_base"].rstrip("/") + "/" + self.assertIn(f'href="{blob}{ENV_EXAMPLE}"', self.html) + + +class TheContractPagesAreSlices(BuiltSite): + """Acceptance: slices of the contract files, not paraphrases.""" + + def test_the_adapter_contract_is_the_adapters_readme(self): + html = self.page("/reference/adapters/") + self.assertIn("stdout is captured by the board as the job log", + html) + self.assertIn("work", html) + # The normalized event schema, indented in the README, arrives as + # the code block it is. + self.assertIn("
", html)
+        self.assertIn(""v": 1", html)
+
+    def test_the_driver_contract_is_agents_md(self):
+        html = self.page("/reference/driver/")
+        self.assertIn("refuse fast with a printed reason", html)
+        self.assertIn("DRIVE URL", html)
+
+    def test_the_command_contract_is_agents_md(self):
+        html = self.page("/reference/commands/")
+        self.assertIn("CMD_WORKTREE", html)
+        self.assertIn("Commands arm on first click and run on the second",
+                      html)
+
+    def test_each_one_edits_at_the_section_it_was_cut_from(self):
+        blob = self.manifest["site"]["blob_base"].rstrip("/") + "/"
+        for entry in self.entries():
+            if not entry.get("from"):
+                continue
+            wanted = (blob + entry["source"] + "#"
+                      + BUILDER.github_anchor(entry["from"]))
+            self.assertIn(f'href="{wanted}"', self.page(entry["path"]),
+                          f'{entry["path"]}: "Edit this page" does not open '
+                          f'{entry["source"]} at {entry["from"]}')
+
+    def test_a_pages_console_is_its_own_headings(self):
+        """The adapter page's entries are the contract's `###` headings,
+        promoted — so the console is generated for a sliced page too,
+        with no per-page authoring anywhere."""
+        html = self.page("/reference/adapters/")
+        anchors = dict(HEADING_ID.findall(html))
+        lines = CONSOLE_LINE.findall(html)
+        self.assertEqual(sorted(anchors), sorted(slug for slug, _ in lines))
+        self.assertIn("run — execute one headless job to completion",
+                      [CONSOLE_KEY.search(line).group(1) for _, line in lines],
+                      "the console shows the heading's markdown backticks")
+
+
+class TheLayoutKeepsEveryArticlesFurniture(BuiltSite):
+    """A reader must not lose the nav, the contents or the arrows by
+    walking into the reference section."""
+
+    def test_the_three_columns_are_there(self):
+        for entry in self.entries():
+            html = self.page(entry["path"])
+            for furniture in ('class="page-reference"', 'class="side"',
+                              'class="prose"', 'class="gutter"',
+                              'class="crumbs"', 'class="prose-lede"'):
+                self.assertIn(furniture, html,
+                              f'{entry["path"]} is missing {furniture}')
+
+    def test_the_section_nav_is_folded_in_as_well(self):
+        listed = [page for page in self.manifest["pages"]
+                  if page.get("section")]
+        for entry in self.entries():
+            menu = NAV.search(self.page(entry["path"]))
+            self.assertIsNotNone(menu, f'{entry["path"]} has no folded nav')
+            for page in listed:
+                self.assertIn(f'href="{page["path"]}"', menu.group(1),
+                              f'{entry["path"]}\'s menu cannot reach '
+                              f'{page["path"]}')
+
+    def test_the_folded_contents_carries_the_anchors_the_console_does(self):
+        """Below 1080px the whole gutter goes, console and all. The strip
+        that replaces it has to reach everything the console did, or a
+        phone loses the index entirely."""
+        for entry in self.entries():
+            html = self.page(entry["path"])
+            strip = CONTENTS.search(html)
+            self.assertIsNotNone(strip,
+                                 f'{entry["path"]} has no contents strip')
+            for slug, _ in CONSOLE_LINE.findall(html):
+                self.assertIn(f'href="#{slug}"', strip.group(1),
+                              f'{entry["path"]}\'s contents strip drops '
+                              f"#{slug}")
+
+    def test_the_arrows_are_rendered(self):
+        for entry in self.entries():
+            self.assertIn('