diff --git a/cmd/summer/docs_test.go b/cmd/summer/docs_test.go index 1a2d479..24722e0 100644 --- a/cmd/summer/docs_test.go +++ b/cmd/summer/docs_test.go @@ -409,3 +409,160 @@ func TestDocsCommandsMirrorGeneratedMain(t *testing.T) { } } } + +// cleanDocsFixture is the internal/docsite fixture that passes every check. +const cleanDocsFixture = "../../internal/docsite/testdata/clean" + +// scratchDocsRoot copies the clean docs fixture into a temp dir. +func scratchDocsRoot(t *testing.T) string { + t.Helper() + root := t.TempDir() + err := filepath.WalkDir(cleanDocsFixture, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(cleanDocsFixture, p) + target := filepath.Join(root, rel) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + return os.WriteFile(target, data, 0o644) + }) + if err != nil { + t.Fatal(err) + } + return root +} + +// runSummer runs the summer tool with args and returns its output. +func runSummer(t *testing.T, args ...string) (string, error) { + t.Helper() + var buf bytes.Buffer + root, err := bonfire.NewRoot("summer", toolCommands(), &buf) + if err != nil { + t.Fatal(err) + } + root.SetArgs(args) + err = root.Execute() + return buf.String(), err +} + +func appendDoc(t *testing.T, root, name, text string) { + t.Helper() + f, err := os.OpenFile(filepath.Join(root, filepath.FromSlash(name)), os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if _, err := f.WriteString(text); err != nil { + t.Fatal(err) + } +} + +func TestDocsBuildCheckOutput(t *testing.T) { + root := scratchDocsRoot(t) + out, err := runSummer(t, "docs:build", "--check", "--root", root) + if err != nil || out != "docs:build: no problems found\n" { + t.Fatalf("docs:build --check on the clean fixture = %q, %v", out, err) + } + + appendDoc(t, root, "docs/extras/faq.md", "\nSee `demo.Missing` and `summer no:such`.\n") + site := filepath.Join(t.TempDir(), "site") + for _, args := range [][]string{ + {"docs:build", "--check", "--root", root}, + {"docs:build", "--root", root, "--out", site}, + } { + out, err := runSummer(t, args...) + if err == nil || err.Error() != "docs:build failed" { + t.Fatalf("%v = %v, want docs:build failed", args, err) + } + want := "docs/extras/faq.md:17: identifier: demo.Missing does not exist in modules/demo\n" + + "docs/extras/faq.md:17: command: \"no:such\" is not a summer or application command\n" + + "docs:build: 2 problems, nothing written\n" + if out != want { + t.Fatalf("%v output =\n%s\nwant\n%s", args, out, want) + } + } + if _, err := os.Stat(site); err == nil { + t.Fatal("docs:build wrote output despite problems") + } + + // A refused --out is an error without problem lines. + if _, err := runSummer(t, "docs:build", "--root", root, "--out", filepath.Join(root, "docs", "site")); err == nil || + !strings.Contains(err.Error(), "--out must not be inside --src") { + t.Fatalf("docs:build --out inside docs = %v", err) + } + // A missing site.yaml is an error for --check too. + if _, err := runSummer(t, "docs:build", "--check", "--root", t.TempDir()); err == nil || !strings.Contains(err.Error(), "read site config") { + t.Fatalf("docs:build --check without site.yaml = %v", err) + } + + // --src and --base-url are honoured. + clean := scratchDocsRoot(t) + out, err = runSummer(t, "docs:build", "--root", clean, "--src", filepath.Join(clean, "docs"), "--out", site, "--base-url", "/v2/") + if err != nil || !strings.HasPrefix(out, "docs:build: wrote 6 pages to ") { + t.Fatalf("docs:build --src --base-url = %q, %v", out, err) + } + if html, err := os.ReadFile(filepath.Join(site, "index.html")); err != nil || !strings.Contains(string(html), `href="/v2/assets/site.css"`) { + t.Fatalf("--base-url not applied: %v", err) + } +} + +func TestDocsSyncOutput(t *testing.T) { + root := scratchDocsRoot(t) + out, err := runSummer(t, "docs:sync", "--root", root) + if err != nil || out != "docs:sync: all snippets up to date\n" { + t.Fatalf("docs:sync on the clean fixture = %q, %v", out, err) + } + + start := filepath.Join(root, "docs", "guide", "start.md") + raw, err := os.ReadFile(start) + if err != nil { + t.Fatal(err) + } + drifted := strings.Replace(string(raw), `demo.Hello("blog")`, `demo.Hello("stale")`, 1) + if err := os.WriteFile(start, []byte(drifted), 0o644); err != nil { + t.Fatal(err) + } + out, err = runSummer(t, "docs:sync", "--root", root) + if err != nil || out != "docs:sync: updated 1 snippets in 1 files\n" { + t.Fatalf("docs:sync with drift = %q, %v", out, err) + } + if got, _ := os.ReadFile(start); string(got) != string(raw) { + t.Fatal("docs:sync did not restore the source text") + } + + appendDoc(t, root, "docs/extras/faq.md", "\n```go src=modules/demo/demo.go#Nope\n```\n") + out, err = runSummer(t, "docs:sync", "--root", root) + if err == nil || err.Error() != "docs:sync failed" { + t.Fatalf("docs:sync with a broken ref = %v", err) + } + if want := "docs/extras/faq.md:17: snippet: modules/demo/demo.go#Nope not found\ndocs:sync: 1 problems, nothing written\n"; out != want { + t.Fatalf("docs:sync output =\n%s\nwant\n%s", out, want) + } +} + +func TestDocsServeRefusal(t *testing.T) { + root := scratchDocsRoot(t) + for _, addr := range []string{"0.0.0.0:8088", "[::]:8088", "192.0.2.10:8088"} { + _, err := runSummer(t, "docs:serve", "--root", root, "--addr", addr) + want := "docs:serve: refusing to listen on " + addr + ": not a loopback address. Pass --allow-remote to serve on the network." + if err == nil || err.Error() != want { + t.Errorf("docs:serve --addr %s = %v, want %q", addr, err, want) + } + } + // --allow-remote passes the address check; the planted problem then + // stops the first build before anything listens. + appendDoc(t, root, "docs/extras/faq.md", "\n`summer no:such`\n") + out, err := runSummer(t, "docs:serve", "--root", root, "--addr", "0.0.0.0:0", "--allow-remote") + if err == nil || err.Error() != "docs:serve: build failed" || !strings.Contains(out, `"no:such"`) { + t.Fatalf("docs:serve --allow-remote with a problem = %q, %v", out, err) + } + if strings.Contains(out, "Serving docs") { + t.Fatal("docs:serve listened despite a failed build") + } +} diff --git a/internal/docsite/checks_test.go b/internal/docsite/checks_test.go index 5e6fd76..221173a 100644 --- a/internal/docsite/checks_test.go +++ b/internal/docsite/checks_test.go @@ -1,6 +1,7 @@ package docsite import ( + "maps" "os" "path/filepath" "slices" @@ -274,3 +275,256 @@ func TestFencePolicy(t *testing.T) { "modules/fixture/README.md:14: callout: unknown type CAUTION (use NOTE, TIP or WARNING)", }) } + +// indexFormsModule declares the receiver and embedding forms the index +// must resolve. +const indexFormsModule = `package forms + +import "sync" + +// List is generic over one type. +type List[T any] struct{ items []T } + +// Map is generic over two types. +type Map[K comparable, V any] struct{ m map[K]V } + +// Len has a generic receiver. +func (l *List[T]) Len() int { return len(l.items) } + +// Get has a two-parameter generic receiver. +func (m Map[K, V]) Get(k K) V { return m.m[k] } + +// Paren has a parenthesised receiver. +func (l (*List[T])) Paren() {} + +// Base is embedded by pointer. +type Base struct{} + +// Hello is promoted to Outer through *Base. +func (*Base) Hello() {} + +// Inner embeds Base. +type Inner struct{ *Base } + +// Outer embeds Inner, a qualified type and a generic type. +type Outer struct { + Inner + sync.Mutex + List[int] + Named, Other string +} + +// Reader embeds an interface and declares a method. +type Reader interface { + Closer + Read() error +} + +// Closer is embedded in Reader. +type Closer interface{ Close() error } + +// Func is not a struct or interface. +type Func func() + +// Empty is an interface without methods. +type Empty interface{} +` + +func TestIdentifierIndexForms(t *testing.T) { + root := writeTree(t, map[string]string{ + "go.mod": "module example.com/forms\n\ngo 1.27\n", + "modules/forms/forms.go": indexFormsModule, + "modules/forms/forms_test.go": "package forms\n\nfunc NotIndexed() {}\n", + "modules/forms/_skip/skip.go": "package skip\n", + "modules/forms/.hidden/h.go": "package hidden\n", + "modules/forms/testdata/td.go": "package td\n", + "modules/forms/deeper/leaf/l.go": "package leaf\n\n// Leaf is deep.\nconst Leaf = 1\n", + }) + idx, problems, err := buildIdentIndex(root) + if err != nil || len(problems) > 0 { + t.Fatalf("buildIdentIndex: %v %q", err, problemLines(problems)) + } + if _, ok := idx.pkgs["leaf"]; !ok || len(idx.pkgs) != 2 { + t.Fatalf("indexed packages = %v", slices.Sorted(maps.Keys(idx.pkgs))) + } + for span, want := range map[string]string{ + "forms.List": "", + "forms.List.Len": "", + "*forms.List[int]": "", + "forms.List[string].Len()": "", + "forms.Map.Get": "", + "forms.List.Paren": "", + "forms.Outer.Hello": "", + "forms.Outer.Inner": "", + "forms.Outer.Base": "", + "forms.Outer.Mutex": "", + "forms.Outer.List": "", + "forms.Outer.Len": "", + "forms.Outer.Other": "", + "forms.Reader.Close": "", + "forms.Reader.Closer": "", + "forms.Reader.Read": "", + "forms.Func": "", + "forms.Empty": "", + "forms.Outer.lowercase": "", + "leaf.Leaf": "", + "forms.New(ctx, opts)": "forms.New does not exist in modules/forms", + "forms.Outer.Lock": "forms.Outer.Lock does not exist in modules/forms", + "forms.NotIndexed": "forms.NotIndexed does not exist in modules/forms", + "forms.Outer.Hello.Extra": "", + "other.Thing": "", + "forms.lower": "", + "Forms.Upper": "", + "forms.Func[": "", + "leaf.Missing": "leaf.Missing does not exist in modules/forms/deeper/leaf", + " forms.Map.Get ": "", + "fmt.Println(forms.List{}.Len)": "", + } { + if got := idx.checkSpan(span); got != want { + t.Errorf("checkSpan(%q) = %q, want %q", span, got, want) + } + } +} + +func TestIdentifierGoDocFallback(t *testing.T) { + root := writeTree(t, map[string]string{ + "go.mod": "module example.com/forms\n\ngo 1.27\n", + "modules/forms/forms.go": indexFormsModule, + }) + idx, _, err := buildIdentIndex(root) + if err != nil { + t.Fatal(err) + } + // go doc resolves what it is asked for in the package directory. + if !idx.goDoc("modules/forms", "List.Len") || idx.goDoc("modules/forms", "List.Nope") { + t.Fatal("go doc fallback does not agree with the package") + } + if v, ok := idx.docs["modules/forms List.Len"]; !ok || !v { + t.Fatal("go doc result not cached") + } + // Cached answers are used without running go doc again. + idx.docs["modules/forms Cached"] = true + if !idx.goDoc("modules/forms", "Cached") { + t.Fatal("cached go doc answer ignored") + } + // Anything that is not an identifier never reaches the go doc argv. + for _, q := range []string{"List[int]", "-u", "A.B.C", "A;B", ""} { + if idx.goDoc("modules/forms", q) { + t.Errorf("goDoc(%q) = true", q) + } + if _, ok := idx.docs["modules/forms "+q]; ok { + t.Errorf("goDoc(%q) ran go doc", q) + } + } + // The index misses a member promoted from another package; the + // fallback runs and refuses it too, so the span is a problem. + if got := idx.checkSpan("forms.Outer.Unlock"); got != "forms.Outer.Unlock does not exist in modules/forms" { + t.Fatalf("checkSpan(forms.Outer.Unlock) = %q", got) + } + if _, ok := idx.docs["modules/forms Outer.Unlock"]; !ok { + t.Fatal("the fallback did not run for an index miss") + } + // No modules directory is an empty index. + idx, problems, err := buildIdentIndex(t.TempDir()) + if err != nil || len(problems) > 0 || len(idx.pkgs) != 0 { + t.Fatalf("buildIdentIndex without modules/ = %v %v %v", idx.pkgs, problems, err) + } + // A module that does not parse is an error. + bad := writeTree(t, map[string]string{"modules/bad/bad.go": "package bad\n\nfunc {\n"}) + if _, _, err := buildIdentIndex(bad); err == nil || !strings.Contains(err.Error(), "index modules") { + t.Fatalf("buildIdentIndex on a broken module = %v", err) + } +} + +func TestCommandTokenForms(t *testing.T) { + body := strings.Join([]string{ + "Spans: `summer docs:build`, `$ summer make:plugin acme.blog`, `summer --version`, `summer`, `./bin/acme serve --addr 127.0.0.1:8080`.", + "", + "```console", + "$ summer docs:build --out site; summer make:plugin acme.x || ./bin/acme migrate | tee log", + "summer docs:build \\", + " --check", + "# summer not:a-command in a comment", + "echo summer not:first", + "./bin/other-app.v2 serve", + "```", + "", + "```shell", + "$ summer make:plugin spaced", + "```", + "", + }, "\n") + assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{ + "docs/index.md": page("Acme docs", "index", 0, body), + }), nil) + + bad := body + "`summer bogus:one`\n\n```sh\n$ summer docs:build && summer bogus:two; ./bin/acme bogus:three\n```\n\n```bash\nsummer bogus:four\n" + assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{ + "docs/index.md": page("Acme docs", "index", 0, bad), + }), []string{ + `docs/index.md:23: command: "bogus:one" is not a summer or application command`, + `docs/index.md:26: command: "bogus:two" is not a summer or application command`, + `docs/index.md:26: command: "bogus:three" is not a summer or application command`, + `docs/index.md:30: command: "bogus:four" is not a summer or application command`, + }) +} + +func TestExampleCommandNames(t *testing.T) { + dir := writeTree(t, map[string]string{ + "a/main.go": "package main\n\nimport \"x/bonfire\"\n\nvar a = bonfire.Command{Name: \"a:one\", Description: \"d\"}\n" + + "var b = []bonfire.Command{{Name: `a:two`}, {Name: \"a:\" + \"skip\"}, bonfire.Command{Name: \"a:three\"}}\n" + + "var c = []other.Command{{Name: \"c:skip\"}}\n" + + "var d = struct{ Name string }{Name: \"d:skip\"}\n" + + "var e = bonfire.Command{\"e:positional\"}\n" + + "var f = bonfire.Command{Name: name}\n", + "a/main_test.go": "package main\n\nvar t = bonfire.Command{Name: \"t:skip\"}\n", + "testdata/x.go": "package x\n\nvar x = bonfire.Command{Name: \"x:skip\"}\n", + "node_modules/n.go": "package n\n\nvar n = bonfire.Command{Name: \"n:skip\"}\n", + "vendor/v.go": "package v\n\nvar v = bonfire.Command{Name: \"v:skip\"}\n", + "_old/o.go": "package o\n\nvar o = bonfire.Command{Name: \"o:skip\"}\n", + ".git/g.go": "package g\n\nvar g = bonfire.Command{Name: \"g:skip\"}\n", + "b/readme.md": "bonfire.Command{Name: \"md:skip\"}\n", + "b/pointer.go": "package b\n\nvar p = &bonfire.Command{Name: \"b:pointer\"}\n", + "b/nested/deeper/deep.go": "package deeper\n\nfunc f() { _ = []bonfire.Command{{Name: \"b:deep\"}} }\n", + }) + names, err := exampleCommandNames(dir) + if err != nil { + t.Fatal(err) + } + slices.Sort(names) + if want := []string{"a:one", "a:three", "a:two", "b:deep", "b:pointer"}; !slices.Equal(names, want) { + t.Fatalf("exampleCommandNames = %v, want %v", names, want) + } + if names, err := exampleCommandNames(filepath.Join(dir, "missing")); err != nil || names != nil { + t.Fatalf("missing dir = %v, %v", names, err) + } + writeFile(t, dir, "c/broken.go", "package c\n\nfunc {\n") + if _, err := exampleCommandNames(dir); err == nil || !strings.Contains(err.Error(), "scan") { + t.Fatalf("broken example = %v", err) + } +} + +func TestLinkCheckerImagesAndSchemes(t *testing.T) { + good := "## Top\n\n![logo](https://example.com/logo.png) [top](#top) [mail](mailto:x@example.com)\n" + assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{ + "docs/index.md": page("Acme docs", "index", 0, good), + }), nil) + bad := good + "\n![](local.png)\n\n[ftp](ftp://example.com/file) [js](javascript:alert(1))\n\n[](setup/missing.md)\n" + assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{ + "docs/index.md": page("Acme docs", "index", 0, bad), + }), []string{ + "docs/index.md:13: link: local.png does not resolve", + "docs/index.md:15: link: ftp://example.com/file does not resolve", + "docs/index.md:15: link: javascript:alert(1) does not resolve", + "docs/index.md:17: link: setup/missing.md does not resolve", + }) + if got := lineOf([]byte("a\nb"), -1, 7); got != 7 { + t.Errorf("lineOf(-1) = %d", got) + } + if got := lineOf([]byte("a\nb"), 99, 7); got != 7 { + t.Errorf("lineOf(past the end) = %d", got) + } + if got := lineOf([]byte("a\nb\nc"), 4, 1); got != 3 { + t.Errorf("lineOf(4) = %d", got) + } +} diff --git a/internal/docsite/docsite_test.go b/internal/docsite/docsite_test.go index 5668fbd..a25244d 100644 --- a/internal/docsite/docsite_test.go +++ b/internal/docsite/docsite_test.go @@ -379,3 +379,85 @@ func TestSyncRewritesDrift(t *testing.T) { t.Errorf("start.md fences not reduced to the language word:\n%s", md) } } + +func TestProblemString(t *testing.T) { + for _, tc := range []struct { + p Problem + want string + }{ + {Problem{File: "docs/a.md", Line: 3, Rule: "link", Message: "x does not resolve"}, "docs/a.md:3: link: x does not resolve"}, + {Problem{File: "modules/b", Rule: "readme", Message: "no README"}, "modules/b: readme: no README"}, + } { + if got := tc.p.String(); got != tc.want { + t.Errorf("String() = %q, want %q", got, tc.want) + } + } + problems := []Problem{{File: "b", Line: 2}, {File: "a", Line: 9}, {File: "b", Line: 1}, {File: "a", Line: 9, Rule: "second"}} + sortProblems(problems) + if got := problemLines(problems); !slices.Equal(got, []string{"a:9: : ", "a:9: second: ", "b:1: : ", "b:2: : "}) { + t.Errorf("sortProblems = %q", got) + } +} + +func TestPathHelpers(t *testing.T) { + sep := string(filepath.Separator) + for _, tc := range []struct { + p, dir string + want bool + }{ + {"/a/b", "/a/b", true}, + {"/a/b/c", "/a/b", true}, + {"/a/bc", "/a/b", false}, + {"/a", "/a/b", false}, + {"/a/..b", "/a", true}, + {"/x" + sep + "y", "/x", true}, + } { + if got := within(filepath.FromSlash(tc.p), filepath.FromSlash(tc.dir)); got != tc.want { + t.Errorf("within(%s, %s) = %v, want %v", tc.p, tc.dir, got, tc.want) + } + } + dir := t.TempDir() + real := filepath.Join(dir, "real") + if err := os.Mkdir(real, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "link") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + realResolved, err := filepath.EvalSymlinks(real) + if err != nil { + t.Fatal(err) + } + if got := resolved(filepath.Join(link, "not", "yet")); got != filepath.Join(realResolved, "not", "yet") { + t.Errorf("resolved(link/not/yet) = %s", got) + } + if got := resolved("/no-such-root-dir/x"); got != "/no-such-root-dir/x" { + t.Errorf("resolved(missing) = %s", got) + } +} + +func TestWriteOutputs(t *testing.T) { + out := t.TempDir() + if err := writeOutputs(out, map[string][]byte{"a/b.html": []byte("x"), "c.txt": []byte("y")}); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(filepath.Join(out, "a", "b.html")); err != nil || string(got) != "x" { + t.Fatalf("a/b.html = %q, %v", got, err) + } + if err := writeOutputs(out, map[string][]byte{"../escape.html": []byte("x")}); err == nil || !strings.Contains(err.Error(), "escapes") { + t.Fatalf("an escaping output path = %v", err) + } + if _, err := os.Stat(filepath.Join(filepath.Dir(out), "escape.html")); err == nil { + t.Fatal("an output escaped the directory") + } + // A file where a directory is needed fails the write. + if err := writeOutputs(out, map[string][]byte{"c.txt/d.html": []byte("z")}); err == nil { + t.Fatal("writing under a file succeeded") + } + // prepareOut refuses a path it cannot read as a directory. + file := filepath.Join(out, "c.txt") + if err := prepareOut(file); err == nil { + t.Fatal("prepareOut on a file succeeded") + } +} diff --git a/internal/docsite/emit_test.go b/internal/docsite/emit_test.go new file mode 100644 index 0000000..92df44a --- /dev/null +++ b/internal/docsite/emit_test.go @@ -0,0 +1,298 @@ +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, `