Files

302 lines
13 KiB
Go

package build
import (
"fmt"
"image/png"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
"git.coffeylabs.org/coffey-labs/hotdog-cms/starter"
)
// writeStarter puts the starter site in a temporary folder.
func writeStarter(t *testing.T) string {
t.Helper()
dir := t.TempDir()
err := fs.WalkDir(starter.Files, "site", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
target := filepath.Join(dir, strings.TrimPrefix(p, "site"))
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
data, _ := starter.Files.ReadFile(p)
return os.WriteFile(target, []byte(strings.ReplaceAll(string(data), "{{SITE_NAME}}", "Test")), 0o644)
})
if err != nil {
t.Fatal(err)
}
return dir
}
func TestStarterBuilds(t *testing.T) {
dir := writeStarter(t)
res, err := Run(Options{SiteDir: dir})
if err != nil {
t.Fatal(err)
}
for _, f := range []string{"index.html", "about/index.html", "articles/index.html", "articles/hello-world/index.html",
"tags/hotdog-cms/index.html", "404.html", "sitemap.xml", "robots.txt", "articles/feed.xml", "llms.txt"} {
if _, err := os.Stat(filepath.Join(res.Out, f)); err != nil {
t.Errorf("missing %s", f)
}
}
home, _ := os.ReadFile(filepath.Join(res.Out, "index.html"))
if strings.Contains(string(home), `href="/styles.css"`) || !strings.Contains(string(home), `/styles.`) {
t.Error("stylesheet is not fingerprinted")
}
// A second build replaces the first.
if _, err := Run(Options{SiteDir: dir}); err != nil {
t.Fatalf("rebuild: %v", err)
}
}
func TestTemplatesEscape(t *testing.T) {
dir := writeStarter(t)
page := "---\ntitle: \"<script>alert(1)</script>\"\nsummary: '\"><img src=x onerror=alert(1)>'\n---\n\n<script>alert(2)</script>\n\n[x](javascript:alert(3))\n"
if err := os.WriteFile(filepath.Join(dir, "content", "evil.md"), []byte(page), 0o644); err != nil {
t.Fatal(err)
}
res, err := Run(Options{SiteDir: dir})
if err != nil {
t.Fatal(err)
}
out, _ := os.ReadFile(filepath.Join(res.Out, "evil", "index.html"))
for _, bad := range []string{"<script>alert", "<img src=x", "javascript:alert"} {
if strings.Contains(string(out), bad) {
t.Errorf("page contains %q", bad)
}
}
}
func TestRefusesForeignOutput(t *testing.T) {
dir := writeStarter(t)
other := t.TempDir()
if err := os.WriteFile(filepath.Join(other, "precious.txt"), []byte("keep me"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Run(Options{SiteDir: dir, Out: other}); err == nil {
t.Fatal("replaced a folder hotdog-cms did not write")
}
if _, err := os.Stat(filepath.Join(other, "precious.txt")); err != nil {
t.Fatal("the folder's file is gone")
}
if _, err := Run(Options{SiteDir: dir, Out: filepath.Join(dir, "content", "out")}); err == nil {
t.Fatal("wrote output inside content/")
}
if _, err := Run(Options{SiteDir: dir, Out: filepath.Dir(dir)}); err == nil {
t.Fatal("wrote output over the site's parent")
}
}
func TestPaginationTermsRedirectsRSS(t *testing.T) {
dir := writeStarter(t)
// Fourteen articles, tagged in three spellings of one subject.
spellings := []string{"open source", "#OpenSource", "open-source"}
for i := 0; i < 14; i++ {
body := fmt.Sprintf("---\ntitle: Article %02d\ndate: 2026-09-%02d\ntags: [%q, linux]\n---\nSee https://example.com and [elsewhere](https://example.net/x).\n", i, i+1, spellings[i%3])
os.WriteFile(filepath.Join(dir, "content", "articles", fmt.Sprintf("a%02d.md", i)), []byte(body), 0o644)
}
cfg, _ := os.ReadFile(filepath.Join(dir, "site.yaml"))
cfg = []byte(strings.Replace(string(cfg), "\npaginate: 12\n", "\npaginate: 5\n", 1))
extra := "\nterm_index_min: 20\nredirects:\n - { from: /old/, to: /about/ }\n"
cfg = []byte(strings.Replace(string(cfg), " feed: true\n", " feed: true\n feed_format: rss\n paginate: 6\n", 1) + extra)
cfg = []byte(strings.Replace(string(cfg), "markdown:", "markdown:", 1))
cfg = append(cfg, []byte("markdown:\n autolink: false\n external_links: new_tab\n")...)
os.WriteFile(filepath.Join(dir, "site.yaml"), cfg, 0o644)
res, err := Run(Options{SiteDir: dir})
if err != nil {
t.Fatal(err)
}
read := func(p string) string { b, _ := os.ReadFile(filepath.Join(res.Out, p)); return string(b) }
// 15 articles (14 + hello-world), 6 per page: 3 pages.
for _, p := range []string{"articles/index.html", "articles/page/2/index.html", "articles/page/3/index.html"} {
if read(p) == "" {
t.Errorf("missing %s", p)
}
}
p2 := read("articles/page/2/index.html")
for _, want := range []string{"<title>Articles · page 2 · Test</title>", `<link rel="prev" href="https://example.org/articles/">`, `<link rel="next" href="https://example.org/articles/page/3/">`, `<a href="/articles/page/2/" aria-current="page">2</a>`, `<a class="older" href="/articles/page/3/" rel="next">`} {
if !strings.Contains(p2, want) {
t.Errorf("page 2 lacks %s", want)
}
}
if strings.Count(p2, `<li class="card">`) != 6 {
t.Errorf("page 2 lists %d cards, want 6", strings.Count(p2, `<li class="card">`))
}
if read("articles/page/4/index.html") != "" {
t.Error("a fourth page of 15 items at 6 a page")
}
// Three spellings, one page, under the slug used most.
if read("tags/open-source/index.html") == "" {
t.Error("open source spellings did not share /tags/open-source/")
}
for _, p := range []string{"tags/opensource/index.html"} {
if read(p) != "" {
t.Errorf("%s exists; spellings were not merged", p)
}
}
if !strings.Contains(read("tags/linux/index.html"), `content="noindex`) {
t.Error("thin term page is not noindex")
}
if strings.Contains(read("sitemap.xml"), "/tags/linux/") {
t.Error("noindex term page is in the sitemap")
}
if !strings.Contains(read("old/index.html"), "url=/about/") || !strings.Contains(read(".hotdog-cms-redirects"), "/old/ /about/") {
t.Error("redirect not written")
}
if feed := read("articles/feed.xml"); !strings.Contains(feed, `<rss version="2.0"`) || !strings.Contains(feed, "<category>linux</category>") {
t.Error("feed is not RSS with categories")
}
art := read("articles/a00/index.html")
if strings.Contains(art, `href="https://example.com"`) {
t.Error("bare URL was autolinked with autolink off")
}
if !strings.Contains(art, `href="https://example.net/x" target="_blank" rel="noopener noreferrer"`) {
t.Error("external link does not open in a new tab")
}
}
func TestOwnHostLinksStayInTab(t *testing.T) {
md := site.NewMarkdown(site.MarkdownConfig{ExternalLinks: "new_tab"}, "example.org")
h, _ := md.Render([]byte("[a](https://www.example.org/x) [b](https://example.net/) [c](/local/)"))
if strings.Count(string(h), `target="_blank"`) != 1 || !strings.Contains(string(h), `href="https://example.net/" target="_blank"`) {
t.Errorf("new-tab rule wrong: %s", h)
}
}
func TestHTMLBodiesAndRedirectTo(t *testing.T) {
dir := writeStarter(t)
page := "---\ntitle: Raw\nformat: html\n---\n<section class=\"x\">\n\n <p>indented, after a blank line</p>\n<script>alert(1)</script>\n</section>\n"
os.WriteFile(filepath.Join(dir, "content", "raw.md"), []byte(page), 0o644)
// Without trust_html or unsafe_html, an HTML body is refused.
if _, err := Run(Options{SiteDir: dir}); err == nil || !strings.Contains(err.Error(), "trust_html") {
t.Fatalf("HTML body accepted without permission: %v", err)
}
cfgPath := filepath.Join(dir, "site.yaml")
base, _ := os.ReadFile(cfgPath)
// Sanitized: the script goes, the indented paragraph stays a paragraph.
os.WriteFile(cfgPath, append(base, []byte("markdown:\n unsafe_html: true\n")...), 0o644)
res, err := Run(Options{SiteDir: dir})
if err != nil {
t.Fatal(err)
}
out, _ := os.ReadFile(filepath.Join(res.Out, "raw", "index.html"))
if strings.Contains(string(out), "<script>alert") || strings.Contains(string(out), "<pre><code>") || !strings.Contains(string(out), "<p>indented, after a blank line</p>") {
t.Errorf("sanitized HTML body wrong:\n%s", out)
}
// A link post: listed and in the feed at its destination, built as a redirect, not in the sitemap.
os.WriteFile(cfgPath, base, 0o644)
os.Remove(filepath.Join(dir, "content", "raw.md"))
os.WriteFile(filepath.Join(dir, "content", "articles", "elsewhere.md"), []byte("---\ntitle: Written elsewhere\ndate: 2026-10-11\nredirect_to: https://example.net/post/\n---\n"), 0o644)
if res, err = Run(Options{SiteDir: dir}); err != nil {
t.Fatal(err)
}
read := func(p string) string { b, _ := os.ReadFile(filepath.Join(res.Out, p)); return string(b) }
if !strings.Contains(read("articles/elsewhere/index.html"), "url=https://example.net/post/") {
t.Error("redirect_to page is not a redirect")
}
if strings.Contains(read("sitemap.xml"), "/articles/elsewhere/") {
t.Error("redirect_to page is in the sitemap")
}
if !strings.Contains(read("articles/feed.xml"), "https://example.net/post/") {
t.Error("feed doesn't link the redirect_to destination")
}
}
func TestLook(t *testing.T) {
dir := writeStarter(t)
res, err := Run(Options{SiteDir: dir})
if err != nil {
t.Fatal(err)
}
home, _ := os.ReadFile(filepath.Join(res.Out, "index.html"))
if strings.Contains(string(home), "/look.") || strings.Contains(string(home), "brand-logo") {
t.Error("a site that changes nothing gets a look stylesheet or a logo")
}
cfg, _ := os.ReadFile(filepath.Join(dir, "site.yaml"))
os.WriteFile(filepath.Join(dir, "site.yaml"), append(cfg, []byte("look:\n accent: \"#0a7d55\"\n font: serif\n logo: /logo.svg\n")...), 0o644)
if res, err = Run(Options{SiteDir: dir}); err != nil {
t.Fatal(err)
}
home, _ = os.ReadFile(filepath.Join(res.Out, "index.html"))
link := regexp.MustCompile(`<link rel="stylesheet" href="(/look\.[0-9a-f]{10}\.css)">`).FindStringSubmatch(string(home))
if link == nil || !strings.Contains(string(home), `<img class="brand-logo" src="/logo.svg" alt="">`) {
t.Fatalf("home page:\n%s", home)
}
css, err := os.ReadFile(filepath.Join(res.Out, link[1]))
if err != nil || !strings.Contains(string(css), "--accent: #0a7d55;") || !strings.Contains(string(css), "--font: Charter") {
t.Errorf("look.css: %v\n%s", err, css)
}
// LookCSS, for the editor's preview, names the same file.
u, data, err := LookCSS(dir, map[string]any{"accent": "#0a7d55", "font": "serif", "logo": "/logo.svg"})
if err != nil || u != link[1] || string(data) != string(css) {
t.Errorf("LookCSS: %s %v", u, err)
}
// A choice the theme doesn't allow stops the build, saying why.
os.WriteFile(filepath.Join(dir, "site.yaml"), append(cfg, []byte("look:\n font: comic\n")...), 0o644)
if _, err := Run(Options{SiteDir: dir}); err == nil || !strings.Contains(err.Error(), `"comic" isn't one of`) {
t.Errorf("bad look: %v", err)
}
}
func TestSocialCards(t *testing.T) {
dir := writeStarter(t)
// A page with its own picture keeps it.
about := filepath.Join(dir, "content", "about.md")
src, _ := os.ReadFile(about)
os.WriteFile(about, []byte(strings.Replace(string(src), "title: About", "title: About\nimage: /own.png\nimage_alt: Our own", 1)), 0o644)
res, err := Run(Options{SiteDir: dir})
if err != nil {
t.Fatal(err)
}
home, _ := os.ReadFile(filepath.Join(res.Out, "index.html"))
m := regexp.MustCompile(`<meta property="og:image" content="https://example.org(/cards/home\.[0-9a-f]{8}\.png)">`).FindStringSubmatch(string(home))
if m == nil || !strings.Contains(string(home), `<meta property="og:image:alt" content="Home">`) {
t.Fatalf("home head:\n%s", home)
}
f, err := os.Open(filepath.Join(res.Out, m[1]))
if err != nil {
t.Fatal(err)
}
img, err := png.Decode(f)
f.Close()
if err != nil || img.Bounds().Dx() != 1200 || img.Bounds().Dy() != 630 {
t.Fatalf("card: %v %v", err, img.Bounds())
}
// The site's band color fills it; the accent runs down the left edge.
if r, g, b, _ := img.At(600, 20).RGBA(); r>>8 != 0x1d || g>>8 != 0x2b || b>>8 != 0x3a {
t.Errorf("background %x %x %x", r>>8, g>>8, b>>8)
}
aboutHTML, _ := os.ReadFile(filepath.Join(res.Out, "about", "index.html"))
if !strings.Contains(string(aboutHTML), `content="https://example.org/own.png"`) || strings.Contains(string(aboutHTML), "/cards/") {
t.Error("a page with its own image got a card")
}
if _, err := os.Stat(filepath.Join(res.Out, "articles", "hello-world")); err != nil {
t.Fatal(err)
}
cards, _ := filepath.Glob(filepath.Join(res.Out, "cards", "*.png"))
// Home, the articles list, the article, contact, privacy; not 404, tags or about.
if len(cards) != 5 {
t.Errorf("%d cards: %v", len(cards), cards)
}
// A new title is a new address, so previews refresh.
idx := filepath.Join(dir, "content", "index.md")
src, _ = os.ReadFile(idx)
os.WriteFile(idx, []byte(strings.Replace(string(src), "title: Home", "title: Welcome", 1)), 0o644)
res, _ = Run(Options{SiteDir: dir})
home2, _ := os.ReadFile(filepath.Join(res.Out, "index.html"))
if strings.Contains(string(home2), m[1]) {
t.Error("the card's address didn't change with its title")
}
}