summaryrefslogtreecommitdiff
path: root/main_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'main_test.go')
-rw-r--r--main_test.go268
1 files changed, 268 insertions, 0 deletions
diff --git a/main_test.go b/main_test.go
new file mode 100644
index 0000000..cf40a29
--- /dev/null
+++ b/main_test.go
@@ -0,0 +1,268 @@
+package main
+
+import (
+ "encoding/json"
+ "encoding/xml"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+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 testLayouts() string {
+ return `{{define "page"}}<html><head><title>{{.Title}}</title></head><body>{{joinSections .Sections}}</body></html>{{end}}
+{{define "weblog"}}<html><head><title>{{.Title}}</title></head><body>{{joinSections .Sections}}</body></html>{{end}}
+{{define "note"}}{{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
+---
+# My *Page*
+
+First paragraph with [local](other.md#part), [web](https://example.com/a.md), and [fragment](#part).
+
+| A | B |
+| - | - |
+| 1 | 2 |
+
+<script>alert(1)</script>
+`)
+ 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{"<title>My Page</title>", `href="other.html#part"`, `href="https://example.com/a.md"`, `href="#part"`, "<table>"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("output missing %q:\n%s", want, got)
+ }
+ }
+ if strings.Contains(got, "<h1") || strings.Contains(got, "<script>") {
+ t.Errorf("H1 duplicated or 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) != 3 {
+ t.Fatalf("unexpected page model: %#v", page)
+ }
+}
+
+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")
+ 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("underscore sources were discovered: %#v", s.sources)
+ }
+}
+
+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("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 TestPathSelectsMarkdownLayout(t *testing.T) {
+ root := t.TempDir()
+ writeTestFile(t, root, "layouts.tmpl", `{{define "page"}}page:{{.Title}}{{end}}{{define "weblog"}}post:{{.Title}}{{end}}{{define "note"}}note:{{.Title}}{{end}}`)
+ writeTestFile(t, root, "about.md", "# About\n")
+ writeTestFile(t, root, "weblog/post.md", "# Post\n")
+ writeTestFile(t, root, "notes/item.md", "# Item\n")
+ if err := build(root); err != nil {
+ t.Fatal(err)
+ }
+ for name, want := range map[string]string{"about.html": "page:About", "weblog/post.html": "post:Post", "notes/item.html": "note:Item"} {
+ 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", `<?xml version="1.0" encoding="utf-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom"><title>Site</title>{{range sortPages (index .Pages "weblog") "post_date"}}<entry><title>{{.Title}}</title><id>{{.CanonicalURL}}</id><updated>{{if index .Meta "updated"}}{{rfc3339 (index .Meta "updated")}}{{else}}{{rfc3339 (index .Meta "post_date")}}{{end}}</updated><content type="html">{{xml (joinSections .Sections)}}</content></entry>{{end}}</feed>`)
+ 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 &lt;strong&gt;full&lt;/strong&gt; 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), "<title>One</title>") {
+ 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 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.Unix(2, 0)}
+ if got := sortPages([]*Page{a, b}, "post_date"); got[0] != b {
+ t.Fatal("date 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")
+ }
+}