test(11.1-06): branch-level tests for every docsite stage (94.8% coverage)
- 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)
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [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\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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
298
internal/docsite/emit_test.go
Normal file
298
internal/docsite/emit_test.go
Normal file
@@ -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, `<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)
|
||||
}
|
||||
}
|
||||
152
internal/docsite/highlight_test.go
Normal file
152
internal/docsite/highlight_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package docsite
|
||||
|
||||
import (
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// spanText matches one tok-* span.
|
||||
var spanText = regexp.MustCompile(`<span class="(tok-[a-z]+)">([^<]*)</span>`)
|
||||
|
||||
// tokens returns the class and unescaped text of every tok-* span.
|
||||
func tokens(out string) map[string][]string {
|
||||
got := map[string][]string{}
|
||||
for _, m := range spanText.FindAllStringSubmatch(out, -1) {
|
||||
got[m[1]] = append(got[m[1]], html.UnescapeString(m[2]))
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func assertToken(t *testing.T, lang, out, class, text string) {
|
||||
t.Helper()
|
||||
for _, v := range tokens(out)[class] {
|
||||
if strings.TrimSpace(v) == text {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("%s: no %s span for %q in\n%s", lang, class, text, out)
|
||||
}
|
||||
|
||||
// assertNoTags fails when output has markup other than tok-* spans.
|
||||
func assertNoTags(t *testing.T, lang, out string) {
|
||||
t.Helper()
|
||||
rest := spanText.ReplaceAllString(out, "")
|
||||
if strings.ContainsAny(rest, "<>") {
|
||||
t.Errorf("%s: unexpected markup in %q", lang, rest)
|
||||
}
|
||||
if strings.Contains(out, "style=") {
|
||||
t.Errorf("%s: inline style in %q", lang, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightGo(t *testing.T) {
|
||||
src := "package main\n\n// Say says.\nfunc Say() (int, string) {\n\treturn 42, \"a<b & \\\"c\\\"\"\n}\n"
|
||||
out := highlight("go", src)
|
||||
assertToken(t, "go", out, "tok-kw", "package")
|
||||
assertToken(t, "go", out, "tok-kw", "func")
|
||||
assertToken(t, "go", out, "tok-kw", "return")
|
||||
assertToken(t, "go", out, "tok-com", "// Say says.")
|
||||
assertToken(t, "go", out, "tok-num", "42")
|
||||
assertToken(t, "go", out, "tok-str", `"a<b & \"c\""`)
|
||||
assertNoTags(t, "go", out)
|
||||
for _, want := range []string{"<", "&", """} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("go output does not escape to %s: %s", want, out)
|
||||
}
|
||||
}
|
||||
if html.UnescapeString(spanText.ReplaceAllString(out, "$2")) != src {
|
||||
t.Errorf("go output does not round-trip to the source:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightYAML(t *testing.T) {
|
||||
src := "# comment\nname: \"quoted <x>\"\nport: 5432\nenabled: true\nplain: text value\n"
|
||||
for _, lang := range []string{"yaml", "yml"} {
|
||||
out := highlight(lang, src)
|
||||
assertToken(t, lang, out, "tok-com", "# comment")
|
||||
assertToken(t, lang, out, "tok-key", "name")
|
||||
assertToken(t, lang, out, "tok-str", `"quoted <x>"`)
|
||||
assertToken(t, lang, out, "tok-num", "5432")
|
||||
assertToken(t, lang, out, "tok-num", "true")
|
||||
assertToken(t, lang, out, "tok-str", "text value")
|
||||
assertNoTags(t, lang, out)
|
||||
}
|
||||
out := highlight("json", `{"a": null, "b": 1}`)
|
||||
assertToken(t, "json", out, "tok-num", "null")
|
||||
assertToken(t, "json", out, "tok-num", "1")
|
||||
assertNoTags(t, "json", out)
|
||||
}
|
||||
|
||||
func TestHighlightShell(t *testing.T) {
|
||||
src := "$ summer docs:build --out \"site\" # build\n# a comment line\n ./bin/acme migrate \\\n --force\n\nsummer\n"
|
||||
for _, lang := range []string{"sh", "shell", "bash", "console"} {
|
||||
out := highlight(lang, src)
|
||||
assertToken(t, lang, out, "tok-prompt", "$")
|
||||
assertToken(t, lang, out, "tok-kw", "summer")
|
||||
assertToken(t, lang, out, "tok-kw", "./bin/acme")
|
||||
assertToken(t, lang, out, "tok-com", "# a comment line")
|
||||
assertToken(t, lang, out, "tok-com", "# build")
|
||||
assertToken(t, lang, out, "tok-str", `"site"`)
|
||||
assertNoTags(t, lang, out)
|
||||
// The continuation line has no command word.
|
||||
for _, kw := range tokens(out)["tok-kw"] {
|
||||
if strings.Contains(kw, "--force") {
|
||||
t.Errorf("%s: continuation line highlighted as a command: %q", lang, kw)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(out, `<span class="tok-prompt">$ </span><span class="tok-kw">summer</span> `) {
|
||||
t.Errorf("%s: output starts %q", lang, out[:min(len(out), 80)])
|
||||
}
|
||||
if !strings.Contains(out, "\n <span class=\"tok-kw\">./bin/acme</span>") {
|
||||
t.Errorf("%s: indentation before a command was not kept:\n%s", lang, out)
|
||||
}
|
||||
if html.UnescapeString(spanText.ReplaceAllString(out, "$2")) != src {
|
||||
t.Errorf("%s: output does not round-trip to the source:\n%s", lang, out)
|
||||
}
|
||||
}
|
||||
// A line fragment without a trailing newline stays one line.
|
||||
if out := highlight("sh", "summer serve"); strings.Contains(out, "\n") {
|
||||
t.Errorf("sh fragment gained a newline: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightFallback(t *testing.T) {
|
||||
src := "a < b && c > \"d\" 'e'"
|
||||
want := html.EscapeString(src)
|
||||
for _, lang := range []string{"", "text", "txt", "plain", "plaintext", "no-such-language"} {
|
||||
if got := highlight(lang, src); got != want {
|
||||
t.Errorf("highlight(%q) = %q, want escaped plain text %q", lang, got, want)
|
||||
}
|
||||
}
|
||||
if lexerFor("go") == nil || lexerFor("text") != nil || lexerFor("no-such-language") != nil {
|
||||
t.Error("lexerFor")
|
||||
}
|
||||
var b strings.Builder
|
||||
writeSpan(&b, "tok-kw", "")
|
||||
writeSpan(&b, "", "<x>")
|
||||
if b.String() != "<x>" {
|
||||
t.Errorf("writeSpan = %q", b.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightFenceMarkup(t *testing.T) {
|
||||
s, pages := renderFixture(t, map[string]string{
|
||||
"docs/setup/start.md": page("Start", "setup", 10, "```\nno <lang>\n```\n\n```go title=x\nx := 1\n```\n"),
|
||||
})
|
||||
out := string(renderHTML(t, s, pages["setup/start"]).html)
|
||||
for _, want := range []string{
|
||||
`<figure class="code"><button type="button" class="copy-button" aria-label="Copy code" hidden><svg class="icon icon-copy"`,
|
||||
`<svg class="icon icon-check"`,
|
||||
"</button><pre><code>no <lang>\n</code></pre></figure>",
|
||||
`<pre><code class="language-go">x := <span class="tok-num">1</span>`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %s in\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "<figcaption>") {
|
||||
t.Error("a fence without src= has a caption")
|
||||
}
|
||||
}
|
||||
@@ -369,7 +369,7 @@ func (s *site) loadGuides() ([]*Page, []Problem, error) {
|
||||
}
|
||||
fmRaw, body, bodyLine, ok := splitFrontmatter(raw)
|
||||
if !ok {
|
||||
fail(`the file must start with a "---" frontmatter block closed by a "---" line`)
|
||||
fail(frontmatterBlockProblem(raw))
|
||||
continue
|
||||
}
|
||||
fm, details := parseFrontmatter(fmRaw)
|
||||
@@ -488,6 +488,20 @@ func splitFrontmatter(raw []byte) (fm, body []byte, bodyLine int, ok bool) {
|
||||
return nil, nil, 0, false
|
||||
}
|
||||
|
||||
// frontmatterBlockProblem explains why a page has no frontmatter block. A
|
||||
// byte order mark or CRLF line endings hide a block the file does have, so
|
||||
// they are named instead of the missing block.
|
||||
func frontmatterBlockProblem(raw []byte) string {
|
||||
first, _, _ := bytes.Cut(raw, []byte("\n"))
|
||||
switch {
|
||||
case bytes.HasPrefix(raw, []byte("\xef\xbb\xbf")):
|
||||
return "the file starts with a UTF-8 byte order mark; save it without one"
|
||||
case bytes.HasSuffix(first, []byte("\r")):
|
||||
return "the file uses CRLF line endings; save it with LF line endings"
|
||||
}
|
||||
return `the file must start with a "---" frontmatter block closed by a "---" line`
|
||||
}
|
||||
|
||||
// parseFrontmatter decodes a frontmatter block strictly. It returns the
|
||||
// UI-SPEC problem details: unknown and missing fields first, then decode
|
||||
// errors.
|
||||
|
||||
312
internal/docsite/load_test.go
Normal file
312
internal/docsite/load_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package docsite
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSite(t *testing.T) {
|
||||
const valid = "title: Acme\ndescription: Acme docs.\nsections:\n - name: setup\n title: Setup\n"
|
||||
s, err := ParseSite([]byte(valid))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Title != "Acme" || len(s.Sections) != 1 || s.sectionTitle("setup") != "Setup" || s.sectionTitle("other") != "other" {
|
||||
t.Fatalf("ParseSite = %+v", s)
|
||||
}
|
||||
for _, tc := range []struct{ name, raw, want string }{
|
||||
{"unknown key", valid + "colour: red\n", "docsite: parse site config: "},
|
||||
{"not a mapping", "- a\n- b\n", "docsite: parse site config: "},
|
||||
{"no title", "description: d\nsections:\n - name: a\n title: A\n", "title is required"},
|
||||
{"no description", "title: t\nsections:\n - name: a\n title: A\n", "description is required"},
|
||||
{"no sections", "title: t\ndescription: d\n", "sections is required"},
|
||||
{"section without title", "title: t\ndescription: d\nsections:\n - name: a\n", "every section needs a name and a title"},
|
||||
{"reserved index", "title: t\ndescription: d\nsections:\n - name: index\n title: I\n", `section name "index" is reserved`},
|
||||
{"bad slug", "title: t\ndescription: d\nsections:\n - name: Setup\n title: S\n", `section name "Setup" must be lowercase`},
|
||||
{"duplicate", "title: t\ndescription: d\nsections:\n - name: a\n title: A\n - name: a\n title: B\n", `section "a" is listed twice`},
|
||||
} {
|
||||
if _, err := ParseSite([]byte(tc.raw)); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: err = %v, want %q", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(func() string { _, err := ParseSite([]byte(valid + "colour: red\n")); return err.Error() }(), "\n") {
|
||||
t.Error("a decode error must be reported on one line")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "site.yaml")
|
||||
if err := os.WriteFile(path, []byte(valid), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := LoadSite(path); err != nil || got.Title != "Acme" {
|
||||
t.Fatalf("LoadSite = %+v, %v", got, err)
|
||||
}
|
||||
if _, err := LoadSite(filepath.Join(dir, "missing.yaml")); err == nil || !strings.Contains(err.Error(), "read site config") {
|
||||
t.Fatalf("LoadSite(missing) = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitFrontmatter(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
raw string
|
||||
ok bool
|
||||
fm, body string
|
||||
bodyLine int
|
||||
}{
|
||||
{"plain", "---\ntitle: A\n---\n# A\n", true, "title: A\n", "# A\n", 4},
|
||||
{"trailing spaces on the closing line", "---\ntitle: A\n--- \n# A\n", true, "title: A\n", "# A\n", 4},
|
||||
{"no body", "---\ntitle: A\n---", true, "title: A\n", "", 4},
|
||||
{"no body with newline", "---\ntitle: A\n---\n", true, "title: A\n", "", 4},
|
||||
{"empty block", "---\n---\n# A\n", true, "", "# A\n", 3},
|
||||
{"no closing delimiter", "---\ntitle: A\n# A\n", false, "", "", 0},
|
||||
{"no opening delimiter", "title: A\n---\n# A\n", false, "", "", 0},
|
||||
{"CRLF line endings", "---\r\ntitle: A\r\n---\r\n# A\r\n", false, "", "", 0},
|
||||
{"byte order mark", "\xef\xbb\xbf---\ntitle: A\n---\n# A\n", false, "", "", 0},
|
||||
} {
|
||||
fm, body, line, ok := splitFrontmatter([]byte(tc.raw))
|
||||
if ok != tc.ok || string(fm) != tc.fm || string(body) != tc.body || line != tc.bodyLine {
|
||||
t.Errorf("%s: splitFrontmatter = %q, %q, %d, %v; want %q, %q, %d, %v",
|
||||
tc.name, fm, body, line, ok, tc.fm, tc.body, tc.bodyLine, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFrontmatterEncodingProblems pins the problem a CRLF or BOM page gets:
|
||||
// it names the encoding, not a missing frontmatter block the file has.
|
||||
func TestFrontmatterEncodingProblems(t *testing.T) {
|
||||
crlf := strings.ReplaceAll(page("Win", "setup", 30, "Text.\n"), "\n", "\r\n")
|
||||
bom := "\xef\xbb\xbf" + page("Bom", "setup", 40, "Text.\n")
|
||||
assertProblems(t, checkFixture(t, fixtureCommands, map[string]string{
|
||||
"docs/index.md": page("Acme docs", "index", 0, "Text.\n"),
|
||||
"docs/setup/win.md": crlf,
|
||||
"docs/setup/bom.md": bom,
|
||||
}), []string{
|
||||
"docs/setup/bom.md:1: frontmatter: the file starts with a UTF-8 byte order mark; save it without one",
|
||||
"docs/setup/win.md:1: frontmatter: the file uses CRLF line endings; save it with LF line endings",
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseFrontmatter(t *testing.T) {
|
||||
fm, details := parseFrontmatter([]byte("title: A\ndescription: B\nsection: setup\norder: 3\n"))
|
||||
if len(details) > 0 || fm != (Frontmatter{Title: "A", Description: "B", Section: "setup", Order: 3}) {
|
||||
t.Fatalf("parseFrontmatter = %+v %q", fm, details)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, raw string
|
||||
want []string
|
||||
}{
|
||||
{"unknown fields sorted, then missing", "title: A\nzeta: 1\nalpha: 2\nsection: s\norder: 1\n",
|
||||
[]string{`unknown field "alpha"`, `unknown field "zeta"`, `missing field "description"`}},
|
||||
{"empty and null values are missing", "title: \"\"\ndescription: ~\nsection: s\norder: 1\n",
|
||||
[]string{`missing field "title"`, `missing field "description"`}},
|
||||
{"everything missing", "{}\n",
|
||||
[]string{`missing field "title"`, `missing field "description"`, `missing field "section"`, `missing field "order"`}},
|
||||
} {
|
||||
if _, got := parseFrontmatter([]byte(tc.raw)); !slices.Equal(got, tc.want) {
|
||||
t.Errorf("%s: details = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"- a\n- b\n", "title: A\ndescription: B\nsection: s\norder: x\n"} {
|
||||
if _, got := parseFrontmatter([]byte(raw)); len(got) != 1 || strings.Contains(got[0], "\n") {
|
||||
t.Errorf("parseFrontmatter(%q) = %q, want one single-line decode error", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalkPagesExclusions(t *testing.T) {
|
||||
root := writeTree(t, map[string]string{
|
||||
"docs/index.md": "x",
|
||||
"docs/setup/a.md": "x",
|
||||
"docs/setup/examples/b.md": "x",
|
||||
"docs/setup/notes.txt": "x",
|
||||
"docs/setup/_draft.md": "x",
|
||||
"docs/setup/.hidden.md": "x",
|
||||
"docs/_partials/c.md": "x",
|
||||
"docs/.cache/d.md": "x",
|
||||
"docs/examples/blog/README.md": "x",
|
||||
"docs/examples/blog/plugin.go": "x",
|
||||
"docs/archive/examples/keep.md": "x",
|
||||
})
|
||||
files, err := walkPages(filepath.Join(root, "docs"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, f := range files {
|
||||
rel, _ := filepath.Rel(root, f)
|
||||
got = append(got, filepath.ToSlash(rel))
|
||||
}
|
||||
want := []string{"docs/archive/examples/keep.md", "docs/index.md", "docs/setup/a.md", "docs/setup/examples/b.md"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("walkPages = %v, want %v", got, want)
|
||||
}
|
||||
if _, err := walkPages(filepath.Join(root, "nope")); err == nil {
|
||||
t.Fatal("walkPages on a missing directory returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitReadme(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, raw string
|
||||
ok bool
|
||||
title, desc string
|
||||
body string
|
||||
}{
|
||||
{"summary after blank lines", "# alpha\n\n\n Alpha does it. \n\n## Usage\n", true, "alpha", "Alpha does it.", "# alpha\n\n\n\n\n## Usage\n"},
|
||||
{"summary on line two", "# alpha\nAlpha does it.\n", true, "alpha", "Alpha does it.", "# alpha\n\n"},
|
||||
{"missing H1", "## alpha\n\nAlpha.\n", false, "", "", ""},
|
||||
{"no heading at all", "alpha\n\nAlpha.\n", false, "", "", ""},
|
||||
{"empty title", "# \n\nAlpha.\n", false, "", "", ""},
|
||||
{"heading instead of summary", "# alpha\n\n## Usage\n", false, "", "", ""},
|
||||
{"title only", "# alpha\n\n", false, "", "", ""},
|
||||
{"empty file", "", false, "", "", ""},
|
||||
} {
|
||||
title, desc, body, ok := splitReadme([]byte(tc.raw))
|
||||
if ok != tc.ok || title != tc.title || desc != tc.desc || string(body) != tc.body {
|
||||
t.Errorf("%s: splitReadme = %q, %q, %q, %v", tc.name, title, desc, body, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleNames(t *testing.T) {
|
||||
root := writeTree(t, map[string]string{
|
||||
"modules/zeta/zeta.go": "package zeta\n",
|
||||
"modules/alpha/alpha.go": "package alpha\n",
|
||||
"modules/tests/only_test.go": "package tests\n",
|
||||
"modules/nested/sub/sub.go": "package sub\n",
|
||||
"modules/_skip/skip.go": "package skip\n",
|
||||
"modules/.hidden/hidden.go": "package hidden\n",
|
||||
"modules/file.go": "package modules\n",
|
||||
"modules/docs/README.md": "# docs\n\nNo Go.\n",
|
||||
"modules/gofolder.go/keep.md": "not a Go file\n",
|
||||
})
|
||||
names, err := moduleNames(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(names, []string{"alpha", "zeta"}) {
|
||||
t.Fatalf("moduleNames = %v", names)
|
||||
}
|
||||
if names, err := moduleNames(t.TempDir()); err != nil || names != nil {
|
||||
t.Fatalf("moduleNames without modules/ = %v, %v", names, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSectionRules(t *testing.T) {
|
||||
// A module without an api section in site.yaml.
|
||||
noAPI := "title: Acme\ndescription: Acme docs.\nsections:\n - name: setup\n title: Setup\n - name: \"empty\"\n title: Empty\n"
|
||||
root := writeTree(t, map[string]string{
|
||||
"docs/site.yaml": noAPI,
|
||||
"docs/index.md": page("Acme docs", "index", 0, "Text.\n"),
|
||||
"docs/setup/start.md": page("Start", "setup", 10, "Text.\n"),
|
||||
"modules/fixture/fixture.go": "package fixture\n",
|
||||
"modules/fixture/README.md": "# fixture\n\nFixture.\n",
|
||||
})
|
||||
_, problems, err := Pages(Options{Root: root})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertProblems(t, problemLines(problems), []string{
|
||||
`docs/site.yaml:1: section: "api" is not listed (every module README is published there)`,
|
||||
`docs/site.yaml:6: section: "empty" has no pages (add the section in the same change as its first page)`,
|
||||
})
|
||||
|
||||
// An invalid site.yaml stops the load with one site problem.
|
||||
root = writeTree(t, map[string]string{"docs/site.yaml": "title: Acme\n"})
|
||||
pages, problems, err := Pages(Options{Root: root})
|
||||
if err != nil || pages != nil {
|
||||
t.Fatalf("Pages = %v, %v", pages, err)
|
||||
}
|
||||
assertProblems(t, problemLines(problems), []string{"docs/site.yaml:1: site: site config: description is required"})
|
||||
|
||||
// A missing site.yaml is an error, not a problem.
|
||||
if _, _, err := Pages(Options{Root: t.TempDir()}); err == nil || !strings.Contains(err.Error(), "read site config") {
|
||||
t.Fatalf("Pages without site.yaml = %v", err)
|
||||
}
|
||||
if _, err := Check(Options{Root: t.TempDir(), Commands: fixtureCommands}); err == nil {
|
||||
t.Fatal("Check without site.yaml returned no error")
|
||||
}
|
||||
if _, _, err := Build(Options{Root: t.TempDir(), Out: filepath.Join(t.TempDir(), "site"), Commands: fixtureCommands}); err == nil {
|
||||
t.Fatal("Build without site.yaml returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionLine(t *testing.T) {
|
||||
raw := []byte("title: t\nsections:\n - name: setup\n title: S\n - name: 'quoted'\n title: Q\n - name: \"api\"\n")
|
||||
for name, want := range map[string]int{"setup": 3, "quoted": 5, "api": 7, "missing": 1} {
|
||||
if got := sectionLine(raw, name); got != want {
|
||||
t.Errorf("sectionLine(%s) = %d, want %d", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrderAndSources(t *testing.T) {
|
||||
root := writeTree(t, map[string]string{
|
||||
"docs/site.yaml": fixtureSite,
|
||||
"docs/index.md": page("Acme docs", "index", 0, "Text.\n"),
|
||||
"docs/setup/b.md": page("Bee", "setup", 10, "Text.\n"),
|
||||
"docs/setup/a.md": page("Ay", "setup", 10+10, "Text.\n"),
|
||||
"docs/setup/c.md": page("Cee", "setup", 5, "Text.\n"),
|
||||
"modules/zeta/zeta.go": "package zeta\n",
|
||||
"modules/zeta/README.md": "# zeta\n\nZeta.\n",
|
||||
"modules/beta/beta.go": "package beta\n",
|
||||
"modules/beta/README.md": "# beta\n\nBeta.\n",
|
||||
})
|
||||
pages, problems, err := Pages(Options{Root: root})
|
||||
if err != nil || len(problems) > 0 {
|
||||
t.Fatalf("Pages: %v %q", err, problemLines(problems))
|
||||
}
|
||||
var urls []string
|
||||
for _, p := range pages {
|
||||
urls = append(urls, p.URL)
|
||||
}
|
||||
if want := []string{"index", "setup/c", "setup/b", "setup/a", "api/beta", "api/zeta"}; !slices.Equal(urls, want) {
|
||||
t.Fatalf("reading order = %v, want %v", urls, want)
|
||||
}
|
||||
if pages[1].Source != "docs/setup/c.md" || pages[1].BodyLine != 7 || !strings.HasPrefix(string(pages[1].Body), "# Cee\n") {
|
||||
t.Fatalf("setup/c = %+v", pages[1])
|
||||
}
|
||||
|
||||
// A Src outside the root is shown as an absolute path.
|
||||
src := filepath.Join(t.TempDir(), "docs")
|
||||
if err := os.Rename(filepath.Join(root, "docs"), src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(src, "setup/c.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "setup/c.md"), []byte("no frontmatter\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, problems, err = Pages(Options{Root: root, Src: src})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.ToSlash(filepath.Join(src, "setup/c.md")) + `:1: frontmatter: the file must start with a "---" frontmatter block closed by a "---" line`
|
||||
assertProblems(t, problemLines(problems), []string{want})
|
||||
}
|
||||
|
||||
func TestNormalizeDefaults(t *testing.T) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o, err := Options{}.normalize()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o.Root != wd || o.Src != filepath.Join(wd, "docs") || o.Out != filepath.Join(wd, "site") {
|
||||
t.Fatalf("normalize() = %+v", o)
|
||||
}
|
||||
o, err = Options{Root: "a/../b", Src: "x", Out: "y/"}.normalize()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o.Root != filepath.Join(wd, "b") || o.Src != filepath.Join(wd, "x") || o.Out != filepath.Join(wd, "y") {
|
||||
t.Fatalf("normalize(relative) = %+v", o)
|
||||
}
|
||||
}
|
||||
373
internal/docsite/render_test.go
Normal file
373
internal/docsite/render_test.go
Normal file
@@ -0,0 +1,373 @@
|
||||
package docsite
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/text"
|
||||
)
|
||||
|
||||
func TestSlugIDsEdgeCases(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"Hello 👋 world", "hello--world"},
|
||||
{" Trimmed ", "trimmed"},
|
||||
{"Ünïcödé Straße", "ünïcödé-straße"},
|
||||
{"snake_case_name", "snake_case_name"},
|
||||
{"a.b/c:d", "abcd"},
|
||||
{"v1.2 → v2", "v12--v2"},
|
||||
{"", "section"},
|
||||
{"🎉", "section"},
|
||||
} {
|
||||
if got := string(newSlugIDs().Generate([]byte(tc.in), ast.KindHeading)); got != tc.want {
|
||||
t.Errorf("Generate(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
ids := newSlugIDs()
|
||||
for i, want := range []string{"section", "section-1", "section-2"} {
|
||||
if got := string(ids.Generate(nil, ast.KindHeading)); got != want {
|
||||
t.Errorf("empty heading %d = %q, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderFixture loads a two-section fixture site and returns it with its
|
||||
// pages by URL, without running the checkers.
|
||||
func renderFixture(t *testing.T, files map[string]string) (*site, map[string]*Page) {
|
||||
t.Helper()
|
||||
tree := map[string]string{
|
||||
"docs/site.yaml": fixtureSite,
|
||||
"docs/index.md": page("Acme docs", "index", 0, "Text.\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.\n\n## Usage\n\n" +
|
||||
"See [beta](../beta/README.md#api), [start](../../docs/setup/start.md) and [source](alpha.go).\n",
|
||||
"modules/beta/beta.go": "package beta\n",
|
||||
"modules/beta/README.md": "# beta\n\nBeta does another.\n\n## API\n\nText.\n",
|
||||
}
|
||||
for k, v := range files {
|
||||
tree[k] = v
|
||||
}
|
||||
s, problems, err := load(Options{Root: writeTree(t, tree)})
|
||||
if err != nil || len(problems) > 0 {
|
||||
t.Fatalf("load: %v %q", err, problemLines(problems))
|
||||
}
|
||||
byURL := map[string]*Page{}
|
||||
for _, p := range s.pages {
|
||||
byURL[p.URL] = p
|
||||
}
|
||||
return s, byURL
|
||||
}
|
||||
|
||||
func renderHTML(t *testing.T, s *site, p *Page) renderedPage {
|
||||
t.Helper()
|
||||
r, err := s.renderPage(newMarkdown(), p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestLinkRewriting(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
"- [start](setup/start.md)",
|
||||
"- [steps](setup/start.md#first-steps)",
|
||||
"- [alpha](../modules/alpha/README.md)",
|
||||
"- [usage](../modules/alpha/README.md#usage)",
|
||||
"- [web](https://example.com/x.md)",
|
||||
"- [mail](mailto:a@example.com)",
|
||||
"- [here](#local)",
|
||||
"- [abs](/setup/start.md)",
|
||||
"- [file](../go.mod)",
|
||||
"- [gone](setup/gone.md)",
|
||||
"",
|
||||
}, "\n")
|
||||
s, pages := renderFixture(t, map[string]string{"docs/index.md": page("Acme docs", "index", 0, body)})
|
||||
r := renderHTML(t, s, pages["index"])
|
||||
html := string(r.html)
|
||||
for _, want := range []string{
|
||||
`href="/setup/start.html">start`,
|
||||
`href="/setup/start.html#first-steps">steps`,
|
||||
`href="/api/alpha.html">alpha`,
|
||||
`href="/api/alpha.html#usage">usage`,
|
||||
`href="https://example.com/x.md">web`,
|
||||
`href="mailto:a@example.com">mail`,
|
||||
`href="#local">here`,
|
||||
`href="/setup/start.md">abs`,
|
||||
`href="../go.mod">file`,
|
||||
`href="setup/gone.md">gone`,
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("index HTML missing %s\n%s", want, html)
|
||||
}
|
||||
}
|
||||
wantMD := map[string]string{
|
||||
"setup/start.md": "/setup/start.md",
|
||||
"setup/start.md#first-steps": "/setup/start.md#first-steps",
|
||||
"../modules/alpha/README.md": "/api/alpha.md",
|
||||
"../modules/alpha/README.md#usage": "/api/alpha.md#usage",
|
||||
}
|
||||
if len(r.mdLinks) != len(wantMD) {
|
||||
t.Errorf("mdLinks = %v, want %v", r.mdLinks, wantMD)
|
||||
}
|
||||
for k, v := range wantMD {
|
||||
if r.mdLinks[k] != v {
|
||||
t.Errorf("mdLinks[%s] = %q, want %q", k, r.mdLinks[k], v)
|
||||
}
|
||||
}
|
||||
|
||||
// README to README, README to guide, and a README's source-file link.
|
||||
alpha := string(renderHTML(t, s, pages["api/alpha"]).html)
|
||||
for _, want := range []string{`href="/api/beta.html#api"`, `href="/setup/start.html"`, `href="alpha.go"`} {
|
||||
if !strings.Contains(alpha, want) {
|
||||
t.Errorf("alpha HTML missing %s", want)
|
||||
}
|
||||
}
|
||||
|
||||
// A base URL prefixes every rewritten link.
|
||||
s.base = "https://docs.example/v1"
|
||||
if html := string(renderHTML(t, s, pages["index"]).html); !strings.Contains(html, `href="https://docs.example/v1/setup/start.html"`) {
|
||||
t.Errorf("base URL not applied:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestH1StripAndHeadingAnchors(t *testing.T) {
|
||||
s, pages := renderFixture(t, map[string]string{
|
||||
"docs/setup/start.md": page("Start", "setup", 10, "## Two\n\n### Three\n\n#### Four\n\n## Two\n"),
|
||||
})
|
||||
r := renderHTML(t, s, pages["setup/start"])
|
||||
html := string(r.html)
|
||||
if strings.Contains(html, "<h1") {
|
||||
t.Errorf("the page H1 was rendered:\n%s", html)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`<h2 id="two">Two<a class="heading-anchor" href="#two" aria-label="Link to section: Two">#</a></h2>`,
|
||||
`<h3 id="three">Three<a class="heading-anchor" href="#three" aria-label="Link to section: Three">#</a></h3>`,
|
||||
`<h4 id="four">Four</h4>`,
|
||||
`<h2 id="two-1">Two<a class="heading-anchor" href="#two-1"`,
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("missing %s in\n%s", want, html)
|
||||
}
|
||||
}
|
||||
if got := s.headingIDs(newMarkdown(), pages["setup/start"]); !slices.Equal(got, []string{"two", "three", "four", "two-1"}) {
|
||||
t.Errorf("headingIDs = %v", got)
|
||||
}
|
||||
var ids []string
|
||||
for _, h := range r.headings {
|
||||
ids = append(ids, h.ID)
|
||||
}
|
||||
if !slices.Equal(ids, s.headingIDs(newMarkdown(), pages["setup/start"])) {
|
||||
t.Errorf("rendered heading IDs %v differ from headingIDs", ids)
|
||||
}
|
||||
|
||||
// Only a leading H1 is stripped; a body that starts with prose keeps a
|
||||
// later H1.
|
||||
p := &Page{Source: "docs/setup/x.md", Body: []byte("Intro.\n\n# Later\n")}
|
||||
if html := string(renderHTML(t, s, p).html); !strings.Contains(html, `<h1 id="later">Later</h1>`) {
|
||||
t.Errorf("a non-leading H1 was stripped:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalloutRendering(t *testing.T) {
|
||||
body := "> [!NOTE]\n> A note.\n\n> [!TIP]\n> A tip with `code`.\n\n> [!WARNING]\n> Careful.\n>\n> Second paragraph.\n\n" +
|
||||
"> [!NOTE]\n\n> [!note]\n> Lowercase stays a quote.\n\n> Plain quote.\n\n> [!NOTE] inline text\n"
|
||||
s, pages := renderFixture(t, map[string]string{"docs/setup/start.md": page("Start", "setup", 10, body)})
|
||||
html := string(renderHTML(t, s, pages["setup/start"]).html)
|
||||
for _, want := range []string{
|
||||
`<aside class="callout callout-note" role="note"><p class="callout-title"><svg class="icon icon-info"`,
|
||||
`<span>Note</span></p><div class="callout-body"><p>A note.</p>`,
|
||||
`<aside class="callout callout-tip" role="note">`,
|
||||
`<span>Tip</span></p><div class="callout-body"><p>A tip with <code>code</code>.</p>`,
|
||||
`<aside class="callout callout-warning" role="note">`,
|
||||
`<svg class="icon icon-triangle-alert"`,
|
||||
`<p>Second paragraph.</p>`,
|
||||
`<blockquote>
|
||||
<p>[!note]`,
|
||||
`<blockquote>
|
||||
<p>Plain quote.</p>`,
|
||||
`<p>[!NOTE] inline text</p>`,
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("missing %s in\n%s", want, html)
|
||||
}
|
||||
}
|
||||
if n := strings.Count(html, `class="callout `); n != 4 {
|
||||
t.Errorf("%d callouts, want 4 (the empty NOTE included)", n)
|
||||
}
|
||||
if strings.Contains(html, "[!NOTE]\n") || strings.Contains(html, "[!TIP]") || strings.Contains(html, "[!WARNING]") {
|
||||
t.Errorf("a callout marker line was rendered:\n%s", html)
|
||||
}
|
||||
|
||||
// Dump is the goldmark debugging hook; it must not panic.
|
||||
doc := newMarkdown().Parser().Parse(text.NewReader([]byte("> [!TIP]\n> x\n")))
|
||||
stdout := os.Stdout
|
||||
devnull, err := os.Open(os.DevNull)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer devnull.Close()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
doc.FirstChild().Dump([]byte("> [!TIP]\n> x\n"), 0)
|
||||
os.Stdout = stdout
|
||||
w.Close()
|
||||
dump, _ := io.ReadAll(r)
|
||||
if !strings.Contains(string(dump), "CalloutType: TIP") {
|
||||
t.Errorf("Dump = %q", dump)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainTextAndSectionText(t *testing.T) {
|
||||
src := []byte("## Intro\n\nFirst *line*\nsoft break.\n\n- item one\n- item two\n\n```go\nhidden()\n```\n\n### Sub\n\nSub text with `code`.\n\n## Next\n\nNot included.\n")
|
||||
doc := newMarkdown().Parser().Parse(text.NewReader(src))
|
||||
var intro *ast.Heading
|
||||
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if h, ok := n.(*ast.Heading); ok && entering && intro == nil {
|
||||
intro = h
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
got := sectionText(intro, src, 300)
|
||||
want := "First line soft break. item one item two Sub Sub text with code."
|
||||
if got != want {
|
||||
t.Fatalf("sectionText = %q, want %q", got, want)
|
||||
}
|
||||
if got := sectionText(intro, src, 10); got != "First line" {
|
||||
t.Fatalf("sectionText(10) = %q", got)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
max int
|
||||
want string
|
||||
}{
|
||||
{"short", 10, "short"},
|
||||
{"ąęśćż", 3, "ąęś"},
|
||||
{"cut at space here", 8, "cut at s"},
|
||||
{"cut at space here", 7, "cut at"},
|
||||
{"", 3, ""},
|
||||
} {
|
||||
if got := truncateRunes(tc.in, tc.max); got != tc.want {
|
||||
t.Errorf("truncateRunes(%q, %d) = %q, want %q", tc.in, tc.max, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFences(t *testing.T) {
|
||||
lines := strings.Split(strings.Join([]string{
|
||||
"```go src=a.go", // 0 open
|
||||
"code", // 1
|
||||
"``", // 2 too short to close
|
||||
"```", // 3 close
|
||||
" ~~~yaml `ok` in tilde", // 4 open, indented, backticks allowed
|
||||
" ````", // 5 not a tilde fence close
|
||||
" ~~~~", // 6 close (longer fence)
|
||||
"``` bad `info`", // 7 not a fence (backtick in info)
|
||||
"````text", // 8 open, four backticks
|
||||
"```", // 9 too short
|
||||
"```` trailing", // 10 text after: not a close
|
||||
"````", // 11 close
|
||||
"~~~", // 12 unterminated
|
||||
"rest", // 13
|
||||
}, "\n"), "\n")
|
||||
got := scanFences(lines)
|
||||
want := []fence{
|
||||
{open: 0, close: 3, indent: 0, char: '`', count: 3, info: "go src=a.go"},
|
||||
{open: 4, close: 6, indent: 2, char: '~', count: 3, info: "yaml `ok` in tilde"},
|
||||
{open: 8, close: 11, indent: 0, char: '`', count: 4, info: "text"},
|
||||
{open: 12, close: -1, indent: 0, char: '~', count: 3, info: ""},
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("scanFences =\n%+v\nwant\n%+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteMarkdown(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
"See [start](setup/start.md) and [steps](setup/start.md#first-steps \"title\").",
|
||||
"",
|
||||
" ```go src=modules/a/a.go#A",
|
||||
" [start](setup/start.md) inside a fence stays",
|
||||
" ```",
|
||||
"",
|
||||
"~~~yaml src=config/app.yaml#db",
|
||||
"db: {}",
|
||||
"~~~",
|
||||
"",
|
||||
"```text",
|
||||
"plain",
|
||||
"```",
|
||||
"",
|
||||
"[start](setup/start.md)",
|
||||
}, "\n")
|
||||
links := map[string]string{
|
||||
"setup/start.md": "/setup/start.md",
|
||||
"setup/start.md#first-steps": "/setup/start.md#first-steps",
|
||||
}
|
||||
got := rewriteMarkdown(body, links)
|
||||
want := strings.Join([]string{
|
||||
"See [start](/setup/start.md) and [steps](/setup/start.md#first-steps \"title\").",
|
||||
"",
|
||||
" ```go",
|
||||
" [start](setup/start.md) inside a fence stays",
|
||||
" ```",
|
||||
"",
|
||||
"~~~yaml",
|
||||
"db: {}",
|
||||
"~~~",
|
||||
"",
|
||||
"```text",
|
||||
"plain",
|
||||
"```",
|
||||
"",
|
||||
"[start](/setup/start.md)",
|
||||
}, "\n")
|
||||
if got != want {
|
||||
t.Fatalf("rewriteMarkdown =\n%s\nwant\n%s", got, want)
|
||||
}
|
||||
// An unterminated fence protects the rest of the body.
|
||||
if got := rewriteMarkdown("```sh\n[start](setup/start.md)", links); got != "```sh\n[start](setup/start.md)" {
|
||||
t.Fatalf("unterminated fence = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLinkAndScheme(t *testing.T) {
|
||||
s, pages := renderFixture(t, nil)
|
||||
from := pages["index"]
|
||||
for _, tc := range []struct {
|
||||
dest, url, frag string
|
||||
ok bool
|
||||
}{
|
||||
{"setup/start.md", "setup/start", "", true},
|
||||
{"./setup/start.md#first-steps", "setup/start", "#first-steps", true},
|
||||
{"../modules/beta/README.md#api", "api/beta", "#api", true},
|
||||
{"setup/start.html", "", "", false},
|
||||
{"#frag", "", "", false},
|
||||
{"/setup/start.md", "", "", false},
|
||||
{"https://x/y.md", "", "", false},
|
||||
{"", "", "", false},
|
||||
{"setup/none.md", "", "", false},
|
||||
} {
|
||||
p, frag, ok := s.resolveLink(from, tc.dest)
|
||||
url := ""
|
||||
if p != nil {
|
||||
url = p.URL
|
||||
}
|
||||
if ok != tc.ok || url != tc.url || frag != tc.frag {
|
||||
t.Errorf("resolveLink(%q) = %q, %q, %v", tc.dest, url, frag, ok)
|
||||
}
|
||||
}
|
||||
for dest, want := range map[string]bool{
|
||||
"https://x": true, "mailto:a": true, "ftp:x": true, "a/b:c": false, "?q:x": false, "#a:b": false, ":x": false, "plain": false,
|
||||
} {
|
||||
if got := hasScheme(dest); got != want {
|
||||
t.Errorf("hasScheme(%q) = %v, want %v", dest, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,15 @@ func Serve(ctx context.Context, opts Options, addr string, allowRemote bool, out
|
||||
return errors.New("docs:serve: build failed")
|
||||
}
|
||||
|
||||
// The watches are in place before the serving line is printed, so an
|
||||
// edit made as soon as the server is announced triggers a rebuild.
|
||||
w, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return fmt.Errorf("docs:serve: watcher: %w", err)
|
||||
}
|
||||
defer w.Close()
|
||||
s.addWatches(w)
|
||||
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("docs:serve: %w", err)
|
||||
@@ -61,7 +70,7 @@ func Serve(ctx context.Context, opts Options, addr string, allowRemote bool, out
|
||||
watchErr := make(chan error, 1)
|
||||
watchCtx, stopWatch := context.WithCancel(ctx)
|
||||
defer stopWatch()
|
||||
go func() { watchErr <- s.watch(watchCtx) }()
|
||||
go func() { watchErr <- s.watch(watchCtx, w) }()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -149,15 +158,8 @@ func (s *server) rebuild() bool {
|
||||
}
|
||||
|
||||
// watch rebuilds on changes under Src, modules/ and every src= source
|
||||
// directory, debounced.
|
||||
func (s *server) watch(ctx context.Context) error {
|
||||
w, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return fmt.Errorf("docs:serve: watcher: %w", err)
|
||||
}
|
||||
defer w.Close()
|
||||
s.addWatches(w)
|
||||
|
||||
// directory, debounced. The caller owns w and has added the first watches.
|
||||
func (s *server) watch(ctx context.Context, w *fsnotify.Watcher) error {
|
||||
var timer *time.Timer
|
||||
fire := make(chan struct{}, 1)
|
||||
for {
|
||||
|
||||
265
internal/docsite/serve_test.go
Normal file
265
internal/docsite/serve_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package docsite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestServeAddrPolicy(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
addr string
|
||||
allowRemote bool
|
||||
want string // "" accepts; otherwise an error substring
|
||||
}{
|
||||
{"127.0.0.1:8088", false, ""},
|
||||
{"127.0.0.2:0", false, ""},
|
||||
{"[::1]:8088", false, ""},
|
||||
{"localhost:8088", false, ""},
|
||||
{"0.0.0.0:8088", false, "not a loopback address"},
|
||||
{"[::]:8088", false, "not a loopback address"},
|
||||
{"192.168.1.10:8088", false, "not a loopback address"},
|
||||
{"docs.example.com:8088", false, "not a loopback address"},
|
||||
{":8088", false, "not a loopback address"},
|
||||
{"0.0.0.0:8088", true, ""},
|
||||
{"[::]:8088", true, ""},
|
||||
{"docs.example.com:8088", true, ""},
|
||||
{"127.0.0.1", false, "invalid --addr"},
|
||||
{"127.0.0.1", true, "invalid --addr"},
|
||||
} {
|
||||
err := checkServeAddr(tc.addr, tc.allowRemote)
|
||||
if (tc.want == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), tc.want)) {
|
||||
t.Errorf("checkServeAddr(%q, %v) = %v, want %q", tc.addr, tc.allowRemote, err, tc.want)
|
||||
}
|
||||
}
|
||||
if DefaultServeAddr != "127.0.0.1:8088" || checkServeAddr(DefaultServeAddr, false) != nil {
|
||||
t.Errorf("DefaultServeAddr %s must be loopback", DefaultServeAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHandlerBranches(t *testing.T) {
|
||||
out := buildTheme(t)
|
||||
if err := os.MkdirAll(filepath.Join(out, "nested", "dir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(out, "nested", "index.html"), []byte("nested index"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(Handler(out))
|
||||
defer srv.Close()
|
||||
do := func(method, p string) (*http.Response, string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, srv.URL+p, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
return res, string(body)
|
||||
}
|
||||
if res, body := do(http.MethodGet, "/nested/"); res.StatusCode != http.StatusOK || body != "nested index" {
|
||||
t.Errorf("GET /nested/ = %d %q", res.StatusCode, body)
|
||||
}
|
||||
if res, body := do(http.MethodHead, "/index.html"); res.StatusCode != http.StatusOK || body != "" {
|
||||
t.Errorf("HEAD /index.html = %d %q", res.StatusCode, body)
|
||||
}
|
||||
for _, p := range []string{"/nested/dir/", "/.hidden/x", "/assets/.x.css", "/nope"} {
|
||||
res, body := do(http.MethodGet, p)
|
||||
if res.StatusCode != http.StatusNotFound || !strings.Contains(body, "Page not found") ||
|
||||
!strings.HasPrefix(res.Header.Get("Content-Type"), "text/html") {
|
||||
t.Errorf("GET %s = %d %q", p, res.StatusCode, res.Header.Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
|
||||
res, _ := do(m, "/index.html")
|
||||
if res.StatusCode != http.StatusMethodNotAllowed || res.Header.Get("Allow") != "GET, HEAD" {
|
||||
t.Errorf("%s = %d Allow=%q", m, res.StatusCode, res.Header.Get("Allow"))
|
||||
}
|
||||
}
|
||||
|
||||
// Without a 404.html the handler still answers 404.
|
||||
bare := httptest.NewServer(Handler(t.TempDir()))
|
||||
defer bare.Close()
|
||||
res, err := http.Get(bare.URL + "/missing")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if res.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("bare 404 = %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeRebuildKeepsLastGoodBuild(t *testing.T) {
|
||||
root := themeTree(t)
|
||||
out := &syncBuffer{}
|
||||
s := &server{opts: Options{Root: root, Commands: fixtureCommands}, tmp: t.TempDir(), out: out}
|
||||
s.opts, _ = s.opts.normalize()
|
||||
if !s.rebuild() {
|
||||
t.Fatalf("first build failed: %s", out.String())
|
||||
}
|
||||
first := s.dir()
|
||||
if filepath.Base(first) != "build-1" {
|
||||
t.Fatalf("first build dir = %s", first)
|
||||
}
|
||||
|
||||
// A problem keeps the previous build and says so.
|
||||
index := filepath.Join(root, "docs", "index.md")
|
||||
good, err := os.ReadFile(index)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(index, append(append([]byte{}, good...), "\nSee `summer no:such`.\n"...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.rebuild() {
|
||||
t.Fatal("rebuild accepted a planted problem")
|
||||
}
|
||||
if s.dir() != first {
|
||||
t.Fatalf("serving %s after a failed build, want %s", s.dir(), first)
|
||||
}
|
||||
for _, want := range []string{`command: "no:such" is not a summer or application command`, "docs:serve: build failed, still serving the previous version"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.tmp, "build-2")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Error("the failed build directory was kept")
|
||||
}
|
||||
|
||||
// A load error is printed too.
|
||||
site := filepath.Join(root, "docs", "site.yaml")
|
||||
cfg, err := os.ReadFile(site)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(site); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.rebuild() || !strings.Contains(out.String(), "read site config") {
|
||||
t.Fatalf("rebuild without site.yaml: %s", out.String())
|
||||
}
|
||||
if err := os.WriteFile(site, cfg, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Two good builds later the first directory is gone; the one before
|
||||
// the current build is kept for in-flight requests.
|
||||
if err := os.WriteFile(index, good, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.rebuild() || !s.rebuild() {
|
||||
t.Fatalf("rebuilds failed: %s", out.String())
|
||||
}
|
||||
if _, err := os.Stat(first); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("%s survived two newer builds", first)
|
||||
}
|
||||
if _, err := os.Stat(s.stale); err != nil {
|
||||
t.Errorf("the previous build %s was removed early", s.stale)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.dir(), "index.html")); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeStartupFailures(t *testing.T) {
|
||||
// A tree with a problem never starts serving.
|
||||
root := themeTree(t)
|
||||
appendFile(t, root, "docs/index.md", "\n`summer no:such`\n")
|
||||
var out syncBuffer
|
||||
err := Serve(context.Background(), Options{Root: root, Commands: fixtureCommands}, "127.0.0.1:0", false, &out)
|
||||
if err == nil || err.Error() != "docs:serve: build failed" {
|
||||
t.Fatalf("Serve with a problem = %v", err)
|
||||
}
|
||||
if strings.Contains(out.String(), "still serving") || !strings.Contains(out.String(), "no:such") {
|
||||
t.Errorf("output = %s", out.String())
|
||||
}
|
||||
|
||||
// A taken address is reported.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
err = Serve(context.Background(), Options{Root: themeTree(t), Commands: fixtureCommands}, ln.Addr().String(), false, &out)
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "docs:serve: ") {
|
||||
t.Fatalf("Serve on a taken address = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeWatchRebuilds edits a page while Serve runs and waits for the
|
||||
// new build, then plants a problem and checks the old build stays up.
|
||||
func TestServeWatchRebuilds(t *testing.T) {
|
||||
root := themeTree(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
out := &syncBuffer{}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- Serve(ctx, Options{Root: root, Commands: fixtureCommands}, "127.0.0.1:0", false, out) }()
|
||||
waitFor := func(what string, cond func() bool) {
|
||||
t.Helper()
|
||||
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s; output:\n%s", what, out.String())
|
||||
}
|
||||
var base string
|
||||
waitFor("the serving line", func() bool {
|
||||
if m := servingLine.FindStringSubmatch(out.String()); m != nil {
|
||||
base = m[1]
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
get := func(p string) string {
|
||||
t.Helper()
|
||||
res, err := http.Get(base + p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
b, _ := io.ReadAll(res.Body)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// A dot-file change is ignored; a page edit rebuilds.
|
||||
writeFile(t, root, "docs/.scratch", "x")
|
||||
appendFile(t, root, "docs/setup/second.md", "\nFreshly edited.\n")
|
||||
waitFor("a rebuild", func() bool { return strings.Contains(out.String(), "docs:serve: rebuilt") })
|
||||
waitFor("the edited page", func() bool { return strings.Contains(get("/setup/second.html"), "Freshly edited.") })
|
||||
|
||||
// A new page rebuilds and is served.
|
||||
writeFile(t, root, "docs/setup/third.md", page("Third", "setup", 90, "Brand new.\n"))
|
||||
waitFor("the new page", func() bool { return strings.Contains(get("/setup/third.html"), "Brand new.") })
|
||||
|
||||
// A planted problem keeps the last good build online.
|
||||
appendFile(t, root, "docs/setup/second.md", "\n`summer no:such`\n")
|
||||
waitFor("the failed rebuild", func() bool { return strings.Contains(out.String(), "still serving the previous version") })
|
||||
if !strings.Contains(get("/setup/second.html"), "Freshly edited.") {
|
||||
t.Error("the last good build is no longer served")
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Serve = %v after cancel", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Serve did not return after cancel")
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,9 @@ func funcKey(d *ast.FuncDecl) string {
|
||||
case *ast.IndexListExpr:
|
||||
t = x.X
|
||||
continue
|
||||
case *ast.ParenExpr:
|
||||
t = x.X
|
||||
continue
|
||||
case *ast.Ident:
|
||||
return x.Name + "." + d.Name.Name
|
||||
}
|
||||
|
||||
322
internal/docsite/snippet_test.go
Normal file
322
internal/docsite/snippet_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package docsite
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const genericGo = `package gen
|
||||
|
||||
// Map applies f to every element.
|
||||
func Map[T, U any](in []T, f func(T) U) []U {
|
||||
out := make([]U, 0, len(in))
|
||||
for _, v := range in {
|
||||
out = append(out, f(v))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Box holds one value.
|
||||
type Box[T any] struct{ v T }
|
||||
|
||||
// Get returns the value.
|
||||
func (b *Box[T]) Get() T { return b.v }
|
||||
|
||||
// Pair holds a key and a value.
|
||||
type Pair[K comparable, V any] struct {
|
||||
K K
|
||||
V V
|
||||
}
|
||||
|
||||
// Key returns the key.
|
||||
func (p Pair[K, V]) Key() K { return p.K }
|
||||
|
||||
// Paren has a parenthesised receiver.
|
||||
func (b (*Box[T])) Paren() {}
|
||||
|
||||
var (
|
||||
// Limit is documented in the group.
|
||||
Limit = 10
|
||||
Other = 2
|
||||
)
|
||||
|
||||
// Single is an ungrouped var.
|
||||
var Single = 1
|
||||
|
||||
type (
|
||||
// ID is a grouped type.
|
||||
ID int
|
||||
)
|
||||
`
|
||||
|
||||
const genericTestGo = `package gen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMap(t *testing.T) {
|
||||
// docs:start map
|
||||
got := Map([]int{1, 2}, func(v int) string {
|
||||
return fmt.Sprint(v)
|
||||
})
|
||||
// docs:end map
|
||||
_ = viaVar
|
||||
if len(got) != 2 || (fixture{name: "x"}).name == "" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
// docs:start map
|
||||
duplicate := 1
|
||||
// docs:end map
|
||||
_ = duplicate
|
||||
}
|
||||
|
||||
// fixture is referenced by a test, so it counts as run.
|
||||
type fixture struct{ name string }
|
||||
|
||||
// indirect is only reached through a package-level var: the run check is
|
||||
// conservative and does not follow var initialisers.
|
||||
type indirect struct{}
|
||||
|
||||
var viaVar = indirect{}
|
||||
|
||||
func ExampleBox_Get() {
|
||||
b := &Box[string]{v: "boxed"}
|
||||
if b != nil {
|
||||
fmt.Println(b.Get())
|
||||
}
|
||||
// Output: boxed
|
||||
}
|
||||
|
||||
func ExamplePair_Key() {
|
||||
fmt.Println(Pair[string, int]{K: "k"}.Key())
|
||||
// Output:
|
||||
// k
|
||||
}
|
||||
|
||||
func ExampleSingle() {
|
||||
_ = Single
|
||||
// Output:
|
||||
}
|
||||
`
|
||||
|
||||
func genericTree(t *testing.T) string {
|
||||
t.Helper()
|
||||
return writeTree(t, map[string]string{
|
||||
"go.mod": "module example.com/gen\n\ngo 1.27\n",
|
||||
"gen/gen.go": genericGo,
|
||||
"gen/gen_test.go": genericTestGo,
|
||||
"config/app.yaml": "a:\n # docs:start keys\n b: 1\n\n c: 2\n # docs:end keys\n",
|
||||
"config/tabs.go.txt": "x",
|
||||
"docs/site.yaml": snippetSite,
|
||||
"docs/index.md": page("Acme docs", "index", 0, "Hello.\n"),
|
||||
"docs/setup/start.md": page("Start", "setup", 10, "Text.\n"),
|
||||
})
|
||||
}
|
||||
|
||||
func TestSnippetGenericsAndGroups(t *testing.T) {
|
||||
root := genericTree(t)
|
||||
for _, tc := range []struct {
|
||||
ref Ref
|
||||
want string
|
||||
}{
|
||||
{Ref{"gen/gen.go", "Map"}, "// Map applies f to every element.\nfunc Map[T, U any](in []T, f func(T) U) []U {\n\tout := make([]U, 0, len(in))\n\tfor _, v := range in {\n\t\tout = append(out, f(v))\n\t}\n\treturn out\n}"},
|
||||
{Ref{"gen/gen.go", "Box"}, "// Box holds one value.\ntype Box[T any] struct{ v T }"},
|
||||
{Ref{"gen/gen.go", "Box.Get"}, "// Get returns the value.\nfunc (b *Box[T]) Get() T { return b.v }"},
|
||||
{Ref{"gen/gen.go", "Pair.Key"}, "// Key returns the key.\nfunc (p Pair[K, V]) Key() K { return p.K }"},
|
||||
{Ref{"gen/gen.go", "Box.Paren"}, "// Paren has a parenthesised receiver.\nfunc (b (*Box[T])) Paren() {}"},
|
||||
{Ref{"gen/gen.go", "Limit"}, "// Limit is documented in the group.\n\tLimit = 10"},
|
||||
{Ref{"gen/gen.go", "Other"}, "Other = 2"},
|
||||
{Ref{"gen/gen.go", "Single"}, "// Single is an ungrouped var.\nvar Single = 1"},
|
||||
{Ref{"gen/gen.go", "ID"}, "// ID is a grouped type.\n\tID int"},
|
||||
{Ref{"gen/gen_test.go", "ExampleBox_Get"}, "b := &Box[string]{v: \"boxed\"}\nif b != nil {\n\tfmt.Println(b.Get())\n}\n// Output: boxed"},
|
||||
{Ref{"gen/gen_test.go", "ExamplePair_Key"}, "fmt.Println(Pair[string, int]{K: \"k\"}.Key())\n// Output:\n// k"},
|
||||
{Ref{"gen/gen_test.go", "ExampleSingle"}, "_ = Single\n// Output:"},
|
||||
{Ref{"gen/gen_test.go", "fixture"}, "// fixture is referenced by a test, so it counts as run.\ntype fixture struct{ name string }"},
|
||||
{Ref{"gen/gen_test.go", "viaVar"}, "var viaVar = indirect{}"},
|
||||
// The first of two same-named regions wins.
|
||||
{Ref{"gen/gen_test.go", "map"}, "got := Map([]int{1, 2}, func(v int) string {\n\treturn fmt.Sprint(v)\n})"},
|
||||
// A YAML region keeps its inner blank line and is dedented.
|
||||
{Ref{"config/app.yaml", "keys"}, "b: 1\n\nc: 2"},
|
||||
} {
|
||||
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) =\n%q\nwant\n%q", tc.ref, got, tc.want)
|
||||
}
|
||||
}
|
||||
if _, err := Extract(root, Ref{"gen/gen_test.go", "indirect"}); err == nil || err.Error() != notRunMessage {
|
||||
t.Errorf("Extract(indirect) = %v, want the not-run refusal", err)
|
||||
}
|
||||
// A fragment on a non-Go file that is not a region is not found.
|
||||
if _, err := Extract(root, Ref{"config/app.yaml", "Map"}); err != errSnippetNotFound {
|
||||
t.Errorf("YAML ident err = %v", err)
|
||||
}
|
||||
// A missing root is an error, not a problem.
|
||||
if _, err := Extract(filepath.Join(root, "missing"), Ref{Path: "gen/gen.go"}); err == nil || err == errSnippetNotFound {
|
||||
t.Errorf("Extract with a missing root = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedent(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in, want []string
|
||||
}{
|
||||
{[]string{"", "\t\ta", "\t\t\tb", "", "\t\tc", " "}, []string{"a", "\tb", "", "c"}},
|
||||
{[]string{" x", " y"}, []string{" x", "y"}},
|
||||
{[]string{"\t x", "\t\ty"}, []string{" x", "\ty"}},
|
||||
{[]string{" ", ""}, []string{}},
|
||||
{nil, []string{}},
|
||||
} {
|
||||
if got := dedent(tc.in); !slices.Equal(got, tc.want) {
|
||||
t.Errorf("dedent(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetRefPathRules(t *testing.T) {
|
||||
for p, want := range map[string]string{
|
||||
"": "src= has no path",
|
||||
"/abs.go": "must be relative",
|
||||
`\abs.go`: "must be relative",
|
||||
"C:/x.go": "must be relative",
|
||||
"c:x.go": "must be relative",
|
||||
`a\b.go`: "must use forward slashes",
|
||||
"a/../b.go": "must not leave the repository root",
|
||||
"a/.git/x": "dotfile or .env file",
|
||||
"a/prod.env": "dotfile or .env file",
|
||||
"./a.go": "dotfile or .env file",
|
||||
"a//b.go": "must be clean",
|
||||
"a/b/": "must be clean",
|
||||
"a/b.go": "",
|
||||
"docs/x.md": "",
|
||||
"environment": "",
|
||||
"a.environ.go": "",
|
||||
} {
|
||||
err := checkRefPath(p)
|
||||
if (want == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), want)) {
|
||||
t.Errorf("checkRefPath(%q) = %v, want %q", p, err, want)
|
||||
}
|
||||
}
|
||||
for info, want := range map[string]Ref{
|
||||
"go src=a.go#X": {"a.go", "X"},
|
||||
"yaml src=b.yaml": {"b.yaml", ""},
|
||||
"go title=x src=c.go": {"c.go", ""},
|
||||
"go src=d.go#A#B": {"d.go", "A#B"},
|
||||
} {
|
||||
if got, ok := ParseSrc(info); !ok || got != want {
|
||||
t.Errorf("ParseSrc(%q) = %+v, %v", info, got, ok)
|
||||
}
|
||||
}
|
||||
if (Ref{Path: "a.go"}).String() != "a.go" || (Ref{"a.go", "X"}).String() != "a.go#X" {
|
||||
t.Error("Ref.String")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetSymlinkInsideRoot(t *testing.T) {
|
||||
root := genericTree(t)
|
||||
if err := os.Symlink(filepath.Join(root, "config", "app.yaml"), filepath.Join(root, "config", "alias.yaml")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Extract(root, Ref{"config/alias.yaml", "keys"})
|
||||
if err != nil || got != "b: 1\n\nc: 2" {
|
||||
t.Fatalf("Extract through an in-root symlink = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetTrailingNewlines(t *testing.T) {
|
||||
// A fence body with or without trailing blank lines matches a source
|
||||
// ending in newlines; any other difference is drift.
|
||||
body := "```yaml src=config/app.yaml#keys\nb: 1\n\nc: 2\n\n\n```\n\n" +
|
||||
"```text src=config/whole.txt\nline\n```\n\n" +
|
||||
" ```text src=config/whole.txt\n line\n ```\n"
|
||||
root := snippetTree(t, map[string]string{
|
||||
"config/app.yaml": "a:\n # docs:start keys\n b: 1\n\n c: 2\n # docs:end keys\n",
|
||||
"config/whole.txt": "line\n\n\n",
|
||||
"docs/setup/start.md": page("Start", "setup", 10, body),
|
||||
})
|
||||
if problems, err := Check(Options{Root: root, Commands: fixtureCommands}); err != nil || len(problems) > 0 {
|
||||
t.Fatalf("Check: %v %q", err, problemLines(problems))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPreservesAndReports(t *testing.T) {
|
||||
doc := page("Start", "setup", 10, "Before.\n\n```go src=pkg/lib.go#Greeting\nold one\n```\n\nMiddle `x`.\n\n"+
|
||||
"```go src=pkg/lib_test.go#ExampleGreeting\nold two\n```\n\n```go src=pkg/lib.go#Greeting\n// Greeting returns a greeting.\n"+
|
||||
"func Greeting(name string) string {\n\treturn \"Hello, \" + name\n}\n```\n\nAfter.")
|
||||
root := snippetTree(t, map[string]string{"docs/setup/start.md": doc})
|
||||
path := filepath.Join(root, "docs/setup/start.md")
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, problems, err := Sync(Options{Root: root})
|
||||
if err != nil || len(problems) > 0 || res != (SyncResult{Snippets: 2, Files: 1}) {
|
||||
t.Fatalf("Sync = %+v %q %v", res, problemLines(problems), err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := strings.Replace(strings.Replace(doc, "old one", "// Greeting returns a greeting.\nfunc Greeting(name string) string {\n\treturn \"Hello, \" + name\n}", 1),
|
||||
"old two", "fmt.Println(Greeting(\"blog\"))\n// Output: Hello, blog", 1)
|
||||
if string(got) != want {
|
||||
t.Fatalf("synced =\n%s\nwant\n%s", got, want)
|
||||
}
|
||||
if st, err := os.Stat(path); err != nil || st.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("mode after Sync = %v, %v; want 0600", st.Mode().Perm(), err)
|
||||
}
|
||||
if res, _, err := Sync(Options{Root: root}); err != nil || res != (SyncResult{}) {
|
||||
t.Fatalf("second Sync = %+v, %v", res, err)
|
||||
}
|
||||
|
||||
// One broken reference anywhere means Sync writes nothing at all.
|
||||
other := page("Other", "setup", 20, "```go src=pkg/lib.go#Greeting\nstale\n```\n")
|
||||
broken := page("Broken", "setup", 30, "```go src=pkg/lib.go#Missing\n```\n")
|
||||
root = snippetTree(t, map[string]string{"docs/setup/other.md": other, "docs/setup/broken.md": broken})
|
||||
res, problems, err = Sync(Options{Root: root})
|
||||
if err != nil || res != (SyncResult{}) {
|
||||
t.Fatalf("Sync with a broken ref = %+v, %v", res, err)
|
||||
}
|
||||
assertProblems(t, problemLines(problems), []string{"docs/setup/broken.md:9: snippet: pkg/lib.go#Missing not found"})
|
||||
if got, _ := os.ReadFile(filepath.Join(root, "docs/setup/other.md")); string(got) != other {
|
||||
t.Fatal("Sync wrote a file although another reference was broken")
|
||||
}
|
||||
if _, _, err := Sync(Options{Root: root, Src: filepath.Join(root, "missing")}); err == nil {
|
||||
t.Fatal("Sync on a missing source directory returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetTestGraph(t *testing.T) {
|
||||
root := writeTree(t, map[string]string{
|
||||
"p/p.go": "package p\n",
|
||||
"p/a_test.go": "package p\n\nimport \"testing\"\n\nfunc TestA(t *testing.T) { helperOne() }\n\nfunc helperOne() { helperTwo() }\n",
|
||||
"p/b_test.go": "package p\n\nfunc helperTwo() {\n\t// docs:start deep\n\t_ = 1\n\t// docs:end deep\n}\n\nfunc (s *suite) run() {}\n\ntype suite struct{}\n\nfunc lonely() {}\n",
|
||||
})
|
||||
for _, tc := range []struct {
|
||||
ref Ref
|
||||
want string
|
||||
}{
|
||||
{Ref{"p/b_test.go", "deep"}, ""},
|
||||
{Ref{"p/b_test.go", "helperTwo"}, ""},
|
||||
{Ref{"p/b_test.go", "lonely"}, notRunMessage},
|
||||
{Ref{"p/b_test.go", "suite.run"}, notRunMessage},
|
||||
{Ref{"p/b_test.go", "suite"}, notRunMessage},
|
||||
} {
|
||||
_, err := Extract(root, tc.ref)
|
||||
if (tc.want == "") != (err == nil) || (err != nil && err.Error() != tc.want) {
|
||||
t.Errorf("Extract(%s) = %v, want %q", tc.ref, err, tc.want)
|
||||
}
|
||||
}
|
||||
// A test file that does not parse is reported, not skipped.
|
||||
writeFile(t, root, "p/c_test.go", "package p\n\nfunc {\n")
|
||||
if _, err := Extract(root, Ref{"p/b_test.go", "deep"}); err == nil || !strings.Contains(err.Error(), "cannot parse c_test.go") {
|
||||
t.Errorf("Extract with an unparsable sibling test = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -293,3 +293,34 @@ func TestServeRefusesNonLoopback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestThemeAssetsAndPager checks the full asset set every build ships and
|
||||
// the pager edge that points back at the index page.
|
||||
func TestThemeAssetsAndPager(t *testing.T) {
|
||||
out := buildTheme(t)
|
||||
for _, name := range []string{
|
||||
"assets/site.css", "assets/site.js", "assets/search.js", "assets/theme-init.js",
|
||||
"assets/LICENSE-lucide.txt", "assets/fonts/LICENSE-dm-sans.txt", "assets/fonts/LICENSE-dm-mono.txt",
|
||||
"assets/fonts/dm-sans-latin-400-normal.woff2", "assets/fonts/dm-sans-latin-600-normal.woff2",
|
||||
"assets/fonts/dm-sans-latin-ext-400-normal.woff2", "assets/fonts/dm-sans-latin-ext-600-normal.woff2",
|
||||
"assets/fonts/dm-sans-latin-400-italic.woff2", "assets/fonts/dm-sans-latin-ext-400-italic.woff2",
|
||||
"assets/fonts/dm-mono-latin-400-normal.woff2", "assets/fonts/dm-mono-latin-ext-400-normal.woff2",
|
||||
MarkerFile,
|
||||
} {
|
||||
if st, err := os.Stat(filepath.Join(out, name)); err != nil || st.Size() == 0 {
|
||||
t.Errorf("missing or empty %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
// setup/start's Previous is the index page: no section line.
|
||||
start := readOut(t, out, "setup/start.html")
|
||||
prev := start[strings.Index(start, "pager-prev"):]
|
||||
prev = prev[:strings.Index(prev, "</a>")]
|
||||
if strings.Contains(prev, "pager-section") || !strings.Contains(prev, "Acme docs") {
|
||||
t.Errorf("pager back to the index = %q", prev)
|
||||
}
|
||||
for _, name := range []string{"index.html", "setup/start.html"} {
|
||||
if html := readOut(t, out, name); !strings.Contains(html, `<title>`) || strings.Count(html, "<h1") != 1 {
|
||||
t.Errorf("%s must have a title and exactly one H1", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user