feat(11.1-01): add summer docs:build with the docsite generator core

- internal/docsite loads docs/ with strict site.yaml and frontmatter decoding
- goldmark GFM pipeline renders pages into an embedded html/template shell
- emits .html pages, .md siblings, llms.txt, llms-full.txt, search-index.json
- output guard refuses unmarked non-empty dirs and --out inside --src or root
- docs/index.md and docs/setup/installation.md; /site/ is gitignored
This commit is contained in:
Jakub Zych
2026-09-30 21:18:32 +02:00
parent 63bcbc31b0
commit 6dacddc040
14 changed files with 1307 additions and 1 deletions

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

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