- 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)
374 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|