package check import ( "fmt" "io/fs" "path" "regexp" "sort" "strings" "khosra/internal/content" ) // Finding is one thing worth an author's attention. type Finding struct { // Where names the file, or the key when the finding is about identity rather than one file. Where string // What says what is wrong, in terms the author can act on. What string // Fatal marks a finding that makes the site wrong rather than merely untidy: something the engine had to // drop, or a link that will 404 for a reader. Only these decide the exit status. Fatal bool } // Run validates a site and returns every finding, ordered so two runs read the same. // // This is where content errors are meant to surface (ADR-0029): the server never fails on a bad bundle, it // works around it and says so in the log, which is easy to miss. `check` is the place that looks on purpose. func Run(fsys fs.FS, bundles []content.Bundle, site *content.Site, problems []content.Problem) []Finding { var found []Finding // What the engine already worked around. Fatal by definition: each is content the site is not serving. for _, p := range problems { found = append(found, Finding{p.Path, p.Detail, true}) } for _, p := range site.Problems() { found = append(found, Finding{p.Path, p.Detail, true}) } for _, b := range bundles { found = append(found, inspect(fsys, b, site)...) } found = append(found, mixedOrdering(bundles, site)...) sort.SliceStable(found, func(i, j int) bool { if found[i].Where != found[j].Where { return found[i].Where < found[j].Where } return found[i].What < found[j].What }) return found } // Fatal reports whether any finding means the site is actually wrong. func Fatal(found []Finding) bool { for _, f := range found { if f.Fatal { return true } } return false } // inspect checks one bundle. func inspect(fsys fs.FS, b content.Bundle, site *content.Site) []Finding { var found []Finding if b.Title == "" { // Not fatal: the page renders with its key as a title. Still almost never what anyone wants. found = append(found, Finding{b.Path, "no title, so the page is titled by its key", false}) } if norm := content.Normalise(b.Path); norm != b.Path { found = append(found, Finding{b.Path, "filename is not in NFC, so two visually identical names could take different keys (ADR-0015)", true}) } found = append(found, checkFigures(b)...) found = append(found, checkLinks(fsys, b, site)...) return found } // figureCall matches a whole figure shortcode, so its arguments can be examined. var figureCall = regexp.MustCompile(`(?m)^\s*\{\{<\s*figure\s+([^>]*)>\}\}\s*$`) // altArg matches a non-empty alt argument. var altArg = regexp.MustCompile(`alt="[^"]+"`) // checkFigures looks for pictures nobody described. // // Not fatal — a missing alt is a page that works and excludes someone — but it is the one accessibility fault // the engine can see, so it says so every time. func checkFigures(b content.Bundle) []Finding { var found []Finding for _, call := range figureCall.FindAllStringSubmatch(string(b.Body), -1) { if !altArg.MatchString(call[1]) { found = append(found, Finding{b.Path, "figure with no alt text: " + strings.TrimSpace(call[0]), false}) } } return found } // internalLink matches a Markdown link or image whose target is a root-relative path. var internalLink = regexp.MustCompile(`\]\((/[^)\s"]*)`) // checkLinks resolves every root-relative link a body contains. // // Fatal: a link that 404s is the site lying to a reader, and it is exactly the mistake that survives a rename // because nothing else looks. Engine-owned paths are skipped — they are generated, not authored. func checkLinks(fsys fs.FS, b content.Bundle, site *content.Site) []Finding { var found []Finding seen := map[string]bool{} for _, m := range internalLink.FindAllStringSubmatch(string(b.Body), -1) { target := m[1] if seen[target] || engineOwned(target) { continue } seen[target] = true if !resolves(fsys, target, b, site) { found = append(found, Finding{b.Path, "link goes nowhere: " + target, true}) } } return found } // engineOwned reports whether a path is generated rather than authored, so a checker has nothing to say. func engineOwned(target string) bool { for _, prefix := range []string{"/static/", content.DerivedPrefix, "/tags/"} { if strings.HasPrefix(target, prefix) { return true } } switch target { case "/", "/robots.txt", "/sitemap.xml", "/feed.xml": return true } return false } // resolves reports whether a root-relative path would be served. // // It asks the same questions the resolver does — a bundle at that route, an alias, a section listing, or a file // inside a bundle — because a checker that guesses differently from the server is worse than no checker. func resolves(fsys fs.FS, target string, from content.Bundle, site *content.Site) bool { trimmed := strings.Trim(target, "/") if trimmed == "" { return true } // A language prefix resolves to the same content, so drop it before asking. if head, rest, found := strings.Cut(trimmed, "/"); found && site.HasLang(head) { trimmed = rest } key, live := site.KeyFor(content.Normalise(trimmed)) if live { if _, _, ok := site.Lookup(key, content.DefaultLang); ok { return true } // A section with anything in it has a listing; and a file inside a bundle is served under it. if len(site.Run(content.Query{Section: key, Lang: content.DefaultLang})) > 0 { return true } } if _, isAlias := site.Alias(content.Normalise(trimmed)); isAlias { return true } return asset(fsys, trimmed, site) } // asset reports whether the path names a file inside some bundle's own directory. func asset(fsys fs.FS, trimmed string, site *content.Site) bool { dir, file := path.Split(trimmed) route := strings.TrimSuffix(dir, "/") if route == "" || file == "" { return false } key, live := site.KeyFor(route) if !live { return false } b, _, ok := site.Lookup(key, content.DefaultLang) if !ok { return false } assets, hasAssets := b.Assets() if !hasAssets { return false } if _, err := fs.Stat(fsys, path.Join(assets, file)); err != nil { return false } return true } // mixedOrdering finds a series where some members declare `order` and others do not. // // Not fatal, but the surprise this exists for: unordered members sort after every ordered one, so adding // `order` to one chapter silently moves every chapter that lacks it to the end (ADR-0033). func mixedOrdering(bundles []content.Bundle, site *content.Site) []Finding { ordered, unordered := map[string]bool{}, map[string][]string{} for _, b := range bundles { seq, inSeries := site.Sequence(b.Key, b.Lang) if !inSeries || seq.Index == 0 { continue } if b.Order == 0 { unordered[seq.Series.Key] = append(unordered[seq.Series.Key], b.Key) continue } ordered[seq.Series.Key] = true } var found []Finding for series := range ordered { if missing := unordered[series]; len(missing) > 0 { sort.Strings(missing) found = append(found, Finding{series, fmt.Sprintf( "some members declare order and these do not, so they sort last: %s", strings.Join(missing, ", ")), false}) } } return found }