This cookbook is build on the Bridgetown static site generator.
bin/bt devThe tests are completely separate to Bridgetown, and were used to enable the migration both of framework and hosting provider.
npm testThree different ways:
- Generate a skeleton —
bundle exec rake 'recipe[Crispy Tofu Bowl]'writessrc/_recipes/crispy_tofu_bowl.mdwith today's date and the canonical frontmatter shape, ready to fill in. The slug is normalised from the argument; quotes are required in zsh so the brackets aren't globbed. - Scrape from a URL —
npm run scrape -- https://…extracts JSON-LDRecipeschema and writes a draftsrc/_recipes/<slug>.mdfor you to refine. - Hand-edit YAML — copy
src/_recipes/focaccia.mdas a template.
Each recipe is classified along four axes:
---
name: Focaccia
cuisine: Italian # one of: British, Italian, Mexican, ...
meal: [Side] # Main, Lunch, Breakfast, Side, Snack, Sweet, Drink, Condiment
effort: weekend # weeknight | weekend | project
tags: [bread, baking, vegan] # free-form, lowercase
status: favourite # optional: favourite | faded | untried
servings: 4 # optional integer; see "Scaling" below
---- cuisine — single value, the dominant cuisine.
- meal — array; one or more of the values above.
- effort —
weeknight(≤1h, hands-on),weekend(1–4h or one involved step),project(overnight ferment, multi-day, etc.). - status — optional; how the recipe is faring in the kitchen.
favourite(family favourite),faded(fallen out of favour),untried(added but not yet a hit). Omit to leave unclassified. Browse at/by-status/; shown as a badge on cards. - tags — cross-cutting:
bread,pasta,vegan,vegetarian,pizza,sous-vide,slow-cook,salad,soup,pie,eggs,salsa,dessert,sweet,baking,coffee,preserves,winter,grandma-bo,base-recipe. - servings — integer; optional. When set, the recipe-page stepper scales by people (e.g. "Serves [- 4 +]"). When omitted — typical for breads, batch sweets, drinks, condiments — it scales by multiplier (×½, ×1, ×2). A planned meal-plan / shopping-list feature will aggregate quantities across recipes;
servingslets it scale by household size. - variant_of — optional slug of a canonical sibling recipe (e.g.
quick_brioche_burger_bunssetsvariant_of: slow_brioche_burger_buns). Renders an "Other versions" block on both the variant and the canonical. Single level only — the target must not itself be a variant.
Two relationships are derived automatically and need no fields:
- More like this — recipes that link the same
base-recipe-tagged sub-recipe (e.g. the topping pizzas sharing the Gozney dough) cross-link each other. - Used in — a
base-recipepage lists the dishes that link it.
Both recipeIngredient and recipeInstructions are lists of sections. The heading is optional — omit it for an unnamed single section.
recipeIngredient:
- items:
- { quantity: 500, unit: g, item: strong white flour }
- { quantity: 2, unit: tsp, item: salt }
recipeInstructions:
- items:
- Mix the dough...
- Bake for 20 minutes...Sectioned form (used by pizza, caesar, sourdough, etc.):
recipeIngredient:
- heading: Dough
items: [...]
- heading: Toppings
items: [...]Each ingredient hash supports a couple of optional flags beyond the
core quantity / unit / item:
- quantity: 2
item: kaffir lime leaves
optional: true # renders an OPTIONAL pill next to the name
- quantity: 1
unit: ball
item: "[pizza dough](pizza_dough_gozney.html)"
uses_fraction: 0.2 # this recipe uses 1 of 5 dough ballsoptional: true— renders a small OPTIONAL pill next to the ingredient name. The validator (bundle exec rake validate) refuses the word "optional" embedded inside item text, so the pill is the canonical signal.uses_fraction— when an ingredient inlines another recipe (markdown link toslug.html), declare what portion of the sub-recipe's batch this recipe uses. The recipe-page stepper multiplies the inlined ingredients byfactor × uses_fraction, and the sub's summary line shows e.g. "make ½ batch" — updating as the parent scales. Omituses_fractionfor sub-recipes whose full batch is used.
This site is a single-author cookbook. Recipes are written and reviewed by Ryan; there is no contributor flow, no recipe import path, and no CMS. That single trust assumption underpins two design choices worth calling out so future-me (or anyone) doesn't loosen them by accident:
- Markdown allows raw HTML. Both
_partials/_instruction_list.erband_partials/_ingredient_item.erbmarkdownify recipe text and pass the output throughsafe(...). Bridgetown uses Kramdown by default, and Kramdown permits inline raw HTML —<em>except</em>insrc/_recipes/bean_ragout.mdis the one legitimate use. A<script>in a recipe Markdown file would execute on the recipe page and on the homepage search excerpt. Do not accept third-party recipe PRs and do not add a "scrape from URL" flow that auto-commits without a sanitiser landing first (Loofah or Sanitize, scoped to deny<script>,<iframe>, event-handler attributes, andjavascript:URLs). recipe.urlin the plan JSON.src/plan.erbinlines a JSON blob of every recipe (slug, name, url, ...). Theurlis currentlyr.relative_url— a Bridgetown-computed path, not user input. The client-side renderer (frontend/javascript/lib/plan.js) passes every URL throughsafeHref()before placing it in anhref=, which rejects anything that isn't a site-relative path orhttp(s)://…. If you ever populateurlfrom an external source,safeHrefis the last line of defence — keep it.
Other relevant defences in code; don't loosen without thinking:
Content-Security-Policyinsrc/_headers. Headers stay strict (script-src 'self' 'wasm-unsafe-eval'); the theme + plan-mode bootstrap lives insrc/assets/theme-bootstrap.jsas a static file rather than inline so the CSP doesn't need a per-edit SHA-256 hash (Netlify's HTML minifier rewrites whitespace inside inline<script>blocks, invalidating any pre-computed hash).wasm-unsafe-evalis the narrow grant required for Pagefind's search index; broaderunsafe-evalis not.- The plan-share import flow (URL hash →
decodePlan) validates schema version, payload shape, and per-entry types, and caps the decompressed JSON at 200 KB to defuse lz-string zip-bombs. - Search excerpts from Pagefind are HTML-escaped except for
<mark>/</mark>(the highlight tags), viasafeExcerptinsearch.js.
- Mobile ingredients peek sheet. On narrow viewports the recipe
page's ingredients block is pinned to the bottom of the viewport
with only the heading visible above the fold; tapping the heading
slides the full list up. On desktop the same markup renders as a
sticky sidebar. Driven by
frontend/javascript/lib/ingredients-sheet.js, gated onhtml.jsso a no-JS visit falls back to an in-flow ingredients section. - Tick + Reset. Tap an ingredient row (recipe page) or a row in
the meal plan's shopping list to strike it through. State is
persisted per-recipe (
cookbook.ticks.{slug}) and per-plan (cookbook.plan-ticks) inlocalStorage. When at least one item is ticked, a small RESET button takes the count's slot in the heading; tapping it clears the ticks and brings the count back. Stale shop ticks (items no longer in the aggregated list) are pruned on render. Ticking a parent ingredient strikes its inlined sub-recipe items via a CSS cascade; un-ticking the parent leaves individually-ticked sub-items struck.
Recipe hero images live in src/images/. After dropping a new master in
(named <slug>.jpg or <slug>-2160w.jpg), run:
bundle exec rake imagesImageMagick resizes it into responsive variants (360 / 720 / 1280 /
2160w), skipping any width larger than the master and any output already
newer than its source. Commit the generated files. The recipe layout
emits a <img srcset> covering whichever variants exist on disk, so
browsers pick the right size without further wiring. External image
URLs (e.g. BBC food images) pass through unchanged with no srcset.
-
Phase 3 — Ocado integration. Map shopping-list items to Ocado SKUs (favourites, pack counts) so a basket can be assembled. Smart-paste of an existing basket is the proposed starting point.
-
Phase 4 — Auto-populate basket. Once mapping is in place, push the resolved basket to Ocado via their API or a scripted flow, with a review step before checkout. Blocked on Phase 3.