Files
summercms/internal/docsite/docsite.go
Jakub Zych 6dacddc040 feat(11.1-01): add summer docs:build with the docsite generator core
- internal/docsite loads docs/ with strict site.yaml and frontmatter decoding
- goldmark GFM pipeline renders pages into an embedded html/template shell
- emits .html pages, .md siblings, llms.txt, llms-full.txt, search-index.json
- output guard refuses unmarked non-empty dirs and --out inside --src or root
- docs/index.md and docs/setup/installation.md; /site/ is gitignored
2026-09-30 21:18:32 +02:00

237 lines
6.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
}
// 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
}
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))
})
}