typstify_parser/
typst_parser.rs1use 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#[derive(Debug, Error)]
18pub enum TypstError {
19 #[error("frontmatter error: {0}")]
21 Frontmatter(#[from] typstify_core::error::CoreError),
22
23 #[error("typst compilation failed: {0}")]
25 Compilation(String),
26
27 #[error("SVG rendering failed: {0}")]
29 Render(String),
30}
31
32pub type Result<T> = std::result::Result<T, TypstError>;
34
35#[derive(Debug)]
43pub struct TypstParser {
44 extract_toc: bool,
46}
47
48impl Default for TypstParser {
49 fn default() -> Self {
50 Self::new()
51 }
52}
53
54impl TypstParser {
55 pub fn new() -> Self {
57 Self { extract_toc: true }
58 }
59
60 pub fn parse(&self, content: &str, path: &Path) -> Result<ParsedContent> {
66 let (frontmatter, body) = parse_typst_frontmatter(content, path)?;
68
69 let toc = if self.extract_toc {
71 self.extract_toc_from_source(&body)
72 } else {
73 Vec::new()
74 };
75
76 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 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 if let Some(heading) = parse_typst_heading(trimmed) {
100 toc.push(heading);
101 }
102 }
103
104 toc
105 }
106}
107
108fn parse_typst_heading(line: &str) -> Option<TocEntry> {
110 if !line.starts_with('=') {
111 return None;
112 }
113
114 let level = line.chars().take_while(|c| *c == '=').count();
116 if level == 0 || level > 6 {
117 return None;
118 }
119
120 let text = line[level..].trim().to_string();
122 if text.is_empty() {
123 return None;
124 }
125
126 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()); }
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}