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