Skip to main content

typstify_parser/
typst_parser.rs

1//! Typst parser for converting Typst documents to HTML.
2//!
3//! This module provides Typst document parsing with frontmatter extraction
4//! and TOC generation. The actual Typst compilation requires setting up
5//! a proper TypstWorld which is deferred to the generator phase.
6
7use std::path::Path;
8
9use thiserror::Error;
10use typstify_core::{
11    content::{ParsedContent, TocEntry},
12    frontmatter::parse_typst_frontmatter,
13    utils::{html_escape, slugify},
14};
15
16/// Typst parsing errors.
17#[derive(Debug, Error)]
18pub enum TypstError {
19    /// Failed to parse frontmatter.
20    #[error("frontmatter error: {0}")]
21    Frontmatter(#[from] typstify_core::error::CoreError),
22
23    /// Typst compilation error.
24    #[error("typst compilation failed: {0}")]
25    Compilation(String),
26
27    /// SVG rendering error.
28    #[error("SVG rendering failed: {0}")]
29    Render(String),
30}
31
32/// Result type for Typst operations.
33pub type Result<T> = std::result::Result<T, TypstError>;
34
35/// Typst parser that extracts frontmatter and prepares content for compilation.
36///
37/// Note: Full Typst compilation requires a proper World implementation with
38/// file system access and font loading. This parser focuses on:
39/// - Extracting frontmatter from Typst comment syntax
40/// - Extracting TOC from heading patterns
41/// - Preparing content for later compilation
42#[derive(Debug)]
43pub struct TypstParser {
44    /// Whether to extract TOC from headings.
45    extract_toc: bool,
46}
47
48impl Default for TypstParser {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl TypstParser {
55    /// Create a new Typst parser.
56    pub fn new() -> Self {
57        Self { extract_toc: true }
58    }
59
60    /// Parse a Typst document with frontmatter.
61    ///
62    /// This extracts frontmatter and TOC but does not perform full compilation.
63    /// The HTML field will contain the raw Typst source wrapped in a code block
64    /// for preview, or can be compiled later with a proper World implementation.
65    pub fn parse(&self, content: &str, path: &Path) -> Result<ParsedContent> {
66        // Parse frontmatter from Typst comments
67        let (frontmatter, body) = parse_typst_frontmatter(content, path)?;
68
69        // Extract TOC from source
70        let toc = if self.extract_toc {
71            self.extract_toc_from_source(&body)
72        } else {
73            Vec::new()
74        };
75
76        // For now, wrap the Typst source in a placeholder
77        // Full compilation will be done in the generator with proper World setup
78        let html = format!(
79            "<div class=\"typst-source\">\n<pre><code class=\"language-typst\">{}</code></pre>\n</div>",
80            html_escape(&body)
81        );
82
83        Ok(ParsedContent {
84            frontmatter,
85            html,
86            raw: body,
87            toc,
88        })
89    }
90
91    /// Extract TOC entries from Typst source (simple heuristic).
92    fn extract_toc_from_source(&self, content: &str) -> Vec<TocEntry> {
93        let mut toc = Vec::new();
94
95        for line in content.lines() {
96            let trimmed = line.trim();
97
98            // Match Typst headings: = Title, == Subtitle, etc.
99            if let Some(heading) = parse_typst_heading(trimmed) {
100                toc.push(heading);
101            }
102        }
103
104        toc
105    }
106}
107
108/// Parse a Typst heading line into a TocEntry.
109fn parse_typst_heading(line: &str) -> Option<TocEntry> {
110    if !line.starts_with('=') {
111        return None;
112    }
113
114    // Count the number of = at the start
115    let level = line.chars().take_while(|c| *c == '=').count();
116    if level == 0 || level > 6 {
117        return None;
118    }
119
120    // Extract the heading text
121    let text = line[level..].trim().to_string();
122    if text.is_empty() {
123        return None;
124    }
125
126    // Generate a slug from the text
127    let id = slugify(&text);
128
129    Some(TocEntry {
130        level: level as u8,
131        text,
132        id,
133    })
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_parse_typst_heading() {
142        let h1 = parse_typst_heading("= Introduction").unwrap();
143        assert_eq!(h1.level, 1);
144        assert_eq!(h1.text, "Introduction");
145
146        let h2 = parse_typst_heading("== Sub Section").unwrap();
147        assert_eq!(h2.level, 2);
148        assert_eq!(h2.text, "Sub Section");
149
150        assert!(parse_typst_heading("Not a heading").is_none());
151        assert!(parse_typst_heading("=").is_none()); // Empty heading
152    }
153
154    #[test]
155    fn test_extract_toc() {
156        let parser = TypstParser::new();
157        let content = r#"= Main Title
158== Section One
159=== Subsection
160== Section Two"#;
161
162        let toc = parser.extract_toc_from_source(content);
163
164        assert_eq!(toc.len(), 4);
165        assert_eq!(toc[0].level, 1);
166        assert_eq!(toc[0].text, "Main Title");
167        assert_eq!(toc[1].level, 2);
168        assert_eq!(toc[2].level, 3);
169    }
170
171    #[test]
172    fn test_parse_with_frontmatter() {
173        let parser = TypstParser::new();
174        let content = r#"// typstify:frontmatter
175// title: "Test Document"
176
177= Hello Typst
178
179This is a test document."#;
180
181        let result = parser.parse(content, Path::new("test.typ")).unwrap();
182
183        assert_eq!(result.frontmatter.title, "Test Document");
184        assert!(!result.toc.is_empty());
185        assert!(result.html.contains("typst-source"));
186    }
187}