Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 63 additions & 4 deletions src/rs/cli.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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<HashSet<&'a PathBuf>> {
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
Expand Down Expand Up @@ -265,15 +315,24 @@ 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())?;
}

// Per-file compilation
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)
Expand All @@ -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 } => {
Expand Down Expand Up @@ -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)
Expand Down
127 changes: 104 additions & 23 deletions src/rs/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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).
/// Results are sorted lexicographically.
/// Example: ["cover.typ", "chapters/**"]
pub vertebrae: Vec<String>,

/// 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<bool>,
}

/// 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<String>,

/// 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<String>,
}

/// 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<String>,

/// 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<String>,
}

/// 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<bool> {
None
}
}

impl SpineConfig for PdfSpine {
fn title(&self) -> Option<&str> {
self.title.as_deref()
}

fn vertebrae(&self) -> &[String] {
&self.vertebrae
}

fn merge(&self) -> Option<bool> {
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 {
Expand All @@ -136,7 +217,7 @@ pub struct HtmlConfig {
/// Configuration for an HTML spine (sitemap/navbar).
/// HTML never merges vertebrae.
#[serde(default)]
pub spine: Option<Spine>,
pub spine: Option<HtmlSpine>,
}

impl Default for HtmlConfig {
Expand All @@ -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<Spine>,
pub spine: Option<PdfSpine>,
}

/// EPUB output configuration
Expand All @@ -173,7 +254,7 @@ pub struct EpubConfig {
pub date: Option<DateTime<Utc>>,

/// Configuration for an EPUB spine with multiple chapters.
pub spine: Option<Spine>,
pub spine: Option<EpubSpine>,
}

impl RheoConfig {
Expand Down Expand Up @@ -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));
}
Expand All @@ -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));
}
Expand All @@ -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);
}
Expand All @@ -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]
Expand All @@ -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]
Expand All @@ -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());
}

Expand Down
20 changes: 10 additions & 10 deletions src/rs/formats/epub/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/rs/formats/pdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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");

Expand Down
Loading