package docsite import ( "bytes" "embed" "encoding/json" "fmt" "html/template" "io" "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 } // writeIcon writes the inline Lucide SVG named name (icons.html). func writeIcon(w io.Writer, name string) { _ = pageTmpl.ExecuteTemplate(w, "icon-"+name, nil) } type navItem struct { Title string URL string Current bool } type navSection struct { Title string // API marks the API reference section, whose items are module names // set in DM Mono. API bool Items []navItem } type tocItem struct { ID string Text string Level int } type pagerLink struct { Title string URL string // Section is set only when the target is in another section. Section string } // pageView is the data of page.html and 404.html. type pageView struct { DocTitle string Title string Description string Eyebrow string Content template.HTML Assets string HomeURL string SearchIndex string LLMS string LLMSFull string EditURL string MarkdownURL string Nav []navSection TOC []tocItem Prev, Next *pagerLink } // minTOC is the number of H2/H3 headings a page needs for a TOC. const minTOC = 2 // baseView fills the fields every page shares. func (s *site) baseView(current *Page) pageView { return pageView{ Assets: s.url("assets"), HomeURL: s.url("index.html"), SearchIndex: s.url("search-index.json"), LLMS: s.url("llms.txt"), LLMSFull: s.url("llms-full.txt"), Nav: s.nav(current), } } // render renders every page and fills s.outputs with the HTML pages, their // Markdown siblings, 404.html, 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 := s.baseView(p) view.DocTitle = p.Title + " · " + s.cfg.Title + " docs" view.Title = p.Title view.Description = p.Description view.Content = template.HTML(rendered[i].html) //nolint:gosec // goldmark output in safe mode view.MarkdownURL = s.url(p.URL + ".md") if s.cfg.EditURL != "" { view.EditURL = strings.ReplaceAll(s.cfg.EditURL, "{path}", p.Source) } if p.Section == indexSection { view.DocTitle = s.cfg.Title + " documentation" } else { view.Eyebrow = s.cfg.sectionTitle(p.Section) } for _, h := range rendered[i].headings { if h.Level == 2 || h.Level == 3 { view.TOC = append(view.TOC, tocItem{ID: h.ID, Text: h.Text, Level: h.Level}) } } if len(view.TOC) < minTOC { view.TOC = nil } if i > 0 { view.Prev = s.pagerLink(p, s.pages[i-1]) } if i < len(s.pages)-1 { view.Next = s.pagerLink(p, s.pages[i+1]) } 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() } notFound := s.baseView(nil) notFound.DocTitle = "Page not found · " + s.cfg.Title + " docs" notFound.Title = "Page not found" notFound.Description = "This page does not exist or has moved." var buf bytes.Buffer if err := pageTmpl.ExecuteTemplate(&buf, "404.html", notFound); err != nil { return nil, fmt.Errorf("docsite: render 404.html: %w", err) } s.outputs["404.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 } // pagerLink describes a prev/next target; the section line is kept only // when the target sits in another section (and is not the index page). func (s *site) pagerLink(from, to *Page) *pagerLink { l := &pagerLink{Title: to.Title, URL: s.url(to.URL + ".html")} if to.Section != from.Section && to.Section != indexSection { l.Section = s.cfg.sectionTitle(to.Section) } return l } // 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, API: sec.Name == apiSection} 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: current != nil && 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 }) }