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 }) }