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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
/bin/
|
/bin/
|
||||||
/examples/hello/bin/
|
/examples/hello/bin/
|
||||||
/dist/
|
/dist/
|
||||||
|
/site/
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
coverage.*
|
coverage.*
|
||||||
|
|||||||
65
cmd/summer/docs.go
Normal file
65
cmd/summer/docs.go
Normal 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
59
cmd/summer/docs_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ func toolCommands() []bonfire.Command {
|
|||||||
delegateQueueWorkCommand(),
|
delegateQueueWorkCommand(),
|
||||||
delegateScheduleRunCommand(),
|
delegateScheduleRunCommand(),
|
||||||
delegateCommand("queue:clear", "Clear pending queued jobs in the app binary"),
|
delegateCommand("queue:clear", "Clear pending queued jobs in the app binary"),
|
||||||
|
docsBuildCommand(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestToolCommandNames(t *testing.T) {
|
|||||||
for _, c := range toolCommands() {
|
for _, c := range toolCommands() {
|
||||||
names = append(names, c.Name)
|
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) {
|
if !slices.Contains(names, want) {
|
||||||
t.Fatalf("missing %s in %v", want, names)
|
t.Fatalf("missing %s in %v", want, names)
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,7 @@ func TestToolCommandNames(t *testing.T) {
|
|||||||
"make:admin-controller": {"[plugin] [name]"},
|
"make:admin-controller": {"[plugin] [name]"},
|
||||||
"schedule:run": {"--once"},
|
"schedule:run": {"--once"},
|
||||||
"parity:broadcasts": {"--flow", "--step", "--ids", "127.0.0.1:8424"},
|
"parity:broadcasts": {"--flow", "--step", "--ids", "127.0.0.1:8424"},
|
||||||
|
"docs:build": {"--out", "--src", "--root", "--base-url", "--check"},
|
||||||
}
|
}
|
||||||
for cmd, wants := range helpWants {
|
for cmd, wants := range helpWants {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|||||||
11
docs/index.md
Normal file
11
docs/index.md
Normal 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.
|
||||||
35
docs/setup/installation.md
Normal file
35
docs/setup/installation.md
Normal 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
15
docs/site.yaml
Normal 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
236
internal/docsite/docsite.go
Normal 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
223
internal/docsite/emit.go
Normal 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
430
internal/docsite/load.go
Normal 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
|
||||||
|
}
|
||||||
55
internal/docsite/render.go
Normal file
55
internal/docsite/render.go
Normal 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
|
||||||
|
}
|
||||||
142
internal/docsite/theme/assets/site.css
Normal file
142
internal/docsite/theme/assets/site.css
Normal 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; }
|
||||||
|
}
|
||||||
32
internal/docsite/theme/templates/page.html
Normal file
32
internal/docsite/theme/templates/page.html
Normal 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>
|
||||||
Reference in New Issue
Block a user