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    /// Font faces to register with the typst compile, as manifest keys into
89    /// `assets`, in declaration order (typst tie-breaks by it). The template
90    /// path's counterpart to [`RenderOptions::layout_fonts`]: without it a
91    /// brand's non-system faces silently fall back to whatever the server
92    /// host has installed.
93    #[serde(default)]
94    pub fonts: Vec<String>,
95}
96
97/// Output format for a template render. `Html` needs a server built with the
98/// `typst-html` feature; one built without it answers
99/// [`crate::ErrorCode::UnsupportedTarget`].
100#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "kebab-case")]
102pub enum TemplateFormat {
103    #[default]
104    Pdf,
105    Html,
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::digest::Digest;
112
113    fn manifest() -> AssetManifest {
114        AssetManifest::from_iter([("img/logo.png".to_string(), Digest::from_sha256([7; 32]))])
115    }
116
117    #[test]
118    fn markdown_request_round_trips_through_json() {
119        let req = RenderMarkdownRequest {
120            markdown: "# Hi".to_string(),
121            meta: DocMeta {
122                title: Some("Hi".to_string()),
123                ..DocMeta::default()
124            },
125            brand: Some(BrandSpec::default()),
126            target: Target::HtmlReveal,
127            assets: manifest(),
128            options: RenderOptions {
129                html_image_tags: true,
130                mermaid: true,
131                toc_depth: Some(3),
132                layout_assets: vec!["img/logo.png".to_string()],
133                layout_fonts: vec!["fonts/Brand-Regular.ttf".to_string()],
134            },
135        };
136
137        let json = serde_json::to_string(&req).unwrap();
138        assert_eq!(
139            serde_json::from_str::<RenderMarkdownRequest>(&json).unwrap(),
140            req
141        );
142    }
143
144    #[test]
145    fn markdown_request_needs_only_markdown_and_target() {
146        let req: RenderMarkdownRequest =
147            serde_json::from_str(r##"{"markdown":"# Hi","target":"pdf-presentation"}"##)
148                .expect("every other field defaults");
149
150        assert_eq!(req.target, Target::PdfPresentation);
151        assert_eq!(req.meta, DocMeta::default());
152        assert_eq!(req.brand, None);
153        assert!(req.assets.is_empty());
154        assert_eq!(req.options, RenderOptions::default());
155    }
156
157    #[test]
158    fn template_request_round_trips_through_json() {
159        let req = RenderTemplateRequest {
160            template_source: "= Invoice #json(\"/data.json\").number".to_string(),
161            data: r#"{"number":"INV-042"}"#.to_string(),
162            meta: DocMeta::default(),
163            brand: None,
164            format: TemplateFormat::Html,
165            assets: manifest(),
166            fonts: vec!["fonts/Brand-Regular.ttf".to_string()],
167        };
168
169        let json = serde_json::to_string(&req).unwrap();
170        assert_eq!(
171            serde_json::from_str::<RenderTemplateRequest>(&json).unwrap(),
172            req
173        );
174    }
175
176    #[test]
177    fn template_format_defaults_to_pdf() {
178        let req: RenderTemplateRequest =
179            serde_json::from_str(r#"{"template_source":"= Hi","data":"{}"}"#)
180                .expect("format is optional");
181
182        assert_eq!(req.format, TemplateFormat::Pdf);
183        assert_eq!(serde_json::to_string(&req.format).unwrap(), "\"pdf\"");
184        assert!(
185            req.fonts.is_empty(),
186            "fonts default to none (0.4.1 wire compat)"
187        );
188    }
189}