Every folder of docs/ has a README.md linking its pages and subfolders and a _category_.json that parses, with a label, a position unique among its siblings and a link to that README; every page has front matter with a title and a sidebar_position unique in its folder; every docs/...md path in Go code exists. testing.md describes the tree and the checks.
- commit
- caf9120b9d6293f7b2f8db48487718711c394033
- parent
- c0adea8b20fb40eb2c2dd9f80851daa960575264
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 37 | ++++-- |
| M | scripts/ | 28 | +---- |
| A | scripts/ | 181 | ++++++++++++++++++++++++++++++ |
@@ -107,18 +107,35 @@ shows go there, and GIFs stay under 1 MB.
107107 108108 ## Docs109109 110+The docs are a tree of folders by concept under `docs/`, Markdown that111+reads on GitHub and builds with Docusaurus as it is:112+113+- each folder has a `README.md` that links its pages and subfolders (the114+ folder's page in Docusaurus, and what GitHub shows for the folder), and115+ a `_category_.json` with its sidebar `label`, `position` and a link to116+ that README;117+- each page starts with front matter: a `title` and a `sidebar_position`118+ unique in its folder;119+- links are relative, to `.md` files, so they work in both; images live120+ in `docs/media/`.121+122+A new page goes in the folder a reader would look in, with front matter,123+and a line in the folder's README.124+110125 `make lint` runs `scripts/doclint`, which flags wording that narrates111-history (see [CLAUDE.md](../../CLAUDE.md)), and `scripts/doccheck`: relative112-links and anchors resolve, [012 documentation](../README.md) links every doc,113-every file in `docs/media` is shown by a doc and made by a tape, and114-every tape records something a doc shows. Unit tests check what the docs115-say about the code: menu paths lead to menu items, command ids and keys116-exist and every bound key is in [Keys and mouse](../reference/keys.md)126+history (see [CLAUDE.md](../../CLAUDE.md)), and `scripts/doccheck`, which127+checks the tree above, that relative links and anchors resolve, that128+every `docs/...md` path Go code names exists, that every file in129+`docs/media` is shown by a doc and made by a tape, and that every tape130+records something a doc shows. Unit tests check what the docs say about131+the code: menu paths lead to menu items, command ids and keys exist and132+every bound key is in [Keys and mouse](../reference/keys.md)117133 (`internal/ui/docs_test.go`), settings and variables are options118-(`internal/config/docs_test.go`), and [Files](../files/README.md) names every119-field of the format (`internal/sheet/file_doc_test.go`).120-[Functions](../reference/functions.md) and [Configuration](../reference/config.md)'s reference are121-generated, and their tests fail when they're stale.134+(`internal/config/docs_test.go`), and [the .012 format](../files/format.md)135+names every field (`internal/sheet/file_doc_test.go`).136+[Functions](../reference/functions.md) and the reference in137+[Configuration](../reference/config.md) are generated, and their tests138+fail when they're stale.122139 123140 ## No broken windows124141 @@ -3,7 +3,10 @@
33 //44 // - every relative link and image in Markdown points at a file that55 // exists, and every #anchor at a heading of that file;6-// - docs/README.md, the index, links every doc in docs/;6+// - docs/ is a Docusaurus tree: every folder has a README.md that links7+// its pages and subfolders, and a valid _category_.json, and every8+// page front matter with a title and a sidebar_position (tree.go);9+// - every docs/...md path Go code names exists;710 // - every file in docs/media is shown by some doc, comes from a VHS tape811 // in demos/ (its Output or a Screenshot), and no GIF is over maxGIF;912 // - every tape in demos/ records something a doc shows.@@ -38,7 +41,8 @@ func main() {
3841 }3942 var problems []string4043 problems = append(problems, checkLinks(docs)...)41- problems = append(problems, checkIndex(docs)...)44+ problems = append(problems, checkTree(docs)...)45+ problems = append(problems, checkGoPaths()...)4246 problems = append(problems, checkMedia(docs)...)4347 for _, p := range problems {4448 fmt.Println(p)@@ -187,26 +191,6 @@ func checkLinks(docs map[string]*doc) []string {
187191 return out188192 }189193 190-// checkIndex reports docs the index doesn't link.191-func checkIndex(docs map[string]*doc) []string {192- index, ok := docs["docs/README.md"]193- if !ok {194- return []string{"docs/README.md: missing"}195- }196- linked := map[string]bool{}197- for _, l := range index.links {198- file, _, _ := strings.Cut(l.target, "#")199- linked[path.Clean(path.Join("docs", file))] = true200- }201- var out []string202- for _, p := range sortedKeys(docs) {203- if path.Dir(p) == "docs" && p != "docs/README.md" && !linked[p] {204- out = append(out, "docs/README.md: doesn't link "+p)205- }206- }207- return out208-}209-210194 var (211195 tapeOutput = regexp.MustCompile(`(?m)^Output\s+"?out/([^"\s]+)"?`)212196 tapeScreenshot = regexp.MustCompile(`(?m)^Screenshot\s+"?out/stills/([^"\s]+)"?`)@@ -0,0 +1,181 @@
1+package main2+3+import (4+ "encoding/json"5+ "fmt"6+ "io/fs"7+ "os"8+ "path"9+ "path/filepath"10+ "regexp"11+ "strconv"12+ "strings"13+)14+15+// The docs are a Docusaurus tree that also reads on GitHub: each folder16+// under docs/ has a README.md that indexes it and a _category_.json that17+// names it in the sidebar, and each page has front matter with its title18+// and sidebar position.19+20+// checkTree reports pages their folder's README.md doesn't link, folders21+// their parent's README.md doesn't link, and missing or invalid category22+// files and front matter.23+func checkTree(docs map[string]*doc) []string {24+ var out []string25+ folders := map[string]bool{"docs": true}26+ for p := range docs {27+ if strings.HasPrefix(p, "docs/") {28+ folders[path.Dir(p)] = true29+ }30+ }31+ for _, dir := range sortedKeys(folders) {32+ index := dir + "/README.md"33+ if docs[index] == nil {34+ out = append(out, index+": missing; every folder of docs has one")35+ continue36+ }37+ linked := linkedFrom(docs[index])38+ for _, p := range sortedKeys(docs) {39+ if p != index && path.Dir(p) == dir && !linked[p] {40+ out = append(out, index+": doesn't link "+p)41+ }42+ }43+ for sub := range folders {44+ if path.Dir(sub) == dir && sub != dir && !linked[sub+"/README.md"] {45+ out = append(out, index+": doesn't link "+sub+"/README.md")46+ }47+ }48+ if dir != "docs" {49+ out = append(out, checkCategory(dir, folders)...)50+ }51+ }52+ out = append(out, checkFrontMatter(docs)...)53+ return out54+}55+56+func linkedFrom(dc *doc) map[string]bool {57+ linked := map[string]bool{}58+ for _, l := range dc.links {59+ file, _, _ := strings.Cut(l.target, "#")60+ if file != "" && !strings.Contains(file, "://") {61+ linked[path.Clean(path.Join(path.Dir(dc.path), file))] = true62+ }63+ }64+ return linked65+}66+67+// category is a folder's _category_.json, as Docusaurus reads it.68+type category struct {69+ Label string `json:"label"`70+ Position *int `json:"position"`71+ Link *struct {72+ Type string `json:"type"`73+ ID string `json:"id"`74+ } `json:"link"`75+}76+77+// checkCategory reports a folder's missing or invalid _category_.json,78+// and positions its siblings share.79+func checkCategory(dir string, folders map[string]bool) []string {80+ file := dir + "/_category_.json"81+ c, err := readCategory(file)82+ if err != nil {83+ return []string{file + ": " + err.Error()}84+ }85+ var out []string86+ if c.Label == "" || c.Position == nil {87+ out = append(out, file+": needs a label and a position")88+ }89+ if c.Link != nil && c.Link.ID != strings.TrimPrefix(dir, "docs/")+"/README" {90+ out = append(out, fmt.Sprintf("%s: link id %q isn't the folder's README", file, c.Link.ID))91+ }92+ for sib := range folders {93+ if sib == dir || path.Dir(sib) != path.Dir(dir) || sib == "docs" || c.Position == nil {94+ continue95+ }96+ if o, err := readCategory(sib + "/_category_.json"); err == nil && o.Position != nil && *o.Position == *c.Position && sib < dir {97+ out = append(out, fmt.Sprintf("%s: position %d is also %s's", file, *c.Position, sib))98+ }99+ }100+ return out101+}102+103+func readCategory(file string) (category, error) {104+ var c category105+ data, err := os.ReadFile(file)106+ if err != nil {107+ return c, fmt.Errorf("missing; every folder of docs has one")108+ }109+ if err := json.Unmarshal(data, &c); err != nil {110+ return c, fmt.Errorf("not valid JSON: %v", err)111+ }112+ return c, nil113+}114+115+var frontMatter = regexp.MustCompile(`\A---\n((?:.*\n)*?)---\n`)116+117+// checkFrontMatter reports pages under docs/ without a title and a118+// sidebar position, and positions pages of one folder share.119+func checkFrontMatter(docs map[string]*doc) []string {120+ var out []string121+ seen := map[string]string{} // folder and position -> page122+ for _, p := range sortedKeys(docs) {123+ if !strings.HasPrefix(p, "docs/") {124+ continue125+ }126+ data, err := os.ReadFile(filepath.FromSlash(p))127+ if err != nil {128+ continue129+ }130+ m := frontMatter.FindSubmatch(data)131+ if m == nil {132+ out = append(out, p+": no front matter (title, sidebar_position)")133+ continue134+ }135+ fields := map[string]string{}136+ for l := range strings.SplitSeq(string(m[1]), "\n") {137+ if k, v, ok := strings.Cut(l, ":"); ok {138+ fields[strings.TrimSpace(k)] = strings.TrimSpace(v)139+ }140+ }141+ pos, err := strconv.Atoi(fields["sidebar_position"])142+ if fields["title"] == "" || err != nil {143+ out = append(out, p+": front matter needs a title and a whole-number sidebar_position")144+ continue145+ }146+ key := fmt.Sprintf("%s#%d", path.Dir(p), pos)147+ if other, ok := seen[key]; ok {148+ out = append(out, fmt.Sprintf("%s: sidebar_position %d is also %s's", p, pos, other))149+ }150+ seen[key] = p151+ }152+ return out153+}154+155+var goDocPath = regexp.MustCompile(`\bdocs/[a-z0-9_/-]+\.md\b`)156+157+// checkGoPaths reports doc paths named in Go code, in messages and158+// comments, that don't exist.159+func checkGoPaths() []string {160+ var out []string161+ for _, root := range []string{"cmd", "internal", "e2e", "oracle", "demos", "scripts"} {162+ filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {163+ if err != nil || d.IsDir() || filepath.Ext(p) != ".go" {164+ return nil165+ }166+ data, err := os.ReadFile(p)167+ if err != nil {168+ return nil169+ }170+ for i, l := range strings.Split(string(data), "\n") {171+ for _, m := range goDocPath.FindAllString(l, -1) {172+ if _, err := os.Stat(filepath.FromSlash(m)); err != nil {173+ out = append(out, fmt.Sprintf("%s:%d: %s: no such doc", p, i+1, m))174+ }175+ }176+ }177+ return nil178+ })179+ }180+ return out181+}