use std::path::Path;
use thiserror::Error;
use typstify_core::{
content::{ParsedContent, TocEntry},
frontmatter::parse_typst_frontmatter,
};
#[derive(Debug, Error)]
pub enum TypstError {
#[error("frontmatter error: {0}")]
Frontmatter(#[from] typstify_core::error::CoreError),
#[error("typst compilation failed: {0}")]
Compilation(String),
#[error("SVG rendering failed: {0}")]
Render(String),
}
pub type Result<T> = std::result::Result<T, TypstError>;
#[derive(Debug)]
pub struct TypstParser {
extract_toc: bool,
}
impl Default for TypstParser {
fn default() -> Self {
Self::new()
}
}
impl TypstParser {
pub fn new() -> Self {
Self { extract_toc: true }
}
pub fn parse(&self, content: &str, path: &Path) -> Result<ParsedContent> {
let (frontmatter, body) = parse_typst_frontmatter(content, path)?;
let toc = if self.extract_toc {
self.extract_toc_from_source(&body)
} else {
Vec::new()
};
let html = format!(
"<div class=\"typst-source\" data-path=\"{}\">\n<pre><code class=\"language-typst\">{}</code></pre>\n</div>",
path.display(),
html_escape(&body)
);
Ok(ParsedContent {
frontmatter,
html,
raw: body,
toc,
})
}
fn extract_toc_from_source(&self, content: &str) -> Vec<TocEntry> {
let mut toc = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if let Some(heading) = parse_typst_heading(trimmed) {
toc.push(heading);
}
}
toc
}
}
fn parse_typst_heading(line: &str) -> Option<TocEntry> {
if !line.starts_with('=') {
return None;
}
let level = line.chars().take_while(|c| *c == '=').count();
if level == 0 || level > 6 {
return None;
}
let text = line[level..].trim().to_string();
if text.is_empty() {
return None;
}
let id = slugify(&text);
Some(TocEntry {
level: level as u8,
text,
id,
})
}
fn slugify(text: &str) -> String {
text.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() {
c
} else if c.is_whitespace() || c == '-' || c == '_' {
'-'
} else {
'\0'
}
})
.filter(|c| *c != '\0')
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_typst_heading() {
let h1 = parse_typst_heading("= Introduction").unwrap();
assert_eq!(h1.level, 1);
assert_eq!(h1.text, "Introduction");
let h2 = parse_typst_heading("== Sub Section").unwrap();
assert_eq!(h2.level, 2);
assert_eq!(h2.text, "Sub Section");
assert!(parse_typst_heading("Not a heading").is_none());
assert!(parse_typst_heading("=").is_none()); }
#[test]
fn test_slugify() {
assert_eq!(slugify("Hello World"), "hello-world");
assert_eq!(slugify("Test 123"), "test-123");
}
#[test]
fn test_extract_toc() {
let parser = TypstParser::new();
let content = r#"= Main Title
== Section One
=== Subsection
== Section Two"#;
let toc = parser.extract_toc_from_source(content);
assert_eq!(toc.len(), 4);
assert_eq!(toc[0].level, 1);
assert_eq!(toc[0].text, "Main Title");
assert_eq!(toc[1].level, 2);
assert_eq!(toc[2].level, 3);
}
#[test]
fn test_parse_with_frontmatter() {
let parser = TypstParser::new();
let content = r#"// typstify:frontmatter
// title: "Test Document"
= Hello Typst
This is a test document."#;
let result = parser.parse(content, Path::new("test.typ")).unwrap();
assert_eq!(result.frontmatter.title, "Test Document");
assert!(!result.toc.is_empty());
assert!(result.html.contains("typst-source"));
}
#[test]
fn test_html_escape() {
assert_eq!(html_escape("<script>"), "<script>");
assert_eq!(html_escape("a & b"), "a & b");
}
}