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:
istos
2026-07-31 16:28:53 +02:00
co-authored by Claude Opus 5
parent b0b0927de6
commit 38745ae0b3
8 changed files with 1082 additions and 24 deletions
+51 -3
View File
@@ -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
View File
@@ -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
+43
View File
@@ -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
View File
@@ -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}
+139
View File
@@ -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>
+1 -1
View File
@@ -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.
+13 -9
View File
@@ -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():
+560
View File
@@ -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: &lt;number&gt; → &lt;stage&gt; "
"(&lt;name&gt;)</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("&lt;git user.name&gt;", 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("&quot;v&quot;: 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()