- 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
112 lines
3.2 KiB
Go
112 lines
3.2 KiB
Go
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
|
|
}
|