site: a minisite generated from the repo's own markdown
bench has no public page. site/ is the machinery for one: a manifest, a generator, two layouts from the turn-2 docs design, and the Daylight half of the board's own token set. Nothing here is transcribed. Every page body is a heading slice of a file that already documents bench, and site/pages.json is the only place that mapping is written down — the IA is editorial judgement, so it lives in one readable file rather than in a directory convention. The point is the failure: a from/to heading the source no longer contains stops the build naming the route, the file and the heading, and every page renders before the output directory is touched, so a drifting rebuild leaves the last good build standing instead of replacing it with an empty one. Repo-relative links come out as site routes or GitHub links; a dead one stops the build too. Rendering is markdown-it-py, pinned in site/requirements.txt. The stdlib-only law binds manager/core/ — the tool people install — and site/ is neither shipped nor installed, so a hand-rolled parser would have been risk spent in the wrong place on a document that leans on fenced code, nested lists and inline code throughout. Templates are string.Template: $name substitutes and a literal dollar doubles, because str.format's braces collide with every line of CSS. Fonts are self-hosted and same-origin: no page bench serves may need a request to a third party to be read. fetch-fonts.py downloads the seven woff2 files once; until they are there the build says which are missing and the pages fall back to the system stack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
# site/ — bench.12vectors.com
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
behaviour and the behaviour itself cannot drift apart when one is cut
|
||||
from the other.
|
||||
|
||||
```bash
|
||||
python3 -m pip install -r site/requirements.txt # once
|
||||
python3 site/fetch-fonts.py # once, needs network
|
||||
python3 site/build.py # → site/dist/
|
||||
```
|
||||
|
||||
`site/dist/` is gitignored — it is output, rebuilt on every deploy.
|
||||
Deployment is task 32's; this directory only has to produce the files.
|
||||
|
||||
`site/` never reaches a host repo: releases ship exactly what
|
||||
`../manager/core/release-manifest` lists, and it does not list this
|
||||
directory. `tests/test_release_artifact.py` asserts that out loud rather
|
||||
than leaving it to inference.
|
||||
|
||||
## What is where
|
||||
|
||||
| Path | What it is |
|
||||
| --- | --- |
|
||||
| `pages.json` | The manifest: one entry per route, and the site's whole IA |
|
||||
| `build.py` | The generator — slicing, drift detection, rendering, links |
|
||||
| `templates/` | One `string.Template` per layout (`$name`, `$$` for a literal dollar) |
|
||||
| `static/` | Copied to `dist/static/` verbatim: the stylesheet, the icon, the fonts |
|
||||
| `requirements.txt` | `markdown-it-py`, pinned. The only dependency |
|
||||
| `fetch-fonts.py` | Downloads the self-hosted woff2 files, once |
|
||||
|
||||
## A manifest entry
|
||||
|
||||
```json
|
||||
{
|
||||
"path": "/concepts/claiming-a-card/",
|
||||
"title": "Claiming a card",
|
||||
"layout": "article",
|
||||
"section": "Concepts",
|
||||
"description": "…",
|
||||
"source": "AGENTS.md",
|
||||
"from": "## Claiming a card",
|
||||
"to": "## Syncing boards"
|
||||
}
|
||||
```
|
||||
|
||||
- **`path`** starts and ends with `/`; `/x/y/` is written to
|
||||
`dist/x/y/index.html`.
|
||||
- **`layout`** names a file in `templates/`.
|
||||
- **`section`** groups the page in the nav and the sidebar. The IA is
|
||||
read out of this file in this file's order — nothing is derived from
|
||||
the directory layout.
|
||||
- **`source`** is repo-relative, or `null` for a landing page whose body
|
||||
is authored in its template rather than sliced.
|
||||
- **`from`** is the heading the slice starts at. It is matched on the
|
||||
heading's text; writing the `#`s (`## Stages`) pins the level too.
|
||||
Headings inside fenced code blocks never match.
|
||||
- **`to`** is optional. Without it the slice runs to the next heading of
|
||||
the same level or shallower.
|
||||
|
||||
The `from` heading itself is dropped — the layout renders the page title
|
||||
— and what remains is promoted by `level - 1`, so a section's `###`
|
||||
sub-headings land as the page's `<h2>`s.
|
||||
|
||||
## What fails the build
|
||||
|
||||
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 markdown link to a repo path that does not exist, or that escapes
|
||||
the repo — a dead relative link must never reach the site;
|
||||
- a template placeholder the builder does not supply.
|
||||
|
||||
Repo-relative links that *do* resolve are rewritten: to a site route if
|
||||
`link_routes` maps the file to one, otherwise to the file on GitHub.
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the bench minisite from the repo's own markdown.
|
||||
|
||||
python3 site/build.py # writes site/dist/
|
||||
python3 site/build.py --out /tmp/x # somewhere else
|
||||
|
||||
Nothing here is transcribed. Every page body is a heading slice of a file
|
||||
that already documents bench — AGENTS.md, README.md, the settings example,
|
||||
the adapter contract — and site/pages.json is the only place that mapping
|
||||
is written down. That is the whole point: renaming a section in AGENTS.md
|
||||
must break this build, loudly, naming the route and the heading it can no
|
||||
longer find, rather than quietly emitting a page with an empty body.
|
||||
|
||||
The stdlib-only law binds `manager/core/` — the tool people install. This
|
||||
directory is neither shipped nor installed (see manager/core/release-manifest,
|
||||
"Anything not listed here does not ship"), so it may depend on a real
|
||||
markdown parser: markdown-it-py, pinned in site/requirements.txt.
|
||||
|
||||
## How a slice becomes a page
|
||||
|
||||
Given `"from": "## Claiming a card"` the builder takes the lines after
|
||||
that heading up to the next heading of the same level or shallower (or to
|
||||
an explicit `"to"` heading), then promotes what is left by `level - 1` so
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
|
||||
SITE = Path(__file__).resolve().parent
|
||||
REPO = SITE.parent
|
||||
|
||||
# Written into every output directory the builder owns, so a later run
|
||||
# knows the tree is its own before removing it. --out pointed at
|
||||
# something else refuses rather than deleting a stranger's files.
|
||||
MARKER = ".bench-site"
|
||||
|
||||
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*\)""")
|
||||
EXTERNAL = ("http://", "https://", "//", "mailto:", "tel:", "data:")
|
||||
|
||||
|
||||
class BuildError(Exception):
|
||||
"""A failure a person must read and fix: a missing source, a renamed
|
||||
heading, a dead link, a manifest that does not make sense."""
|
||||
|
||||
|
||||
# ── reading markdown ──────────────────────────────────────────────────
|
||||
|
||||
def headings(text: str):
|
||||
"""(line index, level, text) for every ATX heading *outside* a code
|
||||
fence. The fence tracking is not a nicety: AGENTS.md fences a task
|
||||
file template whose first line is `# Task title`, and matching that
|
||||
would slice the document in half."""
|
||||
fence = None
|
||||
for index, line in enumerate(text.splitlines()):
|
||||
opener = FENCE.match(line)
|
||||
if opener:
|
||||
marker = opener.group(1)
|
||||
if fence is None:
|
||||
fence = marker
|
||||
elif marker[0] == fence[0] and len(marker) >= len(fence) \
|
||||
and not line.strip().strip(marker[0]):
|
||||
fence = None
|
||||
continue
|
||||
if fence is not None:
|
||||
continue
|
||||
found = ATX.match(line)
|
||||
if found:
|
||||
yield index, len(found.group(1)), found.group(2).strip()
|
||||
|
||||
|
||||
def wanted(value: str):
|
||||
"""A manifest heading as (level or None, text). `## Stages` pins the
|
||||
level too; a bare `Stages` matches the heading wherever it sits."""
|
||||
text = value.lstrip("#")
|
||||
level = len(value) - len(text)
|
||||
return (level or None), text.strip()
|
||||
|
||||
|
||||
def find_heading(marks: list, value: str, after: int = -1):
|
||||
level, text = wanted(value)
|
||||
for index, found_level, found_text in marks:
|
||||
if index <= after:
|
||||
continue
|
||||
if found_text == text and (level is None or level == found_level):
|
||||
return index, found_level
|
||||
return None
|
||||
|
||||
|
||||
def promote(text: str, by: int) -> str:
|
||||
"""Shift every heading in a slice `by` levels shallower, so a section
|
||||
lifted out of a larger document keeps its internal hierarchy while
|
||||
starting at <h2> under the page's own <h1>."""
|
||||
if by <= 0:
|
||||
return text
|
||||
lines = text.splitlines()
|
||||
for index, level, _ in list(headings(text)):
|
||||
found = ATX.match(lines[index])
|
||||
lines[index] = "#" * max(1, level - by) + " " + found.group(2).strip()
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def slice_section(text: str, page: dict, source: str) -> str:
|
||||
"""The body of one page, or a BuildError naming what drifted."""
|
||||
route = page["path"]
|
||||
marks = list(headings(text))
|
||||
start = find_heading(marks, page["from"])
|
||||
if start is None:
|
||||
raise BuildError(
|
||||
f'{route}: {source} has no heading "{page["from"]}" — the '
|
||||
f"section was renamed, moved or deleted. Fix the heading or "
|
||||
f"update the entry in site/pages.json.")
|
||||
start_line, level = start
|
||||
|
||||
if page.get("to"):
|
||||
end = find_heading(marks, page["to"], after=start_line)
|
||||
if end is None:
|
||||
raise BuildError(
|
||||
f'{route}: {source} has no heading "{page["to"]}" after '
|
||||
f'"{page["from"]}" — the slice has no end. Fix the heading '
|
||||
f"or update the entry in site/pages.json.")
|
||||
end_line = end[0]
|
||||
else:
|
||||
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())
|
||||
|
||||
body = "\n".join(text.splitlines()[start_line + 1:end_line]).strip("\n")
|
||||
if not body.strip():
|
||||
raise BuildError(
|
||||
f'{route}: the slice of {source} from "{page["from"]}" is '
|
||||
f"empty. A page with no body is a drift, not a page.")
|
||||
return promote(body, level - 1)
|
||||
|
||||
|
||||
# ── links ─────────────────────────────────────────────────────────────
|
||||
|
||||
def rewrite_link(href: str, *, page: dict, source: str, manifest: dict,
|
||||
repo: Path) -> str:
|
||||
"""A repo-relative link out of a markdown file is a dead path on the
|
||||
web. Send it to the site route that covers it, or to the file on
|
||||
GitHub — and refuse to emit anything else."""
|
||||
if not href or href.startswith("#") or href.startswith(EXTERNAL):
|
||||
return href
|
||||
target, hash_mark, fragment = href.partition("#")
|
||||
if not target:
|
||||
return href
|
||||
rel = os.path.normpath(os.path.join(os.path.dirname(source), target))
|
||||
if rel.startswith(".."):
|
||||
raise BuildError(
|
||||
f'{page["path"]}: {source} links to "{href}", which is outside '
|
||||
f"the repository. Only links within the repo can be rewritten.")
|
||||
if not (repo / rel).exists():
|
||||
raise BuildError(
|
||||
f'{page["path"]}: {source} links to "{href}", which does not '
|
||||
f"exist ({rel}). A dead link in a source file is a dead link "
|
||||
f"on the site.")
|
||||
route = manifest.get("link_routes", {}).get(rel)
|
||||
if route:
|
||||
return route + hash_mark + fragment
|
||||
blob = manifest["site"]["blob_base"].rstrip("/") + "/"
|
||||
return blob + rel + hash_mark + fragment
|
||||
|
||||
|
||||
# ── rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
||||
return slug or "section"
|
||||
|
||||
|
||||
def render_markdown(body: str, *, page: dict, source: str, manifest: dict,
|
||||
repo: Path):
|
||||
"""(html, [(slug, text)] for the h2s) — heading ids and rewritten
|
||||
links are done on the token stream, not with regexes over HTML."""
|
||||
try:
|
||||
from markdown_it import MarkdownIt
|
||||
except ImportError as missing: # pragma: no cover - environment
|
||||
raise BuildError(
|
||||
"markdown-it-py is not installed. It is the site's only "
|
||||
"dependency: python3 -m pip install -r site/requirements.txt"
|
||||
) from missing
|
||||
|
||||
renderer = MarkdownIt("commonmark").enable(["table", "strikethrough"])
|
||||
tokens = renderer.parse(body)
|
||||
contents, seen = [], {}
|
||||
for index, token in enumerate(tokens):
|
||||
if token.type == "heading_open":
|
||||
text = tokens[index + 1].content
|
||||
slug = slugify(text)
|
||||
seen[slug] = seen.get(slug, 0) + 1
|
||||
if seen[slug] > 1:
|
||||
slug = f"{slug}-{seen[slug]}"
|
||||
token.attrSet("id", slug)
|
||||
if token.tag == "h2":
|
||||
contents.append((slug, text))
|
||||
elif token.type == "inline":
|
||||
for child in token.children or []:
|
||||
if child.type == "link_open":
|
||||
child.attrSet("href", rewrite_link(
|
||||
child.attrGet("href"), page=page, source=source,
|
||||
manifest=manifest, repo=repo))
|
||||
elif child.type == "image":
|
||||
child.attrSet("src", rewrite_link(
|
||||
child.attrGet("src"), page=page, source=source,
|
||||
manifest=manifest, repo=repo))
|
||||
return renderer.renderer.render(tokens, renderer.options, {}), contents
|
||||
|
||||
|
||||
def read_source(page: dict, repo: Path) -> str:
|
||||
source = page["source"]
|
||||
path = repo / source
|
||||
if not path.is_file():
|
||||
raise BuildError(
|
||||
f'{page["path"]}: source file {source} does not exist. The '
|
||||
f"file was renamed or moved; update site/pages.json.")
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ── the shell around a body ───────────────────────────────────────────
|
||||
|
||||
def sections(manifest: dict) -> list:
|
||||
"""The IA, read out of the manifest in manifest order: the sections
|
||||
that have pages, each with its pages. Nothing is derived from the
|
||||
directory layout — pages.json is where the site's shape is written."""
|
||||
groups: list = []
|
||||
for page in manifest["pages"]:
|
||||
name = page.get("section")
|
||||
if not name:
|
||||
continue
|
||||
for group in groups:
|
||||
if group["name"] == name:
|
||||
group["pages"].append(page)
|
||||
break
|
||||
else:
|
||||
groups.append({"name": name, "pages": [page]})
|
||||
return groups
|
||||
|
||||
|
||||
def render_nav(manifest: dict, current: dict) -> str:
|
||||
out = []
|
||||
for group in sections(manifest):
|
||||
first = group["pages"][0]
|
||||
active = " nav-here" if current.get("section") == group["name"] else ""
|
||||
out.append(f'<a class="nav-link{active}" href="{first["path"]}">'
|
||||
f'{escape(group["name"])}</a>')
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def render_sidebar(manifest: dict, current: dict) -> str:
|
||||
out = []
|
||||
for group in sections(manifest):
|
||||
out.append('<div class="side-group">')
|
||||
out.append(f'<span class="side-label">{escape(group["name"])}</span>')
|
||||
for page in group["pages"]:
|
||||
here = " side-here" if page["path"] == current["path"] else ""
|
||||
out.append(f'<a class="side-link{here}" href="{page["path"]}">'
|
||||
f'{escape(page["title"])}</a>')
|
||||
out.append("</div>")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
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>')
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def render_breadcrumb(page: dict) -> str:
|
||||
if not page.get("section"):
|
||||
return ""
|
||||
return (f'<span>{escape(page["section"])}</span><span>›</span>'
|
||||
f'<span class="crumb-here">{escape(page["title"])}</span>')
|
||||
|
||||
|
||||
def load_template(name: str, site: Path) -> Template:
|
||||
path = site / "templates" / f"{name}.html"
|
||||
if not path.is_file():
|
||||
available = sorted(p.stem for p in (site / "templates").glob("*.html"))
|
||||
raise BuildError(
|
||||
f'no template for layout "{name}". Templates present: '
|
||||
f'{", ".join(available) or "none"}.')
|
||||
return Template(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def render_page(page: dict, manifest: dict, *, site: Path, repo: Path) -> str:
|
||||
source = page.get("source")
|
||||
if source:
|
||||
body, contents = render_markdown(
|
||||
slice_section(read_source(page, repo), page, source),
|
||||
page=page, source=source, manifest=manifest, repo=repo)
|
||||
else:
|
||||
# An authored landing page says so with "source": null. Its words
|
||||
# live in the template, so there is no slice and nothing to drift.
|
||||
body, contents = "", []
|
||||
|
||||
config = manifest["site"]
|
||||
blob = config["blob_base"].rstrip("/") + "/"
|
||||
fields = {
|
||||
"title": escape(page["title"]),
|
||||
"description": escape(page.get("description")
|
||||
or config.get("description", "")),
|
||||
"site_title": escape(config["title"]),
|
||||
"site_tagline": escape(config.get("tagline", "")),
|
||||
"version": escape(config.get("version", "")),
|
||||
"body": body,
|
||||
"toc": render_contents(contents),
|
||||
"nav": render_nav(manifest, page),
|
||||
"sidebar": render_sidebar(manifest, page),
|
||||
"breadcrumb": render_breadcrumb(page),
|
||||
"section": escape(page.get("section") or ""),
|
||||
"repo_url": config["repo_url"],
|
||||
"issues_url": config.get("issues_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"],
|
||||
}
|
||||
try:
|
||||
return load_template(page["layout"], site).substitute(fields)
|
||||
except KeyError as unknown:
|
||||
raise BuildError(
|
||||
f'{page["layout"]}.html uses an unknown placeholder '
|
||||
f"${unknown.args[0]}. Known: "
|
||||
f'{", ".join("$" + k for k in sorted(fields))}.') from unknown
|
||||
except ValueError as bad:
|
||||
raise BuildError(
|
||||
f"{page['layout']}.html: {bad}. A literal dollar sign in a "
|
||||
f"template must be written $$.") from bad
|
||||
|
||||
|
||||
# ── the manifest ──────────────────────────────────────────────────────
|
||||
|
||||
def load_manifest(site: Path) -> dict:
|
||||
path = site / "pages.json"
|
||||
if not path.is_file():
|
||||
raise BuildError(f"no manifest at {path}")
|
||||
try:
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as broken:
|
||||
raise BuildError(f"pages.json is not valid JSON: {broken}") from broken
|
||||
|
||||
for key in ("site", "pages"):
|
||||
if key not in manifest:
|
||||
raise BuildError(f'pages.json has no "{key}" key')
|
||||
for key in ("title", "repo_url", "blob_base"):
|
||||
if key not in manifest["site"]:
|
||||
raise BuildError(f'pages.json: site has no "{key}" key')
|
||||
|
||||
seen = set()
|
||||
for page in manifest["pages"]:
|
||||
for key in ("path", "title", "layout"):
|
||||
if not page.get(key):
|
||||
raise BuildError(f'pages.json: an entry has no "{key}": '
|
||||
f"{json.dumps(page)}")
|
||||
route = page["path"]
|
||||
if not route.startswith("/") or not route.endswith("/"):
|
||||
raise BuildError(f'pages.json: route "{route}" must start and '
|
||||
f'end with "/"')
|
||||
if route in seen:
|
||||
raise BuildError(f'pages.json: route "{route}" appears twice')
|
||||
seen.add(route)
|
||||
if "source" not in page:
|
||||
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"):
|
||||
raise BuildError(
|
||||
f'{route}: "source" is {page["source"]} but there is no '
|
||||
f'"from" heading to slice from.')
|
||||
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.")
|
||||
return manifest
|
||||
|
||||
|
||||
# ── output ────────────────────────────────────────────────────────────
|
||||
|
||||
def clear(out: Path) -> None:
|
||||
"""Empty the output directory — but only one this builder made. A
|
||||
--out pointed at something else stops the build instead."""
|
||||
if not out.exists():
|
||||
return
|
||||
if not out.is_dir():
|
||||
raise BuildError(f"{out} is not a directory")
|
||||
if any(out.iterdir()) and not (out / MARKER).exists():
|
||||
raise BuildError(
|
||||
f"{out} is not empty and was not written by this builder "
|
||||
f"(no {MARKER}). Refusing to delete it.")
|
||||
shutil.rmtree(out)
|
||||
|
||||
|
||||
def copy_static(site: Path, out: Path) -> None:
|
||||
static = site / "static"
|
||||
if static.is_dir():
|
||||
shutil.copytree(static, out / "static",
|
||||
ignore=shutil.ignore_patterns(".DS_Store"))
|
||||
|
||||
|
||||
def missing_assets(out: Path) -> list:
|
||||
"""Every same-origin url() a stylesheet asks for that is not in the
|
||||
output. The fonts are self-hosted on purpose — the shipped site makes
|
||||
no third-party request — so an absent woff2 is worth saying out loud
|
||||
even though the page still renders on the fallback stack."""
|
||||
absent = []
|
||||
for sheet in sorted((out / "static").rglob("*.css")):
|
||||
for url in CSS_URL.findall(sheet.read_text(encoding="utf-8")):
|
||||
if url.startswith(EXTERNAL):
|
||||
continue
|
||||
target = (out / url.lstrip("/")) if url.startswith("/") \
|
||||
else (sheet.parent / url)
|
||||
if not target.exists():
|
||||
absent.append(url)
|
||||
return sorted(set(absent))
|
||||
|
||||
|
||||
def build(*, repo: Path = REPO, site: Path = None, out: Path = None,
|
||||
log=print) -> list:
|
||||
site = site or (repo / "site")
|
||||
out = out or (site / "dist")
|
||||
manifest = load_manifest(site)
|
||||
|
||||
pages = [(page, render_page(page, manifest, site=site, repo=repo))
|
||||
for page in manifest["pages"]]
|
||||
|
||||
clear(out)
|
||||
out.mkdir(parents=True)
|
||||
(out / MARKER).write_text(
|
||||
"written by site/build.py; safe to delete\n", encoding="utf-8")
|
||||
copy_static(site, out)
|
||||
|
||||
written = []
|
||||
for page, html in pages:
|
||||
target = out / page["path"].strip("/") / "index.html"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(html, encoding="utf-8")
|
||||
written.append(target)
|
||||
log(f" {page['path']:<34} {target.relative_to(out)}")
|
||||
return written
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build the bench minisite into site/dist/.")
|
||||
parser.add_argument("--repo", type=Path, default=REPO,
|
||||
help="repository root (default: the one above site/)")
|
||||
parser.add_argument("--site", type=Path, default=None,
|
||||
help="site directory (default: <repo>/site)")
|
||||
parser.add_argument("--out", type=Path, default=None,
|
||||
help="output directory (default: <site>/dist)")
|
||||
parser.add_argument("-q", "--quiet", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
repo = args.repo.resolve()
|
||||
site = (args.site or repo / "site").resolve()
|
||||
out = (args.out or site / "dist").resolve()
|
||||
log = (lambda *a: None) if args.quiet else print
|
||||
|
||||
try:
|
||||
written = build(repo=repo, site=site, out=out, log=log)
|
||||
except BuildError as failure:
|
||||
print(f"error: {failure}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
for url in missing_assets(out):
|
||||
print(f"warning: {url} is referenced by the stylesheet but is not "
|
||||
f"in the build — see site/static/fonts/README.md",
|
||||
file=sys.stderr)
|
||||
log(f"{len(written)} pages → {out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download the site's woff2 files into site/static/fonts/.
|
||||
|
||||
python3 site/fetch-fonts.py # fetch what is missing
|
||||
python3 site/fetch-fonts.py --force # fetch everything again
|
||||
|
||||
The site self-hosts its type: no page bench serves may make a request to
|
||||
a third party, and a linked font CDN is exactly that request. This script
|
||||
is the one moment the fonts come from Google — at build time, on someone's
|
||||
machine — after which the files are the site's own.
|
||||
|
||||
It asks the CSS API for each face on its own, so the reply names exactly
|
||||
one woff2 per request and there is nothing to guess about which URL is
|
||||
which weight. The `latin` subset is what that API already serves.
|
||||
|
||||
Stdlib only, network required. If you are offline, copy the files in by
|
||||
hand; site/static/fonts/README.md lists the seven names.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
FONTS = Path(__file__).resolve().parent / "static" / "fonts"
|
||||
API = "https://fonts.googleapis.com/css2"
|
||||
# A modern browser UA is what makes the API answer in woff2 rather than
|
||||
# in one of the older formats it keeps for old clients.
|
||||
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36")
|
||||
WOFF2 = re.compile(r"url\((https://[^)]+\.woff2)\)")
|
||||
|
||||
# (output name, family, css spec) — the css spec is the `family=` value.
|
||||
FACES = [
|
||||
("IBMPlexSans-Regular.woff2", "IBM Plex Sans", "IBM+Plex+Sans:ital,wght@0,400"),
|
||||
("IBMPlexSans-Italic.woff2", "IBM Plex Sans", "IBM+Plex+Sans:ital,wght@1,400"),
|
||||
("IBMPlexSans-Medium.woff2", "IBM Plex Sans", "IBM+Plex+Sans:ital,wght@0,500"),
|
||||
("IBMPlexSans-SemiBold.woff2", "IBM Plex Sans", "IBM+Plex+Sans:ital,wght@0,600"),
|
||||
("IBMPlexMono-Regular.woff2", "IBM Plex Mono", "IBM+Plex+Mono:ital,wght@0,400"),
|
||||
("IBMPlexMono-Medium.woff2", "IBM Plex Mono", "IBM+Plex+Mono:ital,wght@0,500"),
|
||||
("ZillaSlab-SemiBold.woff2", "Zilla Slab", "Zilla+Slab:wght@600"),
|
||||
]
|
||||
|
||||
|
||||
def get(url: str, *, binary: bool = False):
|
||||
request = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||
with urllib.request.urlopen(request, timeout=30) as reply:
|
||||
raw = reply.read()
|
||||
return raw if binary else raw.decode("utf-8")
|
||||
|
||||
|
||||
def fetch(face: tuple, force: bool) -> str:
|
||||
name, family, spec = face
|
||||
target = FONTS / name
|
||||
if target.exists() and not force:
|
||||
return f" kept {name}"
|
||||
css = get(f"{API}?family={spec}&display=swap&subset=latin")
|
||||
urls = WOFF2.findall(css)
|
||||
if not urls:
|
||||
raise RuntimeError(
|
||||
f"{family}: the CSS API answered with no woff2 url. It may have "
|
||||
f"changed its reply for this User-Agent; fetch by hand.")
|
||||
# The API lists subsets in order; latin is the one the site needs and
|
||||
# is the last block it emits for these families.
|
||||
target.write_bytes(get(urls[-1], binary=True))
|
||||
return f" wrote {name} ({len(urls)} subsets offered)"
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="re-download faces that are already here")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
FONTS.mkdir(parents=True, exist_ok=True)
|
||||
print(f"fonts → {FONTS}")
|
||||
for face in FACES:
|
||||
try:
|
||||
print(fetch(face, args.force))
|
||||
except (urllib.error.URLError, RuntimeError) as failure:
|
||||
print(f"error: {face[0]}: {failure}", file=sys.stderr)
|
||||
return 1
|
||||
print("done — the site now serves its own type, same-origin.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"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",
|
||||
"blob_base": "https://github.com/12vectors/bench/blob/main/"
|
||||
},
|
||||
|
||||
"link_routes": {},
|
||||
|
||||
"pages": [
|
||||
{
|
||||
"path": "/",
|
||||
"title": "bench",
|
||||
"layout": "home",
|
||||
"section": null,
|
||||
"description": "A live kanban for coding-agent work: task files in stage directories are the only source of truth.",
|
||||
"source": "AGENTS.md",
|
||||
"from": "## Stages",
|
||||
"to": "## Moving a task"
|
||||
},
|
||||
{
|
||||
"path": "/concepts/claiming-a-card/",
|
||||
"title": "Claiming a card",
|
||||
"layout": "article",
|
||||
"section": "Concepts",
|
||||
"description": "Claiming is moving: taking a card towards work is the commitment, and that is where ownership is recorded.",
|
||||
"source": "AGENTS.md",
|
||||
"from": "## Claiming a card",
|
||||
"to": "## Syncing boards"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# The minisite's only dependency, pinned.
|
||||
#
|
||||
# The stdlib-only law binds manager/core/ — the tool people install. This
|
||||
# directory is neither shipped nor installed (manager/core/release-manifest:
|
||||
# "Anything not listed here does not ship"), and AGENTS.md leans on fenced
|
||||
# code, nested lists, inline code and tables, which is a bad place to spend
|
||||
# risk on a hand-rolled parser.
|
||||
#
|
||||
# python3 -m pip install -r site/requirements.txt
|
||||
#
|
||||
markdown-it-py==4.0.0
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill-rule="evenodd"><rect width="16" height="16" rx="4" fill="#0d6e8c"/><path fill="#ffffff" transform="translate(4.81 3.57) scale(.011)" d="M15 0H178V210H330C450 210 565 305 565 420V530C565 645 450 740 330 740H15V648H60V92H15ZM178 314V636H305C385 636 447 585 447 530V420C447 365 385 314 305 314Z"/></svg>
|
||||
|
After Width: | Height: | Size: 366 B |
@@ -0,0 +1,42 @@
|
||||
# The site's fonts live here
|
||||
|
||||
Reading bench's documentation must not require a request to anyone else,
|
||||
so the site links no font CDN. `site/static/site.css` declares its faces
|
||||
against files in this directory, and everything the built pages fetch is
|
||||
same-origin.
|
||||
|
||||
Seven files, exactly these names — they are what the `@font-face` rules
|
||||
in `site.css` ask for:
|
||||
|
||||
| File | Family | Weight | Style |
|
||||
| --- | --- | --- | --- |
|
||||
| `IBMPlexSans-Regular.woff2` | IBM Plex Sans | 400 | normal |
|
||||
| `IBMPlexSans-Italic.woff2` | IBM Plex Sans | 400 | italic |
|
||||
| `IBMPlexSans-Medium.woff2` | IBM Plex Sans | 500 | normal |
|
||||
| `IBMPlexSans-SemiBold.woff2` | IBM Plex Sans | 600 | normal |
|
||||
| `IBMPlexMono-Regular.woff2` | IBM Plex Mono | 400 | normal |
|
||||
| `IBMPlexMono-Medium.woff2` | IBM Plex Mono | 500 | normal |
|
||||
| `ZillaSlab-SemiBold.woff2` | Zilla Slab | 600 | normal |
|
||||
|
||||
## Fetching them
|
||||
|
||||
```bash
|
||||
python3 site/fetch-fonts.py
|
||||
```
|
||||
|
||||
The script asks Google Fonts for the CSS these faces would need, reads
|
||||
the `woff2` URLs out of the reply, and writes the files here under the
|
||||
names above. What it downloads is already the `latin` subset Google
|
||||
serves — the site's copy of the font, not a link to Google's.
|
||||
|
||||
Both families are licensed for this: IBM Plex under the SIL Open Font
|
||||
License 1.1, Zilla Slab likewise. Keeping the downloaded `*.LICENSE.txt`
|
||||
beside the woff2 files satisfies the licence's one obligation.
|
||||
|
||||
## If they are absent
|
||||
|
||||
`site/build.py` prints a `warning:` line naming every file the
|
||||
stylesheet wants and the build does not have, and carries on. The pages
|
||||
render on the fallback stack (`system-ui`, `ui-monospace`, `Georgia`)
|
||||
and still make no third-party request — the typography is wrong, the
|
||||
privacy promise is not.
|
||||
@@ -0,0 +1,303 @@
|
||||
/* ── Bench docs: the Daylight register of the board's own system ───────
|
||||
The four state tokens are named exactly as manager/core/board.html
|
||||
names them — --accent (surf), --calm (pine), --alarm (terracotta),
|
||||
--idle (driftwood) — because the site and the board are one system,
|
||||
and colour in both only ever means state. Their values are the board's
|
||||
light theme; the neutrals are the docs design's paper register.
|
||||
|
||||
Fonts are self-hosted: reading bench's docs must not require a request
|
||||
to anyone else. site/static/fonts/README.md says which files these
|
||||
@font-face rules want and how to fetch them.
|
||||
------------------------------------------------------------------- */
|
||||
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url("/static/fonts/IBMPlexSans-Regular.woff2") format("woff2")}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:italic;font-weight:400;font-display:swap;src:url("/static/fonts/IBMPlexSans-Italic.woff2") format("woff2")}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url("/static/fonts/IBMPlexSans-Medium.woff2") format("woff2")}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url("/static/fonts/IBMPlexSans-SemiBold.woff2") format("woff2")}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2")}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url("/static/fonts/IBMPlexMono-Medium.woff2") format("woff2")}
|
||||
@font-face{font-family:'Zilla Slab';font-style:normal;font-weight:600;font-display:swap;src:url("/static/fonts/ZillaSlab-SemiBold.woff2") format("woff2")}
|
||||
|
||||
:root{
|
||||
/* paper */
|
||||
--bg:#c9dde1; --canvas:#eaf3f4; --surface:#ffffff; --sunken:#e0eef1;
|
||||
--border:#c5d7db; --border-soft:#d9e6e9;
|
||||
--text:#12323b; --muted:#4d6e77; --dim:#87a1a8;
|
||||
/* state — the same four the board carries */
|
||||
--accent:#0d6e8c; --calm:#5f7f33; --alarm:#b1543a; --idle:#93a8ac;
|
||||
--on-accent:#ffffff;
|
||||
/* dark ink: code, terminals, chrome */
|
||||
--ink:#0c1a20; --ink-deep:#0a161b; --ink-line:#1c343a;
|
||||
--ink-text:#e9f3f3; --ink-muted:#95afb4; --ink-dim:#62828a;
|
||||
--ink-accent:#56c2d8; --ink-calm:#a6c96f; --ink-alarm:#e08a63;
|
||||
/* tinted callouts, both state colours */
|
||||
--calm-wash:#f4f8e6; --calm-edge:#d6e2a8; --calm-ink:#4a6329;
|
||||
--alarm-wash:#fbf1ec; --alarm-edge:#ecc9b8; --alarm-ink:#7a3c2a;
|
||||
|
||||
--sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;
|
||||
--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
--display:'Zilla Slab',Georgia,'Times New Roman',serif;
|
||||
|
||||
/* type scale, straight off the design */
|
||||
--t-hero:52px; --t-title:40px; --t-h2:23px; --t-h3:17px;
|
||||
--t-lede:17px; --t-body:15px; --t-ui:13.5px; --t-code:12.5px;
|
||||
--t-micro:10.5px;
|
||||
|
||||
--radius:11px; --shadow:0 20px 50px -34px rgba(18,50,59,.55);
|
||||
--shadow-ink:0 18px 40px -30px rgba(12,26,32,.9);
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0}
|
||||
body{
|
||||
background:var(--canvas); color:var(--text);
|
||||
font:var(--t-body)/1.68 var(--sans);
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
a:hover{color:var(--text);text-decoration:underline}
|
||||
::selection{background:var(--accent);color:var(--on-accent)}
|
||||
.mono{font-family:var(--mono)}
|
||||
.dim{color:var(--dim)}
|
||||
.spacer{flex:1}
|
||||
.rule{height:1px;background:var(--border-soft);margin:12px 0}
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
*,*::before,*::after{animation:none !important;transition:none !important}
|
||||
}
|
||||
|
||||
/* ── chrome ── */
|
||||
.topbar{
|
||||
display:flex;align-items:center;gap:10px;height:30px;padding:0 18px;
|
||||
background:var(--text);color:#a9c4c9;
|
||||
font:11px/1 var(--mono);
|
||||
}
|
||||
.topbar-org{color:var(--canvas);font-weight:500}
|
||||
.topbar-sep{color:var(--muted)}
|
||||
.topbar-repo{color:var(--ink-accent)}
|
||||
|
||||
.masthead{
|
||||
display:flex;align-items:center;gap:20px;padding:14px 30px;
|
||||
background:var(--surface);border-bottom:1px solid var(--border);
|
||||
}
|
||||
.masthead-flat{background:var(--canvas);border-bottom:0}
|
||||
.wordmark{display:flex;align-items:baseline;gap:9px}
|
||||
.wordmark:hover{text-decoration:none}
|
||||
.wordmark-name{
|
||||
font:600 21px/1 var(--display);letter-spacing:-.015em;color:var(--text);
|
||||
}
|
||||
.wordmark-tag{font:12px/1 var(--mono);color:var(--dim)}
|
||||
|
||||
.nav{display:flex;gap:18px;font-size:var(--t-ui)}
|
||||
.nav-link{color:var(--muted);padding-bottom:2px;border-bottom:2px solid transparent}
|
||||
.nav-link:hover{color:var(--text);text-decoration:none}
|
||||
.nav-here{color:var(--text);font-weight:600;border-bottom-color:var(--accent)}
|
||||
|
||||
.button{
|
||||
display:inline-block;padding:7px 13px;font:500 var(--t-ui)/1.4 var(--sans);
|
||||
color:var(--text);background:transparent;
|
||||
border:1px solid var(--border);border-radius:8px;
|
||||
}
|
||||
.button:hover{border-color:var(--accent);color:var(--accent);text-decoration:none}
|
||||
.button-solid{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}
|
||||
.button-solid:hover{background:var(--text);border-color:var(--text);color:var(--on-accent)}
|
||||
.button-lg{padding:11px 18px;font-size:14px}
|
||||
|
||||
.footer{
|
||||
display:flex;align-items:flex-end;gap:26px;padding:22px 26px;
|
||||
background:var(--text);color:#a9c4c9;margin-top:40px;
|
||||
}
|
||||
.footer-mark{margin:0;font:9.5px/1.25 var(--mono);color:var(--muted)}
|
||||
.footer-id{display:flex;flex-direction:column;gap:4px}
|
||||
.footer-name{font:600 15px/1 var(--display);color:var(--canvas)}
|
||||
.footer-id .mono,.footer-repo{font-size:11px}
|
||||
.footer-repo{color:#a9c4c9}
|
||||
|
||||
/* ── article layout (1a Harbour) ── */
|
||||
.shell{
|
||||
display:grid;grid-template-columns:236px minmax(0,1fr) 208px;
|
||||
background:var(--surface);
|
||||
}
|
||||
.side{
|
||||
border-right:1px solid var(--border-soft);
|
||||
padding:22px 16px 40px;display:flex;flex-direction:column;gap:22px;
|
||||
}
|
||||
.side-group{display:flex;flex-direction:column;gap:7px}
|
||||
.side-label{
|
||||
font:var(--t-micro)/1 var(--mono);letter-spacing:.1em;
|
||||
text-transform:uppercase;color:var(--dim);padding-left:9px;
|
||||
}
|
||||
.side-link{font-size:var(--t-ui);color:var(--muted);padding:4px 9px;border-radius:0 6px 6px 0}
|
||||
.side-link:hover{color:var(--text);text-decoration:none}
|
||||
.side-here{
|
||||
font-weight:600;color:var(--text);background:var(--sunken);
|
||||
border-left:2px solid var(--accent);
|
||||
}
|
||||
.side-note{
|
||||
display:flex;flex-direction:column;gap:5px;margin-top:6px;
|
||||
padding:11px 12px;background:var(--canvas);
|
||||
border:1px solid var(--border-soft);border-radius:10px;
|
||||
font-size:12.5px;line-height:1.45;color:var(--muted);
|
||||
}
|
||||
.side-note .mono{font-size:var(--t-micro)}
|
||||
|
||||
.gutter{border-left:1px solid var(--border-soft);padding:30px 18px}
|
||||
.toc{display:flex;flex-direction:column;gap:9px;position:sticky;top:20px}
|
||||
.toc-label{
|
||||
font:var(--t-micro)/1 var(--mono);letter-spacing:.1em;
|
||||
text-transform:uppercase;color:var(--dim);margin-bottom:2px;
|
||||
}
|
||||
.toc-link{
|
||||
font-size:12.5px;color:var(--muted);
|
||||
border-left:2px solid var(--sunken);padding-left:9px;
|
||||
}
|
||||
.toc-link:hover{color:var(--accent);border-left-color:var(--accent);text-decoration:none}
|
||||
.gutter-link{font-size:12.5px;line-height:1.5;color:var(--muted)}
|
||||
.marginalia{
|
||||
margin-top:36px;font:italic 11px/1.55 var(--mono);color:var(--alarm);
|
||||
transform:rotate(-1.2deg);transform-origin:left top;
|
||||
}
|
||||
.marginalia .mono{font-size:11px}
|
||||
|
||||
.crumbs{
|
||||
display:flex;align-items:center;gap:8px;margin-bottom:16px;
|
||||
font:11.5px/1 var(--mono);color:var(--dim);
|
||||
}
|
||||
.crumb-here{color:var(--muted)}
|
||||
|
||||
/* ── generated prose ── */
|
||||
.page-article .prose{padding:30px 40px 44px;min-width:0}
|
||||
.prose h1{
|
||||
font:600 var(--t-title)/1.1 var(--display);letter-spacing:-.02em;
|
||||
margin:0 0 12px;
|
||||
}
|
||||
.prose h2{
|
||||
font:600 var(--t-h2)/1.3 var(--display);letter-spacing:-.01em;
|
||||
margin:34px 0 10px;scroll-margin-top:20px;
|
||||
}
|
||||
.prose h3{font:600 var(--t-h3)/1.4 var(--sans);margin:26px 0 8px}
|
||||
.prose h1 + p,.prose > p:first-child{
|
||||
font-size:var(--t-lede);line-height:1.6;color:var(--muted);max-width:60ch;
|
||||
}
|
||||
.prose p{margin:0 0 16px;color:var(--muted);max-width:64ch;text-wrap:pretty}
|
||||
.prose strong{color:var(--text);font-weight:600}
|
||||
.prose ul,.prose ol{margin:0 0 18px;padding-left:22px;max-width:64ch;color:var(--muted)}
|
||||
.prose li{margin:0 0 7px}
|
||||
.prose li > ul,.prose li > ol{margin:7px 0 0}
|
||||
.prose hr{border:0;border-top:1px solid var(--border-soft);margin:30px 0}
|
||||
.prose blockquote{
|
||||
margin:0 0 20px;padding:14px 16px;background:var(--canvas);
|
||||
border:1px solid var(--border-soft);border-left:3px solid var(--accent);
|
||||
border-radius:0 10px 10px 0;color:var(--text);
|
||||
}
|
||||
.prose blockquote p:last-child{margin:0}
|
||||
|
||||
.prose code{
|
||||
font:var(--t-code)/1.5 var(--mono);
|
||||
background:var(--canvas);border-radius:4px;padding:1px 5px;
|
||||
color:var(--text);
|
||||
}
|
||||
.prose pre{
|
||||
margin:0 0 24px;padding:16px 18px;overflow-x:auto;
|
||||
background:var(--ink);color:var(--ink-text);
|
||||
font:var(--t-code)/1.75 var(--mono);
|
||||
border-radius:var(--radius);box-shadow:var(--shadow-ink);
|
||||
}
|
||||
.prose pre code{background:transparent;padding:0;color:inherit;font-size:inherit}
|
||||
|
||||
.prose table{
|
||||
border-collapse:separate;border-spacing:0;margin:0 0 26px;width:100%;
|
||||
background:var(--surface);border:1px solid var(--border-soft);
|
||||
border-radius:10px;overflow:hidden;font-size:var(--t-ui);
|
||||
}
|
||||
.prose th{
|
||||
text-align:left;padding:9px 14px;background:var(--canvas);
|
||||
border-bottom:1px solid var(--border-soft);
|
||||
font:var(--t-micro)/1.4 var(--mono);letter-spacing:.08em;
|
||||
text-transform:uppercase;color:var(--dim);
|
||||
}
|
||||
.prose td{padding:11px 14px;border-bottom:1px solid var(--canvas);color:var(--muted);vertical-align:top}
|
||||
.prose tr:last-child td{border-bottom:0}
|
||||
|
||||
/* ── home layout (1b Dockside) ── */
|
||||
.hero{
|
||||
display:grid;grid-template-columns:1.05fr .95fr;gap:44px;
|
||||
padding:44px 44px 40px;align-items:center;
|
||||
}
|
||||
.hero-copy{display:flex;flex-direction:column;gap:20px}
|
||||
.eyebrow{
|
||||
font:11px/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;
|
||||
color:var(--accent);
|
||||
}
|
||||
.hero h1{
|
||||
margin:0;font:600 var(--t-hero)/1.05 var(--display);
|
||||
letter-spacing:-.025em;text-wrap:balance;
|
||||
}
|
||||
.lede{
|
||||
margin:0;max-width:44ch;font-size:var(--t-lede);line-height:1.62;
|
||||
color:var(--muted);text-wrap:pretty;
|
||||
}
|
||||
.hero-actions{display:flex;gap:10px;align-items:center}
|
||||
.aside-note{font-size:11.5px;font-style:italic;color:var(--dim)}
|
||||
|
||||
.terminal{border-radius:12px;overflow:hidden;box-shadow:0 24px 46px -30px rgba(12,26,32,.85)}
|
||||
.terminal-bar{
|
||||
display:flex;align-items:center;gap:8px;padding:9px 14px;
|
||||
background:var(--ink-deep);
|
||||
}
|
||||
.dot{width:8px;height:8px;border-radius:99px}
|
||||
.dot-alarm{background:var(--ink-alarm)}
|
||||
.dot-calm{background:var(--ink-calm)}
|
||||
.terminal-title{font-size:11px;color:var(--ink-dim);margin-left:6px}
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.doors{
|
||||
display:grid;grid-template-columns:repeat(3,1fr);gap:14px;
|
||||
padding:0 44px 34px;
|
||||
}
|
||||
.door{
|
||||
display:flex;flex-direction:column;gap:8px;padding:20px;
|
||||
background:var(--surface);border:1px solid var(--border-soft);
|
||||
border-radius:12px;
|
||||
}
|
||||
.door-dark{background:var(--text);border-color:var(--text);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)}
|
||||
.door-text{font-size:var(--t-ui);line-height:1.6;color:var(--muted)}
|
||||
.door-dark .door-text{color:#a9c4c9}
|
||||
.door .mono{font-size:var(--t-code)}
|
||||
|
||||
/* ── narrow ── */
|
||||
@media (max-width:1080px){
|
||||
.shell{grid-template-columns:220px minmax(0,1fr)}
|
||||
.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}
|
||||
}
|
||||
@media (max-width:760px){
|
||||
.shell{grid-template-columns:minmax(0,1fr)}
|
||||
.side{display:none}
|
||||
.page-article .prose{padding:24px 22px 36px}
|
||||
.doors{grid-template-columns:1fr}
|
||||
.hero h1{font-size:38px}
|
||||
.masthead{padding:12px 18px;gap:12px}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<!-- Layout "article" — 1a Harbour from the turn-2 docs design: three
|
||||
columns, the sidebar carrying the IA, the generated body in the
|
||||
middle, contents and the two GitHub links in the gutter.
|
||||
|
||||
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="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/site.css">
|
||||
</head>
|
||||
<body class="page-article">
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
$body
|
||||
</article>
|
||||
|
||||
<aside class="gutter">
|
||||
<div class="toc">
|
||||
$toc
|
||||
<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>
|
||||
<p class="marginalia">← this page is a slice of
|
||||
<span class="mono">$source_path</span>; rename that heading and the
|
||||
build stops</p>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<pre class="footer-mark"> ___________________
|
||||
|___________________|
|
||||
|| ||
|
||||
|| ||</pre>
|
||||
<div class="footer-id">
|
||||
<span class="footer-name">$site_title</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>
|
||||
@@ -0,0 +1,147 @@
|
||||
<!doctype html>
|
||||
<!-- 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.
|
||||
|
||||
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. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>$site_title · $site_tagline</title>
|
||||
<meta name="description" content="$description">
|
||||
<link rel="canonical" href="$canonical">
|
||||
<link rel="icon" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/site.css">
|
||||
</head>
|
||||
<body class="page-home">
|
||||
|
||||
<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>$version</span>
|
||||
</div>
|
||||
|
||||
<header class="masthead masthead-flat">
|
||||
<a class="wordmark" href="/">
|
||||
<span class="wordmark-name">$site_title</span>
|
||||
<span class="wordmark-tag">$site_tagline</span>
|
||||
</a>
|
||||
<span class="spacer"></span>
|
||||
<nav class="nav">
|
||||
$nav
|
||||
</nav>
|
||||
<a class="button" href="$repo_url">GitHub ↗</a>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<span class="eyebrow">Documentation</span>
|
||||
<h1>Put the agents<br>on the bench.</h1>
|
||||
<p class="lede">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.</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>
|
||||
</div>
|
||||
<span class="mono aside-note">no account, no service, no database. it is
|
||||
python 3 on your own machine.</span>
|
||||
</div>
|
||||
|
||||
<div class="terminal">
|
||||
<div class="terminal-bar">
|
||||
<span class="dot dot-alarm"></span>
|
||||
<span class="dot dot-calm"></span>
|
||||
<span class="mono terminal-title">~/your-repo</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
|
||||
|
||||
<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>:
|
||||
|
||||
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="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
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="doors">
|
||||
<div class="door">
|
||||
<span class="mono door-index">01</span>
|
||||
<span class="door-title">Install</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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<pre class="footer-mark"> ___________________
|
||||
|___________________|
|
||||
|| ||
|
||||
|| ||</pre>
|
||||
<div class="footer-id">
|
||||
<span class="footer-name">$site_title</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>
|
||||
Reference in New Issue
Block a user