package docsite import ( "encoding/json" "path/filepath" "strings" "testing" "unicode/utf8" ) const emitSite = `title: Acme description: "Acme is a test site." llms_notes: - "Note one." - "Note two." sections: - name: setup title: Setup - name: guides title: Guides - name: api title: API reference ` // emitTree is a fixture with an index, a page in each guide section and one // module README. func emitTree(t *testing.T, site string, extra map[string]string) string { t.Helper() files := map[string]string{ "docs/site.yaml": site, "docs/index.md": page("Acme docs", "index", 0, "Welcome.\n"), "docs/setup/install.md": page("Install", "setup", 10, "## Get it\n\nRun it.\n\nSee [usage](../guides/usage.md#call-it).\n"), "docs/guides/usage.md": page("Usage", "guides", 10, "## Call it\n\n```sh src=config/run.sh\nrun\n```\n"), "config/run.sh": "run\n", "modules/alpha/alpha.go": "package alpha\n", "modules/alpha/README.md": "# alpha\n\nAlpha does one thing.\n\n## Usage\n\n" + "Read [install](../../docs/setup/install.md).\n", } for k, v := range extra { files[k] = v } return writeTree(t, files) } func buildEmit(t *testing.T, opts Options) string { t.Helper() if opts.Out == "" { opts.Out = filepath.Join(t.TempDir(), "site") } opts.Commands = fixtureCommands if _, problems, err := Build(opts); err != nil || len(problems) > 0 { t.Fatalf("Build: %v %q", err, problemLines(problems)) } return opts.Out } func TestLLMSTxtShape(t *testing.T) { out := buildEmit(t, Options{Root: emitTree(t, emitSite, nil)}) want := `# Acme > Acme is a test site. - Note one. - Note two. ## Overview - [Acme docs](/index.md): Acme docs page. ## Setup - [Install](/setup/install.md): Install page. ## Guides - [Usage](/guides/usage.md): Usage page. ## API reference - [alpha](/api/alpha.md): Alpha does one thing. ` if got := readOut(t, out, "llms.txt"); got != want { t.Fatalf("llms.txt =\n%s\nwant\n%s", got, want) } // Without notes there is no notes list. noNotes := strings.Replace(emitSite, "llms_notes:\n - \"Note one.\"\n - \"Note two.\"\n", "", 1) out = buildEmit(t, Options{Root: emitTree(t, noNotes, nil)}) if got := readOut(t, out, "llms.txt"); !strings.HasPrefix(got, "# Acme\n\n> Acme is a test site.\n\n## Overview\n") { t.Fatalf("llms.txt without notes =\n%s", got) } } func TestLLMSFullBlocks(t *testing.T) { out := buildEmit(t, Options{Root: emitTree(t, emitSite, nil)}) want := `# Acme docs Source: /index.html Acme docs page. Welcome. # Install Source: /setup/install.html Install page. ## Get it Run it. See [usage](/guides/usage.md#call-it). # Usage Source: /guides/usage.html Usage page. ## Call it ` + "```sh\nrun\n```" + ` # alpha Source: /api/alpha.html Alpha does one thing. ## Usage Read [install](/setup/install.md). ` if got := readOut(t, out, "llms-full.txt"); got != want { t.Fatalf("llms-full.txt =\n%s\nwant\n%s", got, want) } } func TestMarkdownSiblings(t *testing.T) { out := buildEmit(t, Options{Root: emitTree(t, emitSite, nil)}) for name, want := range map[string]string{ "setup/install.md": "# Install\n\n> Install page.\n\n## Get it\n\nRun it.\n\nSee [usage](/guides/usage.md#call-it).\n", "guides/usage.md": "# Usage\n\n> Usage page.\n\n## Call it\n\n```sh\nrun\n```\n", "api/alpha.md": "# alpha\n\n> Alpha does one thing.\n\n## Usage\n\nRead [install](/setup/install.md).\n", "index.md": "# Acme docs\n\n> Acme docs page.\n\nWelcome.\n", } { if got := readOut(t, out, name); got != want { t.Errorf("%s =\n%q\nwant\n%q", name, got, want) } } // A body that is only its title leaves a single newline. p := &Page{Title: "T", Description: "D.", Body: []byte("# T")} if got := string(pageMarkdown(p, markdownBody(p, nil))); got != "# T\n\n> D.\n\n\n" { t.Fatalf("title-only page = %q", got) } p.Body = []byte("No H1 here.\n\n\n") if got := markdownBody(p, nil); got != "No H1 here.\n" { t.Fatalf("markdownBody without H1 = %q", got) } } func TestSearchIndexSchema(t *testing.T) { long := strings.Repeat("word ", 100) root := emitTree(t, emitSite, map[string]string{ "docs/guides/long.md": page("Long", "guides", 20, "## Big section\n\n"+long+"\n\n### Nested\n\nNested text.\n\n## Small\n\nTiny.\n"), }) out := buildEmit(t, Options{Root: root}) var idx struct { Pages []map[string]string `json:"p"` E []map[string]any `json:"e"` } raw := readOut(t, out, "search-index.json") if !strings.HasSuffix(raw, "}\n") { t.Errorf("search-index.json does not end in a newline") } if err := json.Unmarshal([]byte(raw), &idx); err != nil { t.Fatal(err) } if len(idx.Pages) != 5 { t.Fatalf("pages = %v", idx.Pages) } for _, p := range idx.Pages { if len(p) != 3 || p["u"] == "" || p["t"] == "" || p["s"] == "" { t.Errorf("page entry %v does not have exactly u, t and s", p) } } if idx.Pages[0]["s"] != "Acme" || idx.Pages[1]["s"] != "Setup" || idx.Pages[4]["s"] != "API reference" { t.Errorf("section labels = %v", idx.Pages) } var big map[string]any for _, e := range idx.E { if len(e) != 4 { t.Errorf("entry %v does not have exactly p, a, h and x", e) } if e["h"] == "Nested" { t.Error("an H3 got its own search entry") } if e["a"] == "big-section" { big = e } } if big == nil { t.Fatalf("no entry for the Big section: %v", idx.E) } x := big["x"].(string) if n := utf8.RuneCountInString(x); n > searchTextMax || n < searchTextMax-5 { t.Errorf("excerpt has %d runes, want at most %d and close to it", n, searchTextMax) } if strings.Contains(x, "Tiny") { t.Error("the excerpt ran into the next H2") } if int(big["p"].(float64)) != 3 || big["h"] != "Big section" { t.Errorf("big entry = %v", big) } } func TestBaseURLPrefixing(t *testing.T) { for _, tc := range []struct { name, siteBase, optBase, prefix string }{ {"empty", "", "", "/"}, {"path prefix from site.yaml", "/docs/", "", "/docs/"}, {"absolute URL from site.yaml", "https://docs.example/v1", "", "https://docs.example/v1/"}, {"option overrides site.yaml", "/ignored", "https://cdn.example/", "https://cdn.example/"}, } { t.Run(tc.name, func(t *testing.T) { site := emitSite if tc.siteBase != "" { site += "base_url: \"" + tc.siteBase + "\"\n" } out := buildEmit(t, Options{Root: emitTree(t, site, nil), BaseURL: tc.optBase}) html := readOut(t, out, "setup/install.html") for _, want := range []string{ `href="` + tc.prefix + `assets/site.css"`, `href="` + tc.prefix + `guides/usage.html#call-it"`, `href="` + tc.prefix + `setup/install.md"`, `href="` + tc.prefix + `llms.txt"`, `href="` + tc.prefix + `index.html"`, } { if !strings.Contains(html, want) { t.Errorf("setup/install.html missing %s", want) } } if llms := readOut(t, out, "llms.txt"); !strings.Contains(llms, "]("+tc.prefix+"setup/install.md)") { t.Errorf("llms.txt links are not prefixed with %s", tc.prefix) } if full := readOut(t, out, "llms-full.txt"); !strings.Contains(full, "Source: "+tc.prefix+"setup/install.html") { t.Errorf("llms-full.txt sources are not prefixed with %s", tc.prefix) } if idx := readOut(t, out, "search-index.json"); !strings.Contains(idx, `"u":"`+tc.prefix+`setup/install.html"`) { t.Errorf("search-index.json URLs are not prefixed with %s", tc.prefix) } }) } } func TestTOCThreshold(t *testing.T) { root := emitTree(t, emitSite, map[string]string{ "docs/guides/zero.md": page("Zero", "guides", 20, "No headings.\n"), "docs/guides/one.md": page("One", "guides", 30, "## Only\n\n#### Deep\n\nText.\n"), "docs/guides/two.md": page("Two", "guides", 40, "## First\n\n### Second\n\nText.\n"), }) out := buildEmit(t, Options{Root: root}) for name, want := range map[string]bool{"guides/zero.html": false, "guides/one.html": false, "guides/two.html": true} { html := readOut(t, out, name) if got := strings.Contains(html, `