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
This commit is contained in:
Jakub Zych
2026-09-30 21:18:32 +02:00
parent 63bcbc31b0
commit 6dacddc040
14 changed files with 1307 additions and 1 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
/bin/
/examples/hello/bin/
/dist/
/site/
*.test
*.out
coverage.*

65
cmd/summer/docs.go Normal file
View File

@@ -0,0 +1,65 @@
package main
import (
"context"
"errors"
"git.golem15.com/golem15/summercms/internal/docsite"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
func docsBuildCommand() bonfire.Command {
return bonfire.Command{
Name: "docs:build",
Description: "Build the documentation site",
Flags: []bonfire.Flag{
{Name: "root", Description: "Repository root; src= paths and modules/ resolve against it", Default: "."},
{Name: "src", Description: "Docs source directory (default <root>/docs)"},
{Name: "out", Description: "Output directory (default <root>/site)"},
{Name: "base-url", Description: "Base URL for site links (overrides site.yaml base_url)"},
{Name: "check", Description: "Validate the docs and write nothing", Bare: true},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
opts := docsOptions(in)
if flagTrue(in, "check") {
problems, err := docsite.Check(opts)
if err != nil {
return err
}
if len(problems) > 0 {
return reportDocsProblems(out, "docs:build", problems)
}
out.Printf("docs:build: no problems found\n")
return nil
}
result, problems, err := docsite.Build(opts)
if err != nil {
return err
}
if len(problems) > 0 {
return reportDocsProblems(out, "docs:build", problems)
}
out.Printf("docs:build: wrote %d pages to %s\n", result.Pages, result.Out)
return nil
},
}
}
func docsOptions(in bonfire.Input) docsite.Options {
var opts docsite.Options
opts.Root, _ = in.Flag("root")
opts.Src, _ = in.Flag("src")
opts.Out, _ = in.Flag("out")
opts.BaseURL, _ = in.Flag("base-url")
return opts
}
// reportDocsProblems prints one line per problem and a summary, and returns
// a short error so the binary exits 1.
func reportDocsProblems(out bonfire.Output, cmd string, problems []docsite.Problem) error {
for _, p := range problems {
out.Printf("%s\n", p)
}
out.Printf("%s: %d problems, nothing written\n", cmd, len(problems))
return errors.New(cmd + " failed")
}

59
cmd/summer/docs_test.go Normal file
View File

@@ -0,0 +1,59 @@
package main
import (
"bytes"
"os"
"path/filepath"
"regexp"
"testing"
"git.golem15.com/golem15/summercms/internal/docsite"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
const repoRoot = "../.."
// TestDocsTree fails with every problem line in the real docs tree.
func TestDocsTree(t *testing.T) {
problems, err := docsite.Check(docsite.Options{Root: repoRoot})
if err != nil {
t.Fatal(err)
}
for _, p := range problems {
t.Error(p.String())
}
}
// buildRealTree runs docs:build on the real tree into a temp dir and returns
// the output dir and the command output.
func buildRealTree(t *testing.T) (string, string) {
t.Helper()
out := filepath.Join(t.TempDir(), "site")
var buf bytes.Buffer
root, err := bonfire.NewRoot("summer", toolCommands(), &buf)
if err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"docs:build", "--root", repoRoot, "--out", out})
if err := root.Execute(); err != nil {
t.Fatalf("docs:build: %v\n%s", err, buf.String())
}
return out, buf.String()
}
func TestDocsBuildRealTree(t *testing.T) {
out, stdout := buildRealTree(t)
if !regexp.MustCompile(`docs:build: wrote \d+ pages to `).MatchString(stdout) {
t.Fatalf("output = %q, want a wrote-pages line", stdout)
}
for _, name := range []string{
"index.html", "index.md",
"setup/installation.html", "setup/installation.md",
"llms.txt", "llms-full.txt", "search-index.json",
"assets/site.css", docsite.MarkerFile,
} {
if _, err := os.Stat(filepath.Join(out, name)); err != nil {
t.Errorf("missing %s: %v", name, err)
}
}
}

View File

@@ -46,6 +46,7 @@ func toolCommands() []bonfire.Command {
delegateQueueWorkCommand(),
delegateScheduleRunCommand(),
delegateCommand("queue:clear", "Clear pending queued jobs in the app binary"),
docsBuildCommand(),
}
}

View File

@@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) {
for _, c := range toolCommands() {
names = append(names, c.Name)
}
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts"} {
for _, want := range []string{"build", "make:plugin", "make:model", "make:migration", "make:command", "make:job", "make:admin-controller", "plugin:add", "dev", "migrate", "migrate:rollback", "migrate:status", "serve", "queue:work", "queue:clear", "schedule:run", "parity:broadcasts", "docs:build"} {
if !slices.Contains(names, want) {
t.Fatalf("missing %s in %v", want, names)
}
@@ -34,6 +34,7 @@ func TestToolCommandNames(t *testing.T) {
"make:admin-controller": {"[plugin] [name]"},
"schedule:run": {"--once"},
"parity:broadcasts": {"--flow", "--step", "--ids", "127.0.0.1:8424"},
"docs:build": {"--out", "--src", "--root", "--base-url", "--check"},
}
for cmd, wants := range helpWants {
var buf bytes.Buffer

11
docs/index.md Normal file
View File

@@ -0,0 +1,11 @@
---
title: SummerCMS documentation
description: SummerCMS is a content management framework for Go, inspired by WinterCMS.
section: index
order: 0
---
# SummerCMS documentation
SummerCMS keeps what makes WinterCMS productive (plugins that extend each other, YAML-driven admin forms and lists, console scaffolding) and compiles an application into a single Go binary. The framework modules, the application's plugins, the embedded admin SPA and the console commands all ship as one executable.
Start with [Installation](setup/installation.md) to set up the toolchain and the `summer` CLI. The API reference section has one page per framework module.

View File

@@ -0,0 +1,35 @@
---
title: Installation
description: Install the Go toolchain, PostgreSQL and the summer CLI you need to build a SummerCMS application.
section: setup
order: 20
---
# Installation
SummerCMS is a Go module. An application requires it, lists its plugins in a `summer.yaml` manifest and builds everything into one binary with the `summer` CLI.
## Requirements
- Go 1.27.
- PostgreSQL 16 for any application that uses the data layer. The database's default locale must be the ICU `pl-PL` locale, which lagoon checks when it connects.
- Docker, only for the integration tests that start PostgreSQL or Mailpit containers.
You do not need Node.js to build an application or these docs. It is needed only when you work on the admin SPA itself.
## Install the summer CLI
Clone the framework repository, then install the `summer` tool from its root:
```sh
go install ./cmd/summer
summer --help
```
The tool builds and watches applications, scaffolds plugins, models, migrations and admin controllers, and builds this documentation. Check that the framework compiles and its unit tests pass:
```sh
go vet ./...
go test -short ./...
```
`go test -short` skips the tests that need Docker. Run `go test ./...` without `-short` when Docker is available.

15
docs/site.yaml Normal file
View File

@@ -0,0 +1,15 @@
# Documentation site configuration, read by `summer docs:build`.
# Sections are listed in sidebar order; each must have at least one page.
title: SummerCMS
description: "SummerCMS is a content management framework for Go, inspired by WinterCMS."
base_url: ""
edit_url: "https://git.golem15.com/golem15/summercms/_edit/master/{path}"
source_url: "https://git.golem15.com/golem15/summercms/src/branch/master/{path}"
llms_notes:
- "Plugins are Go modules compiled into the application binary and registered at build time; nothing is loaded at runtime."
- "The data layer supports PostgreSQL only."
- "SummerCMS is headless: it serves a JSON API and an admin SPA, with no frontend themes."
- "It targets Go 1.27."
sections:
- name: setup
title: Setup

236
internal/docsite/docsite.go Normal file
View File

@@ -0,0 +1,236 @@
// 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))
})
}

223
internal/docsite/emit.go Normal file
View File

@@ -0,0 +1,223 @@
package docsite
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"path"
"strings"
)
//go:embed theme/templates/*.html
var templateFS embed.FS
//go:embed theme/assets
var assetFS embed.FS
var pageTmpl = template.Must(template.ParseFS(templateFS, "theme/templates/*.html"))
// url returns the site URL of an output path: base_url plus "/" plus the
// path, root-relative when the base is empty.
func (s *site) url(p string) string {
return s.base + "/" + p
}
type navItem struct {
Title string
URL string
Current bool
}
type navSection struct {
Title string
Items []navItem
}
type pageView struct {
DocTitle string
Title string
Description string
Content template.HTML
CSS string
HomeURL string
Nav []navSection
}
// render renders every page and fills s.outputs with the HTML pages, their
// Markdown siblings, llms.txt, llms-full.txt, search-index.json and assets.
func (s *site) render() ([]Problem, error) {
md := newMarkdown()
var problems []Problem
rendered := make([]renderedPage, len(s.pages))
for i, p := range s.pages {
r, err := s.renderPage(md, p)
if err != nil {
return nil, err
}
rendered[i] = r
}
for i, p := range s.pages {
view := pageView{
DocTitle: p.Title + " · " + s.cfg.Title + " docs",
Title: p.Title,
Description: p.Description,
Content: template.HTML(rendered[i].html), //nolint:gosec // goldmark output in safe mode
CSS: s.url("assets/site.css"),
HomeURL: s.url("index.html"),
Nav: s.nav(p),
}
if p.Section == indexSection {
view.DocTitle = s.cfg.Title + " documentation"
}
var buf bytes.Buffer
if err := pageTmpl.ExecuteTemplate(&buf, "page.html", view); err != nil {
return nil, fmt.Errorf("docsite: render template for %s: %w", p.Source, err)
}
s.outputs[p.URL+".html"] = buf.Bytes()
s.outputs[p.URL+".md"] = s.pageMarkdown(p)
}
s.outputs["llms.txt"] = s.llmsTxt()
s.outputs["llms-full.txt"] = s.llmsFull()
idx, err := s.searchIndex()
if err != nil {
return nil, err
}
s.outputs["search-index.json"] = idx
if err := s.copyAssets(); err != nil {
return nil, err
}
return problems, nil
}
// nav builds the sidebar: every site.yaml section in order with its pages.
func (s *site) nav(current *Page) []navSection {
var out []navSection
for _, sec := range s.cfg.Sections {
ns := navSection{Title: sec.Title}
for _, p := range s.pages {
if p.Section != sec.Name {
continue
}
ns.Items = append(ns.Items, navItem{Title: p.Title, URL: s.url(p.URL + ".html"), Current: p == current})
}
out = append(out, ns)
}
return out
}
// pageMarkdown returns the clean Markdown sibling of a page: no
// frontmatter, "# Title", "> description", then the body without its H1.
func (s *site) pageMarkdown(p *Page) []byte {
var b bytes.Buffer
fmt.Fprintf(&b, "# %s\n\n> %s\n\n", p.Title, p.Description)
b.WriteString(s.markdownBody(p))
return b.Bytes()
}
// markdownBody is the page body without its H1 and leading blank lines,
// ending in one newline.
func (s *site) markdownBody(p *Page) string {
body := string(p.Body)
if first, rest, ok := strings.Cut(body, "\n"); ok && strings.HasPrefix(first, "# ") {
body = rest
} else if !ok && strings.HasPrefix(first, "# ") {
body = ""
}
body = strings.TrimLeft(body, "\n")
return strings.TrimRight(body, "\n") + "\n"
}
// llmsTxt writes the llms.txt index (llmstxt.org shape).
func (s *site) llmsTxt() []byte {
var b bytes.Buffer
fmt.Fprintf(&b, "# %s\n\n> %s\n", s.cfg.Title, s.cfg.Description)
if len(s.cfg.LLMSNotes) > 0 {
b.WriteString("\n")
for _, note := range s.cfg.LLMSNotes {
fmt.Fprintf(&b, "- %s\n", note)
}
}
item := func(p *Page) {
fmt.Fprintf(&b, "- [%s](%s): %s\n", p.Title, s.url(p.URL+".md"), p.Description)
}
b.WriteString("\n## Overview\n\n")
for _, p := range s.pages {
if p.Section == indexSection {
item(p)
}
}
for _, sec := range s.cfg.Sections {
fmt.Fprintf(&b, "\n## %s\n\n", sec.Title)
for _, p := range s.pages {
if p.Section == sec.Name {
item(p)
}
}
}
return b.Bytes()
}
// llmsFull concatenates every page in reading order.
func (s *site) llmsFull() []byte {
var b bytes.Buffer
for i, p := range s.pages {
if i > 0 {
b.WriteString("\n")
}
fmt.Fprintf(&b, "# %s\nSource: %s\n\n%s\n\n", p.Title, s.url(p.URL+".html"), p.Description)
b.WriteString(s.markdownBody(p))
}
return b.Bytes()
}
type searchPage struct {
URL string `json:"u"`
Title string `json:"t"`
Section string `json:"s"`
}
type searchEntry struct {
Page int `json:"p"`
Anchor string `json:"a"`
Heading string `json:"h"`
Text string `json:"x"`
}
type searchIndex struct {
Pages []searchPage `json:"p"`
Entries []searchEntry `json:"e"`
}
func (s *site) searchIndex() ([]byte, error) {
idx := searchIndex{Pages: []searchPage{}, Entries: []searchEntry{}}
for _, p := range s.pages {
sec := s.cfg.Title
if p.Section != indexSection {
sec = s.cfg.sectionTitle(p.Section)
}
idx.Pages = append(idx.Pages, searchPage{URL: s.url(p.URL + ".html"), Title: p.Title, Section: sec})
}
raw, err := json.Marshal(idx)
if err != nil {
return nil, fmt.Errorf("docsite: search index: %w", err)
}
return append(raw, '\n'), nil
}
// copyAssets copies the embedded theme assets to assets/.
func (s *site) copyAssets() error {
return fs.WalkDir(assetFS, "theme/assets", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
data, err := assetFS.ReadFile(p)
if err != nil {
return err
}
s.outputs[path.Join("assets", strings.TrimPrefix(p, "theme/assets/"))] = data
return nil
})
}

430
internal/docsite/load.go Normal file
View File

@@ -0,0 +1,430 @@
package docsite
import (
"bytes"
"cmp"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"regexp"
"slices"
"strings"
"unicode/utf8"
"github.com/goccy/go-yaml"
)
// Site is docs/site.yaml, decoded strictly.
type Site struct {
Title string `yaml:"title"`
Description string `yaml:"description"`
BaseURL string `yaml:"base_url"`
// EditURL and SourceURL carry a {path} token replaced with a
// repository-relative path.
EditURL string `yaml:"edit_url"`
SourceURL string `yaml:"source_url"`
LLMSNotes []string `yaml:"llms_notes"`
Sections []Section `yaml:"sections"`
}
// Section is one sidebar group, listed in sidebar order in site.yaml.
type Section struct {
Name string `yaml:"name"`
Title string `yaml:"title"`
}
// Frontmatter is the YAML block at the top of every docs page. Every field
// is required.
type Frontmatter struct {
Title string `yaml:"title"`
Description string `yaml:"description"`
Section string `yaml:"section"`
Order int `yaml:"order"`
}
// Page is one page of the site: a docs/ page or an ingested module README.
type Page struct {
// Source is the repository-relative source path (forward slashes).
Source string
// URL is the extension-less output path, such as "setup/installation",
// "api/lagoon" or "index".
URL string
Section string
Title string
Description string
Order int
// Module is the module name for API reference pages.
Module string
// Body is the Markdown body, starting with the "# Title" line.
Body []byte
// BodyLine is the 1-based line of Body's first line in Source.
BodyLine int
abs string
}
const (
indexSection = "index"
apiSection = "api"
maxDescLen = 160
)
var frontmatterFields = []string{"title", "description", "section", "order"}
// LoadSite reads and parses a site.yaml file.
func LoadSite(path string) (Site, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Site{}, fmt.Errorf("docsite: read site config %s: %w", path, err)
}
return ParseSite(raw)
}
// ParseSite decodes site.yaml, rejecting unknown fields, and validates the
// section list.
func ParseSite(raw []byte) (Site, error) {
var s Site
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
if err := dec.Decode(&s); err != nil {
return Site{}, fmt.Errorf("docsite: parse site config: %s", firstLine(err.Error()))
}
if s.Title == "" {
return Site{}, fmt.Errorf("docsite: site config: title is required")
}
if s.Description == "" {
return Site{}, fmt.Errorf("docsite: site config: description is required")
}
if len(s.Sections) == 0 {
return Site{}, fmt.Errorf("docsite: site config: sections is required")
}
seen := map[string]bool{}
for _, sec := range s.Sections {
switch {
case sec.Name == "" || sec.Title == "":
return Site{}, fmt.Errorf("docsite: site config: every section needs a name and a title")
case sec.Name == indexSection:
return Site{}, fmt.Errorf("docsite: site config: section name %q is reserved for docs/index.md", indexSection)
case !slugName.MatchString(sec.Name):
return Site{}, fmt.Errorf("docsite: site config: section name %q must be lowercase letters, digits and dashes", sec.Name)
case seen[sec.Name]:
return Site{}, fmt.Errorf("docsite: site config: section %q is listed twice", sec.Name)
}
seen[sec.Name] = true
}
return s, nil
}
var slugName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
func firstLine(s string) string {
s, _, _ = strings.Cut(strings.TrimSpace(s), "\n")
return strings.TrimSpace(s)
}
// sectionTitle returns the display title of a section.
func (s Site) sectionTitle(name string) string {
for _, sec := range s.Sections {
if sec.Name == name {
return sec.Title
}
}
return name
}
func (s Site) hasSection(name string) bool {
return slices.ContainsFunc(s.Sections, func(sec Section) bool { return sec.Name == name })
}
// site is one assembled documentation site.
type site struct {
opts Options
cfg Site
cfgRaw []byte
base string
pages []*Page // reading order
outputs map[string][]byte
}
// rel returns the display path of an absolute path: repository-relative
// with forward slashes when inside Root, otherwise the absolute path.
func (s *site) rel(abs string) string {
if r, err := filepath.Rel(s.opts.Root, abs); err == nil && within(abs, s.opts.Root) {
return filepath.ToSlash(r)
}
return filepath.ToSlash(abs)
}
// assemble loads, verifies and renders a site in memory.
func assemble(opts Options) (*site, []Problem, error) {
opts, err := opts.normalize()
if err != nil {
return nil, nil, err
}
s := &site{opts: opts, outputs: map[string][]byte{}}
cfgPath := filepath.Join(opts.Src, "site.yaml")
raw, err := os.ReadFile(cfgPath)
if err != nil {
return nil, nil, fmt.Errorf("docsite: read site config %s: %w", cfgPath, err)
}
cfg, err := ParseSite(raw)
if err != nil {
return nil, []Problem{{File: s.rel(cfgPath), Line: 1, Rule: "site", Message: strings.TrimPrefix(err.Error(), "docsite: ")}}, nil
}
s.cfg, s.cfgRaw = cfg, raw
s.base = strings.TrimRight(cfg.BaseURL, "/")
if opts.BaseURL != "" {
s.base = strings.TrimRight(opts.BaseURL, "/")
}
guides, problems, err := s.loadGuides()
if err != nil {
return nil, nil, err
}
s.order(guides)
problems = append(problems, s.emptySections()...)
if len(problems) == 0 {
rp, err := s.render()
if err != nil {
return nil, nil, err
}
problems = append(problems, rp...)
}
sortProblems(problems)
return s, problems, nil
}
// loadGuides walks Src and loads every page with its frontmatter.
func (s *site) loadGuides() ([]*Page, []Problem, error) {
files, err := walkPages(s.opts.Src)
if err != nil {
return nil, nil, err
}
var pages []*Page
var problems []Problem
orders := map[string]map[int]string{}
hasIndex := false
for _, abs := range files {
raw, err := os.ReadFile(abs)
if err != nil {
return nil, nil, fmt.Errorf("docsite: read %s: %w", abs, err)
}
relSrc, err := filepath.Rel(s.opts.Src, abs)
if err != nil {
return nil, nil, fmt.Errorf("docsite: %w", err)
}
relSrc = filepath.ToSlash(relSrc)
file := s.rel(abs)
fail := func(detail string) {
problems = append(problems, Problem{File: file, Line: 1, Rule: "frontmatter", Message: detail})
}
fmRaw, body, bodyLine, ok := splitFrontmatter(raw)
if !ok {
fail(`the file must start with a "---" frontmatter block closed by a "---" line`)
continue
}
fm, details := parseFrontmatter(fmRaw)
if len(details) > 0 {
for _, d := range details {
fail(d)
}
continue
}
dir := path.Dir(relSrc)
switch {
case relSrc == "index.md":
hasIndex = true
if fm.Section != indexSection {
fail(fmt.Sprintf("section %q does not match directory %q (the landing page uses section %q)", fm.Section, ".", indexSection))
}
case dir == ".":
fail(fmt.Sprintf("section %q does not match directory %q (only index.md sits at the docs root)", fm.Section, "."))
case fm.Section != dir:
fail(fmt.Sprintf("section %q does not match directory %q", fm.Section, dir))
case fm.Section == apiSection:
fail(fmt.Sprintf("section %q is reserved for the ingested module READMEs", apiSection))
case !s.cfg.hasSection(fm.Section):
fail(fmt.Sprintf("section %q is not listed in %s", fm.Section, s.rel(filepath.Join(s.opts.Src, "site.yaml"))))
}
if first, _, _ := bytes.Cut(body, []byte("\n")); string(first) != "# "+fm.Title {
fail(fmt.Sprintf("first line must be %q", "# "+fm.Title))
}
if utf8.RuneCountInString(fm.Description) > maxDescLen {
fail(fmt.Sprintf("description is longer than %d characters", maxDescLen))
}
if orders[fm.Section] == nil {
orders[fm.Section] = map[int]string{}
}
if other, dup := orders[fm.Section][fm.Order]; dup {
fail(fmt.Sprintf("order %d already used by %s", fm.Order, other))
} else {
orders[fm.Section][fm.Order] = file
}
pages = append(pages, &Page{
Source: file,
URL: strings.TrimSuffix(relSrc, ".md"),
Section: fm.Section,
Title: fm.Title,
Description: fm.Description,
Order: fm.Order,
Body: body,
BodyLine: bodyLine,
abs: abs,
})
}
if !hasIndex {
problems = append(problems, Problem{File: s.rel(filepath.Join(s.opts.Src, "index.md")), Rule: "page", Message: "the landing page is missing"})
}
return pages, problems, nil
}
// walkPages lists the Markdown pages under src in lexical order, skipping
// examples/, dot and underscore entries.
func walkPages(src string) ([]string, error) {
var files []string
err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if p == src {
return nil
}
name := d.Name()
skip := strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") ||
(d.IsDir() && filepath.Dir(p) == src && name == "examples")
if d.IsDir() {
if skip {
return filepath.SkipDir
}
return nil
}
if !skip && strings.HasSuffix(name, ".md") {
files = append(files, p)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("docsite: walk %s: %w", src, err)
}
return files, nil
}
// splitFrontmatter separates a leading "---" block from the body. bodyLine
// is the 1-based line of the body's first line.
func splitFrontmatter(raw []byte) (fm, body []byte, bodyLine int, ok bool) {
rest, found := bytes.CutPrefix(raw, []byte("---\n"))
if !found {
return nil, nil, 0, false
}
line := 2
for off := 0; off <= len(rest); {
end := bytes.IndexByte(rest[off:], '\n')
var cur []byte
next := len(rest) + 1
if end < 0 {
cur = rest[off:]
} else {
cur = rest[off : off+end]
next = off + end + 1
}
if string(bytes.TrimRight(cur, " \t\r")) == "---" {
if next > len(rest) {
return rest[:off], nil, line + 1, true
}
return rest[:off], rest[next:], line + 1, true
}
off = next
line++
}
return nil, nil, 0, false
}
// parseFrontmatter decodes a frontmatter block strictly. It returns the
// UI-SPEC problem details: unknown and missing fields first, then decode
// errors.
func parseFrontmatter(raw []byte) (Frontmatter, []string) {
var keys map[string]any
if err := yaml.Unmarshal(raw, &keys); err != nil {
return Frontmatter{}, []string{firstLine(err.Error())}
}
var details []string
unknown := make([]string, 0)
for k := range keys {
if !slices.Contains(frontmatterFields, k) {
unknown = append(unknown, k)
}
}
slices.Sort(unknown)
for _, k := range unknown {
details = append(details, fmt.Sprintf("unknown field %q", k))
}
for _, k := range frontmatterFields {
v, ok := keys[k]
if !ok || v == nil || v == "" {
details = append(details, fmt.Sprintf("missing field %q", k))
}
}
if len(details) > 0 {
return Frontmatter{}, details
}
var fm Frontmatter
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
if err := dec.Decode(&fm); err != nil {
return Frontmatter{}, []string{firstLine(err.Error())}
}
return fm, nil
}
// order sorts pages into reading order: index first, then each site.yaml
// section in order with its pages by order (API pages by module name).
func (s *site) order(pages []*Page) {
bySection := map[string][]*Page{}
for _, p := range pages {
bySection[p.Section] = append(bySection[p.Section], p)
}
s.pages = s.pages[:0]
s.pages = append(s.pages, bySection[indexSection]...)
for _, sec := range s.cfg.Sections {
list := bySection[sec.Name]
slices.SortStableFunc(list, func(a, b *Page) int {
if a.Module != "" || b.Module != "" {
return strings.Compare(a.Module, b.Module)
}
return cmp.Or(cmp.Compare(a.Order, b.Order), strings.Compare(a.URL, b.URL))
})
s.pages = append(s.pages, list...)
}
}
// emptySections reports every site.yaml section without pages.
func (s *site) emptySections() []Problem {
count := map[string]int{}
for _, p := range s.pages {
count[p.Section]++
}
var problems []Problem
for _, sec := range s.cfg.Sections {
if count[sec.Name] > 0 {
continue
}
problems = append(problems, Problem{
File: s.rel(filepath.Join(s.opts.Src, "site.yaml")),
Line: sectionLine(s.cfgRaw, sec.Name),
Rule: "section",
Message: fmt.Sprintf("%q has no pages (add the section in the same change as its first page)", sec.Name),
})
}
return problems
}
// sectionLine finds the site.yaml line that names a section.
func sectionLine(raw []byte, name string) int {
re := regexp.MustCompile(`^\s*(-\s*)?name:\s*["']?` + regexp.QuoteMeta(name) + `["']?\s*$`)
for i, line := range strings.Split(string(raw), "\n") {
if re.MatchString(line) {
return i + 1
}
}
return 1
}

View File

@@ -0,0 +1,55 @@
package docsite
import (
"bytes"
"fmt"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
// newMarkdown returns the one goldmark pipeline every page goes through:
// CommonMark plus GFM, heading IDs, and the page transformers. The html
// renderer keeps its default safe mode, so raw HTML in Markdown is never
// passed through.
func newMarkdown() goldmark.Markdown {
return goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
parser.WithASTTransformers(
util.Prioritized(h1Stripper{}, 100),
),
),
)
}
// h1Stripper removes the page's leading "# Title" heading; the template
// renders the title once.
type h1Stripper struct{}
func (h1Stripper) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) {
if h, ok := doc.FirstChild().(*ast.Heading); ok && h.Level == 1 {
doc.RemoveChild(doc, h)
}
}
// renderedPage is one page after parsing and rendering.
type renderedPage struct {
html []byte
}
// renderPage parses and renders a page body to HTML.
func (s *site) renderPage(md goldmark.Markdown, p *Page) (renderedPage, error) {
ctx := parser.NewContext()
doc := md.Parser().Parse(text.NewReader(p.Body), parser.WithContext(ctx))
var buf bytes.Buffer
if err := md.Renderer().Render(&buf, p.Body, doc); err != nil {
return renderedPage{}, fmt.Errorf("docsite: render %s: %w", p.Source, err)
}
return renderedPage{html: buf.Bytes()}, nil
}

View File

@@ -0,0 +1,142 @@
/* SummerCMS docs: tracer theme. Light tokens copied by value from the admin SPA. */
:root {
--c-bg: #f4f6f9;
--c-surface: #ffffff;
--c-subtle: #f3f5f8;
--c-border: #e6e9ef;
--c-border-strong: #d2d8e2;
--c-text: #141b2d;
--c-muted: #566175;
--c-primary: #22304d;
--c-ring: rgba(252, 196, 40, 0.55);
--c-sel: #fdf3cf;
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
--font-mono: "DM Mono", ui-monospace, monospace;
color-scheme: light;
}
* { box-sizing: border-box; }
html {
-webkit-text-size-adjust: 100%;
font-size: 16px;
}
body {
margin: 0;
background: var(--c-surface);
color: var(--c-text);
font-family: var(--font-sans);
line-height: 1.6;
}
.layout {
display: grid;
grid-template-columns: 272px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
background: var(--c-bg);
border-right: 1px solid var(--c-border);
padding: 24px 8px;
font-size: 14px;
line-height: 1.5;
}
.sidebar-home {
display: block;
padding: 0 8px 24px;
font-size: 20px;
font-weight: 600;
color: var(--c-text);
text-decoration: none;
}
.sidebar-section + .sidebar-section { margin-top: 24px; }
.sidebar-title {
margin: 0 0 8px;
padding: 0 8px;
font-weight: 600;
}
.sidebar ul { list-style: none; margin: 0; padding: 0; }
.sidebar li a {
display: block;
min-height: 32px;
padding: 4px 8px;
border-radius: 10px;
color: var(--c-muted);
text-decoration: none;
}
.sidebar li a:hover { color: var(--c-text); }
.sidebar li a[aria-current="page"] {
background: var(--c-sel);
color: var(--c-text);
}
main {
max-width: 768px;
padding: 48px 32px 64px;
}
h1 {
margin: 0 0 8px;
font-size: 32px;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.02em;
}
h2 { margin: 48px 0 16px; font-size: 20px; font-weight: 600; line-height: 1.3; }
h3, h4, h5, h6 { margin: 32px 0 8px; font-size: 16px; font-weight: 600; line-height: 1.5; }
h1, h2, h3, h4, h5, h6 { scroll-margin-top: 32px; }
.lead { margin: 0 0 32px; font-size: 20px; color: var(--c-muted); line-height: 1.3; }
p, ul, ol { margin: 0 0 16px; }
a { color: var(--c-primary); text-decoration-color: var(--c-border-strong); text-underline-offset: 3px; }
a:hover { text-decoration-color: currentColor; }
a:focus-visible { outline: 3px solid var(--c-ring); outline-offset: 2px; }
code {
font-family: var(--font-mono);
font-size: 0.875em;
background: var(--c-subtle);
border-radius: 6px;
padding: 0 4px;
}
pre {
margin: 0 0 24px;
padding: 16px;
overflow-x: auto;
background: var(--c-subtle);
border: 1px solid var(--c-border);
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
}
pre code { background: none; padding: 0; font-size: inherit; }
figure.code { margin: 0 0 24px; }
figure.code pre { margin: 0; }
figure.code figcaption { margin: 0 0 4px; font-size: 14px; color: var(--c-muted); font-family: var(--font-mono); }
table { border-collapse: collapse; margin: 0 0 24px; font-size: 14px; font-variant-numeric: tabular-nums; }
th, td { border-bottom: 1px solid var(--c-border); padding: 8px 16px; text-align: left; vertical-align: top; }
th { background: var(--c-subtle); font-weight: 600; }
blockquote { margin: 0 0 24px; padding: 16px; background: var(--c-subtle); border-left: 4px solid var(--c-border-strong); border-radius: 12px; }
@media (max-width: 1023px) {
.layout { grid-template-columns: minmax(0, 1fr); }
.sidebar { border-right: 0; border-bottom: 1px solid var(--c-border); }
main { padding: 24px 16px 48px; }
}

View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.DocTitle}}</title>
<meta name="description" content="{{.Description}}">
<link rel="stylesheet" href="{{.CSS}}">
</head>
<body class="docs">
<div class="layout">
<nav class="sidebar" aria-label="Documentation">
<a class="sidebar-home" href="{{.HomeURL}}">SummerCMS</a>
{{- range .Nav}}
<div class="sidebar-section">
<p class="sidebar-title">{{.Title}}</p>
<ul>
{{- range .Items}}
<li><a href="{{.URL}}"{{if .Current}} aria-current="page"{{end}}>{{.Title}}</a></li>
{{- end}}
</ul>
</div>
{{- end}}
</nav>
<main id="content">
<h1>{{.Title}}</h1>
<p class="lead">{{.Description}}</p>
{{.Content}}
</main>
</div>
</body>
</html>