- site.yaml keys site_url and site_label, validated: http(s) URL with a host or a path starting with a single /; a label needs a URL - docs:build and docs:serve flags --site-url and --site-label override them the way --base-url overrides base_url - every page header, the 404 page included, links back with the explicit label, else the URL host, else Home; unset output is unchanged - docs/console/utilities.md documents the keys and flags
334 lines
8.8 KiB
Go
334 lines
8.8 KiB
Go
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
|
|
// SiteURL and SiteLabel are the link back to the main site, empty
|
|
// when site_url is unset. SiteURL points at another site, so it is
|
|
// never prefixed with base_url.
|
|
SiteURL string
|
|
SiteLabel 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"),
|
|
SiteURL: s.siteURL,
|
|
SiteLabel: s.siteLabel,
|
|
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
|
|
})
|
|
}
|