diff --git a/Cargo.lock b/Cargo.lock index af4e1175..a147ba53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -642,6 +642,7 @@ dependencies = [ "url", "urlencoding", "uuid", + "walkdir", "yansi", ] diff --git a/Cargo.toml b/Cargo.toml index c16256ff..ad08719d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } unic-langid = "0.9" urlencoding = "2" url = "2.5" +walkdir = "2" uuid = { version = "1", features = ["v4"], optional = true } yansi = "1" @@ -86,6 +87,7 @@ insta = { version = "1", features = ["yaml", "json", "filters"] } strip-ansi-escapes = "0.2" tokio = { version = "1", features = ["full", "test-util"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +regex = "1" [profile.release] strip = true diff --git a/README.md b/README.md index 68c05294..c3fc63ac 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,21 @@ cook server --port 8080 cook server --open ``` +### `cook build` + +Generate a self-contained static website from your recipes. Hostable anywhere or browsable via `file://`. + +```bash +# Build into ./_site from the current directory +cook build + +# Build a specific collection into a custom output +cook build dist --base-path ~/my-recipes + +# Build for hosting under a subpath +cook build --base-url /recipes/ +``` + ### `cook search` Find recipes by searching through ingredients, instructions, and metadata. @@ -371,6 +386,7 @@ Detailed documentation for each command is available in the [docs/](docs/) direc * [Recipe command](docs/recipe.md) - viewing and converting recipes * [Shopping lists](docs/shopping-list.md) - creating shopping lists * [Server](docs/server.md) - web interface +* [Build](docs/build.md) - generate a static website * [Search](docs/search.md) - finding recipes * [Import](docs/import.md) - importing from websites * [Doctor](docs/doctor.md) - validation and maintenance diff --git a/docs/build.md b/docs/build.md new file mode 100644 index 00000000..903d4cc2 --- /dev/null +++ b/docs/build.md @@ -0,0 +1,89 @@ +# Build Command + +Generate a self-contained static website from your recipe collection. The output mirrors `cook server`'s browsing experience but ships as plain HTML, CSS, and JS — no Rust process needed at runtime, so it can be hosted on GitHub Pages, Netlify, S3, or opened directly via `file://`. + +## Usage + +``` +cook build [OPTIONS] [OUTPUT_DIR] +``` + +## Arguments + +| Argument | Description | +|----------|-------------| +| `[OUTPUT_DIR]` | Directory to write the generated site into (default: `./_site`). Created if missing; existing files are overwritten as needed. | + +## Options + +| Option | Description | +|--------|-------------| +| `--base-path ` | Root directory containing recipe files (default: current directory) | +| `--base-url ` | Absolute URL prefix for hosting under a subpath (e.g. `/recipes/`). When unset, links are page-relative and the site works under any prefix, including `file://`. | + +## Examples + +```bash +# Build into ./_site from the current directory +cook build + +# Build a specific recipe collection into a custom output directory +cook build dist --base-path ~/my-recipes + +# Build for hosting under /recipes/ on your domain +cook build --base-url /recipes/ +``` + +## What gets generated + +| Output | Contents | +|--------|----------| +| `index.html` | Root recipe listing | +| `directory/.html` | One listing page per subdirectory | +| `recipe/.html` | One page per `.cook` recipe (URL uses the file stem, not the title metadata) | +| `recipe/.cook` | Raw `.cook` source for each recipe — exposed as a download link on the recipe page | +| `menu/.html` | One page per `.menu` file | +| `api/static/` | Images alongside recipes (`.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.avif`) | +| `static/css/`, `static/js/` | Compiled CSS, fonts, icons, and the client-side search script | +| `static/search-index.json` | Search index consumed by `static/js/search.js` | + +## What's excluded + +The static site is read-only. The following dynamic features from `cook server` are intentionally omitted: + +- Shopping list and pantry pages +- Preferences and sync +- Recipe editor and "New recipe" button +- Recipe scaling controls (output is always 1×) +- "Add to shopping list" buttons +- Server-side search API (`/api/search`) — replaced by a client-side index + +The keyboard-shortcuts modal also hides entries for the removed features so the help is accurate for what's actually available. + +## Hosting + +Because internal links default to page-relative paths, no configuration is needed for most hosts: + +```bash +# GitHub Pages: push _site/ to gh-pages +cook build && git -C _site init && git -C _site add . && \ + git -C _site commit -m "site" && \ + git -C _site push -f git@github.com:user/repo gh-pages + +# Netlify drop: drag and drop _site/ into the Netlify UI + +# Static S3 bucket +aws s3 sync _site/ s3://my-recipes-bucket --delete + +# Just open it locally +open _site/index.html +``` + +Use `--base-url` only if your host serves the site under a fixed subpath and you cannot rely on relative URLs. + +## Notes + +- The generated site has no server dependency — it works fully offline via `file://`. +- Search runs entirely in the browser by loading `static/search-index.json`. +- Re-run `cook build` after editing recipes; the command is idempotent. +- For a live editing experience, use `cook server` instead. diff --git a/src/args.rs b/src/args.rs index 91ca10da..bdf3f21d 100644 --- a/src/args.rs +++ b/src/args.rs @@ -32,7 +32,9 @@ use clap::{Parser, Subcommand}; #[cfg(feature = "self-update")] use crate::update; -use crate::{doctor, import, lsp, pantry, recipe, report, search, seed, server, shopping_list}; +use crate::{ + build, doctor, import, lsp, pantry, recipe, report, search, seed, server, shopping_list, +}; #[derive(Parser, Debug)] #[command( @@ -85,6 +87,20 @@ pub enum Command { )] Server(server::ServerArgs), + /// Generate a self-contained static website from your recipe collection + /// + /// Renders your recipes as static HTML files browsable on any static-file + /// host or directly from disk via file://. Excludes dynamic features + /// (shopping list, pantry, editing). + /// + /// Examples: + /// cook build # Build to ./_site + /// cook build out # Build to ./out + /// cook build --base-path ~/recipes # Use specific source directory + /// cook build --base-url /recipes/ # Absolute URL prefix for subpath hosting + #[command(long_about = "Generate a static HTML website from your recipe collection")] + Build(build::BuildArgs), + /// Generate a combined shopping list from multiple recipes /// /// Creates a shopping list by aggregating ingredients from one or more recipes. diff --git a/src/build/index.rs b/src/build/index.rs new file mode 100644 index 00000000..e7ec4819 --- /dev/null +++ b/src/build/index.rs @@ -0,0 +1,72 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct SearchEntry { + pub title: String, + pub path: String, + pub tags: Vec, + pub ingredients: Vec, +} + +/// Build a flat list of search entries by walking the recipe tree. +pub fn build_search_index(tree: &cooklang_find::RecipeTree) -> Vec { + let mut out = Vec::new(); + collect(tree, String::new(), &mut out); + out +} + +fn collect(tree: &cooklang_find::RecipeTree, prefix: String, out: &mut Vec) { + for (name, child) in &tree.children { + if child.children.is_empty() { + let Some(recipe) = child.recipe.as_ref() else { + continue; + }; + // URL path uses the on-disk file stem, not the tree key + // (the key may be the title from metadata). + let stem = recipe + .file_name() + .as_deref() + .map(|f| { + f.trim_end_matches(".cook") + .trim_end_matches(".menu") + .to_string() + }) + .unwrap_or_else(|| name.clone()); + let sub = if prefix.is_empty() { + stem + } else { + format!("{prefix}/{stem}") + }; + let url_path = if recipe.is_menu() { + format!("menu/{sub}.html") + } else { + format!("recipe/{sub}.html") + }; + let tags = recipe.tags(); + let ingredients = match crate::util::parse_recipe_from_entry(recipe, 1.0) { + Ok(parsed) => parsed + .group_ingredients(crate::util::PARSER.converter()) + .into_iter() + .map(|e| e.ingredient.display_name().to_string()) + .collect(), + Err(e) => { + tracing::warn!("Skipping ingredients for search index entry {url_path}: {e:#}"); + Vec::new() + } + }; + out.push(SearchEntry { + title: name.clone(), + path: url_path, + tags, + ingredients, + }); + } else { + let sub = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}/{name}") + }; + collect(child, sub, out); + } + } +} diff --git a/src/build/links.rs b/src/build/links.rs new file mode 100644 index 00000000..a29bb222 --- /dev/null +++ b/src/build/links.rs @@ -0,0 +1,53 @@ +use camino::Utf8Path; + +/// Given an output file path like "index.html" or "recipe/Breakfast/Pancakes.html", +/// return the relative prefix that resolves from that file back to the output root. +/// Examples: +/// "index.html" -> "." +/// "directory/Breakfast.html" -> ".." +/// "recipe/Breakfast/Pancakes.html" -> "../.." +/// "menu/Sunday/Brunch.html" -> "../.." +pub fn relative_prefix(output_relpath: &Utf8Path) -> String { + let depth = output_relpath.components().count().saturating_sub(1); // last component is the filename + if depth == 0 { + ".".to_string() + } else { + std::iter::repeat_n("..", depth) + .collect::>() + .join("/") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn root_index() { + assert_eq!(relative_prefix(Utf8Path::new("index.html")), "."); + } + + #[test] + fn one_level_deep() { + assert_eq!( + relative_prefix(Utf8Path::new("directory/Breakfast.html")), + ".." + ); + } + + #[test] + fn two_levels_deep() { + assert_eq!( + relative_prefix(Utf8Path::new("recipe/Breakfast/Pancakes.html")), + "../.." + ); + } + + #[test] + fn three_levels_deep() { + assert_eq!( + relative_prefix(Utf8Path::new("recipe/A/B/C.html")), + "../../.." + ); + } +} diff --git a/src/build/mod.rs b/src/build/mod.rs new file mode 100644 index 00000000..e593e8db --- /dev/null +++ b/src/build/mod.rs @@ -0,0 +1,234 @@ +mod index; +mod links; +mod renderer; +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; +use unic_langid::LanguageIdentifier; + +#[derive(Debug, Args)] +pub struct BuildArgs { + /// Output directory for the generated static site + /// + /// Defaults to ./_site if not specified. The directory is created if + /// missing. Existing files in the directory are overwritten as needed + /// but not wiped wholesale. + #[arg(value_hint = clap::ValueHint::DirPath)] + pub output_dir: Option, + + /// Root directory containing your recipe files + #[arg(long, value_hint = clap::ValueHint::DirPath)] + pub base_path: Option, + + /// Absolute URL prefix for hosting under a subpath (e.g. /recipes/) + /// + /// When set, internal links use this absolute prefix instead of + /// page-relative paths. Useful when you know the deployed subpath. + #[arg(long)] + pub base_url: Option, + + /// UI language for the generated site (default: en-US) + /// + /// Accepts a BCP-47 tag like `de-DE`, or a bare language code like `de` + /// that matches a supported region. Supported: en-US, de-DE, nl-NL, + /// fr-FR, es-ES, eu-ES, sv-SE. + #[arg(long, value_parser = parse_lang_arg)] + pub lang: Option, +} + +fn parse_lang_arg(s: &str) -> Result { + 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 { + self.base_path.clone() + } +} + +pub fn run(ctx: &Context, args: BuildArgs) -> 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")); + + // Create the output directory before canonicalizing (canonicalize requires existence). + 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(); + + 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}"))?; + // If the user pointed the output directory inside the source directory + // (the common case: `cook build` with default `_site` next to recipes), + // strip the output subtree so we don't re-process the previous run's + // generated files. Without this, every run would nest `_site/recipe/...` + // and `_site/api/static/...` one level deeper until the OS rejects the + // path length. + 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(), + )?; + + println!( + "Wrote index, directories, {recipe_count} recipe pages, {image_count} images, {asset_count} static assets, {entry_count} search entries" + ); + 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 { + let mut count = 0; + // `walkdir` doesn't follow symlinks by default, which prevents infinite + // loops on symlink cycles. We also filter out dotted directories so that + // hidden caches (.git, .cache, etc.) are skipped. Skip the output + // subtree too — when the user builds into a directory inside the source + // (default: `_site` next to recipes), we'd otherwise re-copy the + // previous run's images into a deeper path each time. + 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; // it's a recipe file, handled by walk_recipes + } + 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 { + let mut count = 0; + for (name, child) in &tree.children { + if child.children.is_empty() { + // Recipe file — use the on-disk file name, not the tree key (which may be the title). + 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) +} diff --git a/src/build/renderer.rs b/src/build/renderer.rs new file mode 100644 index 00000000..4569fd73 --- /dev/null +++ b/src/build/renderer.rs @@ -0,0 +1,104 @@ +use crate::build::links::relative_prefix; +use crate::build::writer::write_html; +use crate::server::builders::{ + build_recipe_template, build_recipes_template, RecipeBuildInput, RecipeBuildOutput, + RecipesBuildInput, +}; +use anyhow::Result; +use askama::Template; +use camino::{Utf8Path, Utf8PathBuf}; +use unic_langid::LanguageIdentifier; + +/// Render the root index page (recipes listing). +pub fn render_index( + source: &Utf8Path, + output: &Utf8Path, + base_url: Option<&str>, + lang: &LanguageIdentifier, +) -> Result<()> { + let relpath = Utf8PathBuf::from("index.html"); + let prefix = compute_prefix(base_url, &relpath); + let template = build_recipes_template(RecipesBuildInput { + base_path: source, + url_prefix: &prefix, + sub_path: None, + lang: lang.clone(), + static_mode: true, + })?; + let html = template.render()?; + write_html(output, &relpath, &html) +} + +/// Render one directory listing page. +pub fn render_directory( + source: &Utf8Path, + output: &Utf8Path, + sub_path: &str, + base_url: Option<&str>, + lang: &LanguageIdentifier, +) -> Result<()> { + let relpath = Utf8PathBuf::from(format!("directory/{sub_path}.html")); + let prefix = compute_prefix(base_url, &relpath); + let template = build_recipes_template(RecipesBuildInput { + base_path: source, + url_prefix: &prefix, + sub_path: Some(sub_path), + lang: lang.clone(), + static_mode: true, + })?; + let html = template.render()?; + write_html(output, &relpath, &html) +} + +fn compute_prefix(base_url: Option<&str>, relpath: &Utf8Path) -> String { + match base_url { + Some(b) => b.trim_end_matches('/').to_string(), + None => relative_prefix(relpath), + } +} + +/// Render a single recipe (or menu) page. +/// +/// Both `recipe/.html` and `menu/.html` sit at the same depth in +/// the output tree, so the page-relative `prefix` is identical regardless of +/// which one we end up writing. We render once and pick the destination path +/// based on whether the entry turned out to be a menu. +pub fn render_recipe( + source: &Utf8Path, + output: &Utf8Path, + recipe_relpath: &str, + aisle_path: Option<&Utf8PathBuf>, + base_url: Option<&str>, + lang: &LanguageIdentifier, +) -> Result<()> { + let trimmed = recipe_relpath + .trim_end_matches(".cook") + .trim_end_matches(".menu"); + let provisional = Utf8PathBuf::from(format!("recipe/{trimmed}.html")); + let prefix = compute_prefix(base_url, &provisional); + + let kind = build_recipe_template(RecipeBuildInput { + base_path: source, + url_prefix: &prefix, + // Pass the extension-less path so template URLs (e.g. the .cook + // download link) match the server convention without doubling the + // extension. + recipe_path: trimmed, + aisle_path, + scale: 1.0, + lang: lang.clone(), + static_mode: true, + })?; + + match kind { + RecipeBuildOutput::Recipe(t) => { + let html = t.render()?; + write_html(output, &provisional, &html) + } + RecipeBuildOutput::Menu(t) => { + let menu_relpath = Utf8PathBuf::from(format!("menu/{trimmed}.html")); + let html = t.render()?; + write_html(output, &menu_relpath, &html) + } + } +} diff --git a/src/build/writer.rs b/src/build/writer.rs new file mode 100644 index 00000000..e0cb5877 --- /dev/null +++ b/src/build/writer.rs @@ -0,0 +1,98 @@ +use anyhow::{Context, Result}; +use camino::Utf8Path; +use std::fs; + +/// Write `contents` to `output_root/relpath`, creating parent directories. +pub fn write_html(output_root: &Utf8Path, relpath: &Utf8Path, contents: &str) -> Result<()> { + let dest = output_root.join(relpath); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create parent dir: {parent}"))?; + } + fs::write(&dest, contents).with_context(|| format!("Failed to write: {dest}"))?; + Ok(()) +} + +/// Copy `bytes` to `output_root/relpath`, creating parent directories. +pub fn write_bytes(output_root: &Utf8Path, relpath: &Utf8Path, bytes: &[u8]) -> Result<()> { + let dest = output_root.join(relpath); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create parent dir: {parent}"))?; + } + fs::write(&dest, bytes).with_context(|| format!("Failed to write: {dest}"))?; + Ok(()) +} + +/// Copy every file in the rust-embed `StaticFiles` to `output_root/static/`. +pub fn copy_static_assets(output_root: &Utf8Path) -> Result { + let mut count = 0; + for path in crate::server::StaticFiles::iter() { + let rel = Utf8Path::new("static").join(path.as_ref()); + let file = crate::server::StaticFiles::get(path.as_ref()) + .with_context(|| format!("Embedded file vanished: {path}"))?; + write_bytes(output_root, &rel, &file.data)?; + count += 1; + } + Ok(count) +} + +/// Copy a single source file into `output_root/api/static/`. +pub fn copy_image( + output_root: &Utf8Path, + source_root: &Utf8Path, + abs_image: &Utf8Path, +) -> Result<()> { + let rel = abs_image + .strip_prefix(source_root) + .with_context(|| format!("Image {abs_image} not under source {source_root}"))?; + let dest = output_root.join("api/static").join(rel); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create parent dir: {parent}"))?; + } + fs::copy(abs_image, &dest).with_context(|| format!("Failed to copy {abs_image} -> {dest}"))?; + Ok(()) +} + +/// Copy a `.cook` source file into `output_root/recipe/` so visitors +/// can download the canonical recipe source alongside the rendered page. +pub fn copy_recipe_source( + output_root: &Utf8Path, + source_root: &Utf8Path, + recipe_relpath: &str, +) -> Result<()> { + let src = source_root.join(recipe_relpath); + let dest = output_root.join("recipe").join(recipe_relpath); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create parent dir: {parent}"))?; + } + fs::copy(&src, &dest).with_context(|| format!("Failed to copy {src} -> {dest}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn write_html_creates_nested_dirs() { + let tmp = TempDir::new().unwrap(); + let root = camino::Utf8Path::from_path(tmp.path()).unwrap(); + let rel = Utf8Path::new("a/b/c.html"); + write_html(root, rel, "").unwrap(); + let contents = std::fs::read_to_string(root.join(rel)).unwrap(); + assert_eq!(contents, ""); + } + + #[test] + fn copy_static_assets_writes_known_file() { + let tmp = TempDir::new().unwrap(); + let root = camino::Utf8Path::from_path(tmp.path()).unwrap(); + let count = copy_static_assets(root).unwrap(); + assert!(count > 0, "should copy at least one static asset"); + assert!(root.join("static/css/output.css").is_file()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 5485a64f..c13d4237 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ use anyhow::{Context as _, Result}; use camino::{Utf8Path, Utf8PathBuf}; // Commands - make them available as public modules +pub mod build; pub mod doctor; pub mod import; pub mod lsp; diff --git a/src/main.rs b/src/main.rs index 0981902c..b10b7241 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,6 +35,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use clap::Parser; // commands +mod build; mod doctor; mod import; mod lsp; @@ -67,6 +68,7 @@ pub fn main() -> Result<()> { match args.command { Command::Recipe(args) => recipe::run(&ctx, args), Command::Server(args) => server::run(ctx, args), + Command::Build(args) => build::run(&ctx, args), Command::ShoppingList(args) => shopping_list::run(&ctx, args), Command::Seed(args) => seed::run(&ctx, args), Command::Search(args) => search::run(&ctx, args), @@ -127,6 +129,9 @@ fn configure_context() -> Result { Command::ShoppingList(ref shopping_list_args) => shopping_list_args .get_base_path() .unwrap_or_else(|| Utf8PathBuf::from(".")), + Command::Build(ref build_args) => build_args + .get_base_path() + .unwrap_or_else(|| Utf8PathBuf::from(".")), _ => Utf8PathBuf::from("."), }; diff --git a/src/server/builders.rs b/src/server/builders.rs new file mode 100644 index 00000000..e9e06612 --- /dev/null +++ b/src/server/builders.rs @@ -0,0 +1,1005 @@ +//! Template builders shared between the dynamic web server and the static-site +//! renderer. Each function takes plain inputs and produces an Askama template +//! struct ready to render (or be turned into an `axum::Response` by a handler). +//! +//! The builders intentionally avoid any axum / tokio-async types so they can be +//! reused from a non-async context (e.g. `cook build`). + +use crate::server::templates::*; +use anyhow::Result; +use camino::{Utf8Path, Utf8PathBuf}; +use fluent_templates::Loader; +use unic_langid::LanguageIdentifier; + +/// Inputs for [`build_recipes_template`]. +pub struct RecipesBuildInput<'a> { + pub base_path: &'a Utf8Path, + pub url_prefix: &'a str, + pub sub_path: Option<&'a str>, + pub lang: LanguageIdentifier, + pub static_mode: bool, +} + +/// Build a [`RecipesTemplate`] for either the root or a subdirectory. +pub fn build_recipes_template(input: RecipesBuildInput<'_>) -> Result { + let RecipesBuildInput { + base_path, + url_prefix, + sub_path, + lang, + static_mode, + } = input; + + let search_path = if let Some(p) = sub_path { + base_path.join(p) + } else { + base_path.to_path_buf() + }; + + let tree = cooklang_find::build_tree(&search_path) + .map_err(|e| anyhow::anyhow!("Failed to build recipe tree: {e}"))?; + + let mut items = Vec::new(); + + for (name, child) in &tree.children { + let is_dir = !child.children.is_empty(); + let item_path = { + let url_path = child + .recipe + .as_ref() + .and_then(|recipe| recipe.file_name()) + .map(|f| { + f.trim_end_matches(".cook") + .trim_end_matches(".menu") + .to_string() + }) + .unwrap_or_else(|| name.to_string()); + + match sub_path { + Some(p) => format!("{p}/{url_path}"), + None => url_path.to_string(), + } + }; + + // Extract tags, image, and is_menu if this is a recipe + let (tags, image_path, is_menu) = if let Some(ref recipe) = child.recipe { + let img_path = recipe.title_image().clone().and_then(|img| { + if img.starts_with("http://") || img.starts_with("https://") { + Some(img) + } else { + // Make path relative to base and accessible via /api/static + let img_path = camino::Utf8Path::new(&img); + if let Ok(relative) = img_path.strip_prefix(base_path) { + Some(format!("{url_prefix}/api/static/{relative}")) + } else if !img_path.is_absolute() { + Some(format!("{url_prefix}/api/static/{img_path}")) + } else { + img_path + .file_name() + .map(|name| format!("{url_prefix}/api/static/{name}")) + } + } + }); + (recipe.tags(), img_path, recipe.is_menu()) + } else { + (Vec::new(), None, false) + }; + + items.push(RecipeItem { + name: name.to_string(), + path: item_path, + is_directory: is_dir, + count: if is_dir { + count_recipes_tree(child) + } else { + None + }, + description: None, + tags, + image_path, + is_menu, + }); + } + + items.sort_by(|a, b| match (a.is_directory, b.is_directory) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.cmp(&b.name), + }); + + let todays_menu = if sub_path.is_none() { + crate::server::handlers::find_todays_menu(base_path, &tree) + } else { + None + }; + + let breadcrumbs = if let Some(p) = sub_path { + p.split('/') + .scan(String::new(), |acc, segment| { + if !acc.is_empty() { + acc.push('/'); + } + acc.push_str(segment); + Some(Breadcrumb { + name: segment.to_string(), + path: acc.clone(), + }) + }) + .collect() + } else { + vec![] + }; + + let current_name = if let Some(p) = sub_path { + p.split('/').next_back().unwrap_or("Recipes").to_string() + } else { + crate::server::i18n::LOCALES.lookup(&lang, "recipes-title") + }; + + let new_recipe_url = match sub_path { + Some(p) => format!("{url_prefix}/new?filename={}%2F", urlencoding::encode(p)), + None => format!("{url_prefix}/new"), + }; + + Ok(RecipesTemplate { + active: "recipes".to_string(), + current_name, + breadcrumbs, + items, + todays_menu, + new_recipe_url, + tr: Tr::new(lang), + prefix: url_prefix.to_string(), + static_mode, + }) +} + +/// Inputs for [`build_recipe_template`]. +pub struct RecipeBuildInput<'a> { + pub base_path: &'a Utf8Path, + pub url_prefix: &'a str, + pub recipe_path: &'a str, + pub aisle_path: Option<&'a Utf8PathBuf>, + pub scale: f64, + pub lang: LanguageIdentifier, + pub static_mode: bool, +} + +/// Output of [`build_recipe_template`] — either a regular recipe or a menu. +pub enum RecipeBuildOutput { + Recipe(Box), + Menu(Box), +} + +/// Build a [`RecipeTemplate`] or [`MenuTemplate`] for the given recipe path. +pub fn build_recipe_template(input: RecipeBuildInput<'_>) -> Result { + let RecipeBuildInput { + base_path, + url_prefix, + recipe_path, + aisle_path, + scale, + lang, + static_mode, + } = input; + + let recipe_path_buf = Utf8PathBuf::from(recipe_path); + tracing::info!( + "Looking for recipe at path: {}, extension: {:?}", + recipe_path, + recipe_path_buf.extension() + ); + + let entry = cooklang_find::get_recipe(vec![base_path], recipe_path_buf.as_path()) + .map_err(|e| anyhow::anyhow!("Recipe not found: {recipe_path}: {e}"))?; + + let actual_path = entry.path(); + tracing::info!( + "Recipe path: {}, actual_path: {:?}, is_menu: {}", + recipe_path, + actual_path, + entry.is_menu() + ); + + if entry.is_menu() { + let template = build_menu_template_inner( + recipe_path.to_string(), + scale, + entry, + base_path, + url_prefix, + lang, + static_mode, + )?; + return Ok(RecipeBuildOutput::Menu(Box::new(template))); + } + + let recipe = crate::util::parse_recipe_from_entry(&entry, scale) + .map_err(|e| anyhow::anyhow!("Failed to parse recipe: {e}"))?; + + // Load aisle config for cooking mode ingredient sorting + let aisle_content = if let Some(path) = aisle_path { + match std::fs::read_to_string(path) { + Ok(content) => content, + Err(e) => { + tracing::warn!("Failed to read aisle file from {:?}: {}", path, e); + String::new() + } + } + } else { + String::new() + }; + let aisle = cooklang::aisle::parse_lenient(&aisle_content) + .into_output() + .unwrap_or_default(); + + let tags = entry.tags(); + + // Get the image path if available + let image_path = entry + .title_image() + .clone() + .and_then(|img_path| get_image_path(base_path, url_prefix, img_path)); + + let mut ingredients = Vec::new(); + let mut cookware = Vec::new(); + let mut sections = Vec::new(); + + // Group ingredients by display name and merge quantities + let mut grouped_ingredients: std::collections::HashMap< + String, + ( + cooklang::quantity::GroupedQuantity, + Vec<&cooklang::model::Ingredient>, + ), + > = std::collections::HashMap::new(); + + for entry in recipe.group_ingredients(crate::util::PARSER.converter()) { + let ingredient = entry.ingredient; + let display_name = ingredient.display_name().to_string(); + + grouped_ingredients + .entry(display_name) + .and_modify(|(merged_qty, igrs)| { + merged_qty.merge(&entry.quantity, crate::util::PARSER.converter()); + igrs.push(ingredient); + }) + .or_insert_with(|| (entry.quantity.clone(), vec![ingredient])); + } + + // Sort by name for consistent display + let mut sorted_ingredients: Vec<_> = grouped_ingredients.into_iter().collect(); + sorted_ingredients.sort_by(|a, b| a.0.cmp(&b.0)); + + for (display_name, (quantity, ingredient_list)) in sorted_ingredients { + // Use the first ingredient's data for reference path and notes + let first_ingredient = ingredient_list[0]; + let reference_path = first_ingredient.reference.as_ref().map(|r| { + // For web URLs - always use forward slash + if r.components.is_empty() { + r.name.clone() + } else { + format!("{}/{}", r.components.join("/"), r.name) + } + }); + + // Combine notes from all ingredients + let combined_note = if ingredient_list.len() > 1 { + let notes: Vec<_> = ingredient_list + .iter() + .filter_map(|i| i.note.as_ref()) + .collect(); + if notes.is_empty() { + None + } else { + Some( + notes + .iter() + .map(|n| n.as_str()) + .collect::>() + .join(", "), + ) + } + } else { + first_ingredient.note.clone() + }; + + // Format the merged quantity - show all quantities comma-separated + let (formatted_quantity, formatted_unit) = if quantity.is_empty() { + (None, None) + } else { + let quantities: Vec<_> = quantity + .iter() + .map(|q| { + let qty_str = + crate::util::format::format_quantity(q.value()).unwrap_or_default(); + let unit_str = q.unit().as_ref().map(|u| u.to_string()).unwrap_or_default(); + if unit_str.is_empty() { + qty_str + } else { + format!("{} {}", qty_str, unit_str) + } + }) + .collect(); + (Some(quantities.join(", ")), None) + }; + + ingredients.push(IngredientData { + name: display_name, + quantity: formatted_quantity, + unit: formatted_unit, + note: combined_note, + reference_path, + }); + } + + for item in &recipe.group_cookware(crate::util::PARSER.converter()) { + cookware.push(CookwareData { + name: item.cookware.name.to_string(), + }); + } + + let mut total_steps = 0; + for section in &recipe.sections { + let mut section_items = Vec::new(); + let mut section_ingredient_indices = std::collections::HashSet::new(); + let mut cooking_mode_ingredient_indices: Vec = Vec::new(); + let mut step_count = 0; + + for content in §ion.content { + use cooklang::Content; + match content { + Content::Step(step) => { + let mut step_items = Vec::new(); + let mut step_ingredients = Vec::new(); + + for item in &step.items { + use crate::server::templates::{StepIngredient, StepItem}; + use cooklang::Item; + + match item { + Item::Text { value } => { + let parts: Vec<&str> = value.split('\n').collect(); + for (i, part) in parts.iter().enumerate() { + if i > 0 { + step_items.push(StepItem::LineBreak); + } + if !part.is_empty() { + step_items.push(StepItem::Text(part.to_string())); + } + } + } + Item::Ingredient { index } => { + section_ingredient_indices.insert(*index); + cooking_mode_ingredient_indices.push(*index); + if let Some(ing) = recipe.ingredients.get(*index) { + let reference_path = ing.reference.as_ref().map(|r| { + // For web URLs - always use forward slash + if r.components.is_empty() { + r.name.clone() + } else { + format!("{}/{}", r.components.join("/"), r.name) + } + }); + + step_items.push(StepItem::Ingredient { + name: ing.name.to_string(), + reference_path, + }); + + // Also add to step ingredients list + step_ingredients.push(StepIngredient { + name: ing.name.to_string(), + quantity: ing.quantity.as_ref().and_then(|q| { + crate::util::format::format_quantity(q.value()) + }), + unit: ing + .quantity + .as_ref() + .and_then(|q| q.unit().as_ref().map(|u| u.to_string())), + note: ing.note.clone(), + }); + } + } + Item::Cookware { index } => { + if let Some(cw) = recipe.cookware.get(*index) { + step_items.push(StepItem::Cookware(cw.name.to_string())); + } + } + Item::Timer { index } => { + if let Some(timer) = recipe.timers.get(*index) { + let mut timer_text = String::new(); + + // Add timer quantity and unit + if let Some(quantity) = &timer.quantity { + if let Some(formatted) = + crate::util::format::format_quantity(quantity.value()) + { + timer_text.push_str(&formatted); + } + if let Some(unit) = quantity.unit() { + if !timer_text.is_empty() { + timer_text.push(' '); + } + timer_text.push_str(unit); + } + } + + // If no duration info, just show "timer" + if timer_text.is_empty() { + timer_text = "timer".to_string(); + } + + step_items.push(StepItem::Timer(timer_text)); + } + } + Item::InlineQuantity { index } => { + if let Some(q) = recipe.inline_quantities.get(*index) { + let mut qty = crate::util::format::format_quantity(q.value()) + .unwrap_or_default(); + if let Some(unit) = q.unit() { + if !qty.is_empty() { + qty.push_str(&format!(" {unit}")); + } else { + qty = unit.to_string(); + } + } + step_items.push(StepItem::Quantity(qty)); + } + } + } + } + + let section_image_path = entry + .step_images() + .get(0, total_steps + step_count + 1) + .and_then(|img_path| { + get_image_path(base_path, url_prefix, img_path.to_string()) + }); + + section_items.push(RecipeSectionItem::Step(StepData { + number: step_count + 1, + items: step_items, + ingredients: step_ingredients, + image_path: section_image_path, + })); + step_count += 1; + } + Content::Text(text) => { + // Skip list bullet items + if text.trim() != "-" { + section_items.push(RecipeSectionItem::Note(text.trim().to_string())); + } + } + } + } + + // Only add sections that have items (steps or notes) + if !section_items.is_empty() { + use crate::server::templates::RecipeSection; + + // Collect and group ingredients used in this section + let mut section_grouped_ingredients: std::collections::HashMap< + String, + ( + cooklang::quantity::GroupedQuantity, + Vec<&cooklang::model::Ingredient>, + ), + > = std::collections::HashMap::new(); + + for idx in section_ingredient_indices { + if let Some(ingredient) = recipe.ingredients.get(idx) { + let display_name = ingredient.display_name().to_string(); + let qty = if let Some(q) = &ingredient.quantity { + let mut grouped_qty = cooklang::quantity::GroupedQuantity::empty(); + grouped_qty.add(q, crate::util::PARSER.converter()); + grouped_qty + } else { + cooklang::quantity::GroupedQuantity::empty() + }; + + section_grouped_ingredients + .entry(display_name) + .and_modify(|(merged_qty, igrs)| { + if let Some(q) = &ingredient.quantity { + merged_qty.add(q, crate::util::PARSER.converter()); + } + igrs.push(ingredient); + }) + .or_insert_with(|| (qty, vec![ingredient])); + } + } + + // Sort section ingredients by name + let mut sorted_section_ingredients: Vec<_> = + section_grouped_ingredients.into_iter().collect(); + sorted_section_ingredients.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut section_ingredients = Vec::new(); + for (display_name, (quantity, ingredient_list)) in sorted_section_ingredients { + let first_ingredient = ingredient_list[0]; + let reference_path = first_ingredient.reference.as_ref().map(|r| { + // For web URLs - always use forward slash + if r.components.is_empty() { + r.name.clone() + } else { + format!("{}/{}", r.components.join("/"), r.name) + } + }); + + // Combine notes from all ingredients in the section + let combined_note = if ingredient_list.len() > 1 { + let notes: Vec<_> = ingredient_list + .iter() + .filter_map(|i| i.note.as_ref()) + .collect(); + if notes.is_empty() { + None + } else { + Some( + notes + .iter() + .map(|n| n.as_str()) + .collect::>() + .join(", "), + ) + } + } else { + first_ingredient.note.clone() + }; + + // Format the merged quantity + let (formatted_quantity, formatted_unit) = if quantity.is_empty() { + (None, None) + } else { + let quantities: Vec<_> = quantity + .iter() + .map(|q| { + let qty_str = + crate::util::format::format_quantity(q.value()).unwrap_or_default(); + let unit_str = + q.unit().as_ref().map(|u| u.to_string()).unwrap_or_default(); + if unit_str.is_empty() { + qty_str + } else { + format!("{} {}", qty_str, unit_str) + } + }) + .collect(); + (Some(quantities.join(", ")), None) + }; + + section_ingredients.push(IngredientData { + name: display_name, + quantity: formatted_quantity, + unit: formatted_unit, + note: combined_note, + reference_path, + }); + } + + // Build uncombined ingredients for cooking mode, sorted by aisle order + let mut cooking_mode_ingredients_with_key: Vec<( + Option<(usize, usize)>, + IngredientData, + )> = Vec::new(); + for idx in &cooking_mode_ingredient_indices { + if let Some(ingredient) = recipe.ingredients.get(*idx) { + if !ingredient.modifiers().should_be_listed() { + continue; + } + let sort_key = aisle.ingredient_sort_key(&ingredient.name); + + let (formatted_quantity, formatted_unit) = if let Some(q) = &ingredient.quantity + { + let qty_str = crate::util::format::format_quantity(q.value()); + let unit_str = q.unit().as_ref().map(|u| u.to_string()); + (qty_str, unit_str) + } else { + (None, None) + }; + + cooking_mode_ingredients_with_key.push(( + sort_key, + IngredientData { + name: ingredient.name.to_string(), + quantity: formatted_quantity, + unit: formatted_unit, + note: ingredient.note.clone(), + reference_path: None, + }, + )); + } + } + + // Sort: aisle items first (by category_index, ingredient_index), then uncategorized at end + cooking_mode_ingredients_with_key.sort_by(|a, b| match (&a.0, &b.0) { + (Some(ka), Some(kb)) => ka.cmp(kb), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.1.name.cmp(&b.1.name), + }); + + let cooking_mode_ingredients: Vec = cooking_mode_ingredients_with_key + .into_iter() + .map(|(_, data)| data) + .collect(); + + sections.push(RecipeSection { + name: section.name.clone(), + items: section_items.clone(), + step_offset: total_steps, + ingredients: section_ingredients, + cooking_mode_ingredients, + }); + total_steps += step_count; + } + } + + let breadcrumbs: Vec = recipe_path + .split('/') + .map(|s| s.trim_end_matches(".cook").to_string()) + .collect(); + + let metadata = if recipe.metadata.map.is_empty() { + None + } else { + // Get standard metadata fields (handle both string and number types) + let get_field = |key: &str| -> Option { + recipe.metadata.get(key).and_then(|v| { + if let Some(s) = v.as_str() { + Some(s.to_string()) + } else if let Some(n) = v.as_i64() { + Some(n.to_string()) + } else { + v.as_f64().map(crate::util::format::format_number) + } + }) + }; + + let mut custom_metadata = Vec::new(); + for (key, value) in recipe.metadata.map_filtered() { + if let (Some(key_str), Some(val_str)) = (key.as_str(), value.as_str()) { + if key_str.starts_with("source.") || key_str.starts_with("time.") { + continue; + } + + custom_metadata.push((key_str.to_string(), val_str.to_string())); + } + } + + Some(RecipeMetadata { + servings: get_field("servings"), + time: get_field("time"), + difficulty: get_field("difficulty"), + course: get_field("course"), + prep_time: get_field("prep time") + .or_else(|| get_field("prep_time")) + .or_else(|| get_field("preptime")) + .or_else(|| get_field("time.prep")), + cook_time: get_field("cook time") + .or_else(|| get_field("cook_time")) + .or_else(|| get_field("cooktime")) + .or_else(|| get_field("time.cook")), + cuisine: get_field("cuisine"), + diet: get_field("diet"), + author: get_field("author").or_else(|| get_field("source.author")), + description: get_field("description"), + source: get_field("source").or_else(|| get_field("source.name")), + source_url: get_field("source.url"), + custom: custom_metadata, + }) + }; + + // Use title from metadata if available, otherwise use filename + let recipe_name = recipe + .metadata + .get("title") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + recipe_path + .split('/') + .next_back() + .unwrap_or(recipe_path) + .replace(".cook", "") + }); + + let template = RecipeTemplate { + active: "recipes".to_string(), + recipe: RecipeData { + name: recipe_name, + metadata, + }, + recipe_path: recipe_path.to_string(), + breadcrumbs, + scale, + tags, + ingredients, + cookware, + sections, + image_path, + tr: Tr::new(lang), + prefix: url_prefix.to_string(), + static_mode, + }; + + Ok(RecipeBuildOutput::Recipe(Box::new(template))) +} + +fn build_menu_template_inner( + path: String, + scale: f64, + entry: cooklang_find::RecipeEntry, + base_path: &Utf8Path, + url_prefix: &str, + lang: LanguageIdentifier, + static_mode: bool, +) -> Result { + let recipe = crate::util::parse_recipe_from_entry(&entry, scale) + .map_err(|e| anyhow::anyhow!("Failed to parse menu: {e}"))?; + + // Get the image path if available + let image_path = entry.title_image().clone().and_then(|img_path| { + if img_path.starts_with("http://") || img_path.starts_with("https://") { + Some(img_path) + } else { + let img_path = camino::Utf8Path::new(&img_path); + if let Ok(relative) = img_path.strip_prefix(base_path) { + Some(format!("{url_prefix}/api/static/{relative}")) + } else if !img_path.is_absolute() { + Some(format!("{url_prefix}/api/static/{img_path}")) + } else { + img_path + .file_name() + .map(|name| format!("{url_prefix}/api/static/{name}")) + } + } + }); + + let breadcrumbs: Vec = path.split('/').map(|s| s.to_string()).collect(); + + // Parse sections and content + let mut sections = Vec::new(); + + for section in &recipe.sections { + let section_name = section.name.clone(); + let mut lines = Vec::new(); + + for content in §ion.content { + use cooklang::Content; + if let Content::Step(step) = content { + // Build the full step content first + let mut step_items = Vec::new(); + let mut current_text = String::new(); + + for item in &step.items { + use crate::server::templates::MenuSectionItem; + use cooklang::Item; + + match item { + Item::Text { value } => { + // Check if this is an isolated dash (bullet marker) + if value == "-" { + // Bullet marker - complete current line and start new one + if !current_text.is_empty() { + step_items.push(MenuSectionItem::Text(current_text.clone())); + current_text.clear(); + } + if !step_items.is_empty() { + lines.push(step_items.clone()); + step_items.clear(); + } + } else { + // Split on newlines to preserve line breaks + let parts: Vec<&str> = value.split('\n').collect(); + for (i, part) in parts.iter().enumerate() { + if i > 0 { + // Newline encountered - flush current text and line + if !current_text.is_empty() { + step_items + .push(MenuSectionItem::Text(current_text.clone())); + current_text.clear(); + } + if !step_items.is_empty() { + lines.push(step_items.clone()); + step_items.clear(); + } + } + if !part.is_empty() { + current_text.push_str(part); + } + } + } + } + Item::Ingredient { index } => { + // First, add any pending text + if !current_text.is_empty() { + step_items.push(MenuSectionItem::Text(current_text.clone())); + current_text.clear(); + } + + if let Some(ing) = recipe.ingredients.get(*index) { + // Check if this is a recipe reference using the reference field + if let Some(ref recipe_ref) = ing.reference { + // This is a recipe reference + let recipe_scale = + ing.quantity.as_ref().and_then(|q| match q.value() { + cooklang::Value::Number(n) => Some(n.value()), + _ => None, + }); + + // Apply menu scaling to the recipe reference + let final_scale = recipe_scale.map(|s| s * scale); + + // Build the full path from components + // For web URLs - always use forward slash + let name = if recipe_ref.components.is_empty() { + recipe_ref.name.clone() + } else { + format!( + "{}/{}", + recipe_ref.components.join("/"), + recipe_ref.name + ) + }; + + step_items.push(MenuSectionItem::RecipeReference { + name, + scale: final_scale, + }); + } else { + // Regular ingredient + let quantity = ing.quantity.as_ref().and_then(|q| { + crate::util::format::format_quantity(q.value()) + }); + let unit = ing + .quantity + .as_ref() + .and_then(|q| q.unit().as_ref().map(|u| u.to_string())); + + step_items.push(MenuSectionItem::Ingredient { + name: ing.name.to_string(), + quantity, + unit, + }); + } + } + } + _ => {} // Ignore other items in menu files + } + } + + // Add any remaining content as a line + if !current_text.is_empty() { + step_items.push(MenuSectionItem::Text(current_text)); + } + if !step_items.is_empty() { + lines.push(step_items); + } + } + } + + // Filter out empty lines (lines that are only whitespace text) + lines.retain(|line| { + !line + .iter() + .all(|item| matches!(item, MenuSectionItem::Text(t) if t.trim().is_empty())) + }); + + if !lines.is_empty() { + sections.push(MenuSection { + name: section_name, + lines, + }); + } + } + + // Get metadata + let metadata = if recipe.metadata.map.is_empty() { + None + } else { + let get_field = |key: &str| -> Option { + recipe.metadata.get(key).and_then(|v| { + if let Some(s) = v.as_str() { + Some(s.to_string()) + } else if let Some(n) = v.as_i64() { + Some(n.to_string()) + } else { + v.as_f64().map(crate::util::format::format_number) + } + }) + }; + + let mut custom_metadata = Vec::new(); + for (key, value) in recipe.metadata.map_filtered() { + if let (Some(key_str), Some(val_str)) = (key.as_str(), value.as_str()) { + custom_metadata.push((key_str.to_string(), val_str.to_string())); + } + } + + Some(RecipeMetadata { + servings: get_field("servings"), + time: get_field("time"), + difficulty: get_field("difficulty"), + course: get_field("course"), + prep_time: get_field("prep time") + .or_else(|| get_field("prep_time")) + .or_else(|| get_field("preptime")), + cook_time: get_field("cook time") + .or_else(|| get_field("cook_time")) + .or_else(|| get_field("cooktime")), + cuisine: get_field("cuisine"), + diet: get_field("diet"), + author: get_field("author").or_else(|| get_field("source.author")), + description: get_field("description"), + source: get_field("source").or_else(|| get_field("source.name")), + source_url: get_field("source.url"), + custom: custom_metadata, + }) + }; + + let menu_name = recipe + .metadata + .get("title") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| { + path.split('/') + .next_back() + .unwrap_or(&path) + .replace(".menu", "") + }); + + Ok(MenuTemplate { + active: "recipes".to_string(), + name: menu_name, + recipe_path: path, + breadcrumbs, + scale, + metadata, + sections, + image_path, + tr: Tr::new(lang), + prefix: url_prefix.to_string(), + static_mode, + }) +} + +fn count_recipes_tree(tree: &cooklang_find::RecipeTree) -> Option { + let mut count = 0; + + for child in tree.children.values() { + if !child.children.is_empty() { + count += count_recipes_tree(child).unwrap_or(0); + } else { + count += 1; + } + } + + Some(count) +} + +fn get_image_path(base_path: &Utf8Path, prefix: &str, img_path: String) -> Option { + tracing::debug!("Recipe image path from entry: {}", img_path); + // If it's a URL, use it directly + if img_path.starts_with("http://") || img_path.starts_with("https://") { + Some(img_path) + } else { + // For file paths, we need to make them relative to the base path and accessible via /api/static + let img_path = camino::Utf8Path::new(&img_path); + + // Try to strip the base_path prefix to get a relative path + if let Ok(relative) = img_path.strip_prefix(base_path) { + let result = format!("{prefix}/api/static/{relative}"); + tracing::debug!("Image path relative to base: {}", result); + Some(result) + } else if !img_path.is_absolute() { + Some(format!("{prefix}/api/static/{img_path}")) + } else { + img_path + .file_name() + .map(|name| format!("{prefix}/api/static/{name}")) + } + } +} diff --git a/src/server/handlers/menus.rs b/src/server/handlers/menus.rs index c818399f..53f47162 100644 --- a/src/server/handlers/menus.rs +++ b/src/server/handlers/menus.rs @@ -438,7 +438,11 @@ pub fn find_todays_menu( if date.as_deref() == Some(today.as_str()) { return Some(crate::server::templates::TodaysMenu { menu_name: menu_item.name.clone(), - menu_path: menu_item.path.clone(), + menu_path: menu_item + .path + .trim_end_matches(".cook") + .trim_end_matches(".menu") + .to_string(), date_display: today_display, }); } diff --git a/src/server/language.rs b/src/server/language.rs index 4e2786f6..2e2878bb 100644 --- a/src/server/language.rs +++ b/src/server/language.rs @@ -16,9 +16,24 @@ pub const ES_ES: LanguageIdentifier = langid!("es-ES"); pub const EU_ES: LanguageIdentifier = langid!("eu-ES"); pub const SV_SE: LanguageIdentifier = langid!("sv-SE"); -const SUPPORTED_LANGUAGES: &[LanguageIdentifier] = +pub const SUPPORTED_LANGUAGES: &[LanguageIdentifier] = &[EN_US, DE_DE, NL_NL, FR_FR, ES_ES, EU_ES, SV_SE]; +/// Parse a user-supplied language tag (e.g. "de", "de-DE") into one of the +/// supported [`LanguageIdentifier`]s. Returns `None` for unsupported tags. +pub fn parse_supported_language(s: &str) -> Option { + let parsed: LanguageIdentifier = s.parse().ok()?; + if SUPPORTED_LANGUAGES.contains(&parsed) { + return Some(parsed); + } + // Allow bare language codes ("de") to match a supported region ("de-DE"). + let base = s.split('-').next().unwrap_or(s); + SUPPORTED_LANGUAGES + .iter() + .find(|l| l.language.as_str().eq_ignore_ascii_case(base)) + .cloned() +} + /// Get the preferred language from headers /// 1. Check for 'lang' cookie /// 2. Parse Accept-Language header diff --git a/src/server/mod.rs b/src/server/mod.rs index 983c3b12..e2f6cf2e 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -49,21 +49,22 @@ use std::{net::IpAddr, net::SocketAddr, sync::Arc}; use tower_http::{cors::CorsLayer, services::ServeDir}; use tracing::{error, info}; +pub mod builders; mod handlers; mod i18n; -mod language; +pub mod language; mod lsp_bridge; mod shopping_list_store; mod shopping_list_watcher; #[cfg(feature = "sync")] pub mod sync; -mod templates; +pub mod templates; mod ui; // Embed static files at compile time #[derive(RustEmbed)] #[folder = "static/"] -struct StaticFiles; +pub struct StaticFiles; #[derive(Debug, Args)] pub struct ServerArgs { diff --git a/src/server/templates.rs b/src/server/templates.rs index c7c590d7..27e85462 100644 --- a/src/server/templates.rs +++ b/src/server/templates.rs @@ -43,6 +43,7 @@ pub struct ErrorTemplate { pub error_message: String, pub tr: Tr, pub prefix: String, + pub static_mode: bool, } pub struct TodaysMenu { @@ -62,6 +63,7 @@ pub struct RecipesTemplate { pub new_recipe_url: String, pub tr: Tr, pub prefix: String, + pub static_mode: bool, } #[derive(Template)] @@ -79,6 +81,7 @@ pub struct RecipeTemplate { pub image_path: Option, pub tr: Tr, pub prefix: String, + pub static_mode: bool, } impl RecipeTemplate { @@ -153,6 +156,216 @@ impl RecipeTemplate { .map(|s| s.replace("` on the recipe page. + pub fn recipe_jsonld(&self) -> String { + let recipe_ingredient: Vec = self + .ingredients + .iter() + .map(|ing| format_ingredient_string(&ing.name, &ing.quantity, &ing.unit, &ing.note)) + .collect(); + + let recipe_instructions: Vec = self + .sections + .iter() + .flat_map(|section| { + section + .items + .iter() + .filter_map(|item| match item { + RecipeSectionItem::Step(step) => { + let text = step_text(step); + if text.trim().is_empty() { + None + } else { + Some(serde_json::json!({ + "@type": "HowToStep", + "text": text, + })) + } + } + RecipeSectionItem::Note(_) => None, + }) + .collect::>() + }) + .collect(); + + let mut data = serde_json::Map::new(); + data.insert("@context".into(), "https://schema.org".into()); + data.insert("@type".into(), "Recipe".into()); + data.insert("name".into(), self.recipe.name.clone().into()); + if let Some(img) = &self.image_path { + data.insert("image".into(), img.clone().into()); + } + data.insert("recipeIngredient".into(), recipe_ingredient.into()); + data.insert("recipeInstructions".into(), recipe_instructions.into()); + if !self.tags.is_empty() { + data.insert("keywords".into(), self.tags.join(", ").into()); + } + + if let Some(meta) = &self.recipe.metadata { + if let Some(desc) = &meta.description { + data.insert("description".into(), desc.clone().into()); + } + if let Some(author) = &meta.author { + data.insert( + "author".into(), + serde_json::json!({ "@type": "Person", "name": author }), + ); + } + if let Some(servings) = &meta.servings { + data.insert("recipeYield".into(), servings.clone().into()); + } + if let Some(course) = &meta.course { + data.insert("recipeCategory".into(), course.clone().into()); + } + if let Some(cuisine) = &meta.cuisine { + data.insert("recipeCuisine".into(), cuisine.clone().into()); + } + if let Some(prep) = meta.prep_time.as_deref().and_then(to_iso8601_duration) { + data.insert("prepTime".into(), prep.into()); + } + if let Some(cook) = meta.cook_time.as_deref().and_then(to_iso8601_duration) { + data.insert("cookTime".into(), cook.into()); + } + if let Some(total) = meta.time.as_deref().and_then(to_iso8601_duration) { + data.insert("totalTime".into(), total.into()); + } + } + + // Escape sequences to prevent premature script tag closing + serde_json::to_string(&serde_json::Value::Object(data)) + .map(|s| s.replace(", + unit: &Option, + note: &Option, +) -> String { + let mut parts: Vec = Vec::new(); + if let Some(q) = quantity { + if !q.is_empty() { + parts.push(q.clone()); + } + } + if let Some(u) = unit { + if !u.is_empty() { + parts.push(u.clone()); + } + } + parts.push(name.to_string()); + let mut s = parts.join(" "); + if let Some(n) = note { + if !n.is_empty() { + s.push_str(&format!(" ({n})")); + } + } + s +} + +fn step_text(step: &StepData) -> String { + let mut out = String::new(); + for item in &step.items { + match item { + StepItem::Text(t) => out.push_str(t), + StepItem::Ingredient { name, .. } => out.push_str(name), + StepItem::Cookware(c) => out.push_str(c), + StepItem::Timer(t) => out.push_str(t), + StepItem::Quantity(q) => out.push_str(q), + StepItem::LineBreak => out.push(' '), + } + } + out.split_whitespace().collect::>().join(" ") +} + +/// Best-effort conversion of free-form time strings (e.g. "30 minutes", +/// "1 hour 15 min", "1h30m") to an ISO 8601 duration like "PT1H30M". +/// Returns `None` if the input cannot be parsed confidently — callers should +/// then skip the field rather than emit an invalid schema.org Duration. +fn to_iso8601_duration(s: &str) -> Option { + let trimmed = s.trim(); + if trimmed.is_empty() { + return None; + } + // If already ISO 8601, pass through. + if trimmed.starts_with('P') && trimmed.chars().all(|c| c.is_ascii_alphanumeric()) { + return Some(trimmed.to_string()); + } + + let lower = trimmed.to_lowercase(); + let mut hours: u32 = 0; + let mut minutes: u32 = 0; + let mut found = false; + + // Capture all " " pairs, where unit is hour(s)/h or minute(s)/min/m. + let mut chars = lower.char_indices().peekable(); + while let Some(&(idx, c)) = chars.peek() { + if c.is_ascii_digit() { + let start = idx; + while let Some(&(_, ch)) = chars.peek() { + if ch.is_ascii_digit() { + chars.next(); + } else { + break; + } + } + let end = chars.peek().map(|&(i, _)| i).unwrap_or(lower.len()); + let n: u32 = lower[start..end].parse().ok()?; + // Skip whitespace + while let Some(&(_, ch)) = chars.peek() { + if ch.is_whitespace() { + chars.next(); + } else { + break; + } + } + // Read unit letters + let unit_start = chars.peek().map(|&(i, _)| i).unwrap_or(lower.len()); + while let Some(&(_, ch)) = chars.peek() { + if ch.is_ascii_alphabetic() { + chars.next(); + } else { + break; + } + } + let unit_end = chars.peek().map(|&(i, _)| i).unwrap_or(lower.len()); + let unit = &lower[unit_start..unit_end]; + match unit { + "h" | "hr" | "hrs" | "hour" | "hours" => { + hours += n; + found = true; + } + "m" | "min" | "mins" | "minute" | "minutes" => { + minutes += n; + found = true; + } + _ => return None, + } + } else { + chars.next(); + } + } + + if !found { + return None; + } + + let mut out = String::from("PT"); + if hours > 0 { + out.push_str(&format!("{hours}H")); + } + if minutes > 0 { + out.push_str(&format!("{minutes}M")); + } + if out == "PT" { + return None; + } + Some(out) } #[derive(Template)] @@ -168,6 +381,7 @@ pub struct MenuTemplate { pub image_path: Option, pub tr: Tr, pub prefix: String, + pub static_mode: bool, } #[derive(Template)] @@ -176,6 +390,7 @@ pub struct ShoppingListTemplate { pub active: String, pub tr: Tr, pub prefix: String, + pub static_mode: bool, } #[derive(Template)] @@ -192,6 +407,7 @@ pub struct PreferencesTemplate { pub sync_email: Option, pub sync_syncing: bool, pub prefix: String, + pub static_mode: bool, } #[derive(Template)] @@ -202,6 +418,7 @@ pub struct PantryTemplate { pub sections: Vec, pub tr: Tr, pub prefix: String, + pub static_mode: bool, } #[derive(Template)] @@ -214,6 +431,7 @@ pub struct EditTemplate { pub base_path: String, pub tr: Tr, pub prefix: String, + pub static_mode: bool, } #[derive(Template)] @@ -224,6 +442,7 @@ pub struct NewTemplate { pub error: Option, pub filename: Option, pub prefix: String, + pub static_mode: bool, } #[derive(Debug, Clone, Serialize)] diff --git a/src/server/ui.rs b/src/server/ui.rs index 859ba937..d81fe58b 100644 --- a/src/server/ui.rs +++ b/src/server/ui.rs @@ -7,7 +7,6 @@ use axum::{ Form, Router, }; use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; -use fluent_templates::Loader; use serde::Deserialize; use std::sync::Arc; use unic_langid::LanguageIdentifier; @@ -22,6 +21,7 @@ fn error_page( error_message: msg.to_string(), tr: Tr::new(lang), prefix: prefix.to_string(), + static_mode: false, }; template.into_response() } @@ -58,150 +58,20 @@ async fn recipes_handler( path: Option, lang: LanguageIdentifier, ) -> axum::response::Response { - let base = &state.base_path; - let search_path = if let Some(p) = &path { - base.join(p) - } else { - base.clone() + let input = crate::server::builders::RecipesBuildInput { + base_path: &state.base_path, + url_prefix: &state.url_prefix, + sub_path: path.as_deref(), + lang: lang.clone(), + static_mode: false, }; - - let tree = match cooklang_find::build_tree(&search_path) { - Ok(tree) => tree, + match crate::server::builders::build_recipes_template(input) { + Ok(template) => template.into_response(), Err(e) => { - tracing::error!("Failed to build recipe tree: {:?}", e); - return error_page(lang, &state.url_prefix, &e); + tracing::error!("Failed to build recipes template: {:?}", e); + error_page(lang, &state.url_prefix, &e) } - }; - - let mut items = Vec::new(); - - for (name, child) in &tree.children { - let is_dir = !child.children.is_empty(); - let item_path = { - let url_path = child - .recipe - .as_ref() - .and_then(|recipe| recipe.file_name()) - .unwrap_or_else(|| name.to_string()); - - match &path { - Some(p) => format!("{p}/{url_path}"), - None => url_path.to_string(), - } - }; - - // Extract tags, image, and is_menu if this is a recipe - let (tags, image_path, is_menu) = if let Some(ref recipe) = child.recipe { - // Get image path similar to how we do it in recipe_page - let prefix = &state.url_prefix; - let img_path = recipe.title_image().clone().and_then(|img| { - if img.starts_with("http://") || img.starts_with("https://") { - Some(img) - } else { - // Make path relative to base and accessible via /api/static - let img_path = camino::Utf8Path::new(&img); - if let Ok(relative) = img_path.strip_prefix(base) { - Some(format!("{prefix}/api/static/{relative}")) - } else if !img_path.is_absolute() { - Some(format!("{prefix}/api/static/{img_path}")) - } else { - img_path - .file_name() - .map(|name| format!("{prefix}/api/static/{name}")) - } - } - }); - (recipe.tags(), img_path, recipe.is_menu()) - } else { - (Vec::new(), None, false) - }; - - items.push(RecipeItem { - name: name.to_string(), - path: item_path, - is_directory: is_dir, - count: if is_dir { - count_recipes_tree(child) - } else { - None - }, - description: None, - tags, - image_path, - is_menu, - }); } - - items.sort_by(|a, b| match (a.is_directory, b.is_directory) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => a.name.cmp(&b.name), - }); - - let todays_menu = if path.is_none() { - crate::server::handlers::find_todays_menu(base, &tree) - } else { - None - }; - - let breadcrumbs = if let Some(p) = &path { - p.split('/') - .scan(String::new(), |acc, segment| { - if !acc.is_empty() { - acc.push('/'); - } - acc.push_str(segment); - Some(Breadcrumb { - name: segment.to_string(), - path: acc.clone(), - }) - }) - .collect() - } else { - vec![] - }; - - let current_name = if let Some(ref p) = path { - p.split('/').next_back().unwrap_or("Recipes").to_string() - } else { - crate::server::i18n::LOCALES.lookup(&lang, "recipes-title") - }; - - let new_recipe_url = match &path { - Some(p) => format!( - "{}/new?filename={}%2F", - state.url_prefix, - urlencoding::encode(p) - ), - None => format!("{}/new", state.url_prefix), - }; - - let template = RecipesTemplate { - active: "recipes".to_string(), - current_name, - breadcrumbs, - items, - todays_menu, - new_recipe_url, - tr: Tr::new(lang), - prefix: state.url_prefix.clone(), - }; - - template.into_response() -} - -fn count_recipes_tree(tree: &cooklang_find::RecipeTree) -> Option { - let mut count = 0; - - for child in tree.children.values() { - if !child.children.is_empty() { - count += count_recipes_tree(child).unwrap_or(0); - } else { - count += 1; - } - } - - Some(count) } #[derive(Deserialize)] @@ -217,565 +87,26 @@ async fn recipe_page( ) -> axum::response::Response { let scale = query.scale.unwrap_or(1.0); - let recipe_path = Utf8PathBuf::from(&path); - tracing::info!( - "Looking for recipe at path: {}, extension: {:?}", - path, - recipe_path.extension() - ); - - let entry = match cooklang_find::get_recipe(vec![&state.base_path], &recipe_path) { - Ok(entry) => entry, - Err(e) => { - tracing::error!("Recipe not found: {path}"); - return error_page( - lang, - &state.url_prefix, - format!("Recipe not found: {path}: {e}"), - ); - } - }; - - // Check if this is a menu file - let actual_path = entry.path(); - tracing::info!( - "Recipe path: {}, actual_path: {:?}, is_menu: {}", - path, - actual_path, - entry.is_menu() - ); - if entry.is_menu() { - let prefix = state.url_prefix.clone(); - return match menu_page_handler(path, scale, entry, state, lang.clone()).await { - Ok(template) => template.into_response(), - Err(e) => error_page(lang, &prefix, &e), - }; - } - - let recipe = match crate::util::parse_recipe_from_entry(&entry, scale) { - Ok(recipe) => recipe, - Err(e) => { - tracing::error!("Failed to parse recipe: {e}"); - return error_page( - lang, - &state.url_prefix, - format!("Failed to parse recipe: {e}"), - ); - } - }; - - // Load aisle config for cooking mode ingredient sorting - let aisle_content = if let Some(path) = &state.aisle_path { - match tokio::fs::read_to_string(path).await { - Ok(content) => content, - Err(e) => { - tracing::warn!("Failed to read aisle file from {:?}: {}", path, e); - String::new() - } - } - } else { - String::new() + let input = crate::server::builders::RecipeBuildInput { + base_path: &state.base_path, + url_prefix: &state.url_prefix, + recipe_path: &path, + aisle_path: state.aisle_path.as_ref(), + scale, + lang: lang.clone(), + static_mode: false, }; - let aisle = cooklang::aisle::parse_lenient(&aisle_content) - .into_output() - .unwrap_or_default(); - - let tags = entry.tags(); - - // Get the image path if available - let image_path = entry - .title_image() - .clone() - .and_then(|img_path| get_image_path(&state.base_path, &state.url_prefix, img_path)); - - let mut ingredients = Vec::new(); - let mut cookware = Vec::new(); - let mut sections = Vec::new(); - - // Group ingredients by display name and merge quantities - let mut grouped_ingredients: std::collections::HashMap< - String, - ( - cooklang::quantity::GroupedQuantity, - Vec<&cooklang::model::Ingredient>, - ), - > = std::collections::HashMap::new(); - - for entry in recipe.group_ingredients(crate::util::PARSER.converter()) { - let ingredient = entry.ingredient; - let display_name = ingredient.display_name().to_string(); - - grouped_ingredients - .entry(display_name) - .and_modify(|(merged_qty, igrs)| { - merged_qty.merge(&entry.quantity, crate::util::PARSER.converter()); - igrs.push(ingredient); - }) - .or_insert_with(|| (entry.quantity.clone(), vec![ingredient])); - } - - // Sort by name for consistent display - let mut sorted_ingredients: Vec<_> = grouped_ingredients.into_iter().collect(); - sorted_ingredients.sort_by(|a, b| a.0.cmp(&b.0)); - - for (display_name, (quantity, ingredient_list)) in sorted_ingredients { - // Use the first ingredient's data for reference path and notes - let first_ingredient = ingredient_list[0]; - let reference_path = first_ingredient.reference.as_ref().map(|r| { - // For web URLs - always use forward slash - if r.components.is_empty() { - r.name.clone() - } else { - format!("{}/{}", r.components.join("/"), r.name) - } - }); - - // Combine notes from all ingredients - let combined_note = if ingredient_list.len() > 1 { - let notes: Vec<_> = ingredient_list - .iter() - .filter_map(|i| i.note.as_ref()) - .collect(); - if notes.is_empty() { - None - } else { - Some( - notes - .iter() - .map(|n| n.as_str()) - .collect::>() - .join(", "), - ) - } - } else { - first_ingredient.note.clone() - }; - - // Format the merged quantity - show all quantities comma-separated - let (formatted_quantity, formatted_unit) = if quantity.is_empty() { - (None, None) - } else { - let quantities: Vec<_> = quantity - .iter() - .map(|q| { - let qty_str = - crate::util::format::format_quantity(q.value()).unwrap_or_default(); - let unit_str = q.unit().as_ref().map(|u| u.to_string()).unwrap_or_default(); - if unit_str.is_empty() { - qty_str - } else { - format!("{} {}", qty_str, unit_str) - } - }) - .collect(); - (Some(quantities.join(", ")), None) - }; - - ingredients.push(IngredientData { - name: display_name, - quantity: formatted_quantity, - unit: formatted_unit, - note: combined_note, - reference_path, - }); - } - - for item in &recipe.group_cookware(crate::util::PARSER.converter()) { - cookware.push(CookwareData { - name: item.cookware.name.to_string(), - }); - } - - let mut total_steps = 0; - for section in &recipe.sections { - let mut section_items = Vec::new(); - let mut section_ingredient_indices = std::collections::HashSet::new(); - let mut cooking_mode_ingredient_indices: Vec = Vec::new(); - let mut step_count = 0; - - for content in §ion.content { - use cooklang::Content; - match content { - Content::Step(step) => { - let mut step_items = Vec::new(); - let mut step_ingredients = Vec::new(); - - for item in &step.items { - use crate::server::templates::{StepIngredient, StepItem}; - use cooklang::Item; - - match item { - Item::Text { value } => { - let parts: Vec<&str> = value.split('\n').collect(); - for (i, part) in parts.iter().enumerate() { - if i > 0 { - step_items.push(StepItem::LineBreak); - } - if !part.is_empty() { - step_items.push(StepItem::Text(part.to_string())); - } - } - } - Item::Ingredient { index } => { - section_ingredient_indices.insert(*index); - cooking_mode_ingredient_indices.push(*index); - if let Some(ing) = recipe.ingredients.get(*index) { - let reference_path = ing.reference.as_ref().map(|r| { - // For web URLs - always use forward slash - if r.components.is_empty() { - r.name.clone() - } else { - format!("{}/{}", r.components.join("/"), r.name) - } - }); - - step_items.push(StepItem::Ingredient { - name: ing.name.to_string(), - reference_path, - }); - - // Also add to step ingredients list - step_ingredients.push(StepIngredient { - name: ing.name.to_string(), - quantity: ing.quantity.as_ref().and_then(|q| { - crate::util::format::format_quantity(q.value()) - }), - unit: ing - .quantity - .as_ref() - .and_then(|q| q.unit().as_ref().map(|u| u.to_string())), - note: ing.note.clone(), - }); - } - } - Item::Cookware { index } => { - if let Some(cw) = recipe.cookware.get(*index) { - step_items.push(StepItem::Cookware(cw.name.to_string())); - } - } - Item::Timer { index } => { - if let Some(timer) = recipe.timers.get(*index) { - let mut timer_text = String::new(); - // Add timer quantity and unit - if let Some(quantity) = &timer.quantity { - if let Some(formatted) = - crate::util::format::format_quantity(quantity.value()) - { - timer_text.push_str(&formatted); - } - if let Some(unit) = quantity.unit() { - if !timer_text.is_empty() { - timer_text.push(' '); - } - timer_text.push_str(unit); - } - } - - // If no duration info, just show "timer" - if timer_text.is_empty() { - timer_text = "timer".to_string(); - } - - step_items.push(StepItem::Timer(timer_text)); - } - } - Item::InlineQuantity { index } => { - if let Some(q) = recipe.inline_quantities.get(*index) { - let mut qty = crate::util::format::format_quantity(q.value()) - .unwrap_or_default(); - if let Some(unit) = q.unit() { - if !qty.is_empty() { - qty.push_str(&format!(" {unit}")); - } else { - qty = unit.to_string(); - } - } - step_items.push(StepItem::Quantity(qty)); - } - } - } - } - - let section_image_path = entry - .step_images() - .get(0, total_steps + step_count + 1) - .and_then(|img_path| { - get_image_path( - &state.base_path, - &state.url_prefix, - img_path.to_string(), - ) - }); - - section_items.push(RecipeSectionItem::Step(StepData { - number: step_count + 1, - items: step_items, - ingredients: step_ingredients, - image_path: section_image_path, - })); - step_count += 1; - } - Content::Text(text) => { - // Skip list bullet items - if text.trim() != "-" { - section_items.push(RecipeSectionItem::Note(text.trim().to_string())); - } - } - } + match crate::server::builders::build_recipe_template(input) { + Ok(crate::server::builders::RecipeBuildOutput::Recipe(template)) => { + template.into_response() } - - // Only add sections that have items (steps or notes) - if !section_items.is_empty() { - use crate::server::templates::RecipeSection; - - // Collect and group ingredients used in this section - let mut section_grouped_ingredients: std::collections::HashMap< - String, - ( - cooklang::quantity::GroupedQuantity, - Vec<&cooklang::model::Ingredient>, - ), - > = std::collections::HashMap::new(); - - for idx in section_ingredient_indices { - if let Some(ingredient) = recipe.ingredients.get(idx) { - let display_name = ingredient.display_name().to_string(); - let qty = if let Some(q) = &ingredient.quantity { - let mut grouped_qty = cooklang::quantity::GroupedQuantity::empty(); - grouped_qty.add(q, crate::util::PARSER.converter()); - grouped_qty - } else { - cooklang::quantity::GroupedQuantity::empty() - }; - - section_grouped_ingredients - .entry(display_name) - .and_modify(|(merged_qty, igrs)| { - if let Some(q) = &ingredient.quantity { - merged_qty.add(q, crate::util::PARSER.converter()); - } - igrs.push(ingredient); - }) - .or_insert_with(|| (qty, vec![ingredient])); - } - } - - // Sort section ingredients by name - let mut sorted_section_ingredients: Vec<_> = - section_grouped_ingredients.into_iter().collect(); - sorted_section_ingredients.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut section_ingredients = Vec::new(); - for (display_name, (quantity, ingredient_list)) in sorted_section_ingredients { - let first_ingredient = ingredient_list[0]; - let reference_path = first_ingredient.reference.as_ref().map(|r| { - // For web URLs - always use forward slash - if r.components.is_empty() { - r.name.clone() - } else { - format!("{}/{}", r.components.join("/"), r.name) - } - }); - - // Combine notes from all ingredients in the section - let combined_note = if ingredient_list.len() > 1 { - let notes: Vec<_> = ingredient_list - .iter() - .filter_map(|i| i.note.as_ref()) - .collect(); - if notes.is_empty() { - None - } else { - Some( - notes - .iter() - .map(|n| n.as_str()) - .collect::>() - .join(", "), - ) - } - } else { - first_ingredient.note.clone() - }; - - // Format the merged quantity - let (formatted_quantity, formatted_unit) = if quantity.is_empty() { - (None, None) - } else { - let quantities: Vec<_> = quantity - .iter() - .map(|q| { - let qty_str = - crate::util::format::format_quantity(q.value()).unwrap_or_default(); - let unit_str = - q.unit().as_ref().map(|u| u.to_string()).unwrap_or_default(); - if unit_str.is_empty() { - qty_str - } else { - format!("{} {}", qty_str, unit_str) - } - }) - .collect(); - (Some(quantities.join(", ")), None) - }; - - section_ingredients.push(IngredientData { - name: display_name, - quantity: formatted_quantity, - unit: formatted_unit, - note: combined_note, - reference_path, - }); - } - - // Build uncombined ingredients for cooking mode, sorted by aisle order - let mut cooking_mode_ingredients_with_key: Vec<( - Option<(usize, usize)>, - IngredientData, - )> = Vec::new(); - for idx in &cooking_mode_ingredient_indices { - if let Some(ingredient) = recipe.ingredients.get(*idx) { - if !ingredient.modifiers().should_be_listed() { - continue; - } - let sort_key = aisle.ingredient_sort_key(&ingredient.name); - - let (formatted_quantity, formatted_unit) = if let Some(q) = &ingredient.quantity - { - let qty_str = crate::util::format::format_quantity(q.value()); - let unit_str = q.unit().as_ref().map(|u| u.to_string()); - (qty_str, unit_str) - } else { - (None, None) - }; - - cooking_mode_ingredients_with_key.push(( - sort_key, - IngredientData { - name: ingredient.name.to_string(), - quantity: formatted_quantity, - unit: formatted_unit, - note: ingredient.note.clone(), - reference_path: None, - }, - )); - } - } - - // Sort: aisle items first (by category_index, ingredient_index), then uncategorized at end - cooking_mode_ingredients_with_key.sort_by(|a, b| match (&a.0, &b.0) { - (Some(ka), Some(kb)) => ka.cmp(kb), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.1.name.cmp(&b.1.name), - }); - - let cooking_mode_ingredients: Vec = cooking_mode_ingredients_with_key - .into_iter() - .map(|(_, data)| data) - .collect(); - - sections.push(RecipeSection { - name: section.name.clone(), - items: section_items.clone(), - step_offset: total_steps, - ingredients: section_ingredients, - cooking_mode_ingredients, - }); - total_steps += step_count; + Ok(crate::server::builders::RecipeBuildOutput::Menu(template)) => template.into_response(), + Err(e) => { + tracing::error!("Failed to build recipe template: {:?}", e); + error_page(lang, &state.url_prefix, &e) } } - - let breadcrumbs: Vec = path - .split('/') - .map(|s| s.trim_end_matches(".cook").to_string()) - .collect(); - - let metadata = if recipe.metadata.map.is_empty() { - None - } else { - // Get standard metadata fields (handle both string and number types) - let get_field = |key: &str| -> Option { - recipe.metadata.get(key).and_then(|v| { - if let Some(s) = v.as_str() { - Some(s.to_string()) - } else if let Some(n) = v.as_i64() { - Some(n.to_string()) - } else { - v.as_f64().map(crate::util::format::format_number) - } - }) - }; - - let mut custom_metadata = Vec::new(); - for (key, value) in recipe.metadata.map_filtered() { - if let (Some(key_str), Some(val_str)) = (key.as_str(), value.as_str()) { - if key_str.starts_with("source.") || key_str.starts_with("time.") { - continue; - } - - custom_metadata.push((key_str.to_string(), val_str.to_string())); - } - } - - Some(RecipeMetadata { - servings: get_field("servings"), - time: get_field("time") - .or_else(|| get_field("duration")) - .or_else(|| get_field("time required")), - difficulty: get_field("difficulty"), - course: get_field("course"), - prep_time: get_field("prep time") - .or_else(|| get_field("prep_time")) - .or_else(|| get_field("preptime")) - .or_else(|| get_field("time.prep")), - cook_time: get_field("cook time") - .or_else(|| get_field("cook_time")) - .or_else(|| get_field("cooktime")) - .or_else(|| get_field("time.cook")), - cuisine: get_field("cuisine"), - diet: get_field("diet"), - author: get_field("author").or_else(|| get_field("source.author")), - description: get_field("description"), - source: get_field("source").or_else(|| get_field("source.name")), - source_url: get_field("source.url"), - custom: custom_metadata, - }) - }; - - // Use title from metadata if available, otherwise use filename - let recipe_name = recipe - .metadata - .get("title") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| { - path.split('/') - .next_back() - .unwrap_or(&path) - .replace(".cook", "") - }); - - let template = RecipeTemplate { - active: "recipes".to_string(), - recipe: RecipeData { - name: recipe_name, - metadata, - }, - recipe_path: path, - breadcrumbs, - scale, - tags, - ingredients, - cookware, - sections, - image_path, - tr: Tr::new(lang), - prefix: state.url_prefix.clone(), - }; - - template.into_response() } async fn edit_page( @@ -851,6 +182,7 @@ async fn edit_page( base_path: state.base_path.to_string(), tr: crate::server::templates::Tr::new(lang), prefix: state.url_prefix.clone(), + static_mode: false, }; template.into_response() @@ -873,6 +205,7 @@ async fn new_page( error: query.error, filename: query.filename, prefix: state.url_prefix.clone(), + static_mode: false, } } @@ -1096,273 +429,6 @@ async fn create_recipe( .into_response() } -fn get_image_path(base_path: &Utf8PathBuf, prefix: &str, img_path: String) -> Option { - tracing::debug!("Recipe image path from entry: {}", img_path); - // If it's a URL, use it directly - if img_path.starts_with("http://") || img_path.starts_with("https://") { - Some(img_path) - } else { - // For file paths, we need to make them relative to the base path and accessible via /api/static - let img_path = camino::Utf8Path::new(&img_path); - - // Try to strip the base_path prefix to get a relative path - if let Ok(relative) = img_path.strip_prefix(base_path) { - let result = format!("{prefix}/api/static/{relative}"); - tracing::debug!("Image path relative to base: {}", result); - Some(result) - } else if !img_path.is_absolute() { - Some(format!("{prefix}/api/static/{img_path}")) - } else { - img_path - .file_name() - .map(|name| format!("{prefix}/api/static/{name}")) - } - } -} - -async fn menu_page_handler( - path: String, - scale: f64, - entry: cooklang_find::RecipeEntry, - state: Arc, - lang: LanguageIdentifier, -) -> Result { - let recipe = crate::util::parse_recipe_from_entry(&entry, scale).map_err(|e| { - tracing::error!("Failed to parse menu: {e}"); - format!("Failed to parse menu: {e}") - })?; - - // Get the image path if available - let image_path = entry.title_image().clone().and_then(|img_path| { - if img_path.starts_with("http://") || img_path.starts_with("https://") { - Some(img_path) - } else { - let img_path = camino::Utf8Path::new(&img_path); - let prefix = &state.url_prefix; - if let Ok(relative) = img_path.strip_prefix(&state.base_path) { - Some(format!("{prefix}/api/static/{relative}")) - } else if !img_path.is_absolute() { - Some(format!("{prefix}/api/static/{img_path}")) - } else { - img_path - .file_name() - .map(|name| format!("{prefix}/api/static/{name}")) - } - } - }); - - let breadcrumbs: Vec = path.split('/').map(|s| s.to_string()).collect(); - - // Parse sections and content - let mut sections = Vec::new(); - - for section in &recipe.sections { - let section_name = section.name.clone(); - let mut lines = Vec::new(); - - for content in §ion.content { - use cooklang::Content; - if let Content::Step(step) = content { - // Build the full step content first - let mut step_items = Vec::new(); - let mut current_text = String::new(); - - for item in &step.items { - use crate::server::templates::MenuSectionItem; - use cooklang::Item; - - match item { - Item::Text { value } => { - // Check if this is an isolated dash (bullet marker) - if value == "-" { - // Bullet marker - complete current line and start new one - if !current_text.is_empty() { - step_items.push(MenuSectionItem::Text(current_text.clone())); - current_text.clear(); - } - if !step_items.is_empty() { - lines.push(step_items.clone()); - step_items.clear(); - } - } else { - // Split on newlines to preserve line breaks - let parts: Vec<&str> = value.split('\n').collect(); - for (i, part) in parts.iter().enumerate() { - if i > 0 { - // Newline encountered - flush current text and line - if !current_text.is_empty() { - step_items - .push(MenuSectionItem::Text(current_text.clone())); - current_text.clear(); - } - if !step_items.is_empty() { - lines.push(step_items.clone()); - step_items.clear(); - } - } - if !part.is_empty() { - current_text.push_str(part); - } - } - } - } - Item::Ingredient { index } => { - // First, add any pending text - if !current_text.is_empty() { - step_items.push(MenuSectionItem::Text(current_text.clone())); - current_text.clear(); - } - - if let Some(ing) = recipe.ingredients.get(*index) { - // Check if this is a recipe reference using the reference field - if let Some(ref recipe_ref) = ing.reference { - // This is a recipe reference - let recipe_scale = - ing.quantity.as_ref().and_then(|q| match q.value() { - cooklang::Value::Number(n) => Some(n.value()), - _ => None, - }); - - // Apply menu scaling to the recipe reference - let final_scale = recipe_scale.map(|s| s * scale); - - // Build the full path from components - // For web URLs - always use forward slash - let name = if recipe_ref.components.is_empty() { - recipe_ref.name.clone() - } else { - format!( - "{}/{}", - recipe_ref.components.join("/"), - recipe_ref.name - ) - }; - - step_items.push(MenuSectionItem::RecipeReference { - name, - scale: final_scale, - }); - } else { - // Regular ingredient - let quantity = ing.quantity.as_ref().and_then(|q| { - crate::util::format::format_quantity(q.value()) - }); - let unit = ing - .quantity - .as_ref() - .and_then(|q| q.unit().as_ref().map(|u| u.to_string())); - - step_items.push(MenuSectionItem::Ingredient { - name: ing.name.to_string(), - quantity, - unit, - }); - } - } - } - _ => {} // Ignore other items in menu files - } - } - - // Add any remaining content as a line - if !current_text.is_empty() { - step_items.push(MenuSectionItem::Text(current_text)); - } - if !step_items.is_empty() { - lines.push(step_items); - } - } - } - - // Filter out empty lines (lines that are only whitespace text) - lines.retain(|line| { - !line - .iter() - .all(|item| matches!(item, MenuSectionItem::Text(t) if t.trim().is_empty())) - }); - - if !lines.is_empty() { - sections.push(MenuSection { - name: section_name, - lines, - }); - } - } - - // Get metadata - let metadata = if recipe.metadata.map.is_empty() { - None - } else { - let get_field = |key: &str| -> Option { - recipe.metadata.get(key).and_then(|v| { - if let Some(s) = v.as_str() { - Some(s.to_string()) - } else if let Some(n) = v.as_i64() { - Some(n.to_string()) - } else { - v.as_f64().map(crate::util::format::format_number) - } - }) - }; - - let mut custom_metadata = Vec::new(); - for (key, value) in recipe.metadata.map_filtered() { - if let (Some(key_str), Some(val_str)) = (key.as_str(), value.as_str()) { - custom_metadata.push((key_str.to_string(), val_str.to_string())); - } - } - - Some(RecipeMetadata { - servings: get_field("servings"), - time: get_field("time") - .or_else(|| get_field("duration")) - .or_else(|| get_field("time required")), - difficulty: get_field("difficulty"), - course: get_field("course"), - prep_time: get_field("prep time") - .or_else(|| get_field("prep_time")) - .or_else(|| get_field("preptime")), - cook_time: get_field("cook time") - .or_else(|| get_field("cook_time")) - .or_else(|| get_field("cooktime")), - cuisine: get_field("cuisine"), - diet: get_field("diet"), - author: get_field("author").or_else(|| get_field("source.author")), - description: get_field("description"), - source: get_field("source").or_else(|| get_field("source.name")), - source_url: get_field("source.url"), - custom: custom_metadata, - }) - }; - - let menu_name = recipe - .metadata - .get("title") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| { - path.split('/') - .next_back() - .unwrap_or(&path) - .replace(".menu", "") - }); - - let template = MenuTemplate { - active: "recipes".to_string(), - name: menu_name, - recipe_path: path, - breadcrumbs, - scale, - metadata, - sections, - image_path, - tr: Tr::new(lang), - prefix: state.url_prefix.clone(), - }; - - Ok(template) -} - async fn shopping_list_page( State(state): State>, Extension(lang): Extension, @@ -1371,6 +437,7 @@ async fn shopping_list_page( active: "shopping".to_string(), tr: Tr::new(lang), prefix: state.url_prefix.clone(), + static_mode: false, } } @@ -1417,6 +484,7 @@ async fn pantry_page( sections, tr: Tr::new(lang), prefix: state.url_prefix.clone(), + static_mode: false, }) } @@ -1449,5 +517,6 @@ async fn preferences_page( sync_email, sync_syncing, prefix: state.url_prefix.clone(), + static_mode: false, } } diff --git a/static/js/keyboard-shortcuts.js b/static/js/keyboard-shortcuts.js index b94ddff4..9746aea8 100644 --- a/static/js/keyboard-shortcuts.js +++ b/static/js/keyboard-shortcuts.js @@ -73,6 +73,51 @@ return; } + const staticMode = window.__STATIC_MODE__ === true; + const kbd = 'class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono"'; + const row = (label, keys) => ` +
+ ${label} + ${keys} +
`; + const k = (s) => `${s}`; + + const navRows = [ + row('Focus search', k('/')), + row('Navigate search results', `${k('↑')} ${k('↓')} ${k('Enter')}`), + row('Go to recipes', `${k('g')} ${k('h')}`), + ]; + if (!staticMode) { + navRows.push( + row('Go to shopping list', `${k('g')} ${k('s')}`), + row('Go to pantry', `${k('g')} ${k('p')}`), + row('Go to preferences', `${k('g')} ${k('x')}`) + ); + } + + const recipeRows = [row('Start cooking mode', k('c'))]; + if (!staticMode) { + recipeRows.push( + row('Edit recipe', k('e')), + row('Add to shopping list', k('a')) + ); + } + recipeRows.push(row('Print recipe', k('p'))); + if (!staticMode) { + recipeRows.push( + row('Increase scale', k('+')), + row('Decrease scale', k('-')) + ); + } + + const shoppingSection = staticMode ? '' : ` +
+

Shopping List

+
+ ${row('Clear all items', k('c'))} +
+
`; + const modal = document.createElement('div'); modal.id = 'keyboard-shortcuts-modal'; modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50'; @@ -90,88 +135,21 @@

Navigation

-
-
- Focus search - / -
-
- Navigate search results - Enter -
-
- Go to recipes - g h -
-
- Go to shopping list - g s -
-
- Go to pantry - g p -
-
- Go to preferences - g x -
-
+
${navRows.join('')}

General

-
- Toggle theme - t -
-
- Show shortcuts - ? -
-
- Close modal - Esc -
+ ${row('Toggle theme', k('t'))} + ${row('Show shortcuts', k('?'))} + ${row('Close modal', k('Esc'))}

Recipe Page

-
-
- Start cooking mode - c -
-
- Edit recipe - e -
-
- Add to shopping list - a -
-
- Print recipe - p -
-
- Increase scale - + -
-
- Decrease scale - - -
-
-
-
-

Shopping List

-
-
- Clear all items - c -
-
+
${recipeRows.join('')}
+ ${shoppingSection}
@@ -220,6 +198,7 @@ if (pendingKey === 'g') { clearPendingKey(); const pfx = window.__PREFIX__ || ''; + const staticMode = window.__STATIC_MODE__ === true; switch (key) { case 'h': case 'r': @@ -227,14 +206,17 @@ window.location.href = pfx + '/'; return; case 's': + if (staticMode) break; event.preventDefault(); window.location.href = pfx + '/shopping-list'; return; case 'p': + if (staticMode) break; event.preventDefault(); window.location.href = pfx + '/pantry'; return; case 'x': + if (staticMode) break; event.preventDefault(); window.location.href = pfx + '/preferences'; return; @@ -308,6 +290,7 @@ // Recipe page specific shortcuts function handleRecipeShortcuts(event, key) { + const staticMode = window.__STATIC_MODE__ === true; switch (key) { case 'c': event.preventDefault(); @@ -317,6 +300,7 @@ return; case 'e': + if (staticMode) return; event.preventDefault(); // Find and click the edit link const editPfx = (window.__PREFIX__ || '') + '/edit/'; @@ -327,6 +311,7 @@ return; case 'a': + if (staticMode) return; event.preventDefault(); // Find and click the add to shopping list button const addButton = document.querySelector('button[onclick^="addToShoppingList"]'); @@ -342,22 +327,26 @@ case '+': case '=': // = is on the same key as + without shift + if (staticMode) return; event.preventDefault(); adjustScale(0.5); return; case '-': case '_': + if (staticMode) return; event.preventDefault(); adjustScale(-0.5); return; case ']': + if (staticMode) return; event.preventDefault(); adjustScale(1); return; case '[': + if (staticMode) return; event.preventDefault(); adjustScale(-1); return; diff --git a/static/js/search.js b/static/js/search.js new file mode 100644 index 00000000..f38138b2 --- /dev/null +++ b/static/js/search.js @@ -0,0 +1,110 @@ +(function () { + var prefix = window.__PREFIX__ || "."; + var input = document.getElementById("search-input"); + var results = document.getElementById("search-results"); + if (!input || !results) return; + + var index = null; + var selectedIndex = -1; + + function loadIndex() { + if (index !== null) return Promise.resolve(index); + return fetch(prefix + "/static/search-index.json") + .then(function (r) { return r.json(); }) + .then(function (data) { + index = data; + return data; + }) + .catch(function (e) { + console.error("search-index load failed", e); + index = []; + return index; + }); + } + + function score(entry, q) { + var ql = q.toLowerCase(); + if (entry.title.toLowerCase().indexOf(ql) !== -1) return 3; + if (entry.tags.some(function (t) { return t.toLowerCase().indexOf(ql) !== -1; })) return 2; + if (entry.ingredients.some(function (i) { return i.toLowerCase().indexOf(ql) !== -1; })) return 1; + return 0; + } + + function escapeHtml(s) { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + function render(matches) { + if (matches.length === 0) { + results.innerHTML = '
No recipes found
'; + } else { + results.innerHTML = matches.map(function (m) { + var href = prefix + "/" + m.path; + return '' + + '
' + escapeHtml(m.title) + '
' + + '
'; + }).join(""); + } + results.classList.remove("hidden"); + } + + function updateSearchSelection() { + var items = results.querySelectorAll("a"); + items.forEach(function (item, i) { + if (i === selectedIndex) { + item.classList.add("search-selected"); + item.scrollIntoView({ block: "nearest" }); + } else { + item.classList.remove("search-selected"); + } + }); + } + + var timeout; + input.addEventListener("input", function () { + clearTimeout(timeout); + var q = this.value.trim(); + selectedIndex = -1; + if (q.length < 2) { + results.classList.add("hidden"); + return; + } + timeout = setTimeout(function () { + loadIndex().then(function (idx) { + var matches = idx + .map(function (e) { return { e: e, s: score(e, q) }; }) + .filter(function (x) { return x.s > 0; }) + .sort(function (a, b) { return b.s - a.s; }) + .slice(0, 20) + .map(function (x) { return x.e; }); + render(matches); + }); + }, 150); + }); + + input.addEventListener("keydown", function (e) { + var items = results.querySelectorAll("a"); + if (items.length === 0 || results.classList.contains("hidden")) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + selectedIndex = Math.min(selectedIndex + 1, items.length - 1); + updateSearchSelection(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + selectedIndex = Math.max(selectedIndex - 1, -1); + updateSearchSelection(); + } else if (e.key === "Enter") { + if (selectedIndex >= 0 && selectedIndex < items.length) { + e.preventDefault(); + items[selectedIndex].click(); + } + } + }); + + document.addEventListener("click", function (e) { + if (!input.contains(e.target) && !results.contains(e.target)) { + results.classList.add("hidden"); + selectedIndex = -1; + } + }); +})(); diff --git a/templates/base.html b/templates/base.html index ac16d248..8ff85f62 100644 --- a/templates/base.html +++ b/templates/base.html @@ -172,8 +172,8 @@ } /* Fix for navigation pill hover states */ - .dark a[href="{{ prefix }}/"].hover\\:bg-gradient-to-r:hover, - .dark a[href="{{ prefix }}/shopping-list"].hover\\:bg-gradient-to-r:hover { + .dark a[href="{{ prefix }}/"].hover\\:bg-gradient-to-r:hover{% if !static_mode %}, + .dark a[href="{{ prefix }}/shopping-list"].hover\\:bg-gradient-to-r:hover{% endif %} { background: #374151 !important; } @@ -468,7 +468,7 @@ } /* Hide all navigation links */ - a[href="{{ prefix }}/"], a[href="{{ prefix }}/shopping-list"], a[href="{{ prefix }}/preferences"] { + a[href="{{ prefix }}/"]{% if !static_mode %}, a[href="{{ prefix }}/shopping-list"], a[href="{{ prefix }}/preferences"]{% endif %} { display: none !important; } @@ -750,7 +750,7 @@
- + {{ tr.t("todays-menu-view") }} @@ -56,7 +58,7 @@

{{ tr.t("todays-menu-title") }}

{% for item in items %} {% if item.is_directory %} - +
📁 @@ -70,7 +72,7 @@

{{ item.name }}

{% else %} - + {% match item.image_path %} {% when Some with (img) %}
diff --git a/tests/build.rs b/tests/build.rs new file mode 100644 index 00000000..3105ac98 --- /dev/null +++ b/tests/build.rs @@ -0,0 +1,495 @@ +use assert_cmd::Command; +use std::path::PathBuf; +use tempfile::TempDir; + +fn seed_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("seed") +} + +#[test] +fn build_command_help_works() { + let mut cmd = Command::cargo_bin("cook").unwrap(); + cmd.args(["build", "--help"]).assert().success(); +} + +#[test] +fn build_creates_output_dir() { + let tmp = TempDir::new().unwrap(); + let out = tmp.path().join("_site"); + let seed = seed_dir(); + + let mut cmd = Command::cargo_bin("cook").unwrap(); + cmd.args([ + "build", + out.to_str().unwrap(), + "--base-path", + seed.to_str().unwrap(), + ]) + .assert() + .success(); + + assert!(out.is_dir(), "output dir should exist after build"); +} + +#[test] +fn build_writes_index_and_static_assets() { + let tmp = TempDir::new().unwrap(); + let out = tmp.path().join("_site"); + let seed = seed_dir(); + + Command::cargo_bin("cook") + .unwrap() + .args([ + "build", + out.to_str().unwrap(), + "--base-path", + seed.to_str().unwrap(), + ]) + .assert() + .success(); + + assert!(out.join("index.html").is_file(), "index.html should exist"); + assert!( + out.join("static/css/output.css").is_file(), + "css should exist" + ); + + let index = std::fs::read_to_string(out.join("index.html")).unwrap(); + assert!( + !index.contains("/api/search"), + "static index should not reference api search" + ); + assert!( + !index.contains("Add to shopping list"), + "no shopping list UI" + ); +} + +#[test] +fn build_lang_arg_changes_ui_locale() { + let tmp = TempDir::new().unwrap(); + let out = tmp.path().join("_site"); + let seed = seed_dir(); + + Command::cargo_bin("cook") + .unwrap() + .args([ + "build", + out.to_str().unwrap(), + "--base-path", + seed.to_str().unwrap(), + "--lang", + "de-DE", + ]) + .assert() + .success(); + + let index = std::fs::read_to_string(out.join("index.html")).unwrap(); + // The German locale renders the search placeholder text in German rather + // than the English default ("Search recipes..."). + assert!( + !index.contains("Search recipes"), + "index should not contain the English search placeholder under --lang de-DE" + ); +} + +#[test] +fn build_lang_arg_rejects_unsupported() { + let tmp = TempDir::new().unwrap(); + let out = tmp.path().join("_site"); + let seed = seed_dir(); + + Command::cargo_bin("cook") + .unwrap() + .args([ + "build", + out.to_str().unwrap(), + "--base-path", + seed.to_str().unwrap(), + "--lang", + "xx-YY", + ]) + .assert() + .failure(); +} + +#[test] +fn build_writes_recipe_pages() { + let tmp = TempDir::new().unwrap(); + let out = tmp.path().join("_site"); + let seed = seed_dir(); + + Command::cargo_bin("cook") + .unwrap() + .args([ + "build", + out.to_str().unwrap(), + "--base-path", + seed.to_str().unwrap(), + ]) + .assert() + .success(); + + // The seed contains "Easy Pancakes.cook" under Breakfast. + let pancakes = out.join("recipe/Breakfast/Easy Pancakes.html"); + assert!( + pancakes.is_file(), + "pancakes html should exist at {pancakes:?}" + ); + + let html = std::fs::read_to_string(&pancakes).unwrap(); + assert!(html.contains("Pancakes"), "title should be present"); + assert!( + !html.contains("/api/shopping_list"), + "no shopping-list api references" + ); + + // The .cook source should be copied alongside the rendered HTML so visitors + // can download the canonical recipe data. + let source = out.join("recipe/Breakfast/Easy Pancakes.cook"); + assert!(source.is_file(), "source .cook should exist at {source:?}"); + + // And the recipe page should expose a download link to it. + // We check the exact href to catch extension-doubling regressions + // (e.g. ".cook.cook") where the file silently 404s. + assert!( + html.contains("href=\"../../recipe/Breakfast/Easy Pancakes.cook\""), + "recipe page should link to the .cook source with correct extension" + ); + assert!( + !html.contains(".cook.cook"), + "download link must not double the .cook extension" + ); + assert!( + html.contains(" download"), + "recipe page should use a download attribute" + ); + + // schema.org Recipe JSON-LD must be embedded for SEO. + assert!( + html.contains("application/ld+json"), + "recipe page should embed JSON-LD" + ); + assert!( + html.contains("\"@type\":\"Recipe\""), + "JSON-LD should declare @type Recipe" + ); + assert!( + html.contains("\"recipeIngredient\""), + "JSON-LD should include recipeIngredient list" + ); + assert!( + html.contains("\"recipeInstructions\""), + "JSON-LD should include recipeInstructions list" + ); +} + +#[test] +fn build_renders_recipes_with_title_metadata() { + let tmp = TempDir::new().unwrap(); + let out = tmp.path().join("_site"); + let seed = seed_dir(); + + Command::cargo_bin("cook") + .unwrap() + .args([ + "build", + out.to_str().unwrap(), + "--base-path", + seed.to_str().unwrap(), + ]) + .assert() + .success(); + + // Risotto.cook has title metadata "Classic Risotto alla Milanese". + // The output URL/path must use the file stem, not the title. + assert!( + out.join("recipe/Risotto.html").is_file(), + "Risotto.html should exist (title-metadata regression)" + ); + assert!( + out.join("recipe/lamb-chops.html").is_file(), + "lamb-chops.html should exist (title-metadata regression)" + ); +} + +#[test] +fn build_writes_search_index() { + let tmp = TempDir::new().unwrap(); + let out = tmp.path().join("_site"); + let seed = seed_dir(); + + Command::cargo_bin("cook") + .unwrap() + .args([ + "build", + out.to_str().unwrap(), + "--base-path", + seed.to_str().unwrap(), + ]) + .assert() + .success(); + + let idx = out.join("static/search-index.json"); + assert!(idx.is_file(), "search-index.json should exist"); + + let json: serde_json::Value = + serde_json::from_reader(std::fs::File::open(&idx).unwrap()).unwrap(); + let arr = json.as_array().expect("index is array"); + assert!(!arr.is_empty(), "index should not be empty for seed"); + + let first = &arr[0]; + assert!(first.get("title").is_some()); + assert!(first.get("path").is_some()); + assert!(first.get("tags").is_some()); + assert!(first.get("ingredients").is_some()); + + // Find the Risotto entry (title from metadata) and confirm its path uses file stem. + let risotto = arr + .iter() + .find(|e| e.get("path").and_then(|p| p.as_str()) == Some("recipe/Risotto.html")) + .expect("entry for recipe/Risotto.html"); + assert_eq!( + risotto.get("title").and_then(|t| t.as_str()), + Some("Classic Risotto alla Milanese"), + "title should be the metadata title; path should be the file stem" + ); +} + +#[test] +fn build_copies_images_when_present() { + let tmp = TempDir::new().unwrap(); + let out = tmp.path().join("_site"); + let seed = seed_dir(); + + Command::cargo_bin("cook") + .unwrap() + .args([ + "build", + out.to_str().unwrap(), + "--base-path", + seed.to_str().unwrap(), + ]) + .assert() + .success(); + + // The seed contains "Easy Pancakes.jpg" alongside "Easy Pancakes.cook". + let pancakes_image = out.join("api/static/Breakfast/Easy Pancakes.jpg"); + assert!( + pancakes_image.is_file(), + "expected copied image at {pancakes_image:?}" + ); +} + +#[test] +fn build_writes_search_js() { + let tmp = TempDir::new().unwrap(); + let out = tmp.path().join("_site"); + let seed = seed_dir(); + + Command::cargo_bin("cook") + .unwrap() + .args([ + "build", + out.to_str().unwrap(), + "--base-path", + seed.to_str().unwrap(), + ]) + .assert() + .success(); + + assert!( + out.join("static/js/search.js").is_file(), + "search.js should exist" + ); + + // __PREFIX__ must be assigned BEFORE search.js loads, otherwise the IIFE + // snapshots `undefined` and fetches use the wrong base, 404ing the index. + let listing = std::fs::read_to_string(out.join("directory/Breakfast.html")).unwrap(); + let prefix_idx = listing + .find("window.__PREFIX__") + .expect("__PREFIX__ assignment missing"); + let search_idx = listing.find("search.js").expect("search.js tag missing"); + assert!( + prefix_idx < search_idx, + "__PREFIX__ must be set before search.js loads" + ); + + // Keyboard-shortcuts JS reads __STATIC_MODE__ to hide dynamic-only entries + // and skip nav to nonexistent pages. + assert!( + listing.contains("window.__STATIC_MODE__ = true"), + "__STATIC_MODE__ must be set to true in static output" + ); + + let index = std::fs::read_to_string(out.join("index.html")).unwrap(); + assert!( + index.contains("https://cooklang.org/cli/"), + "static pages should include a 'Built with CookCLI' footer link" + ); +} + +#[test] +fn build_writes_menu_pages_without_dotmenu_suffix() { + let tmp = TempDir::new().unwrap(); + let out = tmp.path().join("_site"); + let seed = seed_dir(); + + Command::cargo_bin("cook") + .unwrap() + .args([ + "build", + out.to_str().unwrap(), + "--base-path", + seed.to_str().unwrap(), + ]) + .assert() + .success(); + + // Menu files (e.g. "Weekly Plan.menu") must land at "menu/.html", + // not "menu/.menu.html", so search-index URLs resolve. + assert!( + out.join("menu/Weekly Plan.html").is_file(), + "menu page should be at menu/.html (no .menu suffix)" + ); + assert!( + !out.join("menu/Weekly Plan.menu.html").exists(), + "menu page should not have a .menu.html suffix" + ); +} + +#[test] +fn static_output_omits_dynamic_ui() { + let tmp = TempDir::new().unwrap(); + let out = tmp.path().join("_site"); + let seed = seed_dir(); + + Command::cargo_bin("cook") + .unwrap() + .args([ + "build", + out.to_str().unwrap(), + "--base-path", + seed.to_str().unwrap(), + ]) + .assert() + .success(); + + let index = std::fs::read_to_string(out.join("index.html")).unwrap(); + + // Dynamic nav links to dynamic-only pages should be gone. + // We look for the rendered form to avoid matching unrelated + // CSS selectors that mention the same paths. + assert!( + !index.contains("href=\"./shopping-list\""), + "shopping-list nav link still present in static index" + ); + assert!( + !index.contains("href=\"./pantry\""), + "pantry nav link still present in static index" + ); + assert!( + !index.contains("href=\"./preferences\""), + "preferences nav link still present in static index" + ); + + // The dynamic server search fetch should be gone; the static search.js + // link should be in its place. + assert!( + !index.contains("/api/search"), + "api search reference remains in static index" + ); + assert!( + index.contains("/static/js/search.js"), + "static search.js link missing" + ); +} + +#[test] +fn build_internal_links_resolve_to_existing_files() { + let tmp = TempDir::new().unwrap(); + let out = tmp.path().join("_site"); + let seed = seed_dir(); + + Command::cargo_bin("cook") + .unwrap() + .args([ + "build", + out.to_str().unwrap(), + "--base-path", + seed.to_str().unwrap(), + ]) + .assert() + .success(); + + // Parse a directory listing and verify every anchor href that points + // into recipe/ menu/ or directory/ resolves to an actual file. + let listing = std::fs::read_to_string(out.join("directory/Breakfast.html")).unwrap(); + let re = regex::Regex::new(r##"href="\.\./([^"#?]+)""##).unwrap(); + let prefixes = ["recipe/", "menu/", "directory/"]; + let mut checked = 0; + for cap in re.captures_iter(&listing) { + let rel = &cap[1]; + if !prefixes.iter().any(|p| rel.starts_with(p)) { + continue; + } + let target = out.join(rel); + assert!( + target.is_file(), + "broken link in directory/Breakfast.html: '{rel}' -> {target:?}" + ); + checked += 1; + } + assert!( + checked > 0, + "no recipe/menu/directory links found in listing" + ); +} + +#[test] +fn build_twice_with_output_inside_source_does_not_recurse() { + // Regression: when the output dir lives inside the source dir + // (`cook build` from the recipe root with default `_site`), every run + // used to discover the previous run's generated files and copy them one + // level deeper, eventually hitting ENAMETOOLONG. + let tmp = TempDir::new().unwrap(); + // Use a non-hidden subdir; the image walker skips dotted directories, + // and `TempDir::new()` creates `.tmpXXXX`. + let source = tmp.path().join("recipes"); + std::fs::create_dir_all(&source).unwrap(); + + // Copy the seed into a writable scratch dir so we can build into it. + let seed = seed_dir(); + for entry in walkdir::WalkDir::new(&seed) { + let entry = entry.unwrap(); + let rel = entry.path().strip_prefix(&seed).unwrap(); + let dst = source.join(rel); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&dst).unwrap(); + } else { + std::fs::copy(entry.path(), &dst).unwrap(); + } + } + + let out = source.join("_site"); + for _ in 0..3 { + Command::cargo_bin("cook") + .unwrap() + .args([ + "build", + out.to_str().unwrap(), + "--base-path", + source.to_str().unwrap(), + ]) + .assert() + .success(); + } + + // No nested `_site/api/static/_site/...` should exist — that's the + // signature of the previous run's output being re-walked as input. + let nested = out.join("api/static/_site"); + assert!( + !nested.exists(), + "output should not contain a nested _site after repeated builds: {nested:?}" + ); +} diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index 8f7c1427..0bd153dc 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -131,9 +131,12 @@ test.describe('Navigation', () => { }); test('should use recipe filename in URLs instead of display names', async ({ page }) => { - // Check the recipe with title 'Sicilian-style Scottadito Lamb Chops' and filename 'lamb-chops.cook' + // Check the recipe with title 'Sicilian-style Scottadito Lamb Chops' and filename 'lamb-chops.cook'. + // Recipe URLs use the bare stem (no .cook extension) — the title would be + // 'Sicilian-style Scottadito Lamb Chops', so 'lamb-chops' in the URL proves + // the filename is used, not the title. - const simpleRecipeCard = page.locator('a[href="/recipe/lamb-chops.cook"]'); + const simpleRecipeCard = page.locator('a[href="/recipe/lamb-chops"]'); await expect(simpleRecipeCard).toBeVisible(); const recipeName = await simpleRecipeCard.locator('h3').textContent(); @@ -143,7 +146,7 @@ test.describe('Navigation', () => { await page.waitForLoadState('networkidle'); // Verify we're on the correct recipe page - expect(page.url()).toContain('/recipe/lamb-chops.cook'); + expect(page.url()).toContain('/recipe/lamb-chops'); const recipeTitle = page.locator('h1, h2').first(); await expect(recipeTitle).toContainText('Sicilian-style Scottadito Lamb Chops'); }); diff --git a/tests/snapshots/snapshot_test__help_output.snap b/tests/snapshots/snapshot_test__help_output.snap index ef61c388..fb0972a8 100644 --- a/tests/snapshots/snapshot_test__help_output.snap +++ b/tests/snapshots/snapshot_test__help_output.snap @@ -10,6 +10,7 @@ Usage: cook [OPTIONS] Commands: recipe Parse, validate and display recipe files in various formats server Start a local web server to browse and view your recipe collection + build Generate a self-contained static website from your recipe collection shopping-list Generate a combined shopping list from multiple recipes [aliases: sl] seed Initialize a directory with example Cooklang recipes search Search through your recipe collection for matching text diff --git a/tests/snapshots/snapshot_test__help_output_no_update.snap b/tests/snapshots/snapshot_test__help_output_no_update.snap index a991815b..fdb15b69 100644 --- a/tests/snapshots/snapshot_test__help_output_no_update.snap +++ b/tests/snapshots/snapshot_test__help_output_no_update.snap @@ -10,6 +10,7 @@ Usage: cook [OPTIONS] Commands: recipe Parse, validate and display recipe files in various formats server Start a local web server to browse and view your recipe collection + build Generate a self-contained static website from your recipe collection shopping-list Generate a combined shopping list from multiple recipes [aliases: sl] seed Initialize a directory with example Cooklang recipes search Search through your recipe collection for matching text