diff --git a/src/rs/cli.rs b/src/rs/cli.rs index 40e1816..23fc854 100644 --- a/src/rs/cli.rs +++ b/src/rs/cli.rs @@ -1,9 +1,11 @@ use crate::CompilationResults; use crate::compile::RheoCompileOptions; -use crate::config::{EpubOptions, HtmlOptions}; +use crate::config::{EpubOptions, HtmlOptions, SpineConfig}; use crate::formats::{epub, html, pdf}; +use crate::reticulate::spine::generate_spine; use crate::{OutputFormat, Result, open_all_files_in_folder}; use clap::{Parser, Subcommand}; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use tracing::{debug, error, info, warn}; @@ -232,6 +234,54 @@ fn get_per_file_formats( .collect() } +/// Returns the set of files to compile for a given format based on spine config. +/// If no spine is configured, returns all project files. +fn get_files_for_format<'a>( + format: OutputFormat, + project: &'a crate::project::ProjectConfig, + per_file_formats: &[OutputFormat], +) -> Result> { + if !per_file_formats.contains(&format) { + return Ok(HashSet::new()); + } + + let content_dir = project + .config + .resolve_content_dir(&project.root) + .unwrap_or_else(|| project.root.clone()); + + match format { + OutputFormat::Pdf => match &project.config.pdf.spine { + None => Ok(project.typ_files.iter().collect()), + Some(spine) if spine.merge == Some(true) => Ok(HashSet::new()), + Some(spine) => { + let spine_files = + generate_spine(&content_dir, Some(spine as &dyn SpineConfig), false)?; + let spine_set: HashSet<_> = spine_files.iter().collect(); + Ok(project + .typ_files + .iter() + .filter(|f| spine_set.contains(f)) + .collect()) + } + }, + OutputFormat::Html => match &project.config.html.spine { + None => Ok(project.typ_files.iter().collect()), + Some(spine) => { + let spine_files = + generate_spine(&content_dir, Some(spine as &dyn SpineConfig), false)?; + let spine_set: HashSet<_> = spine_files.iter().collect(); + Ok(project + .typ_files + .iter() + .filter(|f| spine_set.contains(f)) + .collect()) + } + }, + OutputFormat::Epub => Ok(HashSet::new()), // EPUB is always merged, not per-file + } +} + /// Perform compilation for a project with specified formats /// /// This is the unified compilation logic that supports both fresh and incremental compilation @@ -265,8 +315,12 @@ fn perform_compilation<'a>( // Determine which formats should be compiled per-file let per_file_formats = get_per_file_formats(&project.config, formats); + // Compute filtered file sets based on spine configuration + let pdf_files = get_files_for_format(OutputFormat::Pdf, project, &per_file_formats)?; + let html_files = get_files_for_format(OutputFormat::Html, project, &per_file_formats)?; + // Copy HTML assets (style.css) if HTML compilation is requested - if per_file_formats.contains(&OutputFormat::Html) { + if !html_files.is_empty() { output_config.copy_html_assets(project.style_css.as_deref())?; } @@ -274,6 +328,11 @@ fn perform_compilation<'a>( for typ_file in &project.typ_files { let filename = get_output_filename(typ_file)?; + // Skip files not in either filtered set + if !pdf_files.contains(typ_file) && !html_files.contains(typ_file) { + continue; + } + // For incremental mode, prepare the World for compiling this specific file // 1. set_main() tells the World which file we're compiling (updates main file ID) // 2. reset() clears file caches while preserving fonts/packages (enables incremental compilation) @@ -283,7 +342,7 @@ fn perform_compilation<'a>( } // Compile to PDF (per-file mode) - if per_file_formats.contains(&OutputFormat::Pdf) { + if pdf_files.contains(typ_file) { let output_path = output_config.pdf_dir.join(&filename).with_extension("pdf"); let options = match &mode { CompilationMode::Fresh { root } => { @@ -312,7 +371,7 @@ fn perform_compilation<'a>( } // Compile to HTML - if per_file_formats.contains(&OutputFormat::Html) { + if html_files.contains(typ_file) { let output_path = output_config .html_dir .join(&filename) diff --git a/src/rs/config.rs b/src/rs/config.rs index 04575fb..727625d 100644 --- a/src/rs/config.rs +++ b/src/rs/config.rs @@ -103,25 +103,106 @@ impl Default for RheoConfig { } } -/// Configuration for merging outputs +/// PDF spine configuration for merging multiple files into a single PDF. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Spine { - /// Title for merged document - pub title: String, - - /// Glob patterns for files to include in the combined document - /// Patterns are evaluated relative to content_dir (or project root if content_dir not set) - /// Output of patterns are sorted lexicographically - /// Example: ["cover.typ", "chapters/**"]" +pub struct PdfSpine { + /// Title of the merged PDF document. + /// Required when merge=true. + pub title: Option, + + /// Glob patterns for files to include in the combined document. + /// Patterns are evaluated relative to content_dir (or project root if content_dir not set). + /// Results are sorted lexicographically. + /// Example: ["cover.typ", "chapters/**"] pub vertebrae: Vec, - /// Whether to merge vertebrae into a single output file. - /// Only meaningful for PDF (HTML always false, EPUB always true). - /// If not specified for PDF, defaults to false (per-file compilation). + /// Whether to merge vertebrae into a single PDF file. + /// If false or not specified, compiles each file separately. #[serde(default)] pub merge: Option, } +/// EPUB spine configuration for combining multiple files into a single EPUB. +/// EPUB always merges files - there is no merge option. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EpubSpine { + /// Title of the EPUB document. + /// Required for EPUB output. + pub title: Option, + + /// Glob patterns for files to include in the EPUB. + /// Patterns are evaluated relative to content_dir (or project root if content_dir not set). + /// Results are sorted lexicographically. + /// Example: ["cover.typ", "chapters/**"] + pub vertebrae: Vec, +} + +/// HTML spine configuration for organizing multiple HTML files. +/// HTML always produces per-file output - there is no merge option. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HtmlSpine { + /// Title of the HTML site/collection. + pub title: Option, + + /// Glob patterns for files to include. + /// Patterns are evaluated relative to content_dir (or project root if content_dir not set). + /// Results are sorted lexicographically. + /// Example: ["index.typ", "pages/**"] + pub vertebrae: Vec, +} + +/// Common interface for spine configurations across output formats. +/// +/// This trait provides uniform access to spine fields, allowing generic +/// code to work with any spine type (PDF, EPUB, HTML). +pub trait SpineConfig { + /// Returns the spine title, if configured. + fn title(&self) -> Option<&str>; + + /// Returns the vertebrae glob patterns. + fn vertebrae(&self) -> &[String]; + + /// Returns whether to merge outputs into a single file. + /// Only meaningful for PDF; returns None for other formats. + fn merge(&self) -> Option { + None + } +} + +impl SpineConfig for PdfSpine { + fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + fn vertebrae(&self) -> &[String] { + &self.vertebrae + } + + fn merge(&self) -> Option { + self.merge + } +} + +impl SpineConfig for EpubSpine { + fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + fn vertebrae(&self) -> &[String] { + &self.vertebrae + } +} + +impl SpineConfig for HtmlSpine { + fn title(&self) -> Option<&str> { + self.title.as_deref() + } + + fn vertebrae(&self) -> &[String] { + &self.vertebrae + } +} + /// HTML output configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HtmlConfig { @@ -136,7 +217,7 @@ pub struct HtmlConfig { /// Configuration for an HTML spine (sitemap/navbar). /// HTML never merges vertebrae. #[serde(default)] - pub spine: Option, + pub spine: Option, } impl Default for HtmlConfig { @@ -153,7 +234,7 @@ impl Default for HtmlConfig { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct PdfConfig { /// Configuration for a PDF spine with multiple chapters. - pub spine: Option, + pub spine: Option, } /// EPUB output configuration @@ -173,7 +254,7 @@ pub struct EpubConfig { pub date: Option>, /// Configuration for an EPUB spine with multiple chapters. - pub spine: Option, + pub spine: Option, } impl RheoConfig { @@ -459,7 +540,7 @@ mod tests { let config: RheoConfig = toml::from_str(toml).unwrap(); let spine = config.pdf.spine.as_ref().unwrap(); - assert_eq!(spine.title, "My Book"); + assert_eq!(spine.title.as_ref().unwrap(), "My Book"); assert_eq!(spine.vertebrae, vec!["cover.typ", "chapters/*.typ"]); assert_eq!(spine.merge, Some(true)); } @@ -476,7 +557,7 @@ mod tests { let config: RheoConfig = toml::from_str(toml).unwrap(); let spine = config.pdf.spine.as_ref().unwrap(); - assert_eq!(spine.title, "My Book"); + assert_eq!(spine.title.as_ref().unwrap(), "My Book"); assert_eq!(spine.vertebrae, vec!["cover.typ", "chapters/*.typ"]); assert_eq!(spine.merge, Some(false)); } @@ -492,7 +573,7 @@ mod tests { let config: RheoConfig = toml::from_str(toml).unwrap(); let spine = config.pdf.spine.as_ref().unwrap(); - assert_eq!(spine.title, "My Book"); + assert_eq!(spine.title.as_ref().unwrap(), "My Book"); assert_eq!(spine.vertebrae, vec!["cover.typ"]); assert_eq!(spine.merge, None); } @@ -508,12 +589,12 @@ mod tests { let config: RheoConfig = toml::from_str(toml).unwrap(); let spine = config.epub.spine.as_ref().unwrap(); - assert_eq!(spine.title, "My EPUB"); + assert_eq!(spine.title.as_ref().unwrap(), "My EPUB"); assert_eq!( spine.vertebrae, vec!["intro.typ", "chapter*.typ", "outro.typ"] ); - assert_eq!(spine.merge, None); + // assert_eq!(spine.merge, None); } #[test] @@ -527,9 +608,9 @@ mod tests { let config: RheoConfig = toml::from_str(toml).unwrap(); let spine = config.html.spine.as_ref().unwrap(); - assert_eq!(spine.title, "My Website"); + assert_eq!(spine.title.as_ref().unwrap(), "My Website"); assert_eq!(spine.vertebrae, vec!["index.typ", "about.typ"]); - assert_eq!(spine.merge, None); + // Note: HtmlSpine has no merge field - HTML always produces per-file output } #[test] @@ -543,7 +624,7 @@ mod tests { let config: RheoConfig = toml::from_str(toml).unwrap(); let spine = config.epub.spine.as_ref().unwrap(); - assert_eq!(spine.title, "Single File Book"); + assert_eq!(spine.title.as_ref().unwrap(), "Single File Book"); assert!(spine.vertebrae.is_empty()); } diff --git a/src/rs/formats/epub/mod.rs b/src/rs/formats/epub/mod.rs index 0483938..e887f50 100644 --- a/src/rs/formats/epub/mod.rs +++ b/src/rs/formats/epub/mod.rs @@ -119,7 +119,7 @@ pub fn generate_package(items: &[EpubItem], config: &EpubConfig) -> AnyhowResult let language = info.locale.unwrap_or_default().rfc_3066(); let title = match &config.spine { None => items[0].title(), - Some(combined) => combined.title.clone().into(), + Some(combined) => combined.title.as_ref().unwrap().into(), }; const INTERNAL_UNIQUE_ID: &str = "uid"; @@ -242,22 +242,22 @@ pub fn zip_epub( Ok(()) } -// ============================================================================ -// EPUB compilation (implementation function) -// ============================================================================ - -/// Implementation: Compile multiple Typst files to EPUB format. -/// /// Generates a spine from the EPUB configuration using RheoSpine for AST-based -/// link transformation (.typ → .xhtml), compiles each file to HTML, +/// link transformation (.typ → .xhtml), compiles each file to XHTML, /// generates navigation, and packages everything into a .epub (zip) file. fn compile_epub_impl(config: &EpubConfig, epub_path: &Path, root: &Path) -> Result<()> { let inner = || -> AnyhowResult<()> { + // Convert spine config to trait object for generic spine handling + let spine_config = config + .spine + .as_ref() + .map(|s| s as &dyn crate::config::SpineConfig); + // Build RheoSpine with AST-transformed sources (.typ links → .xhtml) - let rheo_spine = RheoSpine::build(root, config.spine.as_ref(), crate::OutputFormat::Epub)?; + let rheo_spine = RheoSpine::build(root, spine_config, crate::OutputFormat::Epub)?; // Get the spine file paths - let spine = crate::reticulate::spine::generate_spine(root, config.spine.as_ref(), false)?; + let spine = crate::reticulate::spine::generate_spine(root, spine_config, false)?; // Create EpubItems from transformed sources let mut items = spine diff --git a/src/rs/formats/pdf.rs b/src/rs/formats/pdf.rs index 7faa7e7..226bfc6 100644 --- a/src/rs/formats/pdf.rs +++ b/src/rs/formats/pdf.rs @@ -219,7 +219,8 @@ fn compile_pdf_merged_impl_fresh( })?; // Build RheoSpine with AST-transformed sources (links → labels, metadata headings injected) - let rheo_spine = RheoSpine::build(root, Some(merge), crate::OutputFormat::Pdf)?; + let spine_config: &dyn crate::config::SpineConfig = merge; + let rheo_spine = RheoSpine::build(root, Some(spine_config), crate::OutputFormat::Pdf)?; debug!(file_count = rheo_spine.source.len(), "built PDF spine"); @@ -283,7 +284,8 @@ fn compile_pdf_merged_impl( })?; // Build RheoSpine with AST-transformed sources (links → labels, metadata headings injected) - let rheo_spine = RheoSpine::build(root, Some(merge), crate::OutputFormat::Pdf)?; + let spine_config: &dyn crate::config::SpineConfig = merge; + let rheo_spine = RheoSpine::build(root, Some(spine_config), crate::OutputFormat::Pdf)?; debug!(file_count = rheo_spine.source.len(), "built PDF spine"); diff --git a/src/rs/project.rs b/src/rs/project.rs index b812dfa..637b5f4 100644 --- a/src/rs/project.rs +++ b/src/rs/project.rs @@ -1,4 +1,4 @@ -use crate::config::Spine; +use crate::config::EpubSpine; use crate::formats::pdf::DocumentTitle; use crate::{Result, RheoConfig, RheoError}; use std::path::{Path, PathBuf}; @@ -212,7 +212,7 @@ fn apply_smart_defaults( mode: ProjectMode, ) -> RheoConfig { // Generate human-readable title from project/file name - let title = DocumentTitle::to_readable_name(project_name); + let title = Some(DocumentTitle::to_readable_name(project_name)); // Apply EPUB defaults if spine not configured if config.epub.spine.is_none() { @@ -220,11 +220,7 @@ fn apply_smart_defaults( ProjectMode::SingleFile => vec![], // Empty: will auto-discover single file ProjectMode::Directory => vec!["**/*.typ".to_string()], // All files }; - config.epub.spine = Some(Spine { - title, - vertebrae, - merge: None, - }); + config.epub.spine = Some(EpubSpine { title, vertebrae }); } config @@ -347,7 +343,7 @@ mod tests { // Should have default spine config for EPUB assert!(project.config.epub.spine.is_some()); let merge = project.config.epub.spine.as_ref().unwrap(); - assert_eq!(merge.title, "My Document"); + assert_eq!(merge.title.as_ref().unwrap(), "My Document"); assert!(merge.vertebrae.is_empty()); } @@ -364,7 +360,7 @@ mod tests { let merge = project.config.epub.spine.as_ref().unwrap(); assert_eq!(merge.vertebrae, vec!["**/*.typ"]); // Title should be based on temp directory name (will vary) - assert!(!merge.title.is_empty()); + assert!(merge.title.is_some()); } #[test] @@ -388,7 +384,7 @@ vertebrae = ["custom.typ"] // Should preserve explicit config let merge = project.config.epub.spine.as_ref().unwrap(); - assert_eq!(merge.title, "Custom Title"); + assert_eq!(merge.title.clone().unwrap(), "Custom Title"); assert_eq!(merge.vertebrae, vec!["custom.typ"]); } diff --git a/src/rs/reticulate/spine.rs b/src/rs/reticulate/spine.rs index 64c1e36..14d9a6f 100644 --- a/src/rs/reticulate/spine.rs +++ b/src/rs/reticulate/spine.rs @@ -1,4 +1,4 @@ -use crate::config::Spine; +use crate::config::SpineConfig; use crate::formats::pdf::{DocumentTitle, sanitize_label_name}; use crate::{OutputFormat, Result, RheoError, TYP_EXT}; use std::collections::HashSet; @@ -10,7 +10,7 @@ use walkdir::WalkDir; #[derive(Debug, Clone)] pub struct RheoSpine { /// The name of the file or website that the spine will generate. - pub title: String, + pub title: Option, /// Whether or not the source has been merged into a single file. /// This is only false in the case of HTML currently. @@ -38,7 +38,7 @@ impl RheoSpine { /// A RheoSpine containing transformed Typst sources ready for compilation. pub fn build( root: &Path, - spine_config: Option<&Spine>, + spine_config: Option<&dyn SpineConfig>, output_format: OutputFormat, ) -> Result { // Generate spine: ordered list of .typ files @@ -49,7 +49,7 @@ impl RheoSpine { // Determine if we should merge sources based on format and config let should_merge = match output_format { - OutputFormat::Pdf => spine_config.and_then(|s| s.merge).unwrap_or(false), + OutputFormat::Pdf => spine_config.and_then(|s| s.merge()).unwrap_or(false), OutputFormat::Html | OutputFormat::Epub => false, }; @@ -90,10 +90,8 @@ impl RheoSpine { sources }; - // Extract title from spine config, or use "Untitled" as fallback - let title = spine_config - .map(|s| s.title.clone()) - .unwrap_or_else(|| "Untitled".to_string()); + // Extract title from spine config + let title = spine_config.and_then(|s| s.title().map(String::from)); Ok(RheoSpine { title, @@ -209,7 +207,7 @@ fn collect_one_typst_file(root: &Path) -> Result> { /// - Spine vertebrae matched no .typ files pub fn generate_spine( root: &Path, - spine_config: Option<&Spine>, + spine_config: Option<&dyn SpineConfig>, require_spine: bool, ) -> Result> { // PDF mode: spine config is required @@ -225,13 +223,13 @@ pub fn generate_spine( // Empty vertebrae pattern: auto-discover single file only // This is used for single-file mode with default EPUB spine config - Some(spine) if spine.vertebrae.is_empty() => collect_one_typst_file(root), + Some(spine) if spine.vertebrae().is_empty() => collect_one_typst_file(root), // Vertebrae is specified // Process glob patterns from spine config Some(spine) => { let mut typst_files = Vec::new(); - for pattern in &spine.vertebrae { + for pattern in spine.vertebrae() { let glob_pattern = root.join(pattern).display().to_string(); let glob = glob::glob(&glob_pattern).map_err(|e| { RheoError::project_config(format!("invalid glob pattern '{}': {}", pattern, e)) @@ -267,6 +265,7 @@ pub fn generate_spine( #[cfg(test)] mod tests { use super::*; + use crate::config::{HtmlSpine, PdfSpine}; use std::fs; use tempfile::TempDir; @@ -332,14 +331,13 @@ mod tests { } #[test] - fn test_generate_spine_with_merge_config() { + fn test_generate_spine_with_html_config() { let temp = create_test_dir_with_files(&["a.typ", "b.typ", "c.typ"]); - let merge = Spine { - title: "Test".to_string(), + let spine = HtmlSpine { + title: Some("Test".to_string()), vertebrae: vec!["*.typ".to_string()], - merge: None, }; - let result = generate_spine(temp.path(), Some(&merge), false); + let result = generate_spine(temp.path(), Some(&spine), false); assert!(result.is_ok()); let files = result.unwrap(); assert_eq!(files.len(), 3); @@ -353,8 +351,8 @@ mod tests { "chapters/ch2.typ", "appendix.typ", ]); - let merge = Spine { - title: "Book".to_string(), + let spine = PdfSpine { + title: Some("Book".to_string()), vertebrae: vec![ "cover.typ".to_string(), "chapters/*.typ".to_string(), @@ -362,7 +360,7 @@ mod tests { ], merge: None, }; - let result = generate_spine(temp.path(), Some(&merge), true); + let result = generate_spine(temp.path(), Some(&spine), true); assert!(result.is_ok()); let files = result.unwrap(); assert_eq!(files.len(), 4); @@ -389,14 +387,14 @@ mod tests { } #[test] - fn test_generate_spine_merge_no_matches_error() { + fn test_generate_spine_no_matches_error() { let temp = create_test_dir_with_files(&["readme.md"]); - let merge = Spine { - title: "Test".to_string(), + let spine = PdfSpine { + title: None, vertebrae: vec!["*.typ".to_string()], merge: None, }; - let result = generate_spine(temp.path(), Some(&merge), false); + let result = generate_spine(temp.path(), Some(&spine), false); assert!(result.is_err()); assert!( result @@ -409,13 +407,13 @@ mod tests { #[test] fn test_generate_spine_empty_pattern_single_file() { let temp = create_test_dir_with_files(&["single.typ"]); - let merge = Spine { - title: "Test".to_string(), + let spine = PdfSpine { + title: Some("Test".to_string()), vertebrae: vec![], // Empty vertebrae merge: None, }; - let result = generate_spine(temp.path(), Some(&merge), false); + let result = generate_spine(temp.path(), Some(&spine), false); assert!(result.is_ok()); let files = result.unwrap(); assert_eq!(files.len(), 1); @@ -425,13 +423,13 @@ mod tests { #[test] fn test_generate_spine_empty_pattern_multiple_files_error() { let temp = create_test_dir_with_files(&["a.typ", "b.typ"]); - let merge = Spine { - title: "Test".to_string(), + let spine = PdfSpine { + title: Some("Test".to_string()), vertebrae: vec![], // Empty vertebrae with multiple files merge: None, }; - let result = generate_spine(temp.path(), Some(&merge), false); + let result = generate_spine(temp.path(), Some(&spine), false); assert!(result.is_err()); assert!( result diff --git a/src/rs/validation.rs b/src/rs/validation.rs index c02d523..fa1b4b1 100644 --- a/src/rs/validation.rs +++ b/src/rs/validation.rs @@ -1,4 +1,4 @@ -use crate::config::{EpubConfig, HtmlConfig, PdfConfig, Spine}; +use crate::config::{EpubConfig, EpubSpine, HtmlConfig, HtmlSpine, PdfConfig, PdfSpine}; use crate::manifest_version::ManifestVersion; use crate::{Result, RheoConfig, RheoError}; use tracing::warn; @@ -50,10 +50,6 @@ impl ValidateConfig for HtmlConfig { fn validate(&self) -> Result<()> { if let Some(spine) = &self.spine { spine.validate()?; - // Warn if merge field is set - it's ignored for HTML - if spine.merge.is_some() { - warn!("html.spine.merge field is ignored (HTML always produces per-file output)"); - } } // Stylesheet and font paths are validated at usage time Ok(()) @@ -64,151 +60,200 @@ impl ValidateConfig for EpubConfig { fn validate(&self) -> Result<()> { if let Some(spine) = &self.spine { spine.validate()?; - // Warn if merge field is set - it's ignored for EPUB - if spine.merge.is_some() { - warn!("epub.spine.merge field is ignored (EPUB always merges into single .epub)"); - } } Ok(()) } } -impl ValidateConfig for Spine { +/// Validate glob patterns in a vertebrae list. +fn validate_vertebrae(vertebrae: &[String]) -> Result<()> { + for pattern in vertebrae { + glob::Pattern::new(pattern).map_err(|e| { + RheoError::project_config(format!("invalid glob pattern '{}': {}", pattern, e)) + })?; + } + Ok(()) +} + +impl ValidateConfig for PdfSpine { fn validate(&self) -> Result<()> { - // Empty vertebrae is allowed - it has special behavior for single-file mode - // See spine.rs lines 62-87 - - // Validate that all glob patterns are syntactically valid - for pattern in &self.vertebrae { - glob::Pattern::new(pattern).map_err(|e| { - RheoError::project_config(format!("invalid glob pattern '{}': {}", pattern, e)) - })?; + validate_vertebrae(&self.vertebrae)?; + + // PDF spine with merge=true requires a title + if self.merge == Some(true) && self.title.is_none() { + return Err(RheoError::project_config( + "pdf.spine.title is required when merge=true", + )); } Ok(()) } } +impl ValidateConfig for EpubSpine { + fn validate(&self) -> Result<()> { + validate_vertebrae(&self.vertebrae)?; + // EPUB always merges, title is optional (can be inferred) + Ok(()) + } +} + +impl ValidateConfig for HtmlSpine { + fn validate(&self) -> Result<()> { + validate_vertebrae(&self.vertebrae)?; + // HTML never merges, title is optional + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn test_merge_validate_empty_spine() { - let merge = Spine { - title: "Test".to_string(), + fn test_pdf_spine_validate_empty() { + let spine = PdfSpine { + title: Some("Test".to_string()), vertebrae: vec![], merge: None, }; - assert!(merge.validate().is_ok()); + assert!(spine.validate().is_ok()); } #[test] - fn test_merge_validate_valid_patterns() { - let merge = Spine { - title: "Test".to_string(), + fn test_pdf_spine_validate_valid_patterns() { + let spine = PdfSpine { + title: Some("Test".to_string()), vertebrae: vec!["*.typ".to_string(), "chapters/**/*.typ".to_string()], merge: None, }; - assert!(merge.validate().is_ok()); + assert!(spine.validate().is_ok()); } #[test] - fn test_merge_validate_invalid_pattern() { - let merge = Spine { - title: "Test".to_string(), + fn test_pdf_spine_validate_invalid_pattern() { + let spine = PdfSpine { + title: Some("Test".to_string()), vertebrae: vec!["[invalid".to_string()], // Unclosed bracket is invalid glob merge: None, }; - let result = merge.validate(); + let result = spine.validate(); assert!(result.is_err()); let err_msg = format!("{}", result.unwrap_err()); assert!(err_msg.contains("invalid glob pattern")); } #[test] - fn test_pdf_config_validate_with_valid_merge() { - let merge = Spine { - title: "Test".to_string(), + fn test_pdf_spine_merge_true_requires_title() { + let spine = PdfSpine { + title: None, + vertebrae: vec!["*.typ".to_string()], + merge: Some(true), + }; + let result = spine.validate(); + assert!(result.is_err()); + let err_msg = format!("{}", result.unwrap_err()); + assert!(err_msg.contains("title is required when merge=true")); + } + + #[test] + fn test_pdf_spine_merge_true_with_title_ok() { + let spine = PdfSpine { + title: Some("My Book".to_string()), + vertebrae: vec!["*.typ".to_string()], + merge: Some(true), + }; + assert!(spine.validate().is_ok()); + } + + #[test] + fn test_pdf_spine_merge_false_no_title_ok() { + let spine = PdfSpine { + title: None, + vertebrae: vec!["*.typ".to_string()], + merge: Some(false), + }; + assert!(spine.validate().is_ok()); + } + + #[test] + fn test_pdf_config_validate_with_valid_spine() { + let spine = PdfSpine { + title: Some("Test".to_string()), vertebrae: vec!["*.typ".to_string()], merge: None, }; - let config = PdfConfig { spine: Some(merge) }; + let config = PdfConfig { spine: Some(spine) }; assert!(config.validate().is_ok()); } #[test] - fn test_pdf_config_validate_with_invalid_merge() { - let merge = Spine { - title: "Test".to_string(), + fn test_pdf_config_validate_with_invalid_spine() { + let spine = PdfSpine { + title: Some("Test".to_string()), vertebrae: vec!["[invalid".to_string()], merge: None, }; - let config = PdfConfig { spine: Some(merge) }; + let config = PdfConfig { spine: Some(spine) }; let result = config.validate(); assert!(result.is_err()); } #[test] - fn test_pdf_config_validate_no_merge() { + fn test_pdf_config_validate_no_spine() { let config = PdfConfig { spine: None }; assert!(config.validate().is_ok()); } #[test] - fn test_epub_config_validate() { - let merge = Spine { - title: "Test".to_string(), + fn test_epub_spine_validate() { + let spine = EpubSpine { + title: Some("Test".to_string()), vertebrae: vec!["*.typ".to_string()], - merge: None, - }; - let config = EpubConfig { - identifier: None, - date: None, - spine: Some(merge), }; - assert!(config.validate().is_ok()); + assert!(spine.validate().is_ok()); } #[test] - fn test_html_config_validate() { - let config = HtmlConfig { - stylesheets: vec!["style.css".to_string()], - fonts: vec![], - spine: None, + fn test_epub_spine_validate_no_title_ok() { + let spine = EpubSpine { + title: None, + vertebrae: vec!["*.typ".to_string()], }; - assert!(config.validate().is_ok()); + // EPUB title is optional (can be inferred) + assert!(spine.validate().is_ok()); } #[test] - fn test_html_config_warns_on_merge_field() { - let spine = Spine { - title: "Test".to_string(), + fn test_epub_config_validate() { + let spine = EpubSpine { + title: Some("Test".to_string()), vertebrae: vec!["*.typ".to_string()], - merge: Some(true), }; - let config = HtmlConfig { - stylesheets: vec![], - fonts: vec![], + let config = EpubConfig { + identifier: None, + date: None, spine: Some(spine), }; - // Should validate successfully but log warning assert!(config.validate().is_ok()); } #[test] - fn test_epub_config_warns_on_merge_field() { - let spine = Spine { - title: "Test".to_string(), + fn test_html_spine_validate() { + let spine = HtmlSpine { + title: Some("Test".to_string()), vertebrae: vec!["*.typ".to_string()], - merge: Some(false), }; - let config = EpubConfig { - identifier: None, - date: None, - spine: Some(spine), + assert!(spine.validate().is_ok()); + } + + #[test] + fn test_html_config_validate() { + let config = HtmlConfig { + stylesheets: vec!["style.css".to_string()], + fonts: vec![], + spine: None, }; - // Should validate successfully but log warning assert!(config.validate().is_ok()); } diff --git a/tests/cases/pdf_merge_false/a.typ b/tests/cases/pdf_merge_false/a.typ new file mode 100644 index 0000000..371d2ea --- /dev/null +++ b/tests/cases/pdf_merge_false/a.typ @@ -0,0 +1,5 @@ +#set document(title: "doc1") + += A + +The first doc. diff --git a/tests/cases/pdf_merge_false/b.typ b/tests/cases/pdf_merge_false/b.typ new file mode 100644 index 0000000..05b0ba0 --- /dev/null +++ b/tests/cases/pdf_merge_false/b.typ @@ -0,0 +1,6 @@ +#set document(title: "B") + += B + +THIS IS A DRAFT, DO NOT RENDER. + diff --git a/tests/cases/pdf_merge_false/c.typ b/tests/cases/pdf_merge_false/c.typ new file mode 100644 index 0000000..f8931d9 --- /dev/null +++ b/tests/cases/pdf_merge_false/c.typ @@ -0,0 +1,5 @@ +#set document(title: "doc2") + += C + +The second doc. diff --git a/tests/cases/pdf_merge_false/rheo.toml b/tests/cases/pdf_merge_false/rheo.toml new file mode 100644 index 0000000..375f406 --- /dev/null +++ b/tests/cases/pdf_merge_false/rheo.toml @@ -0,0 +1,16 @@ +version = "0.1.0" + +formats = ["pdf", "html"] + +[pdf.spine] +vertebrae = [ + "a.typ", + "c.typ" +] +merge = false + +[html.spine] +vertebrae = [ + "a.typ", + "c.typ" +] diff --git a/tests/harness.rs b/tests/harness.rs index 246d3c3..e586261 100644 --- a/tests/harness.rs +++ b/tests/harness.rs @@ -27,6 +27,7 @@ use std::path::PathBuf; #[test_case("tests/cases/links_with_fragments")] #[test_case("tests/cases/multiple_links_inline.typ")] #[test_case("tests/cases/pdf_individual")] +#[test_case("tests/cases/pdf_merge_false")] #[test_case("tests/cases/relative_path_links")] #[test_case("tests/cases/target_function")] #[test_case("tests/cases/target_function_in_module")] diff --git a/tests/ref/examples/pdf_merge_false/html/a.html b/tests/ref/examples/pdf_merge_false/html/a.html new file mode 100644 index 0000000..6ce805b --- /dev/null +++ b/tests/ref/examples/pdf_merge_false/html/a.html @@ -0,0 +1,11 @@ + + + + doc1 + + +

A

+

The first doc.

+ + + \ No newline at end of file diff --git a/tests/ref/examples/pdf_merge_false/html/c.html b/tests/ref/examples/pdf_merge_false/html/c.html new file mode 100644 index 0000000..f92389e --- /dev/null +++ b/tests/ref/examples/pdf_merge_false/html/c.html @@ -0,0 +1,11 @@ + + + + doc2 + + +

C

+

The second doc.

+ + + \ No newline at end of file diff --git a/tests/ref/examples/pdf_merge_false/html/style.metadata.json b/tests/ref/examples/pdf_merge_false/html/style.metadata.json new file mode 100644 index 0000000..dbd27db --- /dev/null +++ b/tests/ref/examples/pdf_merge_false/html/style.metadata.json @@ -0,0 +1,6 @@ +{ + "filetype": "css", + "file_size": 2603, + "path": "src/css/style.css", + "hash": "02d37b0efd17dd8c8570eb1e3a71db78cde94f9de017e354aca839b6134ca93e" +} \ No newline at end of file diff --git a/tests/ref/examples/pdf_merge_false/pdf/a.metadata.json b/tests/ref/examples/pdf_merge_false/pdf/a.metadata.json new file mode 100644 index 0000000..7d2f94f --- /dev/null +++ b/tests/ref/examples/pdf_merge_false/pdf/a.metadata.json @@ -0,0 +1,5 @@ +{ + "filetype": "pdf", + "file_size": 2605, + "page_count": 1 +} \ No newline at end of file diff --git a/tests/ref/examples/pdf_merge_false/pdf/c.metadata.json b/tests/ref/examples/pdf_merge_false/pdf/c.metadata.json new file mode 100644 index 0000000..7d2f94f --- /dev/null +++ b/tests/ref/examples/pdf_merge_false/pdf/c.metadata.json @@ -0,0 +1,5 @@ +{ + "filetype": "pdf", + "file_size": 2605, + "page_count": 1 +} \ No newline at end of file