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 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
---
# 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")
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 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")
}
}