diff options
Diffstat (limited to 'main_test.go')
| -rw-r--r-- | main_test.go | 268 |
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 <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), "<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") + } +} |
