ZDDC/zddc/internal/handler/converttemplate_test.go
ZDDC 1d816ae43a feat(server): multi-template MD→HTML with .zddc.d/templates cascade
The convert engine renders markdown→HTML/PDF through named doctype templates
selected by the document's `template:` front matter, with per-project/per-party
overrides.

convert package:
- embed.go now embeds the whole templates/ dir (all: prefix so _-prefixed
  partials are included) as an embed.FS; drop the single viewer-template.html +
  custom.css embeds. New TemplateSet type + DefaultTemplateSet(name) returning the
  chosen doctype + its partials.
- ToHTML/ToPDF take a TemplateSet; writeTemplateSetToScratch materialises the
  template + partials flat into the per-call scratch dir (pandoc resolves
  $partial()$ from the template's own directory).

handler:
- converttemplate.go: templateNameFromFrontMatter (YAML front-matter scan,
  sanitized to a bare basename) + resolveTemplateSet, which overlays
  <level>/.zddc.d/templates/<name>.html overrides onto the embedded defaults,
  walking docDir→fsRoot so a party dir beats the project-global dir. An override
  may replace a doctype, a partial, or add a brand-new doctype.
- buildAndStore threads fsRoot + source into the html/pdf paths.

build: pandoc/templates/ is the single source of truth; shared/build-lib.sh
sync_pandoc_templates mirrors it into the embed dir on every build (cmp-guarded,
stale-pruning). convert.TestEmbeddedTemplatesMatchSource fails on drift.

Tests: drift + DefaultTemplateSet (convert); front-matter parse + cascade
override precedence (handler). Full ./... suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:18:40 -05:00

95 lines
3.3 KiB
Go

package handler
import (
"os"
"path/filepath"
"testing"
)
func TestTemplateNameFromFrontMatter(t *testing.T) {
cases := []struct {
name string
src string
want string
}{
{"plain", "---\ntemplate: specification\n---\n\n# H\n", "specification"},
{"quoted", "---\ntemplate: \"letter\"\n---\n", "letter"},
{"absent", "---\ntitle: X\n---\n", ""},
{"no-frontmatter", "# Just a heading\n", ""},
{"traversal-rejected", "---\ntemplate: ../../etc/passwd\n---\n", ""},
{"slash-rejected", "---\ntemplate: a/b\n---\n", ""},
{"crlf", "---\r\ntemplate: report\r\n---\r\n", "report"},
{"dots-terminator", "---\ntemplate: letter\n...\nbody", "letter"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := templateNameFromFrontMatter([]byte(c.src)); got != c.want {
t.Errorf("templateNameFromFrontMatter(%q) = %q, want %q", c.src, got, c.want)
}
})
}
}
func TestResolveTemplateSet_DefaultsAndCascade(t *testing.T) {
root := t.TempDir()
party := filepath.Join(root, "working", "AcmeCo")
if err := os.MkdirAll(party, 0o755); err != nil {
t.Fatal(err)
}
// No overrides, no front matter → embedded report, partials present.
ts := resolveTemplateSet(root, party, []byte("# Hi\n"))
if ts.Name != "report.html" {
t.Fatalf("default doctype: got %q, want report.html", ts.Name)
}
if ts.Files["_head.html"] == nil {
t.Errorf("partial _head.html missing from default set")
}
embeddedReport := string(ts.Files["report.html"])
// Front matter selects a doctype.
if ts := resolveTemplateSet(root, party, []byte("---\ntemplate: letter\n---\n")); ts.Name != "letter.html" {
t.Errorf("front-matter doctype: got %q, want letter.html", ts.Name)
}
// Project-global override at <root>/.zddc.d/templates/report.html.
projDir := filepath.Join(root, ".zddc.d", "templates")
if err := os.MkdirAll(projDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(projDir, "report.html"), []byte("PROJECT-REPORT"), 0o644); err != nil {
t.Fatal(err)
}
ts = resolveTemplateSet(root, party, []byte("# Hi\n"))
if string(ts.Files["report.html"]) != "PROJECT-REPORT" {
t.Errorf("project override not applied: %q", ts.Files["report.html"])
}
if string(ts.Files["report.html"]) == embeddedReport {
t.Errorf("override identical to embedded — overlay didn't happen")
}
// Party override wins over project-global.
partyDir := filepath.Join(party, ".zddc.d", "templates")
if err := os.MkdirAll(partyDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(partyDir, "report.html"), []byte("PARTY-REPORT"), 0o644); err != nil {
t.Fatal(err)
}
ts = resolveTemplateSet(root, party, []byte("# Hi\n"))
if string(ts.Files["report.html"]) != "PARTY-REPORT" {
t.Errorf("party override should win: got %q", ts.Files["report.html"])
}
// A brand-new doctype provided only as an override is adopted as primary.
if err := os.WriteFile(filepath.Join(partyDir, "memo.html"), []byte("MEMO-TEMPLATE"), 0o644); err != nil {
t.Fatal(err)
}
ts = resolveTemplateSet(root, party, []byte("---\ntemplate: memo\n---\n"))
if ts.Name != "memo.html" || string(ts.Files["memo.html"]) != "MEMO-TEMPLATE" {
t.Errorf("custom doctype override: name=%q bytes=%q", ts.Name, ts.Files["memo.html"])
}
if ts.Files["_head.html"] == nil {
t.Errorf("partials should still ride along with a custom doctype override")
}
}