1// Command doccheck keeps the documentation's structure sound, where a2// machine can tell:3//4// - every relative link and image in Markdown points at a file that5// exists, and every #anchor at a heading of that file;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;10// - every file in docs/media is shown by some doc, comes from a VHS tape11// in demos/ (its Output or a Screenshot) or is a still of a golden12// screen (e2e/stills_test.go), and no GIF is over maxGIF;13// - every tape in demos/ records something a doc shows, and every14// still is shown, its dark and light pictures together;15// - every page showing a picture is in one of the pictures' color16// families, and the stills and recordings it shows are in that17// family's schemes (families.go);18// - every docs page the site has published is still a page, or has a19// redirect from its address in website/redirects.json (redirects.go).20//21// What the docs say about the program (keys, menus, commands, options) is22// checked by tests next to the code they name: internal/ui's docs_test.go23// and internal/config's.24//25// Usage: go run ./scripts/doccheck (from the repository root)26package main2728import (29 "bufio"30 "fmt"31 "io/fs"32 "os"33 "path"34 "path/filepath"35 "regexp"36 "slices"37 "strings"38)3940// maxGIF keeps the README's recordings quick to load.41const maxGIF = 1 << 204243func main() {44 docs, err := readDocs(".")45 if err != nil {46 fmt.Println("doccheck:", err)47 os.Exit(1)48 }49 var problems []string50 problems = append(problems, checkLinks(docs)...)51 problems = append(problems, checkTree(docs)...)52 problems = append(problems, checkGoPaths()...)53 problems = append(problems, checkMedia(docs)...)54 problems = append(problems, checkRedirects(docs)...)55 for _, p := range problems {56 fmt.Println(p)57 }58 if len(problems) > 0 {59 fmt.Printf("doccheck: %d problems\n", len(problems))60 os.Exit(1)61 }62}6364// doc is a Markdown file: its links and the anchors of its headings.65type doc struct {66 path string // slash-separated, relative to the root67 links []link68 anchors map[string]bool69}7071type link struct {72 line int73 target string74}7576// readDocs reads every Markdown file under root, skipping hidden77// directories, test data and generated output.78func readDocs(root string) (map[string]*doc, error) {79 docs := map[string]*doc{}80 err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {81 if err != nil {82 return err83 }84 name := d.Name()85 if d.IsDir() {86 if p != root && (strings.HasPrefix(name, ".") || name == "testdata" || name == "node_modules" || name == "out") {87 return filepath.SkipDir88 }89 return nil90 }91 if filepath.Ext(name) != ".md" {92 return nil93 }94 dc, err := parseDoc(p)95 if err != nil {96 return err97 }98 docs[dc.path] = dc99 return nil100 })101 return docs, err102}103104var (105 linkRE = regexp.MustCompile(`!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)`)106 inlineCode = regexp.MustCompile("`[^`]*`")107 headingRE = regexp.MustCompile(`^#{1,6}\s+(.*?)\s*#*\s*$`)108 anchorTag = regexp.MustCompile(`<a\s+(?:name|id)="([^"]+)"`)109)110111func parseDoc(p string) (*doc, error) {112 f, err := os.Open(p)113 if err != nil {114 return nil, err115 }116 defer f.Close()117 dc := &doc{path: filepath.ToSlash(filepath.Clean(p)), anchors: map[string]bool{}}118 seen := map[string]int{}119 fenced := false120 sc := bufio.NewScanner(f)121 sc.Buffer(make([]byte, 1<<20), 1<<20)122 for n := 1; sc.Scan(); n++ {123 l := sc.Text()124 if strings.HasPrefix(strings.TrimSpace(l), "```") {125 fenced = !fenced126 continue127 }128 if fenced {129 continue130 }131 if m := headingRE.FindStringSubmatch(l); m != nil {132 s := slug(m[1])133 if k := seen[s]; k > 0 {134 dc.anchors[fmt.Sprintf("%s-%d", s, k)] = true135 } else {136 dc.anchors[s] = true137 }138 seen[s]++139 }140 for _, m := range anchorTag.FindAllStringSubmatch(l, -1) {141 dc.anchors[m[1]] = true142 }143 for _, m := range linkRE.FindAllStringSubmatch(inlineCode.ReplaceAllString(l, "``"), -1) {144 dc.links = append(dc.links, link{n, m[1]})145 }146 }147 return dc, sc.Err()148}149150// slug is the anchor GitHub gives a heading: lower case, spaces as151// hyphens, punctuation other than hyphens and underscores dropped.152func slug(heading string) string {153 heading = strings.ToLower(inlineCode.ReplaceAllStringFunc(heading, func(s string) string { return strings.Trim(s, "`") }))154 var b strings.Builder155 for _, r := range heading {156 switch {157 case r == ' ':158 b.WriteByte('-')159 case r == '-' || r == '_' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r > 127 && isLetter(r):160 b.WriteRune(r)161 }162 }163 return b.String()164}165166func isLetter(r rune) bool { return strings.ToLower(string(r)) != strings.ToUpper(string(r)) }167168// checkLinks reports relative links to missing files or anchors.169func checkLinks(docs map[string]*doc) []string {170 var out []string171 for _, p := range sortedKeys(docs) {172 dc := docs[p]173 for _, l := range dc.links {174 t := l.target175 if strings.Contains(t, "://") || strings.HasPrefix(t, "mailto:") {176 continue177 }178 file, anchor, _ := strings.Cut(t, "#")179 target := dc.path180 if file != "" {181 target = path.Clean(path.Join(path.Dir(dc.path), file))182 }183 where := fmt.Sprintf("%s:%d: %s", dc.path, l.line, t)184 if _, err := os.Stat(filepath.FromSlash(target)); err != nil {185 out = append(out, where+": no such file")186 continue187 }188 if anchor == "" || modeOnly[anchor] {189 continue // the color mode a still shows in, checked in checkMedia190 }191 td, ok := docs[target]192 if !ok {193 out = append(out, where+": an anchor into a file that isn't Markdown")194 } else if !td.anchors[anchor] {195 out = append(out, where+": no heading with that anchor in "+target)196 }197 }198 }199 return out200}201202var (203 tapeOutput = regexp.MustCompile(`(?m)^Output\s+"?out/([^"\s]+)"?`)204 tapeScreenshot = regexp.MustCompile(`(?m)^Screenshot\s+"?out/stills/([^"\s]+)"?`)205 // docScreen is an entry of e2e's docScreens, a still drawn from a206 // golden screen as <name>-dark.png and <name>-light.png.207 docScreen = regexp.MustCompile(`(?m)^\s*\{name: "([^"]+)", from:`)208)209210// stillsSource lists the stills of golden screens.211const stillsSource = "e2e/stills_test.go"212213// modeOnly are the fragments GitHub and the site show an image by: only214// in dark mode, or only in light.215var modeOnly = map[string]bool{"gh-dark-mode-only": true, "gh-light-mode-only": true}216217// checkMedia reports media no doc shows, media nothing makes, GIFs over218// maxGIF, tapes and stills no doc shows, and stills shown without their219// other color mode.220func checkMedia(docs map[string]*doc) []string {221 // shown maps a media path to the fragment each doc shows it with.222 shown := map[string]map[string]string{}223 for _, dc := range docs {224 for _, l := range dc.links {225 file, frag, _ := strings.Cut(l.target, "#")226 if file != "" && !strings.Contains(file, "://") {227 p := path.Clean(path.Join(path.Dir(dc.path), file))228 if shown[p] == nil {229 shown[p] = map[string]string{}230 }231 shown[p][dc.path] = frag232 }233 }234 }235 madeBy := map[string]string{} // media file name -> the tape or stills list236 out := tapeMedia(shown, madeBy)237 out = append(out, stillMedia(shown, madeBy)...)238 out = append(out, checkFamilies(shown)...)239 media, _ := filepath.Glob("docs/media/*")240 for _, m := range media {241 m = filepath.ToSlash(m)242 name := path.Base(m)243 if shown[m] == nil {244 out = append(out, m+": no doc shows it")245 }246 if madeBy[name] == "" {247 out = append(out, m+": nothing makes it: no tape in demos/ (Output or Screenshot), nor docScreens in "+stillsSource)248 }249 if fi, err := os.Stat(m); err == nil && path.Ext(m) == ".gif" && fi.Size() > maxGIF {250 out = append(out, fmt.Sprintf("%s: %d KB, over %d KB", m, fi.Size()>>10, maxGIF>>10))251 }252 }253 return out254}255256// tapeMedia records what each tape makes in madeBy and reports tapes257// whose recordings no doc shows.258func tapeMedia(shown map[string]map[string]string, madeBy map[string]string) []string {259 tapes, _ := filepath.Glob("demos/*.tape")260 var out []string261 for _, t := range tapes {262 src, err := os.ReadFile(t)263 if err != nil {264 out = append(out, t+": "+err.Error())265 continue266 }267 var made []string268 for _, re := range []*regexp.Regexp{tapeOutput, tapeScreenshot} {269 for _, m := range re.FindAllStringSubmatch(string(src), -1) {270 made = append(made, m[1])271 madeBy[m[1]] = t272 }273 }274 if !slices.ContainsFunc(made, func(name string) bool { return shown["docs/media/"+name] != nil }) {275 out = append(out, t+": no doc shows its recording or stills (docs/media/"+strings.Join(made, ", docs/media/")+")")276 }277 }278 return out279}280281// stillMedia records the stills of golden screens in madeBy, and reports282// one no doc shows, or one shown without its other color mode: a doc283// shows <name>-dark.png#gh-dark-mode-only and284// <name>-light.png#gh-light-mode-only together.285func stillMedia(shown map[string]map[string]string, madeBy map[string]string) []string {286 src, err := os.ReadFile(stillsSource)287 if err != nil {288 return []string{stillsSource + ": " + err.Error()}289 }290 var out []string291 for _, m := range docScreen.FindAllStringSubmatch(string(src), -1) {292 dark, light := "docs/media/"+m[1]+"-dark.png", "docs/media/"+m[1]+"-light.png"293 madeBy[path.Base(dark)], madeBy[path.Base(light)] = stillsSource, stillsSource294 if shown[dark] == nil && shown[light] == nil {295 out = append(out, stillsSource+": no doc shows the still "+m[1])296 }297 for _, d := range sortedKeys(mergeKeys(shown[dark], shown[light])) {298 if shown[dark][d] != "gh-dark-mode-only" || shown[light][d] != "gh-light-mode-only" {299 out = append(out, d+": shows the still "+m[1]+" without both "+path.Base(dark)+"#gh-dark-mode-only and "+path.Base(light)+"#gh-light-mode-only")300 }301 }302 }303 return out304}305306func mergeKeys(a, b map[string]string) map[string]bool {307 out := map[string]bool{}308 for k := range a {309 out[k] = true310 }311 for k := range b {312 out[k] = true313 }314 return out315}316317func sortedKeys[V any](m map[string]V) []string {318 keys := make([]string, 0, len(m))319 for k := range m {320 keys = append(keys, k)321 }322 slices.Sort(keys)323 return keys324}