Merge pull request #296 from carlosframework/docs
docs: carlosframework.com/docs — the corpus, six gates, and the CLI reference
24 files changed,
+3646
−0
AGENTS.md+8 −0docs/site/anchors.json+15 −0docs/site/cli.md+714 −0docs/site/index.md+17 −0docs/site/nav.json+14 −0docs/superpowers/plans/2026-08-25-docs-pipeline-and-gates.md+1615 −0docs/superpowers/specs/2026-08-24-docs-sites-design.md+248 −0internal/docsite/cli_test.go+77 −0internal/docsite/cliscan.go+92 −0internal/docsite/docsite.go+331 −0internal/docsite/fences_test.go+187 −0internal/docsite/links_test.go+125 −0internal/docsite/nav_test.go+80 −0internal/docsite/parse_test.go+74 −0internal/docsite/testdata/corpus/getting-started.md+14 −0internal/docsite/testdata/corpus/index.md+4 −0internal/docsite/testdata/corpus/nav.json+16 −0internal/docsite/testdata/corpus/reference/cli.md+9 −0internal/docsite/testdata/missing/index.md+1 −0internal/docsite/testdata/missing/nav.json+1 −0internal/docsite/testdata/orphan/index.md+1 −0internal/docsite/testdata/orphan/kept.md+1 −0internal/docsite/testdata/orphan/nav.json+1 −0internal/docsite/testdata/orphan/stranded.md+1 −0
diff --git a/AGENTS.md b/AGENTS.md| index 78b2f8c32..ab281fd3c 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -112,6 +112,14 @@ than commit by commit, while the change can still be turned away. |
| - **When a storage shape changes, the consumer list is everything that PARSES |
| the shape** — not everything that calls the API. (Learned three times.) |
| +- **A new `carlos` command is not done until `docs/site/cli.md` names it.** |
| + `internal/docsite` reads main.go's dispatch switch and fails the gate over a |
| + command with no heading on the CLI page, over a fenced example running a verb |
| + that no longer exists, and over a `/docs` link whose page or `#fragment` does |
| + not resolve. The check is a heading and not a substring on purpose: `route` is |
| + a prefix of `routes`, and a substring check let a deleted section pass. The |
| + corpus is rendered by `carlosframework/website`, which vendors a copy — so a |
| + change here reaches the site only after that repo re-syncs and ships. |
| - **Wire-shape changes are additive**: live pointers/manifests exist in |
| production buckets; anything written before your change must still parse. |
| - **Production channel names are pinned in tests** |
diff --git a/docs/site/anchors.json b/docs/site/anchors.json| new file mode 100644 |
| index 000000000..d2f9211bb |
| --- /dev/null |
| +++ b/docs/site/anchors.json |
| @@ -0,0 +1,15 @@ |
| +{ |
| + "Getting started": "getting-started", |
| + "The `carlos ship` contract": "the-carlos-ship-contract", |
| + "404, never 403": "404-never-403", |
| + "Routes & instances": "routes-instances", |
| + " Leading and trailing ": "leading-and-trailing", |
| + "Hyphen-joined words": "hyphen-joined-words", |
| + "`-app` and `--app`": "app-and-app", |
| + "canary/rehearsal": "canary-rehearsal", |
| + "X-Carlos-Version": "x-carlos-version", |
| + "-app": "app", |
| + "--app": "app", |
| + "Ship — then promote": "ship-then-promote", |
| + "Montréal edge": "montr-al-edge" |
| +} |
diff --git a/docs/site/cli.md b/docs/site/cli.md| new file mode 100644 |
| index 000000000..ee9a45caf |
| --- /dev/null |
| +++ b/docs/site/cli.md |
| @@ -0,0 +1,714 @@ |
| +# The CLI |
| + |
| +`carlos` is one binary that plays three roles, picked by the first word you |
| +type. Most days you are in the first role: you have an app, and you want it |
| +running somewhere. The second is for people who stand a deployment up and |
| +administer it. The third is what systemd starts on a box. |
| + |
| +Run `carlos help` for the same grouping at the terminal, and |
| +`carlos <command> -h` for a command's own flags. This page says what each |
| +command is for and when you would reach for it. It does not list every flag, |
| +because `-h` already does. |
| + |
| +Three flags turn up almost everywhere, and the sections below do not repeat |
| +them. `-app` names the app. `-account` names the account it belongs to, or |
| +`-account-id` when you want a sqid matched as a sqid and nothing else; leave |
| +both off if you belong to exactly one account. `-console` picks which |
| +logged-in console to act through, and you need it only when this terminal is |
| +logged in to more than one deployment. |
| + |
| +Most app commands work two ways. Signed in with `carlos auth login`, they go |
| +through a console's API, which is how someone with no box access and no cloud |
| +credentials gets their work done. With `CARLOS_DEPLOYMENT_BUCKET` or |
| +`CARLOS_DEPLOYMENT_DIR` set, they write to the bucket directly. That is the |
| +operator's path. Where a command only works one way, its section says so. |
| + |
| +## Your apps |
| + |
| +Everything from claiming a name to watching a deploy land. None of it needs |
| +access to a box, a cloud console, or the deployment's bucket; that is a rule |
| +the platform holds itself to, and a command that broke it would be a bug. |
| + |
| +### carlos auth |
| + |
| +Logs this terminal in to a console. `carlos auth login` prints a short code |
| +that you approve in a browser already signed in to that console, and the |
| +browser does not have to be on this machine, so this works over SSH. The |
| +token lands in `~/.carlos/credentials` at mode 0600, one entry per console, |
| +so a laptop can hold credentials for the flagship and your own deployment at |
| +once. |
| + |
| +```sh |
| +carlos auth login |
| +carlos auth whoami |
| +``` |
| + |
| +Do not do this on a box. A box has no browser and no human, and the |
| +long-lived token you would leave behind is a standing credential nobody is |
| +watching; pass `-owner <address>` to |
| +[carlos accounts](/docs/cli#carlos-accounts) instead. `carlos auth default` |
| +shows the console this machine talks to by default, and sets it when you give |
| +it a URL. With `-project` it writes `./.carlos/config`, which you can commit |
| +so a checkout pins its own deployment. |
| + |
| +### carlos apps |
| + |
| +Claims an app name, moves an app onto a customer fleet, and deletes or |
| +restores one. Creating goes through the same door the console's New app form |
| +uses, so a name that form would refuse is refused here too. |
| + |
| +```sh |
| +carlos apps create -app hello |
| +``` |
| + |
| +`carlos apps delete` moves an app to the Trash. Every route drops, custom |
| +domains included, the name stays reserved, and you have thirty days to |
| +`carlos apps restore` it. Restoring brings back releases, channels and the |
| +production flag exactly as they were, but no routes at all — not even the |
| +platform apex — so plan on adding those again. Delete is refused while the |
| +app is flagged production; clear the flag first. |
| + |
| +`-place fleet/<name>` at create, or `carlos apps place -target fleet/<name>` |
| +later, puts the app's routes and instances on a fleet your account owns. |
| +`-target ""` brings them back to platform-served. `-target` is mandatory even |
| +in that empty spelling: leaving it off is a usage error, never a silent |
| +clear. |
| + |
| +### carlos ship |
| + |
| +Publishes a release. The artifact is hashed, stored under its content |
| +address, and recorded in a manifest that never changes afterwards. Shipping |
| +does not change what anything serves — for that, see |
| +[carlos promote](/docs/cli#carlos-promote). |
| + |
| +```sh |
| +carlos ship -app hello -label "fixing modals" ./hello |
| +``` |
| + |
| +`-version` is optional once the app has a version target set, and leaving it |
| +off is the point of having one: ship mints the next iteration under it. Use |
| +`-kind static` with a directory to ship a site instead of a binary. `-label` |
| +is one line for humans and shows up in `carlos releases` and in the console, |
| +so write it for the person doing a rollback at 2am. |
| + |
| +### carlos promote |
| + |
| +Points a channel at a version you have already shipped. The ladder is |
| +`canary → edge → beta → stable`: beta is entered from edge and stable from |
| +beta, so "it was signed off on beta" means something, and reaching stable |
| +cuts the release's semver tag. Channels named `canary/<something>` are never |
| +refused, because that is what the fleet itself runs on. |
| + |
| +```sh |
| +carlos promote -app hello a1b2c3d edge |
| +``` |
| + |
| +Promoting the same version twice succeeds and does nothing. `-hotfix` skips |
| +the ladder and is an operator act: it works on the direct-bucket path only, |
| +and the console refuses it by name, so the flag never quietly does nothing. |
| + |
| +### carlos deploy |
| + |
| +Ship, promote, and then wait until the app's URL actually serves the new |
| +build. It watches the `X-Carlos-Version` header on the live host and does not |
| +report success until that header names what you shipped, over a 2xx. Reach |
| +for this one by default; ship and promote separately when you want the gap |
| +between them. |
| + |
| +```sh |
| +carlos deploy -app hello ./hello |
| +``` |
| + |
| +Signed-in only. With saved project defaults you can run `carlos deploy` with |
| +no arguments and no questions. A first run at a terminal fills the blanks by |
| +asking. If your account owns no apps yet, it offers to claim one named after |
| +the directory you are in, through the same door as `carlos apps create`. It |
| +then asks which artifact to ship, works out from what you point it at whether |
| +that is a binary or a site, and offers to save both answers as the project's |
| +defaults. With neither arguments nor defaults and no terminal to ask at — CI, |
| +usually — it refuses instead of guessing. `-channel` overrides the channel it |
| +picks (normally the one the app's instances already follow), and `-host` |
| +scopes the watch to a single instance. A static site needs that second flag, |
| +having no instances to resolve a channel from. |
| + |
| +```sh |
| +carlos deploy -app website -kind static -host www.example.com ./dist |
| +``` |
| + |
| +### carlos restart |
| + |
| +Cycles an app's processes with no new version and no config change. This is |
| +the answer to a wedged instance. |
| + |
| +```sh |
| +carlos restart -app hello |
| +``` |
| + |
| +It reports the restart as *requested*, which is the honest word: the console |
| +touches one small object and every box serving the app notices within |
| +seconds. Long-running tenants come back within seconds. Exec-backed instances |
| +stop within seconds and respawn on their next request, which for an idle app |
| +may be a while. Nothing is stuck: the instance comes back with the next |
| +request that needs it. Hibernating instances are left asleep. |
| + |
| +### carlos schedule |
| + |
| +Gives an app a timetable. A schedule is a time and a path: at each fire the |
| +app's own instance gets a POST at that path, so the work is a route your app |
| +already serves rather than a separate worker. The sub-verbs are `ls`, `set`, |
| +`rm` and `run`. |
| + |
| +```sh |
| +carlos schedule set -app hello -name nightly -every 6h -path /jobs/nightly |
| +``` |
| + |
| +`-every` takes whole minutes, from `1m` up to `720h`; `-cron` takes a |
| +five-field expression instead, and you give one or the other, never both. |
| +`carlos schedule ls` prints the declared schedules alongside what each |
| +instance reports it will do next and what it did last. `carlos schedule run |
| +-app hello -name nightly` asks for one out-of-band run on top of the normal |
| +timetable, and every instance fires within about fifteen seconds. |
| + |
| +The wording is deliberate: `set` records, `rm` removes, `run` requests. The |
| +console writes one small object and every box serving the app notices on its |
| +own within seconds; nothing is pushed at a box. The write itself wakes |
| +nothing, and a hibernating instance is woken by its own runner when a tick |
| +falls due. Schedules need a logged-in console; there is no direct-bucket |
| +form. |
| + |
| +### carlos rollback |
| + |
| +Points a channel back at the version it was serving before. |
| + |
| +```sh |
| +carlos rollback -app hello stable |
| +``` |
| + |
| +### carlos channels |
| + |
| +What each of the app's channels is serving right now, with the tag if the |
| +release earned one at stable. |
| + |
| +```sh |
| +carlos channels -app hello |
| +``` |
| + |
| +### carlos pipeline |
| + |
| +Shows an app's release channels and the rules for moving a version through |
| +them, and shapes that ladder. Bare `carlos pipeline` is the `show` verb: the |
| +channels in order, plus any change still waiting on confirmations. `init`, |
| +`add`, `set` and `remove` edit it. |
| + |
| +```sh |
| +carlos pipeline init -app hello -template edge-production |
| +``` |
| + |
| +Two starter templates exist, `edge-production` and `full-ladder`. After |
| +that, `-bake <dur>` holds a version for a while before the channel may adopt |
| +it, and `-passkey`, `-promote-approvals N` and `-change-approvals N` set how |
| +much human agreement a promotion into the channel, or an edit to the channel |
| +itself, has to collect. `set` leaves any rule flag you did not type exactly |
| +as it was, so two people shaping different rules do not overwrite each other. |
| + |
| +A fresh app has one channel, `edge`, and prints as a single line rather than |
| +a one-row table. Shaping a pipeline wants a logged-in console: there is no |
| +bucket-direct editor for it. Promoting and rolling back are unaffected and |
| +work in either mode. |
| + |
| +### carlos releases |
| + |
| +Every version shipped, newest first, with the label its author typed and the |
| +tag it earned. Channels answer "what is running"; this answers "what is there |
| +to run". |
| + |
| +```sh |
| +carlos releases -app hello |
| +``` |
| + |
| +There is one sub-verb. `carlos releases retention -keep 20` sets an ambient |
| +prune policy, and `-dry-run` shows you what it would remove before you commit |
| +to it. The safety list always wins over the number: channel pointers, |
| +rollback history, tagged releases, anything inside the bake window, and each |
| +box's own adopted versions are never pruned, so nothing promoted or recent |
| +disappears by policy. `-off` goes back to keeping everything. |
| + |
| +### carlos version |
| + |
| +`carlos version` prints the build id of the binary in front of you. It is the |
| +first thing to check when a command behaves unlike this page describes. |
| + |
| +`carlos version target` is a separate thing wearing the same word: it sets |
| +the semver your ships count iterations under, so `carlos ship` can mint the |
| +next one for you. |
| + |
| +```sh |
| +carlos version target -app hello 0.5.0 |
| +``` |
| + |
| +### carlos env |
| + |
| +Plain per-app config vars: `set`, `unset`, `list`. |
| + |
| +```sh |
| +carlos env set -app hello LOG_LEVEL=debug |
| +``` |
| + |
| +Values land in the app's default bundle. `-environment <name>` writes a named |
| +bundle instead, layered on top of the default when config is materialized, |
| +and `carlos route -environment` is what binds a route to one. |
| +`carlos env environments -app hello` lists the bundle names an app has. |
| + |
| +### carlos secrets |
| + |
| +The same shape as `carlos env`, sealed. `list` prints key names and never |
| +values. |
| + |
| +```sh |
| +carlos secrets set -app hello STRIPE_KEY=sk_live_example |
| +``` |
| + |
| +Sealing uses a public key; the private half lives on the box that decrypts |
| +them and never leaves it, in either mode. `carlos secrets genkey` mints the |
| +pair, and it is always local to your terminal. |
| + |
| +### carlos instances |
| + |
| +An app's instances are the places it actually runs. `carlos instances enable` |
| +is the app's opt-in to provisioning, and until it is set the API refuses to |
| +create anything. After that, `create`, `list`, `delete` and `set-upstreams` |
| +work on records through the console, and the box's reconcile pass turns a |
| +record into a live route. |
| + |
| +```sh |
| +carlos instances enable -app hello |
| +carlos instances list -app hello |
| +``` |
| + |
| +`-health <slug>` narrows the listing to one state: `running`, `asleep`, |
| +`not-responding` and the rest. That is usually what you want when something |
| +is wrong. |
| + |
| +Typed on a box with no sub-verb, `carlos instances` does something different: |
| +it lists that box's own registry routes, backing and owning unit included. |
| +Off the box, [carlos routes](/docs/cli#carlos-routes) answers the same |
| +question through the console. |
| + |
| +### carlos steering |
| + |
| +Decides whether one instance's DNS answer varies by where the request comes |
| +from. `latency` opts the host into a Route53 latency record per armed edge, |
| +so the nearest one answers; `off` clears the opt-in and returns the host to |
| +the single static answer every requester shares. |
| + |
| +```sh |
| +carlos steering -app hello -host hello.example.com latency |
| +``` |
| + |
| +The opt-in is recorded immediately and reaches the box's registry row at the |
| +next converge tick, but no DNS answer changes until the deployment itself |
| +has armed its Route53 steering converger. In v1 only instances on the shared |
| +pool can be steered; a pool-scoped route is refused, and the refusal names |
| +that as the reason. |
| + |
| +### carlos routes |
| + |
| +The app's routes on this deployment: where each one sends traffic, which |
| +channel it follows, and which config environment it is bound to. That last |
| +column is the one worth knowing about, because it decides which sealed |
| +secrets the route's process is delivered. |
| + |
| +```sh |
| +carlos routes -app hello |
| +``` |
| + |
| +It reads the console's own box, so on a multi-box fleet a short answer is not |
| +necessarily a complete one. The command says so every time it prints. |
| + |
| +### carlos domains |
| + |
| +Attaches a customer's own hostname to one of the app's routes, detaches it, |
| +or lists what is claimed. |
| + |
| +```sh |
| +carlos domains attach -app hello www.example.com |
| +``` |
| + |
| +`-route` picks which route to point at, and you can leave it off when the app |
| +has exactly one instance record. The hostname comes live on the box owning |
| +that route at its next domains pass, certificate and all. The `list` verb |
| +joins the claims to the fleet's own readings sweep, so DNS state, certificate |
| +expiry and delegation are readable without leaving the terminal. |
| + |
| +### carlos logs |
| + |
| +The app's own stdout and stderr, merged with the platform's events about it: |
| +wakes, restarts, and failures with the reason. A site served from files has |
| +no app stream, but its platform events still show up. |
| + |
| +```sh |
| +carlos logs -app hello -since 1h -f |
| +``` |
| + |
| +`-f` follows, polling every two seconds until you interrupt it. `-grep` takes |
| +an RE2 pattern, `-stream app` drops the platform commentary, and `-host` |
| +narrows to one instance. |
| + |
| +### carlos store |
| + |
| +Object storage for an app. `create` declares that the app wants a bucket, |
| +which is a request and not a provision — nothing exists in any cloud account |
| +until an operator runs `grant`. `status` shows what is declared, granted and |
| +delivered, including a rotation that stopped half way. |
| + |
| +```sh |
| +carlos store create -app hello |
| +carlos store status -app hello |
| +``` |
| + |
| +`carlos store rotate` mints a fresh credential and leaves the old one live |
| +until you run it again with `-finish`, so the app has a window to pick the |
| +new one up. Rotating, declaring and reading status are member verbs and want |
| +nothing but your bearer token. `grant` is the one that spends money in the |
| +deployment's own cloud account, so it also wants an address on |
| +`CARLOS_STORE_OPERATORS`; that check runs after the account gate, so a |
| +stranger still meets the same 404 as everyone else rather than a 403 that |
| +would confirm the app exists. |
| + |
| +### carlos email |
| + |
| +Tenant email sending. `carlos email enable` is the whole path in one |
| +command: it declares the From address the app sends as, ensures a sending |
| +domain, waits for SES to verify it, and delivers SMTP credentials to the app |
| +as env vars under a prefix — `CARLOS_SMTP` unless `-env-prefix` names |
| +another. |
| + |
| +```sh |
| +carlos email enable -app hello |
| +carlos email test -app hello -to you@example.com |
| +``` |
| + |
| +On a custom domain, `enable` prints the DNS records to publish and then |
| +keeps polling until SES confirms them, giving up after ten minutes unless |
| +`-timeout` says otherwise; a wait that gives up exits non-zero and names what |
| +SES never confirmed. `carlos email domains add` is that same half on its own, |
| +for a second domain on an app already declared. |
| + |
| +`carlos email test` sends a real message and reports what SES said about it. |
| +It sends through a throwaway standalone credential it mints and revokes |
| +around the send, so it never needs the delivered credential's password, and |
| +it proves that mail leaves the building rather than that the console believes |
| +it should. |
| + |
| +`status` shows every domain's per-region verification state, the day's count |
| +against the cap, and whether sending is paused. `credentials create` mints a |
| +standalone SMTP credential for something not running on CARLOS — a laptop, a |
| +cron box — printed once on that command's output and nowhere else. `rotate` |
| +mints a fresh delivered credential and leaves the old one live until you run |
| +it again with `-finish`. `pause` and `resume` are operator verbs. None of it |
| +has a direct-bucket form; all of it wants a logged-in console. |
| + |
| +### carlos ledger |
| + |
| +Append-only hash-chained ledgers an app can publish. `append` adds one JSON |
| +entry to a chain, `head` prints that chain's current head, `list` shows the |
| +app's ledgers and what it publishes, and `publish` decides which of them are |
| +served publicly. |
| + |
| +```sh |
| +carlos ledger append -app hello carbon entry.json |
| +``` |
| + |
| +`blob` uploads files to a chain as content-addressed blobs and prints each |
| +one's sha, so an entry can point at it. Uploads are create-only and capped at |
| +4 MB a file, and re-uploading identical bytes does nothing. |
| + |
| +Ledgers have no direct-bucket mode at all: every sub-verb except `verify` |
| +goes through the console, because a contributor's machine is never given |
| +bucket credentials. `carlos ledger verify` walks a published chain over plain |
| +HTTPS and re-hashes every entry. It needs no credentials at all: anybody can |
| +check a ledger you publish, including you, from a machine that has never been |
| +logged in. |
| + |
| +### carlos vet |
| + |
| +Checks a shipped release against the platform contract before anyone promotes |
| +it. The manifest has to exist and parse, and every artifact's stored bytes |
| +have to match the sha256 and size it claims. |
| + |
| +```sh |
| +carlos vet -app hello -version a1b2c3d -boot |
| +``` |
| + |
| +`-boot` goes further and runs the binary: it must accept `-socket <path>`, |
| +serve HTTP on that socket, and answer `GET /healthz` with a 200 within ten |
| +seconds. That is the entire app contract, and this is the cheapest place to |
| +find out you have broken it. |
| + |
| +### carlos update |
| + |
| +Replaces the `carlos` binary on your workstation with the latest published |
| +release, checking the signed checksums before it swaps anything. Where a |
| +package manager owns the install, it prints the `brew` or `apt` command |
| +instead of fighting it. |
| + |
| +```sh |
| +carlos update |
| +``` |
| + |
| +It refuses to run as root, and refuses on a box: box binaries are rolled by |
| +the platform, and a self-updating box would fight that machinery. `-y` skips |
| +the confirmation, though it still wants a terminal. |
| + |
| +## Your deployment |
| + |
| +The verbs for the person who owns the boxes. Some of these run against a |
| +console like the app commands above; others open a box's registry directly |
| +and only make sense while you are standing on it. |
| + |
| +### carlos bootstrap |
| + |
| +Prepares a host to be a CARLOS box: the service user, the data directories, |
| +the systemd units. Run it once per host, as root. |
| + |
| +```sh |
| +carlos bootstrap |
| +``` |
| + |
| +`-root <dir>` writes the files into a staging directory instead, creating no |
| +users and running no systemctl, so you can read what it would do before it |
| +does it. `-offcloud` is for a box outside EC2, where credentials come from a |
| +staged 0600 file instead of an instance profile. |
| + |
| +### carlos accounts |
| + |
| +Accounts are the tenancy primitive: apps, fleets, credentials and bucket |
| +prefixes all hang off one. `create` mints an account and prints the sqid |
| +every object underneath it is keyed by. |
| + |
| +```sh |
| +carlos accounts create -name acme -owner someone@example.com |
| +``` |
| + |
| +`-owner` is the path for a box or a CI job, where there is no browser to log |
| +in with. It needs no identity of its own and takes precedence over any |
| +logged-in one. |
| + |
| +`carlos accounts migrate` copies an app's objects to an account-qualified |
| +prefix and re-stamps its routes. Run it with `-dry-run` first; it prints the |
| +plan and touches nothing. |
| + |
| +### carlos fleets |
| + |
| +A fleet is a group of remote boxes an account owns, dialing in to this |
| +console over a channel named `<fleet>/<label>`. Create one against the |
| +customer's own data bucket, then register each box. |
| + |
| +```sh |
| +carlos fleets create -bucket acme-data acme |
| +carlos fleets add-box acme pi-1 |
| +``` |
| + |
| +The box's bearer token prints exactly once, at `add-box`. The console keeps |
| +only its hash, so losing that output means `rotate-token`; a second `add-box` |
| +will only refuse the label. Put it straight into the box's credential store. |
| + |
| +Rotation is not revocation. A fresh token refuses the box's next dial-in with |
| +the old one, but it does not evict a channel the box is already holding. If a |
| +credential may be compromised, `detach-box` is the command that actually |
| +stops it. |
| + |
| +### carlos services |
| + |
| +Credentials a server holds rather than a person: a CI job that ships, a |
| +sidecar that reads instances. Reach is fixed at mint, so adding you to |
| +another account later does not widen it, and it has its own rate-limit budget. |
| + |
| +```sh |
| +carlos services create -role publish -app hello ci-shipper |
| +``` |
| + |
| +Pick the narrowest role that works. `publish` ships releases, `instance` is |
| +what a box-side reader wants, `operate` administers, and `admin` includes |
| +`store grant`, which mints a path-scoped IAM user; treat that one as a real |
| +handover. `-app` binds the credential to one app, which also means it cannot |
| +create apps. |
| + |
| +The secret prints once, same as a fleet token, and the same distinction |
| +applies at the other end: `rotate` refuses the credential's next call with |
| +the old secret, `revoke` stops it whatever secret it holds. |
| + |
| +### carlos add |
| + |
| +Writes a route straight into a box's registry: this hostname, served by this |
| +app, following this channel. It is the box-local, operator-side counterpart |
| +to an instance record. |
| + |
| +```sh |
| +carlos add -app hello -socket /run/hello.sock hello.example.com |
| +``` |
| + |
| +`-channel` sets what the route follows, `-kind` picks instance, service or |
| +static, and `-addr` takes a TCP upstream where the app is not a socket |
| +tenant. `-unit console.service` names the systemd unit that owns the route's |
| +process, which is what lets adoption cycle it; leave it off for an ordinary |
| +exec child. |
| + |
| +### carlos remove |
| + |
| +Drops one of this box's routes. It stops routing the hostname and never |
| +touches the instance's database. |
| + |
| +```sh |
| +carlos remove hello.example.com |
| +``` |
| + |
| +### carlos route |
| + |
| +Changes one thing about a route that already exists. Each axis is its own |
| +operation and the command refuses to combine them, because a capability grant |
| +and a repoint are different acts with different safety rails, and a command |
| +that did both would commit them together. |
| + |
| +```sh |
| +carlos route -host hello.example.com -channel edge |
| +``` |
| + |
| +`-channel` and `-environment` repoint, and those two may be given together. |
| +`-grant` and `-revoke` change a capability. `-hibernate` lets the route sleep |
| +when idle. `-backing` and `-unit` are two answers to one question — who owns |
| +this route's process — so the command will not let you write both. |
| + |
| +Repointing the channel is the half to be careful with. Adoption converges a |
| +route onto whatever its channel currently points at, so moving the channel |
| +changes what production serves on the next pass; the command resolves the |
| +target pointer first and refuses a version change unless you pass |
| +`-allow-version-change`; `-dry-run` runs the same rails and prints the plan |
| +without writing. And once a route is `Provisioned`, its channel and |
| +environment belong to its instance record: `carlos route` refuses both axes |
| +on such a row and names the record, because the reconciler would revert you |
| +within seconds anyway. |
| + |
| +### carlos release-keygen |
| + |
| +Mints the deployment's root signing key, or a scoped key that can sign only |
| +certain rungs of the ladder. |
| + |
| +```sh |
| +carlos release-keygen -scope canary,edge -name builder |
| +``` |
| + |
| +With no flags it prints a fresh root pair: the key goes into your secret |
| +manager, the public half into host config. With `-scope` it prints a key and |
| +a grant. The grant carries no secret material but has to travel with the key, |
| +because a scoped key on its own signs pointers nothing will accept. Only the |
| +root key can mint a grant, which is what stops a scoped key widening itself. |
| + |
| +### carlos economics |
| + |
| +Records one month's AWS bill so the console's economics dashboard has a real |
| +number to divide by. |
| + |
| +```sh |
| +carlos economics bill -month 2026-08 -total 412.55 -line AmazonEC2=55.00 |
| +``` |
| + |
| +Operator only, and the console answers everyone else with the same bare 404 |
| +it gives an unknown route. A "not found" here almost always means your |
| +account lacks the fleet-operator bit, not that you typed the path wrong. |
| + |
| +### carlos status |
| + |
| +`-live` prints what every box in the fleet last reported about itself: |
| +release, tunnel state, memory, load, free disk. A box that has stopped |
| +reporting shows as UNREACHABLE with how long it has been dark, and none of |
| +its stale readings. |
| + |
| +```sh |
| +carlos status -live |
| +``` |
| + |
| +`-dry-run <dir>` is the self-hoster's leak check. It builds a throwaway fleet |
| +stuffed with fixture secrets, runs it through the real publish path into a |
| +scratch directory, reads every published object back, and fails if any |
| +fixture escaped. Run it before you ever point this binary at a real bucket. |
| +Neither mode publishes anything: the public status page is written by each |
| +box's own agent tick. |
| + |
| +## On the host |
| + |
| +These are what systemd starts. You will rarely type them, but knowing what |
| +they do explains most of what happens between a promote and a URL changing. |
| + |
| +### carlos edge |
| + |
| +The front door. TLS and ACME, and the proxy that maps a hostname to whatever |
| +is serving it. One per box, started by `carlos-edge.service`. |
| + |
| +```sh |
| +carlos edge -dev -http :8080 |
| +``` |
| + |
| +`-dev` serves plain HTTP with no TLS or ACME, which is how you run it on a |
| +laptop. `-fallback` redirects unknown hostnames somewhere (an apex marketing |
| +site, usually) instead of returning the plain 404. |
| + |
| +### carlos agent |
| + |
| +Edge, the hibernation activator, release adoption and the status tick, in one |
| +process per host. This is what a box actually runs; `carlos edge` alone is |
| +the proxy without any of the rest. |
| + |
| +### carlos adopt |
| + |
| +One release-adoption pass: read each route's channel, resolve what that |
| +channel points at, fetch it if the box does not have it, and swap the |
| +symlink. The hourly timer runs this verb. |
| + |
| +```sh |
| +carlos adopt |
| +``` |
| + |
| +Adoption swaps a symlink, and on its own it does not restart anything. A |
| +long-running unit keeps serving the old build until something cycles it, and |
| +a hibernating tenant picks the new one up when it next wakes — so the order |
| +that works is promote, then adopt, then restart. A route that names its |
| +owning unit is the exception: adoption cycles that one itself, and rolls the |
| +adoption back if the restart fails, so the version header never claims a |
| +build the process is not serving. |
| + |
| +`-restart-only` and `-config-only` are the narrower passes the agent asks a |
| +root-side unit to run for it, since the agent can request a pass but cannot |
| +write to `/etc` or restart units itself. |
| + |
| +### carlos ops |
| + |
| +Box operations, run by a timer or by an operator standing on the box. |
| +`litestream` regenerates the replication config from the registry and the |
| +data directory, printing `changed` or `unchanged` so the caller knows whether |
| +to restart replication. `restore-verify` restores every configured replica |
| +from S3 and integrity-checks it. `hibernate-verify` does the same drill for |
| +the replicas the config does not name — hibernating instances, whose |
| +replication belongs to the activator — and publishes a per-host verdict to |
| +the deployment bucket so you can read it off-box. |
| + |
| +```sh |
| +carlos ops restore-verify |
| +``` |
| + |
| +## Everything else |
| + |
| +`carlos help` belongs to all three roles above; `carlos canary` belongs |
| +to none of them yet. |
| + |
| +### carlos help |
| + |
| +Prints the command list, grouped the way this page is. It goes to stdout and |
| +exits 0, so `carlos help | less` works. A bare `carlos` with no arguments |
| +prints the same text on stderr and exits 2, because that one is a mistake. |
| + |
| +### carlos canary |
| + |
| +Reserved, and not implemented: typing it exits with `not implemented`. |
| +Canary releases today are ordinary channels named `canary/<something>`, |
| +promoted with [carlos promote](/docs/cli#carlos-promote) like any other rung. |
diff --git a/docs/site/index.md b/docs/site/index.md| new file mode 100644 |
| index 000000000..fe9fd6eca |
| --- /dev/null |
| +++ b/docs/site/index.md |
| @@ -0,0 +1,17 @@ |
| +# CARLOS |
| + |
| +CARLOS runs your apps on hardware you control. A deployment is one |
| +object-store bucket, one or more Linux hosts running the `carlos` binary, |
| +and a DNS zone; you publish a release with `carlos ship`, and the platform |
| +places it on instances, routes to it through a shared edge, and keeps TLS, |
| +config, secrets and replication working underneath it. |
| + |
| +These pages are the reference for the platform itself — the commands you |
| +type, and what happens on a box after you type them. They assume you are |
| +either running a deployment or shipping onto one. They do not teach Go, and |
| +they are not a tutorial. |
| + |
| +If you are a Carloku customer, the docs you want are at |
| +[carloku.com](https://carloku.com). Carloku is CARLOS run for you, and it |
| +covers the parts you actually touch without the box-side machinery these |
| +pages spend most of their time on. |
diff --git a/docs/site/nav.json b/docs/site/nav.json| new file mode 100644 |
| index 000000000..616eb25f0 |
| --- /dev/null |
| +++ b/docs/site/nav.json |
| @@ -0,0 +1,14 @@ |
| +{ |
| + "sections": [ |
| + { |
| + "title": "Reference", |
| + "pages": [ |
| + { |
| + "slug": "cli", |
| + "label": "The CLI", |
| + "blurb": "Every carlos command, grouped by whether you are shipping an app, running a deployment, or on a box." |
| + } |
| + ] |
| + } |
| + ] |
| +} |
diff --git a/docs/superpowers/plans/2026-08-25-docs-pipeline-and-gates.md b/docs/superpowers/plans/2026-08-25-docs-pipeline-and-gates.md| new file mode 100644 |
| index 000000000..fa9425d55 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-25-docs-pipeline-and-gates.md |
| @@ -0,0 +1,1615 @@ |
| +# Docs Pipeline and Gates Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Stand up `carlosframework.com/docs` end to end with two real pages, and the CI gate that fails a platform PR whose docs stop describing the code. |
| + |
| +**Architecture:** A markdown corpus plus `nav.json` lives in the platform repo at `docs/site/`. `internal/docsite` parses it and its tests are the gate. The `carlosframework/website` repo vendors the corpus and renders it with Eleventy at authoring time; `_site/` is gitignored and shipped. This is the split rastrillo already runs — Go parses and gates, JavaScript renders. |
| + |
| +**Tech Stack:** Go 1.26 (stdlib only — `go/ast`, `go/parser`, `encoding/json`); Eleventy 3, `markdown-it` 14, `markdown-it-anchor` 9 as devDependencies in the website repo. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-24-docs-sites-design.md` |
| + |
| +## Global Constraints |
| + |
| +- **Reference implementation:** `~/github.com/rastrilloorg/rastrillo/internal/docsite/` and `~/github.com/carlosframework/rastrillo-website/`. Read the file named in a task before porting it. Port; do not reinvent. |
| +- **No new Go dependencies.** `go.mod` does not change in this plan. Everything here is stdlib. |
| +- **Gate before every commit:** `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go vet ./... && go test ./... -count=1`, and `gofmt -l` empty on touched files. Disable the sandbox if the toolchain reports "read-only file system". |
| +- **Every change lands through a PR.** Work on branch `docs` (platform) and `docs-site` (website). Never merge to `main` locally. |
| +- **Corpus root is `docs/site`**, addressed from the package as `../../docs/site`. |
| +- **Anchor rule, both languages:** lowercase ASCII alphanumerics kept; every other run becomes a single `-`; no leading or trailing `-`. |
| +- **Prose in the corpus is written against the `humane-docs` skill.** rastrillo.org is the structural model and explicitly not the prose model. |
| +- **Commit trailer:** `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>` |
| + |
| +--- |
| + |
| +## File Structure |
| + |
| +**Platform repo (branch `docs`):** |
| + |
| +| File | Responsibility | |
| +|---|---| |
| +| `internal/docsite/docsite.go` | Parse a corpus: `Load`, `Page`, `Site`, `Anchor`, `MarkdownFiles`. No rendering, no gates. | |
| +| `internal/docsite/nav_test.go` | Nav and files agree both ways; every page has a title, label, blurb. | |
| +| `internal/docsite/links_test.go` | Internal links and fragments resolve; anchors unique; `Anchor` matches the fixture. | |
| +| `internal/docsite/fences_test.go` | Fence languages declared; every `carlos` invocation in a fence names a real command. | |
| +| `internal/docsite/cli_test.go` | Every `main.go` dispatch case appears on the CLI reference page. | |
| +| `internal/docsite/cliscan.go` | AST scan of `main.go` for dispatched command names. Non-test so `fences_test.go` can share it. | |
| +| `internal/docsite/testdata/corpus/` | A small valid corpus the parser tests run against. | |
| +| `docs/site/anchors.json` | The shared slug fixture. Vendored to the website; both languages assert against this file. | |
| +| `docs/site/nav.json`, `index.md`, `cli.md` | The real corpus, two pages to start. | |
| + |
| +**Website repo (branch `docs-site`):** |
| + |
| +| File | Responsibility | |
| +|---|---| |
| +| `package.json`, `eleventy.config.js` | Build config, `slugify`, the docs collections and filters. | |
| +| `src/docs.njk`, `src/docs-index.njk`, `src/docsmd.njk` | Page, index and raw-markdown outputs. | |
| +| `src/_includes/docs.njk` | The shell: breadcrumb, sidebar, sub-nav. | |
| +| `src/docs.css` | The docs skin. Loaded alongside the existing `site.css`. | |
| +| `hack/sync-docs.mjs` | Vendor the corpus from a platform checkout, record its sha. | |
| +| `hack/check-docs.mjs` | Built pages exist, hrefs resolve, ids exist, slugify agrees with the fixture. | |
| + |
| +--- |
| + |
| +### Task 1: The corpus parser |
| + |
| +**Files:** |
| +- Create: `internal/docsite/docsite.go` |
| +- Create: `internal/docsite/testdata/corpus/nav.json`, `index.md`, `getting-started.md`, `reference/cli.md` |
| +- Test: `internal/docsite/parse_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing. |
| +- Produces: `Load(root string) (*Site, error)`; `Site{Index *Page; Sections []Section; BySlug map[string]*Page}` with method `Pages() []*Page`; `Section{Title string; Pages []*Page}`; `Page{Slug, Label, Blurb, Title, Path, Body string; Headings []Heading; Links []Link; Fences []Fence}`; `Heading{Level int; Text, Anchor string}`; `Link{Target, Fragment string; Line int}`; `Fence{Lang, Body string; Line int}`; `Anchor(text string) string`; `MarkdownFiles(root string) ([]string, error)`. |
| + |
| +- [ ] **Step 1: Read the reference implementation** |
| + |
| +Read `~/github.com/rastrilloorg/rastrillo/internal/docsite/docsite.go` in full. You are porting it. Three deliberate differences: |
| + |
| +1. Drop `Page.Ignores` and `ignoreMarker`. Those serve rastrillo's exported-symbol gate, which CARLOS has no analogue for — the platform is an application, not a library. |
| +2. Keep everything else byte-for-byte where you can, including the fence-matching rules and `parseDocsHref`. |
| +3. Package comment describes CARLOS, not rastrillo. Write it against `humane-docs`: say what the package is for and what it deliberately is not. |
| + |
| +- [ ] **Step 2: Write the testdata corpus** |
| + |
| +`internal/docsite/testdata/corpus/nav.json`: |
| + |
| +```json |
| +{ |
| + "sections": [ |
| + { |
| + "title": "Start here", |
| + "pages": [ |
| + { "slug": "getting-started", "label": "Getting started", "blurb": "Install it and deploy something." } |
| + ] |
| + }, |
| + { |
| + "title": "Reference", |
| + "pages": [ |
| + { "slug": "reference/cli", "label": "The CLI", "blurb": "Every command." } |
| + ] |
| + } |
| + ] |
| +} |
| +``` |
| + |
| +`internal/docsite/testdata/corpus/index.md`: |
| + |
| +```text |
| +# Documentation |
| + |
| +Start with [getting started](/docs/getting-started), or jump to |
| +[the CLI](/docs/reference/cli#carlos-ship). |
| +``` |
| + |
| +`internal/docsite/testdata/corpus/getting-started.md`: |
| + |
| +```text |
| +# Getting started |
| + |
| +## Install the CLI |
| + |
| +Run it: |
| + |
| +```sh |
| +carlos auth login |
| +``` |
| + |
| +## Deploy something |
| + |
| +See [the CLI reference](/docs/reference/cli). |
| +``` |
| + |
| +`internal/docsite/testdata/corpus/reference/cli.md`: |
| + |
| +```text |
| +# The CLI |
| + |
| +## carlos ship |
| + |
| +Publish a release. |
| + |
| +```sh |
| +carlos ship -app hello -version 1.0.0 ./dist |
| +``` |
| +``` |
| + |
| +Note the nested fence in `getting-started.md` and `cli.md`: when writing those files, the outer block above is this plan's formatting. The file content is the markdown itself, with a single ```` ```sh ```` fence inside. |
| + |
| +- [ ] **Step 3: Write the failing parser test** |
| + |
| +`internal/docsite/parse_test.go`: |
| + |
| +```go |
| +package docsite |
| + |
| +import "testing" |
| + |
| +const testCorpus = "testdata/corpus" |
| + |
| +func TestLoadReadsNavAndPages(t *testing.T) { |
| + site, err := Load(testCorpus) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + if site.Index == nil || site.Index.Title != "Documentation" { |
| + t.Fatalf("index not loaded: %+v", site.Index) |
| + } |
| + if len(site.Sections) != 2 { |
| + t.Fatalf("got %d sections, want 2", len(site.Sections)) |
| + } |
| + if got := site.Sections[1].Pages[0].Slug; got != "reference/cli" { |
| + t.Errorf("nested slug = %q, want reference/cli", got) |
| + } |
| + if got := len(site.Pages()); got != 3 { |
| + t.Errorf("Pages() = %d, want 3 (index + two)", got) |
| + } |
| +} |
| + |
| +func TestParsePageFindsHeadingsLinksAndFences(t *testing.T) { |
| + site, err := Load(testCorpus) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + p := site.BySlug["getting-started"] |
| + if p == nil { |
| + t.Fatal("no getting-started page") |
| + } |
| + if len(p.Headings) != 3 { |
| + t.Fatalf("got %d headings, want 3", len(p.Headings)) |
| + } |
| + if p.Headings[1].Anchor != "install-the-cli" { |
| + t.Errorf("anchor = %q, want install-the-cli", p.Headings[1].Anchor) |
| + } |
| + if len(p.Fences) != 1 || p.Fences[0].Lang != "sh" { |
| + t.Fatalf("fences = %+v, want one sh fence", p.Fences) |
| + } |
| + if len(p.Links) != 1 || p.Links[0].Target != "reference/cli" { |
| + t.Fatalf("links = %+v, want one to reference/cli", p.Links) |
| + } |
| +} |
| + |
| +func TestLinksInsideFencesAreNotLinks(t *testing.T) { |
| + site, err := Load(testCorpus) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + for _, p := range site.Pages() { |
| + for _, l := range p.Links { |
| + if l.Target == "" { |
| + t.Errorf("%s: empty link target at line %d", p.Slug, l.Line) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestIndexLinkResolvesToIndexSlug(t *testing.T) { |
| + l := parseDocsHref("/docs") |
| + if l.Target != "index" { |
| + t.Errorf("parseDocsHref(\"/docs\") target = %q, want index", l.Target) |
| + } |
| + l = parseDocsHref("/docs/reference/cli#carlos-ship") |
| + if l.Target != "reference/cli" || l.Fragment != "carlos-ship" { |
| + t.Errorf("got %+v, want reference/cli #carlos-ship", l) |
| + } |
| +} |
| + |
| +func TestMarkdownFilesWalksNested(t *testing.T) { |
| + files, err := MarkdownFiles(testCorpus) |
| + if err != nil { |
| + t.Fatalf("walk: %v", err) |
| + } |
| + want := map[string]bool{"index": true, "getting-started": true, "reference/cli": true} |
| + if len(files) != len(want) { |
| + t.Fatalf("got %v, want %d files", files, len(want)) |
| + } |
| + for _, f := range files { |
| + if !want[f] { |
| + t.Errorf("unexpected file %q", f) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they fail** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run 'TestLoad|TestParse|TestLinks|TestIndex|TestMarkdown' -count=1 |
| +``` |
| + |
| +Expected: build failure — `undefined: Load`. |
| + |
| +- [ ] **Step 5: Write `docsite.go`** |
| + |
| +Port from the reference read in Step 1, applying the three differences. `Anchor` verbatim: |
| + |
| +```go |
| +// Anchor is the heading anchor the rendered page will carry. |
| +// |
| +// The website configures markdown-it-anchor with a slugify that matches |
| +// this function, and both sides assert against docs/site/anchors.json. |
| +// Both are deliberately plain — lowercase alphanumerics, everything else |
| +// a separator — because markdown-it-anchor's own default URI-encodes |
| +// punctuation, which would anchor a heading naming `carlos ship -app` |
| +// differently on each side. |
| +func Anchor(text string) string { |
| + var b strings.Builder |
| + pendingSep := false |
| + for _, r := range strings.ToLower(text) { |
| + switch { |
| + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': |
| + if pendingSep && b.Len() > 0 { |
| + b.WriteByte('-') |
| + } |
| + pendingSep = false |
| + b.WriteRune(r) |
| + default: |
| + pendingSep = true |
| + } |
| + } |
| + return b.String() |
| +} |
| +``` |
| + |
| +- [ ] **Step 6: Run the tests to verify they pass** |
| + |
| +Same command as Step 4. Expected: PASS, five tests. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +gofmt -l internal/docsite/ |
| +git add internal/docsite/ |
| +git commit -m "docsite: parse a markdown corpus and its nav |
| + |
| +Ported from rastrillo's internal/docsite, minus the docs:ignore markers |
| +that serve its exported-symbol gate. The platform is an application, not |
| +a library, so there is no symbol surface to hold docs against — the CLI |
| +is the surface, and that gate arrives in a later commit. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: The nav gate |
| + |
| +**Files:** |
| +- Create: `internal/docsite/nav_test.go` |
| +- Create: `internal/docsite/testdata/orphan/` (nav.json naming one page, two `.md` files on disk) |
| + |
| +**Interfaces:** |
| +- Consumes: `Load`, `MarkdownFiles`, `Site.Pages()` from Task 1. |
| +- Produces: `const root = "../../docs/site"` — every later gate test uses this constant. |
| + |
| +- [ ] **Step 1: Write the orphan fixture** |
| + |
| +`internal/docsite/testdata/orphan/nav.json`: |
| + |
| +```json |
| +{ "sections": [ { "title": "Only", "pages": [ { "slug": "kept", "label": "Kept", "blurb": "Reachable." } ] } ] } |
| +``` |
| + |
| +`internal/docsite/testdata/orphan/index.md`: |
| + |
| +```text |
| +# Documentation |
| +``` |
| + |
| +`internal/docsite/testdata/orphan/kept.md`: |
| + |
| +```text |
| +# Kept |
| +``` |
| + |
| +`internal/docsite/testdata/orphan/stranded.md`: |
| + |
| +```text |
| +# Stranded |
| +``` |
| + |
| +- [ ] **Step 2: Write the failing test** |
| + |
| +```go |
| +package docsite |
| + |
| +import "testing" |
| + |
| +// root is the real corpus, relative to this package. Every gate below |
| +// runs against it. |
| +const root = "../../docs/site" |
| + |
| +// TestNavAndFilesAgreeBothWays keeps the table of contents honest in |
| +// both directions. A page nav.json names must exist — Load fails on |
| +// that one — and a page on disk that no nav entry reaches must not. An |
| +// unreachable page is the worse failure: it looks written, and nobody |
| +// ever reads it. |
| +func TestNavAndFilesAgreeBothWays(t *testing.T) { |
| + site, err := Load(root) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + |
| + seen := map[string]bool{} |
| + for _, p := range site.Pages() { |
| + if seen[p.Slug] { |
| + t.Errorf("%s: appears in nav.json more than once", p.Slug) |
| + } |
| + seen[p.Slug] = true |
| + |
| + if p.Title == "" { |
| + t.Errorf("%s: no `# ` title", p.Slug) |
| + } |
| + if p != site.Index && p.Blurb == "" { |
| + t.Errorf("%s: no blurb in nav.json (it renders on the /docs index)", p.Slug) |
| + } |
| + if p != site.Index && p.Label == "" { |
| + t.Errorf("%s: no label in nav.json", p.Slug) |
| + } |
| + } |
| + |
| + files, err := MarkdownFiles(root) |
| + if err != nil { |
| + t.Fatalf("walk %s: %v", root, err) |
| + } |
| + for _, slug := range files { |
| + if !seen[slug] { |
| + t.Errorf("docs/site/%s.md exists but no nav.json entry reaches it", slug) |
| + } |
| + } |
| +} |
| + |
| +// TestOrphanPagesAreCaught proves the gate above can actually fail. The |
| +// check that never fires is the check nobody notices is broken. |
| +func TestOrphanPagesAreCaught(t *testing.T) { |
| + site, err := Load("testdata/orphan") |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + seen := map[string]bool{} |
| + for _, p := range site.Pages() { |
| + seen[p.Slug] = true |
| + } |
| + files, err := MarkdownFiles("testdata/orphan") |
| + if err != nil { |
| + t.Fatalf("walk: %v", err) |
| + } |
| + var orphans []string |
| + for _, slug := range files { |
| + if !seen[slug] { |
| + orphans = append(orphans, slug) |
| + } |
| + } |
| + if len(orphans) != 1 || orphans[0] != "stranded" { |
| + t.Errorf("orphans = %v, want [stranded]", orphans) |
| + } |
| +} |
| + |
| +// TestNavNamingAMissingPageFails is the other direction. |
| +func TestNavNamingAMissingPageFails(t *testing.T) { |
| + if _, err := Load("testdata/missing"); err == nil { |
| + t.Fatal("Load succeeded on a nav.json naming a page that does not exist") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 3: Write the `missing` fixture** |
| + |
| +`internal/docsite/testdata/missing/nav.json`: |
| + |
| +```json |
| +{ "sections": [ { "title": "Only", "pages": [ { "slug": "nowhere", "label": "Nowhere", "blurb": "Not on disk." } ] } ] } |
| +``` |
| + |
| +`internal/docsite/testdata/missing/index.md`: |
| + |
| +```text |
| +# Documentation |
| +``` |
| + |
| +- [ ] **Step 4: Run to verify it fails** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run TestNav -count=1 |
| +``` |
| + |
| +Expected: `TestNavAndFilesAgreeBothWays` FAILS with `load: read nav.json: ... docs/site/nav.json: no such file`. The other two pass. That failure is correct — the real corpus arrives in Task 8, and this test is what makes Task 8 mandatory rather than optional. |
| + |
| +- [ ] **Step 5: Skip the real-corpus gate until the corpus exists** |
| + |
| +Add to the top of `TestNavAndFilesAgreeBothWays` only: |
| + |
| +```go |
| + if _, err := os.Stat(root + "/nav.json"); os.IsNotExist(err) { |
| + t.Skip("no corpus yet — Task 8 creates docs/site/nav.json") |
| + } |
| +``` |
| + |
| +Add the same three lines to every gate test that reads `root` in Tasks 3–7. Task 8 deletes all of them in one commit. Import `"os"`. |
| + |
| +The condition tests `nav.json`, not the directory. Task 4 creates `docs/site/anchors.json`, so a bare directory test would stop skipping one task early and every gate would then fail on a corpus that does not exist until Task 8. |
| + |
| +- [ ] **Step 6: Run to verify all pass** |
| + |
| +Same command. Expected: PASS (one skipped). |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +gofmt -l internal/docsite/ |
| +git add internal/docsite/ |
| +git commit -m "docsite: gate the nav against the files, both directions |
| + |
| +A nav entry with no file fails in Load. A file no nav entry reaches |
| +fails here — that is the worse one, because the page looks written and |
| +is never read. Both directions have a fixture that proves the check can |
| +fire. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: Links, fragments and anchor uniqueness |
| + |
| +**Files:** |
| +- Create: `internal/docsite/links_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Load`, `root`, `Page.Links`, `Page.Headings`. |
| +- Produces: `hasAnchor(p *Page, anchor string) bool`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +package docsite |
| + |
| +import ( |
| + "os" |
| + "testing" |
| +) |
| + |
| +// TestInternalLinksResolve stops a docs link 404ing. |
| +// |
| +// It checks the fragment as well as the page, because a link to a |
| +// renamed heading is the failure that actually happens: the page still |
| +// exists, the link still looks right in review, and the reader lands at |
| +// the top of a long page wondering what they missed. |
| +func TestInternalLinksResolve(t *testing.T) { |
| + if _, err := os.Stat(root + "/nav.json"); os.IsNotExist(err) { |
| + t.Skip("no corpus yet — Task 8 creates docs/site/nav.json") |
| + } |
| + site, err := Load(root) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + for _, p := range site.Pages() { |
| + for _, l := range p.Links { |
| + target, ok := site.BySlug[l.Target] |
| + if !ok { |
| + t.Errorf("%s.md:%d: links to /docs/%s, which is not a page", |
| + p.Slug, l.Line, l.Target) |
| + continue |
| + } |
| + if l.Fragment == "" { |
| + continue |
| + } |
| + if !hasAnchor(target, l.Fragment) { |
| + t.Errorf("%s.md:%d: links to /docs/%s#%s, but %s has no heading anchoring %q", |
| + p.Slug, l.Line, l.Target, l.Fragment, l.Target, l.Fragment) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func hasAnchor(p *Page, anchor string) bool { |
| + for _, h := range p.Headings { |
| + if h.Anchor == anchor { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// TestLinkCheckCatchesADeadFragment proves the fragment half fires. |
| +func TestLinkCheckCatchesADeadFragment(t *testing.T) { |
| + site, err := Load(testCorpus) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + cli := site.BySlug["reference/cli"] |
| + if cli == nil { |
| + t.Fatal("no reference/cli page") |
| + } |
| + if !hasAnchor(cli, "carlos-ship") { |
| + t.Error("expected reference/cli to anchor carlos-ship") |
| + } |
| + if hasAnchor(cli, "carlos-teleport") { |
| + t.Error("hasAnchor invented an anchor that is not there") |
| + } |
| +} |
| + |
| +// TestAnchorsAreUnique matters because a duplicate anchor makes a link |
| +// to it ambiguous, and the renderer resolves that by silently suffixing |
| +// the second one. The link then goes to the wrong section rather than |
| +// nowhere, which is harder to notice. |
| +func TestAnchorsAreUnique(t *testing.T) { |
| + if _, err := os.Stat(root + "/nav.json"); os.IsNotExist(err) { |
| + t.Skip("no corpus yet — Task 8 creates docs/site/nav.json") |
| + } |
| + site, err := Load(root) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + for _, p := range site.Pages() { |
| + seen := map[string]string{} |
| + for _, h := range p.Headings { |
| + if h.Level == 1 { |
| + continue |
| + } |
| + if prev, dup := seen[h.Anchor]; dup { |
| + t.Errorf("%s.md: headings %q and %q both anchor to #%s", |
| + p.Slug, prev, h.Text, h.Anchor) |
| + } |
| + seen[h.Anchor] = h.Text |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run 'TestInternalLinks|TestLinkCheck|TestAnchorsAreUnique' -count=1 |
| +``` |
| + |
| +Expected: PASS — two skipped, `TestLinkCheckCatchesADeadFragment` passing against the testdata corpus. |
| + |
| +- [ ] **Step 3: Commit** |
| + |
| +```bash |
| +gofmt -l internal/docsite/ |
| +git add internal/docsite/links_test.go |
| +git commit -m "docsite: gate internal links, their fragments, and anchor uniqueness |
| + |
| +The fragment half is the one that earns its keep: a link to a renamed |
| +heading still looks right in review and still lands the reader at the |
| +top of the wrong page. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: The shared anchor fixture |
| + |
| +**Files:** |
| +- Create: `docs/site/anchors.json` |
| +- Modify: `internal/docsite/links_test.go` (append) |
| + |
| +**Interfaces:** |
| +- Consumes: `Anchor` from Task 1. |
| +- Produces: `docs/site/anchors.json` — a flat `{heading: anchor}` object. `hack/check-docs.mjs` in Task 11 reads this same file. It is the contract between the two languages. |
| + |
| +- [ ] **Step 1: Write the fixture** |
| + |
| +`docs/site/anchors.json`: |
| + |
| +```json |
| +{ |
| + "Getting started": "getting-started", |
| + "The `carlos ship` contract": "the-carlos-ship-contract", |
| + "404, never 403": "404-never-403", |
| + "Routes & instances": "routes-instances", |
| + " Leading and trailing ": "leading-and-trailing", |
| + "Hyphen-joined words": "hyphen-joined-words", |
| + "`-app` and `--app`": "app-and-app", |
| + "canary/rehearsal": "canary-rehearsal", |
| + "X-Carlos-Version": "x-carlos-version" |
| +} |
| +``` |
| + |
| +Every case is a shape that actually occurs in CARLOS headings: backticked flags, a slashed channel name, a hyphenated HTTP header. `"`-app` and `--app`"` collapsing to `app-and-app` is the one to keep — it is the collision the uniqueness gate exists to catch. |
| + |
| +- [ ] **Step 2: Write the failing test** |
| + |
| +Append to `internal/docsite/links_test.go`: |
| + |
| +```go |
| +// TestAnchorMatchesFixture pins the anchor rule itself. The website's |
| +// slugify reads the same file, so the two implementations assert |
| +// against a shared fixture rather than against each other. |
| +func TestAnchorMatchesFixture(t *testing.T) { |
| + raw, err := os.ReadFile(filepath.Join(root, "anchors.json")) |
| + if err != nil { |
| + t.Fatalf("read fixture: %v", err) |
| + } |
| + var cases map[string]string |
| + if err := json.Unmarshal(raw, &cases); err != nil { |
| + t.Fatalf("parse fixture: %v", err) |
| + } |
| + if len(cases) < 5 { |
| + t.Fatalf("fixture has %d cases — too few to pin anything", len(cases)) |
| + } |
| + for in, want := range cases { |
| + if got := Anchor(in); got != want { |
| + t.Errorf("Anchor(%q) = %q, want %q", in, got, want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +Add `"encoding/json"` and `"path/filepath"` to the imports. |
| + |
| +Note this test does **not** skip: `anchors.json` is created by this task, so it is always there to read. The other gates keep skipping until Task 8 because they need the `.md` pages — which is why their condition tests `nav.json` rather than the `docs/site` directory this task brings into existence. |
| + |
| +- [ ] **Step 3: Run to verify it passes** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run TestAnchorMatchesFixture -count=1 -v |
| +``` |
| + |
| +Expected: PASS. If any case fails, the fixture is wrong, not `Anchor` — work out by hand what the rule in Global Constraints produces and fix the JSON. |
| + |
| +- [ ] **Step 4: Commit** |
| + |
| +```bash |
| +gofmt -l internal/docsite/ |
| +git add docs/site/anchors.json internal/docsite/links_test.go |
| +git commit -m "docsite: pin the anchor rule in a fixture both languages read |
| + |
| +The website's slugify and docsite.Anchor have to agree exactly or a |
| +fragment link passes the Go gate and 404s in a browser. They now assert |
| +against docs/site/anchors.json rather than against each other, and the |
| +cases are shapes CARLOS headings actually take: backticked flags, a |
| +slashed channel name, an HTTP header. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: Fence languages |
| + |
| +**Files:** |
| +- Create: `internal/docsite/fences_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Load`, `root`, `Page.Fences`. |
| +- Produces: `knownFenceLangs` — a package-level map Task 7 does not use but a later corpus task may extend. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +package docsite |
| + |
| +import ( |
| + "os" |
| + "testing" |
| +) |
| + |
| +// knownFenceLangs is what a fence in this corpus may declare. Adding a |
| +// language here is a deliberate act: an undeclared or unknown fence is |
| +// invisible to every content gate that reads fences, so it is a way to |
| +// smuggle a wrong command past review. |
| +var knownFenceLangs = map[string]bool{ |
| + "sh": true, "bash": true, "console": true, "json": true, |
| + "go": true, "yaml": true, "toml": true, "ini": true, |
| + "text": true, "http": true, |
| +} |
| + |
| +func TestFenceLanguagesAreDeclared(t *testing.T) { |
| + if _, err := os.Stat(root + "/nav.json"); os.IsNotExist(err) { |
| + t.Skip("no corpus yet — Task 8 creates docs/site/nav.json") |
| + } |
| + site, err := Load(root) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + for _, p := range site.Pages() { |
| + for _, f := range p.Fences { |
| + if f.Lang == "" { |
| + t.Errorf("%s.md:%d: fence declares no language", p.Slug, f.Line) |
| + continue |
| + } |
| + if !knownFenceLangs[f.Lang] { |
| + t.Errorf("%s.md:%d: unknown fence language %q — add it to knownFenceLangs if it is real", |
| + p.Slug, f.Line, f.Lang) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestTestdataFencesDeclareLanguages runs the same rule against the |
| +// testdata corpus, so the gate above is proven to work on a corpus that |
| +// exists before docs/site does. |
| +func TestTestdataFencesDeclareLanguages(t *testing.T) { |
| + site, err := Load(testCorpus) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + for _, p := range site.Pages() { |
| + for _, f := range p.Fences { |
| + if f.Lang == "" { |
| + t.Errorf("%s: testdata corpus has an undeclared fence at line %d", p.Slug, f.Line) |
| + } |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run TestFence -count=1 |
| +``` |
| + |
| +Expected: PASS — one skipped, one passing. |
| + |
| +- [ ] **Step 3: Commit** |
| + |
| +```bash |
| +gofmt -l internal/docsite/ |
| +git add internal/docsite/fences_test.go |
| +git commit -m "docsite: require every fence to declare a language |
| + |
| +An undeclared fence is invisible to the content gates that read fences, |
| +so it is the way a wrong command gets past review. The allowed list is |
| +short and extending it is deliberate. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: Every command is documented |
| + |
| +**Files:** |
| +- Create: `internal/docsite/cliscan.go` |
| +- Create: `internal/docsite/cli_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `Load`, `root`. |
| +- Produces: `TopLevelCommands(mainFile string) ([]string, error)` — the dispatch cases in `main.go`, sorted, deduped. Task 7 calls it. |
| + |
| +- [ ] **Step 1: Confirm what the scanner must find** |
| + |
| +```bash |
| +grep -n '^\s*case "' main.go | head -50 |
| +``` |
| + |
| +You should see `case "help", "-h", "--help":` through `case "canary":`. The scanner keeps bare lowercase words and drops anything starting with `-`, so `help` survives and `-h` does not. |
| + |
| +- [ ] **Step 2: Write the failing test** |
| + |
| +`internal/docsite/cli_test.go`: |
| + |
| +```go |
| +package docsite |
| + |
| +import ( |
| + "os" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// mainFile is the CLI's dispatch, relative to this package. |
| +const mainFile = "../../main.go" |
| + |
| +// cliPage is the page that must document all of it. |
| +const cliPage = "cli" |
| + |
| +// TestTopLevelCommandsReadsTheDispatch checks the scanner before the |
| +// gate that depends on it. A scanner that silently returns nothing |
| +// turns the gate below into a test that always passes. |
| +func TestTopLevelCommandsReadsTheDispatch(t *testing.T) { |
| + cmds, err := TopLevelCommands(mainFile) |
| + if err != nil { |
| + t.Fatalf("scan: %v", err) |
| + } |
| + if len(cmds) < 20 { |
| + t.Fatalf("found %d commands (%v) — the scanner is broken, not the docs", len(cmds), cmds) |
| + } |
| + for _, want := range []string{"ship", "promote", "deploy", "domains", "logs", "fleets"} { |
| + if !contains(cmds, want) { |
| + t.Errorf("scanner missed the %q command", want) |
| + } |
| + } |
| + for _, unwanted := range []string{"-h", "--help", ""} { |
| + if contains(cmds, unwanted) { |
| + t.Errorf("scanner kept %q, which is not a command", unwanted) |
| + } |
| + } |
| +} |
| + |
| +func contains(xs []string, s string) bool { |
| + for _, x := range xs { |
| + if x == s { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// TestCLICommandsAreDocumented is the gate. It reads the dispatch |
| +// switch rather than the usage text on purpose: usage text is prose |
| +// someone can forget to update, so gating against it would only prove |
| +// the docs match a second copy of the docs. The switch is the CLI. |
| +func TestCLICommandsAreDocumented(t *testing.T) { |
| + if _, err := os.Stat(root + "/nav.json"); os.IsNotExist(err) { |
| + t.Skip("no corpus yet — Task 8 creates docs/site/nav.json") |
| + } |
| + site, err := Load(root) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + page, ok := site.BySlug[cliPage] |
| + if !ok { |
| + t.Fatalf("no %s page — every command has to be documented somewhere", cliPage) |
| + } |
| + cmds, err := TopLevelCommands(mainFile) |
| + if err != nil { |
| + t.Fatalf("scan: %v", err) |
| + } |
| + for _, cmd := range cmds { |
| + if !strings.Contains(page.Body, "carlos "+cmd) { |
| + t.Errorf("%s.md does not document %q (looked for %q)", cliPage, cmd, "carlos "+cmd) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 3: Run to verify it fails** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run TestTopLevel -count=1 |
| +``` |
| + |
| +Expected: build failure — `undefined: TopLevelCommands`. |
| + |
| +- [ ] **Step 4: Write the scanner** |
| + |
| +`internal/docsite/cliscan.go`: |
| + |
| +```go |
| +package docsite |
| + |
| +import ( |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "sort" |
| + "strconv" |
| +) |
| + |
| +// TopLevelCommands returns every subcommand main.go dispatches on. |
| +// |
| +// It reads the switch statement rather than the usage text because the |
| +// switch is the CLI and the usage text is prose about it. A gate |
| +// against the prose would prove only that the docs agree with a second |
| +// copy of the docs, which is exactly the drift worth catching. |
| +// |
| +// Flags are deliberately not scanned. rastrillo's equivalent gate does |
| +// scan them, but its CLI is one small package; CARLOS declares flags |
| +// across the whole root package and the list would run to hundreds, |
| +// most of them things no reader ever types. |
| +func TopLevelCommands(mainFile string) ([]string, error) { |
| + fset := token.NewFileSet() |
| + file, err := parser.ParseFile(fset, mainFile, nil, parser.SkipObjectResolution) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + seen := map[string]bool{} |
| + var out []string |
| + ast.Inspect(file, func(n ast.Node) bool { |
| + sw, ok := n.(*ast.SwitchStmt) |
| + if !ok { |
| + return true |
| + } |
| + for _, stmt := range sw.Body.List { |
| + clause, ok := stmt.(*ast.CaseClause) |
| + if !ok { |
| + continue |
| + } |
| + for _, expr := range clause.List { |
| + s, ok := stringLit(expr) |
| + if !ok || !isCommandWord(s) { |
| + continue |
| + } |
| + if !seen[s] { |
| + seen[s] = true |
| + out = append(out, s) |
| + } |
| + } |
| + } |
| + return true |
| + }) |
| + sort.Strings(out) |
| + return out, nil |
| +} |
| + |
| +// isCommandWord filters case arms down to real commands: flag aliases |
| +// like "-h" are documented as part of the command they belong to, and a |
| +// case arm is only a command if it looks like one. |
| +func isCommandWord(s string) bool { |
| + if s == "" { |
| + return false |
| + } |
| + for _, r := range s { |
| + if (r < 'a' || r > 'z') && r != '-' { |
| + return false |
| + } |
| + } |
| + return s[0] != '-' |
| +} |
| + |
| +func stringLit(e ast.Expr) (string, bool) { |
| + lit, ok := e.(*ast.BasicLit) |
| + if !ok || lit.Kind != token.STRING { |
| + return "", false |
| + } |
| + s, err := strconv.Unquote(lit.Value) |
| + if err != nil { |
| + return "", false |
| + } |
| + return s, true |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Run to verify it passes** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run 'TestTopLevel|TestCLICommands' -count=1 -v |
| +``` |
| + |
| +Expected: `TestTopLevelCommandsReadsTheDispatch` PASS, `TestCLICommandsAreDocumented` SKIP. |
| + |
| +If the scanner returns fewer than 20, check whether `main.go` dispatches from a helper rather than a top-level switch, and widen `ast.Inspect` to that function — do not lower the threshold. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +gofmt -l internal/docsite/ |
| +git add internal/docsite/cliscan.go internal/docsite/cli_test.go |
| +git commit -m "docsite: fail the build when a command has no docs |
| + |
| +Reads main.go's dispatch switch as an AST, not its usage text — gating |
| +against the prose would only prove the docs match a second copy of the |
| +docs. Flags stay out of scope: rastrillo can scan them because its CLI |
| +is one small package, and CARLOS's root package would produce hundreds. |
| + |
| +The scanner has its own test, because a scanner that silently finds |
| +nothing turns the gate into one that always passes. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 7: Every `carlos` invocation in a fence is real |
| + |
| +**Files:** |
| +- Modify: `internal/docsite/fences_test.go` (append) |
| + |
| +**Interfaces:** |
| +- Consumes: `TopLevelCommands` from Task 6, `Page.Fences` from Task 1. |
| +- Produces: nothing later tasks use. |
| + |
| +This is the gate rastrillo does not have. A renamed verb currently breaks nothing until a reader types it, and docs full of copy-pasteable examples make that worse. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `internal/docsite/fences_test.go`: |
| + |
| +```go |
| +// TestFencedCarlosCommandsAreReal checks the examples, not just the |
| +// prose. A renamed verb leaves every fence containing it copy-pasteable |
| +// and wrong, and nothing else in this package would notice. |
| +func TestFencedCarlosCommandsAreReal(t *testing.T) { |
| + if _, err := os.Stat(root + "/nav.json"); os.IsNotExist(err) { |
| + t.Skip("no corpus yet — Task 8 creates docs/site/nav.json") |
| + } |
| + site, err := Load(root) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + cmds, err := TopLevelCommands(mainFile) |
| + if err != nil { |
| + t.Fatalf("scan: %v", err) |
| + } |
| + real := map[string]bool{} |
| + for _, c := range cmds { |
| + real[c] = true |
| + } |
| + |
| + for _, p := range site.Pages() { |
| + for _, f := range p.Fences { |
| + for _, verb := range fencedCarlosVerbs(f.Body) { |
| + if !real[verb] { |
| + t.Errorf("%s.md:%d: example runs `carlos %s`, which is not a command", |
| + p.Slug, f.Line, verb) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// fencedCarlosVerbs returns the word after each "carlos" in a fence |
| +// body. It skips lines where "carlos" is not the command being run — |
| +// a path like /opt/carlos/carlos, or prose inside a comment. |
| +func fencedCarlosVerbs(body string) []string { |
| + var out []string |
| + for _, line := range strings.Split(body, "\n") { |
| + line = strings.TrimSpace(line) |
| + line = strings.TrimPrefix(line, "$ ") |
| + if i := strings.Index(line, "#"); i >= 0 { |
| + line = strings.TrimSpace(line[:i]) |
| + } |
| + fields := strings.Fields(line) |
| + for i, f := range fields { |
| + // Bare "carlos", or the binary called by an absolute path — |
| + // runbooks on a box invoke /opt/carlos/carlos, and those |
| + // examples go stale the same way. |
| + if f != "carlos" && !strings.HasSuffix(f, "/carlos") { |
| + continue |
| + } |
| + if i+1 >= len(fields) { |
| + continue |
| + } |
| + next := fields[i+1] |
| + if strings.HasPrefix(next, "-") { |
| + break |
| + } |
| + out = append(out, next) |
| + break |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// TestFencedCarlosVerbsIgnoresNoise pins the parsing, because a |
| +// permissive extractor turns the gate above into one that finds |
| +// nothing. |
| +func TestFencedCarlosVerbsIgnoresNoise(t *testing.T) { |
| + body := strings.Join([]string{ |
| + "carlos ship -app hello ./dist", |
| + "$ carlos promote -app hello abc123 edge", |
| + "/opt/carlos/carlos adopt", |
| + "# run carlos deploy afterwards", |
| + "carlos -h", |
| + "echo carlos", |
| + }, "\n") |
| + got := fencedCarlosVerbs(body) |
| + want := []string{"ship", "promote", "adopt"} |
| + if len(got) != len(want) { |
| + t.Fatalf("got %v, want %v", got, want) |
| + } |
| + for i := range want { |
| + if got[i] != want[i] { |
| + t.Fatalf("got %v, want %v", got, want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +Add `"strings"` to the imports. |
| + |
| +- [ ] **Step 2: Run to verify** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -run TestFenced -count=1 -v |
| +``` |
| + |
| +Expected: `TestFencedCarlosCommandsAreReal` SKIP, `TestFencedCarlosVerbsIgnoresNoise` PASS. |
| + |
| +The `/opt/carlos/carlos adopt` case yields `adopt`: a field ending in `/carlos` is the binary called by absolute path, which is how the on-box runbooks invoke it, and those examples go stale the same way bare ones do. `echo carlos` yields nothing because no word follows, and `carlos -h` yields nothing because a flag is not a verb. The test pins all three. |
| + |
| +- [ ] **Step 3: Commit** |
| + |
| +```bash |
| +gofmt -l internal/docsite/ |
| +git add internal/docsite/fences_test.go |
| +git commit -m "docsite: check the examples, not just the prose |
| + |
| +Every \`carlos <verb>\` inside a fence has to name a real command. A |
| +renamed verb otherwise leaves every example containing it copy-pasteable |
| +and wrong, and no other gate here would notice. The extractor has its |
| +own test — a permissive one would find nothing and pass forever. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 8: The real corpus, and the gates go live |
| + |
| +**Files:** |
| +- Create: `docs/site/nav.json`, `docs/site/index.md`, `docs/site/cli.md` |
| +- Modify: every `_test.go` in `internal/docsite/` — delete the skips |
| +- Modify: `AGENTS.md` |
| + |
| +**Interfaces:** |
| +- Consumes: every gate from Tasks 2–7. |
| +- Produces: a corpus the website repo vendors in Task 9. |
| + |
| +- [ ] **Step 1: Get the command list** |
| + |
| +```bash |
| +grep -o 'case "[a-z-]*"' main.go | sort -u |
| +``` |
| + |
| +That is the list the gate will hold you to. Then read `main.go:138-204`, the `usage` function. It already groups every command by role — your apps, your deployment, on the host — with a one-line description each. That grouping is the CLI page's structure. Do not copy its wording; it is terse to fit a terminal, and the page has room to say what a command is for. |
| + |
| +- [ ] **Step 2: Write `docs/site/nav.json`** |
| + |
| +```json |
| +{ |
| + "sections": [ |
| + { |
| + "title": "Reference", |
| + "pages": [ |
| + { |
| + "slug": "cli", |
| + "label": "The CLI", |
| + "blurb": "Every carlos command, grouped by whether you are shipping an app, running a deployment, or on a box." |
| + } |
| + ] |
| + } |
| + ] |
| +} |
| +``` |
| + |
| +The remaining thirteen pages arrive in the next plan. One section with one page is a complete, valid corpus, and it is what lets this plan ship something that works. |
| + |
| +- [ ] **Step 3: Write `docs/site/index.md`** |
| + |
| +Apply `humane-docs`. Open with what CARLOS is in two plain sentences — no "powerful", no "flexible". Say what is on this page set and what is not (Carloku customers want carloku.com/docs). Keep it under a screen. The index's own prose renders above the nav cards, so it does not need to list the pages. |
| + |
| +- [ ] **Step 4: Write `docs/site/cli.md`** |
| + |
| +One `##` heading per command, titled `carlos <command>`, in the three role groups `main.go` uses. For each: what it does in one or two sentences, the common invocation as one `sh` fence, and the one or two flags people actually change. Not every flag — `carlos <command> -h` prints those, and an exhaustive dump is the failure mode `humane-docs` names. |
| + |
| +Requirements the gates enforce, so check them before running: |
| + |
| +- Every command from `TopLevelCommands` appears as the literal string `carlos <command>`. |
| +- Every fence declares `sh`. |
| +- Every `carlos <verb>` inside a fence is a real command. |
| +- No two `##` headings anchor the same — `carlos route` and `carlos routes` are fine (`carlos-route` and `carlos-routes`), but check anything with punctuation. |
| + |
| +- [ ] **Step 5: Delete every skip** |
| + |
| +```bash |
| +grep -rn 'Task 8 creates docs/site' internal/docsite/ |
| +``` |
| + |
| +Remove those three-line blocks from all six tests. Drop the now-unused `"os"` import where nothing else uses it. |
| + |
| +- [ ] **Step 6: Run the whole package** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/docsite/ -count=1 -v |
| +``` |
| + |
| +Expected: every test PASS, none skipped. Failures here are real — a missing command, a dead fragment, a duplicate anchor. Fix the markdown, not the test. |
| + |
| +- [ ] **Step 7: Run the full gate** |
| + |
| +```bash |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go vet ./... && go test ./... -count=1 |
| +``` |
| + |
| +Expected: all packages pass. |
| + |
| +- [ ] **Step 8: Document the corpus in AGENTS.md** |
| + |
| +Add to "Rules this repo has paid for": |
| + |
| +```text |
| +- **A new `carlos` command is not done until `docs/site/cli.md` names it.** |
| + `internal/docsite` reads main.go's dispatch switch and fails the build over |
| + a command with no docs, over a fenced example running a verb that no longer |
| + exists, and over a `/docs` link whose page or `#fragment` does not resolve. |
| + The corpus is rendered by `carlosframework/website`, which vendors a copy — |
| + so a change here reaches the site only after that repo re-syncs and ships. |
| +``` |
| + |
| +- [ ] **Step 9: Commit** |
| + |
| +```bash |
| +gofmt -l internal/docsite/ |
| +git add docs/site/ internal/docsite/ AGENTS.md |
| +git commit -m "docs: the corpus, and the gates stop skipping |
| + |
| +Two pages — the index and the CLI reference — which is a complete corpus |
| +and enough to turn every gate on for real. The other thirteen pages come |
| +next. |
| + |
| +The CLI page is written from main.go's grouping but not its wording: the |
| +usage text is terse because it has to fit a terminal, and a reference |
| +page has room to say what a command is for. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 9: The website build |
| + |
| +**Files (all in `~/github.com/carlosframework/website`, branch `docs-site`):** |
| +- Create: `package.json`, `eleventy.config.js`, `.gitignore` |
| +- Create: `src/docs.njk`, `src/docs-index.njk`, `src/docsmd.njk`, `src/_includes/docs.njk` |
| +- Create: `hack/sync-docs.mjs` |
| + |
| +**Interfaces:** |
| +- Consumes: `docs/site/` from Task 8 in a platform checkout. |
| +- Produces: `src/docs/**.md`, `src/_data/docsnav.json`, `src/_data/docsanchors.json`, `src/_data/docsversion.json` — all vendored, all gitignored from hand-editing by convention and by a header comment. |
| + |
| +- [ ] **Step 1: Read the reference** |
| + |
| +Read all of `~/github.com/carlosframework/rastrillo-website/`: `eleventy.config.js`, `src/docs.njk`, `src/docs-index.njk`, `src/docsmd.njk`, `src/_includes/docs.njk`, `hack/sync-docs.mjs`. You are porting them. |
| + |
| +Two things in `eleventy.config.js` are load-bearing and easy to lose: |
| + |
| +- `slugify` must match the rule in Global Constraints exactly. Task 11 asserts it against `docs/site/anchors.json`. |
| +- The `docsRawFor` filter must not be routed through `eleventyComputed`. A computed value in Nunjucks front matter is a string interpolation, which HTML-escapes it — an escaped apostrophe turns `carlos's` into `carlos-39-s` in every anchor derived from it, and the sub-nav ends up linking to ids no page has. The reference file carries this comment; keep it. |
| + |
| +- [ ] **Step 2: Write `package.json`** |
| + |
| +```json |
| +{ |
| + "name": "carlosframework-website", |
| + "private": true, |
| + "type": "module", |
| + "scripts": { |
| + "build": "eleventy", |
| + "serve": "eleventy --serve", |
| + "check": "eleventy && node hack/check-docs.mjs", |
| + "sync-docs": "node hack/sync-docs.mjs" |
| + }, |
| + "devDependencies": { |
| + "@11ty/eleventy": "^3.0.0", |
| + "markdown-it": "^14.1.0", |
| + "markdown-it-anchor": "^9.2.0" |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 3: Write `.gitignore`** |
| + |
| +```text |
| +node_modules/ |
| +_site/ |
| +``` |
| + |
| +- [ ] **Step 4: Move the hand-written pages under `src/`** |
| + |
| +Eleventy's input directory is `src`. The existing `index.html`, `platform/index.html`, `site.css` and `favicon.svg` move there, and pass through unchanged: |
| + |
| +```bash |
| +mkdir -p src |
| +git mv index.html platform site.css favicon.svg src/ |
| +``` |
| + |
| +Add to `eleventy.config.js`: |
| + |
| +```js |
| + eleventyConfig.addPassthroughCopy({ "src/site.css": "site.css" }); |
| + eleventyConfig.addPassthroughCopy({ "src/docs.css": "docs.css" }); |
| + eleventyConfig.addPassthroughCopy({ "src/favicon.svg": "favicon.svg" }); |
| + eleventyConfig.addPassthroughCopy({ "src/index.html": "index.html" }); |
| + eleventyConfig.addPassthroughCopy({ "src/platform": "platform" }); |
| +``` |
| + |
| +Verify after the first build that `_site/index.html` is byte-identical to the old `index.html`: |
| + |
| +```bash |
| +npx eleventy && diff <(git show HEAD:index.html) _site/index.html && echo "landing page unchanged" |
| +``` |
| + |
| +- [ ] **Step 5: Port the templates and the sync script** |
| + |
| +`src/docs.njk`, `src/docs-index.njk`, `src/docsmd.njk` port verbatim from the reference — they contain no rastrillo-specific content beyond the layout name. |
| + |
| +`src/_includes/docs.njk` needs the breadcrumb changed from `CARLOS / rastrillo / docs` to `CARLOS / platform / docs`, and its `<head>` aligned with the existing `src/index.html` (same meta, same `site.css` link, plus `docs.css`). |
| + |
| +`hack/sync-docs.mjs` ports with three changes: source is `<checkout>/docs/site`, and it copies `anchors.json` to `src/_data/docsanchors.json` as well as `nav.json` to `src/_data/docsnav.json`. |
| + |
| +- [ ] **Step 6: Sync and build** |
| + |
| +```bash |
| +npm install |
| +node hack/sync-docs.mjs ~/.herdr/worktrees/platform/docs |
| +npx eleventy |
| +ls _site/docs/ _site/docs/cli/ |
| +``` |
| + |
| +Expected: `_site/docs/index.html`, `_site/docs/cli/index.html`, `_site/docs/cli.md`. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add -A |
| +git commit -m "🤖 Eleventy, and /docs renders |
| + |
| +The pipeline rastrillo.org has run since its docs landed, ported here. |
| +The hand-written pages move under src/ and pass through untouched — the |
| +built index.html is byte-identical to the committed one, which the build |
| +step checks. |
| + |
| +The corpus is vendored from the platform repo by hack/sync-docs.mjs, |
| +which records the sha it came from, so this build never needs a Go |
| +checkout present. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 10: The docs skin |
| + |
| +**Files:** |
| +- Create: `src/docs.css` (website repo) |
| + |
| +**Interfaces:** |
| +- Consumes: the class names `src/_includes/docs.njk` emits. |
| +- Produces: nothing later tasks use. |
| + |
| +- [ ] **Step 1: Read the house style** |
| + |
| +Read `AGENTS.md` "House style" and `src/site.css`. The world is: type from Modern Font Stacks with Neo-Grotesque carrying the voice and Monospace Code carrying identifiers and measurement only; platform blue `#0d5f88` light / `#59bfe8` dark; `--wrap: 72rem`, `--prose: 40rem`, body copy near 36rem; hairline rules divide, and nothing is a card unless being a card is a choice. |
| + |
| +The docs index's nav entries **are** cards — one per page with a label and a blurb, which is a deliberate use, not page structure. |
| + |
| +- [ ] **Step 2: Write `src/docs.css`** |
| + |
| +A sidebar layout at `--wrap`, collapsing to a single column under about 60rem with the sidebar above the content. Reuse `site.css`'s tokens; define nothing it already defines. Both `prefers-color-scheme` and the `[data-theme]` overrides that `site.css` already handles must keep working — check both. |
| + |
| +- [ ] **Step 3: Look at it** |
| + |
| +```bash |
| +npm run serve |
| +``` |
| + |
| +Open `http://localhost:8080/docs/` and `http://localhost:8080/docs/cli/`. Check: the sidebar marks the current page; the sub-nav lists the page's `##` headings and its links work; long fences scroll inside their own box rather than the page scrolling sideways; light and dark both read; a narrow viewport is not broken. Compare against `/` — sibling pages, visibly related, not identical. |
| + |
| +- [ ] **Step 4: Commit** |
| + |
| +```bash |
| +git add src/docs.css |
| +git commit -m "🤖 The docs skin |
| + |
| +Sidebar at --wrap, prose at --prose, platform blue, hairline rules. The |
| +index's nav entries are cards because a label-plus-blurb list is what a |
| +card is for; nothing else here is one. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 11: The rendered-output gate and the ship runbook |
| + |
| +**Files:** |
| +- Create: `hack/check-docs.mjs` (website repo) |
| +- Modify: `AGENTS.md` (website repo) |
| + |
| +**Interfaces:** |
| +- Consumes: `_site/`, `src/_data/docsnav.json`, `src/_data/docsanchors.json`, and the exported `slugify` from `eleventy.config.js`. |
| +- Produces: `npm run check`, the command the ship runbook depends on. |
| + |
| +- [ ] **Step 1: Port `check-docs.mjs`** |
| + |
| +Port from `~/github.com/carlosframework/rastrillo-website/hack/check-docs.mjs`, with one change: the anchor fixture is not inlined. Read it from the vendored file, so the Go and JS sides assert against one artifact: |
| + |
| +```js |
| +// 3. The anchor rule agrees on both sides. The Go gate accepted these |
| +// fragments; if the renderer slugifies differently they are dead |
| +// links no earlier check can see. The fixture is the platform repo's |
| +// docs/site/anchors.json, vendored — both languages read this file |
| +// rather than each other's implementation. |
| +const { slugify } = await import("../eleventy.config.js"); |
| +const fixture = JSON.parse(readFileSync("src/_data/docsanchors.json", "utf8")); |
| +if (Object.keys(fixture).length < 5) { |
| + fail("anchor fixture has too few cases to pin anything — did sync-docs run?"); |
| +} |
| +for (const [input, want] of Object.entries(fixture)) { |
| + const got = slugify(input); |
| + if (got !== want) { |
| + fail(`slugify(${JSON.stringify(input)}) = ${JSON.stringify(got)}, want ${JSON.stringify(want)} — the renderer and internal/docsite.Anchor disagree`); |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Prove the gate fires** |
| + |
| +Temporarily break `slugify` in `eleventy.config.js` — make it return `text.toLowerCase()` unchanged — then: |
| + |
| +```bash |
| +npm run check |
| +``` |
| + |
| +Expected: exit 1, with `the renderer and internal/docsite.Anchor disagree` naming a case. Revert the change and re-run; expected `docs ok`. |
| + |
| +Do the same for a dead link: add `[nope](/docs/nowhere)` to a vendored page, rebuild, confirm `check-docs.mjs` names it, then remove it. |
| + |
| +- [ ] **Step 3: Write the ship runbook** |
| + |
| +In the website repo's `AGENTS.md`, under "Building the docs" — the section the earlier commit already promises: |
| + |
| +````markdown |
| +## Building the docs |
| + |
| +`/docs` is built from markdown that lives in the **platform** repo at |
| +`docs/site/`, where its Go tests gate it against the CLI. Nothing under |
| +`src/docs/` or `src/_data/docs*.json` is edited here — `sync-docs.mjs` |
| +overwrites all of it. |
| + |
| +``` |
| +npm install |
| +node hack/sync-docs.mjs ~/github.com/carlosframework/platform |
| +npm run check # builds, then checks links, ids and the anchor rule |
| +npm run serve # localhost:8080 |
| +``` |
| + |
| +`node hack/sync-docs.mjs <checkout> --check` reports whether the vendored |
| +copy is behind without writing anything. |
| + |
| +## Deploying |
| + |
| +**This repo has a build step, so never ship the repo tree.** A tree ship |
| +has no `_site/` in it and takes /docs off the internet. Ship the built |
| +output of a clean export: |
| + |
| +``` |
| +mkdir -p /tmp/website-ship |
| +git archive <sha> --prefix=export/ | tar -x -C /tmp/website-ship |
| +cd /tmp/website-ship/export && npm ci && npm run check |
| + |
| +export AWS_PROFILE=keymail AWS_REGION=eu-west-1 \ |
| + CARLOS_DEPLOYMENT_BUCKET=carlos-flagship-271376211898 |
| +carlos ship -app website -kind static -version <sha> _site |
| +CARLOS_RELEASE_KEY=$(aws ssm get-parameter --name /carlos/release-key \ |
| + --with-decryption --query Parameter.Value --output text) \ |
| + carlos promote -app website <sha> <channel> |
| +``` |
| + |
| +Verify by content, and against the docs specifically — the landing page |
| +will look right whether or not /docs made it into the artifact: |
| + |
| +``` |
| +curl -s https://carlosframework.com/docs/ | grep -i "the cli" |
| +``` |
| +```` |
| + |
| +Replace `<app>` and `<channel>` with the values the existing runbook in this file already uses; read it rather than assuming. If the existing runbook says to ship the tree, delete that wording — leaving both is how someone ships the wrong one. |
| + |
| +- [ ] **Step 4: Commit** |
| + |
| +```bash |
| +git add hack/check-docs.mjs AGENTS.md |
| +git commit -m "🤖 Check the rendered output, and never ship the tree again |
| + |
| +check-docs.mjs covers what the Go gate cannot: that rendering produced |
| +the pages and ids the links point at, and that slugify agrees with |
| +docsite.Anchor. The fixture is the vendored docs/site/anchors.json, so |
| +both languages assert against one artifact instead of two copies. |
| + |
| +The ship runbook now says to ship _site/ of a clean export, and the old |
| +ship-the-tree wording is gone rather than sitting alongside it — a tree |
| +ship would take /docs off the internet. |
| + |
| +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>" |
| +``` |
| + |
| +--- |
| + |
| +### Task 12: Open both PRs |
| + |
| +- [ ] **Step 1: Full platform gate** |
| + |
| +```bash |
| +cd ~/.herdr/worktrees/platform/docs |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... && go vet ./... && go test ./... -count=1 |
| +gofmt -l $(git diff --name-only origin/main...HEAD | grep '\.go$') |
| +``` |
| + |
| +Expected: all packages pass, `gofmt -l` empty. |
| + |
| +- [ ] **Step 2: Full website gate** |
| + |
| +```bash |
| +cd ~/github.com/carlosframework/website && npm ci && npm run check |
| +``` |
| + |
| +Expected: `docs ok`. |
| + |
| +- [ ] **Step 3: Push and open the platform PR first** |
| + |
| +The website's sync script vendors from the platform repo, so that PR is the one that has to exist first. |
| + |
| +```bash |
| +cd ~/.herdr/worktrees/platform/docs |
| +git push -u origin docs |
| +gh pr create --title "docs: the corpus, its gates, and the CLI reference" --body "$(cat <<'BODY' |
| +Adds `internal/docsite` and the first two pages of `docs/site/`. |
| + |
| +The gate that matters: a PR adding a `carlos` command now fails until |
| +`docs/site/cli.md` names it. It reads main.go's dispatch switch as an |
| +AST rather than its usage text, because gating against the prose would |
| +only prove the docs match a second copy of the docs. Fenced examples are |
| +checked too — every `carlos <verb>` in a code block has to be real. |
| + |
| +Also in here: AGENTS.md replaces CLAUDE.md as the canonical agent file, |
| +per Paul's project-wide rule, with a pointer left behind. |
| + |
| +Spec: `docs/superpowers/specs/2026-08-24-docs-sites-design.md` |
| +Plan: `docs/superpowers/plans/2026-08-25-docs-pipeline-and-gates.md` |
| + |
| +The remaining thirteen pages, and carloku.com/docs, are the next two plans. |
| + |
| +🤖 Generated with [Claude Code](https://claude.com/claude-code) |
| +BODY |
| +)" |
| +``` |
| + |
| +- [ ] **Step 4: Push and open the website PR** |
| + |
| +```bash |
| +cd ~/github.com/carlosframework/website |
| +git push -u origin docs-site |
| +gh pr create --title "🤖 /docs: Eleventy, the docs skin, and a ship runbook that does not lose them" --body "$(cat <<'BODY' |
| +Renders carlosframework.com/docs from the platform repo's corpus, using |
| +the pipeline rastrillo.org already runs. |
| + |
| +**This repo now has a build step**, which means shipping the repo tree |
| +would put a site with no /docs in it on the internet. The runbook in |
| +AGENTS.md is rewritten to ship `_site/` of a clean export, and the old |
| +wording is deleted rather than left alongside it. |
| + |
| +The hand-written pages move under `src/` and pass through untouched — the |
| +built `index.html` is byte-identical to the committed one. |
| + |
| +Depends on carlosframework/platform#<N>, which carries the corpus. |
| + |
| +🤖 Generated with [Claude Code](https://claude.com/claude-code) |
| +BODY |
| +)" |
| +``` |
| + |
| +- [ ] **Step 5: Do not merge** |
| + |
| +Both PRs wait for Paul. The website one changes how the site is deployed, which is exactly the diff the PR rule exists to put in front of a person. |
| + |
| +--- |
| + |
| +## Self-Review |
| + |
| +**Spec coverage.** Corpus location, Task 8. Eleventy pipeline and the three templates, Task 9. Skin, Task 10. Sync script, Task 9. `docsmd.njk` raw-markdown output, Task 9. Nav gate, Task 2. Link and fragment gate, Task 3. Anchor uniqueness, Task 3. Shared fixture, Tasks 4 and 11. Fence languages, Task 5. Command-documented gate, Task 6. Fenced-invocation gate, Task 7. Ship runbook, Task 11. No search, no versioning, no console API reference — all absent by design. |
| + |
| +Two spec items are deliberately **not** in this plan and belong to the next two: the remaining thirteen carlosframework.com pages, and everything Carloku. The five-line CI workflow the spec leaves as a separate decision is also not here. |
| + |
| +**Placeholders.** One remains and is marked: `<app>` and `<channel>` in the website ship runbook, Task 11 Step 3, which instructs the implementer to read the existing runbook rather than guess. The PR body in Task 12 Step 4 carries `#<N>` for the platform PR number, which does not exist until Step 3 runs. |
| + |
| +**Type consistency.** `Load`, `Site`, `Page`, `Heading`, `Link`, `Fence`, `Anchor`, `MarkdownFiles` defined in Task 1 and used unchanged in 2–8. `TopLevelCommands(mainFile string) ([]string, error)` defined in Task 6, called in 6 and 7. `root` defined in Task 2, used in 3–8. `testCorpus` defined in Task 1, used in 3 and 5. `hasAnchor` defined in Task 3, used only there. `stringLit` and `isCommandWord` defined once, in `cliscan.go`. `knownFenceLangs` defined in Task 5, not used elsewhere — noted in that task's Interfaces block so nobody looks for a consumer. |
diff --git a/docs/superpowers/specs/2026-08-24-docs-sites-design.md b/docs/superpowers/specs/2026-08-24-docs-sites-design.md| new file mode 100644 |
| index 000000000..1544d4d66 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-24-docs-sites-design.md |
| @@ -0,0 +1,248 @@ |
| +# Two docs sites: carlosframework.com/docs and carloku.com/docs |
| + |
| +Date: 2026-08-24 |
| +Status: approved design, not implemented |
| + |
| +CARLOS has no documentation. There is a CLI with thirty-odd commands, a |
| +console, a bucket format, a channel ladder and a self-hosting story, and the |
| +only way to learn any of it is to read `main.go`'s usage text, ask an agent, or |
| +find the one paragraph in a spec that mentions it. This builds the two docs |
| +sites that fix that, and the CI gate that stops them going stale. |
| + |
| +Two sites, because there are two audiences and they want different things. A |
| +Carloku customer wants to know how to attach a domain. Someone evaluating |
| +CARLOS wants to know what a bucket-as-control-plane actually means before they |
| +trust it with anything. |
| + |
| +## The split |
| + |
| +`carloku.com/docs` teaches the jobs a customer does — deploy, domains, config, |
| +storage, logs, the console, their account. It is complete enough that a paying |
| +customer never has to leave the site they bought from to do ordinary work. |
| + |
| +`carlosframework.com/docs` is the platform itself — how it works, how to run |
| +your own, and the full CLI reference. Carloku links out to it for reference and |
| +internals, and for nothing else. |
| + |
| +The overlap is real and the boundary is the reader's intent, not the subject. |
| +"Attach a custom domain to your app" is a Carloku page. "How the edge resolves |
| +a hostname to a route" is a carlosframework.com page. Both mention domains. |
| + |
| +## Where things live |
| + |
| +``` |
| +carlosframework/platform |
| + docs/site/**.md, docs/site/nav.json the OSS corpus |
| + internal/docsite/ parser + the gates, Go tests |
| + |
| +carlosframework/website carlosframework.com |
| + src/docs/**.md vendored from platform, never edited here |
| + src/_data/docsnav.json vendored |
| + src/docs.njk, docs-index.njk, docsmd.njk, _includes/docs.njk |
| + hack/sync-docs.mjs, check-docs.mjs, check-anchors.mjs |
| + _site/ gitignored, shipped |
| + |
| +carlosframework/carloku carloku.com |
| + src/docs/**.md, src/_data/docsnav.json the Carloku corpus, authored here |
| + (same layouts, same hack/ scripts minus sync-docs) |
| + _site/ gitignored, shipped |
| +``` |
| + |
| +The OSS corpus lives in the platform repo so that CI can hold it to the code: |
| +a PR that adds `carlos foo` fails until `foo` appears in the CLI reference. |
| +That gate is the single most valuable thing in this design, and it only works |
| +if the markdown and the code change in the same pull request. |
| + |
| +The Carloku corpus lives in the carloku repo. It is product copy about a hosted |
| +service — sign-in, the console, limits, support — and it has no Go code to be |
| +held against. It gets the JavaScript half of the gates instead. |
| + |
| +## The pipeline |
| + |
| +Eleventy with `markdown-it` and `markdown-it-anchor`, copied from |
| +`carlosframework/rastrillo-website`, which has run this exact pipeline since |
| +rastrillo.org's docs landed. Three page templates and one layout, all of which |
| +already exist there and need skinning rather than writing: |
| + |
| +`docs.njk` paginates the corpus, one output page per markdown file, at |
| +`/docs/<slug>/`. `docs-index.njk` renders `/docs/` — the index page's own prose |
| +followed by a card per nav entry, showing its label and blurb. `docsmd.njk` |
| +publishes each page's raw markdown at `/docs/<slug>.md`, which costs nothing |
| +and is the version an agent should fetch. |
| + |
| +`_includes/docs.njk` is the shell: breadcrumb, sidebar of sections and pages, |
| +and a sub-nav of the current page's `##` headings. The sidebar is the whole |
| +navigation model — there is no search. Amadan ships client-side search for its |
| +docs; that costs served JavaScript, which both of these sites refuse, and |
| +fourteen pages with a visible sidebar do not need it. |
| + |
| +Each site gets its own `docs.css` alongside its existing `site.css`. |
| +carlosframework.com is Neo-Grotesque, platform blue, hairline rules, `--prose: |
| +40rem`. Carloku is Geist, its own accent, its own header. Same HTML, two skins; |
| +they should look like siblings and not like the same page. |
| + |
| +`hack/sync-docs.mjs` vendors the platform corpus into the website repo and |
| +records the sha it came from, so the site build never needs a Go checkout |
| +present and never fails because someone's platform clone is missing. Run it |
| +with `--check` to find out whether the vendored copy is behind. |
| + |
| +### Deploying |
| + |
| +Both sites gain a build step, so neither may ship its repo tree again. The |
| +sequence becomes the one rastrillo-website already documents: export a clean |
| +`git archive`, `npm ci && npm run check`, then `carlos ship -kind static` the |
| +`_site/` directory and promote it. `_site/` is gitignored. Both repos' AGENTS.md |
| +get the runbook, and the old "ship the tree" wording goes. |
| + |
| +This is the one genuinely disruptive part of the design. A person who has |
| +shipped carlosframework.com by muscle memory will ship a tree without a `/docs` |
| +directory in it and take the docs off the internet. The runbook change is not |
| +optional, and the ship command should be copied from AGENTS.md rather than |
| +recalled. |
| + |
| +## The gates |
| + |
| +Go, in `internal/docsite`, over the platform corpus: |
| + |
| +- **Nav and files agree both ways.** A nav entry with no file, or a file no nav |
| + entry reaches, fails. |
| +- **Internal links resolve.** Every `/docs/...` link in the corpus names a page |
| + that exists, and every `#fragment` names a heading that exists on it. |
| +- **Anchors are unique** within a page, and the Go slug rule matches |
| + `markdown-it-anchor`'s configured `slugify`. The fixture is a JSON list of |
| + heading-to-anchor pairs committed in the platform repo next to the corpus and |
| + vendored with it, so both sides assert against the same file rather than |
| + against each other's implementation. Both are lowercase alphanumerics with |
| + everything else a separator, because the library's default URI-encodes |
| + punctuation and would anchor a heading naming `carlos ship -app` differently |
| + on each side. |
| +- **Fence languages are declared.** No bare triple-backticks. |
| +- **Every top-level command is documented.** The list comes from `main.go`'s |
| + dispatch switch, read as a Go AST — not from its usage text. Gating against |
| + the usage prose would only prove the docs match a second copy of the docs; |
| + the switch statement is the CLI. Each case must appear in the CLI reference |
| + page. Flags are deliberately not gated: rastrillo can, because its CLI is one |
| + small package, but CARLOS declares flags across forty files in the root |
| + package and the list would be hundreds long. |
| +- **Every `carlos` invocation in a fence names a real command.** This is the |
| + gate rastrillo does not have and CARLOS needs: a renamed verb currently |
| + breaks nothing until a reader types it. |
| + |
| +JavaScript, in both site repos, run by `npm run check`: Eleventy builds, then |
| +`check-docs.mjs` checks what the Go gate cannot — that rendering actually |
| +produced the pages and the ids those links point at, and that the renderer's |
| +`slugify` agrees with `docsite.Anchor` on the shared fixture. A link can be |
| +correct in the source and still 404 in a browser if the two disagree about how |
| +a heading becomes an anchor. For carloku this is the only gate, so it carries |
| +the nav and link checks on its own as well. |
| + |
| +Neither site repo has CI today and Actions minutes have been tight, so |
| +`npm run check` is a local step in the ship runbook. Adding a five-line |
| +workflow to each is cheap and I would rather have it, but it is a separate |
| +decision and the runbook does not depend on it. |
| + |
| +## carlosframework.com/docs |
| + |
| +**Start here** — what CARLOS is: one binary, a bucket and DNS, and what |
| +"bucket as control plane" buys you · installing the CLI: brew, apt, the direct |
| +download, and the Windows zip · your first deploy, end to end, against |
| +whichever CARLOS deployment you have access to. |
| + |
| +**How it works** — the bucket as control plane: what is under `apps/`, |
| +`control/` and `releases/`, and how a serial bump reaches a box in seconds · |
| +releases and channels: immutable artifacts, the `canary → edge → beta → stable` |
| +ladder, what promoting does, what rollback does, and where the semver tag gets |
| +cut · routes and instances: what each one is, which fields the instance record |
| +owns once a route is `Provisioned`, and why `carlos route -channel` refuses |
| +them · sleeping and waking: hibernation, wake on demand, and what a visitor |
| +sees during one · the app contract: what a release must do to be servable, |
| +what `carlos vet` checks, and how static, exec and unit kinds differ. |
| + |
| +**Run your own** — self-hosting: bootstrap a box, the bucket, DNS, certificates |
| +and the console · fleets: account-owned boxes that dial a console, and the |
| +token rules that come with them · signing keys: the root key and scoped keys, |
| +and why the key never touches a terminal · operations: litestream, |
| +restore-verify, `carlos status -live`, and upgrading the platform binary. |
| + |
| +**Reference** — the CLI: every command grouped by role, generated against |
| +`main.go`'s usage and held there by the gate · bucket layout: the on-disk |
| +shape, for anyone writing a tool against it. |
| + |
| +Fourteen pages plus the index. Two I considered and cut: a contributing guide, which the repo |
| +does not want yet, and an architecture-decisions page, which is what |
| +`docs/superpowers/specs/` already is. |
| + |
| +## carloku.com/docs |
| + |
| +**Start here** — the index, pointing at the existing `/get-started` rather than |
| +restating it. `/get-started` stays where it is; it does marketing work as much |
| +as documentation work, and it is current. |
| + |
| +**Running your app** — deploying: `ship`, `promote`, `deploy`, and what "live" |
| +means · channels: how a release reaches production and how to put it back · |
| +custom domains: attaching a hostname, the DNS you add, and how the certificate |
| +happens · env and secrets: the two stores, and which one a value belongs in · |
| +object storage: creating a store, granting access, rotating · logs: the merged |
| +timeline, and following it live · restarting and sleeping: cycling an app, and |
| +what hibernation means for a customer. |
| + |
| +**Your account** — the console: what each page shows · members and service |
| +credentials: who can do what, and credentials a server holds rather than a |
| +person · limits and getting help: the ship rate limit, and where to ask. |
| + |
| +Ten pages plus the index. No pricing language anywhere — billing does not |
| +exist yet, the site says nothing about it, and #226 is unmerged. |
| + |
| +## Voice |
| + |
| +Both corpora are written against the `humane-docs` skill, and the revision pass |
| +it describes is a required step, not a nicety. |
| + |
| +Worth saying plainly, because the obvious move is to imitate it: rastrillo.org |
| +is the structural model and not the prose model. Its nav blurbs are a catalogue |
| +of what that skill tells you to cut — "its two callbacks", "the three |
| +middlewares", "the six refusals", "the one seam every owned query goes |
| +through", "— and why there is no Down". Every one of those is a sentence shape |
| +repeated until it stops carrying meaning. Take the sidebar and the card index; |
| +write the sentences fresh. |
| + |
| +Each page opens with what the thing is and why you would care, in plain |
| +sentences, then the common case as one runnable example. Configuration and edge |
| +cases come after, or not at all where `-h` already covers them. |
| + |
| +## What this does not do |
| + |
| +No search. No versioned docs — the platform ships from `main` and the docs |
| +describe what is live. No API reference for the console HTTP API; it is not a |
| +public interface yet and documenting it would imply a stability promise nobody |
| +has made. No generated CLI pages: the reference page is written by hand and |
| +gated against `main.go`, because generated usage dumps are the "exhaustive |
| +option dump" failure mode and read like one. |
| + |
| +## Risks |
| + |
| +**The Carloku corpus has a weaker gate than the platform corpus.** It gets the |
| +JavaScript link and nav checks and nothing that holds it to the console's |
| +behaviour. A console change that renames a page will not fail anything. Accepted: |
| +the alternative is a Go toolchain in a static site repo. |
| + |
| +**Docs describing unmerged work.** Several things a customer would look for are |
| +built but not rolled — `carlos instances set-channel` needs a console roll, |
| +`carlos email test` is broken as shipped (#265). Every page states what is live |
| +on the day it is written, and anything unrolled is left out rather than |
| +described in the future tense. |
| + |
| +**The ship runbook change can take the docs offline** if someone ships a repo |
| +tree out of habit. Covered above; the mitigation is the runbook and nothing |
| +cleverer. |
| + |
| +## Rollout |
| + |
| +1. `internal/docsite` and its gates, against an empty-but-valid corpus. |
| +2. The platform corpus: fifteen pages, written and gated. |
| +3. The website pipeline: layouts, skin, sync script, checks; then the runbook. |
| +4. The Carloku corpus and pipeline, same shape. |
| +5. Ship both, verify by content against the live URLs. |
| + |
| +Each of the three repos lands as its own pull request. The platform PR goes |
| +first, because the website's sync script vendors from it. |
diff --git a/internal/docsite/cli_test.go b/internal/docsite/cli_test.go| new file mode 100644 |
| index 000000000..3a5b7a393 |
| --- /dev/null |
| +++ b/internal/docsite/cli_test.go |
| @@ -0,0 +1,77 @@ |
| +package docsite |
| + |
| +import "testing" |
| + |
| +// mainFile is the CLI's dispatch, relative to this package. |
| +const mainFile = "../../main.go" |
| + |
| +// cliPage is the page that must document all of it. |
| +const cliPage = "cli" |
| + |
| +// TestTopLevelCommandsReadsTheDispatch checks the scanner before the |
| +// gate that depends on it. A scanner that silently returns nothing |
| +// turns the gate below into a test that always passes. |
| +func TestTopLevelCommandsReadsTheDispatch(t *testing.T) { |
| + cmds, err := TopLevelCommands(mainFile) |
| + if err != nil { |
| + t.Fatalf("scan: %v", err) |
| + } |
| + if len(cmds) < 20 { |
| + t.Fatalf("found %d commands (%v) — the scanner is broken, not the docs", len(cmds), cmds) |
| + } |
| + for _, want := range []string{"ship", "promote", "deploy", "domains", "logs", "fleets"} { |
| + if !contains(cmds, want) { |
| + t.Errorf("scanner missed the %q command", want) |
| + } |
| + } |
| + for _, unwanted := range []string{"-h", "--help", ""} { |
| + if contains(cmds, unwanted) { |
| + t.Errorf("scanner kept %q, which is not a command", unwanted) |
| + } |
| + } |
| +} |
| + |
| +func contains(xs []string, s string) bool { |
| + for _, x := range xs { |
| + if x == s { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// TestCLICommandsAreDocumented is the gate. It reads the dispatch |
| +// switch rather than the usage text on purpose: usage text is prose |
| +// someone can forget to update, so gating against it would only prove |
| +// the docs match a second copy of the docs. The switch is the CLI. |
| +// |
| +// It asks for a HEADING, not a substring of the body, and that is not |
| +// a stylistic preference. A substring check is defeated by prefixes: |
| +// "route" is a prefix of "routes", so the literal text "carlos routes" |
| +// anywhere on the page satisfies a Contains check for "route" — the |
| +// whole `### carlos route` section can be deleted with every test |
| +// still green (a reviewer did exactly that, 2026-08-25). The same |
| +// check also counts a mention inside another command's example fence, |
| +// or in a link, as documentation. A heading is the thing a reader can |
| +// actually land on, and `#carlos-route` and `#carlos-routes` are |
| +// distinct anchors. Do not "simplify" this back to Contains. |
| +func TestCLICommandsAreDocumented(t *testing.T) { |
| + site, err := Load(root) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + page, ok := site.BySlug[cliPage] |
| + if !ok { |
| + t.Fatalf("no %s page — every command has to be documented somewhere", cliPage) |
| + } |
| + cmds, err := TopLevelCommands(mainFile) |
| + if err != nil { |
| + t.Fatalf("scan: %v", err) |
| + } |
| + for _, cmd := range cmds { |
| + if !hasAnchor(page, Anchor("carlos "+cmd)) { |
| + t.Errorf("%s.md has no heading for %q (expected a heading anchoring #%s)", |
| + cliPage, cmd, Anchor("carlos "+cmd)) |
| + } |
| + } |
| +} |
diff --git a/internal/docsite/cliscan.go b/internal/docsite/cliscan.go| new file mode 100644 |
| index 000000000..56fe105e5 |
| --- /dev/null |
| +++ b/internal/docsite/cliscan.go |
| @@ -0,0 +1,92 @@ |
| +package docsite |
| + |
| +import ( |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "sort" |
| + "strconv" |
| +) |
| + |
| +// TopLevelCommands returns every subcommand main.go dispatches on. |
| +// |
| +// It reads the switch statement rather than the usage text because the |
| +// switch is the CLI and the usage text is prose about it. A gate |
| +// against the prose would prove only that the docs agree with a second |
| +// copy of the docs, which is exactly the drift worth catching. |
| +// |
| +// Flags are deliberately not scanned. rastrillo's equivalent gate does |
| +// scan them, but its CLI is one small package; CARLOS declares flags |
| +// across the whole root package and the list would run to hundreds, |
| +// most of them things no reader ever types. |
| +func TopLevelCommands(mainFile string) ([]string, error) { |
| + fset := token.NewFileSet() |
| + file, err := parser.ParseFile(fset, mainFile, nil, parser.SkipObjectResolution) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + seen := map[string]bool{} |
| + var out []string |
| + ast.Inspect(file, func(n ast.Node) bool { |
| + sw, ok := n.(*ast.SwitchStmt) |
| + if !ok { |
| + return true |
| + } |
| + // Only the dispatch switch — the one on `cmd`. main.go has a |
| + // single string switch today, but a second one added later |
| + // would feed non-commands into the gate, and the obvious way |
| + // to make that gate pass again is to document things that are |
| + // not commands. |
| + tag, ok := sw.Tag.(*ast.Ident) |
| + if !ok || tag.Name != "cmd" { |
| + return true |
| + } |
| + for _, stmt := range sw.Body.List { |
| + clause, ok := stmt.(*ast.CaseClause) |
| + if !ok { |
| + continue |
| + } |
| + for _, expr := range clause.List { |
| + s, ok := stringLit(expr) |
| + if !ok || !isCommandWord(s) { |
| + continue |
| + } |
| + if !seen[s] { |
| + seen[s] = true |
| + out = append(out, s) |
| + } |
| + } |
| + } |
| + return true |
| + }) |
| + sort.Strings(out) |
| + return out, nil |
| +} |
| + |
| +// isCommandWord filters case arms down to real commands: flag aliases |
| +// like "-h" are documented as part of the command they belong to, and a |
| +// case arm is only a command if it looks like one. |
| +func isCommandWord(s string) bool { |
| + if s == "" { |
| + return false |
| + } |
| + for _, r := range s { |
| + if (r < 'a' || r > 'z') && r != '-' { |
| + return false |
| + } |
| + } |
| + return s[0] != '-' |
| +} |
| + |
| +func stringLit(e ast.Expr) (string, bool) { |
| + lit, ok := e.(*ast.BasicLit) |
| + if !ok || lit.Kind != token.STRING { |
| + return "", false |
| + } |
| + s, err := strconv.Unquote(lit.Value) |
| + if err != nil { |
| + return "", false |
| + } |
| + return s, true |
| +} |
diff --git a/internal/docsite/docsite.go b/internal/docsite/docsite.go| new file mode 100644 |
| index 000000000..f16ae1e53 |
| --- /dev/null |
| +++ b/internal/docsite/docsite.go |
| @@ -0,0 +1,331 @@ |
| +// Package docsite loads the documentation corpus under docs/site and |
| +// exposes it in the shape the gates need. |
| +// |
| +// The corpus is markdown plus one nav.json holding the table of |
| +// contents. Everything here is parsing: the tests beside this file are |
| +// the point, and they are what stop the docs and the code drifting |
| +// apart. Nothing in this package ships in a binary — docs/site is |
| +// rendered by the website, which vendors a copy. This package exists so |
| +// CARLOS's own CI can fail a pull request whose docs no longer describe |
| +// the platform. |
| +// |
| +// The markdown parsing is deliberately small and deliberately |
| +// fence-aware. It is not a markdown implementation and must never grow |
| +// into one: it finds ATX headings, fenced code blocks and internal |
| +// links, and it knows that a "#" inside a shell fence is a comment |
| +// rather than a heading. Anything more belongs to the renderer. |
| +package docsite |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "io/fs" |
| + "os" |
| + "path" |
| + "path/filepath" |
| + "strings" |
| +) |
| + |
| +// Heading is one ATX heading, with the anchor the renderer will give |
| +// it. Anchor equivalence with the website's markdown-it-anchor |
| +// configuration is pinned from the website side; see Anchor. |
| +type Heading struct { |
| + Level int |
| + Text string |
| + Anchor string |
| +} |
| + |
| +// Link is one internal /docs link found in a page's source, with the |
| +// line it sits on so a failure can name it. |
| +type Link struct { |
| + Target string // page slug, e.g. "migrations" or "reference/db" |
| + Fragment string // heading anchor, or "" for a whole-page link |
| + Line int |
| +} |
| + |
| +// Fence is one fenced code block and the language on its info string. |
| +type Fence struct { |
| + Lang string |
| + Body string |
| + Line int |
| +} |
| + |
| +// Page is one documentation page: its nav metadata, its source, and |
| +// everything the gates read out of it. |
| +type Page struct { |
| + Slug string // "migrations", "reference/db" |
| + Label string // nav label, usually shorter than Title |
| + Blurb string // one line, rendered on the /docs index |
| + Title string // the page's own "# " heading |
| + Path string // path on disk |
| + |
| + Body string |
| + Headings []Heading |
| + Links []Link |
| + Fences []Fence |
| +} |
| + |
| +// Section is one group in the sidebar. |
| +type Section struct { |
| + Title string |
| + Pages []*Page |
| +} |
| + |
| +// Site is the whole corpus, in reading order. |
| +type Site struct { |
| + // Index is docs/site/index.md, the /docs overview. It sits outside |
| + // Sections because it is the page the sidebar's sections are listed |
| + // on: an entry for itself would render as a card linking to the page |
| + // you are already reading. |
| + Index *Page |
| + Sections []Section |
| + BySlug map[string]*Page |
| +} |
| + |
| +// Pages returns every page in reading order, the index first. |
| +func (s *Site) Pages() []*Page { |
| + var out []*Page |
| + if s.Index != nil { |
| + out = append(out, s.Index) |
| + } |
| + for _, sec := range s.Sections { |
| + out = append(out, sec.Pages...) |
| + } |
| + return out |
| +} |
| + |
| +// navFile is nav.json's shape. |
| +type navFile struct { |
| + Sections []struct { |
| + Title string `json:"title"` |
| + Pages []struct { |
| + Slug string `json:"slug"` |
| + Label string `json:"label"` |
| + Blurb string `json:"blurb"` |
| + } `json:"pages"` |
| + } `json:"sections"` |
| +} |
| + |
| +// Load reads nav.json and every page it names. A page named by nav.json |
| +// that does not exist is an error here; a page that exists and is not |
| +// named is not — that asymmetry is deliberate, because the nav test |
| +// reports it with a better message than a load failure could. |
| +func Load(root string) (*Site, error) { |
| + raw, err := os.ReadFile(filepath.Join(root, "nav.json")) |
| + if err != nil { |
| + return nil, fmt.Errorf("read nav.json: %w", err) |
| + } |
| + var nav navFile |
| + if err := json.Unmarshal(raw, &nav); err != nil { |
| + return nil, fmt.Errorf("parse nav.json: %w", err) |
| + } |
| + |
| + site := &Site{BySlug: map[string]*Page{}} |
| + index, err := loadPage(root, "index", "Documentation", "") |
| + if err != nil { |
| + return nil, err |
| + } |
| + site.Index = index |
| + site.BySlug[index.Slug] = index |
| + |
| + for _, sec := range nav.Sections { |
| + out := Section{Title: sec.Title} |
| + for _, entry := range sec.Pages { |
| + p, err := loadPage(root, entry.Slug, entry.Label, entry.Blurb) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out.Pages = append(out.Pages, p) |
| + site.BySlug[p.Slug] = p |
| + } |
| + site.Sections = append(site.Sections, out) |
| + } |
| + return site, nil |
| +} |
| + |
| +// loadPage reads one page off disk and parses it. |
| +func loadPage(root, slug, label, blurb string) (*Page, error) { |
| + p := &Page{ |
| + Slug: slug, |
| + Label: label, |
| + Blurb: blurb, |
| + Path: filepath.Join(root, filepath.FromSlash(slug)+".md"), |
| + } |
| + body, err := os.ReadFile(p.Path) |
| + if err != nil { |
| + return nil, fmt.Errorf("nav.json names %q: %w", slug, err) |
| + } |
| + p.Body = string(body) |
| + parsePage(p) |
| + return p, nil |
| +} |
| + |
| +// parsePage fills in everything derived from a page's source. |
| +func parsePage(p *Page) { |
| + lines := strings.Split(p.Body, "\n") |
| + |
| + inFence := false |
| + fenceMark := "" |
| + var fence Fence |
| + |
| + for i, line := range lines { |
| + lineNo := i + 1 |
| + trimmed := strings.TrimSpace(line) |
| + |
| + // Fence open/close. The marker must match in kind and be at |
| + // least as long as the opener, so a ``` inside a ~~~~ block is |
| + // content rather than a close. |
| + if mark, info, ok := fenceDelim(trimmed); ok { |
| + switch { |
| + case !inFence: |
| + inFence, fenceMark = true, mark |
| + fence = Fence{Lang: info, Line: lineNo} |
| + continue |
| + case sameFence(mark, fenceMark): |
| + inFence = false |
| + p.Fences = append(p.Fences, fence) |
| + continue |
| + } |
| + } |
| + if inFence { |
| + fence.Body += line + "\n" |
| + continue |
| + } |
| + |
| + if level, text, ok := heading(trimmed); ok { |
| + h := Heading{Level: level, Text: text, Anchor: Anchor(text)} |
| + p.Headings = append(p.Headings, h) |
| + if level == 1 && p.Title == "" { |
| + p.Title = text |
| + } |
| + } |
| + |
| + for _, l := range docsLinks(line) { |
| + l.Line = lineNo |
| + p.Links = append(p.Links, l) |
| + } |
| + } |
| +} |
| + |
| +// fenceDelim reports whether a trimmed line opens or closes a fence, |
| +// returning the marker run and the info string. |
| +func fenceDelim(line string) (mark, info string, ok bool) { |
| + var r byte |
| + switch { |
| + case strings.HasPrefix(line, "```"): |
| + r = '`' |
| + case strings.HasPrefix(line, "~~~"): |
| + r = '~' |
| + default: |
| + return "", "", false |
| + } |
| + n := 0 |
| + for n < len(line) && line[n] == r { |
| + n++ |
| + } |
| + return line[:n], strings.TrimSpace(line[n:]), true |
| +} |
| + |
| +// sameFence reports whether a closing marker can close an opener: same |
| +// character, and at least as long. |
| +func sameFence(closing, opening string) bool { |
| + if closing == "" || opening == "" || closing[0] != opening[0] { |
| + return false |
| + } |
| + return len(closing) >= len(opening) |
| +} |
| + |
| +// heading parses an ATX heading. |
| +func heading(line string) (level int, text string, ok bool) { |
| + n := 0 |
| + for n < len(line) && line[n] == '#' { |
| + n++ |
| + } |
| + if n == 0 || n > 6 || n >= len(line) || line[n] != ' ' { |
| + return 0, "", false |
| + } |
| + return n, strings.TrimSpace(strings.TrimRight(line[n+1:], " #")), true |
| +} |
| + |
| +// docsLinks finds every markdown link whose target starts with /docs. |
| +func docsLinks(line string) []Link { |
| + var out []Link |
| + rest := line |
| + for { |
| + i := strings.Index(rest, "](/docs") |
| + if i < 0 { |
| + return out |
| + } |
| + rest = rest[i+2:] |
| + end := strings.IndexByte(rest, ')') |
| + if end < 0 { |
| + return out |
| + } |
| + out = append(out, parseDocsHref(rest[:end])) |
| + rest = rest[end:] |
| + } |
| +} |
| + |
| +// parseDocsHref turns "/docs/reference/db#open" into its slug and |
| +// fragment. A bare "/docs" is the index, whose slug is "index". |
| +func parseDocsHref(href string) Link { |
| + var l Link |
| + if i := strings.IndexByte(href, '#'); i >= 0 { |
| + l.Fragment, href = href[i+1:], href[:i] |
| + } |
| + href = strings.TrimSuffix(href, "/") |
| + slug := strings.TrimPrefix(href, "/docs") |
| + slug = strings.TrimPrefix(slug, "/") |
| + if slug == "" { |
| + slug = "index" |
| + } |
| + l.Target = path.Clean(slug) |
| + return l |
| +} |
| + |
| +// Anchor is the heading anchor the rendered page will carry. |
| +// |
| +// The website configures markdown-it-anchor with a slugify that matches |
| +// this function, and both sides assert against docs/site/anchors.json. |
| +// Both are deliberately plain — lowercase alphanumerics, everything else |
| +// a separator — because markdown-it-anchor's own default URI-encodes |
| +// punctuation, which would anchor a heading naming `carlos ship -app` |
| +// differently on each side. |
| +func Anchor(text string) string { |
| + var b strings.Builder |
| + pendingSep := false |
| + for _, r := range strings.ToLower(text) { |
| + switch { |
| + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': |
| + if pendingSep && b.Len() > 0 { |
| + b.WriteByte('-') |
| + } |
| + pendingSep = false |
| + b.WriteRune(r) |
| + default: |
| + pendingSep = true |
| + } |
| + } |
| + return b.String() |
| +} |
| + |
| +// MarkdownFiles walks root and returns every page slug on disk, with |
| +// "reference/" prefixes preserved and ".md" stripped. |
| +func MarkdownFiles(root string) ([]string, error) { |
| + var out []string |
| + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if d.IsDir() || !strings.HasSuffix(p, ".md") { |
| + return nil |
| + } |
| + rel, err := filepath.Rel(root, p) |
| + if err != nil { |
| + return err |
| + } |
| + out = append(out, strings.TrimSuffix(filepath.ToSlash(rel), ".md")) |
| + return nil |
| + }) |
| + return out, err |
| +} |
diff --git a/internal/docsite/fences_test.go b/internal/docsite/fences_test.go| new file mode 100644 |
| index 000000000..f8a4f1be3 |
| --- /dev/null |
| +++ b/internal/docsite/fences_test.go |
| @@ -0,0 +1,187 @@ |
| +package docsite |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// knownFenceLangs is what a fence in this corpus may declare. Adding a |
| +// language here is a deliberate act: an undeclared or unknown fence is |
| +// invisible to every content gate that reads fences, so it is a way to |
| +// smuggle a wrong command past review. |
| +var knownFenceLangs = map[string]bool{ |
| + "sh": true, "bash": true, "console": true, "json": true, |
| + "go": true, "yaml": true, "toml": true, "ini": true, |
| + "text": true, "http": true, |
| +} |
| + |
| +func TestFenceLanguagesAreDeclared(t *testing.T) { |
| + site, err := Load(root) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + for _, p := range site.Pages() { |
| + for _, f := range p.Fences { |
| + if f.Lang == "" { |
| + t.Errorf("%s.md:%d: fence declares no language", p.Slug, f.Line) |
| + continue |
| + } |
| + if !knownFenceLangs[f.Lang] { |
| + t.Errorf("%s.md:%d: unknown fence language %q — add it to knownFenceLangs if it is real", |
| + p.Slug, f.Line, f.Lang) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestTestdataFencesDeclareLanguages runs the same rule against the |
| +// testdata corpus, so the gate above is proven to work on a corpus that |
| +// exists before docs/site does. |
| +func TestTestdataFencesDeclareLanguages(t *testing.T) { |
| + site, err := Load(testCorpus) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + for _, p := range site.Pages() { |
| + for _, f := range p.Fences { |
| + if f.Lang == "" { |
| + t.Errorf("%s: testdata corpus has an undeclared fence at line %d", p.Slug, f.Line) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestFencedCarlosCommandsAreReal checks the examples, not just the |
| +// prose. A renamed verb leaves every fence containing it copy-pasteable |
| +// and wrong, and nothing else in this package would notice. |
| +func TestFencedCarlosCommandsAreReal(t *testing.T) { |
| + site, err := Load(root) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + cmds, err := TopLevelCommands(mainFile) |
| + if err != nil { |
| + t.Fatalf("scan: %v", err) |
| + } |
| + real := map[string]bool{} |
| + for _, c := range cmds { |
| + real[c] = true |
| + } |
| + |
| + for _, p := range site.Pages() { |
| + for _, f := range p.Fences { |
| + for _, verb := range fencedCarlosVerbs(f.Body) { |
| + if !real[verb] { |
| + t.Errorf("%s.md:%d: example runs `carlos %s`, which is not a command", |
| + p.Slug, f.Line, verb) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// fencedCarlosVerbs returns the word after each "carlos" in a fence |
| +// body. It skips lines where "carlos" is not the command being run — |
| +// a path like /opt/carlos/carlos, or prose inside a comment. |
| +func fencedCarlosVerbs(body string) []string { |
| + var out []string |
| + for _, line := range strings.Split(body, "\n") { |
| + line = strings.TrimSpace(line) |
| + line = strings.TrimPrefix(line, "$ ") |
| + // Treat # as starting a comment only when it is at index 0 or |
| + // preceded by a space or tab. A # with a non-space before it |
| + // (like a URL fragment) is not a comment. |
| + if i := strings.Index(line, "#"); i >= 0 && (i == 0 || line[i-1] == ' ' || line[i-1] == '\t') { |
| + line = strings.TrimSpace(line[:i]) |
| + } |
| + fields := strings.Fields(line) |
| + for i, f := range fields { |
| + // Bare "carlos", or the binary called by an absolute path — |
| + // runbooks on a box invoke /opt/carlos/carlos, and those |
| + // examples go stale the same way. |
| + if f != "carlos" && !strings.HasSuffix(f, "/carlos") { |
| + continue |
| + } |
| + if i+1 >= len(fields) { |
| + continue |
| + } |
| + next := fields[i+1] |
| + if strings.HasPrefix(next, "-") { |
| + continue |
| + } |
| + if next == "\\" { |
| + continue |
| + } |
| + out = append(out, next) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// TestFencedCarlosVerbsIgnoresNoise pins the parsing, because a |
| +// permissive extractor turns the gate above into one that finds |
| +// nothing. |
| +func TestFencedCarlosVerbsIgnoresNoise(t *testing.T) { |
| + body := strings.Join([]string{ |
| + "carlos ship -app hello ./dist", |
| + "$ carlos promote -app hello abc123 edge", |
| + "/opt/carlos/carlos adopt", |
| + "# run carlos deploy afterwards", |
| + "carlos -h", |
| + "echo carlos", |
| + }, "\n") |
| + got := fencedCarlosVerbs(body) |
| + want := []string{"ship", "promote", "adopt"} |
| + if len(got) != len(want) { |
| + t.Fatalf("got %v, want %v", got, want) |
| + } |
| + for i := range want { |
| + if got[i] != want[i] { |
| + t.Fatalf("got %v, want %v", got, want) |
| + } |
| + } |
| +} |
| + |
| +// TestFencedCarlosVerbsCompoundLines verifies the extractor finds |
| +// multiple carlos invocations on a single line (Finding 1 fix). |
| +func TestFencedCarlosVerbsCompoundLines(t *testing.T) { |
| + body := "carlos ship -app hello ./dist && carlos promote -app hello abc123 edge" |
| + got := fencedCarlosVerbs(body) |
| + want := []string{"ship", "promote"} |
| + if len(got) != len(want) { |
| + t.Fatalf("got %v, want %v", got, want) |
| + } |
| + for i := range want { |
| + if got[i] != want[i] { |
| + t.Fatalf("got %v, want %v", got, want) |
| + } |
| + } |
| +} |
| + |
| +// TestFencedCarlosVerbsURLFragment verifies the extractor does not |
| +// mistake URL fragments for comments (Finding 2 fix). |
| +func TestFencedCarlosVerbsURLFragment(t *testing.T) { |
| + body := "curl -s https://carlosframework.com/docs/#cli && carlos ship -app website ./dist" |
| + got := fencedCarlosVerbs(body) |
| + want := []string{"ship"} |
| + if len(got) != len(want) { |
| + t.Fatalf("got %v, want %v", got, want) |
| + } |
| + for i := range want { |
| + if got[i] != want[i] { |
| + t.Fatalf("got %v, want %v", got, want) |
| + } |
| + } |
| +} |
| + |
| +// TestFencedCarlosVerbsBackslashContinuation verifies the extractor |
| +// does not extract backslash as a verb in line-continued invocations |
| +// (Finding 3 fix). |
| +func TestFencedCarlosVerbsBackslashContinuation(t *testing.T) { |
| + body := "carlos \\" |
| + got := fencedCarlosVerbs(body) |
| + want := []string{} |
| + if len(got) != len(want) { |
| + t.Fatalf("got %v, want %v", got, want) |
| + } |
| +} |
diff --git a/internal/docsite/links_test.go b/internal/docsite/links_test.go| new file mode 100644 |
| index 000000000..0bb99e500 |
| --- /dev/null |
| +++ b/internal/docsite/links_test.go |
| @@ -0,0 +1,125 @@ |
| +package docsite |
| + |
| +import ( |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +// TestInternalLinksResolve stops a docs link 404ing. |
| +// |
| +// It checks the fragment as well as the page, because a link to a |
| +// renamed heading is the failure that actually happens: the page still |
| +// exists, the link still looks right in review, and the reader lands at |
| +// the top of a long page wondering what they missed. |
| +func TestInternalLinksResolve(t *testing.T) { |
| + site, err := Load(root) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + for _, p := range site.Pages() { |
| + for _, l := range p.Links { |
| + target, ok := site.BySlug[l.Target] |
| + if !ok { |
| + t.Errorf("%s.md:%d: links to /docs/%s, which is not a page", |
| + p.Slug, l.Line, l.Target) |
| + continue |
| + } |
| + if l.Fragment == "" { |
| + continue |
| + } |
| + if !hasAnchor(target, l.Fragment) { |
| + t.Errorf("%s.md:%d: links to /docs/%s#%s, but %s has no heading anchoring %q", |
| + p.Slug, l.Line, l.Target, l.Fragment, l.Target, l.Fragment) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func hasAnchor(p *Page, anchor string) bool { |
| + for _, h := range p.Headings { |
| + if h.Anchor == anchor { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// TestLinkCheckCatchesADeadFragment proves the fragment half fires. |
| +func TestLinkCheckCatchesADeadFragment(t *testing.T) { |
| + site, err := Load(testCorpus) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + cli := site.BySlug["reference/cli"] |
| + if cli == nil { |
| + t.Fatal("no reference/cli page") |
| + } |
| + if !hasAnchor(cli, "carlos-ship") { |
| + t.Error("expected reference/cli to anchor carlos-ship") |
| + } |
| + if hasAnchor(cli, "carlos-teleport") { |
| + t.Error("hasAnchor invented an anchor that is not there") |
| + } |
| +} |
| + |
| +// TestAnchorsAreUnique matters because a duplicate anchor makes a link |
| +// to it ambiguous, and the renderer resolves that by silently suffixing |
| +// the second one. The link then goes to the wrong section rather than |
| +// nowhere, which is harder to notice. |
| +func TestAnchorsAreUnique(t *testing.T) { |
| + site, err := Load(root) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + for _, p := range site.Pages() { |
| + seen := map[string]string{} |
| + for _, h := range p.Headings { |
| + if h.Level == 1 { |
| + continue |
| + } |
| + if prev, dup := seen[h.Anchor]; dup { |
| + t.Errorf("%s.md: headings %q and %q both anchor to #%s", |
| + p.Slug, prev, h.Text, h.Anchor) |
| + } |
| + seen[h.Anchor] = h.Text |
| + } |
| + } |
| +} |
| + |
| +// TestAnchorMatchesFixture pins the anchor rule itself. The website's |
| +// slugify reads the same file, so the two implementations assert |
| +// against a shared fixture rather than against each other. |
| +// |
| +// One direction of that sharing is not symmetric: the website's |
| +// hack/sync-docs.mjs injects a "//" notice key into its vendored copy, |
| +// saying the file came from here and must not be edited there, and its |
| +// checker skips that key. This file must never carry one — Anchor("//") |
| +// is "", so a "//" key here fails the test below rather than being |
| +// ignored. |
| +// |
| +// The non-ASCII cases are the ones worth keeping: Go's strings.ToLower |
| +// and JavaScript's String.prototype.toLowerCase do not agree on every |
| +// code point, and both implementations DROP non-ASCII runes rather than |
| +// transliterating them — "Montréal" anchors as montr-al in both. Pin |
| +// anything with an em dash or an accented letter here before it reaches |
| +// a heading. |
| +func TestAnchorMatchesFixture(t *testing.T) { |
| + raw, err := os.ReadFile(filepath.Join(root, "anchors.json")) |
| + if err != nil { |
| + t.Fatalf("read fixture: %v", err) |
| + } |
| + var cases map[string]string |
| + if err := json.Unmarshal(raw, &cases); err != nil { |
| + t.Fatalf("parse fixture: %v", err) |
| + } |
| + if len(cases) < 5 { |
| + t.Fatalf("fixture has %d cases — too few to pin anything", len(cases)) |
| + } |
| + for in, want := range cases { |
| + if got := Anchor(in); got != want { |
| + t.Errorf("Anchor(%q) = %q, want %q", in, got, want) |
| + } |
| + } |
| +} |
diff --git a/internal/docsite/nav_test.go b/internal/docsite/nav_test.go| new file mode 100644 |
| index 000000000..8f2d5d9b3 |
| --- /dev/null |
| +++ b/internal/docsite/nav_test.go |
| @@ -0,0 +1,80 @@ |
| +package docsite |
| + |
| +import "testing" |
| + |
| +// root is the real corpus, relative to this package. Every gate below |
| +// runs against it. |
| +const root = "../../docs/site" |
| + |
| +// TestNavAndFilesAgreeBothWays keeps the table of contents honest in |
| +// both directions. A page nav.json names must exist — Load fails on |
| +// that one — and a page on disk that no nav entry reaches must not. An |
| +// unreachable page is the worse failure: it looks written, and nobody |
| +// ever reads it. |
| +func TestNavAndFilesAgreeBothWays(t *testing.T) { |
| + site, err := Load(root) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + |
| + seen := map[string]bool{} |
| + for _, p := range site.Pages() { |
| + if seen[p.Slug] { |
| + t.Errorf("%s: appears in nav.json more than once", p.Slug) |
| + } |
| + seen[p.Slug] = true |
| + |
| + if p.Title == "" { |
| + t.Errorf("%s: no `# ` title", p.Slug) |
| + } |
| + if p != site.Index && p.Blurb == "" { |
| + t.Errorf("%s: no blurb in nav.json (it renders on the /docs index)", p.Slug) |
| + } |
| + if p != site.Index && p.Label == "" { |
| + t.Errorf("%s: no label in nav.json", p.Slug) |
| + } |
| + } |
| + |
| + files, err := MarkdownFiles(root) |
| + if err != nil { |
| + t.Fatalf("walk %s: %v", root, err) |
| + } |
| + for _, slug := range files { |
| + if !seen[slug] { |
| + t.Errorf("docs/site/%s.md exists but no nav.json entry reaches it", slug) |
| + } |
| + } |
| +} |
| + |
| +// TestOrphanPagesAreCaught proves the gate above can actually fail. The |
| +// check that never fires is the check nobody notices is broken. |
| +func TestOrphanPagesAreCaught(t *testing.T) { |
| + site, err := Load("testdata/orphan") |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + seen := map[string]bool{} |
| + for _, p := range site.Pages() { |
| + seen[p.Slug] = true |
| + } |
| + files, err := MarkdownFiles("testdata/orphan") |
| + if err != nil { |
| + t.Fatalf("walk: %v", err) |
| + } |
| + var orphans []string |
| + for _, slug := range files { |
| + if !seen[slug] { |
| + orphans = append(orphans, slug) |
| + } |
| + } |
| + if len(orphans) != 1 || orphans[0] != "stranded" { |
| + t.Errorf("orphans = %v, want [stranded]", orphans) |
| + } |
| +} |
| + |
| +// TestNavNamingAMissingPageFails is the other direction. |
| +func TestNavNamingAMissingPageFails(t *testing.T) { |
| + if _, err := Load("testdata/missing"); err == nil { |
| + t.Fatal("Load succeeded on a nav.json naming a page that does not exist") |
| + } |
| +} |
diff --git a/internal/docsite/parse_test.go b/internal/docsite/parse_test.go| new file mode 100644 |
| index 000000000..120b054bf |
| --- /dev/null |
| +++ b/internal/docsite/parse_test.go |
| @@ -0,0 +1,74 @@ |
| +package docsite |
| + |
| +import "testing" |
| + |
| +const testCorpus = "testdata/corpus" |
| + |
| +func TestLoadReadsNavAndPages(t *testing.T) { |
| + site, err := Load(testCorpus) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + if site.Index == nil || site.Index.Title != "Documentation" { |
| + t.Fatalf("index not loaded: %+v", site.Index) |
| + } |
| + if len(site.Sections) != 2 { |
| + t.Fatalf("got %d sections, want 2", len(site.Sections)) |
| + } |
| + if got := site.Sections[1].Pages[0].Slug; got != "reference/cli" { |
| + t.Errorf("nested slug = %q, want reference/cli", got) |
| + } |
| + if got := len(site.Pages()); got != 3 { |
| + t.Errorf("Pages() = %d, want 3 (index + two)", got) |
| + } |
| +} |
| + |
| +func TestParsePageFindsHeadingsLinksAndFences(t *testing.T) { |
| + site, err := Load(testCorpus) |
| + if err != nil { |
| + t.Fatalf("load: %v", err) |
| + } |
| + p := site.BySlug["getting-started"] |
| + if p == nil { |
| + t.Fatal("no getting-started page") |
| + } |
| + if len(p.Headings) != 3 { |
| + t.Fatalf("got %d headings, want 3", len(p.Headings)) |
| + } |
| + if p.Headings[1].Anchor != "install-the-cli" { |
| + t.Errorf("anchor = %q, want install-the-cli", p.Headings[1].Anchor) |
| + } |
| + if len(p.Fences) != 1 || p.Fences[0].Lang != "sh" { |
| + t.Fatalf("fences = %+v, want one sh fence", p.Fences) |
| + } |
| + if len(p.Links) != 1 || p.Links[0].Target != "reference/cli" { |
| + t.Fatalf("links = %+v, want one to reference/cli", p.Links) |
| + } |
| +} |
| + |
| +func TestIndexLinkResolvesToIndexSlug(t *testing.T) { |
| + l := parseDocsHref("/docs") |
| + if l.Target != "index" { |
| + t.Errorf("parseDocsHref(\"/docs\") target = %q, want index", l.Target) |
| + } |
| + l = parseDocsHref("/docs/reference/cli#carlos-ship") |
| + if l.Target != "reference/cli" || l.Fragment != "carlos-ship" { |
| + t.Errorf("got %+v, want reference/cli #carlos-ship", l) |
| + } |
| +} |
| + |
| +func TestMarkdownFilesWalksNested(t *testing.T) { |
| + files, err := MarkdownFiles(testCorpus) |
| + if err != nil { |
| + t.Fatalf("walk: %v", err) |
| + } |
| + want := map[string]bool{"index": true, "getting-started": true, "reference/cli": true} |
| + if len(files) != len(want) { |
| + t.Fatalf("got %v, want %d files", files, len(want)) |
| + } |
| + for _, f := range files { |
| + if !want[f] { |
| + t.Errorf("unexpected file %q", f) |
| + } |
| + } |
| +} |
diff --git a/internal/docsite/testdata/corpus/getting-started.md b/internal/docsite/testdata/corpus/getting-started.md| new file mode 100644 |
| index 000000000..af298324a |
| --- /dev/null |
| +++ b/internal/docsite/testdata/corpus/getting-started.md |
| @@ -0,0 +1,14 @@ |
| +# Getting started |
| + |
| +## Install the CLI |
| + |
| +Run it: |
| + |
| +```sh |
| +carlos auth login |
| +# see [nothing](/docs/does-not-exist) — inside a fence, so not a link |
| +``` |
| + |
| +## Deploy something |
| + |
| +See [the CLI reference](/docs/reference/cli). |
diff --git a/internal/docsite/testdata/corpus/index.md b/internal/docsite/testdata/corpus/index.md| new file mode 100644 |
| index 000000000..42c6d985e |
| --- /dev/null |
| +++ b/internal/docsite/testdata/corpus/index.md |
| @@ -0,0 +1,4 @@ |
| +# Documentation |
| + |
| +Start with [getting started](/docs/getting-started), or jump to |
| +[the CLI](/docs/reference/cli#carlos-ship). |
diff --git a/internal/docsite/testdata/corpus/nav.json b/internal/docsite/testdata/corpus/nav.json| new file mode 100644 |
| index 000000000..96c4844f1 |
| --- /dev/null |
| +++ b/internal/docsite/testdata/corpus/nav.json |
| @@ -0,0 +1,16 @@ |
| +{ |
| + "sections": [ |
| + { |
| + "title": "Start here", |
| + "pages": [ |
| + { "slug": "getting-started", "label": "Getting started", "blurb": "Install it and deploy something." } |
| + ] |
| + }, |
| + { |
| + "title": "Reference", |
| + "pages": [ |
| + { "slug": "reference/cli", "label": "The CLI", "blurb": "Every command." } |
| + ] |
| + } |
| + ] |
| +} |
diff --git a/internal/docsite/testdata/corpus/reference/cli.md b/internal/docsite/testdata/corpus/reference/cli.md| new file mode 100644 |
| index 000000000..65756ba51 |
| --- /dev/null |
| +++ b/internal/docsite/testdata/corpus/reference/cli.md |
| @@ -0,0 +1,9 @@ |
| +# The CLI |
| + |
| +## carlos ship |
| + |
| +Publish a release. |
| + |
| +```sh |
| +carlos ship -app hello -version 1.0.0 ./dist |
| +``` |
diff --git a/internal/docsite/testdata/missing/index.md b/internal/docsite/testdata/missing/index.md| new file mode 100644 |
| index 000000000..25f8d4564 |
| --- /dev/null |
| +++ b/internal/docsite/testdata/missing/index.md |
| @@ -0,0 +1 @@ |
| +# Documentation |
diff --git a/internal/docsite/testdata/missing/nav.json b/internal/docsite/testdata/missing/nav.json| new file mode 100644 |
| index 000000000..6115d4411 |
| --- /dev/null |
| +++ b/internal/docsite/testdata/missing/nav.json |
| @@ -0,0 +1 @@ |
| +{ "sections": [ { "title": "Only", "pages": [ { "slug": "nowhere", "label": "Nowhere", "blurb": "Not on disk." } ] } ] } |
diff --git a/internal/docsite/testdata/orphan/index.md b/internal/docsite/testdata/orphan/index.md| new file mode 100644 |
| index 000000000..25f8d4564 |
| --- /dev/null |
| +++ b/internal/docsite/testdata/orphan/index.md |
| @@ -0,0 +1 @@ |
| +# Documentation |
diff --git a/internal/docsite/testdata/orphan/kept.md b/internal/docsite/testdata/orphan/kept.md| new file mode 100644 |
| index 000000000..8ecc772c4 |
| --- /dev/null |
| +++ b/internal/docsite/testdata/orphan/kept.md |
| @@ -0,0 +1 @@ |
| +# Kept |
diff --git a/internal/docsite/testdata/orphan/nav.json b/internal/docsite/testdata/orphan/nav.json| new file mode 100644 |
| index 000000000..11a0de71c |
| --- /dev/null |
| +++ b/internal/docsite/testdata/orphan/nav.json |
| @@ -0,0 +1 @@ |
| +{ "sections": [ { "title": "Only", "pages": [ { "slug": "kept", "label": "Kept", "blurb": "Reachable." } ] } ] } |
diff --git a/internal/docsite/testdata/orphan/stranded.md b/internal/docsite/testdata/orphan/stranded.md| new file mode 100644 |
| index 000000000..d6547a55a |
| --- /dev/null |
| +++ b/internal/docsite/testdata/orphan/stranded.md |
| @@ -0,0 +1 @@ |
| +# Stranded |