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:
@@ -5,7 +5,15 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/internal/docsite"
|
"git.golem15.com/golem15/summercms/internal/docsite"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/compass"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/conga"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/flare"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/surf"
|
||||||
)
|
)
|
||||||
|
|
||||||
func docsBuildCommand() bonfire.Command {
|
func docsBuildCommand() bonfire.Command {
|
||||||
@@ -72,7 +80,7 @@ func docsSyncCommand() bonfire.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func docsOptions(in bonfire.Input) docsite.Options {
|
func docsOptions(in bonfire.Input) docsite.Options {
|
||||||
var opts docsite.Options
|
opts := docsite.Options{Commands: docsCommands()}
|
||||||
opts.Root, _ = in.Flag("root")
|
opts.Root, _ = in.Flag("root")
|
||||||
opts.Src, _ = in.Flag("src")
|
opts.Src, _ = in.Flag("src")
|
||||||
opts.Out, _ = in.Flag("out")
|
opts.Out, _ = in.Flag("out")
|
||||||
@@ -80,6 +88,34 @@ func docsOptions(in bonfire.Input) docsite.Options {
|
|||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// docsCommands collects the command names docs pages may show: the summer
|
||||||
|
// tool's own commands, and every command the generated application main
|
||||||
|
// registers (TestDocsCommandsMirrorGeneratedMain keeps this list in step
|
||||||
|
// with internal/build) plus the realtime and push commands applications
|
||||||
|
// append. The constructors only capture the app, so an empty config is
|
||||||
|
// enough; nothing runs.
|
||||||
|
func docsCommands() *docsite.Commands {
|
||||||
|
var tool []string
|
||||||
|
for _, c := range toolCommands() {
|
||||||
|
tool = append(tool, c.Name)
|
||||||
|
}
|
||||||
|
app := backpack.New(&compass.Config{})
|
||||||
|
var appCmds []bonfire.Command
|
||||||
|
appCmds = append(appCmds, lagoon.RuntimeCommands(app, nil)...)
|
||||||
|
appCmds = append(appCmds, lagoon.KeyGenerateCommand())
|
||||||
|
appCmds = append(appCmds, conga.RuntimeCommands(app, nil)...)
|
||||||
|
appCmds = append(appCmds, surf.ServeCommand(app, nil))
|
||||||
|
appCmds = append(appCmds, surf.RouteListCommand(app, nil))
|
||||||
|
appCmds = append(appCmds, cabana.RuntimeCommands(app)...)
|
||||||
|
appCmds = append(appCmds, centrifugo.Commands(app)...)
|
||||||
|
appCmds = append(appCmds, flare.Commands(app)...)
|
||||||
|
names := make([]string, 0, len(appCmds))
|
||||||
|
for _, c := range appCmds {
|
||||||
|
names = append(names, c.Name)
|
||||||
|
}
|
||||||
|
return &docsite.Commands{Tool: tool, App: names}
|
||||||
|
}
|
||||||
|
|
||||||
// reportDocsProblems prints one line per problem and a summary, and returns
|
// reportDocsProblems prints one line per problem and a summary, and returns
|
||||||
// a short error so the binary exits 1.
|
// a short error so the binary exits 1.
|
||||||
func reportDocsProblems(out bonfire.Output, cmd string, problems []docsite.Problem) error {
|
func reportDocsProblems(out bonfire.Output, cmd string, problems []docsite.Problem) error {
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -19,7 +23,7 @@ const repoRoot = "../.."
|
|||||||
|
|
||||||
// TestDocsTree fails with every problem line in the real docs tree.
|
// TestDocsTree fails with every problem line in the real docs tree.
|
||||||
func TestDocsTree(t *testing.T) {
|
func TestDocsTree(t *testing.T) {
|
||||||
problems, err := docsite.Check(docsite.Options{Root: repoRoot})
|
problems, err := docsite.Check(docsite.Options{Root: repoRoot, Commands: docsCommands()})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -115,7 +119,7 @@ func TestEveryModuleInSidebar(t *testing.T) {
|
|||||||
// .md siblings, llms.txt and llms-full.txt all list the same pages in the
|
// .md siblings, llms.txt and llms-full.txt all list the same pages in the
|
||||||
// same reading order.
|
// same reading order.
|
||||||
func TestDocsAIOutputsInSync(t *testing.T) {
|
func TestDocsAIOutputsInSync(t *testing.T) {
|
||||||
pages, problems, err := docsite.Pages(docsite.Options{Root: repoRoot})
|
pages, problems, err := docsite.Pages(docsite.Options{Root: repoRoot, Commands: docsCommands()})
|
||||||
if err != nil || len(problems) > 0 {
|
if err != nil || len(problems) > 0 {
|
||||||
t.Fatalf("Pages: %v %v", err, problems)
|
t.Fatalf("Pages: %v %v", err, problems)
|
||||||
}
|
}
|
||||||
@@ -223,3 +227,80 @@ func first(lines []string) string {
|
|||||||
}
|
}
|
||||||
return lines[0]
|
return lines[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDocsCommandNames(t *testing.T) {
|
||||||
|
cmds := docsCommands()
|
||||||
|
for _, want := range []string{"docs:build", "make:plugin", "migrate:status"} {
|
||||||
|
if !slices.Contains(cmds.Tool, want) {
|
||||||
|
t.Errorf("Tool is missing %s: %v", want, cmds.Tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"key:generate", "route:list", "admin:create", "queue:clear", "websockets:health"} {
|
||||||
|
if !slices.Contains(cmds.App, want) {
|
||||||
|
t.Errorf("App is missing %s: %v", want, cmds.App)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatedConstructor matches a command constructor the generated app main
|
||||||
|
// appends to its command list.
|
||||||
|
var generatedConstructor = regexp.MustCompile(`(?:commands :=|append\(commands,)\s*([a-z]+)\.([A-Z][A-Za-z0-9]*)\(app\b`)
|
||||||
|
|
||||||
|
// TestDocsCommandsMirrorGeneratedMain keeps docsCommands in step with the
|
||||||
|
// application main internal/build generates: every command constructor
|
||||||
|
// written there must also be called in docs.go.
|
||||||
|
func TestDocsCommandsMirrorGeneratedMain(t *testing.T) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
buildFile, err := parser.ParseFile(fset, filepath.Join(repoRoot, "internal", "build", "build.go"), nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var generated []string
|
||||||
|
ast.Inspect(buildFile, func(n ast.Node) bool {
|
||||||
|
lit, ok := n.(*ast.BasicLit)
|
||||||
|
if !ok || lit.Kind != token.STRING {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
v, err := strconv.Unquote(lit.Value)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, m := range generatedConstructor.FindAllStringSubmatch(v, -1) {
|
||||||
|
generated = append(generated, m[1]+"."+m[2])
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if len(generated) < 5 {
|
||||||
|
t.Fatalf("found %d constructors in internal/build/build.go (%v), want at least 5", len(generated), generated)
|
||||||
|
}
|
||||||
|
|
||||||
|
docsFile, err := parser.ParseFile(fset, "docs.go", nil, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var called []string
|
||||||
|
ast.Inspect(docsFile, func(n ast.Node) bool {
|
||||||
|
fn, ok := n.(*ast.FuncDecl)
|
||||||
|
if !ok || fn.Name.Name != "docsCommands" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ast.Inspect(fn, func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
|
||||||
|
if pkg, ok := sel.X.(*ast.Ident); ok {
|
||||||
|
called = append(called, pkg.Name+"."+sel.Sel.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
for _, c := range generated {
|
||||||
|
if !slices.Contains(called, c) {
|
||||||
|
t.Errorf("the generated main calls %s but docsCommands does not", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func TestToolDoesNotImportExamplePlugins(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if strings.Contains(path, "examples/hello") || strings.Contains(path, "golem15/fonoteka") {
|
if strings.Contains(path, "examples/hello") || strings.Contains(path, "docs/examples") || strings.Contains(path, "golem15/fonoteka") {
|
||||||
t.Fatalf("%s imports %s", name, path)
|
t.Fatalf("%s imports %s", name, path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
190
internal/docsite/check_commands.go
Normal file
190
internal/docsite/check_commands.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package docsite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Commands is the set of command names docs pages may show. The caller
|
||||||
|
// collects them from the real command constructors; the checker holds no
|
||||||
|
// hard-coded list.
|
||||||
|
type Commands struct {
|
||||||
|
// Tool lists the summer CLI command names.
|
||||||
|
Tool []string
|
||||||
|
// App lists the command names every application binary gets from the
|
||||||
|
// framework (migrations, queue, serve, admin and the rest).
|
||||||
|
App []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellLangs are the fence languages whose lines are read as commands.
|
||||||
|
var shellLangs = []string{"sh", "shell", "bash", "console"}
|
||||||
|
|
||||||
|
// commandToken finds `summer <name>` and `./bin/<app> <name>` at the start
|
||||||
|
// of a shell command (after an optional "$ " prompt).
|
||||||
|
var commandToken = regexp.MustCompile(`^(?:\$\s+)?(summer|\./bin/[A-Za-z0-9._-]+)\s+(\S+)`)
|
||||||
|
|
||||||
|
// commandSeparators split one shell line into its commands.
|
||||||
|
var commandSeparators = regexp.MustCompile(`&&|\|\||;|\|`)
|
||||||
|
|
||||||
|
// checkCommands verifies every summer and ./bin/<app> command name in shell
|
||||||
|
// fences and code spans of the pages. Application names may also come from
|
||||||
|
// bonfire.Command literals in docs/examples and examples.
|
||||||
|
func (s *site) checkCommands(docs []parsedDoc) ([]Problem, error) {
|
||||||
|
if s.opts.Commands == nil {
|
||||||
|
return []Problem{{File: s.rel(s.opts.Src), Rule: "command", Message: "no command set supplied"}}, nil
|
||||||
|
}
|
||||||
|
tool := map[string]bool{}
|
||||||
|
for _, n := range s.opts.Commands.Tool {
|
||||||
|
tool[n] = true
|
||||||
|
}
|
||||||
|
app := map[string]bool{}
|
||||||
|
for _, n := range s.opts.Commands.App {
|
||||||
|
app[n] = true
|
||||||
|
}
|
||||||
|
for _, dir := range []string{filepath.Join(s.opts.Src, "examples"), filepath.Join(s.opts.Root, "examples")} {
|
||||||
|
names, err := exampleCommandNames(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
app[n] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var problems []Problem
|
||||||
|
check := func(d parsedDoc, line int, text string) {
|
||||||
|
for _, cmd := range commandSeparators.Split(text, -1) {
|
||||||
|
m := commandToken.FindStringSubmatch(strings.TrimSpace(cmd))
|
||||||
|
if m == nil || strings.HasPrefix(m[2], "-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := m[2]
|
||||||
|
known := tool[name]
|
||||||
|
if m[1] != "summer" {
|
||||||
|
known = app[name]
|
||||||
|
}
|
||||||
|
if !known {
|
||||||
|
problems = append(problems, Problem{File: d.file, Line: line, Rule: "command",
|
||||||
|
Message: fmt.Sprintf("%q is not a summer or application command", name)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range docs {
|
||||||
|
if d.page == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, span := range codeSpans(d.doc, d.body, d.line) {
|
||||||
|
check(d, span.line, span.text)
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(d.body), "\n")
|
||||||
|
for _, f := range scanFences(lines) {
|
||||||
|
lang, _, _ := strings.Cut(f.info, " ")
|
||||||
|
if !slices.Contains(shellLangs, lang) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end := f.close
|
||||||
|
if end < 0 {
|
||||||
|
end = len(lines)
|
||||||
|
}
|
||||||
|
for i := f.open + 1; i < end; i++ {
|
||||||
|
check(d, d.line+i, lines[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// exampleCommandNames returns the string-literal Name of every
|
||||||
|
// bonfire.Command composite literal (including the elided elements of a
|
||||||
|
// []bonfire.Command literal) in the non-test Go files under dir. It parses
|
||||||
|
// the files; the tool never imports example code.
|
||||||
|
func exampleCommandNames(dir string) ([]string, error) {
|
||||||
|
if _, err := os.Stat(dir); errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := d.Name()
|
||||||
|
if d.IsDir() {
|
||||||
|
if p != dir && (strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") || name == "testdata" || name == "node_modules" || name == "vendor") {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f, err := parser.ParseFile(fset, p, nil, parser.SkipObjectResolution)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("docsite: parse %s: %w", p, err)
|
||||||
|
}
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
lit, ok := n.(*ast.CompositeLit)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case isBonfireCommand(lit.Type):
|
||||||
|
names = appendCommandName(names, lit)
|
||||||
|
case isBonfireCommandSlice(lit.Type):
|
||||||
|
for _, e := range lit.Elts {
|
||||||
|
if el, ok := e.(*ast.CompositeLit); ok && el.Type == nil {
|
||||||
|
names = appendCommandName(names, el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("docsite: scan %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBonfireCommand(expr ast.Expr) bool {
|
||||||
|
sel, ok := expr.(*ast.SelectorExpr)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pkg, ok := sel.X.(*ast.Ident)
|
||||||
|
return ok && pkg.Name == "bonfire" && sel.Sel.Name == "Command"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBonfireCommandSlice(expr ast.Expr) bool {
|
||||||
|
arr, ok := expr.(*ast.ArrayType)
|
||||||
|
return ok && isBonfireCommand(arr.Elt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendCommandName(names []string, lit *ast.CompositeLit) []string {
|
||||||
|
for _, e := range lit.Elts {
|
||||||
|
kv, ok := e.(*ast.KeyValueExpr)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, ok := kv.Key.(*ast.Ident)
|
||||||
|
if !ok || key.Name != "Name" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bl, ok := kv.Value.(*ast.BasicLit); ok && bl.Kind == token.STRING {
|
||||||
|
if v, err := strconv.Unquote(bl.Value); err == nil {
|
||||||
|
names = append(names, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
66
internal/docsite/check_forbidden.go
Normal file
66
internal/docsite/check_forbidden.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package docsite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// forbiddenName matches the consuming-application spellings framework docs
|
||||||
|
// must never contain (D-11), the accented variant included.
|
||||||
|
var forbiddenName = regexp.MustCompile(`(?i)fonoteka|p[lł][yý]tarium`)
|
||||||
|
|
||||||
|
const forbiddenMessage = "consuming-application name in output"
|
||||||
|
|
||||||
|
// forbiddenLines returns the 1-based line numbers of text that name a
|
||||||
|
// consuming application. The match itself is never reported.
|
||||||
|
func forbiddenLines(text []byte) []int {
|
||||||
|
var lines []int
|
||||||
|
for i, line := range strings.Split(string(text), "\n") {
|
||||||
|
if forbiddenName.MatchString(line) {
|
||||||
|
lines = append(lines, i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkForbiddenSources scans the full source file of every page (the
|
||||||
|
// frontmatter and a README's summary line included).
|
||||||
|
func (s *site) checkForbiddenSources() ([]Problem, error) {
|
||||||
|
var problems []Problem
|
||||||
|
for _, p := range s.pages {
|
||||||
|
raw, err := os.ReadFile(p.abs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("docsite: read %s: %w", p.Source, err)
|
||||||
|
}
|
||||||
|
for _, line := range forbiddenLines(raw) {
|
||||||
|
problems = append(problems, Problem{File: p.Source, Line: line, Rule: "forbidden", Message: forbiddenMessage})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forbiddenOutputExts are the text outputs scanned after rendering.
|
||||||
|
var forbiddenOutputExts = []string{".html", ".md", ".txt", ".json", ".js", ".css"}
|
||||||
|
|
||||||
|
// checkForbiddenOutputs scans every rendered text output, which covers
|
||||||
|
// text pulled in through src= snippet copies and templates.
|
||||||
|
func (s *site) checkForbiddenOutputs() []Problem {
|
||||||
|
names := make([]string, 0, len(s.outputs))
|
||||||
|
for name := range s.outputs {
|
||||||
|
if slices.Contains(forbiddenOutputExts, path.Ext(name)) {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(names)
|
||||||
|
var problems []Problem
|
||||||
|
for _, name := range names {
|
||||||
|
for _, line := range forbiddenLines(s.outputs[name]) {
|
||||||
|
problems = append(problems, Problem{File: name, Line: line, Rule: "forbidden", Message: forbiddenMessage})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
105
internal/docsite/check_policy.go
Normal file
105
internal/docsite/check_policy.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ func TestIdentifierChecker(t *testing.T) {
|
|||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
root := identFixture(t, passing, "## Usage\n\nCall `fixture.New()`.\n")
|
root := identFixture(t, passing, "## Usage\n\nCall `fixture.New()`.\n")
|
||||||
problems, err := Check(Options{Root: root})
|
problems, err := Check(Options{Root: root, Commands: fixtureCommands})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ func TestIdentifierChecker(t *testing.T) {
|
|||||||
failing := passing + "Then `fixture.Missing` and `fixture.Bus.Nope`.\n\n`fixture.Hidden` lives in testdata.\n"
|
failing := passing + "Then `fixture.Missing` and `fixture.Bus.Nope`.\n\n`fixture.Hidden` lives in testdata.\n"
|
||||||
root = identFixture(t, failing, "## Usage\n\nCall `fixture.Gone()`.\n")
|
root = identFixture(t, failing, "## Usage\n\nCall `fixture.Gone()`.\n")
|
||||||
writeFile(t, root, "README.md", "# Root\n\nSee `x.Y`.\n\n`sub.Nothing`\n")
|
writeFile(t, root, "README.md", "# Root\n\nSee `x.Y`.\n\n`sub.Nothing`\n")
|
||||||
problems, err = Check(Options{Root: root})
|
problems, err = Check(Options{Root: root, Commands: fixtureCommands})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -132,3 +132,145 @@ func TestIdentifierIndexDuplicateName(t *testing.T) {
|
|||||||
t.Fatalf("problems = %q, want [%q]", got, want)
|
t.Fatalf("problems = %q, want [%q]", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkFixture writes a fixture tree around one index body and one
|
||||||
|
// fixture README section and returns its problem lines.
|
||||||
|
func checkFixture(t *testing.T, cmds *Commands, files map[string]string) []string {
|
||||||
|
t.Helper()
|
||||||
|
tree := map[string]string{
|
||||||
|
"docs/site.yaml": fixtureSite,
|
||||||
|
"docs/setup/start.md": page("Start", "setup", 10, "## First steps\n\nText.\n"),
|
||||||
|
"modules/fixture/fixture.go": "package fixture\n",
|
||||||
|
"modules/fixture/README.md": "# fixture\n\nFixture does one thing.\n\n## Usage\n\nText.\n",
|
||||||
|
}
|
||||||
|
for k, v := range files {
|
||||||
|
tree[k] = v
|
||||||
|
}
|
||||||
|
root := writeTree(t, tree)
|
||||||
|
problems, err := Check(Options{Root: root, Commands: cmds})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return problemLines(problems)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertProblems(t *testing.T, got, want []string) {
|
||||||
|
t.Helper()
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkChecker(t *testing.T) {
|
||||||
|
good := "## Alpha\n\nSee [alpha](#alpha), [start](setup/start.md#first-steps), " +
|
||||||
|
"[usage](../modules/fixture/README.md#usage), [web](https://example.com) and [mail](mailto:a@example.com).\n\n" +
|
||||||
|
"\n"
|
||||||
|
assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{
|
||||||
|
"docs/index.md": page("Acme docs", "index", 0, good),
|
||||||
|
"modules/fixture/README.md": "# fixture\n\nFixture does one thing.\n\n## Usage\n\n" +
|
||||||
|
"See [start](../../docs/setup/start.md) and [source](fixture.go).\n",
|
||||||
|
}), nil)
|
||||||
|
|
||||||
|
bad := good + "\n[a](#nope) [b](setup/missing.md) [c](setup/start.md#nope)\n\n" +
|
||||||
|
"[d](../modules/fixture/fixture.go) [e](/abs.html)\n"
|
||||||
|
assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{
|
||||||
|
"docs/index.md": page("Acme docs", "index", 0, bad),
|
||||||
|
"modules/fixture/README.md": "# fixture\n\nFixture does one thing.\n\n## Usage\n\n" +
|
||||||
|
"See [other](../other/README.md) and [anchor](#missing).\n",
|
||||||
|
}), []string{
|
||||||
|
"docs/index.md:15: link: #nope not found in docs/index.md",
|
||||||
|
"docs/index.md:15: link: setup/missing.md does not resolve",
|
||||||
|
"docs/index.md:15: link: #nope not found in docs/setup/start.md",
|
||||||
|
"docs/index.md:17: link: ../modules/fixture/fixture.go does not resolve",
|
||||||
|
"docs/index.md:17: link: /abs.html does not resolve",
|
||||||
|
"modules/fixture/README.md:7: link: ../other/README.md does not resolve",
|
||||||
|
"modules/fixture/README.md:7: link: #missing not found in modules/fixture/README.md",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandChecker(t *testing.T) {
|
||||||
|
example := "package main\n\nimport \"example.com/bonfire\"\n\n" +
|
||||||
|
"var one = bonfire.Command{Name: \"acme:greet\"}\n\n" +
|
||||||
|
"var many = []bonfire.Command{{Name: \"acme:list\"}, {Name: \"acme:sync\"}}\n"
|
||||||
|
good := "Run `summer docs:build` or `$ summer make:plugin acme.blog`.\n\n" +
|
||||||
|
"```sh\n$ summer docs:build --out site\nsummer --help\ncd app && summer make:plugin acme.blog\n" +
|
||||||
|
"./bin/acme serve --addr :8080\n./bin/acme acme:greet blog\n./bin/acme acme:sync\n```\n\n" +
|
||||||
|
"```text\nsummer not:checked\n```\n\n`summer.yaml` and `go install ./cmd/summer` are not commands.\n"
|
||||||
|
files := map[string]string{
|
||||||
|
"docs/index.md": page("Acme docs", "index", 0, good),
|
||||||
|
"docs/examples/greet/main.go": example,
|
||||||
|
"examples/app/plugins/p/plugin.go": example,
|
||||||
|
}
|
||||||
|
assertProblems(t, checkFixture(t, fixtureCommands, files), nil)
|
||||||
|
|
||||||
|
files["docs/index.md"] = page("Acme docs", "index", 0, good+
|
||||||
|
"\nThen `summer no:such`.\n\n```bash\n./bin/acme docs:build\nsummer migrate\n```\n")
|
||||||
|
files["modules/fixture/README.md"] = "# fixture\n\nFixture does one thing.\n\n## Usage\n\n```sh\n./bin/acme fixture:run\n```\n"
|
||||||
|
assertProblems(t, checkFixture(t, fixtureCommands, files), []string{
|
||||||
|
`docs/index.md:26: command: "no:such" is not a summer or application command`,
|
||||||
|
`docs/index.md:29: command: "docs:build" is not a summer or application command`,
|
||||||
|
`docs/index.md:30: command: "migrate" is not a summer or application command`,
|
||||||
|
`modules/fixture/README.md:8: command: "fixture:run" is not a summer or application command`,
|
||||||
|
})
|
||||||
|
|
||||||
|
assertProblems(t, checkFixture(t, nil, map[string]string{
|
||||||
|
"docs/index.md": page("Acme docs", "index", 0, "Text.\n"),
|
||||||
|
}), []string{"docs: command: no command set supplied"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForbiddenChecker(t *testing.T) {
|
||||||
|
// The forbidden words are built at run time so no test source names a
|
||||||
|
// consuming application.
|
||||||
|
name := "Fono" + "teka"
|
||||||
|
accented := "P" + "Ł" + "Ý" + "tarium"
|
||||||
|
assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{
|
||||||
|
"docs/index.md": page("Acme docs", "index", 0, "The host application.\n"),
|
||||||
|
}), nil)
|
||||||
|
|
||||||
|
got := checkFixture(t, fixtureCommands, map[string]string{
|
||||||
|
"docs/index.md": page("Acme docs", "index", 0, "The "+name+" app.\n"),
|
||||||
|
"docs/setup/start.md": page("Start", "setup", 10, "## First steps\n\nSee "+accented+".\n"),
|
||||||
|
})
|
||||||
|
assertProblems(t, got, []string{
|
||||||
|
"docs/index.md:9: forbidden: consuming-application name in output",
|
||||||
|
"docs/setup/start.md:11: forbidden: consuming-application name in output",
|
||||||
|
})
|
||||||
|
|
||||||
|
site := strings.Replace(fixtureSite, "description: Acme docs.", "description: Docs for "+strings.ToLower(name)+".", 1)
|
||||||
|
got = checkFixture(t, fixtureCommands, map[string]string{
|
||||||
|
"docs/site.yaml": site,
|
||||||
|
"docs/index.md": page("Acme docs", "index", 0, "Text.\n"),
|
||||||
|
})
|
||||||
|
if !slices.Contains(got, "llms.txt:3: forbidden: consuming-application name in output") {
|
||||||
|
t.Fatalf("output check missed llms.txt: %q", got)
|
||||||
|
}
|
||||||
|
for _, line := range got {
|
||||||
|
if !strings.HasSuffix(line, ": forbidden: consuming-application name in output") ||
|
||||||
|
strings.Contains(strings.ToLower(line), strings.ToLower(name)) {
|
||||||
|
t.Fatalf("unexpected problem line %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFencePolicy(t *testing.T) {
|
||||||
|
readme := "# fixture\n\nFixture does one thing.\n\n## Usage of `fixture`\n\n```go\nfixture.Run()\n```\n\n> [!TIP]\n> Fine.\n"
|
||||||
|
good := "## Plain heading\n\n> [!NOTE]\n> A note.\n\n> [!WARNING]\n> Careful.\n\n```text\nplain\n```\n\n" +
|
||||||
|
"```yaml\nkey: value\n```\n\n```md\n> [!DANGER]\n```\n"
|
||||||
|
assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{
|
||||||
|
"docs/index.md": page("Acme docs", "index", 0, good),
|
||||||
|
"modules/fixture/README.md": readme,
|
||||||
|
}), nil)
|
||||||
|
|
||||||
|
bad := good + "\n```go\nfmt.Println()\n```\n\n> [!DANGER]\n> Boom.\n\n## Use `fixture`\n\n## Zażółć\n\n## See [start](setup/start.md)\n"
|
||||||
|
assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{
|
||||||
|
"docs/index.md": page("Acme docs", "index", 0, bad),
|
||||||
|
"modules/fixture/README.md": readme + "\n> [!CAUTION]\n> No.\n",
|
||||||
|
}), []string{
|
||||||
|
"docs/index.md:29: snippet: go code block has no src= reference",
|
||||||
|
"docs/index.md:33: callout: unknown type DANGER (use NOTE, TIP or WARNING)",
|
||||||
|
"docs/index.md:36: heading: headings must be plain ASCII text without links or code",
|
||||||
|
"docs/index.md:38: heading: headings must be plain ASCII text without links or code",
|
||||||
|
"docs/index.md:40: heading: headings must be plain ASCII text without links or code",
|
||||||
|
"modules/fixture/README.md:14: callout: unknown type CAUTION (use NOTE, TIP or WARNING)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ type Options struct {
|
|||||||
Out string
|
Out string
|
||||||
// BaseURL overrides site.yaml base_url when non-empty.
|
// BaseURL overrides site.yaml base_url when non-empty.
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
// Commands is the set of summer and application command names pages
|
||||||
|
// may show. Check and Build report a problem when it is nil.
|
||||||
|
Commands *Commands
|
||||||
}
|
}
|
||||||
|
|
||||||
// Problem is one finding in the docs source, printed as
|
// Problem is one finding in the docs source, printed as
|
||||||
@@ -230,7 +233,9 @@ func writeOutputs(out string, files map[string][]byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkContent runs the accuracy checkers over every page and the root
|
// checkContent runs the accuracy checkers over every page and the root
|
||||||
// README.md: module identifiers named in code spans.
|
// README.md: module identifiers, links and anchors, command names,
|
||||||
|
// consuming-application names in the sources, and the fence, callout and
|
||||||
|
// heading policy.
|
||||||
func (s *site) checkContent() ([]Problem, error) {
|
func (s *site) checkContent() ([]Problem, error) {
|
||||||
docs, err := s.parseDocs()
|
docs, err := s.parseDocs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -241,6 +246,18 @@ func (s *site) checkContent() ([]Problem, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
problems = append(problems, s.checkIdentifiers(idx, docs)...)
|
problems = append(problems, s.checkIdentifiers(idx, docs)...)
|
||||||
|
problems = append(problems, s.checkLinks(docs)...)
|
||||||
|
cp, err := s.checkCommands(docs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
problems = append(problems, cp...)
|
||||||
|
fp, err := s.checkForbiddenSources()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
problems = append(problems, fp...)
|
||||||
|
problems = append(problems, s.checkPolicy(docs)...)
|
||||||
return problems, nil
|
return problems, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ func problemLines(problems []Problem) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fixtureCommands is the command set the fixture trees are checked with.
|
||||||
|
var fixtureCommands = &Commands{Tool: []string{"docs:build", "make:plugin"}, App: []string{"serve", "migrate"}}
|
||||||
|
|
||||||
const fixtureSite = `title: Acme
|
const fixtureSite = `title: Acme
|
||||||
description: Acme docs.
|
description: Acme docs.
|
||||||
sections:
|
sections:
|
||||||
@@ -110,7 +113,7 @@ func TestReadmeIngestion(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
out := filepath.Join(t.TempDir(), "site")
|
out := filepath.Join(t.TempDir(), "site")
|
||||||
res, problems, err := Build(Options{Root: root, Out: out})
|
res, problems, err := Build(Options{Root: root, Commands: fixtureCommands, Out: out})
|
||||||
if err != nil || len(problems) > 0 {
|
if err != nil || len(problems) > 0 {
|
||||||
t.Fatalf("Build: %v %q", err, problemLines(problems))
|
t.Fatalf("Build: %v %q", err, problemLines(problems))
|
||||||
}
|
}
|
||||||
@@ -302,7 +305,7 @@ func TestSnippetConfinement(t *testing.T) {
|
|||||||
if err := os.Symlink(outside, filepath.Join(root, "escape.txt")); err != nil {
|
if err := os.Symlink(outside, filepath.Join(root, "escape.txt")); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
problems, err := Check(Options{Root: root})
|
problems, err := Check(Options{Root: root, Commands: fixtureCommands})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -316,7 +319,7 @@ func TestSnippetConfinement(t *testing.T) {
|
|||||||
t.Errorf("src=%s: no problem %q...%q in\n%s", tc.src, prefix, tc.want, strings.Join(got, "\n"))
|
t.Errorf("src=%s: no problem %q...%q in\n%s", tc.src, prefix, tc.want, strings.Join(got, "\n"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, _, err := Build(Options{Root: root, Out: filepath.Join(t.TempDir(), "site")}); err != nil {
|
if _, _, err := Build(Options{Root: root, Commands: fixtureCommands, Out: filepath.Join(t.TempDir(), "site")}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,7 +330,7 @@ func TestSyncRewritesDrift(t *testing.T) {
|
|||||||
root := snippetTree(t, map[string]string{"docs/setup/start.md": doc})
|
root := snippetTree(t, map[string]string{"docs/setup/start.md": doc})
|
||||||
path := filepath.Join(root, "docs/setup/start.md")
|
path := filepath.Join(root, "docs/setup/start.md")
|
||||||
|
|
||||||
problems, err := Check(Options{Root: root})
|
problems, err := Check(Options{Root: root, Commands: fixtureCommands})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -336,7 +339,7 @@ func TestSyncRewritesDrift(t *testing.T) {
|
|||||||
t.Fatalf("problems = %q, want [%q]", got, want)
|
t.Fatalf("problems = %q, want [%q]", got, want)
|
||||||
}
|
}
|
||||||
out := filepath.Join(t.TempDir(), "site")
|
out := filepath.Join(t.TempDir(), "site")
|
||||||
if _, problems, err := Build(Options{Root: root, Out: out}); err != nil || len(problems) != 1 {
|
if _, problems, err := Build(Options{Root: root, Commands: fixtureCommands, Out: out}); err != nil || len(problems) != 1 {
|
||||||
t.Fatalf("Build with drift: %v %v", err, problems)
|
t.Fatalf("Build with drift: %v %v", err, problems)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(out); err == nil {
|
if _, err := os.Stat(out); err == nil {
|
||||||
@@ -362,10 +365,10 @@ func TestSyncRewritesDrift(t *testing.T) {
|
|||||||
if res, _, err := Sync(Options{Root: root}); err != nil || res != (SyncResult{}) {
|
if res, _, err := Sync(Options{Root: root}); err != nil || res != (SyncResult{}) {
|
||||||
t.Fatalf("second Sync = %+v, %v; want up to date", res, err)
|
t.Fatalf("second Sync = %+v, %v; want up to date", res, err)
|
||||||
}
|
}
|
||||||
if problems, err := Check(Options{Root: root}); err != nil || len(problems) > 0 {
|
if problems, err := Check(Options{Root: root, Commands: fixtureCommands}); err != nil || len(problems) > 0 {
|
||||||
t.Fatalf("Check after Sync: %v %q", err, problemLines(problems))
|
t.Fatalf("Check after Sync: %v %q", err, problemLines(problems))
|
||||||
}
|
}
|
||||||
if _, problems, err := Build(Options{Root: root, Out: out}); err != nil || len(problems) > 0 {
|
if _, problems, err := Build(Options{Root: root, Commands: fixtureCommands, Out: out}); err != nil || len(problems) > 0 {
|
||||||
t.Fatalf("Build after Sync: %v %v", err, problems)
|
t.Fatalf("Build after Sync: %v %v", err, problems)
|
||||||
}
|
}
|
||||||
md, err := os.ReadFile(filepath.Join(out, "setup/start.md"))
|
md, err := os.ReadFile(filepath.Join(out, "setup/start.md"))
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ func assemble(opts Options) (*site, []Problem, error) {
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
problems = append(problems, rp...)
|
problems = append(problems, rp...)
|
||||||
|
problems = append(problems, s.checkForbiddenOutputs()...)
|
||||||
}
|
}
|
||||||
sortProblems(problems)
|
sortProblems(problems)
|
||||||
return s, problems, nil
|
return s, problems, nil
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ usage:
|
|||||||
check-phase11.1.sh --deps
|
check-phase11.1.sh --deps
|
||||||
check-phase11.1.sh --docs
|
check-phase11.1.sh --docs
|
||||||
check-phase11.1.sh --forbidden
|
check-phase11.1.sh --forbidden
|
||||||
|
check-phase11.1.sh --claude
|
||||||
check-phase11.1.sh --self-test
|
check-phase11.1.sh --self-test
|
||||||
check-phase11.1.sh --go
|
check-phase11.1.sh --go
|
||||||
check-phase11.1.sh --all
|
check-phase11.1.sh --all
|
||||||
@@ -118,6 +119,24 @@ run_forbidden() {
|
|||||||
echo "phase11.1 forbidden passed"
|
echo "phase11.1 forbidden passed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The D-13 / DOCS-08 rules CLAUDE.md's Documentation section must carry.
|
||||||
|
CLAUDE_RULES=(
|
||||||
|
'also updates the affected pages under `docs/` in the same change'
|
||||||
|
'`go test ./cmd/summer -run TestDocsTree` and `summer docs:build --check` check identifiers'
|
||||||
|
'Every identifier named in a README or a docs page must exist in the package.'
|
||||||
|
'Config keys named in README or docs pages are not checked automatically yet'
|
||||||
|
)
|
||||||
|
|
||||||
|
run_claude() {
|
||||||
|
local section rule
|
||||||
|
section="$(awk '/^## Documentation$/{on=1; next} /^## /{on=0} on' "$ROOT/CLAUDE.md")"
|
||||||
|
[ -n "$section" ] || refuse "CLAUDE.md has no Documentation section"
|
||||||
|
for rule in "${CLAUDE_RULES[@]}"; do
|
||||||
|
grep -Fq -- "$rule" <<<"$section" || refuse "CLAUDE.md Documentation section is missing: $rule"
|
||||||
|
done
|
||||||
|
echo "phase11.1 claude passed"
|
||||||
|
}
|
||||||
|
|
||||||
run_go() {
|
run_go() {
|
||||||
(cd "$ROOT" && go vet ./...)
|
(cd "$ROOT" && go vet ./...)
|
||||||
(cd "$ROOT" && go test ./...)
|
(cd "$ROOT" && go test ./...)
|
||||||
@@ -176,6 +195,27 @@ run_self_test() {
|
|||||||
expect_refusal 'frontmatter typo' 'frontmatter: unknown field' "$summer" "$scratch"
|
expect_refusal 'frontmatter typo' 'frontmatter: unknown field' "$summer" "$scratch"
|
||||||
restore "$pristine" "$scratch" docs/setup/installation.md
|
restore "$pristine" "$scratch" docs/setup/installation.md
|
||||||
|
|
||||||
|
printf '\nSee [nowhere](#no-such-anchor).\n' >>"$scratch/docs/index.md"
|
||||||
|
expect_refusal 'broken anchor' 'link: #no-such-anchor not found in' "$summer" "$scratch"
|
||||||
|
restore "$pristine" "$scratch" docs/index.md
|
||||||
|
|
||||||
|
printf '\nRun `summer no:such`.\n' >>"$scratch/docs/index.md"
|
||||||
|
expect_refusal 'unknown command' '"no:such" is not a summer or application command' "$summer" "$scratch"
|
||||||
|
restore "$pristine" "$scratch" docs/index.md
|
||||||
|
|
||||||
|
# Built from two halves so this script's plant is not itself a hit.
|
||||||
|
printf '\nThe %s%s application.\n' 'fono' 'teka' >>"$scratch/docs/index.md"
|
||||||
|
expect_refusal 'forbidden name' 'forbidden: consuming-application name in output' "$summer" "$scratch"
|
||||||
|
restore "$pristine" "$scratch" docs/index.md
|
||||||
|
|
||||||
|
printf '\n```go\nx := 1\n```\n' >>"$scratch/docs/index.md"
|
||||||
|
expect_refusal 'go fence without src=' 'snippet: go code block has no src= reference' "$summer" "$scratch"
|
||||||
|
restore "$pristine" "$scratch" docs/index.md
|
||||||
|
|
||||||
|
printf '\n> [!DANGER]\n> Planted.\n' >>"$scratch/docs/index.md"
|
||||||
|
expect_refusal 'unknown callout' 'callout: unknown type DANGER (use NOTE, TIP or WARNING)' "$summer" "$scratch"
|
||||||
|
restore "$pristine" "$scratch" docs/index.md
|
||||||
|
|
||||||
echo "phase11.1 self-test passed"
|
echo "phase11.1 self-test passed"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +224,7 @@ case "${1:-}" in
|
|||||||
--deps) run_deps ;;
|
--deps) run_deps ;;
|
||||||
--docs) run_docs ;;
|
--docs) run_docs ;;
|
||||||
--forbidden) run_forbidden ;;
|
--forbidden) run_forbidden ;;
|
||||||
|
--claude) run_claude ;;
|
||||||
--self-test) run_self_test ;;
|
--self-test) run_self_test ;;
|
||||||
--go) run_go ;;
|
--go) run_go ;;
|
||||||
--all)
|
--all)
|
||||||
@@ -192,6 +233,7 @@ case "${1:-}" in
|
|||||||
run_self_test
|
run_self_test
|
||||||
run_docs
|
run_docs
|
||||||
run_forbidden
|
run_forbidden
|
||||||
|
run_claude
|
||||||
run_go
|
run_go
|
||||||
echo "phase11.1 all passed"
|
echo "phase11.1 all passed"
|
||||||
;;
|
;;
|
||||||
|
|||||||
Reference in New Issue
Block a user