repos

blog.bythewood.me-rust

mirror archived upstream

Single-binary self-hosted Markdown blog on Rust axum: no database, live search, Typst PDF export, and strong SEO.

axumblogdockermarkdownminijinjarustself-hostedtypstvite

14.1 KB · 456 lines · Rust Raw History
  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}