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:
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user