- site.yaml keys site_url and site_label, validated: http(s) URL with a host or a path starting with a single /; a label needs a URL - docs:build and docs:serve flags --site-url and --site-label override them the way --base-url overrides base_url - every page header, the 404 page included, links back with the explicit label, else the URL host, else Home; unset output is unchanged - docs/console/utilities.md documents the keys and flags
322 lines
13 KiB
Go
322 lines
13 KiB
Go
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`},
|
|
{"site_url javascript", valid + "site_url: javascript:alert(1)\n", "site_url must be an http(s) URL with a host or a path starting with a single /"},
|
|
{"site_url protocol-relative", valid + "site_url: //acme.example\n", "site_url must be an http(s) URL with a host or a path starting with a single /"},
|
|
{"site_label without site_url", valid + "site_label: Acme\n", "site_label needs site_url"},
|
|
} {
|
|
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)
|
|
}
|
|
}
|
|
for _, u := range []string{"https://acme.example/", "/"} {
|
|
got, err := ParseSite([]byte(valid + "site_url: " + u + "\n"))
|
|
if err != nil || got.SiteURL != u {
|
|
t.Errorf("site_url %q: ParseSite = %+v, %v", u, got, err)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|