diff --git a/.gitignore b/.gitignore index ffce916..28deaf7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /bin/ /examples/hello/bin/ /dist/ +/site/ *.test *.out coverage.* diff --git a/cmd/summer/docs.go b/cmd/summer/docs.go new file mode 100644 index 0000000..f6fa22d --- /dev/null +++ b/cmd/summer/docs.go @@ -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 /docs)"}, + {Name: "out", Description: "Output directory (default /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") +} diff --git a/cmd/summer/docs_test.go b/cmd/summer/docs_test.go new file mode 100644 index 0000000..70f8e62 --- /dev/null +++ b/cmd/summer/docs_test.go @@ -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) + } + } +} diff --git a/cmd/summer/main.go b/cmd/summer/main.go index 9005e1f..84561e9 100644 --- a/cmd/summer/main.go +++ b/cmd/summer/main.go @@ -46,6 +46,7 @@ func toolCommands() []bonfire.Command { delegateQueueWorkCommand(), delegateScheduleRunCommand(), delegateCommand("queue:clear", "Clear pending queued jobs in the app binary"), + docsBuildCommand(), } } diff --git a/cmd/summer/main_test.go b/cmd/summer/main_test.go index da5af95..11ea461 100644 --- a/cmd/summer/main_test.go +++ b/cmd/summer/main_test.go @@ -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 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ca66bd9 --- /dev/null +++ b/docs/index.md @@ -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. diff --git a/docs/setup/installation.md b/docs/setup/installation.md new file mode 100644 index 0000000..498b567 --- /dev/null +++ b/docs/setup/installation.md @@ -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. diff --git a/docs/site.yaml b/docs/site.yaml new file mode 100644 index 0000000..e98929b --- /dev/null +++ b/docs/site.yaml @@ -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 diff --git a/internal/docsite/docsite.go b/internal/docsite/docsite.go new file mode 100644 index 0000000..4d9922f --- /dev/null +++ b/internal/docsite/docsite.go @@ -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 /docs. + Src string + // Out is the output directory, default /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)) + }) +} diff --git a/internal/docsite/emit.go b/internal/docsite/emit.go new file mode 100644 index 0000000..c72bbdc --- /dev/null +++ b/internal/docsite/emit.go @@ -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 + }) +} diff --git a/internal/docsite/load.go b/internal/docsite/load.go new file mode 100644 index 0000000..e339f97 --- /dev/null +++ b/internal/docsite/load.go @@ -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 +} diff --git a/internal/docsite/render.go b/internal/docsite/render.go new file mode 100644 index 0000000..822d747 --- /dev/null +++ b/internal/docsite/render.go @@ -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 +} diff --git a/internal/docsite/theme/assets/site.css b/internal/docsite/theme/assets/site.css new file mode 100644 index 0000000..59f0f0b --- /dev/null +++ b/internal/docsite/theme/assets/site.css @@ -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; } +} diff --git a/internal/docsite/theme/templates/page.html b/internal/docsite/theme/templates/page.html new file mode 100644 index 0000000..9e55ab5 --- /dev/null +++ b/internal/docsite/theme/templates/page.html @@ -0,0 +1,32 @@ + + + + + +{{.DocTitle}} + + + + +
+ +
+

{{.Title}}

+

{{.Description}}

+{{.Content}} +
+
+ +