feat(cli): add configurable paths and --config flag

Enable monorepo support with CLI configuration:

- Add PathsConfig struct with serde defaults for content, output,
  static, and templates directories
- Add optional [paths] table to site.toml (backward compatible)
- Add -c/--config <FILE> flag to specify config file path
- Add -h/--help flag with usage information
- Resolve all paths relative to config file location

Users can now run multiple sites from a single repo:

  nrd-sh                             # uses ./site.toml
  nrd-sh -c sites/blog/site.toml     # looks in sites/blog/

Includes 2 new unit tests for path configuration parsing.
This commit is contained in:
Timothy DeHerrera
2026-01-31 15:26:22 -07:00
parent 0d2c460f52
commit 4c2c3d5495
2 changed files with 138 additions and 24 deletions

View File

@@ -3,7 +3,7 @@
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use serde::Deserialize; use serde::Deserialize;
use std::fs; use std::fs;
use std::path::Path; use std::path::{Path, PathBuf};
/// Site-wide configuration loaded from site.toml. /// Site-wide configuration loaded from site.toml.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -14,6 +14,35 @@ pub struct SiteConfig {
pub author: String, pub author: String,
/// Base URL for the site (used for feeds, canonical links). /// Base URL for the site (used for feeds, canonical links).
pub base_url: String, pub base_url: String,
/// Path configuration (all optional with defaults).
#[serde(default)]
pub paths: PathsConfig,
}
/// Path configuration with sensible defaults.
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct PathsConfig {
/// Content directory (default: "content")
pub content: PathBuf,
/// Output directory (default: "public")
pub output: PathBuf,
/// Static assets directory (default: "static")
#[serde(rename = "static")]
pub static_dir: PathBuf,
/// Templates directory (default: "templates")
pub templates: PathBuf,
}
impl Default for PathsConfig {
fn default() -> Self {
Self {
content: PathBuf::from("content"),
output: PathBuf::from("public"),
static_dir: PathBuf::from("static"),
templates: PathBuf::from("templates"),
}
}
} }
impl SiteConfig { impl SiteConfig {
@@ -48,4 +77,40 @@ mod tests {
assert_eq!(config.author, "Test Author"); assert_eq!(config.author, "Test Author");
assert_eq!(config.base_url, "https://example.com/"); assert_eq!(config.base_url, "https://example.com/");
} }
#[test]
fn test_paths_config_defaults() {
let toml = r#"
title = "Test"
author = "Author"
base_url = "https://example.com"
"#;
let config: SiteConfig = toml::from_str(toml).unwrap();
assert_eq!(config.paths.content, PathBuf::from("content"));
assert_eq!(config.paths.output, PathBuf::from("public"));
assert_eq!(config.paths.static_dir, PathBuf::from("static"));
assert_eq!(config.paths.templates, PathBuf::from("templates"));
}
#[test]
fn test_paths_config_custom() {
let toml = r#"
title = "Test"
author = "Author"
base_url = "https://example.com"
[paths]
content = "src/content"
output = "dist"
static = "assets"
templates = "theme"
"#;
let config: SiteConfig = toml::from_str(toml).unwrap();
assert_eq!(config.paths.content, PathBuf::from("src/content"));
assert_eq!(config.paths.output, PathBuf::from("dist"));
assert_eq!(config.paths.static_dir, PathBuf::from("assets"));
assert_eq!(config.paths.templates, PathBuf::from("theme"));
}
} }

View File

@@ -17,40 +17,89 @@ use crate::content::{discover_nav, discover_sections, Content, ContentKind, NavI
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::template_engine::{ContentContext, TemplateEngine}; use crate::template_engine::{ContentContext, TemplateEngine};
use std::fs; use std::fs;
use std::path::Path; use std::path::{Path, PathBuf};
const USAGE: &str = "\
nrd-sh - Bespoke static site compiler
USAGE:
nrd-sh [OPTIONS]
OPTIONS:
-c, --config <FILE> Path to site.toml config file (default: ./site.toml)
-h, --help Print this help message
";
fn main() { fn main() {
if let Err(e) = run() { match parse_args() {
Ok(Some(config_path)) => {
if let Err(e) = run(&config_path) {
eprintln!("error: {e}"); eprintln!("error: {e}");
std::process::exit(1); std::process::exit(1);
} }
}
Ok(None) => {} // --help was printed
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
} }
fn run() -> Result<()> { /// Parse command-line arguments. Returns None if --help was requested.
let content_dir = Path::new("content"); fn parse_args() -> std::result::Result<Option<PathBuf>, String> {
let output_dir = Path::new("public"); let args: Vec<_> = std::env::args().collect();
let static_dir = Path::new("static"); let mut config_path = PathBuf::from("site.toml");
let config_path = Path::new("site.toml"); let mut i = 1;
let template_dir = Path::new("templates");
while i < args.len() {
match args[i].as_str() {
"-h" | "--help" => {
print!("{USAGE}");
return Ok(None);
}
"-c" | "--config" => {
if i + 1 >= args.len() {
return Err("--config requires an argument".to_string());
}
config_path = PathBuf::from(&args[i + 1]);
i += 2;
}
arg => {
return Err(format!("unknown argument: {arg}"));
}
}
}
Ok(Some(config_path))
}
fn run(config_path: &Path) -> Result<()> {
// Load site configuration
let config = config::SiteConfig::load(config_path)?;
// Resolve paths relative to config file location
let base_dir = config_path.parent().unwrap_or(Path::new("."));
let content_dir = base_dir.join(&config.paths.content);
let output_dir = base_dir.join(&config.paths.output);
let static_dir = base_dir.join(&config.paths.static_dir);
let template_dir = base_dir.join(&config.paths.templates);
if !content_dir.exists() { if !content_dir.exists() {
return Err(Error::ContentDirNotFound(content_dir.to_path_buf())); return Err(Error::ContentDirNotFound(content_dir.to_path_buf()));
} }
// Load site configuration
let config = config::SiteConfig::load(config_path)?;
// Load Tera templates // Load Tera templates
let engine = TemplateEngine::new(template_dir)?; let engine = TemplateEngine::new(&template_dir)?;
// Discover navigation from filesystem // Discover navigation from filesystem
let nav = discover_nav(content_dir)?; let nav = discover_nav(&content_dir)?;
// 0. Copy static assets // 0. Copy static assets
copy_static_assets(static_dir, output_dir)?; copy_static_assets(&static_dir, &output_dir)?;
// 1. Discover and process all sections // 1. Discover and process all sections
let sections = discover_sections(content_dir)?; let sections = discover_sections(&content_dir)?;
let mut all_posts = Vec::new(); // For feed generation let mut all_posts = Vec::new(); // For feed generation
for section in &sections { for section in &sections {
@@ -92,9 +141,9 @@ fn run() -> Result<()> {
for item in &items { for item in &items {
eprintln!(" processing: {}", item.slug); eprintln!(" processing: {}", item.slug);
let html_body = render::markdown_to_html(&item.body); let html_body = render::markdown_to_html(&item.body);
let page_path = format!("/{}", item.output_path(content_dir).display()); let page_path = format!("/{}", item.output_path(&content_dir).display());
let html = engine.render_content(item, &html_body, &page_path, &config, &nav)?; let html = engine.render_content(item, &html_body, &page_path, &config, &nav)?;
write_output(output_dir, content_dir, item, html)?; write_output(&output_dir, &content_dir, item, html)?;
} }
} }
@@ -102,7 +151,7 @@ fn run() -> Result<()> {
let page_path = format!("/{}/index.html", section.name); let page_path = format!("/{}/index.html", section.name);
let item_contexts: Vec<_> = items let item_contexts: Vec<_> = items
.iter() .iter()
.map(|c| ContentContext::from_content(c, content_dir)) .map(|c| ContentContext::from_content(c, &content_dir))
.collect(); .collect();
let html = engine.render_section( let html = engine.render_section(
&section.index, &section.index,
@@ -127,14 +176,14 @@ fn run() -> Result<()> {
// 2. Generate Atom feed (blog posts only) // 2. Generate Atom feed (blog posts only)
if !all_posts.is_empty() { if !all_posts.is_empty() {
generate_feed(output_dir, &all_posts, &config, content_dir)?; generate_feed(&output_dir, &all_posts, &config, &content_dir)?;
} }
// 3. Process standalone pages (discovered dynamically) // 3. Process standalone pages (discovered dynamically)
process_pages(content_dir, output_dir, &config, &nav, &engine)?; process_pages(&content_dir, &output_dir, &config, &nav, &engine)?;
// 4. Generate homepage // 4. Generate homepage
generate_homepage(content_dir, output_dir, &config, &nav, &engine)?; generate_homepage(&content_dir, &output_dir, &config, &nav, &engine)?;
eprintln!("done!"); eprintln!("done!");
Ok(()) Ok(())