package main import ( "bytes" "encoding/json" "encoding/xml" "errors" "html/template" "io" "os" "path/filepath" "strings" "testing" "time" "golang.org/x/net/html" ) func writeTestFile(t *testing.T, root, name, contents string) { t.Helper() full := filepath.Join(root, filepath.FromSlash(name)) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(full, []byte(contents), 0o644); err != nil { t.Fatal(err) } } func build(root string) error { return buildWithFallback(root, "https://example.com", "page", false) } func testLayouts() string { return `{{define "page"}}{{.Title}}{{joinSections .Sections}}{{end}} {{define "weblog"}}{{.Title}}{{joinSections .Sections}}{{end}} {{define "notes"}}{{template "page" .}}{{end}}` } func TestMarkdownGFMFrontmatterTitleSectionsAndLinks(t *testing.T) { root := t.TempDir() writeTestFile(t, root, "layouts.tmpl", testLayouts()) writeTestFile(t, root, "other.md", "# Other\n\nTarget.\n") writeTestFile(t, root, "page.md", `--- summary: hello --- > Read this first. # My *Page* First paragraph with [local](other.md#part), [web](https://example.com/a.md), and [fragment](#part). | A | B | | - | - | | 1 | 2 | `) if err := build(root); err != nil { t.Fatal(err) } raw, err := os.ReadFile(filepath.Join(root, "page.html")) if err != nil { t.Fatal(err) } got := string(raw) for _, want := range []string{"My Page", "
", ""} { if !strings.Contains(got, want) { t.Errorf("output missing %q:\n%s", want, got) } } if strings.Index(got, "
") > strings.Index(got, "") { t.Errorf("raw HTML rendered:\n%s", got) } s := &site{root: root, pages: map[string][]*Page{}} if err := s.discover(); err != nil { t.Fatal(err) } var page *Page for _, src := range s.sources { if src.path == "page.md" { page = src.page } } if page == nil || page.Meta["summary"] != "hello" || len(page.Sections) != 0 || page.markdownAST == nil { t.Fatalf("unexpected discovery model: %#v", page) } if err := s.loadTemplates(); err != nil { t.Fatal(err) } if err := s.prepareMarkdown(); err != nil { t.Fatal(err) } if len(page.Sections) != 5 { t.Fatalf("prepared sections = %d, want 5", len(page.Sections)) } } func TestFilenameFallbackWarns(t *testing.T) { root := t.TempDir() writeTestFile(t, root, "no-title.md", "Just text.\n") s := &site{root: root, pages: map[string][]*Page{}} if err := s.discover(); err != nil { t.Fatal(err) } if len(s.warnings) != 1 || s.sources[0].page.Title != "No Title" { t.Fatalf("warning/title = %#v, %q", s.warnings, s.sources[0].page.Title) } } func TestRecursiveInventoryAndUnderscoreExclusion(t *testing.T) { root := t.TempDir() writeTestFile(t, root, "notes/recipes/cocktails/old.md", "# Old\n") writeTestFile(t, root, "notes/_private/secret.md", "# Secret\n") writeTestFile(t, root, "notes/_draft.md", "# Draft\n") writeTestFile(t, root, "notes/.private/secret.md", "# Secret\n") writeTestFile(t, root, "notes/.draft.md", "# Draft\n") s := &site{root: root, pages: map[string][]*Page{}} if err := s.discover(); err != nil { t.Fatal(err) } for _, key := range []string{"", "notes", "notes/recipes", "notes/recipes/cocktails"} { if len(s.pages[key]) != 1 || s.pages[key][0].Title != "Old" { t.Errorf("Pages[%q] = %#v", key, s.pages[key]) } } if len(s.sources) != 1 { t.Fatalf("private sources were discovered: %#v", s.sources) } } func TestDiscoveryFollowsSymlinksAndRejectsCycles(t *testing.T) { root := t.TempDir() vault := t.TempDir() writeTestFile(t, root, "layouts.tmpl", testLayouts()) writeTestFile(t, vault, "recipe.md", "# Recipe\n") writeTestFile(t, vault, "_private/secret.md", "# Secret\n") if err := os.Symlink(vault, filepath.Join(root, "notes")); err != nil { t.Skipf("symlinks unavailable: %v", err) } s := &site{root: root, pages: map[string][]*Page{}} if err := s.discover(); err != nil { t.Fatal(err) } found := false for _, source := range s.sources { if source.path == "notes/recipe.md" { found = true } if strings.Contains(source.path, "_private") { t.Fatalf("private symlink source discovered: %s", source.path) } } if !found { t.Fatalf("symlink sources = %#v", s.sources) } if err := build(root); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(vault, "recipe.html")); err != nil { t.Fatalf("symlinked page was not generated: %v", err) } if err := os.Symlink(root, filepath.Join(root, "loop")); err != nil { t.Fatal(err) } s = &site{root: root, pages: map[string][]*Page{}} if err := s.discover(); err == nil || !strings.Contains(err.Error(), "symlink cycle") { t.Fatalf("cycle error = %v", err) } } func TestOutputCollision(t *testing.T) { root := t.TempDir() writeTestFile(t, root, "index.md", "# Index\n") writeTestFile(t, root, "index.html.tmpl", "hello") s := &site{root: root, pages: map[string][]*Page{}} err := s.discover() if err == nil || !strings.Contains(err.Error(), "output collision") { t.Fatalf("got %v", err) } } func TestRelativePath(t *testing.T) { cases := map[string]string{ relativePath("index.html", "style.css"): "style.css", relativePath("index.html", "/"): "./", relativePath("about/index.html", "/"): "../", relativePath("index.html", "/notes/"): "notes/", relativePath("about/index.html", "/notes/"): "../notes/", relativePath("notes/deep/page.html", "style.css"): "../../style.css", relativePath("notes/page.html", "/feed.xml"): "../feed.xml", relativePath("notes/page.html", "https://example.com"): "https://example.com", } for got, want := range cases { if got != want { t.Errorf("got %q, want %q", got, want) } } } func TestMarkdownTemplateFallsThroughPathThenFallback(t *testing.T) { root := t.TempDir() writeTestFile(t, root, "layouts.tmpl", `{{define "default"}}default:{{.Title}}{{end}} {{define "notes"}}notes:{{.Title}}{{end}} {{define "notes/recipes"}}recipes:{{.Title}}{{end}} {{define "notes/recipes/cocktails"}}cocktails:{{.Title}}{{end}} {{define "notes/recipes/cocktails/old_fashioned"}}exact:{{.Title}}{{end}}`) writeTestFile(t, root, "about.md", "# About\n") writeTestFile(t, root, "notes/todo.md", "# Todo\n") writeTestFile(t, root, "notes/recipes/bread.md", "# Bread\n") writeTestFile(t, root, "notes/recipes/cocktails/stirred.md", "# Stirred\n") writeTestFile(t, root, "notes/recipes/cocktails/old_fashioned.md", "# Old Fashioned\n") if err := buildWithFallback(root, "https://example.com", "default", false); err != nil { t.Fatal(err) } for name, want := range map[string]string{ "about.html": "default:About", "notes/todo.html": "notes:Todo", "notes/recipes/bread.html": "recipes:Bread", "notes/recipes/cocktails/stirred.html": "cocktails:Stirred", "notes/recipes/cocktails/old_fashioned.html": "exact:Old Fashioned", } { raw, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name))) if err != nil { t.Fatal(err) } if string(raw) != want { t.Errorf("%s = %q, want %q", name, raw, want) } } } func TestAtomValidSortedAndContainsFullEscapedContent(t *testing.T) { root := t.TempDir() writeTestFile(t, root, "layouts.tmpl", testLayouts()) writeTestFile(t, root, "weblog/older.md", "---\npost_date: 2024-01-01\n---\n# Older\n\nOld full body.\n") writeTestFile(t, root, "weblog/newer.md", "---\npost_date: 2025-01-01\nupdated: 2025-02-01\n---\n# Newer\n\nNew **full** body.\n") writeTestFile(t, root, "feed.xml.tmpl", ` Site{{range sortPages (index .Pages "weblog") "post_date"}}{{.Title}}{{.CanonicalURL}}{{if index .Meta "updated"}}{{rfc3339 (index .Meta "updated")}}{{else}}{{rfc3339 (index .Meta "post_date")}}{{end}}{{xml (joinSections .Sections)}}{{end}}`) if err := build(root); err != nil { t.Fatal(err) } raw, err := os.ReadFile(filepath.Join(root, "feed.xml")) if err != nil { t.Fatal(err) } var sink struct{} if err := xml.Unmarshal(raw, &sink); err != nil { t.Fatalf("invalid Atom: %v\n%s", err, raw) } got := string(raw) if strings.Index(got, "Newer") > strings.Index(got, "Older") { t.Errorf("feed is not newest first:\n%s", got) } if !strings.Contains(got, "New <strong>full</strong> body.") { t.Errorf("full HTML content not XML escaped:\n%s", got) } } func TestGeneratedOwnershipStaleCleanupAndFailedBuildPreservation(t *testing.T) { root := t.TempDir() writeTestFile(t, root, "layouts.tmpl", testLayouts()) writeTestFile(t, root, "one.md", "# One\n\nBody.\n") writeTestFile(t, root, "owned.html", "human") if err := build(root); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(root, "one.html")); err != nil { t.Fatal(err) } writeTestFile(t, root, "one.md", "# Changed\n\n[broken](missing.md)\n") if err := build(root); err == nil || !strings.Contains(err.Error(), "broken internal link") { t.Fatalf("got %v", err) } raw, _ := os.ReadFile(filepath.Join(root, "one.html")) if !strings.Contains(string(raw), "One") { t.Fatalf("failed build changed output: %s", raw) } if err := os.Remove(filepath.Join(root, "one.md")); err != nil { t.Fatal(err) } if err := build(root); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(root, "one.html")); !os.IsNotExist(err) { t.Fatalf("stale output still exists: %v", err) } if raw, _ := os.ReadFile(filepath.Join(root, "owned.html")); string(raw) != "human" { t.Fatalf("untracked HTML changed: %q", raw) } writeTestFile(t, root, "owned.md", "# Collision\n") if err := build(root); err == nil || !strings.Contains(err.Error(), "refusing to overwrite") { t.Fatalf("got %v", err) } } func TestLinkValidationRejectsTrackedOutputScheduledForCleanup(t *testing.T) { root := t.TempDir() writeTestFile(t, root, "layouts.tmpl", testLayouts()) writeTestFile(t, root, "one.md", "# One\n\n[Two](two.html)\n") writeTestFile(t, root, "two.md", "# Two\n") if err := build(root); err != nil { t.Fatal(err) } if err := os.Remove(filepath.Join(root, "two.md")); err != nil { t.Fatal(err) } if err := build(root); err == nil || !strings.Contains(err.Error(), "broken internal link") { t.Fatalf("got %v", err) } if _, err := os.Stat(filepath.Join(root, "two.html")); err != nil { t.Fatalf("failed build removed the old output: %v", err) } } func TestManifestRejectsNonOutputPaths(t *testing.T) { root := t.TempDir() for _, output := range []string{"style.css", "../outside.html", `dir\outside.html`} { raw, err := json.Marshal(manifest{Outputs: []string{output}}) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, manifestName), raw, 0o644); err != nil { t.Fatal(err) } s := &site{root: root} if _, err := s.readManifest(); err == nil || !strings.Contains(err.Error(), "unsafe path") { t.Errorf("%q: got %v", output, err) } } } func TestGitignoreTracksOnlyCurrentGeneratedOutputs(t *testing.T) { root := t.TempDir() writeTestFile(t, root, ".gitignore", "*.log\n/pantograph/cache.html\n") writeTestFile(t, root, "layouts.tmpl", testLayouts()) writeTestFile(t, root, "one.md", "# One\n") writeTestFile(t, root, "nested/two.md", "# Two\n") if err := buildWithFallback(root, "https://example.com", "page", true); err != nil { t.Fatal(err) } want := "*.log\n/pantograph/cache.html\n" + gitignoreStart + "\n/nested/two.html\n/one.html\n" + gitignoreEnd + "\n" if raw, _ := os.ReadFile(filepath.Join(root, ".gitignore")); string(raw) != want { t.Fatalf(".gitignore = %q, want %q", raw, want) } if err := os.Remove(filepath.Join(root, "nested/two.md")); err != nil { t.Fatal(err) } if err := buildWithFallback(root, "https://example.com", "page", true); err != nil { t.Fatal(err) } want = "*.log\n/pantograph/cache.html\n" + gitignoreStart + "\n/one.html\n" + gitignoreEnd + "\n" if raw, _ := os.ReadFile(filepath.Join(root, ".gitignore")); string(raw) != want { t.Fatalf("updated .gitignore = %q, want %q", raw, want) } writeTestFile(t, root, ".gitignore", gitignoreStart+"\n") writeTestFile(t, root, "one.md", "# Changed\n") if err := buildWithFallback(root, "https://example.com", "page", true); err == nil { t.Fatal("build accepted an unterminated managed block") } if raw, _ := os.ReadFile(filepath.Join(root, "one.html")); !strings.Contains(string(raw), "One") { t.Fatalf("failed .gitignore update changed output: %s", raw) } } func TestGitignoreOmitsOutputsBelowSymlinks(t *testing.T) { root := t.TempDir() vault := t.TempDir() writeTestFile(t, root, ".gitignore", gitignoreStart+"\n/notes/note.html\n"+gitignoreEnd+"\n") writeTestFile(t, root, "layouts.tmpl", testLayouts()) writeTestFile(t, root, "local.md", "# Local\n") writeTestFile(t, vault, "note.md", "# Note\n") if err := os.Symlink(vault, filepath.Join(root, "notes")); err != nil { t.Skipf("symlinks unavailable: %v", err) } if err := buildWithFallback(root, "https://example.com", "page", true); err != nil { t.Fatal(err) } want := gitignoreStart + "\n/local.html\n" + gitignoreEnd + "\n" if raw, _ := os.ReadFile(filepath.Join(root, ".gitignore")); string(raw) != want { t.Fatalf(".gitignore = %q, want %q", raw, want) } if raw, _ := os.ReadFile(filepath.Join(root, manifestName)); !strings.Contains(string(raw), `"notes/note.html"`) { t.Fatalf("manifest does not own symlinked output: %s", raw) } } func TestSortingFilteringSlicing(t *testing.T) { a := &Page{Title: "A", Meta: map[string]any{"post_date": "2024-01-01", "kind": "x"}, ModTime: time.Unix(1, 0)} b := &Page{Title: "B", Meta: map[string]any{"post_date": "2025-01-01", "kind": "y"}, ModTime: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)} if got := sortPages([]*Page{a, b}, "post_date"); got[0] != b { t.Fatal("date sort") } b.Meta["post_date"] = "invalid" if got := sortPages([]*Page{a, b}, "post_date", "mod_time"); got[0] != b { t.Fatal("date fallback sort") } if got := filterPages([]*Page{a, b}, "kind", "x"); len(got) != 1 || got[0] != a { t.Fatal("filter") } if got := slicePages([]*Page{a, b}, 0, 1); len(got) != 1 || got[0] != a { t.Fatal("slice") } } func TestPathTemplateFunctions(t *testing.T) { tmpl := template.Must(template.New("path").Funcs(templateFuncs()).Parse( `{{$parts := splitPath "notes/sync/recipes/soup.md"}}{{dirPath (joinPath (slice $parts 2))}}|{{dirPath "soup.md"}}`, )) var output strings.Builder if err := tmpl.Execute(&output, nil); err != nil { t.Fatal(err) } if got := output.String(); got != "recipes|" { t.Fatalf("path = %q, want recipes|", got) } } func TestUnchangedOutputsKeepMtime(t *testing.T) { root := t.TempDir() writeTestFile(t, root, "layout.tmpl", testLayouts()) writeTestFile(t, root, "a.md", "# A\n\nbody\n") writeTestFile(t, root, "b.md", "# B\n\nbody\n") if err := build(root); err != nil { t.Fatal(err) } old := time.Now().Add(-time.Hour) for _, name := range []string{"a.html", "b.html"} { if err := os.Chtimes(filepath.Join(root, name), old, old); err != nil { t.Fatal(err) } } writeTestFile(t, root, "b.md", "# B\n\nchanged\n") if err := build(root); err != nil { t.Fatal(err) } unchanged, err := os.Stat(filepath.Join(root, "a.html")) if err != nil { t.Fatal(err) } if !unchanged.ModTime().Equal(old) { t.Errorf("unchanged output mtime bumped: %v", unchanged.ModTime()) } changed, err := os.Stat(filepath.Join(root, "b.html")) if err != nil { t.Fatal(err) } if changed.ModTime().Equal(old) { t.Error("changed output mtime not updated") } } func TestMarkdownNoOverrideMatchesGoldmark(t *testing.T) { root := t.TempDir() writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}{{joinSections .Sections}}{{end}}`) markdown := "# Heading\n\nParagraph with **strong**, ~~strike~~, and [link](#heading).\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n\n" writeTestFile(t, root, "page.md", markdown) if err := build(root); err != nil { t.Fatal(err) } got, err := os.ReadFile(filepath.Join(root, "page.html")) if err != nil { t.Fatal(err) } var want bytes.Buffer if err := newMarkdown().Convert([]byte(markdown), &want); err != nil { t.Fatal(err) } if !bytes.Equal(got, want.Bytes()) { t.Fatalf("no-override output changed:\n got: %s\nwant: %s", got, want.Bytes()) } } func TestMarkdownCoreOverrideContract(t *testing.T) { root := t.TempDir() writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}{{joinSections .Sections}}{{end}} {{define "markdown/heading1"}}

{{.Content}}

{{end}} {{define "markdown/paragraph"}}

{{.Content}}

{{end}} {{define "markdown/blockquote"}}{{.Content}}{{end}} {{define "markdown/code_block"}}{{.Content}}{{end}} {{define "markdown/fenced_code_block"}}{{if .HighlightedContent}}{{.HighlightedContent}}{{else}}{{.Content}}{{end}}{{end}} {{define "markdown/ordered_list"}}
    {{.Content}}
{{end}} {{define "markdown/unordered_list"}}
    {{.Content}}
{{end}} {{define "markdown/list_item"}}
  • {{.Content}}
  • {{end}} {{define "markdown/emphasis"}}{{.Content}}{{end}} {{define "markdown/strong"}}{{.Content}}{{end}} {{define "markdown/code_span"}}{{.Content}}{{end}} {{define "markdown/link"}}{{.Content}}{{end}} {{define "markdown/autolink"}}{{.Content}}{{end}} {{define "markdown/email_autolink"}}{{.Content}}{{end}} {{define "markdown/image"}}{{.Alt}}{{end}}`) writeTestFile(t, root, "target.md", "# Target\n") markdown := "# Root Heading\n\n## Default *heading*\n\nParagraph *em **deep*** ` a b ` [local](target.md \"Title\") [unsafe](javascript:alert(1)).\n\n ![A **very** & black](cat.jpg \"Mochi\")\n\n> quoted **strong**\n\n \n\n" + "```go extra\nx := \"\"\n```\n\n```unknown\n\n```\n\n```\n\n```\n\n" + "1. one\n1. two\n\n### split one\n\n3. three\n3. four\n\n### split two\n\n5. five\n99. six\n\n### split three\n\n- tight\n 1. nested\n\n### split four\n\n- loose\n\n- list\n" writeTestFile(t, root, "page.md", markdown) if err := build(root); err != nil { t.Fatal(err) } raw, err := os.ReadFile(filepath.Join(root, "page.html")) if err != nil { t.Fatal(err) } got := string(raw) for _, want := range []string{ `

    Root Heading

    `, `

    Default heading

    `, `

    Paragraph em deep a b`, `local`, `unsafe`, `https://example.com/a`, `person@example.com`, `A very & black`, `

    quoted strong

    `, `<script>indented</script>`, ``, `<a>`, `<b>`, `
      `, `
        `, `
          `, `
          • tight`, `
            1. nested`, `
              • loose

                `, } { if !strings.Contains(got, want) { t.Errorf("output missing %q:\n%s", want, got) } } if strings.Contains(got, "