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//.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 `/.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 `/` // 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 }