diff --git a/Cargo.lock b/Cargo.lock index 473ce10..9620918 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2900,7 +2900,6 @@ dependencies = [ "ecow", "glob", "globset", - "lazy_static", "notify", "opener", "parking_lot", diff --git a/Cargo.toml b/Cargo.toml index 1e9b646..5dd7d3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,7 +81,6 @@ itertools = "0.14.0" # Other dependencies ecow = "0.2" regex = "1.10" -lazy_static = "1.4" serde-xml-rs = "0.8.2" # Development override: tracks typst git main. diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs new file mode 100644 index 0000000..4ac3429 --- /dev/null +++ b/crates/cli/src/args.rs @@ -0,0 +1,171 @@ +use clap::{Arg, ArgAction, ArgMatches, Command}; +use rheo_core::FormatPlugin; + +pub(crate) fn build_cli(plugins: &[Box]) -> Command { + Command::new("rheo") + .about("A tool for flowing Typst documents into publishable outputs") + .version(env!("CARGO_PKG_VERSION")) + .arg( + Arg::new("quiet") + .short('q') + .long("quiet") + .action(ArgAction::SetTrue) + .conflicts_with("verbose") + .global(true) + .help("Decrease output verbosity (errors only)"), + ) + .arg( + Arg::new("verbose") + .short('v') + .long("verbose") + .action(ArgAction::SetTrue) + .conflicts_with("quiet") + .global(true) + .help("Increase output verbosity (show debug information)"), + ) + .subcommand(build_compile_command(plugins)) + .subcommand(build_watch_command(plugins)) + .subcommand(build_clean_command()) + .subcommand(build_init_command()) + .subcommand_required(true) + .arg_required_else_help(true) +} + +pub(crate) fn add_format_flags(mut cmd: Command, plugins: &[Box]) -> Command { + for plugin in plugins { + cmd = cmd.arg( + Arg::new(plugin.name()) + .long(plugin.name()) + .action(ArgAction::SetTrue) + .help(format!("Compile to {} only", plugin.name())), + ); + } + cmd +} + +pub(crate) fn build_compile_command(plugins: &[Box]) -> Command { + let cmd = Command::new("compile") + .about("Compile Typst documents to PDF, HTML, and/or EPUB") + .arg( + Arg::new("path") + .required(true) + .index(1) + .help("Path to project directory or single .typ file"), + ) + .arg( + Arg::new("config") + .long("config") + .value_name("PATH") + .help("Path to custom rheo.toml config file"), + ) + .arg( + Arg::new("build-dir") + .long("build-dir") + .help("Build output directory (overrides rheo.toml if set)"), + ); + add_format_flags(cmd, plugins) +} + +pub(crate) fn build_watch_command(plugins: &[Box]) -> Command { + let cmd = Command::new("watch") + .about("Watch Typst documents and recompile on changes") + .arg( + Arg::new("path") + .required(true) + .index(1) + .help("Path to project directory or single .typ file"), + ) + .arg( + Arg::new("config") + .long("config") + .value_name("PATH") + .help("Path to custom rheo.toml config file"), + ) + .arg( + Arg::new("build-dir") + .long("build-dir") + .help("Build output directory (overrides rheo.toml if set)"), + ) + .arg( + Arg::new("open") + .long("open") + .action(ArgAction::SetTrue) + .help("Open output in appropriate viewer (HTML opens in browser with live reload)"), + ); + add_format_flags(cmd, plugins) +} + +pub(crate) fn build_clean_command() -> Command { + Command::new("clean") + .about("Clean build artifacts for a project") + .arg( + Arg::new("path") + .index(1) + .default_value(".") + .help("Path to project directory or single .typ file"), + ) + .arg( + Arg::new("config") + .long("config") + .value_name("PATH") + .help("Path to custom rheo.toml config file"), + ) + .arg( + Arg::new("build-dir") + .long("build-dir") + .help("Build output directory to clean (overrides rheo.toml if set)"), + ) +} + +pub(crate) fn build_init_command() -> Command { + Command::new("init") + .about("Initialize a new Rheo project") + .arg( + Arg::new("path") + .required(true) + .index(1) + .help("Path to the new project directory"), + ) +} + +/// Extract enabled format names from arg matches (names of plugins whose flags are set). +pub(crate) fn enabled_formats_from_matches( + matches: &ArgMatches, + plugins: &[Box], +) -> Vec { + plugins + .iter() + .filter(|p| matches.get_flag(p.name())) + .map(|p| p.name().to_string()) + .collect() +} + +/// Determine which format names to compile based on CLI flags and config defaults. +/// +/// Priority: +/// 1. CLI flags (any set → use only those) +/// 2. Config `formats` list (non-empty → use that) +/// 3. All plugins (fallback) +pub(crate) fn determine_formats( + enabled_from_cli: Vec, + config_defaults: &[String], + all: &[Box], +) -> Vec { + if !enabled_from_cli.is_empty() { + return enabled_from_cli; + } + if !config_defaults.is_empty() { + return config_defaults.to_vec(); + } + all.iter().map(|p| p.name().to_string()).collect() +} + +/// Filter `all_plugins()` to only those whose names appear in `formats`. +pub(crate) fn plugins_for_formats( + formats: &[String], + all: Vec>, +) -> Vec> { + all.into_iter() + .filter(|p| formats.iter().any(|f| f == p.name())) + .collect() +} diff --git a/crates/cli/src/init.rs b/crates/cli/src/init.rs new file mode 100644 index 0000000..06c75bf --- /dev/null +++ b/crates/cli/src/init.rs @@ -0,0 +1,82 @@ +use rheo_core::{FormatPlugin, Result, RheoError, manifest_version}; +use std::fs; +use std::path::Path; +use tracing::{debug, info}; + +pub(crate) fn init_project( + target_dir: &Path, + all_plugins: fn() -> Vec>, +) -> Result<()> { + if target_dir.exists() { + return Err(RheoError::project_config(format!( + "directory '{}' already exists", + target_dir.display() + ))); + } + + fs::create_dir_all(target_dir).map_err(|e| RheoError::io(e, "creating target directory"))?; + + let toml_content = + rheo_core::init_templates::RHEO_TOML.replace("{{VERSION}}", manifest_version::CURRENT); + fs::write(target_dir.join("rheo.toml"), toml_content) + .map_err(|e| RheoError::io(e, "writing rheo.toml"))?; + + let content_dir = target_dir.join("content"); + fs::create_dir_all(&content_dir).map_err(|e| RheoError::io(e, "creating content directory"))?; + + fs::write( + content_dir.join("index.typ"), + rheo_core::init_templates::CONTENT_INDEX_TYP, + ) + .map_err(|e| RheoError::io(e, "writing index.typ"))?; + fs::write( + content_dir.join("about.typ"), + rheo_core::init_templates::CONTENT_ABOUT_TYP, + ) + .map_err(|e| RheoError::io(e, "writing about.typ"))?; + + fs::write( + content_dir.join("references.bib"), + rheo_core::init_templates::CONTENT_REFERENCES_BIB, + ) + .map_err(|e| RheoError::io(e, "writing references.bib"))?; + + let img_dir = content_dir.join("img"); + fs::create_dir_all(&img_dir).map_err(|e| RheoError::io(e, "creating img directory"))?; + fs::write( + img_dir.join("header.svg"), + rheo_core::init_templates::CONTENT_IMG_HEADER_SVG, + ) + .map_err(|e| RheoError::io(e, "writing header.svg"))?; + + // Collect template contributions from all plugins + let mut plugin_templates: std::collections::HashMap<&str, (&str, &str)> = + std::collections::HashMap::new(); + for plugin in all_plugins() { + for (path, content) in plugin.init_templates() { + if let Some((existing_plugin, _)) = plugin_templates.get(path) { + return Err(RheoError::project_config(format!( + "template path conflict: both '{}' and '{}' plugins want to write '{}'", + existing_plugin, + plugin.name(), + path + ))); + } + plugin_templates.insert(path, (plugin.name(), content)); + } + } + + for (path, (plugin_name, content)) in plugin_templates { + let file_path = target_dir.join(path); + if let Some(parent_dir) = file_path.parent() { + fs::create_dir_all(parent_dir) + .map_err(|e| RheoError::io(e, "creating plugin template directory"))?; + } + fs::write(&file_path, content) + .map_err(|e| RheoError::io(e, format!("writing plugin template file '{}'", path)))?; + debug!(plugin = plugin_name, path = %path, "wrote plugin template file"); + } + + info!(path = %target_dir.display(), "initialized rheo project"); + Ok(()) +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 5a61ebc..bd7c0b3 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,19 +1,12 @@ -use clap::{Arg, ArgAction, ArgMatches, Command}; -use rheo_core::OpenHandle; -use rheo_core::compile::RheoCompileOptions; -use rheo_core::config::PluginSection; -use rheo_core::manifest_version; -use rheo_core::output::OutputConfig; -use rheo_core::project::{ProjectConfig, ProjectMode}; -use rheo_core::results::CompilationResults; -use rheo_core::reticulate::{SpineDocument, TracedSpine, generate_bundle_entry}; +pub mod args; +pub mod init; +pub mod orchestrate; + +use clap::ArgMatches; use rheo_core::watch::{WatchEvent, watch_project}; -use rheo_core::world::RheoWorld; -use rheo_core::{FormatPlugin, PluginContext, Result, RheoError}; -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; -use tracing::{debug, error, info, warn}; +use rheo_core::{FormatPlugin, OpenHandle, Result}; +use std::path::PathBuf; +use tracing::info; // Re-export logging functionality pub use rheo_core::logging; @@ -32,7 +25,7 @@ pub fn init_logging(verbose: bool, quiet: bool) -> Result<()> { /// Returns all known format plugins. Adding a new plugin here is the only /// change needed in `cli` to support a new output format. -fn all_plugins() -> Vec> { +pub fn all_plugins() -> Vec> { vec![ Box::new(rheo_html::HtmlPlugin), Box::new(rheo_pdf::PdfPlugin), @@ -40,588 +33,10 @@ fn all_plugins() -> Vec> { ] } -/// Build the top-level clap `Command`, adding per-plugin `--` flags -/// dynamically to `compile` and `watch` subcommands. -fn build_cli() -> Command { - let plugins = all_plugins(); - Command::new("rheo") - .about("A tool for flowing Typst documents into publishable outputs") - .version(env!("CARGO_PKG_VERSION")) - .arg( - Arg::new("quiet") - .short('q') - .long("quiet") - .action(ArgAction::SetTrue) - .conflicts_with("verbose") - .global(true) - .help("Decrease output verbosity (errors only)"), - ) - .arg( - Arg::new("verbose") - .short('v') - .long("verbose") - .action(ArgAction::SetTrue) - .conflicts_with("quiet") - .global(true) - .help("Increase output verbosity (show debug information)"), - ) - .subcommand(build_compile_command(&plugins)) - .subcommand(build_watch_command(&plugins)) - .subcommand(build_clean_command()) - .subcommand(build_init_command()) - .subcommand_required(true) - .arg_required_else_help(true) -} - -fn add_format_flags(mut cmd: Command, plugins: &[Box]) -> Command { - for plugin in plugins { - cmd = cmd.arg( - Arg::new(plugin.name()) - .long(plugin.name()) - .action(ArgAction::SetTrue) - .help(format!("Compile to {} only", plugin.name())), - ); - } - cmd -} - -fn build_compile_command(plugins: &[Box]) -> Command { - let cmd = Command::new("compile") - .about("Compile Typst documents to PDF, HTML, and/or EPUB") - .arg( - Arg::new("path") - .required(true) - .index(1) - .help("Path to project directory or single .typ file"), - ) - .arg( - Arg::new("config") - .long("config") - .value_name("PATH") - .help("Path to custom rheo.toml config file"), - ) - .arg( - Arg::new("build-dir") - .long("build-dir") - .help("Build output directory (overrides rheo.toml if set)"), - ); - add_format_flags(cmd, plugins) -} - -fn build_watch_command(plugins: &[Box]) -> Command { - let cmd = Command::new("watch") - .about("Watch Typst documents and recompile on changes") - .arg( - Arg::new("path") - .required(true) - .index(1) - .help("Path to project directory or single .typ file"), - ) - .arg( - Arg::new("config") - .long("config") - .value_name("PATH") - .help("Path to custom rheo.toml config file"), - ) - .arg( - Arg::new("build-dir") - .long("build-dir") - .help("Build output directory (overrides rheo.toml if set)"), - ) - .arg( - Arg::new("open") - .long("open") - .action(ArgAction::SetTrue) - .help("Open output in appropriate viewer (HTML opens in browser with live reload)"), - ); - add_format_flags(cmd, plugins) -} - -fn build_clean_command() -> Command { - Command::new("clean") - .about("Clean build artifacts for a project") - .arg( - Arg::new("path") - .index(1) - .default_value(".") - .help("Path to project directory or single .typ file"), - ) - .arg( - Arg::new("config") - .long("config") - .value_name("PATH") - .help("Path to custom rheo.toml config file"), - ) - .arg( - Arg::new("build-dir") - .long("build-dir") - .help("Build output directory to clean (overrides rheo.toml if set)"), - ) -} - -fn build_init_command() -> Command { - Command::new("init") - .about("Initialize a new Rheo project") - .arg( - Arg::new("path") - .required(true) - .index(1) - .help("Path to the new project directory"), - ) -} - -/// Extract enabled format names from arg matches (names of plugins whose flags are set). -fn enabled_formats_from_matches( - matches: &ArgMatches, - plugins: &[Box], -) -> Vec { - plugins - .iter() - .filter(|p| matches.get_flag(p.name())) - .map(|p| p.name().to_string()) - .collect() -} - -/// Determine which format names to compile based on CLI flags and config defaults. -/// -/// Priority: -/// 1. CLI flags (any set → use only those) -/// 2. Config `formats` list (non-empty → use that) -/// 3. All plugins (fallback) -fn determine_formats( - enabled_from_cli: Vec, - config_defaults: &[String], - all: &[Box], -) -> Vec { - if !enabled_from_cli.is_empty() { - return enabled_from_cli; - } - if !config_defaults.is_empty() { - return config_defaults.to_vec(); - } - all.iter().map(|p| p.name().to_string()).collect() -} - -/// Filter `all_plugins()` to only those whose names appear in `formats`. -fn plugins_for_formats( - formats: &[String], - all: Vec>, -) -> Vec> { - all.into_iter() - .filter(|p| formats.iter().any(|f| f == p.name())) - .collect() -} - -/// Pre-compiled setup context for compilation commands. -struct CompilationContext { - project: ProjectConfig, - plugins: Vec>, - output_config: OutputConfig, -} - -/// Resolve a path relative to a base directory. -fn resolve_path(base: &Path, path: &Path) -> PathBuf { - if path.is_absolute() { - path.to_path_buf() - } else { - base.join(path) - } -} - -/// Resolve build directory with priority: CLI arg > config > default. -fn resolve_build_dir( - project: &ProjectConfig, - cli_build_dir: Option, -) -> Result> { - if let Some(cli_path) = cli_build_dir { - let cwd = - std::env::current_dir().map_err(|e| RheoError::io(e, "getting current directory"))?; - debug!(dir = %cli_path.display(), "build directory"); - Ok(Some(resolve_path(&cwd, &cli_path))) - } else if let Some(config_path) = &project.config.build_dir { - let resolved = resolve_path(&project.root, Path::new(config_path)); - debug!(dir = %resolved.display(), "build directory"); - Ok(Some(resolved)) - } else { - Ok(None) - } -} - -/// Bundle compilation: generate bundle entry, inject into world, compile once. -/// Used by all plugins (HTML, PDF, EPUB) with the bundle API. -#[allow(clippy::too_many_arguments)] -fn compile_with_bundle( - plugin: &dyn FormatPlugin, - output: &Path, - project: &ProjectConfig, - output_config: &OutputConfig, - spine: &TracedSpine, - plugin_section: &PluginSection, - resolved_inputs: HashMap<&'static str, PathBuf>, - results: &mut CompilationResults, - compilation_root: &Path, -) -> Result<()> { - let plugin_library = plugin.typst_library().map(|s| s.to_string()); - let mut bundle_world = RheoWorld::new( - compilation_root, - spine - .documents - .first() - .map(|d| d.path.as_path()) - .unwrap_or(compilation_root), - plugin_library, - )?; - - let bundle_entry_source = generate_bundle_entry( - spine, - compilation_root, - plugin.name(), - plugin.typst_library().unwrap_or_default(), - ); - bundle_world.inject_bundle_entry(bundle_entry_source); - - let options = RheoCompileOptions::new(output, compilation_root, &mut bundle_world); - - let ctx = PluginContext { - project, - output_config, - options, - spine: spine.clone(), - config: plugin_section.clone(), - inputs: resolved_inputs, - }; - - match plugin.compile(ctx) { - Ok(_) => { - results.record_success(plugin.name()); - } - Err(e) => { - error!(error = %e, "{} compilation failed", plugin.name()); - results.record_failure(plugin.name()); - } - } - Ok(()) -} - -fn perform_compilation( - project: &ProjectConfig, - output_config: &OutputConfig, - plugins: &[Box], -) -> Result<()> { - if project.typ_files.is_empty() { - return Err(RheoError::project_config("no .typ files found in project")); - } - - let mut results = CompilationResults::new(); - - for plugin in plugins { - let plugin_output_dir = output_config.dir_for_plugin(plugin.name()); - std::fs::create_dir_all(&plugin_output_dir).map_err(|e| { - RheoError::io( - e, - format!("creating output directory for {}", plugin.name()), - ) - })?; - - // Resolve declared inputs - let mut resolved_inputs: HashMap<&'static str, PathBuf> = HashMap::new(); - for input in plugin.inputs() { - let src = project.root.join(&input.path); - if src.is_file() { - let dest = plugin_output_dir.join(&input.path); - std::fs::copy(&src, &dest).map_err(|e| { - RheoError::io( - e, - format!( - "copying plugin input '{}' from {} to {}", - input.name, - src.display(), - dest.display() - ), - ) - })?; - resolved_inputs.insert(input.name, dest); - } else if input.required { - return Err(RheoError::project_config(format!( - "plugin '{}' requires input '{}' at '{}' but it was not found", - plugin.name(), - input.name, - &input.path - ))); - } - } - - // Execute copy patterns (global + per-plugin) - let plugin_section_for_assets = project.config.plugin_section(plugin.name()); - for pattern in project - .config - .assets - .iter() - .chain(plugin_section_for_assets.assets.iter()) - { - let abs_pattern = project.root.join(pattern).display().to_string(); - let entries = glob::glob(&abs_pattern).map_err(|e| { - RheoError::project_config(format!("invalid copy pattern '{}': {}", pattern, e)) - })?; - let mut matched = false; - for entry in entries.filter_map(|e| e.ok()).filter(|p| p.is_file()) { - matched = true; - let rel = entry.strip_prefix(&project.root).unwrap_or(entry.as_path()); - let dest = plugin_output_dir.join(rel); - if let Some(parent) = dest.parent() { - std::fs::create_dir_all(parent).map_err(|e| { - RheoError::io( - e, - format!("creating directory for copy of {}", rel.display()), - ) - })?; - } - std::fs::copy(&entry, &dest).map_err(|e| { - RheoError::io( - e, - format!("copying {} to {}", entry.display(), dest.display()), - ) - })?; - debug!(src = %entry.display(), dest = %dest.display(), "copied file"); - } - if !matched { - debug!(pattern = %pattern, "copy pattern matched no files"); - } - } - - // Compute compilation root once (content_dir from config or project root) - let compilation_root = project - .config - .resolve_content_dir(&project.root) - .unwrap_or_else(|| project.root.clone()); - - // Resolve spine config and trace - let spine = if project.mode == ProjectMode::SingleFile { - // For single file mode, create a spine with just the single file - // Ignore spine config from rheo.toml - let file = &project.typ_files[0]; - TracedSpine { - title: None, - documents: vec![SpineDocument { - path: file.clone(), - is_bundle_entry: false, - }], - assets: vec![], - merge: false, - } - } else { - // For directory mode, use spine config from rheo.toml, or create default - let mut spine_cfg = project.config.spine_for_plugin(plugin.name()); - - // If no spine config exists, create a default one for auto-discovery - // This allows projects without rheo.toml to work with multiple .typ files - let default_spine; - if spine_cfg.is_none() { - use rheo_core::DocumentTitle; - use rheo_core::config::Spine; - default_spine = Spine { - title: Some(DocumentTitle::to_readable_name(&project.name)), - vertebrae: vec![], - merge: Some(plugin.default_merge()), - }; - spine_cfg = Some(&default_spine); - } - - // Get global and per-plugin asset patterns for assets - let plugin_section_for_assets = project.config.plugin_section(plugin.name()); - let assets_config: Vec = project - .config - .assets - .iter() - .chain(plugin_section_for_assets.assets.iter()) - .cloned() - .collect(); - - TracedSpine::trace( - &project.root, - &compilation_root, - spine_cfg, - &assets_config, - plugin.default_merge(), - )? - }; - - // Get full plugin section - let plugin_section = project.config.plugin_section(plugin.name()); - - // Determine output path based on merge mode - let output = if spine.merge { - plugin_output_dir - .join(&project.name) - .with_extension(plugin.output_extension()) - } else { - plugin_output_dir.clone() - }; - - compile_with_bundle( - plugin.as_ref(), - &output, - project, - output_config, - &spine, - &plugin_section, - resolved_inputs, - &mut results, - &compilation_root, - )?; - } - - let names: Vec<&str> = plugins.iter().map(|p| p.name()).collect(); - results.log_summary(&names); - - if results.has_failures() { - if names.iter().any(|name| results.get(name).succeeded > 0) { - Err(RheoError::project_config( - "some formats failed to compile".to_string(), - )) - } else { - Err(RheoError::project_config( - "all formats failed or no files were compiled".to_string(), - )) - } - } else { - info!("compilation complete"); - Ok(()) - } -} - -fn init_project(target_dir: &Path) -> Result<()> { - if target_dir.exists() { - return Err(RheoError::project_config(format!( - "directory '{}' already exists", - target_dir.display() - ))); - } - - fs::create_dir_all(target_dir).map_err(|e| RheoError::io(e, "creating target directory"))?; - - let toml_content = - rheo_core::init_templates::RHEO_TOML.replace("{{VERSION}}", manifest_version::CURRENT); - fs::write(target_dir.join("rheo.toml"), toml_content) - .map_err(|e| RheoError::io(e, "writing rheo.toml"))?; - - let content_dir = target_dir.join("content"); - fs::create_dir_all(&content_dir).map_err(|e| RheoError::io(e, "creating content directory"))?; - - fs::write( - content_dir.join("index.typ"), - rheo_core::init_templates::CONTENT_INDEX_TYP, - ) - .map_err(|e| RheoError::io(e, "writing index.typ"))?; - fs::write( - content_dir.join("about.typ"), - rheo_core::init_templates::CONTENT_ABOUT_TYP, - ) - .map_err(|e| RheoError::io(e, "writing about.typ"))?; - - // Write references.bib to content directory - // Typst resolves bibliography paths relative to the file being compiled - fs::write( - content_dir.join("references.bib"), - rheo_core::init_templates::CONTENT_REFERENCES_BIB, - ) - .map_err(|e| RheoError::io(e, "writing references.bib"))?; - - let img_dir = content_dir.join("img"); - fs::create_dir_all(&img_dir).map_err(|e| RheoError::io(e, "creating img directory"))?; - fs::write( - img_dir.join("header.svg"), - rheo_core::init_templates::CONTENT_IMG_HEADER_SVG, - ) - .map_err(|e| RheoError::io(e, "writing header.svg"))?; - - // Collect template contributions from all plugins - let mut plugin_templates: std::collections::HashMap<&str, (&str, &str)> = - std::collections::HashMap::new(); - for plugin in all_plugins() { - for (path, content) in plugin.init_templates() { - if let Some((existing_plugin, _)) = plugin_templates.get(path) { - return Err(RheoError::project_config(format!( - "template path conflict: both '{}' and '{}' plugins want to write '{}'", - existing_plugin, - plugin.name(), - path - ))); - } - plugin_templates.insert(path, (plugin.name(), content)); - } - } - - // Write plugin template files - for (path, (plugin_name, content)) in plugin_templates { - let file_path = target_dir.join(path); - if let Some(parent_dir) = file_path.parent() { - fs::create_dir_all(parent_dir) - .map_err(|e| RheoError::io(e, "creating plugin template directory"))?; - } - fs::write(&file_path, content) - .map_err(|e| RheoError::io(e, format!("writing plugin template file '{}'", path)))?; - debug!(plugin = plugin_name, path = %path, "wrote plugin template file"); - } - - info!(path = %target_dir.display(), "initialized rheo project"); - Ok(()) -} - -/// Setup: load project, apply smart defaults (if no config file), resolve plugins + build dir. -fn setup_compilation_context( - path: &Path, - config_path: Option<&Path>, - build_dir: Option, - enabled_from_cli: Vec, -) -> Result { - info!(path = %path.display(), "loading project"); - let mut project = ProjectConfig::from_path(path, config_path)?; - let file_word = if project.typ_files.len() == 1 { - "file" - } else { - "files" - }; - info!( - name = %project.name, - files = project.typ_files.len(), - "found {} Typst {}", - project.typ_files.len(), - file_word - ); - - let all = all_plugins(); - let formats = determine_formats(enabled_from_cli, &project.config.formats, &all); - - // Apply plugin smart defaults for all plugins - // Plugins check their own state and only fill in missing values - { - let plugins = plugins_for_formats(&formats, all_plugins()); - for plugin in &plugins { - let section = project - .config - .plugin_sections - .entry(plugin.name().to_string()) - .or_default(); - plugin.apply_defaults(section, &project.name); - } - } - - let plugins = plugins_for_formats(&formats, all); - - let resolved_build_dir = resolve_build_dir(&project, build_dir)?; - let output_config = OutputConfig::new(&project.root, resolved_build_dir); - - Ok(CompilationContext { - project, - plugins, - output_config, - }) -} - /// Main entry point using the builder-based dynamic CLI. pub fn run() -> Result<()> { - let cli = build_cli(); + let plugins = all_plugins(); + let cli = args::build_cli(&plugins); let matches = cli.get_matches(); let quiet = matches.get_flag("quiet"); @@ -634,7 +49,7 @@ pub fn run() -> Result<()> { Some(("clean", sub)) => run_clean(sub), Some(("init", sub)) => { let path = PathBuf::from(sub.get_one::("path").unwrap()); - init_project(&path) + init::init_project(&path, all_plugins) } _ => unreachable!("subcommand_required enforced by clap"), } @@ -647,18 +62,20 @@ fn run_watch(sub: &ArgMatches) -> Result<()> { let open = sub.get_flag("open"); let all = all_plugins(); - let enabled = enabled_formats_from_matches(sub, &all); + let enabled = args::enabled_formats_from_matches(sub, &all); - let mut ctx = setup_compilation_context( + let mut ctx = orchestrate::setup_compilation_context( &path, config_path.as_deref(), build_dir.clone(), enabled.clone(), + all_plugins, )?; // Initial compilation (best-effort; watch continues on failure) - if let Err(e) = perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins) { - warn!(error = %e, "initial compilation failed"); + if let Err(e) = orchestrate::perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins) + { + tracing::warn!(error = %e, "initial compilation failed"); } // Open outputs if --open requested; collect server handles for live reload @@ -668,7 +85,7 @@ fn run_watch(sub: &ArgMatches) -> Result<()> { let out_dir = ctx.output_config.dir_for_plugin(plugin.name()); match plugin.open(&out_dir, plugin.name()) { Ok(handle) => open_handles.push(handle), - Err(e) => warn!(error = %e, plugin = plugin.name(), "failed to open"), + Err(e) => tracing::warn!(error = %e, plugin = plugin.name(), "failed to open"), } } } @@ -684,7 +101,9 @@ fn run_watch(sub: &ArgMatches) -> Result<()> { match event { WatchEvent::FilesChanged => { info!("files changed, recompiling"); - if perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins).is_ok() { + if orchestrate::perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins) + .is_ok() + { for handle in &open_handles { if let OpenHandle::Server(server) = handle { server.reload(); @@ -694,16 +113,21 @@ fn run_watch(sub: &ArgMatches) -> Result<()> { } WatchEvent::ConfigChanged => { info!("config changed, reloading"); - match setup_compilation_context( + match orchestrate::setup_compilation_context( &path, config_path.as_deref(), build_dir.clone(), enabled.clone(), + all_plugins, ) { Ok(new_ctx) => { ctx = new_ctx; - if perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins) - .is_ok() + if orchestrate::perform_compilation( + &ctx.project, + &ctx.output_config, + &ctx.plugins, + ) + .is_ok() { for handle in &open_handles { if let OpenHandle::Server(server) = handle { @@ -712,7 +136,7 @@ fn run_watch(sub: &ArgMatches) -> Result<()> { } } } - Err(e) => warn!(error = %e, "failed to reload config"), + Err(e) => tracing::warn!(error = %e, "failed to reload config"), } } } @@ -726,21 +150,30 @@ fn run_compile(sub: &ArgMatches) -> Result<()> { let build_dir = sub.get_one::("build-dir").map(PathBuf::from); let all = all_plugins(); - let enabled = enabled_formats_from_matches(sub, &all); + let enabled = args::enabled_formats_from_matches(sub, &all); - let ctx = setup_compilation_context(&path, config.as_deref(), build_dir, enabled)?; + let ctx = orchestrate::setup_compilation_context( + &path, + config.as_deref(), + build_dir, + enabled, + all_plugins, + )?; - perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins) + orchestrate::perform_compilation(&ctx.project, &ctx.output_config, &ctx.plugins) } fn run_clean(sub: &ArgMatches) -> Result<()> { + use rheo_core::output::OutputConfig; + use rheo_core::project::ProjectConfig; + let path = PathBuf::from(sub.get_one::("path").unwrap()); let config = sub.get_one::("config").map(PathBuf::from); let build_dir = sub.get_one::("build-dir").map(PathBuf::from); info!(path = %path.display(), "loading project"); let project = ProjectConfig::from_path(&path, config.as_deref())?; - let resolved_build_dir = resolve_build_dir(&project, build_dir)?; + let resolved_build_dir = orchestrate::resolve_build_dir(&project, build_dir)?; let output_config = OutputConfig::new(&project.root, resolved_build_dir); info!(project = %project.name, "cleaning build artifacts"); output_config.clean()?; @@ -751,6 +184,7 @@ fn run_clean(sub: &ArgMatches) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use crate::args::{determine_formats, plugins_for_formats}; #[test] fn test_determine_formats_cli_flags_override_config() { @@ -781,7 +215,6 @@ mod tests { let enabled: Vec = vec![]; let formats = determine_formats(enabled, &config_defaults, &all); - // Should contain all plugin names assert_eq!(formats.len(), all_plugins().len()); assert!(formats.contains(&"pdf".to_string())); assert!(formats.contains(&"html".to_string())); @@ -813,4 +246,15 @@ mod tests { names.len() ); } + + #[test] + fn test_plugins_for_formats() { + let formats = vec!["pdf".to_string(), "html".to_string()]; + let plugins = plugins_for_formats(&formats, all_plugins()); + assert_eq!(plugins.len(), 2); + let names: Vec<&str> = plugins.iter().map(|p| p.name()).collect(); + assert!(names.contains(&"pdf")); + assert!(names.contains(&"html")); + assert!(!names.contains(&"epub")); + } } diff --git a/crates/cli/src/orchestrate.rs b/crates/cli/src/orchestrate.rs new file mode 100644 index 0000000..638c0ea --- /dev/null +++ b/crates/cli/src/orchestrate.rs @@ -0,0 +1,332 @@ +use crate::args::{determine_formats, plugins_for_formats}; +use rheo_core::compile::RheoCompileOptions; +use rheo_core::config::PluginSection; +use rheo_core::output::OutputConfig; +use rheo_core::project::{ProjectConfig, ProjectMode}; +use rheo_core::results::CompilationResults; +use rheo_core::reticulate::{SpineDocument, TracedSpine, generate_bundle_entry}; +use rheo_core::world::RheoWorld; +use rheo_core::{FormatPlugin, PluginContext, Result, RheoError}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use tracing::{debug, error, info}; + +/// Pre-compiled setup context for compilation commands. +pub(crate) struct CompilationContext { + pub project: ProjectConfig, + pub plugins: Vec>, + pub output_config: OutputConfig, +} + +/// Resolve a path relative to a base directory. +fn resolve_path(base: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + } +} + +/// Resolve build directory with priority: CLI arg > config > default. +pub(crate) fn resolve_build_dir( + project: &ProjectConfig, + cli_build_dir: Option, +) -> Result> { + if let Some(cli_path) = cli_build_dir { + let cwd = + std::env::current_dir().map_err(|e| RheoError::io(e, "getting current directory"))?; + debug!(dir = %cli_path.display(), "build directory"); + Ok(Some(resolve_path(&cwd, &cli_path))) + } else if let Some(config_path) = &project.config.build_dir { + let resolved = resolve_path(&project.root, Path::new(config_path)); + debug!(dir = %resolved.display(), "build directory"); + Ok(Some(resolved)) + } else { + Ok(None) + } +} + +/// Bundle compilation: generate bundle entry, inject into world, compile once. +/// Used by all plugins (HTML, PDF, EPUB) with the bundle API. +#[allow(clippy::too_many_arguments)] +pub(crate) fn compile_with_bundle( + plugin: &dyn FormatPlugin, + output: &Path, + project: &ProjectConfig, + output_config: &OutputConfig, + spine: &TracedSpine, + plugin_section: &PluginSection, + resolved_inputs: HashMap<&'static str, PathBuf>, + results: &mut CompilationResults, + compilation_root: &Path, +) -> Result<()> { + let plugin_library = plugin.typst_library().map(|s| s.to_string()); + let mut bundle_world = RheoWorld::new( + compilation_root, + spine + .documents + .first() + .map(|d| d.path.as_path()) + .unwrap_or(compilation_root), + plugin_library, + )?; + + let bundle_entry_source = generate_bundle_entry( + spine, + compilation_root, + plugin.name(), + plugin.typst_library().unwrap_or_default(), + ); + bundle_world.inject_bundle_entry(bundle_entry_source); + + let options = RheoCompileOptions::new(output, compilation_root, &mut bundle_world); + + let ctx = PluginContext { + project, + output_config, + options, + spine: spine.clone(), + config: plugin_section.clone(), + inputs: resolved_inputs, + }; + + match plugin.compile(ctx) { + Ok(_) => { + results.record_success(plugin.name()); + } + Err(e) => { + error!(error = %e, "{} compilation failed", plugin.name()); + results.record_failure(plugin.name()); + } + } + Ok(()) +} + +pub(crate) fn perform_compilation( + project: &ProjectConfig, + output_config: &OutputConfig, + plugins: &[Box], +) -> Result<()> { + if project.typ_files.is_empty() { + return Err(RheoError::project_config("no .typ files found in project")); + } + + let mut results = CompilationResults::new(); + + for plugin in plugins { + let plugin_output_dir = output_config.dir_for_plugin(plugin.name()); + std::fs::create_dir_all(&plugin_output_dir).map_err(|e| { + RheoError::io( + e, + format!("creating output directory for {}", plugin.name()), + ) + })?; + + // Resolve declared inputs + let mut resolved_inputs: HashMap<&'static str, PathBuf> = HashMap::new(); + for input in plugin.inputs() { + let src = project.root.join(&input.path); + if src.is_file() { + let dest = plugin_output_dir.join(&input.path); + std::fs::copy(&src, &dest).map_err(|e| { + RheoError::io( + e, + format!( + "copying plugin input '{}' from {} to {}", + input.name, + src.display(), + dest.display() + ), + ) + })?; + resolved_inputs.insert(input.name, dest); + } else if input.required { + return Err(RheoError::project_config(format!( + "plugin '{}' requires input '{}' at '{}' but it was not found", + plugin.name(), + input.name, + &input.path + ))); + } + } + + // Execute copy patterns (global + per-plugin) + let plugin_section_for_assets = project.config.plugin_section(plugin.name()); + for pattern in project + .config + .assets + .iter() + .chain(plugin_section_for_assets.assets.iter()) + { + let abs_pattern = project.root.join(pattern).display().to_string(); + let entries = glob::glob(&abs_pattern).map_err(|e| { + RheoError::project_config(format!("invalid copy pattern '{}': {}", pattern, e)) + })?; + let mut matched = false; + for entry in entries.filter_map(|e| e.ok()).filter(|p| p.is_file()) { + matched = true; + let rel = entry.strip_prefix(&project.root).unwrap_or(entry.as_path()); + let dest = plugin_output_dir.join(rel); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + RheoError::io( + e, + format!("creating directory for copy of {}", rel.display()), + ) + })?; + } + std::fs::copy(&entry, &dest).map_err(|e| { + RheoError::io( + e, + format!("copying {} to {}", entry.display(), dest.display()), + ) + })?; + debug!(src = %entry.display(), dest = %dest.display(), "copied file"); + } + if !matched { + debug!(pattern = %pattern, "copy pattern matched no files"); + } + } + + // Compute compilation root once (content_dir from config or project root) + let compilation_root = project + .config + .resolve_content_dir(&project.root) + .unwrap_or_else(|| project.root.clone()); + + // Resolve spine config and trace + let spine = if project.mode == ProjectMode::SingleFile { + TracedSpine { + title: None, + documents: vec![SpineDocument { + path: project.typ_files[0].clone(), + is_bundle_entry: false, + }], + assets: vec![], + merge: false, + } + } else { + let mut spine_cfg = project.config.spine_for_plugin(plugin.name()); + + let default_spine; + if spine_cfg.is_none() { + use rheo_core::DocumentTitle; + use rheo_core::config::Spine; + default_spine = Spine { + title: Some(DocumentTitle::to_readable_name(&project.name)), + vertebrae: vec![], + merge: Some(plugin.default_merge()), + }; + spine_cfg = Some(&default_spine); + } + + let plugin_section_for_assets = project.config.plugin_section(plugin.name()); + let assets_config: Vec = project + .config + .assets + .iter() + .chain(plugin_section_for_assets.assets.iter()) + .cloned() + .collect(); + + TracedSpine::trace( + &project.root, + &compilation_root, + spine_cfg, + &assets_config, + plugin.default_merge(), + )? + }; + + let plugin_section = project.config.plugin_section(plugin.name()); + + let output = if spine.merge { + plugin_output_dir + .join(&project.name) + .with_extension(plugin.output_extension()) + } else { + plugin_output_dir.clone() + }; + + compile_with_bundle( + plugin.as_ref(), + &output, + project, + output_config, + &spine, + &plugin_section, + resolved_inputs, + &mut results, + &compilation_root, + )?; + } + + let names: Vec<&str> = plugins.iter().map(|p| p.name()).collect(); + results.log_summary(&names); + + if results.has_failures() { + if names.iter().any(|name| results.get(name).succeeded > 0) { + Err(RheoError::project_config( + "some formats failed to compile".to_string(), + )) + } else { + Err(RheoError::project_config( + "all formats failed or no files were compiled".to_string(), + )) + } + } else { + info!("compilation complete"); + Ok(()) + } +} + +/// Setup: load project, apply smart defaults (if no config file), resolve plugins + build dir. +pub(crate) fn setup_compilation_context( + path: &Path, + config_path: Option<&Path>, + build_dir: Option, + enabled_from_cli: Vec, + all_plugins: fn() -> Vec>, +) -> Result { + info!(path = %path.display(), "loading project"); + let mut project = ProjectConfig::from_path(path, config_path)?; + let file_word = if project.typ_files.len() == 1 { + "file" + } else { + "files" + }; + info!( + name = %project.name, + files = project.typ_files.len(), + "found {} Typst {}", + project.typ_files.len(), + file_word + ); + + let all = all_plugins(); + let formats = determine_formats(enabled_from_cli, &project.config.formats, &all); + + // Apply plugin smart defaults for all plugins + { + let plugins = plugins_for_formats(&formats, all_plugins()); + for plugin in &plugins { + let section = project + .config + .plugin_sections + .entry(plugin.name().to_string()) + .or_default(); + plugin.apply_defaults(section, &project.name); + } + } + + let plugins = plugins_for_formats(&formats, all_plugins()); + + let resolved_build_dir = resolve_build_dir(&project, build_dir)?; + let output_config = OutputConfig::new(&project.root, resolved_build_dir); + + Ok(CompilationContext { + project, + plugins, + output_config, + }) +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index dffcb14..6689f37 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -46,7 +46,6 @@ atty = { workspace = true } # Concurrency and utilities parking_lot = { workspace = true } -lazy_static = { workspace = true } regex = { workspace = true } notify = { workspace = true } ecow = { workspace = true } diff --git a/crates/core/src/bundle_compile.rs b/crates/core/src/bundle_compile.rs deleted file mode 100644 index 9cad9ca..0000000 --- a/crates/core/src/bundle_compile.rs +++ /dev/null @@ -1,42 +0,0 @@ -use crate::Result; -use crate::RheoError; -use crate::RheoWorld; -use crate::diagnostics::print_diagnostics; -use typst::diag::Warned; - -/// Compile and export a Typst bundle to file bytes. -/// -/// This helper function consolidates the common bundle compilation and export -/// logic used by both HTML and PDF plugins. It compiles the bundle using the -/// world, prints diagnostics, and returns the exported file bytes. -/// -/// # Arguments -/// * `world` - The RheoWorld for compilation context -/// -/// # Returns -/// A vector of (filename, bytes) pairs representing the exported bundle files -/// -/// # Errors -/// Returns `RheoError::project_config` if compilation or export fails -pub fn export_typst_bundle(world: &RheoWorld) -> Result)>> { - let Warned { output, warnings } = typst::compile::(world); - let _ = print_diagnostics(world, &[], &warnings); - let bundle = output.map_err(|errors| { - let _ = print_diagnostics(world, &errors[..], &[]); - let msgs: Vec = errors.iter().map(|e| e.message.to_string()).collect(); - RheoError::project_config(format!( - "bundle compilation had errors: {}", - msgs.join(", ") - )) - })?; - let bundle_options = typst_bundle::BundleOptions { - pixel_per_pt: 144.0, - pdf: typst_pdf::PdfOptions::default(), - }; - let fs = typst_bundle::export(&bundle, &bundle_options) - .map_err(|e| RheoError::project_config(format!("bundle export failed: {:?}", e)))?; - Ok(fs - .into_iter() - .map(|(p, b)| (p.get_without_slash().to_string(), b.to_vec())) - .collect()) -} diff --git a/crates/core/src/compile.rs b/crates/core/src/compile.rs index 41b7334..0666cec 100644 --- a/crates/core/src/compile.rs +++ b/crates/core/src/compile.rs @@ -1,5 +1,11 @@ +use crate::Result; +use crate::diagnostics::{ExportErrorType, handle_export_errors, unwrap_compilation_result}; use crate::world::RheoWorld; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use tracing::info; +use typst::diag::SourceDiagnostic; +use typst_html::HtmlDocument; +use typst_layout::PagedDocument; /// Common compilation options used across all output formats. /// @@ -29,11 +35,6 @@ pub struct RheoCompileOptions<'a> { impl<'a> RheoCompileOptions<'a> { /// Create compilation options. - /// - /// # Arguments - /// * `output` - The output file path - /// * `root` - Root directory for resolving imports - /// * `world` - The RheoWorld with bundle entry pre-configured pub fn new( output: impl Into, root: impl Into, @@ -47,83 +48,53 @@ impl<'a> RheoCompileOptions<'a> { } } -#[cfg(test)] -mod tests { - use crate::pdf_utils; - - #[test] - fn test_filename_to_title() { - assert_eq!( - pdf_utils::DocumentTitle::to_readable_name("severance-ep-1"), - "Severance Ep 1" - ); - assert_eq!( - pdf_utils::DocumentTitle::to_readable_name("my_document"), - "My Document" - ); - assert_eq!( - pdf_utils::DocumentTitle::to_readable_name("chapter-01"), - "Chapter 01" - ); - assert_eq!( - pdf_utils::DocumentTitle::to_readable_name("hello_world"), - "Hello World" - ); - assert_eq!( - pdf_utils::DocumentTitle::to_readable_name("single"), - "Single" - ); - } - - #[test] - fn test_extract_document_title_from_metadata() { - let source = r#"#set document(title: [My Great Title]) - -= Chapter 1 -Content here."#; - - let title = pdf_utils::DocumentTitle::from_source(source, "fallback").extract(); - assert_eq!(title, "My Great Title"); - } - - #[test] - fn test_extract_document_title_fallback() { - let source = r#"= Chapter 1 -Content here."#; - - let title = pdf_utils::DocumentTitle::from_source(source, "my-chapter").extract(); - assert_eq!(title, "My Chapter"); - } - - #[test] - fn test_extract_document_title_with_markup() { - let source = r#"#set document(title: [Good news about hell - #emph[Severance]])"#; - - let title = pdf_utils::DocumentTitle::from_source(source, "fallback").extract(); - // Should strip #emph and underscores - // Note: complex nested bracket handling is limited by regex - assert!(title.contains("Good news")); - assert!(title.contains("Severance")); - } - - #[test] - fn test_extract_document_title_empty() { - let source = r#"#set document(title: []) +pub fn compile_html_to_document( + input: &Path, + root: &Path, + _format_name: &str, + plugin_library: Option, +) -> Result { + compile_html_to_document_with_polyfill(input, root, plugin_library, false) +} -Content"#; +/// Compile to HTML document with optional EPUB polyfill mode. +pub fn compile_html_to_document_with_polyfill( + input: &Path, + root: &Path, + plugin_library: Option, + epub_polyfill_mode: bool, +) -> Result { + let mut world = RheoWorld::new(root, input, plugin_library)?; + world.epub_polyfill_mode = epub_polyfill_mode; + info!(input = %input.display(), "compiling to HTML"); + let result = typst::compile::(&world); + + let html_filter = |w: &SourceDiagnostic| { + !w.message + .contains("html export is under active development and incomplete") + }; + + unwrap_compilation_result(Some(&world), result, Some(html_filter)) +} - let title = pdf_utils::DocumentTitle::from_source(source, "default-name").extract(); - // Empty title should fall back to filename - assert_eq!(title, "Default Name"); - } +pub fn compile_document_to_string(document: &HtmlDocument) -> Result { + typst_html::html(document).map_err(|e| handle_export_errors(e, ExportErrorType::Html)) +} - #[test] - fn test_extract_document_title_complex() { - let source = r#"#set document(title: [Half Loop - _Severance_ [s1/e2]], author: [Test])"#; +pub fn compile_pdf_to_document( + input: &Path, + root: &Path, + _format_name: Option<&str>, + plugin_library: Option, +) -> Result { + let world = RheoWorld::new(root, input, plugin_library)?; + info!(input = %input.display(), "compiling to PDF"); + let result = typst::compile::(&world); + unwrap_compilation_result(Some(&world), result, None:: bool>) +} - let title = pdf_utils::DocumentTitle::from_source(source, "fallback").extract(); - // Should extract title and strip markup - assert!(title.contains("Half Loop")); - assert!(title.contains("Severance")); - } +pub fn document_to_pdf_bytes(document: &PagedDocument) -> Result> { + use typst_pdf::PdfOptions; + typst_pdf::pdf(document, &PdfOptions::default()) + .map_err(|e| handle_export_errors(e, ExportErrorType::Pdf)) } diff --git a/crates/core/src/constants.rs b/crates/core/src/constants.rs index 7d61baa..f6c6f0d 100644 --- a/crates/core/src/constants.rs +++ b/crates/core/src/constants.rs @@ -1,6 +1,6 @@ /// File extension constants and shared regex patterns used throughout rheo -use lazy_static::lazy_static; use regex::Regex; +use std::sync::LazyLock; // File extensions pub const TYP_EXT: &str = ".typ"; @@ -11,19 +11,16 @@ pub const XHTML_EXT: &str = ".xhtml"; pub const EPUB_EXT: &str = ".epub"; // Regex patterns -lazy_static! { - /// Pattern for Typst #link() syntax: #link("url")(body) or #link("url", body) - pub static ref TYPST_LINK_PATTERN: Regex = - Regex::new(r#"#link\("([^"]+)"\)(\[[^\]]+\]|,\s*[^)]+)"#) - .expect("invalid TYPST_LINK_PATTERN"); - /// Pattern for HTML href attributes: href="url" - pub static ref HTML_HREF_PATTERN: Regex = - Regex::new(r#"href="([^"]*)""#) - .expect("invalid HTML_HREF_PATTERN"); +/// Pattern for Typst #link() syntax: #link("url")(body) or #link("url", body) +pub static TYPST_LINK_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r#"#link\("([^"]+)"\)(\[[^\]]+\]|,\s*[^)]+)"#).expect("invalid TYPST_LINK_PATTERN") +}); - /// Pattern for Typst label references: #label[text] - pub static ref TYPST_LABEL_PATTERN: Regex = - Regex::new(r"#\w+\[([^\]]+)\]") - .expect("invalid TYPST_LABEL_PATTERN"); -} +/// Pattern for HTML href attributes: href="url" +pub static HTML_HREF_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r#"href="([^"]*)""#).expect("invalid HTML_HREF_PATTERN")); + +/// Pattern for Typst label references: #label[text] +pub static TYPST_LABEL_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"#\w+\[([^\]]+)\]").expect("invalid TYPST_LABEL_PATTERN")); diff --git a/crates/core/src/html_compile.rs b/crates/core/src/html_compile.rs deleted file mode 100644 index a60e631..0000000 --- a/crates/core/src/html_compile.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::Result; -use crate::diagnostics::{ExportErrorType, handle_export_errors, unwrap_compilation_result}; -use crate::world::RheoWorld; -use std::path::Path; -use tracing::info; -use typst::diag::SourceDiagnostic; -use typst_html::HtmlDocument; - -pub fn compile_html_to_document( - input: &Path, - root: &Path, - _format_name: &str, - plugin_library: Option, -) -> Result { - compile_html_to_document_with_polyfill(input, root, plugin_library, false) -} - -/// Compile to HTML document with optional EPUB polyfill mode. -pub fn compile_html_to_document_with_polyfill( - input: &Path, - root: &Path, - plugin_library: Option, - epub_polyfill_mode: bool, -) -> Result { - let mut world = RheoWorld::new(root, input, plugin_library)?; - world.epub_polyfill_mode = epub_polyfill_mode; - info!(input = %input.display(), "compiling to HTML"); - let result = typst::compile::(&world); - - let html_filter = |w: &SourceDiagnostic| { - !w.message - .contains("html export is under active development and incomplete") - }; - - unwrap_compilation_result(Some(&world), result, Some(html_filter)) -} - -/// Compile using an existing RheoWorld to an HTML document. -/// -/// This function uses a pre-configured RheoWorld (with main file already set) -/// and compiles it to an HtmlDocument. Useful for per-file compilation where -/// the world is shared across multiple files. -pub fn compile_html_with_world(world: &RheoWorld) -> Result { - info!("compiling to HTML"); - let result = typst::compile::(world); - - let html_filter = |w: &SourceDiagnostic| { - !w.message - .contains("html export is under active development and incomplete") - }; - - unwrap_compilation_result(Some(world), result, Some(html_filter)) -} - -pub fn compile_document_to_string(document: &HtmlDocument) -> Result { - typst_html::html(document).map_err(|e| handle_export_errors(e, ExportErrorType::Html)) -} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1157590..7a9077e 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,23 +1,19 @@ -pub mod bundle_compile; pub mod compile; pub mod config; pub mod constants; pub mod diagnostics; pub mod error; -pub mod html_compile; pub mod init_templates; pub mod logging; pub mod manifest_version; pub mod output; pub mod path_utils; -pub mod pdf_compile; pub mod pdf_utils; pub mod plugins; pub mod project; pub mod results; pub mod reticulate; pub mod typst_types; -pub mod unified_compile; pub mod validation; pub mod watch; pub mod world; @@ -47,20 +43,10 @@ pub use plugins::{FormatPlugin, OpenHandle, PluginContext, PluginInput, ServerHa // Re-export TracedSpine for use in bundle compilation pub use reticulate::{SpineDocument, TracedSpine}; -// HTML compilation functions -pub use html_compile::{ +// HTML and PDF compilation functions +pub use compile::{ compile_document_to_string, compile_html_to_document, compile_html_to_document_with_polyfill, - compile_html_with_world, -}; - -// PDF compilation functions -pub use pdf_compile::{compile_pdf_to_document, compile_pdf_with_world, document_to_pdf_bytes}; - -// Unified compilation API (consistent naming pattern) -pub use unified_compile::{ - HtmlDocument as HtmlDoc, HtmlString, PagedDocument as PdfDoc, PdfBytes, - compile_to_html_document, compile_to_html_document_with_world, compile_to_html_string, - compile_to_pdf_bytes, compile_to_pdf_document, compile_to_pdf_document_with_world, + compile_pdf_to_document, document_to_pdf_bytes, }; // World (Typst compilation context) @@ -72,9 +58,6 @@ pub use reticulate::spine::generate_bundle_entry; // PDF utilities pub use pdf_utils::DocumentTitle; -// Bundle compilation helper -pub use bundle_compile::export_typst_bundle; - // Typst types (commonly used by plugins) pub use typst_types::{ EcoString, HeadingElem, HtmlDocument, NativeElement, OutlineNode, StyleChain, eco_format, diff --git a/crates/core/src/pdf_compile.rs b/crates/core/src/pdf_compile.rs deleted file mode 100644 index 7b8edec..0000000 --- a/crates/core/src/pdf_compile.rs +++ /dev/null @@ -1,90 +0,0 @@ -use crate::Result; -/// PDF compilation wrappers for rheo plugins. -/// -/// These functions encapsulate Typst PDF compilation, allowing plugin crates -/// to compile PDFs without directly importing typst crates. -use crate::diagnostics::{ExportErrorType, handle_export_errors, unwrap_compilation_result}; -use crate::world::RheoWorld; -use std::path::Path; -use tracing::info; -use typst_layout::PagedDocument; - -/// Compile a Typst source file to a PDF document. -/// -/// This function creates a new RheoWorld for the given input and compiles -/// it to a PagedDocument. The result can be exported to PDF bytes using -/// `document_to_pdf_bytes`. -/// -/// # Arguments -/// * `input` - Path to the .typ file to compile -/// * `root` - Project root directory for resolving imports -/// * `format_name` - Output format name for link transformations (e.g., "pdf", None) -/// * `plugin_library` - Optional plugin-contributed Typst library code to inject -/// -/// # Returns -/// A PagedDocument ready for PDF export -/// -/// # Example -/// ```ignore -/// let document = compile_pdf_to_document(&input_path, &project_root, Some("pdf"), None)?; -/// let pdf_bytes = document_to_pdf_bytes(&document)?; -/// std::fs::write("output.pdf", &pdf_bytes)?; -/// ``` -pub fn compile_pdf_to_document( - input: &Path, - root: &Path, - _format_name: Option<&str>, - plugin_library: Option, -) -> Result { - let world = RheoWorld::new(root, input, plugin_library)?; - info!(input = %input.display(), "compiling to PDF"); - let result = typst::compile::(&world); - unwrap_compilation_result(Some(&world), result, None:: bool>) -} - -/// Compile using an existing RheoWorld to a PDF document. -/// -/// This function uses a pre-configured RheoWorld (with main file already set) -/// and compiles it to a PagedDocument. Useful for per-file compilation where -/// the world is shared across multiple files. -/// -/// # Arguments -/// * `world` - A configured RheoWorld with the main file set -/// -/// # Returns -/// A PagedDocument ready for PDF export -/// -/// # Example -/// ```ignore -/// let document = compile_pdf_with_world(&world)?; -/// let pdf_bytes = document_to_pdf_bytes(&document)?; -/// std::fs::write("output.pdf", &pdf_bytes)?; -/// ``` -pub fn compile_pdf_with_world(world: &RheoWorld) -> Result { - info!("compiling to PDF"); - let result = typst::compile::(world); - unwrap_compilation_result(Some(world), result, None:: bool>) -} - -/// Export a PagedDocument to PDF bytes. -/// -/// Converts a compiled PagedDocument into its PDF representation as bytes. -/// The resulting bytes can be written directly to a file. -/// -/// # Arguments -/// * `document` - The compiled PagedDocument to export -/// -/// # Returns -/// PDF file content as a byte vector -/// -/// # Example -/// ```ignore -/// let document = compile_pdf_to_document(&input_path, &root, Some("pdf"))?; -/// let pdf_bytes = document_to_pdf_bytes(&document)?; -/// std::fs::write("output.pdf", &pdf_bytes)?; -/// ``` -pub fn document_to_pdf_bytes(document: &PagedDocument) -> Result> { - use typst_pdf::PdfOptions; - typst_pdf::pdf(document, &PdfOptions::default()) - .map_err(|e| handle_export_errors(e, ExportErrorType::Pdf)) -} diff --git a/crates/core/src/reticulate/parser.rs b/crates/core/src/reticulate/parser.rs deleted file mode 100644 index 50fef3a..0000000 --- a/crates/core/src/reticulate/parser.rs +++ /dev/null @@ -1,216 +0,0 @@ -use crate::reticulate::types::LinkInfo; -use typst::syntax::{Source, SyntaxKind, SyntaxNode}; - -/// The identifier in the Typst AST for links. -const LINK_IDENT_ID: &str = "link"; - -/// Extract all links from Typst source by parsing and traversing AST -pub fn extract_links(source: &Source) -> Vec { - let root = typst::syntax::parse(source.text()); - let mut links = Vec::new(); - extract_links_from_node(&root, &root, &mut links); - links -} - -fn extract_links_from_node(node: &SyntaxNode, root: &SyntaxNode, links: &mut Vec) { - // Check if this node itself is a function call - if node.kind() == SyntaxKind::FuncCall - && let Some(link_info) = parse_link_call(node, root) - { - links.push(link_info); - } - - // Recursively traverse children - for child in node.children() { - extract_links_from_node(child, root, links); - } -} - -fn parse_link_call(node: &SyntaxNode, root: &SyntaxNode) -> Option { - // Parse #link("url")[body] or #link("url", body) - // Extract: - // 1. Function name (must be "link") - // 2. URL argument (first string argument) - // 3. Body text (from content block or second argument) - // 4. Byte range by calculating AST node position - - let ident = node.children().find(|n| n.kind() == SyntaxKind::Ident)?; - if ident.text() != LINK_IDENT_ID { - return None; - } - - let args = node.children().find(|n| n.kind() == SyntaxKind::Args)?; - - // Extract URL (first string argument) - let url = extract_first_string_arg(args)?; - - // Extract body text - let body = extract_link_body(node)?; - - // Calculate byte range directly from AST node position - let offset = calculate_node_offset(root, node)?; - let byte_range = offset..(offset + node.len()); - - // Get span for error reporting - let span = node.span(); - - Some(LinkInfo { - url, - body, - span, - byte_range, - }) -} - -fn extract_first_string_arg(args: &SyntaxNode) -> Option { - for child in args.children() { - if child.kind() == SyntaxKind::Str { - // Remove quotes - let text = child.text(); - return Some(text.trim_matches('"').to_string()); - } - } - None -} - -fn extract_link_body(func_call: &SyntaxNode) -> Option { - // The ContentBlock is inside the Args node as the second argument - let args = func_call - .children() - .find(|n| n.kind() == SyntaxKind::Args)?; - - // Find ContentBlock inside Args - let content_block = args - .children() - .find(|n| n.kind() == SyntaxKind::ContentBlock)?; - - // Extract text from inside the ContentBlock - // The structure is: ContentBlock -> Markup -> Text - extract_text_from_node(content_block) -} - -fn extract_text_from_node(node: &SyntaxNode) -> Option { - // If this is a Text node, return its content - if node.kind() == SyntaxKind::Text { - return Some(node.text().to_string()); - } - - // If this is a Space node, return a space - if node.kind() == SyntaxKind::Space { - return Some(" ".to_string()); - } - - // Otherwise, collect text from ALL children (not just the first) - let mut texts = Vec::new(); - for child in node.children() { - if let Some(text) = extract_text_from_node(child) { - texts.push(text); - } - } - - if texts.is_empty() { - None - } else { - Some(texts.join("")) - } -} - -/// Calculate the byte offset of a target node within the root AST -fn calculate_node_offset(root: &SyntaxNode, target: &SyntaxNode) -> Option { - calculate_node_offset_impl(root, target, 0) -} - -fn calculate_node_offset_impl( - current: &SyntaxNode, - target: &SyntaxNode, - offset: usize, -) -> Option { - // Check if this is the target node (pointer equality) - if std::ptr::eq(current as *const _, target as *const _) { - return Some(offset); - } - - // Recursively search children, tracking offset - let mut child_offset = offset; - for child in current.children() { - if let Some(found_offset) = calculate_node_offset_impl(child, target, child_offset) { - return Some(found_offset); - } - child_offset += child.len(); - } - - None -} - -#[cfg(test)] -mod tests { - use super::*; - use typst::syntax::Source; - - #[test] - fn test_extract_link_with_content_block() { - let source = Source::detached(r#"#link("./file.typ")[text]"#); - let links = extract_links(&source); - - assert_eq!(links.len(), 1); - assert_eq!(links[0].url, "./file.typ"); - assert_eq!(links[0].body, "text"); - } - - #[test] - fn test_extract_multiple_links() { - let source = Source::detached( - r#" - Some text #link("./file1.typ")[first] and more - #link("./file2.typ")[second] content. - "#, - ); - let links = extract_links(&source); - - assert_eq!(links.len(), 2); - assert_eq!(links[0].url, "./file1.typ"); - assert_eq!(links[0].body, "first"); - assert_eq!(links[1].url, "./file2.typ"); - assert_eq!(links[1].body, "second"); - } - - #[test] - fn test_no_links() { - let source = Source::detached("Just plain text with no links"); - let links = extract_links(&source); - - assert_eq!(links.len(), 0); - } - - #[test] - fn test_external_urls() { - let source = Source::detached(r#"#link("https://example.com")[external]"#); - let links = extract_links(&source); - - assert_eq!(links.len(), 1); - assert_eq!(links[0].url, "https://example.com"); - assert_eq!(links[0].body, "external"); - } - - #[test] - fn test_extract_link_with_nested_markup() { - let source = Source::detached(r#"#link("./url")[text #super[2]]"#); - let links = extract_links(&source); - - assert_eq!(links.len(), 1); - assert_eq!(links[0].url, "./url"); - assert_eq!(links[0].body, "text 2"); // All text concatenated - // Byte range should cover the entire link (exact start may vary by 1 due to Source::detached) - assert!(links[0].byte_range.len() >= 29); - } - - #[test] - fn test_extract_link_with_multiple_markup() { - let source = Source::detached(r#"#link("url")[#strong[bold] and #emph[italic]]"#); - let links = extract_links(&source); - - assert_eq!(links.len(), 1); - assert_eq!(links[0].url, "url"); - assert_eq!(links[0].body, "bold and italic"); - } -} diff --git a/crates/core/src/reticulate/serializer.rs b/crates/core/src/reticulate/serializer.rs deleted file mode 100644 index 4a42aac..0000000 --- a/crates/core/src/reticulate/serializer.rs +++ /dev/null @@ -1,163 +0,0 @@ -use super::types::LinkTransform; -use std::ops::Range; -use typst::syntax::{Source, SyntaxKind, SyntaxNode}; - -/// Apply link transformations to source code -/// -/// Applies transformations by replacing text at specified byte ranges. -/// Transformations that overlap with code blocks are filtered out. -/// Replacements are applied back-to-front to preserve byte offsets. -/// -/// # Arguments -/// * `source` - Original source text -/// * `transformations` - List of (byte_range, transform) tuples -/// * `code_block_ranges` - Byte ranges of code blocks to protect -pub fn apply_transformations( - source: &str, - transformations: &[(Range, LinkTransform)], - code_block_ranges: &[Range], -) -> String { - // Filter out transformations that overlap with code blocks - let mut active_transforms: Vec<_> = transformations - .iter() - .filter(|(range, _)| !overlaps_with_any(range, code_block_ranges)) - .collect(); - - // Sort by byte range (back-to-front for stable offsets) - active_transforms.sort_by_key(|(range, _)| std::cmp::Reverse(range.start)); - - // Build result string by applying transformations - let mut result = source.to_string(); - - for (range, transform) in active_transforms { - // Get the original link text - let original = &source[range.clone()]; - - // Compute replacement text based on transformation - let replacement = match transform { - LinkTransform::Remove { body } => { - // Just the body text in brackets: [body] - format!("[{}]", body) - } - LinkTransform::ReplaceUrl { new_url } => { - // Replace URL but keep the rest of the syntax - // Original: #link("old.typ")[body] - // New: #link("new.typ")[body] - replace_url_in_link(original, new_url, false) - } - LinkTransform::ReplaceUrlWithLabel { new_label } => { - // Replace URL but keep the rest of the syntax - // Original: #link("old.typ")[body] - // New: #link(