use clap::Parser;
use env_logger::{Builder, Env};
use log::{error, info, warn, SetLoggerError};
use std::{
path::{Path, PathBuf},
sync::Arc,
};
mod atproto;
mod cli;
mod config;
mod content;
mod embedded;
mod feed;
mod gallery;
mod highlight;
mod image_provider;
mod image_resize;
mod parser;
mod re;
mod server;
mod shortcodes;
mod site;
mod slugify;
mod templates;
mod tera_filter;
mod tera_functions;
mod theme_manager;
mod workspace;
fn setup_logging(verbose: u8, debug: bool) -> Result<(), SetLoggerError> {
let env = Env::default().default_filter_or(match verbose {
0 => "marmite=warn",
1 => "marmite=info",
2 => "marmite=debug",
3 => "marmite=trace",
4..=u8::MAX => "trace",
});
Builder::from_env(env).try_init()?;
if debug {
warn!("--debug flag is deprecated, use -vv for debug messages");
}
Ok(())
}
fn determine_verbosity(args: &cli::Cli) -> u8 {
let mut verbose = args.verbose;
if verbose == 0
&& (args.watch
|| args.serve
|| args.start_theme.is_some()
|| args.set_theme.is_some()
|| args.init_templates
|| args.generate_config
|| args.init_site
|| args.skill_install
|| args.skill_install_claude
|| args.subcommand.is_some())
{
verbose = 1;
}
if args.debug {
verbose = 2; }
verbose
}
fn get_config_path(input_folder: &Path, config: &str) -> PathBuf {
if config.starts_with('.') || config.starts_with('/') {
PathBuf::new().join(config)
} else {
input_folder.join(config)
}
}
#[allow(clippy::too_many_lines)]
fn run_cli(args: cli::Cli) -> Result<(), Box<dyn std::error::Error>> {
let verbose = determine_verbosity(&args);
if let Err(e) = setup_logging(verbose, args.debug) {
eprintln!("Logger already initialized: {e:?}");
}
if let Some(cli::CliSubcommand::Atproto(ref atproto_cmd)) = args.subcommand {
return atproto::dispatch(atproto_cmd, &args);
}
let cloned_args = Arc::new(args.clone());
let serve = args.serve;
let watch = args.watch;
let bind_address: &str = args.bind.as_str();
if args.skill {
match embedded::get_skill_content() {
Some(content) => {
print!("{content}");
return Ok(());
}
None => {
return Err("Embedded skill file not found".into());
}
}
}
if args.skill_install || args.skill_install_claude {
let target = args
.input_folder
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
if args.skill_install {
embedded::install_skills_to_agents(&target);
}
if args.skill_install_claude {
embedded::install_skills_to_claude(&target);
}
return Ok(());
}
let input_folder_path = args
.input_folder
.ok_or("Input folder is required. Usage: marmite <INPUT_FOLDER> [OUTPUT_FOLDER]")?;
let input_folder = Arc::new(input_folder_path);
let config_path = Arc::new(get_config_path(&input_folder, &args.config));
if args.init_site {
site::initialize(&input_folder, &cloned_args);
return Ok(());
}
if !input_folder.exists() {
return Err(format!("Input folder does not exist: {input_folder:?}").into());
}
if let Some(ws_config_path) = workspace::detect_workspace(&input_folder) {
return handle_workspace_mode(
&ws_config_path,
&input_folder,
&cloned_args,
serve,
watch,
bind_address,
);
}
if let Some(title) = args.create.new {
content::new(&input_folder, &title, &cloned_args, &config_path);
return Ok(());
}
if args.init_templates {
templates::initialize_templates(&input_folder);
return Ok(());
}
if let Some(theme_name) = args.start_theme {
templates::initialize_theme(&input_folder, &theme_name);
return Ok(());
}
if let Some(theme_source) = args.set_theme {
theme_manager::set_theme(
&input_folder,
&theme_source,
args.configuration.theme.clone(),
);
return Ok(());
}
if args.generate_config {
config::generate(&input_folder, &cloned_args);
return Ok(());
}
if args.shortcodes {
return handle_shortcodes_command(&input_folder, &cloned_args);
}
if args.show_urls {
site::show_urls(&config_path, &input_folder, &cloned_args);
return Ok(());
}
let output_folder = Arc::new(args.output_folder.unwrap_or(input_folder.join("site")));
if let Err(e) = site::generate(
&config_path,
&input_folder,
&output_folder,
watch,
serve,
bind_address,
&cloned_args,
) {
error!("Failed to generate site: {e}");
std::process::exit(1);
}
if serve && !watch {
info!("Starting built-in HTTP server...");
let serve_site_data = crate::site::Data::from_file(&config_path);
let ctx = server::ServerContext {
output_folder: Arc::clone(&output_folder),
input_folder: Arc::clone(&input_folder),
config_path: Arc::clone(&config_path),
enable_toolbar: serve_site_data.site.enable_toolbar,
watch_enabled: false,
};
server::start(bind_address, &ctx, None);
}
Ok(())
}
fn main() {
let args = cli::Cli::parse();
if let Err(e) = run_cli(args) {
error!("{e}");
std::process::exit(1);
}
}
fn handle_workspace_mode(
ws_config_path: &Path,
input_folder: &Arc<PathBuf>,
cli_args: &Arc<cli::Cli>,
serve: bool,
watch: bool,
bind_address: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let ws_config = workspace::load_workspace_config(ws_config_path)?;
if cli_args.init_site
|| cli_args.init_templates
|| cli_args.start_theme.is_some()
|| cli_args.set_theme.is_some()
|| cli_args.generate_config
{
return Err(
"This command is not supported in workspace mode. Run it on individual sites instead."
.into(),
);
}
if let Some(title) = &cli_args.create.new {
let site_name =
cli_args.create.site.as_deref().ok_or(
"In workspace mode, --new requires --site <name> to specify the target site.",
)?;
let site_input = Arc::new(input_folder.join(site_name));
if !site_input.exists() {
return Err(format!("Site directory does not exist: {site_input:?}").into());
}
let config_path = Arc::new(site_input.join(&cli_args.config));
content::new(&site_input, title, cli_args, &config_path);
return Ok(());
}
if cli_args.shortcodes {
workspace::show_shortcodes_workspace(&ws_config, input_folder, cli_args);
return Ok(());
}
if cli_args.show_urls {
workspace::show_urls_workspace(&ws_config, input_folder, cli_args);
return Ok(());
}
let output_folder = cli_args
.output_folder
.clone()
.unwrap_or_else(|| input_folder.join("site"));
workspace::run_workspace(
ws_config_path,
input_folder,
Some(output_folder),
watch,
serve,
bind_address,
cli_args,
)
}
fn handle_shortcodes_command(
input_folder: &Path,
cli_args: &Arc<cli::Cli>,
) -> Result<(), Box<dyn std::error::Error>> {
let config_path = input_folder.join(cli_args.config.as_str());
let mut site_data = site::Data::from_file(&config_path);
site_data.site.override_from_cli_args(cli_args);
let mut processor =
shortcodes::ShortcodeProcessor::new(site_data.site.shortcode_pattern.as_deref());
if let Err(e) = processor.collect_shortcodes(input_folder) {
return Err(format!("Failed to collect shortcodes: {e}").into());
}
println!("Shortcodes:");
println!("Enabled: {}", site_data.site.enable_shortcodes);
let pattern = site_data
.site
.shortcode_pattern
.as_deref()
.unwrap_or(re::SHORTCODE_HTML_COMMENT);
println!("Pattern: {pattern}");
println!("\nReusable blocks of content that can be used in your markdown files.");
println!("They are defined in the shortcodes/ directory and are rendered using the Tera template engine.");
println!("Check the documentation for details on how to use and create shortcodes.");
println!("================");
if processor.shortcodes.is_empty() {
println!("No shortcodes available.");
} else {
println!("Examples based on your configuration:");
let shortcode_names: Vec<&str> = processor
.shortcodes
.keys()
.take(3)
.map(std::string::String::as_str)
.collect();
for name in shortcode_names {
let example = if pattern.contains(r"<!--") {
format!(" <!-- .{name} -->")
} else if pattern.contains(r"\{\{<") || pattern.contains("{{<") {
format!(" {{{{< {name} >}}}}")
} else if pattern.contains(r"\{\{%") || pattern.contains("{%") {
format!(" {{{{% {name} %}}}}")
} else if pattern.contains(r"\[") || pattern.contains('[') {
format!(" [{name}]")
} else {
format!(" [Use '{name}' with your pattern]")
};
println!("{example}");
if matches!(
name,
"youtube"
| "spotify"
| "posts"
| "pages"
| "tags"
| "streams"
| "authors"
| "series"
| "card"
| "gallery"
) {
let param_example = if pattern.contains(r"<!--") {
format!(" <!-- .{name} param=value -->")
} else if pattern.contains(r"\{\{<") || pattern.contains("{{<") {
format!(" {{{{< {name} param=\"value\" >}}}}")
} else if pattern.contains(r"\{\{%") || pattern.contains("{%") {
format!(" {{{{% {name} param=\"value\" %}}}}")
} else {
String::new()
};
if !param_example.is_empty() {
println!("{param_example}");
}
}
}
println!("\nNote: Replace 'param' and 'value' with actual parameter names and values.");
if !pattern.contains(r"<!--") {
println!("Custom pattern in use: {pattern}");
}
}
println!("--------------------------------");
println!("Available shortcodes:");
for (name, description) in processor.list_shortcodes_with_descriptions() {
match description {
Some(desc) => println!(" - {name}: {desc}"),
None => println!(" - {name}"),
}
}
Ok(())
}
#[cfg(test)]
#[path = "tests/main.rs"]
mod tests;