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

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