secunit_core/wisp/
typst_emit.rs1use super::{doc::WispDoc, markdown, Status};
11
12#[derive(Debug, Clone, Copy)]
14pub struct EmitOptions {
15 pub toc: bool,
16 pub page_numbers: bool,
17}
18
19pub 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 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 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 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 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
79fn 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 assert!(out.contains("Battle \\\"Creek\\\""));
127 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}