package docsite import ( "bytes" "cmp" "fmt" "io/fs" "os" "path" "path/filepath" "regexp" "slices" "strings" "unicode/utf8" "github.com/goccy/go-yaml" ) // Site is docs/site.yaml, decoded strictly. type Site struct { Title string `yaml:"title"` Description string `yaml:"description"` BaseURL string `yaml:"base_url"` // EditURL and SourceURL carry a {path} token replaced with a // repository-relative path. EditURL string `yaml:"edit_url"` SourceURL string `yaml:"source_url"` LLMSNotes []string `yaml:"llms_notes"` Sections []Section `yaml:"sections"` } // Section is one sidebar group, listed in sidebar order in site.yaml. type Section struct { Name string `yaml:"name"` Title string `yaml:"title"` } // Frontmatter is the YAML block at the top of every docs page. Every field // is required. type Frontmatter struct { Title string `yaml:"title"` Description string `yaml:"description"` Section string `yaml:"section"` Order int `yaml:"order"` } // Page is one page of the site: a docs/ page or an ingested module README. type Page struct { // Source is the repository-relative source path (forward slashes). Source string // URL is the extension-less output path, such as "setup/installation", // "api/lagoon" or "index". URL string Section string Title string Description string Order int // Module is the module name for API reference pages. Module string // Body is the Markdown body, starting with the "# Title" line. Body []byte // BodyLine is the 1-based line of Body's first line in Source. BodyLine int abs string } const ( indexSection = "index" apiSection = "api" maxDescLen = 160 ) var frontmatterFields = []string{"title", "description", "section", "order"} // LoadSite reads and parses a site.yaml file. func LoadSite(path string) (Site, error) { raw, err := os.ReadFile(path) if err != nil { return Site{}, fmt.Errorf("docsite: read site config %s: %w", path, err) } return ParseSite(raw) } // ParseSite decodes site.yaml, rejecting unknown fields, and validates the // section list. func ParseSite(raw []byte) (Site, error) { var s Site dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField()) if err := dec.Decode(&s); err != nil { return Site{}, fmt.Errorf("docsite: parse site config: %s", firstLine(err.Error())) } if s.Title == "" { return Site{}, fmt.Errorf("docsite: site config: title is required") } if s.Description == "" { return Site{}, fmt.Errorf("docsite: site config: description is required") } if len(s.Sections) == 0 { return Site{}, fmt.Errorf("docsite: site config: sections is required") } seen := map[string]bool{} for _, sec := range s.Sections { switch { case sec.Name == "" || sec.Title == "": return Site{}, fmt.Errorf("docsite: site config: every section needs a name and a title") case sec.Name == indexSection: return Site{}, fmt.Errorf("docsite: site config: section name %q is reserved for docs/index.md", indexSection) case !slugName.MatchString(sec.Name): return Site{}, fmt.Errorf("docsite: site config: section name %q must be lowercase letters, digits and dashes", sec.Name) case seen[sec.Name]: return Site{}, fmt.Errorf("docsite: site config: section %q is listed twice", sec.Name) } seen[sec.Name] = true } return s, nil } var slugName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) func firstLine(s string) string { s, _, _ = strings.Cut(strings.TrimSpace(s), "\n") return strings.TrimSpace(s) } // sectionTitle returns the display title of a section. func (s Site) sectionTitle(name string) string { for _, sec := range s.Sections { if sec.Name == name { return sec.Title } } return name } func (s Site) hasSection(name string) bool { return slices.ContainsFunc(s.Sections, func(sec Section) bool { return sec.Name == name }) } // site is one assembled documentation site. type site struct { opts Options cfg Site cfgRaw []byte base string pages []*Page // reading order outputs map[string][]byte } // rel returns the display path of an absolute path: repository-relative // with forward slashes when inside Root, otherwise the absolute path. func (s *site) rel(abs string) string { if r, err := filepath.Rel(s.opts.Root, abs); err == nil && within(abs, s.opts.Root) { return filepath.ToSlash(r) } return filepath.ToSlash(abs) } // assemble loads, verifies and renders a site in memory. func assemble(opts Options) (*site, []Problem, error) { opts, err := opts.normalize() if err != nil { return nil, nil, err } s := &site{opts: opts, outputs: map[string][]byte{}} cfgPath := filepath.Join(opts.Src, "site.yaml") raw, err := os.ReadFile(cfgPath) if err != nil { return nil, nil, fmt.Errorf("docsite: read site config %s: %w", cfgPath, err) } cfg, err := ParseSite(raw) if err != nil { return nil, []Problem{{File: s.rel(cfgPath), Line: 1, Rule: "site", Message: strings.TrimPrefix(err.Error(), "docsite: ")}}, nil } s.cfg, s.cfgRaw = cfg, raw s.base = strings.TrimRight(cfg.BaseURL, "/") if opts.BaseURL != "" { s.base = strings.TrimRight(opts.BaseURL, "/") } guides, problems, err := s.loadGuides() if err != nil { return nil, nil, err } s.order(guides) problems = append(problems, s.emptySections()...) if len(problems) == 0 { rp, err := s.render() if err != nil { return nil, nil, err } problems = append(problems, rp...) } sortProblems(problems) return s, problems, nil } // loadGuides walks Src and loads every page with its frontmatter. func (s *site) loadGuides() ([]*Page, []Problem, error) { files, err := walkPages(s.opts.Src) if err != nil { return nil, nil, err } var pages []*Page var problems []Problem orders := map[string]map[int]string{} hasIndex := false for _, abs := range files { raw, err := os.ReadFile(abs) if err != nil { return nil, nil, fmt.Errorf("docsite: read %s: %w", abs, err) } relSrc, err := filepath.Rel(s.opts.Src, abs) if err != nil { return nil, nil, fmt.Errorf("docsite: %w", err) } relSrc = filepath.ToSlash(relSrc) file := s.rel(abs) fail := func(detail string) { problems = append(problems, Problem{File: file, Line: 1, Rule: "frontmatter", Message: detail}) } fmRaw, body, bodyLine, ok := splitFrontmatter(raw) if !ok { fail(`the file must start with a "---" frontmatter block closed by a "---" line`) continue } fm, details := parseFrontmatter(fmRaw) if len(details) > 0 { for _, d := range details { fail(d) } continue } dir := path.Dir(relSrc) switch { case relSrc == "index.md": hasIndex = true if fm.Section != indexSection { fail(fmt.Sprintf("section %q does not match directory %q (the landing page uses section %q)", fm.Section, ".", indexSection)) } case dir == ".": fail(fmt.Sprintf("section %q does not match directory %q (only index.md sits at the docs root)", fm.Section, ".")) case fm.Section != dir: fail(fmt.Sprintf("section %q does not match directory %q", fm.Section, dir)) case fm.Section == apiSection: fail(fmt.Sprintf("section %q is reserved for the ingested module READMEs", apiSection)) case !s.cfg.hasSection(fm.Section): fail(fmt.Sprintf("section %q is not listed in %s", fm.Section, s.rel(filepath.Join(s.opts.Src, "site.yaml")))) } if first, _, _ := bytes.Cut(body, []byte("\n")); string(first) != "# "+fm.Title { fail(fmt.Sprintf("first line must be %q", "# "+fm.Title)) } if utf8.RuneCountInString(fm.Description) > maxDescLen { fail(fmt.Sprintf("description is longer than %d characters", maxDescLen)) } if orders[fm.Section] == nil { orders[fm.Section] = map[int]string{} } if other, dup := orders[fm.Section][fm.Order]; dup { fail(fmt.Sprintf("order %d already used by %s", fm.Order, other)) } else { orders[fm.Section][fm.Order] = file } pages = append(pages, &Page{ Source: file, URL: strings.TrimSuffix(relSrc, ".md"), Section: fm.Section, Title: fm.Title, Description: fm.Description, Order: fm.Order, Body: body, BodyLine: bodyLine, abs: abs, }) } if !hasIndex { problems = append(problems, Problem{File: s.rel(filepath.Join(s.opts.Src, "index.md")), Rule: "page", Message: "the landing page is missing"}) } return pages, problems, nil } // walkPages lists the Markdown pages under src in lexical order, skipping // examples/, dot and underscore entries. func walkPages(src string) ([]string, error) { var files []string err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if p == src { return nil } name := d.Name() skip := strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") || (d.IsDir() && filepath.Dir(p) == src && name == "examples") if d.IsDir() { if skip { return filepath.SkipDir } return nil } if !skip && strings.HasSuffix(name, ".md") { files = append(files, p) } return nil }) if err != nil { return nil, fmt.Errorf("docsite: walk %s: %w", src, err) } return files, nil } // splitFrontmatter separates a leading "---" block from the body. bodyLine // is the 1-based line of the body's first line. func splitFrontmatter(raw []byte) (fm, body []byte, bodyLine int, ok bool) { rest, found := bytes.CutPrefix(raw, []byte("---\n")) if !found { return nil, nil, 0, false } line := 2 for off := 0; off <= len(rest); { end := bytes.IndexByte(rest[off:], '\n') var cur []byte next := len(rest) + 1 if end < 0 { cur = rest[off:] } else { cur = rest[off : off+end] next = off + end + 1 } if string(bytes.TrimRight(cur, " \t\r")) == "---" { if next > len(rest) { return rest[:off], nil, line + 1, true } return rest[:off], rest[next:], line + 1, true } off = next line++ } return nil, nil, 0, false } // parseFrontmatter decodes a frontmatter block strictly. It returns the // UI-SPEC problem details: unknown and missing fields first, then decode // errors. func parseFrontmatter(raw []byte) (Frontmatter, []string) { var keys map[string]any if err := yaml.Unmarshal(raw, &keys); err != nil { return Frontmatter{}, []string{firstLine(err.Error())} } var details []string unknown := make([]string, 0) for k := range keys { if !slices.Contains(frontmatterFields, k) { unknown = append(unknown, k) } } slices.Sort(unknown) for _, k := range unknown { details = append(details, fmt.Sprintf("unknown field %q", k)) } for _, k := range frontmatterFields { v, ok := keys[k] if !ok || v == nil || v == "" { details = append(details, fmt.Sprintf("missing field %q", k)) } } if len(details) > 0 { return Frontmatter{}, details } var fm Frontmatter dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField()) if err := dec.Decode(&fm); err != nil { return Frontmatter{}, []string{firstLine(err.Error())} } return fm, nil } // order sorts pages into reading order: index first, then each site.yaml // section in order with its pages by order (API pages by module name). func (s *site) order(pages []*Page) { bySection := map[string][]*Page{} for _, p := range pages { bySection[p.Section] = append(bySection[p.Section], p) } s.pages = s.pages[:0] s.pages = append(s.pages, bySection[indexSection]...) for _, sec := range s.cfg.Sections { list := bySection[sec.Name] slices.SortStableFunc(list, func(a, b *Page) int { if a.Module != "" || b.Module != "" { return strings.Compare(a.Module, b.Module) } return cmp.Or(cmp.Compare(a.Order, b.Order), strings.Compare(a.URL, b.URL)) }) s.pages = append(s.pages, list...) } } // emptySections reports every site.yaml section without pages. func (s *site) emptySections() []Problem { count := map[string]int{} for _, p := range s.pages { count[p.Section]++ } var problems []Problem for _, sec := range s.cfg.Sections { if count[sec.Name] > 0 { continue } problems = append(problems, Problem{ File: s.rel(filepath.Join(s.opts.Src, "site.yaml")), Line: sectionLine(s.cfgRaw, sec.Name), Rule: "section", Message: fmt.Sprintf("%q has no pages (add the section in the same change as its first page)", sec.Name), }) } return problems } // sectionLine finds the site.yaml line that names a section. func sectionLine(raw []byte, name string) int { re := regexp.MustCompile(`^\s*(-\s*)?name:\s*["']?` + regexp.QuoteMeta(name) + `["']?\s*$`) for i, line := range strings.Split(string(raw), "\n") { if re.MatchString(line) { return i + 1 } } return 1 }