Files
khosra/internal/ext/check/check.go
T
bdeshi f05b1c131d add khosra check, the place content errors are looked for
The server never fails on a bad bundle — it works around it and logs, which is easy
to miss (ADR-0029). This is the command that looks on purpose, and it exits non-zero
on anything that makes the site wrong rather than merely untidy.

Fatal: content the engine had to drop (unparseable frontmatter, a key two files
claim, an ignored slug or alias) and links that will 404 for a reader. Warnings:
no title, a figure with no alt text, a series where some members declare order and
others do not — that last one because unordered members sort last, so adding order
to one chapter silently moves every chapter that lacks it.

Two things this needed rather than invented. `Scan` and `Site` now *return* what they
worked around instead of only logging it: `ScanReport` and `Site.Problems`, with
`Scan` staying the logging wrapper so nothing else changed. And required-fields-per-type
is deliberately absent — `title` is the only field the engine requires today, so
checking more would mean inventing the type declaration that is still parked. Its
trigger stays where it was.

The link checker asks the same questions the resolver asks — bundle, alias, section
listing, file inside a bundle — because a checker that guesses differently from the
server is worse than no checker. Engine-owned paths are skipped: they are generated,
not authored.

It lives in internal/ext/check, so cmd/ stays wiring and the feature stays deletable.
Verified against the evidence site: clean before, and five findings across five fault
classes after I introduced them on purpose.
2026-07-31 11:09:45 +06:00

216 lines
7.1 KiB
Go

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
}