site: add the reference section, generated from the settings file
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 <git user.name> 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 <noreply@anthropic.com>
This commit is contained in:
+51
-3
@@ -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 `<git user.name>` 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
|
||||
|
||||
+215
-8
@@ -25,6 +25,21 @@ the section's own `###` sub-headings land as the page's `<h2>`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 `<git user.name>` 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 `<git user.name>` 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 = ['<span class="toc-label">On this page</span>']
|
||||
for slug, text in contents:
|
||||
out.append(f'<a class="toc-link" href="#{slug}">{escape(text)}</a>')
|
||||
out.append(f'<a class="toc-link" href="#{slug}">'
|
||||
f"{escape(label(text))}</a>")
|
||||
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'<span class="console-key">{escape(label(name))}</span>'
|
||||
if sign:
|
||||
line += (f'<span class="console-sign">=</span>'
|
||||
f'<span class="console-value">{escape(value)}</span>')
|
||||
out.append(f'<a class="console-line" href="#{slug}">{line}</a>')
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+60
-3
@@ -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}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<!doctype html>
|
||||
<!-- Layout "reference" — 1c Logbook from the turn-2 docs design: the
|
||||
article's three columns, with the contents gutter given over to a
|
||||
console pinned beside the page.
|
||||
|
||||
A reference page is not read down, it is scanned for one name, so
|
||||
the console carries every entry on the page in the machine register
|
||||
— for the settings page, each key with the default it actually has.
|
||||
It is the same anchors "On this page" carries, which is what lets
|
||||
the narrow steps fold it away and hand back the contents strip
|
||||
without writing a third copy of anything.
|
||||
|
||||
A page whose body has no entries gets no console and keeps the
|
||||
gutter's links, exactly as an article does. Everything else here is
|
||||
article.html's furniture, deliberately: the sidebar, the folded
|
||||
menus and the prev/next arrows are the site's, not a layout's.
|
||||
|
||||
Placeholders are string.Template's; a literal dollar in the markup
|
||||
would have to be doubled. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>$title · $site_title</title>
|
||||
<meta name="description" content="$description">
|
||||
<link rel="canonical" href="$canonical">
|
||||
<link rel="icon" href="$icon">
|
||||
<link rel="stylesheet" href="$stylesheet">
|
||||
<!-- Fathom — cookieless analytics: pageviews only, nothing about a
|
||||
person, so no banner. The one third-party request this site makes;
|
||||
root/_headers names cdn.usefathom.com in the CSP for exactly it. -->
|
||||
<script src="https://cdn.usefathom.com/script.js" data-site="ZPKDEHCV" defer></script>
|
||||
</head>
|
||||
<body class="page-reference">
|
||||
|
||||
<div class="topbar">
|
||||
<span class="topbar-org">12vectors</span>
|
||||
<span class="topbar-sep">/</span>
|
||||
<span class="topbar-repo">bench</span>
|
||||
<span class="spacer"></span>
|
||||
<span>docs for $version</span>
|
||||
</div>
|
||||
|
||||
<header class="masthead">
|
||||
<a class="wordmark" href="/">
|
||||
<span class="wordmark-name">$site_title</span>
|
||||
<span class="wordmark-tag">$site_tagline</span>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
$nav
|
||||
</nav>
|
||||
<span class="spacer"></span>
|
||||
<a class="button button-solid" href="$repo_url">GitHub ↗</a>
|
||||
</header>
|
||||
|
||||
<!-- The section nav, folded. Shown from 760px down, where the .side
|
||||
column below is gone. A <details> is the whole mechanism: one menu
|
||||
that opens and closes needs no script, and this site has none. -->
|
||||
<details class="menu">
|
||||
<summary class="menu-summary">Documentation</summary>
|
||||
<nav class="menu-panel">
|
||||
$sidebar
|
||||
</nav>
|
||||
</details>
|
||||
|
||||
<div class="shell">
|
||||
|
||||
<aside class="side">
|
||||
$sidebar
|
||||
<div class="side-note">
|
||||
<span class="mono dim">$version</span>
|
||||
<span>Generated from the repository's own markdown. What the docs
|
||||
say and what bench does cannot drift apart.</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<article class="prose">
|
||||
<div class="crumbs">$breadcrumb</div>
|
||||
<h1>$title</h1>
|
||||
<!-- "On this page", folded under the title. Shown from 1080px down,
|
||||
where the gutter that normally carries it is gone — here that is
|
||||
the gutter the console is pinned in, and these are its anchors.
|
||||
It sits above the lede on purpose: the stylesheet's lede
|
||||
adjacency names .menu-contents + p for exactly this order. -->
|
||||
<details class="menu menu-contents">
|
||||
<summary class="menu-summary">On this page</summary>
|
||||
<nav class="menu-panel">
|
||||
$toc
|
||||
</nav>
|
||||
</details>
|
||||
<!-- The one sentence a reference page authors. Everything below it
|
||||
is the body placeholder, built out of the file named in the
|
||||
gutter. (Placeholders substitute inside comments too, so this
|
||||
one cannot spell that name out.) -->
|
||||
<p class="prose-lede">$lede</p>
|
||||
$body
|
||||
$flow
|
||||
</article>
|
||||
|
||||
<aside class="gutter">
|
||||
<div class="console-well">
|
||||
<div class="console">
|
||||
<div class="console-bar">
|
||||
<span class="console-dot"></span>
|
||||
<span class="console-name mono">$source_path</span>
|
||||
</div>
|
||||
<div class="console-body">
|
||||
$console
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule"></div>
|
||||
<a class="gutter-link" href="$source_url">Edit this page on GitHub ↗</a>
|
||||
<a class="gutter-link" href="$issues_url">Open an issue ↗</a>
|
||||
</div>
|
||||
<!-- True of a sliced contract page and of the generated settings
|
||||
page alike, and still true on a page with too few entries to
|
||||
pin a console beside. -->
|
||||
<p class="marginalia">← every word and every default on this page is
|
||||
read out of <span class="mono">$source_path</span> on each build</p>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<pre class="footer-mark"> ___________________
|
||||
|___________________|
|
||||
|| ||
|
||||
|| ||</pre>
|
||||
<div class="footer-id">
|
||||
<span class="footer-name">$site_title</span>
|
||||
<span class="footer-line">Put the agents on the bench.</span>
|
||||
<span class="mono">a 12vectors product · built in the open</span>
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<a class="mono footer-repo" href="$repo_url">github.com/12vectors/bench</a>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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.
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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'<a class="console-line" href="#([^"]+)">(.*?)</a>', re.S)
|
||||
CONSOLE_KEY = re.compile(r'<span class="console-key">([^<]*)</span>')
|
||||
CONSOLE_VALUE = re.compile(r'<span class="console-value">([^<]*)</span>')
|
||||
HEADING_ID = re.compile(r'<h2 id="([^"]+)">(.*?)</h2>', re.S)
|
||||
SIDE_HERE = re.compile(r'class="side-link side-here" href="([^"]+)"')
|
||||
NAV = re.compile(r'<details class="menu">(.*?)</details>', re.S)
|
||||
CONTENTS = re.compile(
|
||||
r'<details class="menu menu-contents">(.*?)</details>', 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</a>', 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('<span class="console-key">BOARD_TITLE</span>'
|
||||
'<span class="console-sign">=</span>'
|
||||
'<span class="console-value"></span>', 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('<pre><code class="language-env">BOARD_AGENT_COMMANDS='
|
||||
"python3 -m unittest\n</code></pre>", 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("<code>board: <number> → <stage> "
|
||||
"(<name>)</code>", self.html)
|
||||
|
||||
def test_a_placeholder_in_a_comment_survives_as_text(self):
|
||||
"""`<git user.name>` 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("<git user.name>", 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("<blockquote>", self.html)
|
||||
note = self.html.split("<blockquote>")
|
||||
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("<code>work</code>", html)
|
||||
# The normalized event schema, indented in the README, arrives as
|
||||
# the code block it is.
|
||||
self.assertIn("<pre><code>", 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("<code>CMD_WORKTREE</code>", 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('<nav class="flow">', self.page(entry["path"]),
|
||||
f'{entry["path"]} has no prev/next')
|
||||
|
||||
def test_it_ships_no_script_of_its_own(self):
|
||||
for entry in self.entries():
|
||||
html = self.page(entry["path"])
|
||||
for tag in re.findall(r"<script\b[^>]*>", html, re.I):
|
||||
self.assertIn("cdn.usefathom.com", tag, entry["path"])
|
||||
|
||||
|
||||
@needs_renderer
|
||||
class ANewSettingReachesTheSiteByItself(ScratchCase):
|
||||
"""The headline acceptance: add a key to .env.example with its
|
||||
comment, rebuild, and it is on the page — no edit to site/ anywhere.
|
||||
"""
|
||||
|
||||
def page(self) -> str:
|
||||
return (self.repo.out / "reference" / "settings"
|
||||
/ "index.html").read_text("utf-8")
|
||||
|
||||
def append(self, block: str) -> None:
|
||||
path = self.repo.root / ENV_EXAMPLE
|
||||
path.write_text(path.read_text("utf-8") + block, encoding="utf-8")
|
||||
|
||||
def test_a_new_key_appears_with_its_default_and_its_comment(self):
|
||||
self.append("\n# How long the kettle boils for, in seconds. The\n"
|
||||
"# board waits this long before it gives up.\n"
|
||||
"BOARD_KETTLE_TIMEOUT=90\n")
|
||||
result = self.repo.build()
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
|
||||
html = self.page()
|
||||
self.assertIn('<h2 id="board-kettle-timeout">BOARD_KETTLE_TIMEOUT'
|
||||
"</h2>", html)
|
||||
self.assertIn("How long the kettle boils for", html)
|
||||
self.assertIn('<span class="console-key">BOARD_KETTLE_TIMEOUT</span>'
|
||||
'<span class="console-sign">=</span>'
|
||||
'<span class="console-value">90</span>', html)
|
||||
|
||||
def test_a_multi_line_comment_does_not_swallow_the_next_key(self):
|
||||
"""The edge case named on the card: two entries in a row, the
|
||||
first with a three-line comment. The second keeps its own
|
||||
heading, its own default and its own description."""
|
||||
self.append("\n# One. This comment runs to three lines and it\n"
|
||||
"# mentions BOARD_SECOND_KEY in passing, which is\n"
|
||||
"# exactly how a parser gets this wrong.\n"
|
||||
"BOARD_FIRST_KEY=first\n"
|
||||
"\n"
|
||||
"# Two. Its own comment, its own entry.\n"
|
||||
"BOARD_SECOND_KEY=second\n")
|
||||
result = self.repo.build()
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
|
||||
html = self.page()
|
||||
self.assertIn('<h2 id="board-first-key">BOARD_FIRST_KEY</h2>', html)
|
||||
self.assertIn('<h2 id="board-second-key">BOARD_SECOND_KEY</h2>', html)
|
||||
first = html.split('<h2 id="board-first-key">')[1]
|
||||
first, second = first.split('<h2 id="board-second-key">')
|
||||
self.assertIn("This comment runs to three lines", first)
|
||||
self.assertIn("BOARD_FIRST_KEY=first", first)
|
||||
self.assertNotIn("BOARD_SECOND_KEY=second", first)
|
||||
self.assertIn("Its own comment, its own entry.", second)
|
||||
self.assertNotIn("This comment runs to three lines", second)
|
||||
|
||||
def test_two_keys_under_one_comment_stay_one_entry(self):
|
||||
self.append("\n# A pair, documented together as the file does it.\n"
|
||||
"BOARD_LEFT=l\n"
|
||||
"BOARD_RIGHT=r\n")
|
||||
result = self.repo.build()
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
|
||||
html = self.page()
|
||||
self.assertIn("<h2 id=\"board-left-board-right\">BOARD_LEFT, "
|
||||
"BOARD_RIGHT</h2>", html)
|
||||
for name in ("BOARD_LEFT", "BOARD_RIGHT"):
|
||||
self.assertIn(f'<a class="console-line" href="#board-left-'
|
||||
f'board-right"><span class="console-key">{name}'
|
||||
f"</span>", html)
|
||||
|
||||
|
||||
@needs_renderer
|
||||
class ADriftingEnvFileStopsTheBuild(ScratchCase):
|
||||
"""A settings page that disagrees with the settings file is worse
|
||||
than no settings page, so every way they can disagree is a failure
|
||||
naming the route."""
|
||||
|
||||
def append(self, block: str) -> None:
|
||||
path = self.repo.root / ENV_EXAMPLE
|
||||
path.write_text(path.read_text("utf-8") + block, encoding="utf-8")
|
||||
|
||||
def test_a_key_documented_nowhere_fails_rather_than_being_skipped(self):
|
||||
self.append("\nBOARD_UNDOCUMENTED=1\n")
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode,
|
||||
"an undocumented setting built cleanly")
|
||||
self.assertIn("/reference/settings/", result.stderr)
|
||||
self.assertIn("BOARD_UNDOCUMENTED", result.stderr)
|
||||
self.assertFalse(self.repo.out.exists(),
|
||||
"a failed build wrote pages anyway")
|
||||
|
||||
def test_a_key_set_twice_fails(self):
|
||||
self.append("\n# Once.\nBOARD_PORT=1\n")
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("BOARD_PORT", result.stderr)
|
||||
self.assertIn("twice", result.stderr)
|
||||
|
||||
def test_a_line_that_is_neither_a_comment_nor_a_setting_fails(self):
|
||||
self.append("\nexport BOARD_PORT 26071\n")
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn(ENV_EXAMPLE, result.stderr)
|
||||
|
||||
def test_a_missing_env_file_is_readable(self):
|
||||
(self.repo.root / ENV_EXAMPLE).unlink()
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn(ENV_EXAMPLE, result.stderr)
|
||||
self.assertIn("does not exist", result.stderr)
|
||||
|
||||
def test_an_env_file_with_no_settings_at_all_fails(self):
|
||||
(self.repo.root / ENV_EXAMPLE).write_text(
|
||||
"# Nothing but prose in here.\n", encoding="utf-8")
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("/reference/settings/", result.stderr)
|
||||
|
||||
def test_renaming_the_command_contract_names_its_route(self):
|
||||
"""The contract pages are slices, so they drift like every other
|
||||
slice does — loudly. The heading renamed here is one exactly one
|
||||
entry names: "## Drives" would be reported against the concept
|
||||
page above it, which also ends there."""
|
||||
self.repo.edit("AGENTS.md", "## Local commands", "## Chores")
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("/reference/commands/", result.stderr)
|
||||
self.assertIn("## Local commands", result.stderr)
|
||||
|
||||
def test_renaming_where_the_driver_page_ends_names_its_route(self):
|
||||
self.repo.edit("AGENTS.md", "## The activity bar and the archive",
|
||||
"## The activity bar")
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("/reference/driver/", result.stderr)
|
||||
self.assertIn("## The activity bar and the archive", result.stderr)
|
||||
|
||||
def test_renaming_the_adapter_contract_heading_names_its_route(self):
|
||||
self.repo.edit("manager/core/adapters/README.md", "## The contract",
|
||||
"## The interface")
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("/reference/adapters/", result.stderr)
|
||||
|
||||
|
||||
@needs_renderer
|
||||
class TheManifestChecksAGeneratedPage(ScratchCase):
|
||||
"""`generate` is a second way to make a body, so it gets the same
|
||||
treatment `from` does: a mistake in pages.json is a build failure,
|
||||
not a surprise on the site."""
|
||||
|
||||
def one(self, page: dict):
|
||||
self.repo.pages(page)
|
||||
return self.repo.build()
|
||||
|
||||
def test_an_unknown_generator_lists_the_ones_that_exist(self):
|
||||
result = self.one({
|
||||
"path": "/x/", "title": "X", "layout": "reference",
|
||||
"section": "Reference", "source": ENV_EXAMPLE,
|
||||
"generate": "flags"})
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("flags", result.stderr)
|
||||
self.assertIn("settings", result.stderr)
|
||||
|
||||
def test_a_generator_without_a_source_is_refused(self):
|
||||
result = self.one({
|
||||
"path": "/x/", "title": "X", "layout": "reference",
|
||||
"section": "Reference", "source": None, "generate": "settings"})
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("generate", result.stderr)
|
||||
|
||||
def test_a_generated_page_may_not_also_be_a_slice(self):
|
||||
result = self.one({
|
||||
"path": "/x/", "title": "X", "layout": "reference",
|
||||
"section": "Reference", "source": ENV_EXAMPLE,
|
||||
"generate": "settings", "from": "## Anything"})
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("not a slice", result.stderr)
|
||||
|
||||
def test_a_source_with_neither_still_says_from(self):
|
||||
result = self.one({
|
||||
"path": "/x/", "title": "X", "layout": "reference",
|
||||
"section": "Reference", "source": ENV_EXAMPLE})
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("from", result.stderr)
|
||||
|
||||
|
||||
class TheEnvParserOnItsOwn(unittest.TestCase):
|
||||
"""The block rule, on strings rather than on the shipped file: a
|
||||
blank line separates entries, a comment belongs to the keys under it,
|
||||
and a bare `#` is a paragraph break inside one."""
|
||||
|
||||
def blocks(self, text: str) -> list:
|
||||
return BUILDER.env_blocks(text, "test.env")
|
||||
|
||||
def test_a_comment_binds_to_every_key_under_it(self):
|
||||
blocks = self.blocks("# One comment.\nA=1\nB=2\n")
|
||||
self.assertEqual(1, len(blocks))
|
||||
self.assertEqual(["One comment."], blocks[0]["comment"])
|
||||
self.assertEqual([("A", "1", "A=1"), ("B", "2", "B=2")],
|
||||
blocks[0]["keys"])
|
||||
|
||||
def test_a_blank_line_ends_an_entry(self):
|
||||
blocks = self.blocks("# One.\nA=1\n\n# Two.\nB=2\n")
|
||||
self.assertEqual([["One."], ["Two."]],
|
||||
[block["comment"] for block in blocks])
|
||||
|
||||
def test_a_comment_straight_after_a_key_opens_the_next_entry(self):
|
||||
blocks = self.blocks("# One.\nA=1\n# Two.\nB=2\n")
|
||||
self.assertEqual(2, len(blocks))
|
||||
self.assertEqual([("B", "2", "B=2")], blocks[1]["keys"])
|
||||
|
||||
def test_a_bare_hash_is_a_paragraph_break(self):
|
||||
blocks = self.blocks("# One.\n#\n# Two.\nA=1\n")
|
||||
self.assertEqual(["One.", "", "Two."], blocks[0]["comment"])
|
||||
self.assertEqual(["One.", "Two."],
|
||||
BUILDER.comment_paragraphs(blocks[0]["comment"]))
|
||||
|
||||
def test_a_comment_with_no_keys_is_a_block_of_its_own(self):
|
||||
blocks = self.blocks("# Just a note.\n\nA=1\n")
|
||||
self.assertEqual([], blocks[0]["keys"])
|
||||
self.assertEqual([], blocks[1]["comment"])
|
||||
|
||||
def test_a_value_containing_an_equals_sign_keeps_it(self):
|
||||
self.assertEqual([("A", "x=y", "A=x=y")],
|
||||
self.blocks("# c\nA=x=y\n")[0]["keys"])
|
||||
|
||||
def test_a_value_with_spaces_is_taken_verbatim(self):
|
||||
self.assertEqual([("A", "python3 -m unittest",
|
||||
"A=python3 -m unittest")],
|
||||
self.blocks("# c\nA=python3 -m unittest\n")[0]["keys"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user