feat(11.1-01): publish every module README as an API reference page
- discover modules/<m> with non-test Go files; a missing README is a readme: problem - one GitHub-compatible slug parser.IDs for heading anchors, passed per page - rewrite links to .md pages and module READMEs to site .html and .md URLs - search-index.json gains one entry per H2 with 300-char plain text - add the api section to docs/site.yaml; docsite.Pages exposes reading order - tests: TestSlugIDs, TestReadmeIngestion, TestEveryModuleInSidebar, TestDocsAIOutputsInSync
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/internal/docsite"
|
||||
@@ -57,3 +61,165 @@ func TestDocsBuildRealTree(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// frameworkModules lists modules/<m> directories that hold a non-test Go
|
||||
// file, discovered independently of docsite.
|
||||
func frameworkModules(t *testing.T) []string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(filepath.Join(repoRoot, "modules"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
goFiles, err := filepath.Glob(filepath.Join(repoRoot, "modules", e.Name(), "*.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.ContainsFunc(goFiles, func(f string) bool { return !strings.HasSuffix(f, "_test.go") }) {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
t.Fatal("no framework modules found")
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestEveryModuleInSidebar(t *testing.T) {
|
||||
out, _ := buildRealTree(t)
|
||||
index, err := os.ReadFile(filepath.Join(out, "index.html"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := bytes.Index(index, []byte(`<nav class="sidebar" aria-label="Documentation">`))
|
||||
end := bytes.Index(index[max(start, 0):], []byte("</nav>"))
|
||||
if start < 0 || end < 0 {
|
||||
t.Fatal("index.html has no sidebar nav")
|
||||
}
|
||||
sidebar := string(index[start : start+end])
|
||||
for _, m := range frameworkModules(t) {
|
||||
if _, err := os.Stat(filepath.Join(out, "api", m+".html")); err != nil {
|
||||
t.Errorf("module %s has no API page: %v", m, err)
|
||||
}
|
||||
if !strings.Contains(sidebar, `href="/api/`+m+`.html"`) {
|
||||
t.Errorf("sidebar does not link api/%s.html", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocsAIOutputsInSync asserts that the page tree, the .html pages, the
|
||||
// .md siblings, llms.txt and llms-full.txt all list the same pages in the
|
||||
// same reading order.
|
||||
func TestDocsAIOutputsInSync(t *testing.T) {
|
||||
pages, problems, err := docsite.Pages(docsite.Options{Root: repoRoot})
|
||||
if err != nil || len(problems) > 0 {
|
||||
t.Fatalf("Pages: %v %v", err, problems)
|
||||
}
|
||||
var want []string
|
||||
for _, p := range pages {
|
||||
want = append(want, p.URL)
|
||||
}
|
||||
out, _ := buildRealTree(t)
|
||||
|
||||
var htmlFiles, mdFiles []string
|
||||
if err := filepath.WalkDir(out, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(out, p)
|
||||
rel = filepath.ToSlash(rel)
|
||||
if strings.HasPrefix(rel, "assets/") {
|
||||
return nil
|
||||
}
|
||||
switch filepath.Ext(rel) {
|
||||
case ".html":
|
||||
htmlFiles = append(htmlFiles, strings.TrimSuffix(rel, ".html"))
|
||||
case ".md":
|
||||
mdFiles = append(mdFiles, strings.TrimSuffix(rel, ".md"))
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sorted := slices.Sorted(slices.Values(want))
|
||||
slices.Sort(htmlFiles)
|
||||
slices.Sort(mdFiles)
|
||||
if !slices.Equal(htmlFiles, sorted) {
|
||||
t.Errorf(".html pages = %v, want %v", htmlFiles, sorted)
|
||||
}
|
||||
if !slices.Equal(mdFiles, sorted) {
|
||||
t.Errorf(".md pages = %v, want %v", mdFiles, sorted)
|
||||
}
|
||||
|
||||
llms := readLines(t, filepath.Join(out, "llms.txt"))
|
||||
if len(llms) == 0 || llms[0] != "# SummerCMS" {
|
||||
t.Fatalf("llms.txt line 1 = %q, want # SummerCMS", first(llms))
|
||||
}
|
||||
if next := nextNonEmpty(llms, 1); next < 0 || !strings.HasPrefix(llms[next], "> ") {
|
||||
t.Errorf("llms.txt: the line after the H1 must be a > summary")
|
||||
}
|
||||
item := regexp.MustCompile(`^- \[[^\]]+\]\(/([^)]+)\.md\): \S`)
|
||||
var linked []string
|
||||
for i, line := range llms {
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
if next := nextNonEmpty(llms, i+1); next < 0 || !strings.HasPrefix(llms[next], "- [") {
|
||||
t.Errorf("llms.txt: %q is not followed by a link list", line)
|
||||
}
|
||||
}
|
||||
if m := item.FindStringSubmatch(line); m != nil {
|
||||
linked = append(linked, m[1])
|
||||
}
|
||||
}
|
||||
if !slices.Equal(linked, want) {
|
||||
t.Errorf("llms.txt links = %v, want %v", linked, want)
|
||||
}
|
||||
|
||||
var sources []string
|
||||
for _, line := range readLines(t, filepath.Join(out, "llms-full.txt")) {
|
||||
if rest, ok := strings.CutPrefix(line, "Source: /"); ok {
|
||||
sources = append(sources, strings.TrimSuffix(rest, ".html"))
|
||||
}
|
||||
}
|
||||
if !slices.Equal(sources, want) {
|
||||
t.Errorf("llms-full.txt sources = %v, want %v", sources, want)
|
||||
}
|
||||
}
|
||||
|
||||
func readLines(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
var lines []string
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 1<<20), 1<<24)
|
||||
for sc.Scan() {
|
||||
lines = append(lines, sc.Text())
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func nextNonEmpty(lines []string, from int) int {
|
||||
for i := from; i < len(lines); i++ {
|
||||
if strings.TrimSpace(lines[i]) != "" {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func first(lines []string) string {
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return lines[0]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user