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:
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
95
src/main.rs
95
src/main.rs
@@ -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() {
|
||||||
eprintln!("error: {e}");
|
Ok(Some(config_path)) => {
|
||||||
std::process::exit(1);
|
if let Err(e) = run(&config_path) {
|
||||||
|
eprintln!("error: {e}");
|
||||||
|
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 §ions {
|
for section in §ions {
|
||||||
@@ -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(
|
||||||
§ion.index,
|
§ion.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(())
|
||||||
|
|||||||
Reference in New Issue
Block a user