TestDocsRelativeLinks walked only docs/developer; widening it to all of docs/ immediately surfaced the same class of rot the gate was built for, outside its old reach: - docs/index.md was the old monolith's landing page: linked the deleted api-reference.md, 10 directory-style area links, and escaped the docs tree to README/AGENTS/.env/compose. Rewritten as a portal whose every link resolves, with one representative endpoint doc per API area and pointers to the new writing-ui-docs.md standard. - directory-style links in user stubs (devices/, ../operations/) - case-mismatched links (Development.md) in contributing.md - monolith-era pointer (API_REFERENCE.md, TROUBLESHOOTING.md) and escaped AGENTS.md links in contributing/development.md - docs/development/calibre-opf-implementation.md double-up escapes, a link to the removed repo-root plan file (absorbed here), and a source-code hyperlink the docs site can't serve (now inline code) - operations/operations.md linked three never-written guide pages as aspirational links - they stay plain text until pages exist HANDS-OFF (project owner, 2026-11-02): docs/user/filters/** and docs/user/searching-and-filtering.md are exempt as link sources so no future checker failure can force edits to the filter documentation. Files linking INTO them remain checked.
106 lines
3.1 KiB
Go
106 lines
3.1 KiB
Go
package docs
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestDocsRelativeLinks keeps every doc tree internally navigable: every
|
|
// relative markdown link must point at a file that exists. The api-reference
|
|
// overview once shipped 20+ directory-style links (authentication/ etc.)
|
|
// that 404'd — this gate makes that class of rot fail make test.
|
|
//
|
|
// Rules:
|
|
// - http(s):, mailto:, and pure #anchors are skipped
|
|
// - every other target must end in .md and resolve (relative to the
|
|
// linking file) to an existing file inside the docs tree
|
|
// - directory-style targets ("area/") are the exact bug this test exists
|
|
// for and always fail
|
|
//
|
|
// Hands-off sources (project owner instruction, 2026-11-02: DO NOT EDIT THE
|
|
// FILTER DOCS): docs/user/filters/** and docs/user/searching-and-filtering.md
|
|
// are not scanned as link sources, so no future gate failure can force an
|
|
// edit there. Files linking INTO them are still checked — resolution needs
|
|
// file existence only, never edits to their content.
|
|
|
|
var docsLinkRe = regexp.MustCompile(`\[[^\]]*\]\(([^)\s]+)\)`)
|
|
|
|
var handsOffFilterDocs = []string{
|
|
"user/filters/",
|
|
"user/searching-and-filtering.md",
|
|
}
|
|
|
|
func TestDocsRelativeLinks(t *testing.T) {
|
|
treeRoot, err := filepath.Abs("../../docs")
|
|
if err != nil {
|
|
t.Fatalf("abs docs root: %v", err)
|
|
}
|
|
err = filepath.WalkDir(treeRoot, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
|
|
return err
|
|
}
|
|
rel, err := filepath.Rel(treeRoot, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
relSlash := filepath.ToSlash(rel)
|
|
for _, off := range handsOffFilterDocs {
|
|
if strings.HasPrefix(relSlash, off) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, m := range docsLinkRe.FindAllStringSubmatch(string(raw), -1) {
|
|
target := m[1]
|
|
|
|
if strings.HasPrefix(target, "http://") ||
|
|
strings.HasPrefix(target, "https://") ||
|
|
strings.HasPrefix(target, "mailto:") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(target, "#") {
|
|
continue // in-page anchor
|
|
}
|
|
|
|
// strip a trailing fragment for file resolution
|
|
fileTarget := target
|
|
if i := strings.Index(fileTarget, "#"); i >= 0 {
|
|
fileTarget = fileTarget[:i]
|
|
}
|
|
if fileTarget == "" {
|
|
continue // pure-anchor form already handled; empty = odd, skip
|
|
}
|
|
|
|
if !strings.HasSuffix(fileTarget, ".md") {
|
|
t.Errorf("%s: link target %q is not a .md file — directory-style links 404 on the docs site; link the file (e.g. %q)",
|
|
rel, target, strings.TrimSuffix(fileTarget, "/")+".md")
|
|
continue
|
|
}
|
|
|
|
resolved := filepath.Clean(filepath.Join(filepath.Dir(absPath), fileTarget))
|
|
if !strings.HasPrefix(resolved, treeRoot+string(filepath.Separator)) {
|
|
t.Errorf("%s: link target %q escapes the docs tree", rel, target)
|
|
continue
|
|
}
|
|
if _, err := os.Stat(resolved); err != nil {
|
|
t.Errorf("%s: link target %q does not resolve to an existing file (%s)", rel, target, resolved)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("walk docs: %v", err)
|
|
}
|
|
}
|