- eighteen violation plants pin nested src=, Go aliases, unrun examples, build membership and command forms - unit tests cover fence collection, captions, goLang, go doc -c, commandWord and Sync
404 lines
13 KiB
Go
404 lines
13 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 TestFenceCaptionsOnlyVerified(t *testing.T) {
|
|
guide := page("Start", "setup", 10, strings.Join([]string{
|
|
"```go src=pkg/lib.go#Greeting",
|
|
"func Greeting() {}",
|
|
"```",
|
|
"",
|
|
"> [!TIP]",
|
|
"> ```go src=pkg/lib.go#Greeting",
|
|
"> func stale() {}",
|
|
"> ```",
|
|
"",
|
|
}, "\n"))
|
|
readme := "# alpha\n\nAlpha does one thing.\n\n```go src=modules/alpha/alpha.go\npackage alpha\n```\n"
|
|
s, pages := renderFixture(t, map[string]string{
|
|
"docs/setup/start.md": guide,
|
|
"modules/alpha/README.md": readme,
|
|
})
|
|
guideHTML := string(renderHTML(t, s, pages["setup/start"]).html)
|
|
if got := strings.Count(guideHTML, "<figcaption>"); got != 1 {
|
|
t.Fatalf("guide figcaptions = %d, want 1\n%s", got, guideHTML)
|
|
}
|
|
if !strings.Contains(guideHTML, "<figcaption>pkg/lib.go#Greeting</figcaption>") {
|
|
t.Fatalf("guide caption missing the top-level reference\n%s", guideHTML)
|
|
}
|
|
readmeHTML := string(renderHTML(t, s, pages["api/alpha"]).html)
|
|
if strings.Contains(readmeHTML, "<figcaption>") {
|
|
t.Fatalf("module README has a caption\n%s", readmeHTML)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|