- src= fences name a file, a Go declaration or Example body, or a docs:start region - confinement: relative clean paths inside the root, no dotfiles or .env, no nested go.mod modules, Examples need // Output:, test regions must run - a drifted or missing snippet is a problem, so docs:build writes nothing - docs:sync rewrites drifted fence bodies in place - fences render in figure.code with a source caption; .md fences keep only the language - bonfire ExampleCall is the first verified example, shown in setup/installation
379 lines
12 KiB
Go
379 lines
12 KiB
Go
package docsite
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// writeTree writes files (path relative to root, forward slashes) under a
|
|
// fresh temp dir and returns it.
|
|
func writeTree(t *testing.T, files map[string]string) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
for name, body := range files {
|
|
p := filepath.Join(root, filepath.FromSlash(name))
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return root
|
|
}
|
|
|
|
func problemLines(problems []Problem) []string {
|
|
var out []string
|
|
for _, p := range problems {
|
|
out = append(out, p.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
const fixtureSite = `title: Acme
|
|
description: Acme docs.
|
|
sections:
|
|
- name: setup
|
|
title: Setup
|
|
- name: api
|
|
title: API reference
|
|
`
|
|
|
|
func page(title, section string, order int, body string) string {
|
|
return "---\ntitle: " + title + "\ndescription: " + title + " page.\nsection: " + section +
|
|
"\norder: " + strconv.Itoa(order) + "\n---\n# " + title + "\n\n" + body
|
|
}
|
|
|
|
func TestSlugIDs(t *testing.T) {
|
|
ids := newSlugIDs()
|
|
for _, tc := range []struct{ in, want string }{
|
|
{"Install the CLI", "install-the-cli"},
|
|
{"Install the CLI", "install-the-cli-1"},
|
|
{"Install the CLI", "install-the-cli-2"},
|
|
{"snake_case and dash-case", "snake_case-and-dash-case"},
|
|
{"What's new? (v2.0)", "whats-new-v20"},
|
|
{"`bonfire.Call` usage", "bonfirecall-usage"},
|
|
{"Zażółć gęślą", "zażółć-gęślą"},
|
|
{"!!!", "section"},
|
|
} {
|
|
if got := string(ids.Generate([]byte(tc.in), 0)); got != tc.want {
|
|
t.Errorf("Generate(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
ids.Put([]byte("custom"))
|
|
if got := string(ids.Generate([]byte("Custom"), 0)); got != "custom-1" {
|
|
t.Errorf("after Put, Generate(Custom) = %q, want custom-1", got)
|
|
}
|
|
}
|
|
|
|
func TestReadmeIngestion(t *testing.T) {
|
|
files := map[string]string{
|
|
"docs/site.yaml": fixtureSite,
|
|
"docs/index.md": page("Acme docs", "index", 0, "Read [alpha](../modules/alpha/README.md#usage).\n"),
|
|
"docs/setup/start.md": page("Start", "setup", 10, "## First steps\n\nText.\n"),
|
|
"modules/alpha/alpha.go": "package alpha\n",
|
|
"modules/alpha/README.md": "# alpha\n\nAlpha does one thing well.\n\n## Usage\n\n" +
|
|
"See [delta](../delta/README.md#api) and [start](../../docs/setup/start.md).\n",
|
|
"modules/beta/beta.go": "package beta\n",
|
|
"modules/gamma/gamma_test.go": "package gamma\n",
|
|
"modules/delta/delta.go": "package delta\n",
|
|
"modules/delta/README.md": "# delta\n\nDelta does another thing.\n\n## API\n\nText.\n",
|
|
}
|
|
root := writeTree(t, files)
|
|
|
|
pages, problems, err := Pages(Options{Root: root})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := "modules/beta: readme: package has Go files but no README.md"
|
|
if got := problemLines(problems); !slices.Equal(got, []string{want}) {
|
|
t.Fatalf("problems = %q, want [%q]", got, want)
|
|
}
|
|
var urls []string
|
|
for _, p := range pages {
|
|
urls = append(urls, p.URL)
|
|
}
|
|
if !slices.Equal(urls, []string{"index", "setup/start", "api/alpha", "api/delta"}) {
|
|
t.Fatalf("reading order = %v", urls)
|
|
}
|
|
alpha := pages[2]
|
|
if alpha.Title != "alpha" || alpha.Description != "Alpha does one thing well." || alpha.Section != "api" ||
|
|
alpha.Module != "alpha" || alpha.Source != "modules/alpha/README.md" {
|
|
t.Fatalf("alpha page = %+v", alpha)
|
|
}
|
|
|
|
if err := os.WriteFile(filepath.Join(root, "modules/beta/README.md"), []byte("# beta\n\nBeta.\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := filepath.Join(t.TempDir(), "site")
|
|
res, problems, err := Build(Options{Root: root, Out: out})
|
|
if err != nil || len(problems) > 0 {
|
|
t.Fatalf("Build: %v %q", err, problemLines(problems))
|
|
}
|
|
if res.Pages != 5 {
|
|
t.Fatalf("pages = %d, want 5", res.Pages)
|
|
}
|
|
read := func(name string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join(out, name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
html := read("api/alpha.html")
|
|
for _, want := range []string{`href="/api/delta.html#api"`, `href="/setup/start.html"`, `<h2 id="usage">Usage</h2>`} {
|
|
if !strings.Contains(html, want) {
|
|
t.Errorf("api/alpha.html missing %s", want)
|
|
}
|
|
}
|
|
if strings.Contains(html, "<h1>alpha</h1>\n<p class=\"lead\">Alpha does one thing well.</p>\n<p>Alpha does") {
|
|
t.Error("summary line rendered twice")
|
|
}
|
|
md := read("api/alpha.md")
|
|
if !strings.HasPrefix(md, "# alpha\n\n> Alpha does one thing well.\n\n## Usage\n") ||
|
|
!strings.Contains(md, "(/api/delta.md#api)") || !strings.Contains(md, "(/setup/start.md)") {
|
|
t.Errorf("api/alpha.md = %q", md)
|
|
}
|
|
if idx := read("index.html"); !strings.Contains(idx, `href="/api/alpha.html#usage"`) {
|
|
t.Error("index.html does not link the rewritten module README")
|
|
}
|
|
search := read("search-index.json")
|
|
if !strings.Contains(search, `{"p":2,"a":"usage","h":"Usage","x":"See delta and start."}`) {
|
|
t.Errorf("search index missing the alpha Usage entry: %s", search)
|
|
}
|
|
}
|
|
|
|
const snippetSite = `title: Acme
|
|
description: Acme docs.
|
|
sections:
|
|
- name: setup
|
|
title: Setup
|
|
`
|
|
|
|
const libGo = `package lib
|
|
|
|
// Greeting returns a greeting.
|
|
func Greeting(name string) string {
|
|
return "Hello, " + name
|
|
}
|
|
|
|
const (
|
|
// A is the first.
|
|
A = 1
|
|
B = 2
|
|
)
|
|
`
|
|
|
|
const libTestGo = `package lib
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
)
|
|
|
|
func TestGreeting(t *testing.T) {
|
|
// docs:start call
|
|
got := Greeting("x")
|
|
// docs:end call
|
|
if got == "" || check() == "" {
|
|
t.Fatal("empty")
|
|
}
|
|
}
|
|
|
|
func check() string {
|
|
// docs:start called
|
|
return Greeting("y")
|
|
// docs:end called
|
|
}
|
|
|
|
func helper() string {
|
|
// docs:start orphan
|
|
return "x"
|
|
// docs:end orphan
|
|
}
|
|
|
|
func ExampleGreeting() {
|
|
fmt.Println(Greeting("blog"))
|
|
// Output: Hello, blog
|
|
}
|
|
|
|
func ExampleB() {
|
|
_ = Greeting("x")
|
|
}
|
|
`
|
|
|
|
// snippetTree is a repository fixture with Go sources, tests and YAML that
|
|
// src= references can name.
|
|
func snippetTree(t *testing.T, extra map[string]string) string {
|
|
t.Helper()
|
|
files := map[string]string{
|
|
"go.mod": "module example.com/acme\n\ngo 1.27\n",
|
|
"docs/site.yaml": snippetSite,
|
|
"docs/index.md": page("Acme docs", "index", 0, "Hello.\n"),
|
|
"pkg/lib.go": libGo,
|
|
"pkg/lib_test.go": libTestGo,
|
|
"config/app.yaml": "app:\n # docs:start db\n db:\n host: localhost\n # docs:end db\n",
|
|
"nested/go.mod": "module example.com/nested\n",
|
|
"nested/x.go": "package nested\n\n// X is x.\nconst X = 1\n",
|
|
"nested/x_test.go": "package nested\n",
|
|
"notests/a.go": "package notests\n\n// A is a.\nconst A = 1\n",
|
|
"docs/setup/start.md": page("Start", "setup", 10, "Text.\n"),
|
|
}
|
|
for k, v := range extra {
|
|
files[k] = v
|
|
}
|
|
return writeTree(t, files)
|
|
}
|
|
|
|
func TestSnippetForms(t *testing.T) {
|
|
root := snippetTree(t, nil)
|
|
for _, tc := range []struct {
|
|
ref Ref
|
|
want string
|
|
}{
|
|
{Ref{Path: "pkg/lib.go"}, libGo},
|
|
{Ref{Path: "pkg/lib.go", Fragment: "Greeting"}, "// Greeting returns a greeting.\nfunc Greeting(name string) string {\n\treturn \"Hello, \" + name\n}"},
|
|
{Ref{Path: "pkg/lib.go", Fragment: "A"}, "// A is the first.\n\tA = 1"},
|
|
{Ref{Path: "pkg/lib.go", Fragment: "B"}, "B = 2"},
|
|
{Ref{Path: "pkg/lib_test.go", Fragment: "ExampleGreeting"}, "fmt.Println(Greeting(\"blog\"))\n// Output: Hello, blog"},
|
|
{Ref{Path: "pkg/lib_test.go", Fragment: "call"}, "got := Greeting(\"x\")"},
|
|
{Ref{Path: "pkg/lib_test.go", Fragment: "called"}, "return Greeting(\"y\")"},
|
|
{Ref{Path: "pkg/lib_test.go", Fragment: "check"}, "func check() string {\n\t// docs:start called\n\treturn Greeting(\"y\")\n\t// docs:end called\n}"},
|
|
{Ref{Path: "config/app.yaml", Fragment: "db"}, "db:\n host: localhost"},
|
|
} {
|
|
got, err := Extract(root, tc.ref)
|
|
if err != nil {
|
|
t.Errorf("Extract(%s): %v", tc.ref, err)
|
|
continue
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("Extract(%s) = %q, want %q", tc.ref, got, tc.want)
|
|
}
|
|
}
|
|
for _, ref := range []Ref{{Path: "pkg/lib.go", Fragment: "Missing"}, {Path: "pkg/nope.go"}, {Path: "config/app.yaml", Fragment: "nope"}} {
|
|
if _, err := Extract(root, ref); err != errSnippetNotFound {
|
|
t.Errorf("Extract(%s) err = %v, want not found", ref, err)
|
|
}
|
|
}
|
|
if ref, ok := ParseSrc("go src=pkg/lib.go#Greeting title=x"); !ok || ref != (Ref{Path: "pkg/lib.go", Fragment: "Greeting"}) {
|
|
t.Errorf("ParseSrc = %+v %v", ref, ok)
|
|
}
|
|
if _, ok := ParseSrc("go"); ok {
|
|
t.Error("ParseSrc found src= in a bare info string")
|
|
}
|
|
}
|
|
|
|
func TestSnippetConfinement(t *testing.T) {
|
|
outside := filepath.Join(t.TempDir(), "secret.txt")
|
|
if err := os.WriteFile(outside, []byte("secret\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cases := []struct{ src, want string }{
|
|
{"/etc/passwd", "path must be relative"},
|
|
{"../x.go", "must not leave the repository root"},
|
|
{".env", "dotfile or .env file"},
|
|
{"config/prod.env", "dotfile or .env file"},
|
|
{"nested/x.go#X", "nested module nested/go.mod"},
|
|
{"escape.txt", "outside the repository root"},
|
|
{"notests/a.go#A", "no _test.go files"},
|
|
{"pkg/lib_test.go#ExampleB", "has no // Output: comment"},
|
|
{"pkg/lib_test.go#orphan", "not inside a Test or Example function"},
|
|
{"pkg/lib_test.go#helper", "not inside a Test or Example function"},
|
|
{"pkg/nope.go", "pkg/nope.go not found"},
|
|
}
|
|
var body strings.Builder
|
|
lines := map[string]int{}
|
|
line := 8 // frontmatter (6 lines), "# Snippets", blank
|
|
for _, tc := range cases {
|
|
body.WriteString("```go src=" + tc.src + "\n```\n\n")
|
|
line++
|
|
lines[tc.src] = line
|
|
line += 2
|
|
}
|
|
root := snippetTree(t, map[string]string{
|
|
"docs/setup/snippets.md": page("Snippets", "setup", 20, body.String()),
|
|
"config/prod.env": "SECRET=1\n",
|
|
})
|
|
if err := os.Symlink(outside, filepath.Join(root, "escape.txt")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
problems, err := Check(Options{Root: root})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := problemLines(problems)
|
|
if len(got) != len(cases) {
|
|
t.Errorf("got %d problems, want %d:\n%s", len(got), len(cases), strings.Join(got, "\n"))
|
|
}
|
|
for _, tc := range cases {
|
|
prefix := "docs/setup/snippets.md:" + strconv.Itoa(lines[tc.src]) + ": snippet: "
|
|
if !slices.ContainsFunc(got, func(g string) bool { return strings.HasPrefix(g, prefix) && strings.Contains(g, tc.want) }) {
|
|
t.Errorf("src=%s: no problem %q...%q in\n%s", tc.src, prefix, tc.want, strings.Join(got, "\n"))
|
|
}
|
|
}
|
|
if _, _, err := Build(Options{Root: root, Out: filepath.Join(t.TempDir(), "site")}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestSyncRewritesDrift(t *testing.T) {
|
|
doc := page("Start", "setup", 10, "Intro.\n\n- In a list:\n\n ```go src=pkg/lib.go#Greeting\n func stale() {}\n ```\n\n"+
|
|
"```yaml src=config/app.yaml#db\ndb:\n host: localhost\n```\n\nOutro.\n")
|
|
root := snippetTree(t, map[string]string{"docs/setup/start.md": doc})
|
|
path := filepath.Join(root, "docs/setup/start.md")
|
|
|
|
problems, err := Check(Options{Root: root})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := "docs/setup/start.md:13: snippet: body differs from pkg/lib.go#Greeting (run: summer docs:sync)"
|
|
if got := problemLines(problems); !slices.Equal(got, []string{want}) {
|
|
t.Fatalf("problems = %q, want [%q]", got, want)
|
|
}
|
|
out := filepath.Join(t.TempDir(), "site")
|
|
if _, problems, err := Build(Options{Root: root, Out: out}); err != nil || len(problems) != 1 {
|
|
t.Fatalf("Build with drift: %v %v", err, problems)
|
|
}
|
|
if _, err := os.Stat(out); err == nil {
|
|
t.Fatal("Build wrote output despite a drifted snippet")
|
|
}
|
|
|
|
res, problems, err := Sync(Options{Root: root})
|
|
if err != nil || len(problems) > 0 {
|
|
t.Fatalf("Sync: %v %v", err, problems)
|
|
}
|
|
if res != (SyncResult{Snippets: 1, Files: 1}) {
|
|
t.Fatalf("Sync = %+v, want 1 snippet in 1 file", res)
|
|
}
|
|
got, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantDoc := strings.Replace(doc, " func stale() {}\n",
|
|
" // Greeting returns a greeting.\n func Greeting(name string) string {\n \treturn \"Hello, \" + name\n }\n", 1)
|
|
if string(got) != wantDoc {
|
|
t.Fatalf("synced file =\n%s\nwant\n%s", got, wantDoc)
|
|
}
|
|
if res, _, err := Sync(Options{Root: root}); err != nil || res != (SyncResult{}) {
|
|
t.Fatalf("second Sync = %+v, %v; want up to date", res, err)
|
|
}
|
|
if problems, err := Check(Options{Root: root}); err != nil || len(problems) > 0 {
|
|
t.Fatalf("Check after Sync: %v %q", err, problemLines(problems))
|
|
}
|
|
if _, problems, err := Build(Options{Root: root, Out: out}); err != nil || len(problems) > 0 {
|
|
t.Fatalf("Build after Sync: %v %v", err, problems)
|
|
}
|
|
md, err := os.ReadFile(filepath.Join(out, "setup/start.md"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(md), "src=") || !strings.Contains(string(md), " ```go\n") || !strings.Contains(string(md), "```yaml\n") {
|
|
t.Errorf("start.md fences not reduced to the language word:\n%s", md)
|
|
}
|
|
}
|