Skip to content

feat: add cook build static-site generator#344

Merged
dubadub merged 37 commits into
mainfrom
feat/static-site-build
May 16, 2026
Merged

feat: add cook build static-site generator#344
dubadub merged 37 commits into
mainfrom
feat/static-site-build

Conversation

@dubadub
Copy link
Copy Markdown
Member

@dubadub dubadub commented May 15, 2026

Summary

Adds a new cook build command that generates a self-contained static website from a Cooklang recipe collection. Output mirrors the existing web UI for browsing, recipe pages, menus, and search, but excludes dynamic features (shopping list, pantry, edit, preferences, sync, scaling). The result is hostable on any static-file host or browsable via file://.

Screenshot 2026-05-16 at 06 36 03

Approach

  • New src/build/ module with mod.rs, renderer.rs, writer.rs, links.rs, index.rs.
  • Reuses existing Askama templates via a static_mode: bool field on template structs. Server and build share template-builder code in src/server/builders.rs.
  • Recipe/menu URLs use the file stem (not the title metadata or the raw filename with extension). Menu files render to menu/<name>.html; recipes render to recipe/<path>.html.
  • Client-side search via static/js/search.js + a generated static/search-index.json (title=3, tags=2, ingredients=1 scoring).
  • Images alongside recipes are copied into api/static/<rel>/ to match existing image URL conventions.

Tests

11 integration tests in tests/build.rs:

  • Help, output-dir creation, index + static asset rendering
  • Recipe pages, menu pages (no .menu.html suffix), title-metadata regression
  • Search index shape + Risotto regression entry, search.js presence
  • Image copying with specific seed file
  • Static output omits dynamic UI (shopping-list/pantry/preferences nav, /api/search fetch)
  • Internal links in directory listings resolve to actual files (URL-extension regression)

Full suite green: cargo test, cargo fmt, cargo clippy all clean.

Test plan

  • cook build _site --base-path ./seed produces a browsable site
  • Open _site/index.html in a browser; navigation, search, and recipe scaling-free pages work
  • Verify no shopping-list, pantry, preferences, or /api/search references remain
  • Verify menu pages render at menu/<name>.html and link correctly from listings

Known follow-ups (non-blocking)

  • static/js/search.js: add escapeHtml(href) for defense-in-depth XSS hardening
  • src/build/mod.rs::walkdir_utf8: add symlink loop guard or switch to walkdir crate
  • Consolidate .cook/.menu stem-stripping into a single helper

dubadub added 30 commits May 15, 2026 10:29
Adds spec for --pantry and --ignore-pantry flags on the shopping-list
command, mirroring the existing --aisle handling.
Introduces the `cook build` subcommand stub with `BuildArgs` (output_dir,
--base-path, --base-url), wires it into args.rs/main.rs/lib.rs, and
adds the integration test plus updated help-output snapshot.
Two static-mode fixes discovered during verification:

- renderer wrote menu pages at "menu/<name>.menu.html" because it only
  stripped ".cook"; the search index pointed at "menu/<name>.html", so
  every menu URL 404'd. Strip ".menu" too.
- base.html had two CSS rule sets that referenced the shopping-list
  and preferences nav hrefs unconditionally; the rules are dead in
  static mode but the strings leaked into the output. Gate the extra
  selectors behind `static_mode` so the rendered HTML actually omits
  them.

Adds two integration tests: one regression for the menu URL bug, and
one safety net asserting the static index omits dynamic nav links
and includes the static search.js link.
…c URLs

Static-site directory listings linked to recipe/<name>.cook.html and
recipe/<name>.menu.html, both of which 404 because the renderer writes
to recipe/<name>.html and menu/<name>.html respectively.

- Strip .cook/.menu from item paths in build_recipes_template so URLs
  use the bare stem in both server and static modes.
- Strip .menu from TodaysMenu.menu_path returned by find_todays_menu.
- In recipes.html, route menu items to /menu/ (static mode only) for
  the Today's Menu CTA and the recipe/menu cards; server mode still
  uses /recipe/ which already handles both extensions.
- Drop hardcoded .cook from recipe-reference URLs in menu.html so they
  match the new convention.
- Add an integration test that parses directory/Breakfast.html and
  asserts every recipe/menu/directory href resolves to a real file.
The search.js IIFE snapshots window.__PREFIX__ at script-tag execution.
The prefix assignment was emitted *after* the script tag, so the IIFE
saw undefined and fell back to ".", which produced URLs like
/directory/static/search-index.json (404) from any non-root page.

Move the assignment ahead of the script tag in static mode. The original
post-script assignment is kept for non-static (server) mode where other
scripts may read it later.

Add regression test asserting __PREFIX__ appears before search.js in
generated directory pages.
The keyboard-shortcuts modal listed shopping list, pantry, preferences,
edit, scale, and clear-list entries even on static sites where those
pages and actions don't exist. The g s/g p/g x bindings would also
navigate to nonexistent URLs.

Expose `window.__STATIC_MODE__` in base.html and let the shortcuts JS:
- omit dynamic-only rows from the help modal
- skip the corresponding key handlers (g s/p/x, e, a, +, -, [, ])

Add a build test asserting __STATIC_MODE__ is set to true in static output.
# Conflicts:
#	src/server/ui.rs
#	templates/recipes.html
dubadub added 3 commits May 16, 2026 07:15
Copies each .cook file to recipe/<path>.cook in the output and renders
a download button on the recipe page so visitors can grab the canonical
source alongside the rendered HTML.
The static renderer passed recipe_path with the `.cook`/`.menu` suffix
still attached, so the template's `{{ recipe_path }}.cook` produced
`Recipe.cook.cook` — which 404s and the browser saved the response as
an .html file. Trim the suffix before passing in, matching the server
convention. Tighten the test to assert the exact href.
@cooklang cooklang deleted a comment from claude Bot May 16, 2026
@cooklang cooklang deleted a comment from claude Bot May 16, 2026
@cooklang cooklang deleted a comment from claude Bot May 16, 2026
@cooklang cooklang deleted a comment from claude Bot May 16, 2026
dubadub added 2 commits May 16, 2026 08:06
- Add `--lang` CLI flag to `cook build` so the generated site can be
  rendered in any of the supported UI locales (en-US, de-DE, nl-NL,
  fr-FR, es-ES, eu-ES, sv-SE). Re-exports `parse_supported_language`
  from the server module so the build path validates the same set.
- Emit a `<script type="application/ld+json">` block on every recipe
  page with schema.org Recipe markup (name, description, image,
  author, recipeYield, prepTime/cookTime/totalTime as ISO 8601,
  recipeIngredient, recipeInstructions, recipeCategory,
  recipeCuisine, keywords). Helps search engines surface recipes
  from sites built with `cook build`.
- Point the "Built with CookCLI" footer link to https://cooklang.org/cli/
  so users land on the docs rather than the GitHub repo.

Tests cover --lang en/de happy path, unsupported tag rejection, and
the presence of the JSON-LD block with @type Recipe and core fields.
Commit 627a637 dropped the .cook/.menu suffix from recipe URLs in both
server and static modes (e.g. /recipe/lamb-chops, not /recipe/lamb-chops.cook).
This navigation test still asserted the old href, so the link locator
timed out on the home page. The intent of the test — confirming URLs
use the file stem rather than the title — is preserved: 'lamb-chops'
in the URL still proves it isn't 'sicilian-style-scottadito-lamb-chops'.
@claude
Copy link
Copy Markdown

claude Bot commented May 16, 2026

Code Review — PR #344: cook build static-site generator

Overview

This is a substantial, well-structured feature addition. The static_mode: bool flag approach for template reuse is clean and minimal, the builders.rs extraction correctly avoids code duplication between the server and the build path, and the 11 integration tests provide solid coverage of the key user-facing invariants. The feature is architecturally sound. Below are specific things that should be addressed before merging, followed by lower-priority observations.


Issues to Address

1. Planning/spec docs committed to the main codebase

docs/superpowers/plans/ and docs/superpowers/specs/ contain implementation planning artifacts (1774 and 272 lines of scaffolding instructions). These shouldn't live in the shipped codebase — they're historical development artifacts. Remove them from the PR before merging.

2. regex dev-dependency used only for one test

regex = "1" is added for a single test (build_internal_links_resolve_to_existing_files). That test finds hrefs matching href="../(recipe|menu|directory)/..." — achievable with str::contains + manual string slicing without a regex crate. The dependency is harmless but adds to compile time. Consider simplifying the test to avoid it, or keep the crate if you anticipate more regex use later.

3. Double output for the initial "Building…" line

In src/build/mod.rs:

tracing::info!("Building static site from {source} into {output}");
println!("Building static site from {source} into {output}");

When RUST_LOG=info is active users see this line twice. Other commands don't double-print like this. Remove the tracing::info! call (or the println!).

4. search.js: unescaped href construction (XSS)

var href = prefix + "/" + m.path;
return '<a href="' + href + '" …>' + escapeHtml(m.title) + '</a>';

prefix (from window.__PREFIX__) and m.path (from the search index) are both injected into raw HTML without escaping. The PR author already flagged this in the known follow-ups. It's defense-in-depth for build-time controlled values, but the fix is a one-liner (escapeHtml(href)) and should ship with the feature rather than as a follow-up.

5. walkdir_utf8 has no symlink cycle guard

fn walkdir_utf8(root: &camino::Utf8Path) -> Result<Vec<camino::Utf8PathBuf>> {
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {

Symlink cycles cause an infinite loop. Also flagged in the known follow-ups — worth resolving before merge. The walkdir crate (already available in the dependency tree via cooklang-find) handles this automatically.


Lower-Priority Observations

6. Performance: build_recipes_template rebuilds the tree per directory

In renderer.rs, render_directory calls build_recipes_template, which calls cooklang_find::build_tree(&search_path) for every subdirectory. For a recipe collection with many directories this hits the filesystem repeatedly. The full tree is already in memory in build::run. Passing a subtree/slice to the builder would avoid the redundant I/O, though it requires a refactor of RecipesBuildInput.

7. new_recipe_url is computed unconditionally in static mode

In builders.rs:

let new_recipe_url = match sub_path {};

This string is allocated and formatted even though {% if !static_mode %} in the template always discards it. Add RecipesTemplate::new_recipe_url as Option<String> or skip the computation when static_mode is true.

8. Minor: get_base_path() is a thin wrapper over a public field clone

pub fn get_base_path(&self) -> Option<Utf8PathBuf> {
    self.base_path.clone()
}

This pattern was copied from ShoppingListArgs. It's fine for consistency, but direct field access with .clone() at the call site would be cleaner if there's no intent to add logic here later.

9. Template inline conditionals grow long

Lines like this in recipes.html are hard to read at a glance:

<a href="{{ prefix }}/{% if static_mode %}{% if item.is_menu %}menu{% else %}recipe{% endif %}{% else %}recipe{% endif %}/{{ item.path }}{% if static_mode %}.html{% endif %}">

Consider extracting the URL into an Askama macro or helper method on the template struct to keep the template readable.


Test Coverage Assessment

The integration test suite is well-targeted:

  • build_writes_recipe_pages — checks content, download link, JSON-LD
  • static_output_omits_dynamic_ui — verifies template gating
  • build_internal_links_resolve_to_existing_files — walks real file hrefs and resolves them
  • build_renders_recipes_with_title_metadata — regression test for file-stem vs title URL
  • build_writes_menu_pages_without_dotmenu_suffix — regression for extension doubling

Unit tests for links::relative_prefix cover the edge cases well.


Summary

The feature is solid and the architecture is right. Block on: removing the planning/spec docs from the repo, fixing the escapeHtml(href) call in search.js, and addressing the symlink loop in walkdir_utf8. The other items are quality improvements worth considering but non-blocking.

@cooklang cooklang deleted a comment from claude Bot May 16, 2026
@cooklang cooklang deleted a comment from claude Bot May 16, 2026
@cooklang cooklang deleted a comment from claude Bot May 16, 2026
@cooklang cooklang deleted a comment from claude Bot May 16, 2026
@cooklang cooklang deleted a comment from claude Bot May 16, 2026
@cooklang cooklang deleted a comment from claude Bot May 16, 2026
@cooklang cooklang deleted a comment from claude Bot May 16, 2026
- search.js: escape `href` when rendering search results. `prefix` and
  `m.path` flow into raw HTML; while both are build-time controlled
  today, defense in depth is cheap and the existing helper makes the
  fix a one-liner.
- copy_all_images: replace the hand-rolled walkdir_utf8 with the
  walkdir crate. The previous implementation followed symlinks via
  `path.is_dir()` and would infinite-loop on cycles; walkdir defaults
  to not following symlinks, so loops are impossible.
- run(): drop the duplicate `tracing::info!` so `RUST_LOG=info` doesn't
  print the "Building static site..." banner twice.
- Drop the static-site and shopping-list-pantry-flags planning/spec
  artifacts under docs/superpowers/. These were development scaffolding,
  not user-facing docs.
@claude
Copy link
Copy Markdown

claude Bot commented May 16, 2026

Code Review: feat: add cook build static-site generator

Overview

This is a well-conceived PR that adds a cook build command for generating a self-contained static website. The implementation is clean and the core approach — a shared builders.rs module that both the server and the static renderer use — is solid design.


Code Quality

Wins:

  • Extracting ~600+ lines of duplicated logic from ui.rs into builders.rs is the right call and makes both the server and build paths easier to maintain.
  • src/build/ module structure (mod.rs, renderer.rs, writer.rs, links.rs, index.rs) is well-organized and follows the project's conventions.
  • links.rs::relative_prefix has inline unit tests — good practice.
  • Documentation in docs/build.md is thorough and accurate.

Issues:

  1. Metadata regression (silent behaviour change for the server). The old recipe_page handler in ui.rs supported fallback keys for time:

    // old code in ui.rs
    time: get_field("time").or_else(|| get_field("duration")).or_else(|| get_field("time required")),

    The new build_recipe_template in builders.rs only does:

    time: get_field("time"),

    Since ui.rs now delegates to builders.rs, any recipes that used duration or time required metadata will silently stop showing that field — even in the live server. This is a behaviour regression that affects existing users and has nothing to do with static mode.

  2. count_recipes_tree always returns Some. The return type is Option<usize> but the function body unconditionally returns Some(count). There is no early-return None path. Either change the return type to usize or document why Option is needed.

  3. static_mode added to templates that will never render in static mode. ShoppingListTemplate, PreferencesTemplate, PantryTemplate, EditTemplate, and NewTemplate all receive the new static_mode: bool field, but the static-site generator never renders any of those pages. Adding dead fields to those structs makes the code misleading. It would be cleaner to add static_mode only to the templates that actually use it (RecipesTemplate, RecipeTemplate, MenuTemplate, ErrorTemplate).

  4. Duplicate ingredient-formatting logic. The block that formats a GroupedQuantity into (formatted_quantity, formatted_unit) appears identically at least twice in builders.rs (once for the global ingredient list, once for section ingredients). Extracting a helper like format_grouped_quantity(q: &GroupedQuantity) -> (Option<String>, Option<String>) would reduce the duplication.

  5. info!-level logs in a hot per-request path. These two lines in build_recipe_template emit at info level, which means they appear in normal server output (not just debug mode), for every recipe page request:

    tracing::info!("Looking for recipe at path: {}, extension: {:?}", ...);
    tracing::info!("Recipe path: {}, actual_path: {:?}, is_menu: {}", ...);

    These were likely debugging aids; they should be tracing::debug! or removed.


Performance

  • Double-parse per recipe. build_search_index calls parse_recipe_from_entry for each recipe, and then walk_recipes calls it again for rendering. For large collections this means parsing every recipe twice. Consider passing the parsed recipe through or building the search index entries during the render pass.

Security

  • base_url is used verbatim in HTML attributes. A user supplying --base-url 'javascript:alert(1)' would generate HTML like href="javascript:alert(1)/index.html". This is a CLI tool used locally so the blast radius is effectively zero (you'd have to attack yourself), but it's worth sanitising or at minimum documenting that --base-url expects an HTTP/HTTPS prefix.

  • to_iso8601_duration passthrough check is too permissive. The guard:

    if trimmed.starts_with('P') && trimmed.chars().all(|c| c.is_ascii_alphanumeric()) {
        return Some(trimmed.to_string());
    }

    accepts strings like PHELLO as valid ISO 8601 durations. This doesn't cause a security problem (the value goes into a JSON-LD <script> block), but it could produce invalid schema.org output. Valid durations only contain P, T, digits, and YMWDHS.

  • escapeHtml in search.js is correctly applied to both the href and the displayed title. The known follow-up the PR description mentions (escapeHtml(href)) is already done — no action needed there.


Tests

The 11 integration tests in tests/build.rs cover the most important paths (index rendering, recipe/menu pages, search index shape, image copying, static-mode omissions, and link resolution). That's a solid baseline for a new command.


Minor / Nits

  • BuildArgs::get_base_path clones the Utf8PathBuf but is only called once and could simply take ownership or return a reference. Not blocking.
  • The summary line "Wrote index, directories, {recipe_count} recipe pages, ..." counts .menu files in recipe_count, so the label is slightly inaccurate.
  • The regex = "1" dev-dependency added in Cargo.toml is used in tests/build.rs. Looks fine, just noting it's test-only.

Summary

The PR is well-structured and the builders.rs extraction is a clear improvement. The metadata regression (item 1 above) is the most important thing to fix before merging since it silently changes behaviour for existing server users. The info!-level logging (item 5) should be fixed before this lands in a release build. The rest are suggestions.

When the output directory lives inside the source directory (the common
case: `cook build` from the recipe root with default `_site`), every
subsequent build discovered the previous run's generated files and
copied them one level deeper. After a handful of runs the path got long
enough that the OS rejected it with ENAMETOOLONG.

Prune the output subtree from the recipe tree before walking, and
filter the image walker so it never descends into the output directory.
@claude
Copy link
Copy Markdown

claude Bot commented May 16, 2026

Code Review: feat: add cook build static-site generator

Overview

This PR adds a cook build command that generates a self-contained static website from a Cooklang recipe collection. The core architectural choice — extracting template-building logic from ui.rs into a shared builders.rs — is sound and well-executed. The ~1000-line deletion in ui.rs is almost entirely duplication removal, which is the right move.


Bugs / Behavioral Regressions

1. Metadata time field fallbacks dropped in the builder

The old ui.rs handler resolved time with additional fallbacks:

// Old ui.rs
time: get_field("time")
    .or_else(|| get_field("duration"))
    .or_else(|| get_field("time required")),

The new builders.rs drops those fallbacks:

// New builders.rs
time: get_field("time"),

Any recipe using >> duration: or >> time required: metadata will now show no time on its page. This is a silent regression for existing recipes.

2. Metadata prep_time/cook_time inconsistency between recipe and menu builders

build_recipe_template (line ~1513) looks up time.prep and time.cook:

prep_time: get_field("prep time").or_else(|| ... ).or_else(|| get_field("time.prep")),
cook_time: get_field("cook time").or_else(|| ... ).or_else(|| get_field("time.cook")),

build_menu_template_inner (line ~1763) does not. The two builders should be consistent.

3. to_iso8601_duration has no tests

This is the most complex new function in the PR — a hand-rolled time string parser that feeds schema.org structured data. Inputs like "1.5 hours", "90min", "1h 30m", and "P1H" (already-ISO passthrough) each exercise different branches. There are no tests for it. Given that it silently returns None on unrecognised input, a wrong parse would simply drop the field without warning. A few #[test] cases here would give confidence.


Code Quality

4. count_recipes_tree always returns Some(n) but declares Option<usize>

fn count_recipes_tree(tree: &cooklang_find::RecipeTree) -> Option<usize> {
    let mut count = 0;
    // ... always increments ...
    Some(count)
}

The Option wrapper is never None. Return type should be usize and call sites should drop the .unwrap_or(0).

5. Info-level tracing in a hot production codepath

build_recipe_template emits two tracing::info! calls per recipe (path + actual_path). For a collection of 500 recipes, that's 1000 log lines at INFO even without --verbose. These should be tracing::debug!.

6. static_mode added to templates that can never be true in static mode

ShoppingListTemplate, PreferencesTemplate, PantryTemplate, EditTemplate, and NewTemplate all get static_mode: bool but cook build never renders these pages. The field is always false on those structs. It's harmless, but it adds noise to structs and constructors that have nothing to do with static mode. Consider whether these need the field at all, or whether the compiler/templates already gate on it.

7. regex dev-dependency — unclear usage

regex = "1" is added to [dev-dependencies] in Cargo.toml. I didn't find it used in tests/build.rs from the diff. If it's unused, remove it; if it's used elsewhere in tests it's fine, but it's worth double-checking.


Performance

8. build_recipes_template re-scans the filesystem per directory

renderer::render_directory calls build_recipes_template, which calls cooklang_find::build_tree(&search_path) on the subdirectory — a fresh filesystem walk per subdirectory. Meanwhile mod.rs already built a full tree at the top level and passes it to walk_directories. For large collections with deep nesting this is O(d × n) filesystem scans.

A more efficient approach: pass the already-built subtree node into the renderer instead of re-discovering from disk. This isn't a blocker but worth noting for follow-up.

9. Search index parses every recipe twice

build_search_index calls util::parse_recipe_from_entry on each recipe independently, after walk_recipes already parsed them all. For large collections this doubles parse time. Not blocking, but worth a comment or a follow-up issue.


Security

10. search.js XSS follow-up noted but not blocked

The PR description calls out: "static/js/search.js: add escapeHtml(href) for defense-in-depth XSS hardening". The href value used in search result links comes from the generated search-index.json, which is built from on-disk file names — so in practice the injection surface is whoever controls the recipe files. If those are attacker-controlled (e.g., someone shares a malicious recipe collection), the unescaped href could inject JS via a javascript: URL. This should be fixed before the feature is broadly advertised, not left as a follow-up.


Minor

  • trim_end_matches(".cook") operates character-by-character (Rust str pattern semantics), so it would incorrectly strip from a path ending in ...kcook. In practice only files surfaced by cooklang_find reach this code, so it's harmless — but strip_suffix(".cook").unwrap_or(s) would be more accurate and self-documenting.
  • The get_base_path() method on BuildArgs (3 lines) is a trivial pass-through clone; it could be removed in favour of direct field access since BuildArgs is the only caller's context.

What's working well

  • The refactor cleanly eliminates ~600 lines of duplication between ui.rs and what would otherwise be a parallel static-mode copy.
  • prune_output_subtree correctly handles the common case of _site nested inside the source tree.
  • walkdir (no symlink follow by default) fixes the infinite-loop risk mentioned in the previous walkdir_utf8 approach.
  • The 11 integration tests cover the right regressions (title metadata, search index shape, static nav omission, internal link extensions).
  • links.rs unit tests for relative_prefix are exactly the kind of targeted tests this logic needs.
  • schema.org JSON-LD generation is a nice bonus for SEO.

Summary

The architecture is correct and the deduplication is valuable. The two blocking items before merge are the time metadata regression (silent data loss for existing recipes) and the search.js XSS issue. The to_iso8601_duration test gap and the double-filesystem-scan are worth addressing before this feature sees wide use.

@dubadub dubadub merged commit f6703df into main May 16, 2026
6 checks passed
@dubadub dubadub deleted the feat/static-site-build branch May 16, 2026 13:13
Copy link
Copy Markdown
Contributor

@pinage404 pinage404 left a comment

Choose a reason for hiding this comment

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

Closes #186

Comment thread docs/build.md
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants