Belgian recipe book + smart grocery list. Pick the dishes you want to cook, and the app turns them into one merged shopping list, sorted by the aisles you actually walk through. Installable on your phone and fully usable offline — including inside a shop with no signal.
Vanilla HTML/CSS/JS. No build step, no framework, no runtime dependencies.
Recipes
- 295 recipes in three languages (EN / NL / FR), all the way down to ingredient names and units — including 44 Belgian classics written by hand: waterzooi, boulets à la liégeoise, konijn met pruimen, hutsepot, garnaalkroketten, kaaskroketten, blinde vinken, paling in 't groen, filet américain, croque monsieur, witloofsoep, rijsttaart, speculoos, peperkoek, dame blanche and the rest.
- Every recipe is written the way a person would tell you it, in all three languages — not "Peel potatoes and cut carrots and leeks" but why the pieces should match, what the pan should sound like, and which step you must not hurry. See "The voice" below.
- Search by title, subtitle or ingredient. Accents are folded, so "gaufres de liege" finds Gaufres de Liège.
- Filter by category, by diet (vegetarian, vegan, candida, keto), by allergen (gluten, nuts, dairy, eggs) and by favourites — the pills stack, so "my favourite desserts" works.
- Adjust servings in the recipe drawer and every quantity rescales. Countable things stay whole — you get 5 onions, never 4.3 — and "to taste" never scales.
- Cook Mode: full-screen, one step at a time, for when your hands are covered in flour. Arrow keys page through it.
- Create, edit and delete your own recipes. They are stored separately from the shipped ones, so updating the app can never wipe them.
- Diet and allergen flags are derived from the ingredient list, not asserted. They are a shopping aid, not a medical guarantee — an ingredient the dictionary does not recognise is assumed harmless.
Grocery list
- Select several recipes and generate one merged list, with a servings stepper per recipe.
- Merging happens on a canonical ingredient key, so "garlic" and "garlic cloves" become one line and the amounts add up. An ingredient that shows up in an incompatible unit gets its own line instead of being dropped.
- Sorted by supermarket aisle: produce → fish → butcher → dairy → bakery → frozen → herbs & spices → grocery → drinks.
- Each line remembers which recipes it came from ("2 onions — from Stoofvlees, Waterzooi").
- Pantry staples are skipped by default. Salt, pepper, oil, flour and spices don't clutter your list; they appear as one-tap chips at the top in case you actually ran out. Toggle the behaviour off in Settings.
- Tap any quantity to change it — "1,5 kg" becomes 1500 g, free text like "a handful" is kept as typed.
- Share the list as plain text (share sheet on mobile, clipboard on desktop).
- Progress bar and "clear checked" for shopping in the store.
Your data
- Everything lives in this browser's
localStorage, so Settings has Download a backup and Restore a backup — one JSON file with your recipes, list, favourites and settings. - The app works fully offline and installs to the home screen. When a new version is cached, a banner offers a reload rather than swapping files under a running session.
- Keyboard and screen-reader usable throughout: real buttons, focus trapping in dialogs, Escape to close, visible focus rings, and
prefers-reduced-motionhonoured. - Light by default, dark when you want it. Settings → How it looks offers "my phone decides", "always light" and "always dark". The choice is stamped on
<html>by a tiny inline script inindex.htmlbefore the stylesheet paints, so there is no flash of the wrong theme on load.
Any static file server works. The service worker and the manifest need a real
http:// origin, so opening index.html straight off disk gives you the app
but not offline mode or installation.
npx http-server -p 8080
# then open http://localhost:8080To use it on your phone, open http://<your-computer-ip>:8080 on the same
Wi-Fi, then "Add to home screen".
| File | What it is |
|---|---|
index.html |
The whole UI — four tabs, recipe drawer, cook mode, recipe editor |
app.js |
State, rendering, grocery logic, translations |
ingredients.js |
Canonical ingredient dictionary: names, aisles, units, staples, diet flags. Shared by the browser and the node scripts |
scripts/recipe_db.js |
Shared load/save for recipes.js plus duplicate detection, used by every importer |
scripts/measure.js |
Parses free-text measures — mixed numbers, unicode fractions, ranges, sized tins — into an amount and a unit |
scripts/units.js |
Imperial-to-metric for prose: the oven table, lengths, and volumes via ingredients.js |
scripts/convert_measures.js |
Rewrites the imperial left in instructions; run by apply_voice.js |
scripts/wikimedia.js |
Commons API access: search, licence checking, throttled download |
recipes.js |
The recipe database (window.initialRecipes) |
style.css |
The design system: two palettes behind one set of semantic tokens |
fonts/ |
Fraunces and Literata, bundled under the SIL OFL, with the licence beside them |
scripts/fetch_fonts.js |
Re-downloads those typefaces and rewrites fonts/fonts.css |
scripts/voice/ |
The rewritten recipe prose, one batch file per six recipes, applied by apply_voice.js |
sw.js / manifest.json |
Offline caching + home screen installation |
images/ |
Recipe photos and app icons, all local |
node scripts/smoke_test.js # app flows — run this before committing
node scripts/test_ingredients.js # the ingredient dictionary, table-driven
node scripts/test_units.js # the imperial-to-metric conversions, table-driven
node scripts/check_styles.js # every rendered class is styled, tags balance
node scripts/check_contrast.js # WCAG contrast of both palettes, and that they define the same tokens
node scripts/apply_voice.js --check # verify the recipe rewrites without writing
node scripts/apply_voice.js # apply scripts/voice/*.js to recipes.js
node scripts/convert_measures.js --check # report every imperial measure it would convert
node scripts/normalize_recipes.js # re-canonicalise every ingredient in recipes.js
node scripts/download_images.js # pull any remote recipe image into images/
pwsh scripts/resize_images.ps1 # downscale photos to the size actually shown
node scripts/make_icons.js # regenerate the PWA icons
node scripts/import_mealdb.js # quality-gate TheMealDB and pick a balanced mix
node scripts/fetch_photos.js # licensed Wikimedia photos for anything without one
node scripts/add_belgian_recipes.js # add the hand-written classics
node scripts/set_recipe_photo.js --search "waterzooi" # find a Commons photo
node scripts/set_recipe_photo.js <id> "File:X.jpg" # and pin it to a recipeRecipe photos come from Wikimedia Commons and only under a licence that
permits reuse. The photographer and licence are stored on the recipe and shown
over the photo in the drawer, which is what CC BY-SA asks for. To add a dish of
your own, write it into scripts/belgian_recipes.js or belgian_recipes_2.js
(split only for readability) and re-run the add script — it refuses duplicates,
so re-running is safe.
The automatic photo lookup takes the lead image of a Wikipedia article, which
is right about eight times in ten. Check the results and fix the rest with
set_recipe_photo.js.
Importers (need SPOONACULAR_API_KEY in the environment for the Spoonacular
sources — never hardcode a key):
export SPOONACULAR_API_KEY=...
node scripts/import_recipes.js --mealdb 52772
node scripts/import_recipes.js --url https://example.com/some-recipe
node scripts/bulk_import.js --query soup --count 20
node scripts/import_random.js --count 50Everything imported goes through ingredients.js, so imported recipes get the
same canonical names, metric units, aisles and staple flags as the hand-written
Belgian ones. Free-text measures ("1 1/2 cups", "½ tsp", "2-3 tbsp", "1 (12 oz.)
tin") are read by scripts/measure.js, which is shared by every importer — before
it existed, anything it could not parse silently became "pieces".
The 148 recipes added on top of the Belgian classics came in through a separate bulk pipeline, which is kept because it is the one to reuse next time:
node scripts/import_mealdb.js # quality-gate TheMealDB and pick a balanced mix
node scripts/fetch_photos.js # look each dish up on Wikimedia Commonsimport_mealdb.js fetches the whole free catalogue once, caches it, and then
rejects: no method, fewer than three steps, an ingredient list that does not
match the method, a title already in the book. What survives is selected against
a target mix so the book does not become all desserts.
fetch_photos.js is resumable and only takes photos under a licence that permits
reuse, crediting the photographer in the drawer. It also refuses images that do
not look like the dish — an early run gave "Beef Dumpling Stew" a photograph of a
Hong Kong restaurant front, so a relevance check now compares the article title
against the dish and rejects words like "raw", "dried", "plant" or "market".
Anything it cannot license is left without a photo, and the app draws a placeholder
tile rather than a broken image.
-
Bump the version in three places at once: the
?v=query strings inindex.htmlandASSET_VERSIONinsw.js. If they disagree, the service worker serves a stale mix of old and new files. -
Run both test suites.
node scripts/smoke_test.jsboots the realapp.jsagainst a DOM built fromindex.htmland checks the flows that are easy to break: merging, scaling, staples, backup/restore, custom-recipe persistence, accessibility markup, translation coverage and data integrity.node scripts/test_ingredients.jspins down the dictionary itself. -
Diet flags are generated. Don't hand-edit them in
recipes.js— change the patterns iningredients.jsand re-runscripts/normalize_recipes.js, which is idempotent. -
Colours live in tokens, and both themes define all of them. Nothing in sections 2–12 of
style.cssshould contain a literal colour. The two exceptions are deliberate and commented: type that sits over a recipe photograph uses--on-photo*, which does not flip between themes, because a photo is dark in its corners whichever palette is on.check_contrast.jsfails if the two palettes stop defining the same tokens.
Every description and instruction in the book is written as though someone who has cooked the dish is stood next to you — what to look for, what not to hurry, and which mistake is the one that cannot be undone. The interface talks the same way: no "configure", no "generate", no "invalid input".
Rewriting 295 recipes across three languages by hand is exactly the kind of job
where a temperature quietly moves by ten degrees, so the prose lives in
scripts/voice/batch-*.js and is applied by apply_voice.js, which refuses the
change unless every instruction step still carries the same numbers, the same
oven settings and the same step count as the original. Two escape hatches exist
and both must be spelled out per recipe:
dropSteps: [4, 5, …]removes steps the importer scraped off the page rather than out of the recipe — a newsletter signup form, a request for comments.fixes: [1, 3]marks the steps where a number is meant to change, because the import had it wrong: a Dutch step reading "45-50 graden" where the English said minutes, or an oven "preheated to 35".
Anything not on those lists is a hard failure, and nothing is written.
The ingredient lists were converted to metric when they were imported. The
instructions were not, so for a while a recipe could tell you to heat the oven
to 350 degrees while its own ingredient list was in grams.
scripts/convert_measures.js closes that gap, and apply_voice.js runs it on
the way into the database — the batches in scripts/voice/ keep each source
recipe word for word, and recipes.js comes out metric however often the voice
pass is re-run. Rebuild it from the batches with apply_voice.js --rebuild.
Every change it makes is printed, grouped by rule, so the whole diff can be read
before it is committed — node scripts/convert_measures.js --check. Two kinds of
edit happen and they carry very different risk: stripping the imperial half
of a measure the recipe already gives twice ("180C/350F/Gas 4", "325ml/11fl oz",
"1/4 cup (60 ml)") does no arithmetic and cannot be got wrong, while
converting a bare imperial measure computes a number.
Two things in there are worth knowing about.
The oven table is conventional, not arithmetic. 350 °F is 176.7 °C, but no European oven has a 175 mark on it and no European cookbook prints one: the answer is 180. That is not sloppiness — it is what this book already said about itself, because the British recipes that arrived carrying both scales spell out 180C/350F twelve times and 200C/400F twenty-one. Converting by calculator would have made a British recipe and an American one disagree about the same oven. Temperatures between the marks — an oil bath, a meat probe — are arithmetic to the nearest 5 °C.
A cup is 240 ml or 125 g depending on what is in it, and that factor of two
is the one number in the job worth being slow about. The decision is taken from
the English, and the Dutch and French then take the same answer for the same
step. Deciding per language would mean three vocabularies and three chances to
be wrong, and Dutch defeats the technique anyway: it glues its nouns together,
so no list of words can find the oil inside "olijfolie" or the stock inside
"kippenbouillon", and "room" is cream in Dutch and somewhere to stand in
English. Whatever the lists cannot place is left alone and reported, and the
dozen real cases are settled by hand in BY_HAND with a note on each saying
why — a cup of butter and a cup of ricotta are both measured by volume, and the
dry rule would have put nearly half the butter back on the shelf.
A recipe:
{
id: "carbonnade-flamande",
prepTime: "25 mins", cookTime: "2 hrs 30 mins",
difficulty: { en: "Medium", nl: "Gemiddeld", fr: "Moyen" },
servings: 4,
category: ["main"], // array — a dish can be both soup and main
image: "images/carbonnade_flamande.jpg", // always local
isVegetarian: false, isVegan: false, isGlutenFree: false, /* …8 flags */
translations: {
en: { title, subtitle, description, instructions: [...] },
nl: { ... }, fr: { ... }
},
ingredients: [{
key: "onion", // canonical key — merging is done on this
name: { en: "onion", nl: "ui", fr: "oignon" },
amount: 3,
unit: "st.", // from a closed set, see ingredients.js
category: "Groenten & Fruit", // aisle
staple: false // pantry staple → skipped by default
}]
}Storage keys in localStorage: belgian_user_recipes (yours), belgian_grocery_list,
belgian_skipped_staples, belgian_favorites, belgian_app_settings. The shipped
recipe database is never copied into localStorage.