feat(11.1-02): check links, commands, forbidden names and fence policy
- relative links and anchors resolve against the renderer's heading IDs - summer and ./bin/<app> command names come from the real command constructors through docsite.Options.Commands; a nil set is a problem - consuming-application names fail in page sources and built outputs - go fences in docs/ pages need src=, callouts are NOTE, TIP or WARNING, docs/ headings are plain ASCII - gate gains --claude and self-test plants for each new rule
This commit is contained in:
111
internal/docsite/check_links.go
Normal file
111
internal/docsite/check_links.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package docsite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
)
|
||||
|
||||
// checkLinks verifies every link and image destination in the pages
|
||||
// (guides and ingested READMEs): an anchor must be a heading ID of its
|
||||
// page, a relative .md link must resolve to a page of the site and its
|
||||
// fragment to a heading of that page. Heading IDs come from the same parse
|
||||
// and slug algorithm the renderer uses, so the site and the checker agree.
|
||||
// External http(s) and mailto links are allowed and never fetched.
|
||||
func (s *site) checkLinks(docs []parsedDoc) []Problem {
|
||||
ids := map[string]map[string]bool{}
|
||||
for _, d := range docs {
|
||||
if d.page == nil {
|
||||
continue
|
||||
}
|
||||
set := map[string]bool{}
|
||||
for _, h := range pageHeadings(d.doc, d.body) {
|
||||
set[h.ID] = true
|
||||
}
|
||||
ids[d.page.Source] = set
|
||||
}
|
||||
var problems []Problem
|
||||
for _, d := range docs {
|
||||
if d.page == nil {
|
||||
continue
|
||||
}
|
||||
_ = gast.Walk(d.doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return gast.WalkContinue, nil
|
||||
}
|
||||
var dest string
|
||||
switch l := n.(type) {
|
||||
case *gast.Link:
|
||||
dest = string(l.Destination)
|
||||
case *gast.Image:
|
||||
dest = string(l.Destination)
|
||||
default:
|
||||
return gast.WalkContinue, nil
|
||||
}
|
||||
if msg := s.checkLink(d.page, dest, ids); msg != "" {
|
||||
problems = append(problems, Problem{File: d.file, Line: lineOf(d.body, nodeOffset(n), d.line), Rule: "link", Message: msg})
|
||||
}
|
||||
return gast.WalkContinue, nil
|
||||
})
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// checkLink returns the problem message for one destination, or "".
|
||||
func (s *site) checkLink(from *Page, dest string, ids map[string]map[string]bool) string {
|
||||
switch {
|
||||
case dest == "":
|
||||
return "empty link destination does not resolve"
|
||||
case strings.HasPrefix(dest, "http://"), strings.HasPrefix(dest, "https://"), strings.HasPrefix(dest, "mailto:"):
|
||||
return ""
|
||||
case strings.HasPrefix(dest, "#"):
|
||||
frag := dest[1:]
|
||||
if !ids[from.Source][frag] {
|
||||
return fmt.Sprintf("#%s not found in %s", frag, from.Source)
|
||||
}
|
||||
return ""
|
||||
case hasScheme(dest), strings.HasPrefix(dest, "/"):
|
||||
return fmt.Sprintf("%s does not resolve", dest)
|
||||
}
|
||||
target, frag, _ := strings.Cut(dest, "#")
|
||||
if strings.HasSuffix(target, ".md") {
|
||||
p, _, ok := s.resolveLink(from, dest)
|
||||
if !ok {
|
||||
return fmt.Sprintf("%s does not resolve", dest)
|
||||
}
|
||||
if frag != "" && !ids[p.Source][frag] {
|
||||
return fmt.Sprintf("#%s not found in %s", frag, p.Source)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
// Any other relative path (a source file, .planning/, examples/) works
|
||||
// on the git host but breaks on the site. Module READMEs are read on
|
||||
// the git host first, so only guide pages are held to this.
|
||||
if from.Module == "" {
|
||||
return fmt.Sprintf("%s does not resolve", dest)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// nodeOffset returns the source offset of an inline node: its first text
|
||||
// descendant, else the first line of its enclosing block.
|
||||
func nodeOffset(n gast.Node) int {
|
||||
off := -1
|
||||
_ = gast.Walk(n, func(c gast.Node, entering bool) (gast.WalkStatus, error) {
|
||||
if t, ok := c.(*gast.Text); entering && ok {
|
||||
off = t.Segment.Start
|
||||
return gast.WalkStop, nil
|
||||
}
|
||||
return gast.WalkContinue, nil
|
||||
})
|
||||
if off >= 0 {
|
||||
return off
|
||||
}
|
||||
for p := n; p != nil; p = p.Parent() {
|
||||
if p.Type() == gast.TypeBlock && p.Lines().Len() > 0 {
|
||||
return p.Lines().At(0).Start
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
Reference in New Issue
Block a user