Files
summercms/internal/docsite/check_policy.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

106 lines
3.1 KiB
Go

package docsite
import (
"fmt"
"regexp"
"slices"
"strings"
gast "github.com/yuin/goldmark/ast"
)
// calloutTypes are the only `> [!TYPE]` callouts the theme renders.
var calloutTypes = []string{"NOTE", "TIP", "WARNING"}
// calloutMarker matches the first line of a callout blockquote.
var calloutMarker = regexp.MustCompile(`^\s{0,3}>\s?\[!([A-Za-z]+)\]\s*$`)
// checkPolicy enforces the page content rules:
// - every go fence in a docs/ page carries src= (module READMEs are
// rendered as written, D-18);
// - callouts are NOTE, TIP or WARNING;
// - docs/ headings are plain ASCII text without links or code spans.
func (s *site) checkPolicy(docs []parsedDoc) []Problem {
var problems []Problem
for _, d := range docs {
if d.page == nil {
continue
}
guide := d.page.Module == ""
lines := strings.Split(string(d.body), "\n")
fences := scanFences(lines)
inFence := make([]bool, len(lines))
for _, f := range fences {
end := f.close
if end < 0 {
end = len(lines) - 1
}
for i := f.open; i <= end; i++ {
inFence[i] = true
}
fields := strings.Fields(f.info)
if guide && len(fields) > 0 && fields[0] == "go" &&
!slices.ContainsFunc(fields[1:], func(f string) bool { return strings.HasPrefix(f, "src=") }) {
problems = append(problems, Problem{File: d.file, Line: d.line + f.open, Rule: "snippet",
Message: "go code block has no src= reference"})
}
}
for i, line := range lines {
if inFence[i] {
continue
}
if m := calloutMarker.FindStringSubmatch(line); m != nil && !slices.Contains(calloutTypes, m[1]) {
problems = append(problems, Problem{File: d.file, Line: d.line + i, Rule: "callout",
Message: fmt.Sprintf("unknown type %s (use NOTE, TIP or WARNING)", m[1])})
}
}
if guide {
problems = append(problems, headingProblems(d)...)
}
}
return problems
}
const headingMessage = "headings must be plain ASCII text without links or code"
// headingProblems reports headings with a link, image, code span or raw
// HTML, or with non-ASCII text: their IDs would not be stable across the
// site, Gitea and GitHub.
func headingProblems(d parsedDoc) []Problem {
var problems []Problem
_ = gast.Walk(d.doc, func(n gast.Node, entering bool) (gast.WalkStatus, error) {
h, ok := n.(*gast.Heading)
if !entering || !ok {
return gast.WalkContinue, nil
}
bad := false
_ = gast.Walk(h, func(c gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
switch t := c.(type) {
case *gast.Link, *gast.Image, *gast.CodeSpan, *gast.RawHTML, *gast.AutoLink:
bad = true
return gast.WalkStop, nil
case *gast.Text:
for _, b := range t.Segment.Value(d.body) {
if b >= 0x80 {
bad = true
return gast.WalkStop, nil
}
}
}
return gast.WalkContinue, nil
})
if bad {
off := -1
if h.Lines().Len() > 0 {
off = h.Lines().At(0).Start
}
problems = append(problems, Problem{File: d.file, Line: lineOf(d.body, off, d.line), Rule: "heading", Message: headingMessage})
}
return gast.WalkSkipChildren, nil
})
return problems
}