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"}}
{{.Title}}{{joinSections .Sections}}{{end}}
{{define "weblog"}}{{.Title}}{{joinSections .Sections}}{{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 |
`)
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", `href="other.html#part"`, `href="https://example.com/a.md"`, `href="#part"`, ""} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "") {
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", `
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 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")
}
}