site: the landing page, on things bench really does
Turn design 1b (Dockside) into `/`: a terminal hero, the claim README.md already makes, six doors, and a strip at the foot. The layout is task 31's; what this commit is really about is that nothing factual on the page is typed by hand. - **Two facts are read, not written.** `site/build.py` reads the install one-liner out of README.md's "Install into a repo" block and the version out of `manager/core/VERSION`, and offers them to the template as `$install_block` and `$version`. A renamed section, a missing VERSION or an install section that lost its command block stops the build, exactly as a renamed heading already did. `pages.json` loses its `version` key, and a build refuses one if it comes back. - **A dead internal link stops the build.** Every href a rendered page emits — a door as much as a link inside a slice — must resolve to a route in the manifest or a file in `static/`/`root/`. The check runs after rendering and before writing, so a bad link leaves the last good build standing rather than shipping a 404 with a nice typeface. - **Six real doors.** `pages.json` grows the routes they open: install and first run, the five stages, agents on the board, PRs and review, team mode, the three-layer law. They are heading slices, i.e. the stub routes task 33 expected and task 34 will re-cut. - **The terminal is a transcript.** README.md's own command, then lines install.py and board.py really print, with the abridgement declared in the terminal's title bar. `tests/test_site_landing.py` holds every one of those lines against the source that prints it, so a reworded prompt fails the suite instead of quietly making the page fiction. - **No fake telemetry.** Turn 1's "most opened this week" strip becomes the version, read from VERSION, and a link to the releases. The landing page no longer carries a generated body, so the tests that read one from it now read `/concepts/stages/`, and the two scratch repos in the suite copy VERSION alongside the markdown. python3 -m unittest: 407 tests, OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,13 @@ naming the route and the heading it can no longer find. A documented
|
||||
behaviour and the behaviour itself cannot drift apart when one is cut
|
||||
from the other.
|
||||
|
||||
The landing page is the exception that proves it. Its words are authored
|
||||
in `templates/home.html` (`"source": null`), so the two facts it must
|
||||
never get subtly wrong are not written there at all: the install
|
||||
one-liner is read out of `README.md`'s "Install into a repo" block and
|
||||
the version out of `manager/core/VERSION`, and the template is offered
|
||||
them as `$install_block` and `$version`.
|
||||
|
||||
```bash
|
||||
python3 -m pip install -r site/requirements.txt # once
|
||||
python3 site/fetch-fonts.py # once, needs network
|
||||
@@ -168,6 +175,13 @@ Each of these exits non-zero with a message naming the route:
|
||||
- a slice that comes out empty;
|
||||
- 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
|
||||
much as a link inside a slice — that resolves to nothing this build
|
||||
writes;
|
||||
- a missing or empty `manager/core/VERSION`, or a `README.md` whose
|
||||
"Install into a repo" section has lost its command block;
|
||||
- a `version` key in `pages.json`, which would be a second copy of a
|
||||
number that has one home;
|
||||
- a template placeholder the builder does not supply;
|
||||
- a file in `root/` that a route would also write.
|
||||
|
||||
|
||||
+161
-3
@@ -29,6 +29,17 @@ 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.
|
||||
|
||||
## What an authored page is still not allowed to type
|
||||
|
||||
The landing page's words are authored in its template rather than sliced,
|
||||
which would be a hole in the promise above if it extended to facts. It
|
||||
does not: the install one-liner and the version reach the template as
|
||||
`$install_block` and `$version`, read from `README.md` and
|
||||
`manager/core/VERSION` (see `repo_facts`). And every internal link any
|
||||
page emits — a door on the landing page as much as a link inside a slice
|
||||
— must resolve to something this build writes, or the build stops
|
||||
(`check_links`).
|
||||
|
||||
## What the host needs from the build
|
||||
|
||||
Two things here exist for the way the site is served (site/wrangler.jsonc,
|
||||
@@ -75,8 +86,19 @@ 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,})")
|
||||
CSS_URL = re.compile(r"""url\(\s*["']?([^"')]+)["']?\s*\)""")
|
||||
LINK = re.compile(r"""(?:href|src)=["']([^"']+)["']""", re.IGNORECASE)
|
||||
COMMENT = re.compile(r"\s{2,}#")
|
||||
EXTERNAL = ("http://", "https://", "//", "mailto:", "tel:", "data:")
|
||||
|
||||
# The two facts the landing page must never carry a hand-typed copy of,
|
||||
# and the files that own them. A subtly wrong `curl` is worse than no
|
||||
# landing page at all, and a version the release does not have is a
|
||||
# support question; both are read, and a source that moved stops the
|
||||
# build exactly as a renamed heading does.
|
||||
VERSION_FILE = "manager/core/VERSION"
|
||||
INSTALL_SOURCE = "README.md"
|
||||
INSTALL_HEADING = "## Install into a repo"
|
||||
|
||||
|
||||
class BuildError(Exception):
|
||||
"""A failure a person must read and fix: a missing source, a renamed
|
||||
@@ -172,6 +194,91 @@ def slice_section(text: str, page: dict, source: str) -> str:
|
||||
return promote(body, level - 1)
|
||||
|
||||
|
||||
# ── facts read out of the repo ────────────────────────────────────────
|
||||
|
||||
def read_version(repo: Path) -> str:
|
||||
"""`manager/core/VERSION`, which is the version `release.sh` tags."""
|
||||
path = repo / VERSION_FILE
|
||||
if not path.is_file():
|
||||
raise BuildError(
|
||||
f"the site shows the version from {VERSION_FILE}, which does "
|
||||
f"not exist. It is the one place the version is written; the "
|
||||
f"site does not keep a second copy.")
|
||||
version = path.read_text(encoding="utf-8").strip()
|
||||
if not version:
|
||||
raise BuildError(f"{VERSION_FILE} is empty.")
|
||||
return version
|
||||
|
||||
|
||||
def install_command(repo: Path) -> list:
|
||||
"""The lines of README.md's install one-liner, verbatim.
|
||||
|
||||
It is the command people paste, so the landing page reads it out of
|
||||
the file that documents it rather than transcribing it. Editing the
|
||||
README moves the page; renaming the section stops the build."""
|
||||
path = repo / INSTALL_SOURCE
|
||||
if not path.is_file():
|
||||
raise BuildError(
|
||||
f"the install command is read out of {INSTALL_SOURCE}, which "
|
||||
f"does not exist.")
|
||||
text = path.read_text(encoding="utf-8")
|
||||
marks = list(headings(text))
|
||||
start = find_heading(marks, INSTALL_HEADING)
|
||||
if start is None:
|
||||
raise BuildError(
|
||||
f'{INSTALL_SOURCE} has no heading "{INSTALL_HEADING}" — the '
|
||||
f"landing page reads the install command from that section. "
|
||||
f"Fix the heading, or INSTALL_HEADING in site/build.py.")
|
||||
start_line, level = start
|
||||
following = [i for i, found_level, _ in marks
|
||||
if i > start_line and found_level <= level]
|
||||
end_line = following[0] if following else len(text.splitlines())
|
||||
|
||||
block, fence = [], None
|
||||
for line in text.splitlines()[start_line + 1:end_line]:
|
||||
opener = FENCE.match(line)
|
||||
if opener and fence is None:
|
||||
fence = opener.group(1)
|
||||
continue
|
||||
if opener:
|
||||
break
|
||||
if fence is not None:
|
||||
block.append(line)
|
||||
if not block:
|
||||
raise BuildError(
|
||||
f'{INSTALL_SOURCE}: the "{INSTALL_HEADING}" section has no '
|
||||
f"fenced command block. The landing page's terminal has "
|
||||
f"nothing to show.")
|
||||
return block
|
||||
|
||||
|
||||
def render_command(lines: list) -> str:
|
||||
"""A command block as terminal html: every line escaped and otherwise
|
||||
verbatim, a prompt on the lines that begin a command — a line after
|
||||
one ending in a backslash is a continuation, not a new command — and
|
||||
a dim tail for a trailing comment."""
|
||||
out, continued = [], False
|
||||
for line in lines:
|
||||
prompt = "" if continued else '<span class="t-calm">$</span> '
|
||||
continued = line.rstrip().endswith("\\")
|
||||
found = COMMENT.search(line)
|
||||
body, tail = (line[:found.start()], line[found.start():]) if found \
|
||||
else (line, "")
|
||||
html = escape(body)
|
||||
if tail:
|
||||
html += f'<span class="t-dim">{escape(tail)}</span>'
|
||||
out.append(prompt + html)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def repo_facts(repo: Path) -> dict:
|
||||
"""What the templates are offered instead of typing it themselves."""
|
||||
return {
|
||||
"version": read_version(repo),
|
||||
"install_block": render_command(install_command(repo)),
|
||||
}
|
||||
|
||||
|
||||
# ── links ─────────────────────────────────────────────────────────────
|
||||
|
||||
def rewrite_link(href: str, *, page: dict, source: str, manifest: dict,
|
||||
@@ -201,6 +308,42 @@ def rewrite_link(href: str, *, page: dict, source: str, manifest: dict,
|
||||
return blob + rel + hash_mark + fragment
|
||||
|
||||
|
||||
def internal_targets(manifest: dict, site: Path) -> set:
|
||||
"""Every url the built site answers: one per route (and the file that
|
||||
route is written to), plus everything copied verbatim out of static/
|
||||
and root/."""
|
||||
urls = set()
|
||||
for page in manifest["pages"]:
|
||||
urls.add(page["path"])
|
||||
urls.add("/" + target_for(Path(), page["path"]).as_posix())
|
||||
for base, prefix in ((site / "static", "/static/"), (site / ROOT, "/")):
|
||||
if base.is_dir():
|
||||
urls.update(prefix + path.relative_to(base).as_posix()
|
||||
for path in base.rglob("*") if path.is_file())
|
||||
return urls
|
||||
|
||||
|
||||
def check_links(html: str, page: dict, targets: set) -> None:
|
||||
"""A link on a rendered page that the build does not write is a 404
|
||||
with a nice typeface. Markdown links out of a source file are already
|
||||
resolved (rewrite_link); this is the other half — what the templates
|
||||
themselves point at, which is where the landing page's doors live."""
|
||||
for url in LINK.findall(html):
|
||||
if not url or url.startswith("#") or url.startswith(EXTERNAL):
|
||||
continue
|
||||
if not url.startswith("/"):
|
||||
raise BuildError(
|
||||
f'{page["path"]}: links to "{url}". A page is written to '
|
||||
f"its own directory, so a relative link resolves against "
|
||||
f"that; write internal links from the root.")
|
||||
if url.split("#")[0].split("?")[0] not in targets:
|
||||
raise BuildError(
|
||||
f'{page["path"]}: links to "{url}", which this build does '
|
||||
f"not write. Every internal link must resolve to a route "
|
||||
f"in site/pages.json or a file in site/static/ or "
|
||||
f"site/{ROOT}/.")
|
||||
|
||||
|
||||
# ── rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
@@ -349,7 +492,7 @@ def stamp(site: Path) -> dict:
|
||||
|
||||
|
||||
def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
|
||||
stamps: dict = None) -> str:
|
||||
stamps: dict = None, facts: dict = None) -> str:
|
||||
source = page.get("source")
|
||||
if source:
|
||||
body, contents = render_markdown(
|
||||
@@ -363,6 +506,7 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
|
||||
config = manifest["site"]
|
||||
blob = config["blob_base"].rstrip("/") + "/"
|
||||
stamps = stamps if stamps is not None else stamp(site)
|
||||
facts = facts if facts is not None else repo_facts(repo)
|
||||
fields = {
|
||||
"stylesheet": stamps["stylesheet"],
|
||||
"icon": stamps["icon"],
|
||||
@@ -371,7 +515,8 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
|
||||
or config.get("description", "")),
|
||||
"site_title": escape(config["title"]),
|
||||
"site_tagline": escape(config.get("tagline", "")),
|
||||
"version": escape(config.get("version", "")),
|
||||
"version": escape(facts["version"]),
|
||||
"install_block": facts["install_block"],
|
||||
"body": body,
|
||||
"toc": render_contents(contents),
|
||||
"nav": render_nav(manifest, page),
|
||||
@@ -380,6 +525,7 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path,
|
||||
"section": escape(page.get("section") or ""),
|
||||
"repo_url": config["repo_url"],
|
||||
"issues_url": config.get("issues_url", config["repo_url"]),
|
||||
"releases_url": config.get("releases_url", config["repo_url"]),
|
||||
"source_url": (blob + source) if source else config["repo_url"],
|
||||
"source_path": escape(source or ""),
|
||||
"canonical": config.get("base_url", "").rstrip("/") + page["path"],
|
||||
@@ -414,6 +560,11 @@ def load_manifest(site: Path) -> dict:
|
||||
for key in ("title", "repo_url", "blob_base"):
|
||||
if key not in manifest["site"]:
|
||||
raise BuildError(f'pages.json: site has no "{key}" key')
|
||||
if "version" in manifest["site"]:
|
||||
raise BuildError(
|
||||
f'pages.json: site has a "version" key, but the version the '
|
||||
f"site shows is read from {VERSION_FILE}. Two copies of a "
|
||||
f"version is one copy too many — remove it.")
|
||||
|
||||
seen = set()
|
||||
for page in manifest["pages"]:
|
||||
@@ -526,6 +677,7 @@ def build(*, repo: Path = REPO, site: Path = None, out: Path = None,
|
||||
out = out or (site / "dist")
|
||||
manifest = load_manifest(site)
|
||||
stamps = stamp(site)
|
||||
facts = repo_facts(repo)
|
||||
|
||||
# A file in root/ that a route also claims would be silently replaced
|
||||
# by whichever is written last, so it is a build failure instead.
|
||||
@@ -539,9 +691,15 @@ def build(*, repo: Path = REPO, site: Path = None, out: Path = None,
|
||||
f"{relative}. Rename one — the build will not pick a winner.")
|
||||
|
||||
pages = [(page, render_page(page, manifest, site=site, repo=repo,
|
||||
stamps=stamps))
|
||||
stamps=stamps, facts=facts))
|
||||
for page in manifest["pages"]]
|
||||
|
||||
# After rendering, before writing: a dead internal link leaves the
|
||||
# last good build standing, exactly as a renamed heading does.
|
||||
targets = internal_targets(manifest, site)
|
||||
for page, html in pages:
|
||||
check_links(html, page, targets)
|
||||
|
||||
clear(out)
|
||||
out.mkdir(parents=True)
|
||||
(out / MARKER).write_text(
|
||||
|
||||
+61
-3
@@ -2,11 +2,11 @@
|
||||
"site": {
|
||||
"title": "bench",
|
||||
"tagline": "docs",
|
||||
"version": "0.2-alpha",
|
||||
"description": "A live kanban for coding-agent work: task files in stage directories are the only source of truth.",
|
||||
"base_url": "https://bench.12vectors.com",
|
||||
"repo_url": "https://github.com/12vectors/bench",
|
||||
"issues_url": "https://github.com/12vectors/bench/issues",
|
||||
"releases_url": "https://github.com/12vectors/bench/releases",
|
||||
"blob_base": "https://github.com/12vectors/bench/blob/main/"
|
||||
},
|
||||
|
||||
@@ -15,10 +15,28 @@
|
||||
"pages": [
|
||||
{
|
||||
"path": "/",
|
||||
"title": "bench",
|
||||
"title": "bench — a live kanban for coding-agent work",
|
||||
"layout": "home",
|
||||
"section": null,
|
||||
"description": "A live kanban for coding-agent work: task files in stage directories are the only source of truth.",
|
||||
"description": "Task files in stage directories are the only source of truth. A stdlib-only board narrates what happens to them — agents working in git worktrees, PRs opening on review, CI on the cards — and never merges anything itself.",
|
||||
"source": null
|
||||
},
|
||||
{
|
||||
"path": "/guides/install/",
|
||||
"title": "Install and first run",
|
||||
"layout": "article",
|
||||
"section": "Guides",
|
||||
"description": "Untar bench into .task-manager/, run start.sh, and answer the three questions the first run asks.",
|
||||
"source": "README.md",
|
||||
"from": "## Install into a repo",
|
||||
"to": "## Update"
|
||||
},
|
||||
{
|
||||
"path": "/concepts/stages/",
|
||||
"title": "The five stages",
|
||||
"layout": "article",
|
||||
"section": "Concepts",
|
||||
"description": "backlog, to-do, in-progress, review, done — the directory a task file sits in is its status, and there is no other source of truth.",
|
||||
"source": "AGENTS.md",
|
||||
"from": "## Stages",
|
||||
"to": "## Moving a task"
|
||||
@@ -33,6 +51,46 @@
|
||||
"from": "## Claiming a card",
|
||||
"to": "## Syncing boards"
|
||||
},
|
||||
{
|
||||
"path": "/concepts/agents-on-the-board/",
|
||||
"title": "Agents on the board",
|
||||
"layout": "article",
|
||||
"section": "Concepts",
|
||||
"description": "Start work makes a worktree and a branch, runs the agent headless, and moves the card on the way it exits.",
|
||||
"source": "AGENTS.md",
|
||||
"from": "## Agents working the board",
|
||||
"to": "## Pull requests"
|
||||
},
|
||||
{
|
||||
"path": "/concepts/pull-requests/",
|
||||
"title": "PRs and review",
|
||||
"layout": "article",
|
||||
"section": "Concepts",
|
||||
"description": "A card entering review gets a PR opened for it by the board; then review PR, copilot and act on PR, until it settles.",
|
||||
"source": "AGENTS.md",
|
||||
"from": "## Pull requests",
|
||||
"to": "## Stages"
|
||||
},
|
||||
{
|
||||
"path": "/concepts/team-mode/",
|
||||
"title": "Team mode",
|
||||
"layout": "article",
|
||||
"section": "Concepts",
|
||||
"description": "BOARD_SYNC makes origin/main the truth and every board a converging replica: moves commit and push themselves, and a beat pulls what the other boards published.",
|
||||
"source": "AGENTS.md",
|
||||
"from": "## Syncing boards",
|
||||
"to": "## Task file format"
|
||||
},
|
||||
{
|
||||
"path": "/concepts/three-layer-law/",
|
||||
"title": "The three-layer law",
|
||||
"layout": "article",
|
||||
"section": "Concepts",
|
||||
"description": "Core knows tasks, worktrees, PRs and events. Drivers know apps, adapters know agent vendors, local/ knows your project.",
|
||||
"source": "README.md",
|
||||
"from": "## The three-layer law",
|
||||
"to": "## License"
|
||||
},
|
||||
{
|
||||
"path": "/404.html",
|
||||
"title": "Not found",
|
||||
|
||||
+34
-13
@@ -248,26 +248,25 @@ a:hover{color:var(--text);text-decoration:underline}
|
||||
.dot-alarm{background:var(--ink-alarm)}
|
||||
.dot-calm{background:var(--ink-calm)}
|
||||
.terminal-title{font-size:11px;color:var(--ink-dim);margin-left:6px}
|
||||
/* The transcript below is real output with the long stretches cut, and
|
||||
the bar is where it says so — a claim about the block, not decoration. */
|
||||
.terminal-note{font-size:10.5px;color:var(--ink-dim);letter-spacing:.04em}
|
||||
.terminal-body{
|
||||
margin:0;padding:18px;background:var(--ink);color:var(--ink-text);
|
||||
font:var(--t-code)/1.85 var(--mono);
|
||||
white-space:pre-wrap;word-break:break-all;
|
||||
/* The release url is 72 characters and has to wrap rather than clip;
|
||||
`anywhere` breaks it, and breaks a word only when a word is what
|
||||
does not fit — the transcript's prose lines wrap normally. */
|
||||
white-space:pre-wrap;overflow-wrap:anywhere;
|
||||
}
|
||||
.t-calm{color:var(--ink-calm)}
|
||||
.t-accent{color:var(--ink-accent)}
|
||||
.t-muted{color:var(--ink-muted)}
|
||||
.t-dim{color:var(--ink-dim)}
|
||||
|
||||
.generated{padding:0 44px 34px}
|
||||
.page-home .prose{
|
||||
background:var(--surface);border:1px solid var(--border-soft);
|
||||
border-radius:12px;padding:28px 32px 12px;
|
||||
}
|
||||
.generated-heading{margin-top:0 !important}
|
||||
.generated-note{
|
||||
margin:0 0 20px;font-size:11px;color:var(--dim);max-width:none;
|
||||
}
|
||||
|
||||
/* Every door is a link to a page this site builds — the whole card is
|
||||
the target, so the hover is the card's border rather than an
|
||||
underline under three words of it. */
|
||||
.doors{
|
||||
display:grid;grid-template-columns:repeat(3,1fr);gap:14px;
|
||||
padding:0 44px 34px;
|
||||
@@ -275,9 +274,11 @@ a:hover{color:var(--text);text-decoration:underline}
|
||||
.door{
|
||||
display:flex;flex-direction:column;gap:8px;padding:20px;
|
||||
background:var(--surface);border:1px solid var(--border-soft);
|
||||
border-radius:12px;
|
||||
border-radius:12px;color:var(--text);
|
||||
}
|
||||
.door:hover{border-color:var(--accent);color:var(--text);text-decoration:none}
|
||||
.door-dark{background:var(--text);border-color:var(--text);color:var(--canvas)}
|
||||
.door-dark:hover{border-color:var(--accent);color:var(--canvas)}
|
||||
.door-index{font-size:11px;color:var(--dim)}
|
||||
.door-dark .door-index{color:#7fa0a8}
|
||||
.door-title{font:600 19px/1.2 var(--display)}
|
||||
@@ -285,6 +286,26 @@ a:hover{color:var(--text);text-decoration:underline}
|
||||
.door-dark .door-text{color:#a9c4c9}
|
||||
.door .mono{font-size:var(--t-code)}
|
||||
|
||||
/* The strip at the foot of the landing page. It carries the version and
|
||||
nothing else measurable, because the version is the only thing about
|
||||
itself this site can measure. */
|
||||
.strip{
|
||||
display:flex;align-items:center;gap:14px;flex-wrap:wrap;
|
||||
padding:18px 44px;background:var(--sunken);
|
||||
border-top:1px solid var(--border-soft);
|
||||
}
|
||||
.strip-label{
|
||||
font-size:var(--t-micro);letter-spacing:.1em;text-transform:uppercase;
|
||||
color:var(--dim);
|
||||
}
|
||||
.strip-version{
|
||||
font-size:var(--t-code);padding:4px 10px;background:var(--surface);
|
||||
border:1px solid var(--border);border-radius:99px;color:var(--accent);
|
||||
}
|
||||
.strip-text{font-size:12.5px;line-height:1.55;color:var(--muted);max-width:64ch}
|
||||
.strip-text .mono{font-size:var(--t-code)}
|
||||
.strip-link{font-size:12px;color:var(--accent)}
|
||||
|
||||
/* ── 404 (layout "notfound") ── */
|
||||
.lost{
|
||||
display:flex;flex-direction:column;align-items:flex-start;gap:18px;
|
||||
@@ -304,7 +325,7 @@ a:hover{color:var(--text);text-decoration:underline}
|
||||
.gutter{display:none}
|
||||
.hero{grid-template-columns:1fr;gap:28px;padding:32px 24px}
|
||||
.doors{grid-template-columns:repeat(2,1fr);padding:0 24px 28px}
|
||||
.generated{padding:0 24px 28px}
|
||||
.strip{padding:18px 24px}
|
||||
}
|
||||
@media (max-width:760px){
|
||||
.shell{grid-template-columns:minmax(0,1fr)}
|
||||
|
||||
+68
-41
@@ -2,20 +2,37 @@
|
||||
<!-- Layout "home" — 1b Dockside from the turn-2 docs design: a landing
|
||||
that is mostly a terminal and a set of doors.
|
||||
|
||||
The hero copy and the doors are authored here on purpose; task 33
|
||||
owns the landing page's words. Everything under "The five stages"
|
||||
is generated from the source named in site/pages.json.
|
||||
The words here are authored rather than sliced (site/pages.json says
|
||||
so with "source": null), which is exactly why the two facts a
|
||||
landing page must never get subtly wrong are not written here at
|
||||
all: $install_block is README.md's own install command and $version
|
||||
is manager/core/VERSION, both read by site/build.py.
|
||||
|
||||
The terminal is abridged — it says so in its own title bar — but
|
||||
nothing in it is composed. Every line is one a first run really
|
||||
prints: install.py's setup questions and its closing line,
|
||||
board.py's startup. tests/test_site_landing.py holds each of them
|
||||
against the source that prints it, so a reworded prompt fails the
|
||||
suite rather than quietly making this page fiction.
|
||||
|
||||
Placeholders are string.Template's, so every literal dollar in the
|
||||
markup is doubled — which is why the shell prompts in the terminal
|
||||
below look doubled in the source and single on the page. -->
|
||||
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>$site_title · $site_tagline</title>
|
||||
<title>$title</title>
|
||||
<meta name="description" content="$description">
|
||||
<link rel="canonical" href="$canonical">
|
||||
<!-- This is the url that gets pasted into a chat, so it carries its own
|
||||
card. No og:image: the site has no raster art, and an empty one is
|
||||
worse than none. -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="$site_title">
|
||||
<meta property="og:title" content="$title">
|
||||
<meta property="og:description" content="$description">
|
||||
<meta property="og:url" content="$canonical">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<link rel="icon" href="$icon">
|
||||
<link rel="stylesheet" href="$stylesheet">
|
||||
</head>
|
||||
@@ -26,7 +43,7 @@
|
||||
<span class="topbar-sep">/</span>
|
||||
<span class="topbar-repo">bench</span>
|
||||
<span class="spacer"></span>
|
||||
<span>$version</span>
|
||||
<span>docs for $version</span>
|
||||
</div>
|
||||
|
||||
<header class="masthead masthead-flat">
|
||||
@@ -50,8 +67,8 @@ $nav
|
||||
working in git worktrees, PRs opening on review, CI on the cards —
|
||||
and never merges anything itself.</p>
|
||||
<div class="hero-actions">
|
||||
<a class="button button-solid button-lg" href="#stages">Read the stages</a>
|
||||
<a class="button button-lg" href="$repo_url#install-into-a-repo">Install into a repo</a>
|
||||
<a class="button button-solid button-lg" href="/guides/install/">Install into a repo</a>
|
||||
<a class="button button-lg" href="$repo_url">bench on GitHub ↗</a>
|
||||
</div>
|
||||
<span class="mono aside-note">no account, no service, no database. it is
|
||||
python 3 on your own machine.</span>
|
||||
@@ -62,72 +79,82 @@ $nav
|
||||
<span class="dot dot-alarm"></span>
|
||||
<span class="dot dot-calm"></span>
|
||||
<span class="mono terminal-title">~/your-repo</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="mono terminal-note">first run · abridged</span>
|
||||
</div>
|
||||
<pre class="terminal-body"><span class="t-calm">$$</span> mkdir .task-manager && curl -L \
|
||||
<span class="t-dim">https://github.com/12vectors/bench/releases/latest/download/bench.tar.gz</span> \
|
||||
| tar -xz -C .task-manager
|
||||
<span class="t-calm">$$</span> ./.task-manager/start.sh
|
||||
<pre class="terminal-body">$install_block
|
||||
|
||||
<span class="t-muted">No .task-manager/manager/local/.env yet — a few questions and bench writes one.</span>
|
||||
solo or team? <span class="t-accent">[solo]</span>:
|
||||
which agent adapter? <span class="t-accent">[claude]</span>:
|
||||
what command runs this project's tests? <span class="t-accent">[python3 -m unittest]</span>:
|
||||
No .task-manager/manager/local/.env yet — a few questions and bench writes one.
|
||||
Enter takes the default in [brackets]; Ctrl-D skips the rest.
|
||||
|
||||
Task board for ~/your-repo/.task-manager/tasks
|
||||
<span class="t-accent">http://127.0.0.1:26071/</span>
|
||||
<span class="t-dim">Ctrl-C to stop</span></pre>
|
||||
</div>
|
||||
</section>
|
||||
solo or team? <span class="t-accent">[solo]</span>:
|
||||
which agent adapter? <span class="t-accent">[claude]</span>:
|
||||
what command runs this project's tests? <span class="t-accent">[python3 -m unittest]</span>:
|
||||
|
||||
<section class="generated" id="stages">
|
||||
<div class="prose">
|
||||
<h2 class="generated-heading">The five stages</h2>
|
||||
<p class="generated-note mono">from <span>$source_path</span> — the
|
||||
section below is this file's, not a retelling of it</p>
|
||||
$body
|
||||
Wrote .task-manager/manager/local/.env — every other setting is in there, commented; edit it any time.
|
||||
|
||||
<span class="t-dim"> …</span>
|
||||
|
||||
Task board for ~/your-repo/.task-manager/tasks
|
||||
<span class="t-accent">http://127.0.0.1:26071/</span>
|
||||
<span class="t-dim">Ctrl-C to stop</span></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="doors">
|
||||
<div class="door">
|
||||
<a class="door" href="/guides/install/">
|
||||
<span class="mono door-index">01</span>
|
||||
<span class="door-title">Install</span>
|
||||
<span class="door-title">Install and first run</span>
|
||||
<span class="door-text">Untar it into <span class="mono">.task-manager/</span>,
|
||||
run <span class="mono">start.sh</span>, answer three questions.
|
||||
Port 26071, pinned.</span>
|
||||
</div>
|
||||
<div class="door">
|
||||
</a>
|
||||
<a class="door" href="/concepts/stages/">
|
||||
<span class="mono door-index">02</span>
|
||||
<span class="door-title">The five stages</span>
|
||||
<span class="door-text">backlog → to-do → in-progress → review → done.
|
||||
The directory a file sits in <i>is</i> its status.</span>
|
||||
</div>
|
||||
<div class="door">
|
||||
</a>
|
||||
<a class="door" href="/concepts/agents-on-the-board/">
|
||||
<span class="mono door-index">03</span>
|
||||
<span class="door-title">Agents on the board</span>
|
||||
<span class="door-text">▸ start work makes a worktree and a branch, runs
|
||||
the agent headless, and moves the card when it exits.</span>
|
||||
</div>
|
||||
<div class="door">
|
||||
</a>
|
||||
<a class="door" href="/concepts/pull-requests/">
|
||||
<span class="mono door-index">04</span>
|
||||
<span class="door-title">PRs & review</span>
|
||||
<span class="door-text">A card entering review gets a PR opened for it.
|
||||
Then ◔ review PR, ⚑ copilot, ↻ act on PR — until it settles.</span>
|
||||
</div>
|
||||
<div class="door">
|
||||
</a>
|
||||
<a class="door" href="/concepts/team-mode/">
|
||||
<span class="mono door-index">05</span>
|
||||
<span class="door-title">Team mode</span>
|
||||
<span class="door-text"><span class="mono">BOARD_SYNC=1</span> makes
|
||||
origin/main the truth. Moves commit and push themselves; boards pull
|
||||
on a beat.</span>
|
||||
</div>
|
||||
<div class="door door-dark">
|
||||
</a>
|
||||
<a class="door door-dark" href="/concepts/three-layer-law/">
|
||||
<span class="mono door-index">06</span>
|
||||
<span class="door-title">The three-layer law</span>
|
||||
<span class="door-text">Core knows tasks, worktrees, PRs and events.
|
||||
Drivers know apps, adapters know vendors, local/ knows your
|
||||
project.</span>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- The strip turn 1 of the design filled with a most-read ranking. This
|
||||
site counts nothing and never will, so it says the one thing about
|
||||
itself it can prove: which version it was built from. -->
|
||||
<section class="strip">
|
||||
<span class="mono strip-label">Version</span>
|
||||
<span class="mono strip-version">$version</span>
|
||||
<span class="strip-text">read from <span class="mono">manager/core/VERSION</span>
|
||||
when this page was built. <span class="mono">update.sh</span> replaces an
|
||||
install with the latest release; <span class="mono">BENCH_REF=v2</span>
|
||||
pins an exact one.</span>
|
||||
<span class="spacer"></span>
|
||||
<a class="mono strip-link" href="$releases_url">Releases ↗</a>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
|
||||
+60
-38
@@ -36,9 +36,14 @@ def builder():
|
||||
|
||||
BUILDER = builder()
|
||||
|
||||
# The sources every page is cut from. A scratch repo needs these and
|
||||
# nothing else to build the real manifest.
|
||||
SOURCES = ["AGENTS.md", "README.md"]
|
||||
# The files a build reads out of the repo: the two the pages are cut
|
||||
# from, and the one the version is read from. A scratch repo needs these
|
||||
# and nothing else to build the real manifest.
|
||||
SOURCES = ["AGENTS.md", "README.md", "manager/core/VERSION"]
|
||||
|
||||
# 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.
|
||||
PLAIN = "<!doctype html>\n<title>$title</title>\n$body\n"
|
||||
|
||||
try:
|
||||
import markdown_it # noqa: F401
|
||||
@@ -69,12 +74,28 @@ class ScratchRepo:
|
||||
shutil.copytree(SITE, root / "site",
|
||||
ignore=shutil.ignore_patterns("dist", "__pycache__"))
|
||||
for name in SOURCES:
|
||||
(root / name).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(REPO / name, root / name)
|
||||
|
||||
@property
|
||||
def out(self) -> Path:
|
||||
return self.root / "site" / "dist"
|
||||
|
||||
def plain_home(self) -> dict:
|
||||
"""A landing page with no markup, so a manifest cut down to one
|
||||
page under test still answers the `/` every layout's wordmark
|
||||
links to — which the build now checks."""
|
||||
(self.root / "site" / "templates" / "plain.html").write_text(
|
||||
PLAIN, encoding="utf-8")
|
||||
return {"path": "/", "title": "Home", "layout": "plain",
|
||||
"section": None, "source": None}
|
||||
|
||||
def pages(self, *entries: dict) -> None:
|
||||
"""The manifest reduced to these pages, plus that landing page."""
|
||||
manifest = self.manifest()
|
||||
manifest["pages"] = [self.plain_home(), *entries]
|
||||
self.write_manifest(manifest)
|
||||
|
||||
def manifest(self) -> dict:
|
||||
return json.loads(
|
||||
(self.root / "site" / "pages.json").read_text(encoding="utf-8"))
|
||||
@@ -136,15 +157,17 @@ class TheRealSiteBuilds(unittest.TestCase):
|
||||
self.assertTrue((self.out / "static" / "site.css").is_file())
|
||||
self.assertTrue((self.out / "static" / "favicon.svg").is_file())
|
||||
|
||||
def test_both_layouts_render_real_content_from_agents_md(self):
|
||||
"""Not lorem: the words on the page are the words in the file."""
|
||||
home = self.page("/")
|
||||
def test_the_article_layout_renders_real_content_from_agents_md(self):
|
||||
"""Not lorem: the words on the page are the words in the file.
|
||||
(The home layout is authored rather than sliced — its own facts
|
||||
are tests/test_site_landing.py's subject.)"""
|
||||
stages = self.page("/concepts/stages/")
|
||||
article = self.page("/concepts/claiming-a-card/")
|
||||
# Sliced out of AGENTS.md's "## Stages" by the home entry.
|
||||
self.assertIn("The directory a file sits in", home)
|
||||
# Sliced out of AGENTS.md's "## Stages".
|
||||
self.assertIn("Most tasks live here for most of their life", stages)
|
||||
self.assertIn("a stale <code>in-progress/</code> makes the board",
|
||||
home)
|
||||
# ...and out of "## Claiming a card" by the article entry.
|
||||
stages)
|
||||
# ...and out of "## Claiming a card".
|
||||
self.assertIn("The first claim sticks", article)
|
||||
self.assertIn("Identity is git's, so it collides like git's",
|
||||
article)
|
||||
@@ -159,9 +182,9 @@ class TheRealSiteBuilds(unittest.TestCase):
|
||||
def test_sub_headings_are_promoted_to_the_pages_own_level(self):
|
||||
"""AGENTS.md's `### backlog/` under `## Stages` becomes an <h2>
|
||||
with an anchor, so the layout's contents list can reach it."""
|
||||
home = self.page("/")
|
||||
self.assertIn('<h2 id="backlog">backlog/</h2>', home)
|
||||
self.assertIn('<h2 id="in-progress">in-progress/</h2>', home)
|
||||
stages = self.page("/concepts/stages/")
|
||||
self.assertIn('<h2 id="backlog">backlog/</h2>', stages)
|
||||
self.assertIn('<h2 id="in-progress">in-progress/</h2>', stages)
|
||||
|
||||
def test_the_ia_comes_out_of_the_manifest(self):
|
||||
article = self.page("/concepts/claiming-a-card/")
|
||||
@@ -276,13 +299,11 @@ class DriftStopsTheBuild(ScratchCase):
|
||||
def test_a_section_emptied_to_its_heading_fails(self):
|
||||
"""The subtler drift: the heading survives, its content moves
|
||||
elsewhere. An empty page is a drift, not a page."""
|
||||
manifest = self.repo.manifest()
|
||||
manifest["pages"] = [{
|
||||
self.repo.pages({
|
||||
"path": "/hollow/", "title": "Hollow", "layout": "article",
|
||||
"section": "Concepts", "source": "AGENTS.md",
|
||||
"from": "## Empty", "to": "## After",
|
||||
}]
|
||||
self.repo.write_manifest(manifest)
|
||||
})
|
||||
self.repo.edit("AGENTS.md", "## Claiming a card",
|
||||
"## Empty\n\n## After\n\n## Claiming a card")
|
||||
result = self.repo.build()
|
||||
@@ -293,13 +314,11 @@ class DriftStopsTheBuild(ScratchCase):
|
||||
def test_a_heading_inside_a_code_fence_is_not_a_heading(self):
|
||||
"""AGENTS.md fences a task file template starting `# Task title`.
|
||||
Matching that would slice the document in half."""
|
||||
manifest = self.repo.manifest()
|
||||
manifest["pages"] = [{
|
||||
self.repo.pages({
|
||||
"path": "/fenced/", "title": "Fenced", "layout": "article",
|
||||
"section": "Concepts", "source": "AGENTS.md",
|
||||
"from": "# Task title",
|
||||
}]
|
||||
self.repo.write_manifest(manifest)
|
||||
})
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("# Task title", result.stderr)
|
||||
@@ -312,13 +331,13 @@ class LinksComeOutWorking(ScratchCase):
|
||||
def build_one(self, body: str, *, link_routes=None):
|
||||
(self.repo.root / "SOURCE.md").write_text(
|
||||
f"# Doc\n\n## Section\n\n{body}\n", encoding="utf-8")
|
||||
manifest = self.repo.manifest()
|
||||
manifest["link_routes"] = link_routes or {}
|
||||
manifest["pages"] = [{
|
||||
"path": "/linked/", "title": "Linked", "layout": "article",
|
||||
self.repo.pages({
|
||||
"path": "/linked/", "title": "Linked", "layout": "plain",
|
||||
"section": "Concepts", "source": "SOURCE.md",
|
||||
"from": "## Section",
|
||||
}]
|
||||
})
|
||||
manifest = self.repo.manifest()
|
||||
manifest["link_routes"] = link_routes or {}
|
||||
self.repo.write_manifest(manifest)
|
||||
result = self.repo.build()
|
||||
page = self.repo.out / "linked" / "index.html"
|
||||
@@ -334,9 +353,9 @@ class LinksComeOutWorking(ScratchCase):
|
||||
def test_link_routes_win_over_github(self):
|
||||
result, html = self.build_one(
|
||||
"See [the brief](AGENTS.md#stages).",
|
||||
link_routes={"AGENTS.md": "/concepts/stages/"})
|
||||
link_routes={"AGENTS.md": "/linked/"})
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('href="/concepts/stages/#stages"', html)
|
||||
self.assertIn('href="/linked/#stages"', html)
|
||||
|
||||
def test_an_absolute_link_and_an_anchor_are_left_alone(self):
|
||||
result, html = self.build_one(
|
||||
@@ -364,14 +383,12 @@ class TheArticleGutterFollowsTheBody(ScratchCase):
|
||||
contents entry without anyone editing the site."""
|
||||
|
||||
def test_promoted_sub_headings_become_the_contents_list(self):
|
||||
manifest = self.repo.manifest()
|
||||
manifest["pages"] = [{
|
||||
self.repo.pages({
|
||||
"path": "/concepts/team-mode/", "title": "Syncing boards",
|
||||
"layout": "article", "section": "Concepts",
|
||||
"source": "AGENTS.md", "from": "## Syncing boards",
|
||||
"to": "## Task file format",
|
||||
}]
|
||||
self.repo.write_manifest(manifest)
|
||||
})
|
||||
result = self.repo.build()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
@@ -395,6 +412,10 @@ class TheManifestIsChecked(ScratchCase):
|
||||
self.repo.write_manifest(manifest)
|
||||
return self.repo.build()
|
||||
|
||||
def with_landing(self, page: dict):
|
||||
self.repo.pages(page)
|
||||
return self.repo.build()
|
||||
|
||||
def test_an_unknown_layout_lists_the_ones_that_exist(self):
|
||||
result = self.only({
|
||||
"path": "/x/", "title": "X", "layout": "logbook",
|
||||
@@ -412,13 +433,14 @@ class TheManifestIsChecked(ScratchCase):
|
||||
self.assertIn("from", result.stderr)
|
||||
|
||||
def test_an_authored_page_says_so_with_a_null_source(self):
|
||||
"""The landing page 33 will write has no slice and no drift."""
|
||||
result = self.only({
|
||||
"path": "/", "title": "bench", "layout": "home",
|
||||
"source": None})
|
||||
"""An authored page has no slice and so no drift: the builder
|
||||
offers it an empty $body and renders the template's own words."""
|
||||
result = self.with_landing({
|
||||
"path": "/authored/", "title": "Authored", "layout": "plain",
|
||||
"section": None, "source": None})
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
html = (self.repo.out / "index.html").read_text("utf-8")
|
||||
self.assertIn("Put the agents", html)
|
||||
html = (self.repo.out / "authored" / "index.html").read_text("utf-8")
|
||||
self.assertIn("<title>Authored</title>", html)
|
||||
|
||||
def test_a_route_must_be_a_directory_path(self):
|
||||
result = self.only({
|
||||
|
||||
@@ -318,7 +318,10 @@ class Scratch(unittest.TestCase):
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
shutil.copytree(SITE, self.root / "site",
|
||||
ignore=shutil.ignore_patterns("dist", "__pycache__"))
|
||||
for name in ("AGENTS.md", "README.md"):
|
||||
# The markdown the pages are cut from, plus the file the version
|
||||
# shown on them is read from.
|
||||
for name in ("AGENTS.md", "README.md", "manager/core/VERSION"):
|
||||
(self.root / name).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(REPO / name, self.root / name)
|
||||
self.out = self.root / "site" / "dist"
|
||||
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""The landing page is the one page most visitors read, and the only one
|
||||
whose words are authored rather than cut from a heading. That is the risk
|
||||
this file is about.
|
||||
|
||||
Three promises, each mechanised below:
|
||||
|
||||
- **The facts on it are read, not typed.** The install one-liner comes out
|
||||
of README.md and the version out of manager/core/VERSION, so changing
|
||||
either source changes the page and losing either stops the build.
|
||||
- **The terminal is a transcript, not a mood.** Every line it shows is
|
||||
held against the source that prints it — install.py's setup questions,
|
||||
board.py's startup. A reworded prompt fails here rather than quietly
|
||||
turning the strongest thing on the page into fiction.
|
||||
- **Every door opens.** The six cards point at routes this build writes,
|
||||
and a link that resolves to nothing fails the build instead of shipping.
|
||||
|
||||
python3 -m unittest discover -s tests
|
||||
"""
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from tests.test_site_build import (BUILDER, REPO, SITE, ScratchCase,
|
||||
needs_renderer, run_build)
|
||||
|
||||
INSTALL = REPO / "install.py"
|
||||
BOARD = REPO / "manager" / "core" / "board.py"
|
||||
CONFIG = REPO / "manager" / "core" / "config.py"
|
||||
|
||||
# The hero terminal, line by line, minus the install command: what the
|
||||
# page shows, a fragment of it that the source can be searched for, and
|
||||
# the file that prints it. Two fragments are shorter than the line they
|
||||
# check because the f-string that produces them is wrapped across two
|
||||
# source lines — the page still has to carry the whole line.
|
||||
#
|
||||
# The page shows fewer lines than a real first run does; its title bar
|
||||
# says "abridged". It shows none a first run does not.
|
||||
TRANSCRIPT = [
|
||||
("a few questions and bench writes one.",
|
||||
"a few questions and bench writes", INSTALL),
|
||||
("Enter takes the default in [brackets]; Ctrl-D skips the rest.",
|
||||
"Enter takes the default in [brackets]; Ctrl-D skips the rest.", INSTALL),
|
||||
("solo or team?", "solo or team?", INSTALL),
|
||||
("which agent adapter?", "which agent adapter?", INSTALL),
|
||||
("what command runs this project's tests?",
|
||||
"what command runs this project's tests?", INSTALL),
|
||||
("python3 -m unittest", "python3 -m unittest", INSTALL),
|
||||
("every other setting is in there, commented; edit it any time.",
|
||||
"every other setting is in there,", INSTALL),
|
||||
("Task board for", "Task board for", BOARD),
|
||||
("http://127.0.0.1:", "http://127.0.0.1:", BOARD),
|
||||
("Ctrl-C to stop", "Ctrl-C to stop", BOARD),
|
||||
("26071", "26071", CONFIG),
|
||||
]
|
||||
|
||||
# Words for things bench does not have. Turn 1 of the design documented
|
||||
# several of them; the page must never grow them back.
|
||||
FICTION = ("bench.toml", "brew install", "npm install", "sqlite", "webhook",
|
||||
"sign up", "sign in", "log in", "your account", "api key",
|
||||
"lanes", "7331", "cloud", "pricing", "free trial")
|
||||
|
||||
|
||||
def text_of(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class LandingCase(unittest.TestCase):
|
||||
"""The real site, built once into a scratch directory."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
try:
|
||||
import markdown_it # noqa: F401
|
||||
except ImportError: # pragma: no cover - environment
|
||||
raise unittest.SkipTest("markdown-it-py is not installed")
|
||||
cls.out = Path(tempfile.mkdtemp(prefix="bench-landing-")).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}"
|
||||
f"{result.stderr}")
|
||||
cls.manifest = json.loads(text_of(SITE / "pages.json"))
|
||||
cls.home = text_of(cls.out / "index.html")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
if hasattr(cls, "out"):
|
||||
shutil.rmtree(cls.out, ignore_errors=True)
|
||||
|
||||
def terminal(self) -> str:
|
||||
"""The hero terminal's text, as a reader sees it."""
|
||||
block = re.search(r'<pre class="terminal-body">(.*?)</pre>',
|
||||
self.home, re.DOTALL)
|
||||
self.assertTrue(block, "the landing page has no terminal")
|
||||
return html.unescape(re.sub(r"<[^>]+>", "", block.group(1)))
|
||||
|
||||
|
||||
class TheFactsAreRead(LandingCase):
|
||||
"""Neither of these is allowed to be a copy. A landing page with a
|
||||
subtly wrong curl is worse than no landing page."""
|
||||
|
||||
def test_the_install_command_is_the_readmes_own_line_for_line(self):
|
||||
shown = self.terminal()
|
||||
for line in BUILDER.install_command(REPO):
|
||||
self.assertIn(line, shown,
|
||||
"the terminal does not show README.md's install "
|
||||
"command as README.md writes it")
|
||||
|
||||
def test_the_version_shown_is_the_one_in_the_version_file(self):
|
||||
version = text_of(REPO / "manager" / "core" / "VERSION").strip()
|
||||
self.assertIn(version, self.home)
|
||||
|
||||
def test_the_manifest_keeps_no_second_copy_of_the_version(self):
|
||||
self.assertNotIn("version", self.manifest["site"],
|
||||
"pages.json names a version; the build reads "
|
||||
"manager/core/VERSION")
|
||||
|
||||
|
||||
class TheTerminalIsATranscript(LandingCase):
|
||||
"""The strongest element on the page and the easiest one to fake."""
|
||||
|
||||
def test_every_line_shown_is_a_line_something_really_prints(self):
|
||||
shown = self.terminal()
|
||||
for line, fragment, source in TRANSCRIPT:
|
||||
with self.subTest(line=line):
|
||||
self.assertIn(line, shown,
|
||||
"the terminal no longer shows this")
|
||||
self.assertIn(fragment, text_of(source),
|
||||
f"{source.relative_to(REPO)} no longer prints "
|
||||
f"this — the landing page is now fiction")
|
||||
|
||||
def test_the_abridgement_is_declared_where_the_transcript_is(self):
|
||||
"""It shows fewer lines than a first run prints. Saying so in the
|
||||
terminal's own title bar is the difference between an excerpt and
|
||||
a claim that this is the whole output."""
|
||||
self.assertIn("abridged", self.home)
|
||||
|
||||
|
||||
class TheDoorsOpen(LandingCase):
|
||||
"""Six doors, each a page this site builds."""
|
||||
|
||||
def doors(self) -> list:
|
||||
return re.findall(r'<a class="door[^"]*" href="([^"]+)"', self.home)
|
||||
|
||||
def test_there_are_six_and_they_are_distinct(self):
|
||||
self.assertEqual(6, len(self.doors()))
|
||||
self.assertEqual(6, len(set(self.doors())))
|
||||
|
||||
def test_each_one_is_a_route_the_build_writes(self):
|
||||
routes = {page["path"] for page in self.manifest["pages"]}
|
||||
for door in self.doors():
|
||||
with self.subTest(door=door):
|
||||
self.assertIn(door, routes)
|
||||
self.assertTrue(
|
||||
BUILDER.target_for(self.out, door).is_file(),
|
||||
f"{door} is in the manifest but produced no page")
|
||||
|
||||
def test_both_hero_buttons_go_somewhere_real(self):
|
||||
"""One to the install guide, one to the repository."""
|
||||
hero = re.search(r'<div class="hero-actions">(.*?)</div>',
|
||||
self.home, re.DOTALL).group(1)
|
||||
links = re.findall(r'href="([^"]+)"', hero)
|
||||
self.assertEqual(2, len(links))
|
||||
routes = {page["path"] for page in self.manifest["pages"]}
|
||||
self.assertIn("/guides/install/", links)
|
||||
self.assertIn("/guides/install/", routes)
|
||||
self.assertIn(self.manifest["site"]["repo_url"], links)
|
||||
|
||||
|
||||
class WhatThePageMayNotSay(LandingCase):
|
||||
"""Nothing here claims a feature bench lacks, counts anything it
|
||||
cannot count, or needs a script to be read."""
|
||||
|
||||
def test_the_fake_telemetry_is_gone(self):
|
||||
"""Turn 1's "Most opened this week" was analytics this site does
|
||||
not have and will not get."""
|
||||
self.assertNotIn("Most opened", self.home)
|
||||
|
||||
def test_no_page_claims_something_bench_does_not_have(self):
|
||||
lowered = self.home.lower()
|
||||
for word in FICTION:
|
||||
with self.subTest(word=word):
|
||||
self.assertNotIn(word, lowered)
|
||||
|
||||
def test_the_page_needs_no_javascript(self):
|
||||
for page in self.manifest["pages"]:
|
||||
html_text = text_of(BUILDER.target_for(self.out, page["path"]))
|
||||
with self.subTest(page=page["path"]):
|
||||
self.assertNotIn("<script", html_text.lower())
|
||||
self.assertNotIn("<noscript", html_text.lower())
|
||||
self.assertIsNone(
|
||||
re.search(r"\son[a-z]+=", html_text, re.IGNORECASE),
|
||||
"an inline event handler is a page that needs a script")
|
||||
|
||||
def test_it_carries_the_card_a_pasted_url_needs(self):
|
||||
canonical = (self.manifest["site"]["base_url"].rstrip("/") + "/")
|
||||
entry = next(p for p in self.manifest["pages"] if p["path"] == "/")
|
||||
self.assertIn(f'<title>{entry["title"]}</title>', self.home)
|
||||
for tag, value in (("og:type", "website"),
|
||||
("og:title", entry["title"]),
|
||||
("og:description", entry["description"]),
|
||||
("og:url", canonical)):
|
||||
with self.subTest(tag=tag):
|
||||
self.assertIn(f'<meta property="{tag}" content="{value}">',
|
||||
self.home)
|
||||
self.assertIn(f'<link rel="canonical" href="{canonical}">', self.home)
|
||||
self.assertIn(f'<meta name="description" content='
|
||||
f'"{entry["description"]}">', self.home)
|
||||
|
||||
|
||||
@needs_renderer
|
||||
class ChangingASourceChangesThePage(ScratchCase):
|
||||
"""The acceptance criterion the other way round: edit the file the
|
||||
fact is read from, rebuild, and the page has moved."""
|
||||
|
||||
def home(self) -> str:
|
||||
return (self.repo.out / "index.html").read_text("utf-8")
|
||||
|
||||
def test_editing_the_version_file_moves_the_version_on_the_page(self):
|
||||
self.repo.edit("manager/core/VERSION", "0.2-alpha", "9.9-rc1")
|
||||
self.assertEqual(0, self.repo.build().returncode)
|
||||
self.assertIn("9.9-rc1", self.home())
|
||||
self.assertNotIn("0.2-alpha", self.home())
|
||||
|
||||
def test_editing_the_readme_moves_the_command_on_the_page(self):
|
||||
self.repo.edit("README.md", "mkdir .task-manager && curl -L \\",
|
||||
"mkdir .bench && curl -sSL \\")
|
||||
self.assertEqual(0, self.repo.build().returncode)
|
||||
self.assertIn("mkdir .bench && curl -sSL", self.home())
|
||||
self.assertNotIn("mkdir .task-manager && curl -L",
|
||||
self.home())
|
||||
|
||||
def test_a_missing_version_file_stops_the_build(self):
|
||||
(self.repo.root / "manager" / "core" / "VERSION").unlink()
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("manager/core/VERSION", result.stderr)
|
||||
|
||||
def test_a_renamed_install_section_stops_the_build(self):
|
||||
self.repo.edit("README.md", "## Install into a repo",
|
||||
"## Getting started")
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("## Install into a repo", result.stderr)
|
||||
|
||||
def test_an_install_section_with_no_command_stops_the_build(self):
|
||||
text = (self.repo.root / "README.md").read_text("utf-8")
|
||||
start = text.index("## Install into a repo")
|
||||
end = text.index("## Update")
|
||||
(self.repo.root / "README.md").write_text(
|
||||
text[:start] + "## Install into a repo\n\nSoon.\n\n" + text[end:],
|
||||
encoding="utf-8")
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("no fenced command block", result.stderr)
|
||||
|
||||
def test_a_version_in_the_manifest_is_refused(self):
|
||||
manifest = self.repo.manifest()
|
||||
manifest["site"]["version"] = "1.0"
|
||||
self.repo.write_manifest(manifest)
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("VERSION", result.stderr)
|
||||
|
||||
|
||||
@needs_renderer
|
||||
class ADeadInternalLinkStopsTheBuild(ScratchCase):
|
||||
"""A door that 404s is the failure this page cannot be allowed to
|
||||
ship, so it is a build failure rather than a review finding."""
|
||||
|
||||
def test_a_link_to_a_route_the_manifest_lost_is_named(self):
|
||||
manifest = self.repo.manifest()
|
||||
manifest["pages"] = [page for page in manifest["pages"]
|
||||
if page["path"] != "/guides/install/"]
|
||||
self.repo.write_manifest(manifest)
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("/guides/install/", result.stderr)
|
||||
self.assertIn("does not write", result.stderr)
|
||||
|
||||
def test_it_fails_before_anything_is_written(self):
|
||||
"""As drift does: the last good build stays up."""
|
||||
manifest = self.repo.manifest()
|
||||
manifest["pages"] = [page for page in manifest["pages"]
|
||||
if page["path"] != "/concepts/team-mode/"]
|
||||
self.repo.write_manifest(manifest)
|
||||
self.repo.build()
|
||||
self.assertFalse(self.repo.out.exists(),
|
||||
"a build with a dead link wrote pages anyway")
|
||||
|
||||
def test_a_relative_link_is_refused(self):
|
||||
home = self.repo.plain_home()
|
||||
(self.repo.root / "site" / "templates" / "plain.html").write_text(
|
||||
'<a href="guides/install/">x</a>$title$body', encoding="utf-8")
|
||||
manifest = self.repo.manifest()
|
||||
manifest["pages"] = [home]
|
||||
self.repo.write_manifest(manifest)
|
||||
result = self.repo.build()
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertIn("guides/install/", result.stderr)
|
||||
|
||||
def test_a_stamped_asset_url_is_not_mistaken_for_a_dead_one(self):
|
||||
"""The stylesheet is linked with a ?v=<hash>; the check has to
|
||||
read past the query string rather than call it a dead link."""
|
||||
result = self.repo.build()
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("/static/site.css?v=",
|
||||
(self.repo.out / "index.html").read_text("utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user