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 /// `` 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}