Files
summercms/internal/docsite/check_links.go
Jakub Zych 5d4c1e3046 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
2026-09-30 21:40:46 +02:00

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
}