diff --git a/build b/build index ef1d40c..5f8d5b9 100755 --- a/build +++ b/build @@ -218,6 +218,12 @@ fi cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/internal/handler/tables.html" echo "Populated zddc/internal/handler/tables.html for //go:embed" +# Mirror the canonical conversion templates (pandoc/templates/) into the convert +# package's embed dir so //go:embed picks up the current bytes. pandoc/templates/ +# is the single source of truth; the embed copy is a build artifact guarded by +# convert.TestEmbeddedTemplatesMatchSource. Runs on every build (incl. plain dev). +sync_pandoc_templates "$SCRIPT_DIR/pandoc/templates" "$SCRIPT_DIR/zddc/internal/convert/templates" + if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then # Assemble the embedded versions manifest from the per-tool .label sidecars diff --git a/shared/build-lib.sh b/shared/build-lib.sh index 9ab3bb8..64dbc95 100755 --- a/shared/build-lib.sh +++ b/shared/build-lib.sh @@ -83,6 +83,38 @@ concat_files() { done } +# Mirror the conversion templates from a canonical source dir into a build embed +# dir — go:embed can't follow symlinks, so the bytes must be a real copy under the +# Go package. Copies every *.html, drops stale destination *.html the source no +# longer has, and verifies byte-identity. Guarded at test time by +# convert.TestEmbeddedTemplatesMatchSource. Usage: sync_pandoc_templates +sync_pandoc_templates() { + _src="$1" + _dst="$2" + if [ ! -d "$_src" ]; then + echo "error: missing template source dir: $_src" >&2 + exit 1 + fi + mkdir -p "$_dst" + # Drop destination templates the source no longer provides. + for _f in "$_dst"/*.html; do + [ -e "$_f" ] || continue + if [ ! -f "$_src/$(basename "$_f")" ]; then + rm -f "$_f" + fi + done + # Copy + verify each source template. + for _f in "$_src"/*.html; do + [ -e "$_f" ] || continue + cp "$_f" "$_dst/$(basename "$_f")" + if ! cmp -s "$_f" "$_dst/$(basename "$_f")"; then + echo "error: template sync mismatch: $_f" >&2 + exit 1 + fi + done + echo "Synced templates: $_src -> $_dst" +} + # ISO UTC build timestamp — set once when this file is sourced build_timestamp=$(date -u +"%Y-%m-%d %H:%M:%S") diff --git a/zddc/internal/convert/convert.go b/zddc/internal/convert/convert.go index a2d254a..b9d7b0b 100644 --- a/zddc/internal/convert/convert.go +++ b/zddc/internal/convert/convert.go @@ -14,8 +14,8 @@ // Public surface: // // ToDocx(ctx, source, meta) → []byte (DOCX bytes) -// ToHTML(ctx, source, meta) → []byte (standalone HTML) -// ToPDF (ctx, source, meta) → []byte (PDF, via HTML + chromium) +// ToHTML(ctx, source, meta, ts) → []byte (standalone HTML) +// ToPDF (ctx, source, meta, ts) → []byte (PDF, via HTML + chromium) // // Probe(ctx) → Capabilities (call once at startup) // Available() → (Capabilities, bool) @@ -25,7 +25,7 @@ // All three converters are safe for concurrent use; each call gets a // fresh scratch dir + (image-provided) sandbox. // -// Metadata maps to the placeholders consumed by viewer-template.html. +// Metadata maps to the placeholders consumed by the doctype templates. // title/tracking_number/revision/status/is_draft typically come from // the source filename (zddc.ParseFilename); client/project/contractor/ // project_number from the .zddc cascade `convert:` block. @@ -42,8 +42,8 @@ import ( ) // Metadata is the variable bag passed to pandoc as `--variable k=v` -// pairs. Fields with zero values are omitted. The viewer-template.html -// uses `$if(field)$ … $endif$` blocks so absent fields render cleanly. +// pairs. Fields with zero values are omitted. The templates use +// `$if(field)$ … $endif$` blocks so absent fields render cleanly. type Metadata struct { Title string TrackingNumber string @@ -58,6 +58,28 @@ type Metadata struct { NoTOC bool } +// TemplateSet is the bundle of files written to the per-call scratch dir for an +// HTML render: the chosen doctype template (Name) plus every partial it may +// include. pandoc resolves `$partial()$` includes from the template's own +// directory, so Files must contain Name and all referenced partials. +type TemplateSet struct { + Name string // primary template filename, e.g. "report.html" + Files map[string][]byte // base filename -> bytes (must include Name) +} + +// DefaultTemplateSet returns the baked-in template set for doctype `name` +// (e.g. "report"). An empty or unknown name falls back to DefaultTemplateName. +// The set includes every embedded partial so `$..()$` includes resolve; handlers +// may overlay .zddc.d/templates/ overrides onto the returned Files map. +func DefaultTemplateSet(name string) TemplateSet { + files := embeddedTemplateFiles() + primary := name + ".html" + if name == "" || files[primary] == nil { + primary = DefaultTemplateName + ".html" + } + return TemplateSet{Name: primary, Files: files} +} + // Default binary names. The runtime image installs WRAPPER scripts at // /usr/local/bin/pandoc and /usr/local/bin/chromium-browser (shadowing // the real binaries in /usr/bin/) so these names resolve through the @@ -146,23 +168,27 @@ func ToDocx(ctx context.Context, source []byte, m Metadata) ([]byte, error) { return r.Run(ctx, currentPandocBinary(), source, "", cmd) } -// ToHTML renders source markdown to standalone HTML using -// viewer-template.html. Embeds CSS + images via --embed-resources. -// Template + custom.css live in a per-call scratch dir; the host -// path is passed via ZDDC_SCRATCH so the wrapper bind-mounts it -// into the sandbox at the same path. -func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) { +// ToHTML renders source markdown to standalone HTML using the doctype +// template in ts. Embeds CSS + images via --embed-resources. The +// template + its partials live in a per-call scratch dir; the host path +// is passed via ZDDC_SCRATCH so the wrapper bind-mounts it into the +// sandbox at the same path. A zero-value ts falls back to the embedded +// default template. +func ToHTML(ctx context.Context, source []byte, m Metadata, ts TemplateSet) ([]byte, error) { r := currentRunner() if r == nil { return nil, ErrUnavailable } - scratch, err := writeAssetsToScratch(currentScratchDir()) + if ts.Name == "" || len(ts.Files) == 0 { + ts = DefaultTemplateSet(DefaultTemplateName) + } + scratch, err := writeTemplateSetToScratch(currentScratchDir(), ts) if err != nil { return nil, fmt.Errorf("scratch: %w", err) } defer os.RemoveAll(scratch) - tplPath := filepath.Join(scratch, "viewer-template.html") + tplPath := filepath.Join(scratch, ts.Name) cmd := []string{ "--from=markdown+yaml_metadata_block", "--to=html5", @@ -182,18 +208,18 @@ func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) { } // ToPDF renders source markdown to PDF in two stages: pandoc -// produces HTML using viewer-template.html (stage 1), then headless -// chromium prints that HTML to PDF (stage 2). The two-stage choice -// preserves the print-media CSS already authored in viewer- -// template.html — pandoc's native --pdf-engine path uses LaTeX -// which would bypass it entirely. +// produces HTML using the doctype template in ts (stage 1), then +// headless chromium prints that HTML to PDF (stage 2). The two-stage +// choice preserves the print-media CSS authored in the templates — +// pandoc's native --pdf-engine path uses LaTeX which would bypass it +// entirely. // // Both stages share a single per-call scratch dir: pandoc writes // `in.html` and chromium reads it, then chromium writes `out.pdf` // which the host reads back. The wrapper bind-mounts the scratch // dir read-write into the sandbox at the same path. -func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) { - html, err := ToHTML(ctx, source, m) +func ToPDF(ctx context.Context, source []byte, m Metadata, ts TemplateSet) ([]byte, error) { + html, err := ToHTML(ctx, source, m, ts) if err != nil { return nil, err } diff --git a/zddc/internal/convert/convert_test.go b/zddc/internal/convert/convert_test.go index 30b6d17..93e81f3 100644 --- a/zddc/internal/convert/convert_test.go +++ b/zddc/internal/convert/convert_test.go @@ -86,7 +86,7 @@ func TestToHTML_UsesTemplateFromScratchDir(t *testing.T) { t.Cleanup(func() { InstallRunner(nil) }) SetBinaries("pandoc", "chromium-browser") - _, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"}) + _, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"}, TemplateSet{}) if err != nil { t.Fatalf("ToHTML: %v", err) } @@ -101,7 +101,7 @@ func TestToHTML_UsesTemplateFromScratchDir(t *testing.T) { if scratch == "" { t.Fatalf("ToHTML must pass a scratch dir to the runner") } - wantTpl := "--template=" + scratch + "/viewer-template.html" + wantTpl := "--template=" + scratch + "/report.html" if !contains(call, wantTpl) { t.Errorf("template flag missing/wrong; want %q in %v", wantTpl, call) } @@ -115,7 +115,7 @@ func TestToHTML_NoTOCSuppressesTOC(t *testing.T) { InstallRunner(f) t.Cleanup(func() { InstallRunner(nil) }) - _, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true}) + _, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true}, TemplateSet{}) _, call := f.lastCall() if contains(call, "--toc") { t.Errorf("TOC should be suppressed when NoTOC=true: %v", call) @@ -170,7 +170,7 @@ func TestScratchDir_UsedByToHTML(t *testing.T) { scratchRoot := t.TempDir() SetScratchDir(scratchRoot) - _, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{}) + _, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{}, TemplateSet{}) if err != nil { t.Fatalf("ToHTML: %v", err) } @@ -199,7 +199,7 @@ func TestToPDF_TwoStagePipeline(t *testing.T) { t.Cleanup(func() { InstallRunner(nil) }) SetBinaries("pandoc", "chromium-browser") - _, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{}) + _, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{}, TemplateSet{}) // PDF read-back will fail (fake runner didn't write the file) — // that's expected for this test which only inspects the call shape. if err == nil { diff --git a/zddc/internal/convert/custom.css b/zddc/internal/convert/custom.css deleted file mode 100644 index a391f6a..0000000 --- a/zddc/internal/convert/custom.css +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Legal-style heading numbering for ZDDC documents - * Adds hierarchical numbering like 1, 1.1, 1.1.1, etc. - */ - -/* Reset counters at document level */ -.document-content { - counter-reset: h1-counter; -} - -/* H1 counters */ -h1 { - counter-reset: h2-counter h3-counter h4-counter h5-counter h6-counter; - counter-increment: h1-counter; -} - -h1::before { - content: counter(h1-counter) ". "; - font-weight: bold; - color: var(--primary-color); -} - -/* H2 counters */ -h2 { - counter-reset: h3-counter h4-counter h5-counter h6-counter; - counter-increment: h2-counter; -} - -h2::before { - content: counter(h1-counter) "." counter(h2-counter) " "; - font-weight: bold; - color: var(--primary-color); -} - -/* H3 counters */ -h3 { - counter-reset: h4-counter h5-counter h6-counter; - counter-increment: h3-counter; -} - -h3::before { - content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) " "; - font-weight: bold; - color: var(--primary-color); -} - -/* H4 counters */ -h4 { - counter-reset: h5-counter h6-counter; - counter-increment: h4-counter; -} - -h4::before { - content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) " "; - font-weight: bold; - color: var(--primary-color); -} - -/* H5 counters */ -h5 { - counter-reset: h6-counter; - counter-increment: h5-counter; -} - -h5::before { - content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) " "; - font-weight: bold; - color: var(--primary-color); -} - -/* H6 counters */ -h6 { - counter-increment: h6-counter; -} - -h6::before { - content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) "." counter(h6-counter) " "; - font-weight: bold; - color: var(--primary-color); -} - -/* TOC numbering to match document headings */ -.toc { - counter-reset: toc-h1; -} - -.toc ul { - list-style: none; -} - -.toc > ul > li { - counter-increment: toc-h1; - counter-reset: toc-h2 toc-h3 toc-h4 toc-h5 toc-h6; -} - -.toc > ul > li > a::before { - content: counter(toc-h1) ". "; - font-weight: bold; - color: var(--primary-color); - margin-right: 0.25em; -} - -.toc > ul > li > ul > li { - counter-increment: toc-h2; - counter-reset: toc-h3 toc-h4 toc-h5 toc-h6; -} - -.toc > ul > li > ul > li > a::before { - content: counter(toc-h1) "." counter(toc-h2) " "; - font-weight: bold; - color: var(--primary-color); - margin-right: 0.25em; -} - -.toc > ul > li > ul > li > ul > li { - counter-increment: toc-h3; - counter-reset: toc-h4 toc-h5 toc-h6; -} - -.toc > ul > li > ul > li > ul > li > a::before { - content: counter(toc-h1) "." counter(toc-h2) "." counter(toc-h3) " "; - font-weight: bold; - color: var(--primary-color); - margin-right: 0.25em; -} - -/* Optional: Add some spacing after the numbers */ -h1::before, h2::before, h3::before, h4::before, h5::before, h6::before { - margin-right: 0.5em; -} - -/* Print-specific adjustments */ -@media print { - h1::before, h2::before, h3::before, h4::before, h5::before, h6::before { - color: #000 !important; /* Ensure numbers print in black */ - } -} - -/* Optional: Style adjustments for better visual hierarchy */ -h1 { - border-bottom: 2px solid var(--primary-color); - padding-bottom: 0.3em; - margin-top: 1em; -} - -/* Reduce margin for first heading */ -h1:first-of-type { - margin-top: 0.5em; -} - -h2 { - border-bottom: 1px solid var(--border-color); - padding-bottom: 0.2em; - margin-top: 1.5em; -} - -h3 { - margin-top: 1.2em; -} - -h4, h5, h6 { - margin-top: 1em; -} diff --git a/zddc/internal/convert/embed.go b/zddc/internal/convert/embed.go index 511e820..2a566dd 100644 --- a/zddc/internal/convert/embed.go +++ b/zddc/internal/convert/embed.go @@ -1,19 +1,80 @@ package convert -import _ "embed" +import ( + "embed" + "io/fs" + "path" + "sort" +) -// Pandoc HTML template and its companion stylesheet, copied verbatim from -// /pandoc/viewer-template.html and /pandoc/custom.css. The runner writes -// these to a host scratch dir on each conversion and bind-mounts them -// read-only into the container so pandoc can `--template` against them. +// Default pandoc HTML templates, mirrored verbatim from /pandoc/templates/ by +// the top-level ./build (shared/build-lib.sh: sync_pandoc_templates). The runner +// writes the chosen template + its partials to a host scratch dir on each HTML +// conversion and bind-mounts them into the sandbox so pandoc can `--template` +// against them. // -// Refresh: when /pandoc/viewer-template.html changes, copy the new bytes -// here. There's no symlink because go:embed paths must resolve under the -// containing module — and we want the binary to ship the bytes verbatim, -// not depend on the source tree at runtime. +// pandoc/templates/ is the single source of truth; this directory is a build +// artifact kept in sync and guarded by TestEmbeddedTemplatesMatchSource. There's +// no symlink because go:embed paths must resolve under the containing module, and +// we want the binary to ship the bytes verbatim, not depend on the source tree at +// runtime. +// +// The set holds named doctype templates (report.html, letter.html, +// specification.html) plus the shared partials they include (_head.html, +// _doc.html, _scripts.html). A document picks one via its `template:` front +// matter; operators override individual files through the .zddc.d/templates/ +// cascade (see internal/handler). -//go:embed viewer-template.html -var viewerTemplate []byte +// `all:` is required so the `_`-prefixed partials (_head.html, _doc.html, +// _scripts.html) are embedded — a bare `//go:embed templates` excludes names +// beginning with `_` or `.`. +// +//go:embed all:templates +var templatesFS embed.FS -//go:embed custom.css -var customCSS []byte +// DefaultTemplateName is used when a document declares no `template:` field or +// names one that doesn't resolve. +const DefaultTemplateName = "report" + +// embeddedTemplate returns the bytes of a baked-in template/partial by base file +// name (e.g. "report.html", "_head.html"), or nil if there is no such default. +func embeddedTemplate(name string) []byte { + b, err := templatesFS.ReadFile(path.Join("templates", name)) + if err != nil { + return nil + } + return b +} + +// embeddedTemplateFiles returns all baked-in template/partial files keyed by +// base name. The returned map is a fresh copy the caller may mutate (e.g. to +// overlay .zddc.d/templates overrides). +func embeddedTemplateFiles() map[string][]byte { + out := make(map[string][]byte) + entries, _ := fs.ReadDir(templatesFS, "templates") + for _, e := range entries { + if e.IsDir() { + continue + } + if b := embeddedTemplate(e.Name()); b != nil { + out[e.Name()] = b + } + } + return out +} + +// EmbeddedTemplateNames lists the baked-in doctype template names (no extension, +// partials excluded — i.e. the names a `template:` field may select), sorted. +func EmbeddedTemplateNames() []string { + var names []string + entries, _ := fs.ReadDir(templatesFS, "templates") + for _, e := range entries { + n := e.Name() + if e.IsDir() || n == "" || n[0] == '_' || path.Ext(n) != ".html" { + continue + } + names = append(names, n[:len(n)-len(".html")]) + } + sort.Strings(names) + return names +} diff --git a/zddc/internal/convert/runner.go b/zddc/internal/convert/runner.go index 5dd3d26..c378e67 100644 --- a/zddc/internal/convert/runner.go +++ b/zddc/internal/convert/runner.go @@ -274,29 +274,27 @@ func (r *ringWriter) String() string { return string(r.buf) } -// writeAssetsToScratch materialises the embedded viewer-template.html -// and custom.css into a fresh scratch dir and returns the host path. -// Caller is responsible for os.RemoveAll(dir) when done. Used by -// ToHTML which needs the template visible inside the sandbox. +// writeTemplateSetToScratch materialises a TemplateSet (the chosen doctype +// template plus its partials) into a fresh scratch dir and returns the host +// path. Caller is responsible for os.RemoveAll(dir) when done. Used by ToHTML, +// which needs the template + partials visible inside the sandbox (pandoc +// resolves `$partial()$` includes from the template's own directory). // -// scratchRoot controls where the temp dir lands. Empty means -// "use $TMPDIR". +// scratchRoot controls where the temp dir lands. Empty means "use $TMPDIR". // -// Files are written world-readable so the binary's default user can -// read them through the wrapper's bind mount regardless of the -// host's umask. -func writeAssetsToScratch(scratchRoot string) (string, error) { +// Files are written world-readable so the binary's default user can read them +// through the wrapper's bind mount regardless of the host's umask. File names +// are base names only (no path separators) — they all land flat in the dir. +func writeTemplateSetToScratch(scratchRoot string, ts TemplateSet) (string, error) { dir, err := os.MkdirTemp(scratchRoot, "zddc-convert-") if err != nil { return "", fmt.Errorf("scratch dir: %w", err) } - if err := os.WriteFile(filepath.Join(dir, "viewer-template.html"), viewerTemplate, 0o644); err != nil { - os.RemoveAll(dir) - return "", fmt.Errorf("write template: %w", err) - } - if err := os.WriteFile(filepath.Join(dir, "custom.css"), customCSS, 0o644); err != nil { - os.RemoveAll(dir) - return "", fmt.Errorf("write css: %w", err) + for name, b := range ts.Files { + if err := os.WriteFile(filepath.Join(dir, filepath.Base(name)), b, 0o644); err != nil { + os.RemoveAll(dir) + return "", fmt.Errorf("write template %q: %w", name, err) + } } if err := chmodTree(dir, 0o755, 0o644); err != nil { os.RemoveAll(dir) diff --git a/zddc/internal/convert/templates/_doc.html b/zddc/internal/convert/templates/_doc.html new file mode 100644 index 0000000..b555e16 --- /dev/null +++ b/zddc/internal/convert/templates/_doc.html @@ -0,0 +1,112 @@ +
+ $if(toc)$ + + + $endif$ + +
+
+ +
+ $if(toc)$ +
+ +
+ $endif$ +
+ $if(client)$$if(project)$ +
+ $client$ - $project$$if(project_number)$ ($project_number$)$endif$ +
+ $endif$$endif$ + $if(title)$ +
$title$
+ $endif$ +
+ $if(tracking_number)$$tracking_number$$endif$ + $if(revision)$Revision: $revision$$endif$ + $if(status)$Status: $status$$endif$ + $if(revision_comparison)$$revision_comparison$$endif$ +
+ $if(is_draft)$ + $if(generation_time)$ +
+ [DRAFT Generated at $generation_time$] +
+ $endif$ + $endif$ +
+
+ + +
+
+
+ + + + + + + + + +
+ $body$ +
+
+
+
diff --git a/zddc/internal/convert/templates/_head.html b/zddc/internal/convert/templates/_head.html new file mode 100644 index 0000000..bb03731 --- /dev/null +++ b/zddc/internal/convert/templates/_head.html @@ -0,0 +1,778 @@ + + + + $if(title)$$title$$else$Document$endif$ + + + $if(revision)$$endif$ + $if(generation_time)$$endif$ + + + + + $for(header-includes)$ + $header-includes$ + $endfor$ + diff --git a/zddc/internal/convert/templates/_scripts.html b/zddc/internal/convert/templates/_scripts.html new file mode 100644 index 0000000..9adb801 --- /dev/null +++ b/zddc/internal/convert/templates/_scripts.html @@ -0,0 +1,259 @@ + + diff --git a/zddc/internal/convert/templates/letter.html b/zddc/internal/convert/templates/letter.html new file mode 100644 index 0000000..310e15b --- /dev/null +++ b/zddc/internal/convert/templates/letter.html @@ -0,0 +1,56 @@ + + +$_head()$ + + + +
+
+ +
+
+ $if(client)$$if(project)$ +
+ $client$ - $project$$if(project_number)$ ($project_number$)$endif$ +
+ $endif$$endif$ + $if(title)$ +
$title$
+ $endif$ +
+ $if(date)$$date$$endif$ + $if(tracking_number)$$tracking_number$$endif$ + $if(revision)$Revision: $revision$$endif$ + $if(status)$Status: $status$$endif$ +
+
+
+ + + + + + + +
+ $body$ +
+
+
+$_scripts()$ + + diff --git a/zddc/internal/convert/templates/report.html b/zddc/internal/convert/templates/report.html new file mode 100644 index 0000000..1fbbcdf --- /dev/null +++ b/zddc/internal/convert/templates/report.html @@ -0,0 +1,9 @@ + + +$_head()$ + + +$_doc()$ +$_scripts()$ + + diff --git a/zddc/internal/convert/templates/specification.html b/zddc/internal/convert/templates/specification.html new file mode 100644 index 0000000..3e93e4a --- /dev/null +++ b/zddc/internal/convert/templates/specification.html @@ -0,0 +1,9 @@ + + +$_head()$ + + +$_doc()$ +$_scripts()$ + + diff --git a/zddc/internal/convert/templatesync_test.go b/zddc/internal/convert/templatesync_test.go new file mode 100644 index 0000000..1fb4f7b --- /dev/null +++ b/zddc/internal/convert/templatesync_test.go @@ -0,0 +1,71 @@ +package convert + +import ( + "os" + "path/filepath" + "testing" +) + +// canonicalTemplatesDir is the single source of truth for the conversion +// templates: /pandoc/templates/, relative to this package +// (zddc/internal/convert → ../../../pandoc/templates). +const canonicalTemplatesDir = "../../../pandoc/templates" + +// TestEmbeddedTemplatesMatchSource guards against drift between the embedded +// templates/ (a build artifact, synced by shared/build-lib.sh: +// sync_pandoc_templates) and the canonical pandoc/templates/. If this fails, +// re-run ./build (or copy pandoc/templates/* into this package's templates/). +func TestEmbeddedTemplatesMatchSource(t *testing.T) { + srcEntries, err := os.ReadDir(canonicalTemplatesDir) + if err != nil { + t.Fatalf("read canonical templates dir %q: %v", canonicalTemplatesDir, err) + } + + embedded := embeddedTemplateFiles() + srcCount := 0 + for _, e := range srcEntries { + if e.IsDir() || filepath.Ext(e.Name()) != ".html" { + continue + } + srcCount++ + want, err := os.ReadFile(filepath.Join(canonicalTemplatesDir, e.Name())) + if err != nil { + t.Fatalf("read %s: %v", e.Name(), err) + } + got, ok := embedded[e.Name()] + if !ok { + t.Errorf("embedded templates/ is missing %s (run ./build to sync)", e.Name()) + continue + } + if string(got) != string(want) { + t.Errorf("embedded %s differs from pandoc/templates/%s (run ./build to sync)", e.Name(), e.Name()) + } + } + + if srcCount != len(embedded) { + t.Errorf("template count mismatch: canonical=%d embedded=%d (stale file in one tree?)", srcCount, len(embedded)) + } +} + +// TestDefaultTemplateSet checks the doctype fallback + that partials ride along. +func TestDefaultTemplateSet(t *testing.T) { + for _, name := range EmbeddedTemplateNames() { + ts := DefaultTemplateSet(name) + if ts.Name != name+".html" { + t.Errorf("DefaultTemplateSet(%q).Name = %q, want %q.html", name, ts.Name, name) + } + if ts.Files[ts.Name] == nil { + t.Errorf("DefaultTemplateSet(%q) Files missing primary %q", name, ts.Name) + } + if ts.Files["_head.html"] == nil { + t.Errorf("DefaultTemplateSet(%q) Files missing _head.html partial", name) + } + } + // Unknown / empty fall back to the default doctype. + if ts := DefaultTemplateSet("nope"); ts.Name != DefaultTemplateName+".html" { + t.Errorf("unknown doctype fell back to %q, want %q.html", ts.Name, DefaultTemplateName) + } + if ts := DefaultTemplateSet(""); ts.Name != DefaultTemplateName+".html" { + t.Errorf("empty doctype fell back to %q, want %q.html", ts.Name, DefaultTemplateName) + } +} diff --git a/zddc/internal/convert/viewer-template.html b/zddc/internal/convert/viewer-template.html deleted file mode 100644 index f64acd8..0000000 --- a/zddc/internal/convert/viewer-template.html +++ /dev/null @@ -1,1261 +0,0 @@ - - - - - - $if(title)$$title$$else$Document$endif$ - - - $if(revision)$$endif$ - $if(generation_time)$$endif$ - - - - - $for(header-includes)$ - $header-includes$ - $endfor$ - - - -
- $if(toc)$ - - - $endif$ - -
-
- -
- $if(toc)$ -
- -
- $endif$ -
- $if(client)$$if(project)$ -
- $client$ - $project$$if(project_number)$ ($project_number$)$endif$ -
- $endif$$endif$ - $if(title)$ -
$title$
- $endif$ -
- $if(tracking_number)$$tracking_number$$endif$ - $if(revision)$Revision: $revision$$endif$ - $if(status)$Status: $status$$endif$ - $if(revision_comparison)$$revision_comparison$$endif$ -
- $if(is_draft)$ - $if(generation_time)$ -
- [DRAFT Generated at $generation_time$] -
- $endif$ - $endif$ -
-
- - -
-
-
- - - - - - - - - -
- $body$ -
-
-
-
- - - - - diff --git a/zddc/internal/handler/converthandler.go b/zddc/internal/handler/converthandler.go index 66ea426..a2d648f 100644 --- a/zddc/internal/handler/converthandler.go +++ b/zddc/internal/handler/converthandler.go @@ -135,7 +135,7 @@ func ServeConverted(cfg config.Config, w http.ResponseWriter, r *http.Request, s // Slow path: convert, cache, serve. Singleflight collapses // concurrent requests for the same target. _, err = convertSF.Do(cacheAbs, func() (any, error) { - return nil, buildAndStore(r.Context(), srcAbs, srcInfo, cacheDir, cacheAbs, format, base, chain) + return nil, buildAndStore(r.Context(), cfg.Root, srcAbs, srcInfo, cacheDir, cacheAbs, format, base, chain) }) if err != nil { mapConvertError(w, err, format) @@ -148,7 +148,7 @@ func ServeConverted(cfg config.Config, w http.ResponseWriter, r *http.Request, s // buildAndStore reads the source, runs the conversion, atomically // writes the result, and syncs the cached mtime to the source mtime. // Returns the cached file's absolute path on success. -func buildAndStore(ctx context.Context, srcAbs string, srcInfo os.FileInfo, cacheDir, cacheAbs, format, base string, chain zddc.PolicyChain) error { +func buildAndStore(ctx context.Context, fsRoot, srcAbs string, srcInfo os.FileInfo, cacheDir, cacheAbs, format, base string, chain zddc.PolicyChain) error { source, err := os.ReadFile(srcAbs) if err != nil { return fmt.Errorf("read source: %w", err) @@ -164,9 +164,9 @@ func buildAndStore(ctx context.Context, srcAbs string, srcInfo os.FileInfo, cach case "docx": out, err = convert.ToDocx(ctx, source, meta) case "html": - out, err = convert.ToHTML(ctx, source, meta) + out, err = convert.ToHTML(ctx, source, meta, resolveTemplateSet(fsRoot, filepath.Dir(srcAbs), source)) case "pdf": - out, err = convert.ToPDF(ctx, source, meta) + out, err = convert.ToPDF(ctx, source, meta, resolveTemplateSet(fsRoot, filepath.Dir(srcAbs), source)) default: return fmt.Errorf("unsupported format %q", format) } diff --git a/zddc/internal/handler/converttemplate.go b/zddc/internal/handler/converttemplate.go new file mode 100644 index 0000000..22f9bef --- /dev/null +++ b/zddc/internal/handler/converttemplate.go @@ -0,0 +1,143 @@ +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 +} diff --git a/zddc/internal/handler/converttemplate_test.go b/zddc/internal/handler/converttemplate_test.go new file mode 100644 index 0000000..9e59316 --- /dev/null +++ b/zddc/internal/handler/converttemplate_test.go @@ -0,0 +1,95 @@ +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 /.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") + } +}