ZDDC/zddc/internal/handler/converttemplate.go
2026-06-11 13:32:31 -05:00

143 lines
4.4 KiB
Go

package handler
import (
"bytes"
"os"
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/convert"
"gopkg.in/yaml.v3"
)
// resolveTemplateSet builds the convert.TemplateSet for an HTML/PDF render of a
// markdown source. It starts from the baked-in defaults for the doctype named in
// the document's `template:` front matter (default "report"), then overlays any
// per-project / per-party overrides found in the `.zddc.d/templates/` cascade.
//
// The cascade walks from the document's directory up to fsRoot; a nearer level
// (e.g. working/<party>/.zddc.d/templates/) overrides a farther one (e.g.
// working/.zddc.d/templates/), which overrides the embedded default. Overrides
// may replace the named doctype template, any shared partial (_head.html, …), or
// introduce an entirely new doctype the front matter names.
func resolveTemplateSet(fsRoot, docDir string, source []byte) convert.TemplateSet {
name := templateNameFromFrontMatter(source) // "" when absent/invalid
ts := convert.DefaultTemplateSet(name) // primary falls back to report
dirs := templateCascadeDirs(fsRoot, docDir)
// If the named doctype isn't a baked-in default but an override provides it,
// adopt the override as the primary template.
if name != "" {
primary := name + ".html"
if b := firstTemplateOverride(dirs, primary); b != nil {
ts.Name = primary
ts.Files[primary] = b
}
}
// Overlay overrides for every file in the set (primary + partials).
for fname := range ts.Files {
if b := firstTemplateOverride(dirs, fname); b != nil {
ts.Files[fname] = b
}
}
return ts
}
// templateCascadeDirs returns the `<level>/.zddc.d/templates` directories from
// docDir up to fsRoot, nearest (most specific) first. Levels outside fsRoot are
// skipped (path-containment guard).
func templateCascadeDirs(fsRoot, docDir string) []string {
root := filepath.Clean(fsRoot)
d := filepath.Clean(docDir)
var dirs []string
for {
if d == root || strings.HasPrefix(d, root+string(filepath.Separator)) {
dirs = append(dirs, filepath.Join(d, ReservedSidecar, "templates"))
}
if d == root {
break
}
parent := filepath.Dir(d)
if parent == d {
break
}
d = parent
}
return dirs
}
// firstTemplateOverride returns the bytes of the first existing `<dir>/<name>`
// across dirs (nearest first), or nil. name is reduced to a base name so it can
// never escape the templates dir.
func firstTemplateOverride(dirs []string, name string) []byte {
base := filepath.Base(name)
if base == "" || base == "." || base == ".." {
return nil
}
for _, dir := range dirs {
if b, err := os.ReadFile(filepath.Join(dir, base)); err == nil {
return b
}
}
return nil
}
// templateNameFromFrontMatter extracts a sanitized `template:` doctype name from
// a markdown document's leading YAML front matter. Returns "" when there is no
// front matter, no `template:` field, or the value isn't a safe bare name.
func templateNameFromFrontMatter(source []byte) string {
fm := leadingFrontMatter(source)
if fm == nil {
return ""
}
var doc struct {
Template string `yaml:"template"`
}
if err := yaml.Unmarshal(fm, &doc); err != nil {
return ""
}
return sanitizeTemplateName(doc.Template)
}
// leadingFrontMatter returns the YAML between an opening `---` line (which must
// be the very first line) and the next `---` or `...` line, or nil if absent.
func leadingFrontMatter(src []byte) []byte {
s := bytes.TrimPrefix(src, []byte{0xEF, 0xBB, 0xBF}) // strip a UTF-8 BOM
if !bytes.HasPrefix(s, []byte("---\n")) && !bytes.HasPrefix(s, []byte("---\r\n")) {
return nil
}
lines := bytes.Split(s, []byte("\n"))
var buf bytes.Buffer
for i := 1; i < len(lines); i++ {
ln := bytes.TrimRight(lines[i], "\r")
if bytes.Equal(ln, []byte("---")) || bytes.Equal(ln, []byte("...")) {
return buf.Bytes()
}
buf.Write(lines[i])
buf.WriteByte('\n')
}
return nil // unterminated block
}
// sanitizeTemplateName allows only a bare basename of [A-Za-z0-9_-] so a
// `template:` value can't traverse paths or name a partial. Returns "" if the
// value is empty or contains any other character.
func sanitizeTemplateName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
for _, r := range name {
switch {
case r == '-' || r == '_':
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
default:
return ""
}
}
return name
}