plan: stage 1 — the docsite gates and the website pipeline
Twelve tasks: the parser, six gates, a two-page corpus that turns them on for real, then the Eleventy build, the skin, the rendered-output check and both PRs. Stage 1 deliberately ships something that works rather than scaffolding — carlosframework.com/docs exists at the end of it, with an index and the CLI reference. The other thirteen pages and all of Carloku are the next two plans.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+1608
−0
docs/superpowers/plans/2026-08-25-docs-pipeline-and-gates.md+1608 −0
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..57d3be9e1 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-25-docs-pipeline-and-gates.md |
| @@ -0,0 +1,1608 @@ |
| +# 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); os.IsNotExist(err) { |
| + t.Skip("no corpus yet — Task 8 creates docs/site") |
| + } |
| +``` |
| + |
| +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"`. |
| + |
| +- [ ] **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); os.IsNotExist(err) { |
| + t.Skip("no corpus yet — Task 8 creates docs/site") |
| + } |
| + 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); os.IsNotExist(err) { |
| + t.Skip("no corpus yet — Task 8 creates docs/site") |
| + } |
| + 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 on a missing corpus: `anchors.json` is created by this task, so from here on `docs/site/` exists. Tasks 2, 3 and 5–7 keep their skips until Task 8, because they need the `.md` pages, not just the fixture. |
| + |
| +- [ ] **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); os.IsNotExist(err) { |
| + t.Skip("no corpus yet — Task 8 creates docs/site") |
| + } |
| + 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) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestFenceParsingHandlesNesting proves the parser does not close an |
| +// outer fence on an inner one, which would split one block into two and |
| +// hide the second half from every gate. |
| +func TestFenceParsingHandlesNesting(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); os.IsNotExist(err) { |
| + t.Skip("no corpus yet — Task 8 creates docs/site") |
| + } |
| + 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); os.IsNotExist(err) { |
| + t.Skip("no corpus yet — Task 8 creates docs/site") |
| + } |
| + 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 { |
| + if f != "carlos" || 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` — the path segment is one field, so `carlos` matches only the binary name. That is the behaviour we want, and the test pins it. |
| + |
| +- [ ] **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 |
| +GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go run ./scripts/... 2>/dev/null || \ |
| + go doc ./internal/docsite TopLevelCommands |
| +``` |
| + |
| +Simpler: 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. |