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() } bodies := make([]string, len(s.pages)) for i, p := range s.pages { bodies[i] = markdownBody(p, rendered[i].mdLinks) s.outputs[p.URL+".md"] = pageMarkdown(p, bodies[i]) } s.outputs["llms.txt"] = s.llmsTxt() s.outputs["llms-full.txt"] = s.llmsFull(bodies) idx, err := s.searchIndex(rendered) 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 transformed body. func pageMarkdown(p *Page, body string) []byte { var b bytes.Buffer fmt.Fprintf(&b, "# %s\n\n> %s\n\n", p.Title, p.Description) b.WriteString(body) return b.Bytes() } // markdownBody is the page body without its H1 and leading blank lines, // with links to other pages pointing at their .md URLs, ending in one // newline. func markdownBody(p *Page, links map[string]string) string { body := string(p.Body) if first, rest, ok := strings.Cut(body, "\n"); strings.HasPrefix(first, "# ") { body = "" if ok { body = rest } } body = strings.Trim(body, "\n") return rewriteMarkdown(body, links) + "\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(bodies []string) []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(bodies[i]) } 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"` } const searchTextMax = 300 // searchIndex lists every page and one entry per H2 heading, so a hit // deep-links to its anchor. func (s *site) searchIndex(rendered []renderedPage) ([]byte, error) { idx := searchIndex{Pages: []searchPage{}, Entries: []searchEntry{}} for i, 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}) for _, h := range rendered[i].headings { if h.Level != 2 { continue } idx.Entries = append(idx.Entries, searchEntry{ Page: i, Anchor: h.ID, Heading: h.Text, Text: sectionText(h.node, p.Body, searchTextMax), }) } } 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 }) }