// 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 /docs. Src string // Out is the output directory, default /site. Out string // BaseURL overrides site.yaml base_url when non-empty. BaseURL string // SiteURL overrides site.yaml site_url when non-empty. SiteURL string // SiteLabel overrides site.yaml site_label when non-empty. SiteLabel 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: src= snippets, 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 } problems, err := s.checkSnippets(docs) if err != nil { return nil, err } idx, more, err := buildIdentIndex(s.opts.Root) if err != nil { return nil, err } problems = append(problems, more...) 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...) pp, err := s.checkPolicy(docs) if err != nil { return nil, err } problems = append(problems, pp...) 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)) }) }