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>
8 files changed,
+94
−0
internal/docsite/nav_test.go+87 −0internal/docsite/testdata/corpus/getting-started.md+1 −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/internal/docsite/nav_test.go b/internal/docsite/nav_test.go| new file mode 100644 |
| index 000000000..37681de47 |
| --- /dev/null |
| +++ b/internal/docsite/nav_test.go |
| @@ -0,0 +1,87 @@ |
| +package docsite |
| + |
| +import ( |
| + "os" |
| + "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) { |
| + 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) |
| + } |
| + |
| + 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/testdata/corpus/getting-started.md b/internal/docsite/testdata/corpus/getting-started.md| index fe9dec4fe..af298324a 100644 |
| --- a/internal/docsite/testdata/corpus/getting-started.md |
| +++ b/internal/docsite/testdata/corpus/getting-started.md |
| @@ -6,6 +6,7 @@ Run it: |
| ```sh |
| carlos auth login |
| +# see [nothing](/docs/does-not-exist) — inside a fence, so not a link |
| ``` |
| ## Deploy something |
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 |