Files
summercms/internal/docsite/render_test.go
Jakub Zych 28afd4d197 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)
2026-10-01 00:05:12 +02:00

374 lines
12 KiB
Go

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