-
Notifications
You must be signed in to change notification settings - Fork 95
feat: add cook build static-site generator #344
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 8686823
docs: add implementation plan for shopping-list pantry flags
dubadub 56eabfd
docs: add design for static site build command
dubadub d94f365
docs: add implementation plan for static site build command
dubadub 634e2bf
feat(build): add cook build command skeleton
dubadub a4977e3
style: cargo fmt
dubadub 60ae9a4
feat(build): resolve source and output paths
dubadub eb391fc
refactor(server): add static_mode field to template structs
dubadub 9fdc4bb
feat(templates): gate dynamic nav and search behind static_mode
dubadub cbf7644
feat(templates): gate dynamic actions in recipe/menu/recipes templates
dubadub ca3084b
feat(templates): append .html to internal links in static_mode
dubadub bb07c06
refactor(server): extract template builders for reuse
dubadub b879b4e
feat(build): add relative-prefix helper
dubadub d78c98e
feat(build): add output writer with nested dir support
dubadub a8e8c1e
feat(build): copy embedded static assets to output
dubadub 698bc89
feat(build): render index and directory listings
dubadub 244e6f3
feat(build): render recipe and menu pages
dubadub 70e9e74
fix(build): use file name not title for recipe URL paths
dubadub 8cdbc00
feat(build): copy recipe images to output
dubadub 034371d
test(build): assert specific image lands in output and improve error …
dubadub f314c42
feat(build): generate client-side search index JSON
dubadub 7aaea9d
refactor(build): drop unused Result and log search-index parse failures
dubadub ee81a53
feat(build): add client-side search script
dubadub 79142a9
fix(build): drop .menu suffix from URLs and gate dynamic CSS
dubadub 627a637
fix(build): strip recipe extensions and route menus to menu/ in stati…
dubadub 6da2085
fix(build): set __PREFIX__ before loading search.js
dubadub a3d94c3
fix(build): hide dynamic shortcuts in static-site keyboard help
dubadub e280231
Merge remote-tracking branch 'origin/main' into feat/static-site-build
dubadub 3789424
docs: document `cook build` command
dubadub 06bb578
feat(build): add 'Built with CookCLI' footer to generated static site
dubadub 3eae247
feat(build): expose .cook source as a download on static recipe pages
dubadub 53f63eb
fix(build): don't double `.cook` extension in download link
dubadub 3db53b2
test: update no-default-features help snapshot for `build` command
dubadub 2a76afb
feat(build): --lang flag, schema.org JSON-LD, footer link
dubadub a19dcd3
test(e2e): update lamb-chops URL after extension stripping
dubadub 6f1077e
fix(build): address PR review blockers
dubadub 6f0d57b
fix(build): skip output subtree when building into source dir
dubadub File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| - 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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")), | ||
| "../../.." | ||
| ); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like this
