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.
This commit is contained in:
Claude Opus 5
2026-07-31 11:09:45 +06:00
committed by bdeshi
parent b061f4590f
commit 479d8c6bd6
9 changed files with 488 additions and 19 deletions
+5 -1
View File
@@ -5,7 +5,7 @@ SITE ?= $(KHOSRA_SITE)
ADDR ?= localhost:8080
BIN := ./khosra
.PHONY: help build run test verify fmt tidy clean
.PHONY: help build run check test verify fmt tidy clean
help: ## list targets
@grep -hE '^[a-z]+:.*##' $(MAKEFILE_LIST) | sed 's/:[^#]*## /|/' | column -t -s '|'
@@ -17,6 +17,10 @@ run: build ## serve a site root: make run SITE=/path/to/site
@test -n "$(SITE)" || { echo "set SITE=/path/to/site, or export KHOSRA_SITE"; exit 1; }
$(BIN) -site "$(SITE)" -addr "$(ADDR)"
check: build ## validate a site root's content: make check SITE=/path/to/site
@test -n "$(SITE)" || { echo "set SITE=/path/to/site, or export KHOSRA_SITE"; exit 1; }
$(BIN) check -site "$(SITE)"
test: ## run tests with the race detector
go test -race ./...
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"flag"
"fmt"
"os"
"khosra/internal/content"
"khosra/internal/ext/check"
)
// runCheck validates a site root and exits non-zero when something is actually wrong.
//
// Wiring only: what counts as a finding lives in internal/ext/check, so this stays the place where things are
// assembled and nowhere near the place where they are decided (conventions.md).
func runCheck(args []string) {
flags := flag.NewFlagSet("check", flag.ExitOnError)
site := flags.String("site", os.Getenv("KHOSRA_SITE"), "path to the site root (or KHOSRA_SITE)")
if err := flags.Parse(args); err != nil {
fatal("cannot read the arguments", err)
}
if *site == "" {
fatal("no site root: pass -site or set KHOSRA_SITE", nil)
}
fsys, err := content.OpenSite(*site)
if err != nil {
fatal("cannot open the site root", err)
}
bundles, problems, err := content.ScanReport(fsys)
if err != nil {
fatal("cannot read content", err)
}
found := check.Run(fsys, bundles, content.NewSite(bundles), problems)
for _, f := range found {
mark := "warn"
if f.Fatal {
mark = "FAIL"
}
fmt.Printf("%s %s: %s\n", mark, f.Where, f.What)
}
fmt.Printf("\n%d bundle(s), %d finding(s)\n", len(bundles), len(found))
if check.Fatal(found) {
os.Exit(1)
}
}
+6
View File
@@ -18,6 +18,12 @@ import (
)
func main() {
// One subcommand, matched before the flags are defined: `khosra check` validates a site root and exits,
// while a bare `khosra` serves one. A second subcommand is when this wants a table rather than an if.
if len(os.Args) > 1 && os.Args[1] == "check" {
runCheck(os.Args[2:])
return
}
site := flag.String("site", os.Getenv("KHOSRA_SITE"), "path to the site root (or KHOSRA_SITE)")
addr := flag.String("addr", "localhost:8080", "address to listen on")
base := flag.String("base", "", "canonical site origin, overriding site.yaml (e.g. https://khosra.example)")
+14 -4
View File
@@ -1,6 +1,6 @@
# State
**Verified against:** `02adf84` on 2026-07-30 — update this line every change.
**Verified against:** `f05b1c1` on 2026-07-30 — update this line every change.
If this file disagrees with the code, the code is right and this file is a bug.
## Inventory
@@ -16,6 +16,7 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `internal/render/chrome.go` | the engine's own words: phrase table, month names, digits, and the `t`/`num`/`day` template funcs (ADR-0034) | 105 |
| `internal/render/templates/` | reference theme: `base.html`, `page.html`, `list.html`, `shortcodes.html`, `theme.css` (ADR-0026) | — |
| `internal/ext/shortcodes/` | first feature: `{{< name key="value" >}}` block parser and node renderer, rendering through a theme fragment (ADR-0036). `figure`, `gallery`, `include`, plus the derivative pass and remembered picture inspection (ADR-0042, ADR-0044) | 564 |
| `internal/ext/check/` | third feature: validates a site root — what the engine worked around, broken internal links, missing titles and alt text, mixed series ordering | 216 |
| `internal/ext/widows/` | second feature: joins the last two words of a paragraph or heading with a non-breaking space, over the tree so code spans are safe | 108 |
| `cmd/khosra/wire.go` | the only list of enabled features (`extensions.md`) | 20 |
| `internal/web/resolve.go` | URL → (key, lang, page, tag) or a canonical redirect: language prefix, `/en/…` fork guard, pagination, tags, trailing slash | 112 |
@@ -23,8 +24,9 @@ If this file disagrees with the code, the code is right and this file is a bug.
| `internal/web/feed.go` | Atom for the site, a section or a tag, from dated bundles via one Query (ADR-0043) | 125 |
| `internal/web/discover.go` | `/robots.txt` and `/sitemap.xml`, absolute and only with a declared base (ADR-0039) | 74 |
| `internal/web/web.go` | handler: resolve, look up with fallback, section and tag listings, sequence, `/static/` (misses and refusals alike answer 404), degrade on failure | 152 |
| `cmd/khosra/main.go` | flags (`-site`, `-addr`, `-base`, `-cache`), wiring, startup including the derivative pass — the only place things are assembled | 88 |
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, widows, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path | 2457 |
| `cmd/khosra/main.go` | flags (`-site`, `-addr`, `-base`, `-cache`), wiring, startup including the derivative pass — the only place things are assembled | 92 |
| `cmd/khosra/check.go` | the `check` subcommand: parse, print, exit code. What counts as a finding lives in the feature | 45 |
| `*_test.go` | table-driven, one file per source file; symlink escape (content and static), canonical paths, language fallback, aliases, pagination, tags, sequences, chrome, typography, shortcode escaping, galleries, includes, partials, widows, site settings, absolute URLs, robots, sitemap, slug routes, bundle assets, derivatives, feeds, 404, plus benchmarks for the render path and the checker | 2604 |
Serves a bundle at `/{section}/{slug}/` — the slug derived, or declared in frontmatter without moving the
key (ADR-0035) — a paginated listing per section, tag listings global and
@@ -33,6 +35,7 @@ URL, generated derivatives under `/derived/`, Atom feeds per site,
section and tag, plus `/robots.txt` and `/sitemap.xml`.
Chrome text, dates and digits render in English or Bengali; authored text is untouched but for typographic
smoothing and widow prevention (ADR-0034). This repo holds engine source only — the site root is external and passed with
`khosra check` validates a site root and exits non-zero on anything that makes it wrong.
`-site` (ADR-0011). `site.yaml` declares `base` and `title`; with a base, canonical, hreflang and OpenGraph
URLs go absolute (ADR-0039).
@@ -54,10 +57,17 @@ this change*.
| Collection pages | 4 | **1** — done | Query primitive: `content.Query{Section, Tag, Lang}` + `Site.Run`. The fourth — a series archive — resolves through `Site.Sequence` instead: membership is structural and the sort ascends, so it shares the index but not the Query |
| Views — **per-bundle selection only** | 0 | **2** | The View layer `architecture.md` describes: `view:` in frontmatter choosing a presentation, resolved through the cascade. Nothing selects a view yet. *Output formats* are counted separately and are not it: HTML, sitemap XML and Atom are three functions with nothing to share — an interface over them would have one member and no leverage |
| Effects | 1 | **2** | Effect runner + trigger wiring (change / schedule / demand). The first is the derivative pass (ADR-0042), called straight from `cmd` at startup — one call needs no runner, and startup is the only change signal until queue 21 |
| Extensions | 2 | **3** | Extension registry (`extensions.md`). The wire file arrived with the first feature rather than the registry — `cmd/khosra/wire.go`, one line, no struct |
| Extensions | 3 | **3** — due | Extension registry (`extensions.md`). Three features exist, so the threshold is reached: see the note below the table before building one | Extension registry (`extensions.md`). The wire file arrived with the first feature rather than the registry — `cmd/khosra/wire.go`, one line, no struct |
| Interface implementations | — | **2** | The interface itself |
| Non-stdlib dependencies | 4 direct | budget in `scripts/budgets.env` | — |
**Extensions counter is due, and the answer is probably still no.** Three features exist
(`shortcodes`, `widows`, `check`) but they plug in three different ways: two are goldmark extenders listed in
`extenders()`, and `check` is a function `cmd` calls. A registry would have to abstract over "thing that
extends Markdown" and "thing that validates content", which share nothing but the word *feature*. What the
counter is really detecting is that `wire.go` lists only one kind. Revisit when a *fourth* feature wants a
third way in — or when one wants a route, which is the seam ADR-0042 already named.
Allowlist, all four imported: `goldmark` (markdown), `golang.org/x/text` (NFC, ADR-0015),
`gopkg.in/yaml.v3` (frontmatter, ADR-0020), `golang.org/x/image` (resampling and WebP, ADR-0040).
+32 -9
View File
@@ -72,12 +72,33 @@ func OpenSite(dir string) (fs.FS, error) {
return root.FS(), nil
}
// Scan reads every bundle under content/ in fsys.
// Problem is something wrong with the content that the engine worked around.
//
// Every one of these is also a log line when the server starts, but `check` needs them as data rather than as
// text, so they are collected here and reported by whoever asked (ADR-0029).
type Problem struct {
// Path names the file, or the key when the trouble is about identity rather than one file.
Path string
// Detail says what was wrong, in the terms an author can act on.
Detail string
}
// Scan reads every bundle under content/ in fsys, logging anything it worked around.
//
// A bundle that cannot be parsed, or that collides with another on the same key and language, is logged
// at error level and left out; neither is fatal, because one mistyped colon must not take down a site
// (ADR-0029). An error is returned only when the walk itself fails.
func Scan(fsys fs.FS) ([]Bundle, error) {
found, problems, err := ScanReport(fsys)
for _, p := range problems {
slog.Error("content problem", "path", p.Path, "detail", p.Detail)
}
return found, err
}
// ScanReport is Scan with the problems returned instead of only logged.
func ScanReport(fsys fs.FS) ([]Bundle, []Problem, error) {
var problems []Problem
var found []Bundle
err := fs.WalkDir(fsys, "content", func(p string, d fs.DirEntry, err error) error {
switch {
@@ -95,12 +116,12 @@ func Scan(fsys fs.FS) ([]Bundle, error) {
}
data, err := fs.ReadFile(fsys, p)
if err != nil {
slog.Error("skipping unreadable bundle", "path", p, "err", err)
problems = append(problems, Problem{p, "unreadable, so it is not served: " + err.Error()})
return nil
}
b, err := Parse(strings.TrimPrefix(p, "content/"), data)
if err != nil {
slog.Error("skipping unparseable bundle", "path", p, "err", err)
problems = append(problems, Problem{p, "not served, cannot be parsed: " + err.Error()})
return nil
}
b.Path = p
@@ -108,9 +129,10 @@ func Scan(fsys fs.FS) ([]Bundle, error) {
return nil
})
if err != nil {
return nil, fmt.Errorf("scan content: %w", err)
return nil, problems, fmt.Errorf("scan content: %w", err)
}
return dropCollisions(found), nil
kept, collisions := dropCollisions(found)
return kept, append(problems, collisions...), nil
}
// Parse reads one bundle from the bytes of a file, named relative to content/.
@@ -339,21 +361,22 @@ func trimLeadingFence(data []byte, fence string) ([]byte, bool) {
//
// Two spellings of the same variant — about.md and about.en.md, or about.md and about/index.md — are
// ambiguous rather than harmless, so none of them is served (ADR-0021).
func dropCollisions(all []Bundle) []Bundle {
func dropCollisions(all []Bundle) ([]Bundle, []Problem) {
seen := map[string]int{}
for _, b := range all {
seen[b.Key+"\x00"+b.Lang]++
}
var problems []Problem
kept := make([]Bundle, 0, len(all))
for _, b := range all {
if seen[b.Key+"\x00"+b.Lang] > 1 {
slog.Error("skipping ambiguous bundle: two files claim one key and language",
"key", b.Key, "lang", b.Lang, "path", b.Path)
problems = append(problems, Problem{b.Path,
"not served: another file claims the same key (" + b.Key + ") and language (" + b.Lang + ")"})
continue
}
kept = append(kept, b)
}
return kept
return kept, problems
}
// PerPage is how many entries a listing shows.
+16 -5
View File
@@ -14,6 +14,8 @@ type Site struct {
// keyByRoute maps a served path back to the identity it belongs to. Only renamed bundles appear: a
// bundle with no slug is served at its key, so route and key are the same string (ADR-0035).
keyByRoute map[string]string
// problems are what indexing worked around, kept for `check` rather than only logged.
problems []Problem
// renamed records keys that a slug moved away from, so the old path answers 404 instead of still working
// — the engine serves the new path only (ADR-0035), and an author who wants both writes an alias.
renamed map[string]bool
@@ -36,6 +38,16 @@ func NewSite(bundles []Bundle) *Site {
return s
}
// Problems lists what indexing worked around: a contested alias, a slug two variants disagree on, a slug
// landing where something already answers. Each was also logged.
func (s *Site) Problems() []Problem { return s.problems }
// note records a problem and logs it, so the server says the same thing it always did.
func (s *Site) note(path, detail string) {
s.problems = append(s.problems, Problem{path, detail})
slog.Error("content problem", "path", path, "detail", detail)
}
// indexRoutes resolves each key's served path from the slugs its variants declare.
//
// A slug renames the bundle in every language, so the variants have to agree: two declaring different slugs
@@ -59,13 +71,12 @@ func (s *Site) indexRoutes(bundles []Bundle) {
continue
}
if len(slugs) > 1 {
slog.Error("ignoring slug: variants of one bundle declare different ones",
"key", key, "slugs", sorted(slugs))
s.note(key, "slug ignored: variants declare different ones ("+strings.Join(sorted(slugs), ", ")+")")
continue
}
route := path.Join(path.Dir(key), sorted(slugs)[0])
if _, taken := s.byKeyLang[route+"\x00"+DefaultLang]; taken || s.keyByRoute[route] != "" {
slog.Error("ignoring slug: another bundle already answers there", "key", key, "route", route)
s.note(key, "slug ignored: "+route+" is already answered by another bundle")
continue
}
s.keyByRoute[route] = key
@@ -133,12 +144,12 @@ func (s *Site) indexAliases(bundles []Bundle) {
// ask the same question a request does.
if key, live := s.KeyFor(alias); live {
if _, isReal := s.byKeyLang[key+"\x00"+DefaultLang]; isReal {
slog.Error("ignoring alias that names a real bundle", "alias", alias, "claimed_by", keys)
s.note(alias, "alias ignored: a real bundle answers there, claimed by "+strings.Join(keys, ", "))
continue
}
}
if len(keys) > 1 {
slog.Error("ignoring alias claimed by more than one bundle", "alias", alias, "claimed_by", keys)
s.note(alias, "alias ignored: claimed by more than one bundle ("+strings.Join(keys, ", ")+")")
continue
}
s.aliases[alias] = keys[0]
+215
View File
@@ -0,0 +1,215 @@
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
}
+147
View File
@@ -0,0 +1,147 @@
package check
import (
"strings"
"testing"
"testing/fstest"
"khosra/internal/content"
)
func run(t *testing.T, fsys fstest.MapFS) []Finding {
t.Helper()
bundles, problems, err := content.ScanReport(fsys)
if err != nil {
t.Fatal(err)
}
return Run(fsys, bundles, content.NewSite(bundles), problems)
}
// says reports whether any finding mentions the fragment, and whether it was fatal.
func says(found []Finding, fragment string) (bool, bool) {
for _, f := range found {
if strings.Contains(f.What, fragment) || strings.Contains(f.Where, fragment) {
return true, f.Fatal
}
}
return false, false
}
func TestWhatTheEngineWorkedAroundIsReportedAsFatal(t *testing.T) {
// The server never fails on these; it works around them and logs. `check` is the place that looks on
// purpose (ADR-0029).
found := run(t, fstest.MapFS{
"content/posts/broken.md": {Data: []byte("---\ntitle: [unclosed\n---\nx\n")},
"content/posts/twice.md": {Data: []byte("---\ntitle: One\n---\nx\n")},
"content/posts/twice/index.md": {Data: []byte("---\ntitle: Two\n---\nx\n")},
"content/posts/a.md": {Data: []byte("---\ntitle: A\nslug: taken\n---\nx\n")},
"content/posts/taken.md": {Data: []byte("---\ntitle: Taken\n---\nx\n")},
})
for _, fragment := range []string{"cannot be parsed", "same key", "slug ignored"} {
said, fatal := says(found, fragment)
if !said {
t.Errorf("nothing reported %q:\n%v", fragment, found)
continue
}
if !fatal {
t.Errorf("%q is content the site is not serving, so it must be fatal", fragment)
}
}
}
func TestABrokenInternalLinkIsFatal(t *testing.T) {
found := run(t, fstest.MapFS{
"content/posts/one.md": {Data: []byte("---\ntitle: One\n---\n" +
"[good](/posts/two/) and [gone](/posts/never/) and [section](/posts/) and [alias](/posts/old/)\n")},
"content/posts/two.md": {Data: []byte("---\ntitle: Two\naliases: [posts/old]\n---\nx\n")},
})
if !brokenLink(found, "/posts/never/") {
t.Errorf("a link that 404s must be reported:\n%v", found)
}
if !Fatal(found) {
t.Error("a broken link makes the site wrong, so it must fail the check")
}
// Matched exactly, since "/posts/never/" contains "/posts/" — a substring test would pass either way.
for _, fine := range []string{"/posts/two/", "/posts/", "/posts/old/"} {
if brokenLink(found, fine) {
t.Errorf("%s resolves — a bundle, a listing and an alias are all real:\n%v", fine, found)
}
}
}
// brokenLink reports whether exactly this target was called out, rather than one that merely contains it.
func brokenLink(found []Finding, target string) bool {
for _, f := range found {
if strings.HasSuffix(f.What, "link goes nowhere: "+target) {
return true
}
}
return false
}
func TestEngineOwnedAndAssetLinksAreNotComplaints(t *testing.T) {
found := run(t, fstest.MapFS{
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\n---\n" +
"[css](/static/x.css) [feed](/feed.xml) [tag](/tags/monsoon/) [home](/) " +
"![pic](/art/set/one.jpg) ![missing](/art/set/nope.jpg)\n")},
"content/art/set/one.jpg": {Data: []byte("bytes")},
})
for _, quiet := range []string{"/static/x.css", "/feed.xml", "/tags/monsoon/", "/art/set/one.jpg"} {
if brokenLink(found, quiet) {
t.Errorf("%s should not be a finding:\n%v", quiet, found)
}
}
if !brokenLink(found, "/art/set/nope.jpg") || !Fatal(found) {
t.Errorf("a missing asset is a broken link:\n%v", found)
}
}
func TestTitlesAltTextAndMixedOrderingAreWarnings(t *testing.T) {
found := run(t, fstest.MapFS{
"content/posts/untitled.md": {Data: []byte("no frontmatter at all\n")},
"content/posts/pics.md": {Data: []byte("---\ntitle: Pics\n---\n" +
"{{< figure src=\"a.jpg\" alt=\"A described picture\" >}}\n\n{{< figure src=\"b.jpg\" >}}\n")},
"content/comics/s/_index.md": {Data: []byte("---\ntitle: S\n---\nx\n")},
"content/comics/s/one.md": {Data: []byte("---\ntitle: One\norder: 10\n---\nx\n")},
"content/comics/s/two.md": {Data: []byte("---\ntitle: Two\n---\nx\n")},
})
for _, fragment := range []string{"no title", "no alt text", "sort last"} {
said, fatal := says(found, fragment)
if !said {
t.Errorf("nothing reported %q:\n%v", fragment, found)
continue
}
if fatal {
t.Errorf("%q is untidy, not wrong — it must not fail the check", fragment)
}
}
// The described figure is not a finding.
if strings.Count(findingsAbout(found, "no alt text"), "\n") > 1 {
t.Errorf("only the undescribed figure should be reported:\n%v", found)
}
if Fatal(found) {
t.Errorf("nothing here is fatal, so the command should exit zero:\n%v", found)
}
}
func findingsAbout(found []Finding, fragment string) string {
var out strings.Builder
for _, f := range found {
if strings.Contains(f.What, fragment) {
out.WriteString(f.What + "\n")
}
}
return out.String()
}
func TestACleanSiteHasNothingToSay(t *testing.T) {
found := run(t, fstest.MapFS{
"content/posts/one.md": {Data: []byte("---\ntitle: One\ndate: 2026-01-01\n---\nSee [two](/posts/two/).\n")},
"content/posts/two.md": {Data: []byte("---\ntitle: Two\ndate: 2026-01-02\n---\nx\n")},
"content/art/set/index.md": {Data: []byte("---\ntitle: Set\n---\n{{< figure src=\"one.jpg\" alt=\"Described\" >}}\n")},
"content/art/set/one.jpg": {Data: []byte("bytes")},
})
if len(found) != 0 {
t.Errorf("a clean site should produce no findings, got:\n%v", found)
}
}
+8
View File
@@ -0,0 +1,8 @@
// Package check validates a site root and reports what an author should fix.
//
// Contributes: the `check` subcommand's findings (no request-path behaviour).
// Cascade keys: none.
// Contract fields: none.
// Not doing: near-duplicate tag detection, external link checking, spelling — each needs a judgement call the
// engine has no business making.
package check