Skip to content
Merged
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
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
171 changes: 171 additions & 0 deletions crates/cli/src/args.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
use clap::{Arg, ArgAction, ArgMatches, Command};
use rheo_core::FormatPlugin;

pub(crate) fn build_cli(plugins: &[Box<dyn FormatPlugin>]) -> 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<dyn FormatPlugin>]) -> 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<dyn FormatPlugin>]) -> 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<dyn FormatPlugin>]) -> 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<dyn FormatPlugin>],
) -> Vec<String> {
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<String>,
config_defaults: &[String],
all: &[Box<dyn FormatPlugin>],
) -> Vec<String> {
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<Box<dyn FormatPlugin>>,
) -> Vec<Box<dyn FormatPlugin>> {
all.into_iter()
.filter(|p| formats.iter().any(|f| f == p.name()))
.collect()
}
82 changes: 82 additions & 0 deletions crates/cli/src/init.rs
Original file line number Diff line number Diff line change
@@ -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<Box<dyn FormatPlugin>>,
) -> 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(())
}
Loading
Loading