- load, render, emit, snippet, highlight, serve and checker branch tests; docs:build --check, docs:sync and docs:serve output tests in cmd/summer - fix: a CRLF or BOM page is reported as such, not as a page without a frontmatter block (TestFrontmatterEncodingProblems) - fix: src=#Type.Method finds a method with a parenthesised receiver (TestSnippetGenericsAndGroups, Box.Paren) - fix: docs:serve adds its watches before announcing the server, so an edit made right after the serving line rebuilds (TestServeWatchRebuilds)
299 lines
9.3 KiB
Go
299 lines
9.3 KiB
Go
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, `<aside class="toc"`) && strings.Contains(html, `<details class="toc-inline">`); got != want {
|
|
t.Errorf("%s has a TOC = %v, want %v", name, got, want)
|
|
}
|
|
}
|
|
two := readOut(t, out, "guides/two.html")
|
|
for _, want := range []string{`href="#first"`, `href="#second"`, `class="toc-h3"`} {
|
|
if !strings.Contains(two, want) {
|
|
t.Errorf("guides/two.html TOC missing %s", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEditAndSourceURLs(t *testing.T) {
|
|
site := emitSite + "edit_url: \"https://forge.example/edit/{path}\"\nsource_url: \"https://forge.example/src/{path}\"\n"
|
|
out := buildEmit(t, Options{Root: emitTree(t, site, nil)})
|
|
usage := readOut(t, out, "guides/usage.html")
|
|
for _, want := range []string{
|
|
`href="https://forge.example/edit/docs/guides/usage.md"`,
|
|
`<figcaption><a href="https://forge.example/src/config/run.sh">config/run.sh</a></figcaption>`,
|
|
} {
|
|
if !strings.Contains(usage, want) {
|
|
t.Errorf("guides/usage.html missing %s", want)
|
|
}
|
|
}
|
|
if alpha := readOut(t, out, "api/alpha.html"); !strings.Contains(alpha, `href="https://forge.example/edit/modules/alpha/README.md"`) {
|
|
t.Error("api/alpha.html has no edit link to its README")
|
|
}
|
|
|
|
// Without the URLs there is no edit link and the caption is plain text.
|
|
out = buildEmit(t, Options{Root: emitTree(t, emitSite, nil)})
|
|
usage = readOut(t, out, "guides/usage.html")
|
|
if strings.Contains(usage, "forge.example") || !strings.Contains(usage, "<figcaption>config/run.sh</figcaption>") {
|
|
t.Errorf("guides/usage.html without edit/source URLs:\n%s", usage)
|
|
}
|
|
}
|