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) } }