Skip to main content

mdcast_api/wire/
render.rs

1//! Render request bodies — one per entry point mdcast exposes: the markdown
2//! pipeline (`mdcast render`) and the data-driven typst template pipeline
3//! (`mdcast render-template`).
4
5use serde::{Deserialize, Serialize};
6
7use crate::brand::BrandSpec;
8use crate::doc::{DocMeta, Target};
9use crate::manifest::AssetManifest;
10use crate::wire::generated::GeneratedAsset;
11
12/// Render a markdown document to `target`. Mirrors what the CLI's `render`
13/// subcommand builds before handing a `ResolvedDoc` to a backend: the body
14/// after frontmatter extraction, the metadata that extraction produced, the
15/// brand spec, and the flags that change the pipeline.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct RenderMarkdownRequest {
18    /// Markdown body. Frontmatter may still be present — the server runs the
19    /// same `frontmatter::extract` the CLI does, and anything it finds wins
20    /// over an empty `meta`.
21    pub markdown: String,
22    #[serde(default)]
23    pub meta: DocMeta,
24    /// `None` renders with `BrandSpec::default()` — the same as omitting
25    /// `--brand`.
26    #[serde(default)]
27    pub brand: Option<BrandSpec>,
28    pub target: Target,
29    /// Assets this render may reach through the `AssetProvider`: images the
30    /// markdown references, layout/font keys named in `options`, an override
31    /// for a built-in template. Keys absent here resolve against the
32    /// server's embedded catalog as usual.
33    #[serde(default)]
34    pub assets: AssetManifest,
35    #[serde(default)]
36    pub options: RenderOptions,
37}
38
39/// The pipeline flags `mdcast render` exposes, minus the ones that are
40/// meaningless over HTTP (`--out`, `--assets DIR`).
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
42pub struct RenderOptions {
43    /// `--html-image-tags`: rewrite `<img src="X">` / `<image path="X">` to
44    /// `![](X)` before splitting.
45    #[serde(default)]
46    pub html_image_tags: bool,
47    /// `--mermaid`: render ```` ```mermaid ```` fences to SVG before
48    /// splitting.
49    #[serde(default)]
50    pub mermaid: bool,
51    /// `--qr`: render ```` ```qr ```` fences and `<qr>` tags to SVG QR codes
52    /// before splitting. Advertised as `qr` in
53    /// `CapabilitiesResponse::generators`; a server without it ignores the
54    /// flag.
55    #[serde(default)]
56    pub qr: bool,
57    /// `--chess`: render ```` ```fen ```` / ```` ```pgn ```` fences and
58    /// `<fen>` / `<pgn>` tags to SVG chess boards before splitting.
59    /// Advertised as `fen`/`pgn` in `CapabilitiesResponse::generators`; a
60    /// server without them ignores the flag and leaves the fences as code.
61    #[serde(default)]
62    pub chess: bool,
63    /// `--toc-depth N`: request a table of contents at heading depth 1-6.
64    #[serde(default)]
65    pub toc_depth: Option<u8>,
66    /// `--layout-asset KEY` (repeatable): `ResolvedDoc.assets` entries a
67    /// typst layout reaches via `asset-path`.
68    #[serde(default)]
69    pub layout_assets: Vec<String>,
70    /// `--layout-font KEY` (repeatable): `ResolvedDoc.fonts` entries to
71    /// register with the typst font book.
72    #[serde(default)]
73    pub layout_fonts: Vec<String>,
74}
75
76/// Render a user-supplied typst template over structured data — the
77/// `render-template` path (`TemplateDoc`), which bypasses `ResolvedDoc`
78/// entirely.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct RenderTemplateRequest {
81    /// Typst source of the main template. Inline source rather than
82    /// `TemplateDoc.template`'s provider key: the server has no view of the
83    /// caller's filesystem, and the template is the one file a caller always
84    /// authors itself.
85    pub template_source: String,
86    /// The JSON document the template reads with `json("/data.json")`,
87    /// carried verbatim as text. A string, not a parsed value, so this crate
88    /// needs no JSON implementation of its own — the bytes go straight into
89    /// the virtual `/data.json`.
90    pub data: String,
91    #[serde(default)]
92    pub meta: DocMeta,
93    #[serde(default)]
94    pub brand: Option<BrandSpec>,
95    #[serde(default)]
96    pub format: TemplateFormat,
97    /// Sibling files the template `#import`s or `#image`s, under the provider
98    /// keys it references them by.
99    #[serde(default)]
100    pub assets: AssetManifest,
101    /// Font faces to register with the typst compile, as manifest keys into
102    /// `assets`, in declaration order (typst tie-breaks by it). The template
103    /// path's counterpart to [`RenderOptions::layout_fonts`]: without it a
104    /// brand's non-system faces silently fall back to whatever the server
105    /// host has installed.
106    #[serde(default)]
107    pub fonts: Vec<String>,
108    /// Images the server generates and serves at their `path` alongside
109    /// `assets` — e.g. a payment QR code the template `#image`s, without the
110    /// caller needing a QR library.
111    #[serde(default)]
112    pub generated: Vec<GeneratedAsset>,
113}
114
115/// Output format for a template render. `Html` needs a server built with the
116/// `typst-html` feature; one built without it answers
117/// [`crate::ErrorCode::UnsupportedTarget`].
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "kebab-case")]
120pub enum TemplateFormat {
121    #[default]
122    Pdf,
123    Html,
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::digest::Digest;
130    use crate::wire::generated::{GeneratedSpec, QrEcLevel};
131
132    fn manifest() -> AssetManifest {
133        AssetManifest::from_iter([("img/logo.png".to_string(), Digest::from_sha256([7; 32]))])
134    }
135
136    #[test]
137    fn markdown_request_round_trips_through_json() {
138        let req = RenderMarkdownRequest {
139            markdown: "# Hi".to_string(),
140            meta: DocMeta {
141                title: Some("Hi".to_string()),
142                ..DocMeta::default()
143            },
144            brand: Some(BrandSpec::default()),
145            target: Target::HtmlReveal,
146            assets: manifest(),
147            options: RenderOptions {
148                html_image_tags: true,
149                mermaid: true,
150                qr: true,
151                chess: true,
152                toc_depth: Some(3),
153                layout_assets: vec!["img/logo.png".to_string()],
154                layout_fonts: vec!["fonts/Brand-Regular.ttf".to_string()],
155            },
156        };
157
158        let json = serde_json::to_string(&req).unwrap();
159        assert_eq!(
160            serde_json::from_str::<RenderMarkdownRequest>(&json).unwrap(),
161            req
162        );
163    }
164
165    #[test]
166    fn markdown_request_needs_only_markdown_and_target() {
167        let req: RenderMarkdownRequest =
168            serde_json::from_str(r##"{"markdown":"# Hi","target":"pdf-presentation"}"##)
169                .expect("every other field defaults");
170
171        assert_eq!(req.target, Target::PdfPresentation);
172        assert_eq!(req.meta, DocMeta::default());
173        assert_eq!(req.brand, None);
174        assert!(req.assets.is_empty());
175        assert_eq!(req.options, RenderOptions::default());
176    }
177
178    #[test]
179    fn template_request_round_trips_through_json() {
180        let req = RenderTemplateRequest {
181            template_source: "= Invoice #json(\"/data.json\").number".to_string(),
182            data: r#"{"number":"INV-042"}"#.to_string(),
183            meta: DocMeta::default(),
184            brand: None,
185            format: TemplateFormat::Html,
186            assets: manifest(),
187            fonts: vec!["fonts/Brand-Regular.ttf".to_string()],
188            generated: vec![GeneratedAsset {
189                path: "qr.svg".to_string(),
190                spec: GeneratedSpec::Qr {
191                    data: "SPD*1.0".to_string(),
192                    ec: QrEcLevel::M,
193                },
194            }],
195        };
196
197        let json = serde_json::to_string(&req).unwrap();
198        assert_eq!(
199            serde_json::from_str::<RenderTemplateRequest>(&json).unwrap(),
200            req
201        );
202    }
203
204    #[test]
205    fn template_format_defaults_to_pdf() {
206        let req: RenderTemplateRequest =
207            serde_json::from_str(r#"{"template_source":"= Hi","data":"{}"}"#)
208                .expect("format is optional");
209
210        assert_eq!(req.format, TemplateFormat::Pdf);
211        assert_eq!(serde_json::to_string(&req.format).unwrap(), "\"pdf\"");
212        assert!(
213            req.fonts.is_empty(),
214            "fonts default to none (0.4.1 wire compat)"
215        );
216        assert!(
217            req.generated.is_empty(),
218            "generated defaults to none (0.4.2 wire compat)"
219        );
220    }
221}