- 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
269 lines
7.5 KiB
Go
269 lines
7.5 KiB
Go
// Package docsite builds the SummerCMS documentation site. It reads the
|
|
// Markdown pages under docs/ (strict YAML frontmatter, sections from
|
|
// docs/site.yaml), ingests every module README as an API reference page and
|
|
// writes a static site: HTML pages, a clean Markdown sibling per page,
|
|
// llms.txt, llms-full.txt and a search index.
|
|
//
|
|
// Check, Build and Sync are the entry points; the summer CLI exposes them as
|
|
// docs:build and docs:sync, and the tests call them on the real tree.
|
|
package docsite
|
|
|
|
import (
|
|
"cmp"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
// MarkerFile is written into every output directory. Build only cleans an
|
|
// existing, non-empty output directory that holds it.
|
|
const MarkerFile = ".summer-docs"
|
|
|
|
// Options locates the repository, the docs source and the output directory.
|
|
type Options struct {
|
|
// Root is the repository root. src= paths and modules/ resolve
|
|
// against it. Empty means the current directory.
|
|
Root string
|
|
// Src is the docs source directory, default <Root>/docs.
|
|
Src string
|
|
// Out is the output directory, default <Root>/site.
|
|
Out string
|
|
// BaseURL overrides site.yaml base_url when non-empty.
|
|
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
|
|
// "file:line: rule: message".
|
|
type Problem struct {
|
|
File string
|
|
Line int
|
|
Rule string
|
|
Message string
|
|
}
|
|
|
|
// String formats the problem as "file:line: rule: message", or
|
|
// "file: rule: message" when the problem has no line.
|
|
func (p Problem) String() string {
|
|
if p.Line > 0 {
|
|
return fmt.Sprintf("%s:%d: %s: %s", p.File, p.Line, p.Rule, p.Message)
|
|
}
|
|
return fmt.Sprintf("%s: %s: %s", p.File, p.Rule, p.Message)
|
|
}
|
|
|
|
// Result reports a successful build.
|
|
type Result struct {
|
|
Pages int
|
|
Out string
|
|
}
|
|
|
|
// SyncResult reports how many snippets Sync rewrote and in how many files.
|
|
type SyncResult struct {
|
|
Snippets int
|
|
Files int
|
|
}
|
|
|
|
// Check loads, verifies and renders the docs in memory. It writes nothing.
|
|
func Check(opts Options) ([]Problem, error) {
|
|
_, problems, err := assemble(opts)
|
|
return problems, err
|
|
}
|
|
|
|
// Build runs Check. When problems exist it writes nothing and returns them;
|
|
// otherwise it guards and cleans the output directory, then writes every file.
|
|
func Build(opts Options) (Result, []Problem, error) {
|
|
opts, err := opts.normalize()
|
|
if err != nil {
|
|
return Result{}, nil, err
|
|
}
|
|
if err := guardOutPath(opts); err != nil {
|
|
return Result{}, nil, err
|
|
}
|
|
s, problems, err := assemble(opts)
|
|
if err != nil {
|
|
return Result{}, nil, err
|
|
}
|
|
if len(problems) > 0 {
|
|
return Result{}, problems, nil
|
|
}
|
|
if err := prepareOut(opts.Out); err != nil {
|
|
return Result{}, nil, err
|
|
}
|
|
if err := writeOutputs(opts.Out, s.outputs); err != nil {
|
|
return Result{}, nil, err
|
|
}
|
|
if err := os.WriteFile(filepath.Join(opts.Out, MarkerFile), []byte("generated by summer docs:build\n"), 0o644); err != nil {
|
|
return Result{}, nil, fmt.Errorf("docs:build: write marker: %w", err)
|
|
}
|
|
return Result{Pages: len(s.pages), Out: opts.Out}, nil, nil
|
|
}
|
|
|
|
func (o Options) normalize() (Options, error) {
|
|
root := o.Root
|
|
if root == "" {
|
|
root = "."
|
|
}
|
|
abs := func(p string) (string, error) {
|
|
a, err := filepath.Abs(p)
|
|
if err != nil {
|
|
return "", fmt.Errorf("docsite: resolve %s: %w", p, err)
|
|
}
|
|
return filepath.Clean(a), nil
|
|
}
|
|
var err error
|
|
if o.Root, err = abs(root); err != nil {
|
|
return o, err
|
|
}
|
|
if o.Src == "" {
|
|
o.Src = filepath.Join(o.Root, "docs")
|
|
}
|
|
if o.Src, err = abs(o.Src); err != nil {
|
|
return o, err
|
|
}
|
|
if o.Out == "" {
|
|
o.Out = filepath.Join(o.Root, "site")
|
|
}
|
|
if o.Out, err = abs(o.Out); err != nil {
|
|
return o, err
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
// within reports whether p equals dir or sits below it.
|
|
func within(p, dir string) bool {
|
|
if p == dir {
|
|
return true
|
|
}
|
|
rel, err := filepath.Rel(dir, p)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
|
|
}
|
|
|
|
// resolved returns p with symlinks evaluated, falling back to p when it (or
|
|
// a parent) does not exist yet.
|
|
func resolved(p string) string {
|
|
cur, rest := p, ""
|
|
for {
|
|
if r, err := filepath.EvalSymlinks(cur); err == nil {
|
|
return filepath.Join(r, rest)
|
|
}
|
|
parent := filepath.Dir(cur)
|
|
if parent == cur {
|
|
return p
|
|
}
|
|
rest = filepath.Join(filepath.Base(cur), rest)
|
|
cur = parent
|
|
}
|
|
}
|
|
|
|
// guardOutPath refuses an output directory equal to the repository root,
|
|
// inside the docs source, or containing the source or the root.
|
|
func guardOutPath(opts Options) error {
|
|
outs := []string{opts.Out, resolved(opts.Out)}
|
|
srcs := []string{opts.Src, resolved(opts.Src)}
|
|
roots := []string{opts.Root, resolved(opts.Root)}
|
|
for _, out := range outs {
|
|
for _, src := range srcs {
|
|
if within(out, src) || within(src, out) {
|
|
return errOutInside
|
|
}
|
|
}
|
|
for _, root := range roots {
|
|
if within(root, out) {
|
|
return errOutInside
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var errOutInside = errors.New("docs:build: --out must not be inside --src or equal to the repository root")
|
|
|
|
// prepareOut creates the output directory, or empties it when it carries
|
|
// the marker. A non-empty directory without the marker is refused.
|
|
func prepareOut(out string) error {
|
|
entries, err := os.ReadDir(out)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return os.MkdirAll(out, 0o755)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("docs:build: read %s: %w", out, err)
|
|
}
|
|
if len(entries) == 0 {
|
|
return nil
|
|
}
|
|
if _, err := os.Stat(filepath.Join(out, MarkerFile)); err != nil {
|
|
return fmt.Errorf("docs:build: refusing to clean %s: it has no %s marker. Remove the directory or choose another --out.", out, MarkerFile)
|
|
}
|
|
for _, e := range entries {
|
|
if err := os.RemoveAll(filepath.Join(out, e.Name())); err != nil {
|
|
return fmt.Errorf("docs:build: clean %s: %w", out, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeOutputs(out string, files map[string][]byte) error {
|
|
names := make([]string, 0, len(files))
|
|
for name := range files {
|
|
names = append(names, name)
|
|
}
|
|
slices.Sort(names)
|
|
for _, name := range names {
|
|
target := filepath.Join(out, filepath.FromSlash(name))
|
|
if !within(target, out) {
|
|
return fmt.Errorf("docs:build: output path %s escapes %s", name, out)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
return fmt.Errorf("docs:build: %w", err)
|
|
}
|
|
if err := os.WriteFile(target, files[name], 0o644); err != nil {
|
|
return fmt.Errorf("docs:build: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkContent runs the accuracy checkers over every page and the root
|
|
// 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) {
|
|
docs, err := s.parseDocs()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
idx, problems, err := buildIdentIndex(s.opts.Root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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
|
|
}
|
|
|
|
func sortProblems(problems []Problem) {
|
|
slices.SortStableFunc(problems, func(a, b Problem) int {
|
|
return cmp.Or(strings.Compare(a.File, b.File), cmp.Compare(a.Line, b.Line))
|
|
})
|
|
}
|