quillmark_core/
lib.rs

1//! # Quillmark Core Overview
2//!
3//! Core types and functionality for the Quillmark template-first Markdown rendering system.
4//!
5//! ## Features
6//!
7//! This crate provides the foundational types and traits for Quillmark:
8//!
9//! - **Parsing**: YAML frontmatter extraction with Extended YAML Metadata Standard support
10//! - **Templating**: MiniJinja-based template composition with stable filter API
11//! - **Template model**: [`Quill`] type for managing template bundles with in-memory file system
12//! - **Backend trait**: Extensible interface for implementing output format backends
13//! - **Error handling**: Structured diagnostics with source location tracking
14//! - **Utilities**: TOML⇄YAML conversion helpers
15//!
16//! ## Quick Start
17//!
18//! ```no_run
19//! use quillmark_core::{decompose, Quill};
20//!
21//! // Parse markdown with frontmatter
22//! let markdown = "---\ntitle: Example\n---\n\n# Content";
23//! let doc = decompose(markdown).unwrap();
24//!
25//! // Load a quill template
26//! let quill = Quill::from_path("path/to/quill").unwrap();
27//! ```
28//!
29//! ## Architecture
30//!
31//! The crate is organized into four main modules:
32//!
33//! - [`parse`]: Markdown parsing with YAML frontmatter support
34//! - [`templating`]: Template composition using MiniJinja
35//! - [`backend`]: Backend trait for output format implementations
36//! - [`error`]: Structured error handling and diagnostics
37//!
38//! ## Further Reading
39//!
40//! - [PARSE.md](https://github.com/nibsbin/quillmark/blob/main/quillmark-core/docs/designs/PARSE.md) - Detailed parsing documentation
41//! - [Examples](https://github.com/nibsbin/quillmark/tree/main/examples) - Working examples
42
43use std::collections::HashMap;
44use std::error::Error as StdError;
45use std::path::{Path, PathBuf};
46
47pub mod parse;
48pub use parse::{decompose, ParsedDocument, BODY_FIELD};
49
50pub mod templating;
51pub use templating::{Glue, TemplateError};
52
53pub mod backend;
54pub use backend::Backend;
55
56pub mod error;
57pub use error::{Diagnostic, Location, RenderError, RenderResult, Severity};
58
59/// Output formats supported by backends. See [module docs](self) for examples.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
61pub enum OutputFormat {
62    /// Plain text output
63    Txt,
64    /// Scalable Vector Graphics output
65    Svg,
66    /// Portable Document Format output
67    Pdf,
68}
69
70/// An artifact produced by rendering. See [module docs](self) for examples.
71#[derive(Debug)]
72pub struct Artifact {
73    /// The binary content of the artifact
74    pub bytes: Vec<u8>,
75    /// The format of the output
76    pub output_format: OutputFormat,
77}
78
79/// Internal rendering options. See [module docs](self) for examples.
80#[derive(Debug)]
81pub struct RenderOptions {
82    /// Optional output format specification
83    pub output_format: Option<OutputFormat>,
84}
85
86/// A file entry in the in-memory file system
87#[derive(Debug, Clone)]
88pub struct FileEntry {
89    /// The file contents as bytes
90    pub contents: Vec<u8>,
91    /// The file path relative to the quill root
92    pub path: PathBuf,
93    /// Whether this is a directory entry
94    pub is_dir: bool,
95}
96
97/// Simple gitignore-style pattern matcher for .quillignore
98#[derive(Debug, Clone)]
99pub struct QuillIgnore {
100    patterns: Vec<String>,
101}
102
103impl QuillIgnore {
104    /// Create a new QuillIgnore from pattern strings
105    pub fn new(patterns: Vec<String>) -> Self {
106        Self { patterns }
107    }
108
109    /// Parse .quillignore content into patterns
110    pub fn from_content(content: &str) -> Self {
111        let patterns = content
112            .lines()
113            .map(|line| line.trim())
114            .filter(|line| !line.is_empty() && !line.starts_with('#'))
115            .map(|line| line.to_string())
116            .collect();
117        Self::new(patterns)
118    }
119
120    /// Check if a path should be ignored
121    pub fn is_ignored<P: AsRef<Path>>(&self, path: P) -> bool {
122        let path = path.as_ref();
123        let path_str = path.to_string_lossy();
124
125        for pattern in &self.patterns {
126            if self.matches_pattern(pattern, &path_str) {
127                return true;
128            }
129        }
130        false
131    }
132
133    /// Simple pattern matching (supports * wildcard and directory patterns)
134    fn matches_pattern(&self, pattern: &str, path: &str) -> bool {
135        // Handle directory patterns
136        if pattern.ends_with('/') {
137            let pattern_prefix = &pattern[..pattern.len() - 1];
138            return path.starts_with(pattern_prefix)
139                && (path.len() == pattern_prefix.len()
140                    || path.chars().nth(pattern_prefix.len()) == Some('/'));
141        }
142
143        // Handle exact matches
144        if !pattern.contains('*') {
145            return path == pattern || path.ends_with(&format!("/{}", pattern));
146        }
147
148        // Simple wildcard matching
149        if pattern == "*" {
150            return true;
151        }
152
153        // Handle patterns with wildcards
154        let pattern_parts: Vec<&str> = pattern.split('*').collect();
155        if pattern_parts.len() == 2 {
156            let (prefix, suffix) = (pattern_parts[0], pattern_parts[1]);
157            if prefix.is_empty() {
158                return path.ends_with(suffix);
159            } else if suffix.is_empty() {
160                return path.starts_with(prefix);
161            } else {
162                return path.starts_with(prefix) && path.ends_with(suffix);
163            }
164        }
165
166        false
167    }
168}
169
170/// A quill template bundle. See [module docs](self) for examples.
171#[derive(Debug, Clone)]
172pub struct Quill {
173    /// The template content
174    pub glue_template: String,
175    /// Quill-specific metadata
176    pub metadata: HashMap<String, serde_yaml::Value>,
177    /// Base path for resolving relative paths
178    pub base_path: PathBuf,
179    /// Name of the quill
180    pub name: String,
181    /// Glue template file name
182    pub glue_file: String,
183    /// In-memory file system
184    pub files: HashMap<PathBuf, FileEntry>,
185}
186
187impl Quill {
188    /// Create a Quill from a directory path
189    pub fn from_path<P: AsRef<std::path::Path>>(
190        path: P,
191    ) -> Result<Self, Box<dyn StdError + Send + Sync>> {
192        use std::fs;
193
194        let path = path.as_ref();
195        let name = path
196            .file_name()
197            .and_then(|n| n.to_str())
198            .unwrap_or("unnamed")
199            .to_string();
200
201        // Read Quill.toml (capitalized)
202        let quill_toml_path = path.join("Quill.toml");
203        let quill_toml_content = fs::read_to_string(&quill_toml_path)
204            .map_err(|e| format!("Failed to read Quill.toml: {}", e))?;
205
206        let quill_toml: toml::Value = toml::from_str(&quill_toml_content)
207            .map_err(|e| format!("Failed to parse Quill.toml: {}", e))?;
208
209        let mut metadata = HashMap::new();
210        let mut glue_file = "glue.typ".to_string(); // default
211        let mut quill_name = name; // default to directory name
212
213        // Extract fields from [Quill] section
214        if let Some(quill_section) = quill_toml.get("Quill") {
215            // Extract required fields: name, backend, glue
216            if let Some(name_val) = quill_section.get("name").and_then(|v| v.as_str()) {
217                quill_name = name_val.to_string();
218            }
219
220            if let Some(backend_val) = quill_section.get("backend").and_then(|v| v.as_str()) {
221                match Self::toml_to_yaml_value(&toml::Value::String(backend_val.to_string())) {
222                    Ok(yaml_value) => {
223                        metadata.insert("backend".to_string(), yaml_value);
224                    }
225                    Err(e) => {
226                        eprintln!("Warning: Failed to convert backend field: {}", e);
227                    }
228                }
229            }
230
231            if let Some(glue_val) = quill_section.get("glue").and_then(|v| v.as_str()) {
232                glue_file = glue_val.to_string();
233            }
234
235            // Add other fields to metadata (excluding special fields and version)
236            if let toml::Value::Table(table) = quill_section {
237                for (key, value) in table {
238                    if key != "name" && key != "backend" && key != "glue" && key != "version" {
239                        match Self::toml_to_yaml_value(value) {
240                            Ok(yaml_value) => {
241                                metadata.insert(key.clone(), yaml_value);
242                            }
243                            Err(e) => {
244                                eprintln!("Warning: Failed to convert field '{}': {}", key, e);
245                            }
246                        }
247                    }
248                }
249            }
250        }
251
252        // Extract fields from [typst] section
253        if let Some(typst_section) = quill_toml.get("typst") {
254            if let toml::Value::Table(table) = typst_section {
255                for (key, value) in table {
256                    match Self::toml_to_yaml_value(value) {
257                        Ok(yaml_value) => {
258                            metadata.insert(format!("typst_{}", key), yaml_value);
259                        }
260                        Err(e) => {
261                            eprintln!("Warning: Failed to convert typst field '{}': {}", key, e);
262                        }
263                    }
264                }
265            }
266        }
267
268        // Read the template content from glue file
269        let glue_path = path.join(&glue_file);
270        let template_content = fs::read_to_string(&glue_path)
271            .map_err(|e| format!("Failed to read glue file '{}': {}", glue_file, e))?;
272
273        // Load .quillignore if it exists
274        let quillignore_path = path.join(".quillignore");
275        let ignore = if quillignore_path.exists() {
276            let ignore_content = fs::read_to_string(&quillignore_path)
277                .map_err(|e| format!("Failed to read .quillignore: {}", e))?;
278            QuillIgnore::from_content(&ignore_content)
279        } else {
280            // Default ignore patterns
281            QuillIgnore::new(vec![
282                ".git/".to_string(),
283                ".gitignore".to_string(),
284                ".quillignore".to_string(),
285                "target/".to_string(),
286                "node_modules/".to_string(),
287            ])
288        };
289
290        // Load all files into memory
291        let mut files = HashMap::new();
292        Self::load_directory_recursive(path, path, &mut files, &ignore)?;
293
294        let quill = Quill {
295            glue_template: template_content,
296            metadata,
297            base_path: path.to_path_buf(),
298            name: quill_name,
299            glue_file,
300            files,
301        };
302
303        // Automatically validate the quill upon creation
304        quill.validate()?;
305
306        Ok(quill)
307    }
308
309    /// Recursively load all files from a directory into memory
310    fn load_directory_recursive(
311        current_dir: &Path,
312        base_dir: &Path,
313        files: &mut HashMap<PathBuf, FileEntry>,
314        ignore: &QuillIgnore,
315    ) -> Result<(), Box<dyn StdError + Send + Sync>> {
316        use std::fs;
317
318        if !current_dir.exists() {
319            return Ok(());
320        }
321
322        for entry in fs::read_dir(current_dir)? {
323            let entry = entry?;
324            let path = entry.path();
325            let relative_path = path
326                .strip_prefix(base_dir)
327                .map_err(|e| format!("Failed to get relative path: {}", e))?
328                .to_path_buf();
329
330            // Check if this path should be ignored
331            if ignore.is_ignored(&relative_path) {
332                continue;
333            }
334
335            if path.is_file() {
336                let contents = fs::read(&path)
337                    .map_err(|e| format!("Failed to read file '{}': {}", path.display(), e))?;
338
339                files.insert(
340                    relative_path.clone(),
341                    FileEntry {
342                        contents,
343                        path: relative_path,
344                        is_dir: false,
345                    },
346                );
347            } else if path.is_dir() {
348                // Add directory entry
349                files.insert(
350                    relative_path.clone(),
351                    FileEntry {
352                        contents: Vec::new(),
353                        path: relative_path,
354                        is_dir: true,
355                    },
356                );
357
358                // Recursively process subdirectory
359                Self::load_directory_recursive(&path, base_dir, files, ignore)?;
360            }
361        }
362
363        Ok(())
364    }
365
366    /// Convert TOML value to YAML value
367    pub fn toml_to_yaml_value(
368        toml_val: &toml::Value,
369    ) -> Result<serde_yaml::Value, Box<dyn StdError + Send + Sync>> {
370        let json_val = serde_json::to_value(toml_val)?;
371        let yaml_val = serde_yaml::to_value(json_val)?;
372        Ok(yaml_val)
373    }
374
375    /// Get the path to the assets directory
376    pub fn assets_path(&self) -> PathBuf {
377        self.base_path.join("assets")
378    }
379
380    /// Get the path to the packages directory
381    pub fn packages_path(&self) -> PathBuf {
382        self.base_path.join("packages")
383    }
384
385    /// Get the path to the glue file
386    pub fn glue_path(&self) -> PathBuf {
387        self.base_path.join(&self.glue_file)
388    }
389
390    /// Get the list of typst packages to download, if specified in Quill.toml
391    pub fn typst_packages(&self) -> Vec<String> {
392        self.metadata
393            .get("typst_packages")
394            .and_then(|v| v.as_sequence())
395            .map(|seq| {
396                seq.iter()
397                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
398                    .collect()
399            })
400            .unwrap_or_default()
401    }
402
403    /// Validate the quill structure
404    pub fn validate(&self) -> Result<(), Box<dyn StdError + Send + Sync>> {
405        // Check that glue file exists in memory
406        let glue_path = PathBuf::from(&self.glue_file);
407        if !self.files.contains_key(&glue_path) {
408            return Err(format!("Glue file '{}' does not exist", self.glue_file).into());
409        }
410        Ok(())
411    }
412
413    /// Get file contents by path (relative to quill root)
414    pub fn get_file<P: AsRef<Path>>(&self, path: P) -> Option<&[u8]> {
415        let path = path.as_ref();
416        self.files.get(path).map(|entry| entry.contents.as_slice())
417    }
418
419    /// Get file entry by path (includes metadata)
420    pub fn get_file_entry<P: AsRef<Path>>(&self, path: P) -> Option<&FileEntry> {
421        let path = path.as_ref();
422        self.files.get(path)
423    }
424
425    /// Check if a file exists in memory
426    pub fn file_exists<P: AsRef<Path>>(&self, path: P) -> bool {
427        let path = path.as_ref();
428        self.files.contains_key(path)
429    }
430
431    /// List all files in a directory (returns paths relative to quill root)
432    pub fn list_directory<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
433        let dir_path = dir_path.as_ref();
434        let mut entries = Vec::new();
435
436        for (path, entry) in &self.files {
437            if let Some(parent) = path.parent() {
438                if parent == dir_path && !entry.is_dir {
439                    entries.push(path.clone());
440                }
441            } else if dir_path == Path::new("") && !entry.is_dir {
442                // Files in root directory
443                entries.push(path.clone());
444            }
445        }
446
447        entries.sort();
448        entries
449    }
450
451    /// List all directories in a directory (returns paths relative to quill root)
452    pub fn list_subdirectories<P: AsRef<Path>>(&self, dir_path: P) -> Vec<PathBuf> {
453        let dir_path = dir_path.as_ref();
454        let mut entries = Vec::new();
455
456        for (path, entry) in &self.files {
457            if entry.is_dir {
458                if let Some(parent) = path.parent() {
459                    if parent == dir_path {
460                        entries.push(path.clone());
461                    }
462                } else if dir_path == Path::new("") {
463                    // Directories in root
464                    entries.push(path.clone());
465                }
466            }
467        }
468
469        entries.sort();
470        entries
471    }
472
473    /// Get all files matching a pattern (supports simple wildcards)
474    pub fn find_files<P: AsRef<Path>>(&self, pattern: P) -> Vec<PathBuf> {
475        let pattern_str = pattern.as_ref().to_string_lossy();
476        let mut matches = Vec::new();
477
478        for (path, entry) in &self.files {
479            if !entry.is_dir {
480                let path_str = path.to_string_lossy();
481                if self.matches_simple_pattern(&pattern_str, &path_str) {
482                    matches.push(path.clone());
483                }
484            }
485        }
486
487        matches.sort();
488        matches
489    }
490
491    /// Simple pattern matching helper
492    fn matches_simple_pattern(&self, pattern: &str, path: &str) -> bool {
493        if pattern == "*" {
494            return true;
495        }
496
497        if !pattern.contains('*') {
498            return path == pattern;
499        }
500
501        // Handle directory/* patterns
502        if pattern.ends_with("/*") {
503            let dir_pattern = &pattern[..pattern.len() - 2];
504            return path.starts_with(&format!("{}/", dir_pattern));
505        }
506
507        let parts: Vec<&str> = pattern.split('*').collect();
508        if parts.len() == 2 {
509            let (prefix, suffix) = (parts[0], parts[1]);
510            if prefix.is_empty() {
511                return path.ends_with(suffix);
512            } else if suffix.is_empty() {
513                return path.starts_with(prefix);
514            } else {
515                return path.starts_with(prefix) && path.ends_with(suffix);
516            }
517        }
518
519        false
520    }
521}
522#[cfg(test)]
523mod quill_tests {
524    use super::*;
525    use std::fs;
526    use tempfile::TempDir;
527
528    #[test]
529    fn test_quillignore_parsing() {
530        let ignore_content = r#"
531# This is a comment
532*.tmp
533target/
534node_modules/
535.git/
536"#;
537        let ignore = QuillIgnore::from_content(ignore_content);
538        assert_eq!(ignore.patterns.len(), 4);
539        assert!(ignore.patterns.contains(&"*.tmp".to_string()));
540        assert!(ignore.patterns.contains(&"target/".to_string()));
541    }
542
543    #[test]
544    fn test_quillignore_matching() {
545        let ignore = QuillIgnore::new(vec![
546            "*.tmp".to_string(),
547            "target/".to_string(),
548            "node_modules/".to_string(),
549            ".git/".to_string(),
550        ]);
551
552        // Test file patterns
553        assert!(ignore.is_ignored("test.tmp"));
554        assert!(ignore.is_ignored("path/to/file.tmp"));
555        assert!(!ignore.is_ignored("test.txt"));
556
557        // Test directory patterns
558        assert!(ignore.is_ignored("target"));
559        assert!(ignore.is_ignored("target/debug"));
560        assert!(ignore.is_ignored("target/debug/deps"));
561        assert!(!ignore.is_ignored("src/target.rs"));
562
563        assert!(ignore.is_ignored("node_modules"));
564        assert!(ignore.is_ignored("node_modules/package"));
565        assert!(!ignore.is_ignored("my_node_modules"));
566    }
567
568    #[test]
569    fn test_in_memory_file_system() {
570        let temp_dir = TempDir::new().unwrap();
571        let quill_dir = temp_dir.path();
572
573        // Create test files
574        fs::write(
575            quill_dir.join("Quill.toml"),
576            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
577        )
578        .unwrap();
579        fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
580
581        let assets_dir = quill_dir.join("assets");
582        fs::create_dir_all(&assets_dir).unwrap();
583        fs::write(assets_dir.join("test.txt"), "asset content").unwrap();
584
585        let packages_dir = quill_dir.join("packages");
586        fs::create_dir_all(&packages_dir).unwrap();
587        fs::write(packages_dir.join("package.typ"), "package content").unwrap();
588
589        // Load quill
590        let quill = Quill::from_path(quill_dir).unwrap();
591
592        // Test file access
593        assert!(quill.file_exists("glue.typ"));
594        assert!(quill.file_exists("assets/test.txt"));
595        assert!(quill.file_exists("packages/package.typ"));
596        assert!(!quill.file_exists("nonexistent.txt"));
597
598        // Test file content
599        let asset_content = quill.get_file("assets/test.txt").unwrap();
600        assert_eq!(asset_content, b"asset content");
601
602        // Test directory listing
603        let asset_files = quill.list_directory("assets");
604        assert_eq!(asset_files.len(), 1);
605        assert!(asset_files.contains(&PathBuf::from("assets/test.txt")));
606    }
607
608    #[test]
609    fn test_quillignore_integration() {
610        let temp_dir = TempDir::new().unwrap();
611        let quill_dir = temp_dir.path();
612
613        // Create .quillignore
614        fs::write(quill_dir.join(".quillignore"), "*.tmp\ntarget/\n").unwrap();
615
616        // Create test files
617        fs::write(
618            quill_dir.join("Quill.toml"),
619            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
620        )
621        .unwrap();
622        fs::write(quill_dir.join("glue.typ"), "test template").unwrap();
623        fs::write(quill_dir.join("should_ignore.tmp"), "ignored").unwrap();
624
625        let target_dir = quill_dir.join("target");
626        fs::create_dir_all(&target_dir).unwrap();
627        fs::write(target_dir.join("debug.txt"), "also ignored").unwrap();
628
629        // Load quill
630        let quill = Quill::from_path(quill_dir).unwrap();
631
632        // Test that ignored files are not loaded
633        assert!(quill.file_exists("glue.typ"));
634        assert!(!quill.file_exists("should_ignore.tmp"));
635        assert!(!quill.file_exists("target/debug.txt"));
636    }
637
638    #[test]
639    fn test_find_files_pattern() {
640        let temp_dir = TempDir::new().unwrap();
641        let quill_dir = temp_dir.path();
642
643        // Create test directory structure
644        fs::write(
645            quill_dir.join("Quill.toml"),
646            "[Quill]\nname = \"test\"\nbackend = \"typst\"\nglue = \"glue.typ\"",
647        )
648        .unwrap();
649        fs::write(quill_dir.join("glue.typ"), "template").unwrap();
650
651        let assets_dir = quill_dir.join("assets");
652        fs::create_dir_all(&assets_dir).unwrap();
653        fs::write(assets_dir.join("image.png"), "png data").unwrap();
654        fs::write(assets_dir.join("data.json"), "json data").unwrap();
655
656        let fonts_dir = assets_dir.join("fonts");
657        fs::create_dir_all(&fonts_dir).unwrap();
658        fs::write(fonts_dir.join("font.ttf"), "font data").unwrap();
659
660        // Load quill
661        let quill = Quill::from_path(quill_dir).unwrap();
662
663        // Test pattern matching
664        let all_assets = quill.find_files("assets/*");
665        assert!(all_assets.len() >= 3); // At least image.png, data.json, fonts/font.ttf
666
667        let typ_files = quill.find_files("*.typ");
668        assert_eq!(typ_files.len(), 1);
669        assert!(typ_files.contains(&PathBuf::from("glue.typ")));
670    }
671
672    #[test]
673    fn test_new_standardized_toml_format() {
674        let temp_dir = TempDir::new().unwrap();
675        let quill_dir = temp_dir.path();
676
677        // Create test files using new standardized format
678        let toml_content = r#"[Quill]
679name = "my-custom-quill"
680backend = "typst"
681glue = "custom_glue.typ"
682description = "Test quill with new format"
683author = "Test Author"
684"#;
685        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
686        fs::write(
687            quill_dir.join("custom_glue.typ"),
688            "= Custom Template\n\nThis is a custom template.",
689        )
690        .unwrap();
691
692        // Load quill
693        let quill = Quill::from_path(quill_dir).unwrap();
694
695        // Test that name comes from TOML, not directory
696        assert_eq!(quill.name, "my-custom-quill");
697
698        // Test that glue file is set correctly
699        assert_eq!(quill.glue_file, "custom_glue.typ");
700
701        // Test that backend is in metadata
702        assert!(quill.metadata.contains_key("backend"));
703        if let Some(backend_val) = quill.metadata.get("backend") {
704            if let Some(backend_str) = backend_val.as_str() {
705                assert_eq!(backend_str, "typst");
706            } else {
707                panic!("Backend value is not a string");
708            }
709        }
710
711        // Test that other fields are in metadata (but not version)
712        assert!(quill.metadata.contains_key("description"));
713        assert!(quill.metadata.contains_key("author"));
714        assert!(!quill.metadata.contains_key("version")); // version should be excluded
715
716        // Test that glue template content is loaded correctly
717        assert!(quill.glue_template.contains("Custom Template"));
718        assert!(quill.glue_template.contains("custom template"));
719    }
720
721    #[test]
722    fn test_typst_packages_parsing() {
723        let temp_dir = TempDir::new().unwrap();
724        let quill_dir = temp_dir.path();
725
726        let toml_content = r#"
727[Quill]
728name = "test-quill"
729backend = "typst"
730glue = "glue.typ"
731
732[typst]
733packages = ["@preview/bubble:0.2.2", "@preview/example:1.0.0"]
734"#;
735
736        fs::write(quill_dir.join("Quill.toml"), toml_content).unwrap();
737        fs::write(quill_dir.join("glue.typ"), "test").unwrap();
738
739        let quill = Quill::from_path(quill_dir).unwrap();
740        let packages = quill.typst_packages();
741
742        assert_eq!(packages.len(), 2);
743        assert_eq!(packages[0], "@preview/bubble:0.2.2");
744        assert_eq!(packages[1], "@preview/example:1.0.0");
745    }
746}