Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
e0c6eae
docs: add design for shopping-list pantry flags
dubadub May 15, 2026
8686823
docs: add implementation plan for shopping-list pantry flags
dubadub May 15, 2026
56eabfd
docs: add design for static site build command
dubadub May 15, 2026
d94f365
docs: add implementation plan for static site build command
dubadub May 15, 2026
634e2bf
feat(build): add cook build command skeleton
dubadub May 15, 2026
a4977e3
style: cargo fmt
dubadub May 15, 2026
60ae9a4
feat(build): resolve source and output paths
dubadub May 15, 2026
eb391fc
refactor(server): add static_mode field to template structs
dubadub May 15, 2026
9fdc4bb
feat(templates): gate dynamic nav and search behind static_mode
dubadub May 15, 2026
cbf7644
feat(templates): gate dynamic actions in recipe/menu/recipes templates
dubadub May 15, 2026
ca3084b
feat(templates): append .html to internal links in static_mode
dubadub May 15, 2026
bb07c06
refactor(server): extract template builders for reuse
dubadub May 15, 2026
b879b4e
feat(build): add relative-prefix helper
dubadub May 15, 2026
d78c98e
feat(build): add output writer with nested dir support
dubadub May 15, 2026
a8e8c1e
feat(build): copy embedded static assets to output
dubadub May 15, 2026
698bc89
feat(build): render index and directory listings
dubadub May 15, 2026
244e6f3
feat(build): render recipe and menu pages
dubadub May 15, 2026
70e9e74
fix(build): use file name not title for recipe URL paths
dubadub May 15, 2026
8cdbc00
feat(build): copy recipe images to output
dubadub May 15, 2026
034371d
test(build): assert specific image lands in output and improve error …
dubadub May 15, 2026
f314c42
feat(build): generate client-side search index JSON
dubadub May 15, 2026
7aaea9d
refactor(build): drop unused Result and log search-index parse failures
dubadub May 15, 2026
ee81a53
feat(build): add client-side search script
dubadub May 15, 2026
79142a9
fix(build): drop .menu suffix from URLs and gate dynamic CSS
dubadub May 15, 2026
627a637
fix(build): strip recipe extensions and route menus to menu/ in stati…
dubadub May 15, 2026
6da2085
fix(build): set __PREFIX__ before loading search.js
dubadub May 16, 2026
a3d94c3
fix(build): hide dynamic shortcuts in static-site keyboard help
dubadub May 16, 2026
e280231
Merge remote-tracking branch 'origin/main' into feat/static-site-build
dubadub May 16, 2026
3789424
docs: document `cook build` command
dubadub May 16, 2026
06bb578
feat(build): add 'Built with CookCLI' footer to generated static site
dubadub May 16, 2026
3eae247
feat(build): expose .cook source as a download on static recipe pages
dubadub May 16, 2026
53f63eb
fix(build): don't double `.cook` extension in download link
dubadub May 16, 2026
3db53b2
test: update no-default-features help snapshot for `build` command
dubadub May 16, 2026
2a76afb
feat(build): --lang flag, schema.org JSON-LD, footer link
dubadub May 16, 2026
a19dcd3
test(e2e): update lamb-chops URL after extension stripping
dubadub May 16, 2026
6f1077e
fix(build): address PR review blockers
dubadub May 16, 2026
6f0d57b
fix(build): skip output subtree when building into source dir
dubadub May 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions docs/build.md
Original file line number Diff line number Diff line change
@@ -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 <PATH>` | Root directory containing recipe files (default: current directory) |
| `--base-url <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/<path>.html` | One listing page per subdirectory |
| `recipe/<path>.html` | One page per `.cook` recipe (URL uses the file stem, not the title metadata) |
| `recipe/<path>.cook` | Raw `.cook` source for each recipe — exposed as a download link on the recipe page |
| `menu/<path>.html` | One page per `.menu` file |
| `api/static/<path>` | 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hide the language section
https://gitlab.com/pinage404/moku/-/blob/c780cfeb36ff172f3fcea9ade32a66e19c3c159e/custom.css#L5

I kept the rest of the page to keep the links to cooklang to promote the project with backlinks https://pinage404.gitlab.io/moku/preferences/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this
image

- 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.
18 changes: 17 additions & 1 deletion src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
72 changes: 72 additions & 0 deletions src/build/index.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
use serde::Serialize;

#[derive(Serialize)]
pub struct SearchEntry {
pub title: String,
pub path: String,
pub tags: Vec<String>,
pub ingredients: Vec<String>,
}

/// Build a flat list of search entries by walking the recipe tree.
pub fn build_search_index(tree: &cooklang_find::RecipeTree) -> Vec<SearchEntry> {
let mut out = Vec::new();
collect(tree, String::new(), &mut out);
out
}

fn collect(tree: &cooklang_find::RecipeTree, prefix: String, out: &mut Vec<SearchEntry>) {
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);
}
}
}
53 changes: 53 additions & 0 deletions src/build/links.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>()
.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")),
"../../.."
);
}
}
Loading
Loading