use std::collections::HashMap;
use std::fs;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use statica::{
AliasOptions, AssetProcessOptions, BuildOptions, FormsOptions, I18nOptions,
ImageProcessOptions, LocalAlias, MinifyOptions, PaginationRule, RenderMode, RssOptions,
SearchOptions, SitemapOptions, UrlAlias,
};
pub const CONFIG_FILE: &str = "statica.toml";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct StaticaConfig {
#[serde(default)]
pub project: String,
pub out_dir: String,
pub clean: bool,
pub copy_assets: bool,
pub asset_dirs: Vec<String>,
pub ignore_dirs: Vec<String>,
pub site_url: String,
pub process: ProcessConfig,
pub minify: MinifyConfig,
pub performance: PerformanceConfig,
pub sitemap: SitemapConfig,
pub rss: RssConfig,
pub search: SearchConfig,
pub pagination: Vec<PaginationConfig>,
#[serde(alias = "watch")]
pub preview: PreviewConfig,
pub aliases: AliasesConfig,
pub forms: FormsConfig,
pub env: crate::env::EnvConfig,
pub i18n: I18nConfig,
pub manifest: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AliasesConfig {
pub symbol: String,
#[serde(default)]
pub paths: HashMap<String, String>,
#[serde(default)]
pub urls: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct PerformanceConfig {
pub render_mode: RenderModeConfig,
pub render_threads: usize,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum RenderModeConfig {
#[default]
Auto,
Serial,
Parallel,
}
impl From<RenderModeConfig> for RenderMode {
fn from(mode: RenderModeConfig) -> Self {
match mode {
RenderModeConfig::Auto => Self::Auto,
RenderModeConfig::Serial => Self::Serial,
RenderModeConfig::Parallel => Self::Parallel,
}
}
}
impl Default for AliasesConfig {
fn default() -> Self {
AliasOptions::default().into()
}
}
impl From<AliasOptions> for AliasesConfig {
fn from(opts: AliasOptions) -> Self {
Self {
symbol: opts.symbol,
paths: opts
.paths
.into_iter()
.map(|(name, alias)| (name, alias.base))
.collect(),
urls: opts
.urls
.into_iter()
.map(|(name, alias)| (name, alias.base))
.collect(),
}
}
}
impl AliasesConfig {
pub fn validate(&self) -> Result<()> {
self.to_core().validate()?;
Ok(())
}
#[must_use]
pub fn to_core(&self) -> AliasOptions {
AliasOptions {
symbol: self.symbol.clone(),
paths: self
.paths
.iter()
.map(|(name, base)| (name.clone(), LocalAlias::new(base)))
.collect(),
urls: self
.urls
.iter()
.map(|(name, base)| (name.clone(), UrlAlias::new(base)))
.collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FormsConfig {
pub enabled: bool,
pub provider: String,
pub endpoint: String,
#[serde(default)]
pub ids: HashMap<String, String>,
pub endpoint_env: String,
}
impl Default for FormsConfig {
fn default() -> Self {
Self {
enabled: false,
provider: "formspree".into(),
endpoint: "https://formspree.io/f/{id}".into(),
ids: HashMap::new(),
endpoint_env: "FORMS_ENDPOINT".into(),
}
}
}
impl FormsConfig {
pub fn resolve_env(&mut self) {
if let Ok(v) = std::env::var(&self.endpoint_env) {
if !v.is_empty() {
self.endpoint = v;
}
}
for (key, id) in &mut self.ids {
let env_key = format!("FORMS_{}_ID", key.to_ascii_uppercase().replace('-', "_"));
if let Ok(v) = std::env::var(&env_key) {
if !v.is_empty() {
*id = v;
}
}
}
}
#[must_use]
pub fn to_core(&self) -> FormsOptions {
FormsOptions {
enabled: self.enabled,
provider: FormsOptions::provider_from_str(&self.provider),
endpoint: self.endpoint.clone(),
ids: self.ids.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProcessConfig {
pub enabled: bool,
pub css: bool, pub js: bool,
pub images: bool,
pub fonts: bool,
#[serde(default)]
pub image: ImageProcessConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ImageProcessConfig {
pub widths: Vec<u32>,
pub formats: Vec<String>,
pub quality: u8,
pub sizes: String,
pub responsive: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MinifyConfig {
pub enabled: bool,
pub html: bool,
pub css: bool,
pub js: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SitemapConfig {
pub enabled: bool,
pub filename: String,
pub urls_per_file: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SearchConfig {
pub enabled: bool,
pub output: String,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
enabled: false,
output: "search.json".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PaginationConfig {
pub route: String,
#[serde(alias = "per_page")]
pub page_size: usize,
#[serde(default)]
pub limit: usize,
#[serde(default)]
pub offset: usize,
#[serde(default)]
pub sort_by: String,
#[serde(default)]
pub sort_desc: bool,
#[serde(default)]
pub max_pages: usize,
#[serde(default)]
pub index: bool,
}
impl Default for PaginationConfig {
fn default() -> Self {
Self {
route: String::new(),
page_size: 10,
limit: 0,
offset: 0,
sort_by: String::new(),
sort_desc: false,
max_pages: 0,
index: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RssConfig {
pub enabled: bool,
pub filename: String,
pub title: String,
pub description: String,
pub language: String,
pub limit: usize,
pub title_field: String,
pub description_field: String,
pub date_field: String,
pub collections: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PreviewConfig {
pub host: String,
pub port: u16,
pub debounce_ms: u64,
pub poll_interval_secs: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct I18nConfig {
pub enabled: bool,
#[serde(default = "default_i18n_locale")]
pub default: String,
pub locales: Vec<String>,
#[serde(default = "default_i18n_dir")]
pub dir: String,
#[serde(default)]
pub fallback: String,
}
fn default_i18n_locale() -> String {
"en".into()
}
fn default_i18n_dir() -> String {
"content/i18n".into()
}
impl Default for I18nConfig {
fn default() -> Self {
I18nOptions::default().into()
}
}
impl From<I18nOptions> for I18nConfig {
fn from(opts: I18nOptions) -> Self {
Self {
enabled: opts.enabled,
default: opts.default_locale,
locales: opts.locales,
dir: opts.dir,
fallback: opts.fallback,
}
}
}
impl I18nConfig {
#[must_use]
pub fn to_core(&self) -> I18nOptions {
I18nOptions {
enabled: self.enabled,
default_locale: self.default.clone(),
locales: if self.locales.is_empty() {
vec![self.default.clone()]
} else {
self.locales.clone()
},
dir: self.dir.clone(),
fallback: self.fallback.clone(),
}
}
}
impl Default for StaticaConfig {
fn default() -> Self {
Self {
project: String::new(),
out_dir: ".website".into(),
clean: true,
copy_assets: true,
asset_dirs: vec!["public".into(), "assets".into(), "static".into()],
ignore_dirs: vec![
".website".into(),
"dist".into(),
"target".into(),
".git".into(),
],
site_url: String::new(),
process: ProcessConfig::default(),
minify: MinifyConfig::default(),
performance: PerformanceConfig::default(),
sitemap: SitemapConfig::default(),
rss: RssConfig::default(),
search: SearchConfig::default(),
pagination: Vec::new(),
preview: PreviewConfig::default(),
aliases: AliasesConfig::default(),
forms: FormsConfig::default(),
env: crate::env::EnvConfig::default(),
i18n: I18nConfig::default(),
manifest: false,
}
}
}
impl Default for ProcessConfig {
fn default() -> Self {
Self {
enabled: false,
css: true,
js: true,
images: true,
fonts: false,
image: ImageProcessConfig::default(),
}
}
}
impl Default for ImageProcessConfig {
fn default() -> Self {
ImageProcessOptions::default().into()
}
}
impl From<ImageProcessOptions> for ImageProcessConfig {
fn from(opts: ImageProcessOptions) -> Self {
Self {
widths: opts.widths,
formats: opts.formats,
quality: opts.quality,
sizes: opts.default_sizes,
responsive: opts.responsive,
}
}
}
impl ImageProcessConfig {
#[must_use]
pub fn to_core(&self) -> ImageProcessOptions {
ImageProcessOptions {
widths: if self.widths.is_empty() {
ImageProcessOptions::default().widths
} else {
self.widths.clone()
},
formats: if self.formats.is_empty() {
ImageProcessOptions::default().formats
} else {
self.formats.clone()
},
quality: self.quality,
default_sizes: if self.sizes.is_empty() {
ImageProcessOptions::default().default_sizes
} else {
self.sizes.clone()
},
responsive: self.responsive,
}
}
}
impl Default for MinifyConfig {
fn default() -> Self {
Self {
enabled: false,
html: true,
css: true,
js: true,
}
}
}
impl Default for SitemapConfig {
fn default() -> Self {
Self {
enabled: false,
filename: "sitemap.xml".into(),
urls_per_file: 50_000,
}
}
}
impl Default for RssConfig {
fn default() -> Self {
Self {
enabled: false,
filename: "rss.xml".into(),
title: String::new(),
description: String::new(),
language: "en".into(),
limit: 50,
title_field: "headline".into(),
description_field: "summary".into(),
date_field: "published_at".into(),
collections: Vec::new(),
}
}
}
impl Default for PreviewConfig {
fn default() -> Self {
Self {
host: "0.0.0.0".into(),
port: 4321,
debounce_ms: 80,
poll_interval_secs: 2,
}
}
}
impl ProcessConfig {
#[must_use]
pub fn to_core(&self) -> AssetProcessOptions {
AssetProcessOptions {
enabled: self.enabled,
css: self.css,
js: self.js,
images: self.images,
fonts: self.fonts,
image: self.image.to_core(),
}
}
}
impl MinifyConfig {
#[must_use]
pub fn to_core(&self) -> MinifyOptions {
MinifyOptions {
enabled: self.enabled,
html: self.html,
css: self.css,
js: self.js,
}
}
}
impl SitemapConfig {
#[must_use]
pub fn to_core(&self) -> SitemapOptions {
SitemapOptions {
enabled: self.enabled,
filename: self.filename.clone(),
urls_per_file: self.urls_per_file,
}
}
}
impl SearchConfig {
#[must_use]
pub fn to_core(&self) -> SearchOptions {
SearchOptions {
enabled: self.enabled,
output: self.output.clone(),
}
}
}
impl PaginationConfig {
#[must_use]
pub fn to_core(&self) -> PaginationRule {
PaginationRule {
route: self.route.clone(),
page_size: self.page_size.max(1),
limit: self.limit,
offset: self.offset,
sort_by: self.sort_by.clone(),
sort_desc: self.sort_desc,
max_pages: self.max_pages,
index: self.index,
}
}
}
impl RssConfig {
#[must_use]
pub fn to_core(&self) -> RssOptions {
RssOptions {
enabled: self.enabled,
filename: self.filename.clone(),
title: self.title.clone(),
description: self.description.clone(),
language: self.language.clone(),
limit: self.limit,
title_field: self.title_field.clone(),
description_field: self.description_field.clone(),
date_field: self.date_field.clone(),
collections: self.collections.clone(),
}
}
}
impl PreviewConfig {
pub fn host_addr(&self) -> Result<IpAddr> {
IpAddr::from_str(&self.host)
.with_context(|| format!("invalid [preview].host `{}`", self.host))
}
}
impl StaticaConfig {
pub fn load(root: &Path) -> Result<Self> {
let path = root.join(CONFIG_FILE);
if !path.exists() {
return Ok(Self::default());
}
let text = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let cfg: StaticaConfig = toml::from_str(&text)
.with_context(|| format!("invalid {} ({})", CONFIG_FILE, path.display()))?;
cfg.aliases.validate()?;
Ok(cfg)
}
pub fn apply_env(&mut self, config_dir: &Path) -> Result<()> {
crate::env::apply(config_dir, &self.env)?;
self.forms.resolve_env();
Ok(())
}
#[must_use]
pub fn out_dir_path(&self, root: &Path) -> PathBuf {
if Path::new(&self.out_dir).is_absolute() {
PathBuf::from(&self.out_dir)
} else {
root.join(&self.out_dir)
}
}
#[must_use]
pub fn to_build_options(&self, root: impl Into<PathBuf>) -> BuildOptions {
let root = root.into();
let mut ignore_dirs = self.ignore_dirs.clone();
let out_name = Path::new(&self.out_dir)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(self.out_dir.as_str());
if !ignore_dirs.iter().any(|d| d == out_name) {
ignore_dirs.push(out_name.to_string());
}
BuildOptions {
out_dir: self.out_dir_path(&root),
copy_assets: self.copy_assets,
site_url: self.site_url.clone(),
sitemap: self.sitemap.to_core(),
rss: self.rss.to_core(),
search: self.search.to_core(),
pagination: self
.pagination
.iter()
.map(PaginationConfig::to_core)
.collect(),
process: self.process.to_core(),
minify: self.minify.to_core(),
aliases: self.aliases.to_core(),
forms: self.forms.to_core(),
i18n: self.i18n.to_core(),
manifest: self.manifest,
clean: self.clean,
asset_dirs: self.asset_dirs.clone(),
ignore_dirs,
root,
verbose: false,
render_mode: self.performance.render_mode.into(),
render_threads: self.performance.render_threads,
}
}
pub fn apply_cli(&mut self, cli: &crate::cli::ConfigCli) -> Result<()> {
if let Some(v) = &cli.project {
self.project = v.clone();
}
if let Some(v) = &cli.out_dir {
self.out_dir = v.clone();
}
if let Some(v) = &cli.site_url {
self.site_url = v.clone();
}
apply_bool(cli.clean, cli.no_clean, &mut self.clean);
apply_bool(cli.copy_assets, cli.no_copy_assets, &mut self.copy_assets);
if let Some(v) = &cli.asset_dirs {
self.asset_dirs = v.clone();
}
if let Some(v) = &cli.ignore_dirs {
self.ignore_dirs = v.clone();
}
if let Some(v) = cli.render_mode {
self.performance.render_mode = match v {
crate::cli_config::RenderModeArg::Auto => RenderModeConfig::Auto,
crate::cli_config::RenderModeArg::Serial => RenderModeConfig::Serial,
crate::cli_config::RenderModeArg::Parallel => RenderModeConfig::Parallel,
};
}
if let Some(v) = cli.render_threads {
self.performance.render_threads = v;
}
if cli.no_process {
self.process.enabled = false;
} else if let Some(spec) = &cli.process {
self.process.enabled = true;
if !spec.is_empty() {
apply_process_spec(&mut self.process, spec)?;
}
}
if cli.no_minify {
self.minify.enabled = false;
} else if let Some(spec) = &cli.minify {
self.minify.enabled = true;
if !spec.is_empty() {
apply_minify_spec(&mut self.minify, spec)?;
}
}
if cli.no_sitemap {
self.sitemap.enabled = false;
} else if let Some(spec) = &cli.sitemap {
self.sitemap.enabled = true;
if !spec.is_empty() {
apply_sitemap_spec(&mut self.sitemap, spec)?;
}
}
if cli.no_rss {
self.rss.enabled = false;
} else if let Some(spec) = &cli.rss {
self.rss.enabled = true;
if !spec.is_empty() {
apply_rss_spec(&mut self.rss, spec)?;
}
}
if cli.no_search {
self.search.enabled = false;
} else if let Some(spec) = &cli.search {
self.search.enabled = true;
if !spec.is_empty() {
apply_search_spec(&mut self.search, spec)?;
}
}
if !cli.pagination.is_empty() {
self.pagination = cli
.pagination
.iter()
.map(|s| parse_pagination_spec(s))
.collect::<Result<Vec<_>>>()?;
}
if let Some(spec) = &cli.preview {
apply_preview_spec(&mut self.preview, spec)?;
}
if let Some(v) = &cli.host {
self.preview.host = v.clone();
}
if let Some(v) = cli.port {
self.preview.port = v;
}
if cli.no_i18n {
self.i18n.enabled = false;
} else if let Some(spec) = &cli.i18n {
self.i18n.enabled = true;
if !spec.is_empty() {
apply_i18n_spec(&mut self.i18n, spec)?;
}
}
if cli.no_manifest {
self.manifest = false;
} else if cli.manifest {
self.manifest = true;
}
Ok(())
}
#[must_use]
pub fn default_toml() -> String {
r#"# statica.toml — all keys optional; defaults shown
# Site root relative to this file (empty = this directory).
# Monorepo example: keep statica.toml at the repo root and set:
# project = "apps/docs"
project = ""
out_dir = ".website"
clean = true
copy_assets = true
asset_dirs = ["public", "assets", "static"]
ignore_dirs = [".website", "dist", "target", ".git"]
site_url = "" # e.g. "https://example.com" — needed for sitemap/RSS
# 404/index.html or 404.html overrides the default generated 404 page.
# Authoring aliases — @Name/tail in hrefs (symbol defaults to @)
[aliases]
symbol = "@"
[aliases.urls]
Google = "https://fonts.googleapis.com/css2"
[aliases.paths]
# fonts = "./assets/fonts" # local: @fonts/outfit.css → ./assets/fonts/outfit.css
# Asset optimize (also: statica --process)
[process]
enabled = false
css = true
js = true
images = true
fonts = false
# Responsive images — widths, WebP variants, <picture> in HTML (needs [process].images)
[process.image]
widths = [480, 768, 1024, 1366, 1920]
formats = ["webp"]
quality = 85
sizes = "100vw"
responsive = true
# Final output minification (also: statica --minify)
[minify]
enabled = false
html = true # .html + inline <style>/<script> when css/js on
css = true # .css under out_dir
js = true # .js under out_dir
# Build performance controls
[performance]
render_mode = "auto" # auto | serial | parallel
render_threads = 0 # worker threads for parallel rendering (0 = auto)
# XML sitemap of every emitted page (needs site_url)
[sitemap]
enabled = false
filename = "sitemap.xml"
urls_per_file = 50000 # over this → sitemap-1.xml… + index at filename
# RSS 2.0 from collection pages (needs site_url)
[rss]
enabled = false
filename = "rss.xml"
title = ""
description = ""
language = "en"
limit = 50
title_field = "headline"
description_field = "summary"
date_field = "published_at"
collections = [] # empty = all collections; or ["posts"]
# Browser-side search index. Also auto-enabled by <input type="statica/search">
[search]
enabled = false
output = "search.json"
# Paginated listings — templates with [page], data via <html data-bind>
# [[pagination]]
# page_size = 10 # alias: per_page
# limit = 0 # max items after offset (0 = all)
# offset = 0 # skip first N items
# sort_by = "published_at" # empty = keep JSON order
# sort_desc = true
# max_pages = 0 # cap page folders (0 = all)
# index = true # also write page 1 at blog/
# Local preview for `statica serve` / `statica watch`
[preview]
host = "0.0.0.0"
port = 4321
debounce_ms = 80
poll_interval_secs = 2
# Internationalization — locale from folder structure + content/i18n/{locale}.json
# [i18n]
# enabled = false
# default = "en"
# locales = ["en", "pt"] # expanded for every [locale]/… template
# dir = "content/i18n"
# fallback = "" # empty → default locale catalog
# Web app manifest — scaffold public/manifest.webmanifest + inject PWA head tags
# manifest = false # or: statica --manifest
"#
.into()
}
}
fn apply_bool(set: bool, unset: bool, target: &mut bool) {
if set {
*target = true;
} else if unset {
*target = false;
}
}
fn parse_bool(raw: &str) -> Result<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
other => anyhow::bail!("invalid bool `{other}` (use true/false)"),
}
}
fn for_each_kv(spec: &str, mut f: impl FnMut(&str, &str) -> Result<()>) -> Result<()> {
for part in spec.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let (key, value) = part
.split_once('=')
.with_context(|| format!("SPEC needs key=value, got `{part}`"))?;
f(key.trim(), value.trim())?;
}
Ok(())
}
fn apply_process_spec(cfg: &mut ProcessConfig, spec: &str) -> Result<()> {
for_each_kv(spec, |key, value| {
match key {
"enabled" => cfg.enabled = parse_bool(value)?,
"css" => cfg.css = parse_bool(value)?,
"js" => cfg.js = parse_bool(value)?,
"images" => cfg.images = parse_bool(value)?,
"fonts" => cfg.fonts = parse_bool(value)?,
"image.widths" => cfg.image.widths = parse_width_list(value)?,
"image.formats" => {
cfg.image.formats = value
.split('|')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
}
"image.quality" => {
cfg.image.quality = value
.parse()
.with_context(|| format!("invalid image.quality `{value}`"))?;
}
"image.sizes" => cfg.image.sizes = value.to_string(),
"image.responsive" => cfg.image.responsive = parse_bool(value)?,
other => anyhow::bail!("unknown process key `{other}`"),
}
Ok(())
})
}
fn parse_width_list(value: &str) -> Result<Vec<u32>> {
let sep = if value.contains('|') { '|' } else { ',' };
value
.split(sep)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| {
s.parse()
.with_context(|| format!("invalid width `{s}` in `{value}`"))
})
.collect()
}
fn apply_minify_spec(cfg: &mut MinifyConfig, spec: &str) -> Result<()> {
for_each_kv(spec, |key, value| {
match key {
"enabled" => cfg.enabled = parse_bool(value)?,
"html" => cfg.html = parse_bool(value)?,
"css" => cfg.css = parse_bool(value)?,
"js" => cfg.js = parse_bool(value)?,
other => anyhow::bail!("unknown minify key `{other}`"),
}
Ok(())
})
}
fn apply_sitemap_spec(cfg: &mut SitemapConfig, spec: &str) -> Result<()> {
for_each_kv(spec, |key, value| {
match key {
"enabled" => cfg.enabled = parse_bool(value)?,
"filename" => cfg.filename = value.to_string(),
"urls_per_file" => {
cfg.urls_per_file = value
.parse()
.with_context(|| format!("invalid urls_per_file `{value}`"))?;
}
other => anyhow::bail!("unknown sitemap key `{other}`"),
}
Ok(())
})
}
fn apply_rss_spec(cfg: &mut RssConfig, spec: &str) -> Result<()> {
for_each_kv(spec, |key, value| {
match key {
"enabled" => cfg.enabled = parse_bool(value)?,
"filename" => cfg.filename = value.to_string(),
"title" => cfg.title = value.to_string(),
"description" => cfg.description = value.to_string(),
"language" => cfg.language = value.to_string(),
"limit" => {
cfg.limit = value
.parse()
.with_context(|| format!("invalid limit `{value}`"))?;
}
"title_field" => cfg.title_field = value.to_string(),
"description_field" => cfg.description_field = value.to_string(),
"date_field" => cfg.date_field = value.to_string(),
"collections" => {
cfg.collections = value
.split('|')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
}
other => anyhow::bail!("unknown rss key `{other}`"),
}
Ok(())
})
}
fn apply_search_spec(cfg: &mut SearchConfig, spec: &str) -> Result<()> {
for_each_kv(spec, |key, value| {
match key {
"enabled" => cfg.enabled = parse_bool(value)?,
"output" => cfg.output = value.to_string(),
other => anyhow::bail!("unknown search key `{other}`"),
}
Ok(())
})
}
fn apply_preview_spec(cfg: &mut PreviewConfig, spec: &str) -> Result<()> {
for_each_kv(spec, |key, value| {
match key {
"host" => cfg.host = value.to_string(),
"port" => {
cfg.port = value
.parse()
.with_context(|| format!("invalid port `{value}`"))?;
}
"debounce_ms" => {
cfg.debounce_ms = value
.parse()
.with_context(|| format!("invalid debounce_ms `{value}`"))?;
}
"poll_interval_secs" => {
cfg.poll_interval_secs = value
.parse()
.with_context(|| format!("invalid poll_interval_secs `{value}`"))?;
}
other => anyhow::bail!("unknown preview key `{other}`"),
}
Ok(())
})
}
fn parse_pagination_spec(spec: &str) -> Result<PaginationConfig> {
let mut cfg = PaginationConfig::default();
for_each_kv(spec, |key, value| {
match key {
"route" => cfg.route = value.to_string(),
"page_size" | "per_page" => {
cfg.page_size = value
.parse()
.with_context(|| format!("invalid page_size `{value}`"))?;
}
"limit" => {
cfg.limit = value
.parse()
.with_context(|| format!("invalid limit `{value}`"))?;
}
"offset" => {
cfg.offset = value
.parse()
.with_context(|| format!("invalid offset `{value}`"))?;
}
"sort_by" => cfg.sort_by = value.to_string(),
"sort_desc" => cfg.sort_desc = parse_bool(value)?,
"max_pages" => {
cfg.max_pages = value
.parse()
.with_context(|| format!("invalid max_pages `{value}`"))?;
}
"index" => cfg.index = parse_bool(value)?,
other => anyhow::bail!("unknown pagination key `{other}`"),
}
Ok(())
})?;
Ok(cfg)
}
fn apply_i18n_spec(cfg: &mut I18nConfig, spec: &str) -> Result<()> {
for_each_kv(spec, |key, value| {
match key {
"enabled" => cfg.enabled = parse_bool(value)?,
"default" => cfg.default = value.to_string(),
"locales" => {
cfg.locales = value
.split('|')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
}
"dir" => cfg.dir = value.to_string(),
"fallback" => cfg.fallback = value.to_string(),
other => anyhow::bail!("unknown i18n key `{other}`"),
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("statica-cli-config-{nanos}"));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn defaults_without_file() {
let dir = temp_dir();
let cfg = StaticaConfig::load(&dir).unwrap();
assert_eq!(cfg.preview.host, "0.0.0.0");
assert_eq!(cfg.preview.port, 4321);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn loads_preview() {
let dir = temp_dir();
fs::write(
dir.join(CONFIG_FILE),
r#"
[preview]
host = "0.0.0.0"
port = 8080
"#,
)
.unwrap();
let cfg = StaticaConfig::load(&dir).unwrap();
assert_eq!(cfg.preview.host, "0.0.0.0");
assert_eq!(cfg.preview.port, 8080);
assert_eq!(
cfg.preview.host_addr().unwrap(),
IpAddr::from_str("0.0.0.0").unwrap()
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn watch_alias_still_loads() {
let dir = temp_dir();
fs::write(
dir.join(CONFIG_FILE),
r#"
[watch]
port = 9000
"#,
)
.unwrap();
let cfg = StaticaConfig::load(&dir).unwrap();
assert_eq!(cfg.preview.port, 9000);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn loads_forms() {
let dir = temp_dir();
fs::write(
dir.join(CONFIG_FILE),
r#"
[forms]
enabled = true
provider = "formspree"
[forms.ids]
contact = "xyzabc"
"#,
)
.unwrap();
let cfg = StaticaConfig::load(&dir).unwrap();
assert!(cfg.forms.enabled);
assert_eq!(cfg.forms.provider, "formspree");
assert_eq!(
cfg.forms.ids.get("contact").map(String::as_str),
Some("xyzabc")
);
let core = cfg.forms.to_core();
assert!(core.enabled);
assert_eq!(core.ids.get("contact").map(String::as_str), Some("xyzabc"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn env_files_override_forms_ids() {
let dir = temp_dir();
fs::write(
dir.join(CONFIG_FILE),
r#"
[forms]
enabled = true
[forms.ids]
contact = "from-config"
"#,
)
.unwrap();
fs::write(dir.join(".env"), "FORMS_CONTACT_ID=from-dotenv\n").unwrap();
fs::write(dir.join(".dev.vars"), "FORMS_CONTACT_ID=from-devvars\n").unwrap();
let mut cfg = StaticaConfig::load(&dir).unwrap();
cfg.apply_env(&dir).unwrap();
assert_eq!(
cfg.forms.ids.get("contact").map(String::as_str),
Some("from-devvars")
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn loads_aliases() {
let dir = temp_dir();
fs::write(
dir.join(CONFIG_FILE),
r#"
[aliases]
symbol = "@"
[aliases.urls]
Google = "https://fonts.googleapis.com/css2"
[aliases.paths]
fonts = "./assets/fonts"
"#,
)
.unwrap();
let cfg = StaticaConfig::load(&dir).unwrap();
assert_eq!(cfg.aliases.symbol, "@");
assert_eq!(
cfg.aliases.urls.get("Google").map(String::as_str),
Some("https://fonts.googleapis.com/css2")
);
assert_eq!(
cfg.aliases.paths.get("fonts").map(String::as_str),
Some("./assets/fonts")
);
let opts = cfg.to_build_options(&dir);
let resolved = opts
.aliases
.parse("@Google/?family=Outfit&display=swap")
.unwrap();
assert_eq!(
statica::join_alias(resolved.base(), resolved.tail),
"https://fonts.googleapis.com/css2?family=Outfit&display=swap"
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn rejects_urls_in_aliases_paths() {
let dir = temp_dir();
fs::write(
dir.join(CONFIG_FILE),
r#"
[aliases.paths]
Google = "https://fonts.googleapis.com/css2"
"#,
)
.unwrap();
let err = StaticaConfig::load(&dir).unwrap_err().to_string();
assert!(err.contains("[aliases.paths].Google"), "{err}");
assert!(err.contains("[aliases.urls]"), "{err}");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn rejects_non_urls_in_aliases_urls() {
let dir = temp_dir();
fs::write(
dir.join(CONFIG_FILE),
r#"
[aliases.urls]
fonts = "./assets/fonts"
"#,
)
.unwrap();
let err = StaticaConfig::load(&dir).unwrap_err().to_string();
assert!(err.contains("[aliases.urls].fonts"), "{err}");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn default_toml_roundtrips() {
let cfg: StaticaConfig = toml::from_str(&StaticaConfig::default_toml()).unwrap();
assert!(!cfg.sitemap.enabled);
assert!(!cfg.rss.enabled);
assert_eq!(cfg.preview.host, "0.0.0.0");
assert!(matches!(
cfg.performance.render_mode,
RenderModeConfig::Auto
));
assert_eq!(cfg.performance.render_threads, 0);
}
#[test]
fn maps_feeds() {
let mut cfg = StaticaConfig {
site_url: "https://example.com".into(),
..StaticaConfig::default()
};
cfg.rss.enabled = true;
cfg.rss.title = "Blog".into();
let opts = cfg.to_build_options(PathBuf::from("/tmp/site"));
assert_eq!(opts.site_url, "https://example.com");
assert!(!opts.sitemap.enabled);
assert!(opts.rss.enabled);
assert_eq!(opts.rss.title, "Blog");
}
#[test]
fn apply_cli_i18n() {
let mut cfg = StaticaConfig::default();
let cli = crate::cli::ConfigCli {
i18n: Some("locales=en|pt,default=en".into()),
..crate::cli::ConfigCli::default()
};
cfg.apply_cli(&cli).unwrap();
assert!(cfg.i18n.enabled);
assert_eq!(cfg.i18n.locales, vec!["en", "pt"]);
assert_eq!(cfg.i18n.default, "en");
}
#[test]
fn loads_i18n_config() {
let dir = temp_dir();
fs::write(
dir.join(CONFIG_FILE),
r#"
[i18n]
enabled = true
default = "en"
locales = ["en", "pt"]
dir = "locales"
"#,
)
.unwrap();
let mut cfg = StaticaConfig::load(&dir).unwrap();
cfg.apply_env(&dir).unwrap();
assert!(cfg.i18n.enabled);
assert_eq!(cfg.i18n.locales, vec!["en", "pt"]);
let core = cfg.i18n.to_core();
assert_eq!(core.dir, "locales");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn apply_cli_overrides() {
let mut cfg = StaticaConfig::default();
let cli = crate::cli::ConfigCli {
process: Some(String::new()),
no_sitemap: true,
rss: Some("title=T,limit=3,collections=posts|notes".into()),
search: Some("output=assets/search.json".into()),
site_url: Some("https://ex.com".into()),
pagination: vec!["page_size=2,sort_desc=true,index=true".into()],
render_mode: Some(crate::cli_config::RenderModeArg::Parallel),
render_threads: Some(4),
preview: Some("port=9000,debounce_ms=50".into()),
..crate::cli::ConfigCli::default()
};
cfg.apply_cli(&cli).unwrap();
assert!(cfg.process.enabled);
assert!(!cfg.sitemap.enabled);
assert!(cfg.rss.enabled);
assert_eq!(cfg.rss.title, "T");
assert_eq!(cfg.rss.limit, 3);
assert_eq!(cfg.rss.collections, vec!["posts", "notes"]);
assert!(cfg.search.enabled);
assert_eq!(cfg.search.output, "assets/search.json");
assert_eq!(cfg.preview.port, 9000);
assert_eq!(cfg.site_url, "https://ex.com");
assert_eq!(cfg.pagination.len(), 1);
assert_eq!(cfg.pagination[0].page_size, 2);
assert!(cfg.pagination[0].index);
assert!(matches!(
cfg.performance.render_mode,
RenderModeConfig::Parallel
));
assert_eq!(cfg.performance.render_threads, 4);
let opts = cfg.to_build_options("/tmp/site");
assert_eq!(opts.render_mode, RenderMode::Parallel);
assert_eq!(opts.render_threads, 4);
}
}