mod index;
mod links;
mod renderer;
mod sitemap;
mod writer;
use crate::server::language::{parse_supported_language, EN_US};
use crate::util::resolve_to_absolute_path;
use crate::Context;
use anyhow::{bail, Context as _, Result};
use camino::Utf8PathBuf;
use clap::{Args, Subcommand};
use unic_langid::LanguageIdentifier;
#[derive(Debug, Args)]
pub struct BuildArgs {
#[command(subcommand)]
pub command: BuildCommand,
}
#[derive(Debug, Subcommand)]
pub enum BuildCommand {
Web(WebBuildArgs),
}
#[derive(Debug, Args)]
pub struct WebBuildArgs {
#[arg(value_hint = clap::ValueHint::DirPath)]
pub output_dir: Option<Utf8PathBuf>,
#[arg(long, value_hint = clap::ValueHint::DirPath)]
pub base_path: Option<Utf8PathBuf>,
#[arg(long)]
pub base_url: Option<String>,
#[arg(long, value_parser = parse_lang_arg)]
pub lang: Option<LanguageIdentifier>,
#[arg(long)]
pub sitemap: Option<String>,
}
fn parse_lang_arg(s: &str) -> Result<LanguageIdentifier, String> {
parse_supported_language(s).ok_or_else(|| {
format!(
"unsupported language '{s}'. Supported: en-US, de-DE, nl-NL, fr-FR, es-ES, eu-ES, sv-SE"
)
})
}
impl BuildArgs {
pub fn get_base_path(&self) -> Option<Utf8PathBuf> {
match &self.command {
BuildCommand::Web(args) => args.base_path.clone(),
}
}
}
pub fn run(ctx: &Context, args: BuildArgs) -> Result<()> {
match args.command {
BuildCommand::Web(web_args) => run_web(ctx, web_args),
}
}
fn run_web(ctx: &Context, args: WebBuildArgs) -> Result<()> {
let source = resolve_to_absolute_path(ctx.base_path())?;
if !source.is_dir() {
bail!("Source base path is not a directory: {source}");
}
let output_raw = args
.output_dir
.clone()
.unwrap_or_else(|| Utf8PathBuf::from("_site"));
std::fs::create_dir_all(&output_raw)
.with_context(|| format!("Failed to create output directory: {output_raw}"))?;
let output = resolve_to_absolute_path(&output_raw)?;
println!("Building static site from {source} into {output}");
let lang = args.lang.clone().unwrap_or(EN_US);
let base_url = args.base_url.as_deref();
let sitemap_base = args.sitemap.as_deref();
if let Some(base) = sitemap_base {
let parsed =
url::Url::parse(base).with_context(|| format!("Invalid --sitemap URL: {base}"))?;
if parsed.host().is_none()
|| !matches!(parsed.scheme(), "http" | "https")
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
bail!("--sitemap must be an absolute http(s) URL with a host and no query or fragment, e.g. https://recipes.example.com");
}
}
renderer::render_index(&source, &output, base_url, &lang)?;
let mut tree = cooklang_find::build_tree(&source)
.map_err(|e| anyhow::anyhow!("Failed to build recipe tree: {e}"))?;
prune_output_subtree(&mut tree, &output);
walk_directories(&tree, &source, &output, base_url, &lang, String::new())?;
let aisle = ctx.aisle();
let recipe_count = walk_recipes(
&tree,
&source,
&output,
aisle.as_ref(),
base_url,
&lang,
String::new(),
)?;
let image_count = copy_all_images(&source, &output)?;
let asset_count = writer::copy_static_assets(&output)?;
let entries = index::build_search_index(&tree);
let entry_count = entries.len();
let json = serde_json::to_string(&entries)?;
writer::write_bytes(
&output,
camino::Utf8Path::new("static/search-index.json"),
json.as_bytes(),
)?;
let sitemap_written = if let Some(base) = sitemap_base {
sitemap::write_sitemap(&output, base, &tree)?;
true
} else {
false
};
let sitemap_note = if sitemap_written { ", sitemap.xml" } else { "" };
println!(
"Wrote index, directories, {recipe_count} recipe pages, {image_count} images, {asset_count} static assets, {entry_count} search entries{sitemap_note}"
);
Ok(())
}
fn prune_output_subtree(tree: &mut cooklang_find::RecipeTree, output: &camino::Utf8Path) {
tree.children
.retain(|_, child| !child.path.starts_with(output));
for child in tree.children.values_mut() {
prune_output_subtree(child, output);
}
}
fn copy_all_images(source: &camino::Utf8Path, output: &camino::Utf8Path) -> Result<usize> {
let mut count = 0;
let output_std = output.as_std_path();
let walker = walkdir::WalkDir::new(source).into_iter().filter_entry(|e| {
if e.path().starts_with(output_std) {
return false;
}
!e.file_type().is_dir()
|| e.file_name()
.to_str()
.map(|n| !n.starts_with('.'))
.unwrap_or(true)
});
for entry in walker {
let entry = entry.with_context(|| format!("Failed to walk {source}"))?;
if !entry.file_type().is_file() {
continue;
}
let path = camino::Utf8PathBuf::try_from(entry.into_path())
.map_err(|e| anyhow::anyhow!("Non-UTF-8 path: {e}"))?;
if let Some("jpg" | "jpeg" | "png" | "gif" | "webp" | "avif") =
path.extension().map(|e| e.to_ascii_lowercase()).as_deref()
{
writer::copy_image(output, source, &path)?;
count += 1;
}
}
Ok(count)
}
fn walk_directories(
tree: &cooklang_find::RecipeTree,
source: &camino::Utf8Path,
output: &camino::Utf8Path,
base_url: Option<&str>,
lang: &unic_langid::LanguageIdentifier,
prefix_path: String,
) -> Result<()> {
for (name, child) in &tree.children {
if child.children.is_empty() {
continue; }
let sub = if prefix_path.is_empty() {
name.to_string()
} else {
format!("{prefix_path}/{name}")
};
renderer::render_directory(source, output, &sub, base_url, lang)?;
walk_directories(child, source, output, base_url, lang, sub)?;
}
Ok(())
}
fn walk_recipes(
tree: &cooklang_find::RecipeTree,
source: &camino::Utf8Path,
output: &camino::Utf8Path,
aisle_path: Option<&camino::Utf8PathBuf>,
base_url: Option<&str>,
lang: &unic_langid::LanguageIdentifier,
prefix_path: String,
) -> Result<usize> {
let mut count = 0;
for (name, child) in &tree.children {
if child.children.is_empty() {
let leaf_name = child
.recipe
.as_ref()
.and_then(|r| r.file_name())
.unwrap_or_else(|| name.to_string());
let sub = if prefix_path.is_empty() {
leaf_name
} else {
format!("{prefix_path}/{leaf_name}")
};
if let Err(e) =
renderer::render_recipe(source, output, &sub, aisle_path, base_url, lang)
{
tracing::warn!("Skipping recipe {sub}: {e:#}");
continue;
}
if sub.ends_with(".cook") {
if let Err(e) = writer::copy_recipe_source(output, source, &sub) {
tracing::warn!("Skipping source copy for {sub}: {e:#}");
}
}
count += 1;
} else {
let sub = if prefix_path.is_empty() {
name.to_string()
} else {
format!("{prefix_path}/{name}")
};
count += walk_recipes(child, source, output, aisle_path, base_url, lang, sub)?;
}
}
Ok(count)
}