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>
143 lines
4.4 KiB
Go
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
|
|
}
|