carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 4442fb63699a413adf340e67aae115625fd88ae6 parent d371f63
8 files changed, +94 −0
  • internal/docsite/nav_test.go +87 −0
  • internal/docsite/testdata/corpus/getting-started.md +1 −0
  • internal/docsite/testdata/missing/index.md +1 −0
  • internal/docsite/testdata/missing/nav.json +1 −0
  • internal/docsite/testdata/orphan/index.md +1 −0
  • internal/docsite/testdata/orphan/kept.md +1 −0
  • internal/docsite/testdata/orphan/nav.json +1 −0
  • internal/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