Item 2 of the order of work. An author writes `../day-01.en.md` — the path an editor preview resolves — and the engine emits `/posts/day-01/`. The larger effect is durability. Resolution goes through key → route, and a slug moves the route while never moving the key (ADR-0035), so a relative link survives a rename that a hand-written /posts/a-better-name/ does not. The demo proves it: `../renamed-thing.en.md` renders as href="/posts/a-better-name/" — the author wrote the filename and got the slugged address. This is the engine altering authored markup, which ADR-0045 polices, so the test that matters is what it declines to touch. Fourteen cases must survive exactly as written: an absolute URL, a scheme-relative URL, mailto:, tel:, a root-relative path, a bare fragment, a bare query, a name climbing out of content/, and every relative path whose extension is not .md. That last line is what keeps cover.jpg working — a bundle's assets already resolve because its URL mirrors its directory, so rewriting them would break what works. Nine rewrite cases sit beside them. Key derivation goes through content.KeyFromName, exported for this: the language-suffix rule is the part that would drift between two copies, so it lives in one place while the five lines of joining are duplicated in check. khosra check now reports a relative .md link resolving to no bundle, as fatal — verified by mistyping one and watching exit 1. Only the .md form: an extensionless relative path may be an asset, and a checker that calls a working link broken gets ignored wholesale. Two debts this change paid rather than deferred. render.go reached the file-length advisory, so theme parsing moved to theme.go — 414 and 105 lines, one topic each, since parsing runs per rebuild and rendering runs per request. Not a _helpers.go shard. And the demo's coverage test bound its renderer with a *copy* of the rebuilder's wiring, so it missed this feature entirely while the real binary served it correctly. Navigation had already drifted the same way. Both now call one bind(), which is exactly what ADR-0072 was written about — and the test failing is the only reason the copy was found. Extensions 7 → 8. Core 3020 → 3049 of 3400: the seam is ~20 lines, the feature is in ext where it belongs. 18 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
263 lines
9.3 KiB
Go
263 lines
9.3 KiB
Go
package check
|
|
|
|
import (
|
|
"bytes"
|
|
"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 bytes.Contains(b.Body, []byte("{{<")) {
|
|
// Fatal, because the old call is not a call any more: it renders as literal text in the page
|
|
// (ADR-0059). This is how a site root written against the old syntax is found and migrated.
|
|
found = append(found, Finding{b.Path,
|
|
`shortcode in the retired "{{< name key=\"value\" >}}" form; write ::name{key=value} instead (ADR-0059)`, true})
|
|
}
|
|
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 call, so its arguments can be examined (ADR-0059).
|
|
var figureCall = regexp.MustCompile(`(?m)^\s*::figure\{([^}]*)\}\s*$`)
|
|
|
|
// altArg matches a non-empty alt argument, quoted or not. An empty `alt=""` matches neither branch, which
|
|
// is the point: it is the same absence as leaving the argument out.
|
|
var altArg = regexp.MustCompile(`alt=("[^"]+"|[^"\s}]+)`)
|
|
|
|
// 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"]*)`)
|
|
|
|
// bundleLink matches a Markdown link whose target is a relative path naming a Markdown file.
|
|
//
|
|
// Only the `.md` form, deliberately. An extensionless relative path may perfectly well be an asset, and a
|
|
// checker that guessed would report a working link as broken — which is worse than missing one, since a
|
|
// checker nobody trusts gets ignored wholesale. A `.md` target is unambiguously meant to be a bundle.
|
|
var bundleLink = regexp.MustCompile(`\]\((\.{0,2}/?[^):\s"#?]*\.md)`)
|
|
|
|
// 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})
|
|
}
|
|
}
|
|
// Relative links are rewritten at render time (ADR-0087), so a mistyped one silently stays relative and
|
|
// 404s when somebody clicks it. Nothing else looks: the rule above only sees root-relative paths.
|
|
for _, m := range bundleLink.FindAllStringSubmatch(string(b.Body), -1) {
|
|
target := m[1]
|
|
if seen[target] {
|
|
continue
|
|
}
|
|
seen[target] = true
|
|
if _, ok := bundleAt(target, b, site); !ok {
|
|
found = append(found, Finding{b.Path, "relative link resolves to no bundle: " + target, true})
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
// bundleAt resolves a relative link the way the renderer does, and reports the key it names.
|
|
//
|
|
// The join and the prefix test are five obvious lines rather than a shared package — but the *key* derivation
|
|
// goes through content.KeyFromName, because the language-suffix rule is the part that would drift between two
|
|
// copies (ADR-0021, ADR-0087).
|
|
func bundleAt(target string, b content.Bundle, site *content.Site) (string, bool) {
|
|
joined := path.Join(path.Dir(b.Path), target)
|
|
if !strings.HasPrefix(joined, "content/") {
|
|
return "", false
|
|
}
|
|
key, _, ok := content.KeyFromName(strings.TrimPrefix(joined, "content/"))
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
if _, _, live := site.Lookup(key, b.Lang); !live {
|
|
return "", false
|
|
}
|
|
return key, true
|
|
}
|
|
|
|
// 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
|
|
}
|