Munchable scans a barcode and tells you whether the product fits the gut condition you actually have. Most of the app is built around the fact that a label is somebody else's data: if the ingredients list is incomplete, the verdict says so rather than guessing.
The recipe library is the one surface where that constraint lifts. We write the whole ingredient list ourselves, so there is nothing missing to be careful about. That privilege is paid for by a single rule in a single file: the thing the rules engine scores is derived, never authored.
You can read the output of that rule here:
- munchable.app/recipes is the whole library.
- Ginger chicken rice bowl is a page where everything below the method is computed.
The bug we designed out
Write a recipe library the obvious way and you type the ingredient list twice. Once as prose for the cook, and once as structured data for whatever does the checking: tags, percentages, fat per serving, serving size. Two copies of the same dish, maintained by hand, drifting apart the first time somebody changes a quantity. The page then says one thing and the check says another, and the one people act on is the wrong one.
So a recipe line holds quantities and nothing else:
{
qty: 15, unit: 'g', name: 'fresh ginger', prep: 'grated',
grams: 15,
tags: ['en:ginger'],
}
grams is the single source of truth. qty and unit are display only, because "0.75 tsp" is a number and "¾ tsp" is an instruction, and a validator checks the two agree within a plausible range for volume units.
The tags array is the one place an author exercises judgement, and it is deliberately a list rather than a single value. Composite lines carry what is actually in them: a loaf of bread is bread and wheat flour, tamari is tamari and soya bean. Tag the loaf as bread alone and its fructans are invisible to a rule that would otherwise catch them. Every tag has to be one the engine can name, and a line with no tags is rejected in CI.
Everything else is a function
export function toProduct(recipe: Recipe): Product {
const total = totalGrams(recipe);
const byWeight = [...recipe.ingredients].sort((a, b) => b.grams - a.grams);
const { fat, fiber } = batchNutrition(recipe);
// ...percentages, tag list, per-100 g figures, serving size
}
The return type is Product, exactly the type a scanned barcode produces. There is no recipe-specific scoring path and there should never be one: the same rule sets, the same thresholds, the same fail-closed logic.
Three lines in there are decisions rather than plumbing.
Weight order, not cooking order. Ingredients are sorted heaviest first, the way a legal ingredients list is written, because the engine reads label position as a signal of how much of the product something is. The recipe keeps its own order for the person cooking.
Real percentages. A scanned label almost never prints percentages, so the engine infers importance from position. A recipe knows what it weighs, so every ingredient carries a true percentage and the engine gets fact instead of inference. Where a sub-ingredient inherits its line's percentage it slightly overstates itself, which errs toward the stricter verdict.
Completeness we are allowed to claim. Products carry a flag meaning the ingredients list is complete. For a photographed label that is a hope; here we wrote the list, so it is true.
Cooking water never becomes an ingredient line. It belongs in the method, and as a line it would dilute every trigger's percentage with something nobody eats.
A missing number throws
const entry = NUTRITION[tag];
if (!entry) {
throw new Error(
`No nutrition entry for ${tag} ("${ingredient.name}" in ${recipe.slug}).`,
);
}
Fat and fibre come from a per-ingredient table keyed on the line's identity tag. A missing entry is the interesting case, and defaulting it to zero is the tempting answer: one ingredient, how much fat can it be hiding. The answer is that bile acid malabsorption is a fat-per-serving condition, so a zero would hand somebody a green verdict built on a gap, which is the exact failure the engine's fail-closed rules exist to prevent. It throws instead, and the validator runs in CI, so the failure lands on a pull request rather than on a phone in a supermarket.
The serving stepper cannot change the answer
Recipe pages and the app both let you cook for a different number of people. Scaling multiplies every quantity by the same factor, which leaves percentages, per-100 g figures and per-serving weight untouched, so the verdict is invariant under the stepper by construction rather than by care. A test pins it, because "by construction" is a claim that stops being true the moment somebody adds a line that does not scale.
The hole we print rather than hide
One thing a recipe library genuinely cannot check is the jar in your cupboard. We can check the ingredient we named; we cannot check that the bought stock, bread or sauce does not carry onion, garlic or wheat that the recipe never mentions.
So lines like that carry a packaged flag, which puts a shopping-bag glyph on the line and a note on the page. Peanut butter and banana toast is the clearest example: two slices of bread, peanut butter, a banana, and the sentence "Brands differ. Scan the one in your cupboard to check what it actually contains."
The flag is deliberately not for everything that comes in a bag. Rolled oats, dried rice noodles and quinoa are the ingredient and nothing else, and marking them would spend a reader's attention on a warning with nothing behind it.
What ships, and what does not
Every recipe is run through all seven rule sets before it is published, at the strictest setting the app offers, so the public claim can never be softer than what the app will tell the person who acts on it. A recipe that comes back unassessable does not go in the library at all, which is a stricter bar than a scanned label has to clear, for the same reason as everything above: with a recipe we wrote the ingredient list, so an unknown is our fault rather than the manufacturer's.
The result is three kinds of page, all of them live:
- Ginger chicken rice bowl clears all seven conditions.
- Dark chocolate strawberries clears every condition except reflux.
- Overnight oats with strawberries clears every condition except gastroparesis.
Nobody wrote those sentences. The page asks the engine and then says the shortest true thing, which is why near-misses invert into "every condition except X": the exception is the part a reader is scanning for, and it is shorter than naming the other six.
Why the index is grouped by meal and not by condition
The obvious SEO move is seven collection pages, one per condition, each aimed at a head term. We did not build them, and the library itself is the reason. Counting what the 38 live recipe pages currently claim:
| Condition | Recipes that clear it |
|---|---|
| IBD, Crohn's and colitis | 38 |
| IBS and low FODMAP | 37 |
| Lactose intolerance | 37 |
| SIBO | 37 |
| Bile acid malabsorption | 35 |
| Gastroparesis | 34 |
| GERD and reflux | 30 |
Seven pages built from those sets would be near-duplicates of each other competing for the same crawl budget, with the thinnest of them still listing 30 of the 38. So the index groups by meal, which is how somebody deciding what to cook actually thinks, and the condition guides carry the head terms and link in with the subset that suits them. Collections become worth building when the lists genuinely diverge, and a table is a cheaper way to find that out than seven pages are.
The part that transfers
None of the above is really about food. It is the same decision anybody makes when a document has a human-readable half and a machine-readable half: either you maintain both, or you make one of them a pure function of the other and let the compiler enforce it. The second option costs a derivation layer up front and then stops costing anything, and the first option costs a little attention every time anybody edits anything, forever.
Munchable is at munchable.app. The recipe pages are static, so you can read all 38 of them without an account.
Top comments (0)