Single-binary self-hosted Markdown blog on Rust axum: no database, live search, Typst PDF export, and strong SEO.
axumblogdockermarkdownminijinjarustself-hostedtypstvite
1use std::fmt::Write as _;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use chrono::{Datelike, Local};
6use comrak::{
7 nodes::{AstNode, NodeValue, TableAlignment},
8 Arena, Options,
9};
10use typst::{
11 diag::{FileError, FileResult, SourceDiagnostic},
12 foundations::{Bytes, Datetime},
13 layout::PagedDocument,
14 syntax::{FileId, Source, VirtualPath},
15 text::{Font, FontBook},
16 utils::LazyHash,
17 Library, LibraryExt, World,
18};
19use typst_kit::fonts::{FontSearcher, FontSlot, Fonts};
20
21use crate::posts::Post;
22
23/// Pre-built renderer state. Fonts and the standard library are loaded once at
24/// startup and shared across renders.
25pub struct PdfRenderer {
26 library: Arc<LazyHash<Library>>,
27 book: Arc<LazyHash<FontBook>>,
28 fonts: Arc<Vec<FontSlot>>,
29 root: PathBuf,
30}
31
32impl PdfRenderer {
33 /// Discover system + embedded fonts and build the renderer. `root` is the
34 /// project root that absolute paths in the Typst source resolve against
35 /// (e.g. `image("/content/images/foo.webp")` → `<root>/content/images/foo.webp`).
36 pub fn new(root: PathBuf) -> Self {
37 let Fonts { book, fonts } = FontSearcher::new()
38 .include_system_fonts(true)
39 .search();
40 Self {
41 library: Arc::new(LazyHash::new(Library::default())),
42 book: Arc::new(LazyHash::new(book)),
43 fonts: Arc::new(fonts),
44 root,
45 }
46 }
47
48 /// Compile `source` (Typst markup) into a PDF.
49 pub fn render(&self, source: String) -> anyhow::Result<Vec<u8>> {
50 let main_id = FileId::new(None, VirtualPath::new("/main.typ"));
51 let main = Source::new(main_id, source);
52 let world = PdfWorld {
53 library: self.library.clone(),
54 book: self.book.clone(),
55 fonts: self.fonts.clone(),
56 root: self.root.clone(),
57 main,
58 };
59 let warned = typst::compile::<PagedDocument>(&world);
60 let document = warned
61 .output
62 .map_err(|errs| format_diagnostics("compile", &errs))?;
63 let bytes = typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default())
64 .map_err(|errs| format_diagnostics("pdf export", &errs))?;
65 Ok(bytes)
66 }
67}
68
69fn format_diagnostics(stage: &str, errs: &[SourceDiagnostic]) -> anyhow::Error {
70 let mut s = String::new();
71 for e in errs {
72 if !s.is_empty() {
73 s.push('\n');
74 }
75 s.push_str(&e.message);
76 for h in &e.hints {
77 s.push_str("\n hint: ");
78 s.push_str(h);
79 }
80 }
81 anyhow::anyhow!("typst {stage}: {s}")
82}
83
84struct PdfWorld {
85 library: Arc<LazyHash<Library>>,
86 book: Arc<LazyHash<FontBook>>,
87 fonts: Arc<Vec<FontSlot>>,
88 root: PathBuf,
89 main: Source,
90}
91
92impl World for PdfWorld {
93 fn library(&self) -> &LazyHash<Library> {
94 &self.library
95 }
96 fn book(&self) -> &LazyHash<FontBook> {
97 &self.book
98 }
99 fn main(&self) -> FileId {
100 self.main.id()
101 }
102 fn source(&self, id: FileId) -> FileResult<Source> {
103 if id == self.main.id() {
104 return Ok(self.main.clone());
105 }
106 let path = self.resolve(id)?;
107 let text =
108 std::fs::read_to_string(&path).map_err(|err| FileError::from_io(err, &path))?;
109 Ok(Source::new(id, text))
110 }
111 fn file(&self, id: FileId) -> FileResult<Bytes> {
112 let path = self.resolve(id)?;
113 let bytes = std::fs::read(&path).map_err(|err| FileError::from_io(err, &path))?;
114 Ok(Bytes::new(bytes))
115 }
116 fn font(&self, index: usize) -> Option<Font> {
117 self.fonts.get(index)?.get()
118 }
119 fn today(&self, _offset: Option<i64>) -> Option<Datetime> {
120 let now = Local::now();
121 Datetime::from_ymd(now.year(), now.month() as u8, now.day() as u8)
122 }
123}
124
125impl PdfWorld {
126 fn resolve(&self, id: FileId) -> FileResult<PathBuf> {
127 if id.package().is_some() {
128 return Err(FileError::Other(Some(
129 "remote packages not supported".into(),
130 )));
131 }
132 id.vpath()
133 .resolve(&self.root)
134 .ok_or(FileError::AccessDenied)
135 .and_then(|p| {
136 if path_within(&p, &self.root) {
137 Ok(p)
138 } else {
139 Err(FileError::AccessDenied)
140 }
141 })
142 }
143}
144
145fn path_within(path: &Path, root: &Path) -> bool {
146 let canon = match path.canonicalize() {
147 Ok(p) => p,
148 Err(_) => return false,
149 };
150 let canon_root = match root.canonicalize() {
151 Ok(p) => p,
152 Err(_) => return false,
153 };
154 canon.starts_with(canon_root)
155}
156
157/// Wrap a post's pre-rendered Typst body in the `blog_post.typ` template call.
158pub fn build_source(post: &Post) -> String {
159 let mut s = String::with_capacity(post.body_typst.len() + 512);
160 s.push_str("#import \"/templates/blog_post.typ\": render\n");
161 s.push_str("#render(\n");
162 writeln!(s, " title: \"{}\",", str_lit(&post.title)).ok();
163 writeln!(s, " date: \"{}\",", str_lit(&post.date)).ok();
164 writeln!(s, " read_time: {},", post.read_time).ok();
165 s.push_str(" tags: (");
166 for (i, t) in post.tags.iter().enumerate() {
167 if i > 0 {
168 s.push_str(", ");
169 }
170 write!(s, "\"{}\"", str_lit(t)).ok();
171 }
172 if post.tags.len() == 1 {
173 s.push(',');
174 }
175 s.push_str("),\n");
176 writeln!(s, " description: \"{}\",", str_lit(&post.description)).ok();
177 if post.cover_image.is_empty() {
178 s.push_str(" cover_image: none,\n");
179 } else {
180 writeln!(
181 s,
182 " cover_image: \"/content/images/{}\",",
183 str_lit(&post.cover_image)
184 )
185 .ok();
186 }
187 s.push_str(" body: [\n");
188 s.push_str(&post.body_typst);
189 s.push_str("\n ],\n)\n");
190 s
191}
192
193/// Convert markdown source into Typst markup body (used as `body` argument to
194/// `blog_post.typ::render`). No off-the-shelf md→Typst converter exists, so we
195/// walk comrak's AST and emit Typst by hand.
196pub fn typst_from_markdown(md: &str) -> String {
197 let arena = Arena::new();
198 let mut opts = Options::default();
199 opts.extension.strikethrough = true;
200 opts.extension.table = true;
201 opts.render.unsafe_ = true;
202 let root = comrak::parse_document(&arena, md, &opts);
203 let mut out = String::with_capacity(md.len() * 2);
204 render_block(root, &mut out);
205 out
206}
207
208fn render_block<'a>(node: &'a AstNode<'a>, out: &mut String) {
209 match &node.data.borrow().value {
210 NodeValue::Document => {
211 for child in node.children() {
212 render_block(child, out);
213 }
214 }
215 NodeValue::Paragraph => {
216 if is_paragraph_only_image(node) {
217 if let Some(child) = node.children().next() {
218 if let NodeValue::Image(l) = &child.data.borrow().value {
219 emit_block_image(l, child, out);
220 }
221 }
222 } else {
223 for child in node.children() {
224 render_inline(child, out);
225 }
226 out.push_str("\n\n");
227 }
228 }
229 NodeValue::Heading(h) => {
230 for _ in 0..h.level {
231 out.push('=');
232 }
233 out.push(' ');
234 for child in node.children() {
235 render_inline(child, out);
236 }
237 out.push_str("\n\n");
238 }
239 NodeValue::List(l) => {
240 let ordered = matches!(l.list_type, comrak::nodes::ListType::Ordered);
241 for child in node.children() {
242 emit_list_item(child, out, ordered);
243 }
244 out.push('\n');
245 }
246 NodeValue::Item(_) => {} // handled by parent List
247 NodeValue::BlockQuote => {
248 out.push_str("#quote(block: true)[\n");
249 for child in node.children() {
250 render_block(child, out);
251 }
252 out.push_str("]\n\n");
253 }
254 NodeValue::ThematicBreak => {
255 out.push_str("#align(center)[#line(length: 30%, stroke: 0.5pt + gray)]\n\n");
256 }
257 NodeValue::CodeBlock(c) => {
258 let lang = c.info.split_whitespace().next().unwrap_or("");
259 out.push_str("#raw(block: true");
260 if !lang.is_empty() {
261 out.push_str(", lang: \"");
262 out.push_str(&str_lit(lang));
263 out.push('"');
264 }
265 out.push_str(", \"");
266 out.push_str(&str_lit(&c.literal));
267 out.push_str("\")\n\n");
268 }
269 NodeValue::HtmlBlock(_) => {} // skip raw HTML in PDF output
270 NodeValue::Table(t) => emit_table(node, &t.alignments, out),
271 _ => {
272 for child in node.children() {
273 render_block(child, out);
274 }
275 }
276 }
277}
278
279fn render_inline<'a>(node: &'a AstNode<'a>, out: &mut String) {
280 match &node.data.borrow().value {
281 NodeValue::Text(t) => out.push_str(&escape_markup(t)),
282 NodeValue::SoftBreak => out.push(' '),
283 NodeValue::LineBreak => out.push_str(" \\\n"),
284 NodeValue::Code(c) => {
285 out.push_str("#raw(\"");
286 out.push_str(&str_lit(&c.literal));
287 out.push_str("\")");
288 }
289 NodeValue::HtmlInline(_) => {} // drop raw HTML inline in PDF
290 NodeValue::Emph => wrap_inline(node, "#emph[", "]", out),
291 NodeValue::Strong => wrap_inline(node, "#strong[", "]", out),
292 NodeValue::Strikethrough => wrap_inline(node, "#strike[", "]", out),
293 NodeValue::Link(l) => {
294 out.push_str("#link(\"");
295 out.push_str(&str_lit(&l.url));
296 out.push_str("\")[");
297 for child in node.children() {
298 render_inline(child, out);
299 }
300 out.push(']');
301 }
302 NodeValue::Image(l) => {
303 out.push_str("#image(\"/content/images/");
304 out.push_str(&str_lit(strip_images_prefix(&l.url)));
305 out.push_str("\")");
306 }
307 _ => {
308 for child in node.children() {
309 render_inline(child, out);
310 }
311 }
312 }
313}
314
315fn wrap_inline<'a>(node: &'a AstNode<'a>, open: &str, close: &str, out: &mut String) {
316 out.push_str(open);
317 for child in node.children() {
318 render_inline(child, out);
319 }
320 out.push_str(close);
321}
322
323fn emit_list_item<'a>(item: &'a AstNode<'a>, out: &mut String, ordered: bool) {
324 out.push_str(if ordered { "+ " } else { "- " });
325 for child in item.children() {
326 match &child.data.borrow().value {
327 NodeValue::Paragraph => {
328 for c in child.children() {
329 render_inline(c, out);
330 }
331 }
332 NodeValue::List(l) => {
333 let nested_ordered = matches!(l.list_type, comrak::nodes::ListType::Ordered);
334 out.push('\n');
335 for grandchild in child.children() {
336 out.push_str(" ");
337 emit_list_item(grandchild, out, nested_ordered);
338 }
339 }
340 _ => render_block(child, out),
341 }
342 }
343 out.push('\n');
344}
345
346fn emit_block_image<'a>(l: &comrak::nodes::NodeLink, node: &'a AstNode<'a>, out: &mut String) {
347 let url = strip_images_prefix(&l.url);
348 let mut alt = String::new();
349 for child in node.children() {
350 collect_text(child, &mut alt);
351 }
352 out.push_str("#align(center)[#image(\"/content/images/");
353 out.push_str(&str_lit(url));
354 out.push_str("\", width: 100%");
355 if !alt.is_empty() {
356 out.push_str(", alt: \"");
357 out.push_str(&str_lit(&alt));
358 out.push('"');
359 }
360 out.push_str(")]\n\n");
361}
362
363fn emit_table<'a>(node: &'a AstNode<'a>, alignments: &[TableAlignment], out: &mut String) {
364 let mut rows = node.children().peekable();
365 let columns = match rows.peek() {
366 Some(first) => first.children().count(),
367 None => return,
368 };
369 if columns == 0 {
370 return;
371 }
372 out.push_str("#table(columns: ");
373 out.push_str(&columns.to_string());
374 out.push_str(", align: (");
375 for i in 0..columns {
376 if i > 0 {
377 out.push_str(", ");
378 }
379 out.push_str(match alignments.get(i).copied() {
380 Some(TableAlignment::Right) => "right",
381 Some(TableAlignment::Center) => "center",
382 _ => "left",
383 });
384 }
385 out.push_str("),\n");
386 for row in rows {
387 for cell in row.children() {
388 out.push_str(" [");
389 for child in cell.children() {
390 render_inline(child, out);
391 }
392 out.push_str("],\n");
393 }
394 }
395 out.push_str(")\n\n");
396}
397
398fn collect_text<'a>(node: &'a AstNode<'a>, buf: &mut String) {
399 match &node.data.borrow().value {
400 NodeValue::Text(t) => buf.push_str(t),
401 NodeValue::Code(c) => buf.push_str(&c.literal),
402 _ => {
403 for child in node.children() {
404 collect_text(child, buf);
405 }
406 }
407 }
408}
409
410fn is_paragraph_only_image<'a>(para: &'a AstNode<'a>) -> bool {
411 let mut iter = para.children();
412 let first = iter.next();
413 if iter.next().is_some() {
414 return false;
415 }
416 match first {
417 Some(child) => matches!(child.data.borrow().value, NodeValue::Image(_)),
418 None => false,
419 }
420}
421
422fn strip_images_prefix(url: &str) -> &str {
423 url.strip_prefix("images/").unwrap_or(url)
424}
425
426/// Backslash-escape characters with meaning in Typst markup mode.
427fn escape_markup(s: &str) -> String {
428 let mut out = String::with_capacity(s.len());
429 for c in s.chars() {
430 match c {
431 '\\' | '[' | ']' | '*' | '_' | '`' | '#' | '$' | '<' | '@' | '~' => {
432 out.push('\\');
433 out.push(c);
434 }
435 _ => out.push(c),
436 }
437 }
438 out
439}
440
441/// Escape a string for use inside a `"..."` Typst string literal.
442fn str_lit(s: &str) -> String {
443 let mut out = String::with_capacity(s.len());
444 for c in s.chars() {
445 match c {
446 '\\' => out.push_str("\\\\"),
447 '"' => out.push_str("\\\""),
448 '\n' => out.push_str("\\n"),
449 '\r' => out.push_str("\\r"),
450 '\t' => out.push_str("\\t"),
451 _ => out.push(c),
452 }
453 }
454 out
455}