Skip to main content

secunit_core/wisp/
typst_emit.rs

1//! Compose the top-level Typst document (`main.typ`) from a [`WispDoc`] and
2//! the operator's partials.
3//!
4//! The emitted document `#import`s the partials by filename, so it must be
5//! compiled with the template directory as the import root (the Typst backend
6//! arranges this). It builds the `ctx` dictionary the partials read, wires the
7//! running header/footer and page numbering, renders the cover and table of
8//! contents, then the converted markdown body.
9
10use super::{doc::WispDoc, markdown, Status};
11
12/// Options that affect emission (subset of `ExportOptions`).
13#[derive(Debug, Clone, Copy)]
14pub struct EmitOptions {
15    pub toc: bool,
16    pub page_numbers: bool,
17}
18
19/// Render the full `main.typ` source.
20pub fn emit(doc: &WispDoc, opts: EmitOptions) -> String {
21    let m = &doc.meta;
22    let mut s = String::new();
23
24    s.push_str("// Generated by `secunit wisp export`. Do not edit by hand —\n");
25    s.push_str("// edit the partials in this directory instead, then re-export.\n\n");
26
27    s.push_str("#import \"theme.typ\": apply-theme, wisp-theme\n");
28    s.push_str("#import \"header.typ\": wisp-header\n");
29    s.push_str("#import \"footer.typ\": wisp-footer\n");
30    s.push_str("#import \"cover.typ\": wisp-cover\n");
31    s.push_str("#import \"toc.typ\": wisp-toc\n\n");
32
33    // The document context the partials read.
34    s.push_str("#let ctx = (\n");
35    push_field(&mut s, "org", &m.org);
36    push_field(&mut s, "title", &m.title);
37    push_field(&mut s, "version", &m.version);
38    push_field(&mut s, "effective_date", &m.effective_date);
39    push_field(&mut s, "classification", &m.classification);
40    push_field(&mut s, "status", &m.status.to_string());
41    push_field(&mut s, "logo", &m.logo);
42    push_field(&mut s, "commit", &m.commit);
43    push_field(&mut s, "content_hash", &m.content_hash);
44    push_field(&mut s, "generated_at", &m.generated_at);
45    s.push_str(")\n\n");
46
47    s.push_str("#set document(title: ctx.title, author: ctx.org)\n");
48
49    // Page geometry + running header/footer. Page numbering is left to the
50    // footer's "Page X of Y" when enabled.
51    s.push_str("#set page(\n");
52    s.push_str("  paper: wisp-theme.paper,\n");
53    s.push_str("  margin: wisp-theme.margin,\n");
54    s.push_str("  header: wisp-header(ctx),\n");
55    if opts.page_numbers {
56        s.push_str("  footer: wisp-footer(ctx),\n");
57    }
58    if m.status == Status::Draft {
59        // Diagonal DRAFT watermark behind the content.
60        s.push_str("  background: rotate(-30deg, text(120pt, fill: rgb(\"#0000000d\"))[DRAFT]),\n");
61    }
62    s.push_str("  numbering: none,\n");
63    s.push_str(")\n");
64    s.push_str("#show: apply-theme\n\n");
65
66    // Cover (manages its own bare page + trailing pagebreak).
67    s.push_str("#wisp-cover(ctx)\n\n");
68
69    if opts.toc {
70        s.push_str("#wisp-toc(ctx)\n\n");
71    }
72
73    s.push_str(&markdown::to_typst(&doc.body_markdown, &doc.sections));
74    s.push('\n');
75
76    s
77}
78
79/// Append `  name: "value",` with the value escaped for a Typst string literal.
80fn push_field(s: &mut String, name: &str, value: &str) {
81    s.push_str("  ");
82    s.push_str(name);
83    s.push_str(": \"");
84    s.push_str(&markdown::escape_typst_string(value));
85    s.push_str("\",\n");
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::wisp::doc::WispMeta;
92
93    fn doc() -> WispDoc {
94        WispDoc {
95            meta: WispMeta {
96                org: "Battle \"Creek\"".into(),
97                title: "WISP".into(),
98                version: "1.0.0".into(),
99                effective_date: "2026-05-29".into(),
100                classification: "Confidential".into(),
101                status: Status::Draft,
102                logo: "logo.svg".into(),
103                commit: "abc123".into(),
104                content_hash: "sha256:deadbeef".into(),
105                generated_at: "2026-05-29".into(),
106            },
107            body_markdown: "# Intro\n\nHello.\n".into(),
108            sections: vec!["intro.md".into()],
109        }
110    }
111
112    #[test]
113    fn emits_imports_ctx_and_body() {
114        let out = emit(
115            &doc(),
116            EmitOptions {
117                toc: true,
118                page_numbers: true,
119            },
120        );
121        assert!(out.contains("#import \"theme.typ\""));
122        assert!(out.contains("#wisp-cover(ctx)"));
123        assert!(out.contains("#wisp-toc(ctx)"));
124        assert!(out.contains("= Intro"));
125        // Quotes in values are escaped.
126        assert!(out.contains("Battle \\\"Creek\\\""));
127        // Draft watermark present.
128        assert!(out.contains("DRAFT"));
129    }
130
131    #[test]
132    fn omits_toc_and_footer_when_disabled() {
133        let out = emit(
134            &doc(),
135            EmitOptions {
136                toc: false,
137                page_numbers: false,
138            },
139        );
140        assert!(!out.contains("#wisp-toc(ctx)"));
141        assert!(!out.contains("footer: wisp-footer"));
142    }
143}