Skip to content

JSON schema

The blank a menu is written into. Not a template to copy and fill in by hand — a JSON Schema (2020-12) that scripts/validate-menu.ts checks every menus/*.json file against before it counts as finished.

Why a schema and not a template

A Markdown template is a suggestion — nothing stops a menu drifting from it one edit at a time. A schema is a gate: a file that doesn't match it doesn't validate, and bun scripts/validate-menu.ts menus/*.json says exactly why.

Three rules that used to be enforced by careful reading are now enforced structurally:

  • The disclaimer can't be edited. Its localizedText is declared with const, so a shortened, reworded or removed disclaimer fails to validate.
  • A menu can't be missing its Hebrew. Every client-facing string is a localizedText{ "en": "...", "he": "..." } — and both keys are required, non-empty.
  • Dinner can't carry starch or fruit. The schema's dinner slot fixes portions.starch and portions.fruit to 0 via an if/then.

The shapes

localizedText

{ "en": "cooked lentils", "he": "עדשים מבושלות" }

Every piece of client-facing text in the file — food names, quantities, option names, notes, the disclaimer — is one of these. Nothing is optional; a menu that only has the English half is not a finished menu.

portionCounts

{ "starch": 2, "protein": 2, "fat": 2, "veg": 1, "fruit": 0, "dairy": 1 }

All six exchange groups, always present, zero where a group is unused. This is the shape of dailyPortions, of every slot's portions, and of every food item's own portions.

foodItem

{
  "food": { "en": "cooked lentils", "he": "עדשים מבושלות" },
  "qty": { "en": "½ cup", "he": "חצי כוס" },
  "portions": { "starch": 1, "protein": 1, "fat": 0, "veg": 0, "fruit": 0, "dairy": 0 }
}

One food, at one quantity, contributing to one or more portion groups. A legume item's portions carries both starch: 1 and protein: 1 in the same object — see step 5.

option

{
  "name": { "en": "Rice and lentils", "he": "אורז ועדשים" },
  "items": [ /* foodItem, foodItem, ... */ ]
}

One of a slot's 3–4 interchangeable choices. Its items must sum, group by group, to exactly its slot's portions — see below.

slot

{
  "id": "lunch",
  "time": "13:00–13:30",
  "label": { "en": "Lunch", "he": "ארוחת צהריים" },
  "portions": { "starch": 4, "protein": 3, "fat": 2, "veg": 3, "fruit": 0, "dairy": 0 },
  "options": [ /* 3-4 option objects */ ]
}

slots is a fixed-length array of five, in a fixed order — breakfast, snack1, lunch, snack2, dinner — each id pinned to its position by the schema, and dinner additionally constrained as above.

The top level

type discriminates a menu from a referral — see the output contract for what each top-level key is for. A file is exactly one or the other; the schema is a oneOf over the two shapes.

referral

{
  "type": "referral",
  "client": { "name": "…", "slug": "…" },
  "date": "2026-08-23",
  "whatStopped": { "en": "…", "he": "…" },
  "whatToDoNow": { "en": "…", "he": "…" },
  "whyNoMenu": { "en": "…", "he": "…" },
  "whatWouldContinue": { "en": "…", "he": "…" }
}

Where step 2 stops the run — see the referral note. No slots, no dailyPortions: a referral has none of a menu's structure, only these four required fields plus whatWouldContinue, which exists only for a conditional stop — omit the property entirely for an unconditional one, rather than setting it to an empty string.

The arithmetic the schema can't express

JSON Schema has no arithmetic — it cannot sum a list of numbers and compare the total to another number, which is most of what makes a menu correct. scripts/validate-menu.ts compiles the schema with ajv for the structural half, then runs the checks that need real arithmetic, using the same exchange values as the reference page:

  • Every option's items sum to its slot's portions, group by group — the check from step 9, run on every option rather than by hand.
  • The five slots' portions sum to dailyPortions.
  • dailyPortions, converted through the exchange values, reconciles with dailyTarget within the tolerance from step 5 — ±5 g protein, ±10 g carbohydrate, ±5 g fat.
  • Each main meal (breakfast, lunch, dinner) delivers 30–40 g of proteinstep 4.
bun scripts/validate-menu.ts menus/*.json

A file that fails either half — the structural schema or the arithmetic — is not finished, whichever step of the method produced it.

What the schema does not check

It cannot know a client's exclusions — a peanut allergy, a disliked food — so it cannot catch one appearing inside an option. That check is step 9's, done by searching the finished file for every excluded food by name. It also cannot judge whether four options in a slot are meaningfully different from one another, or whether a translation reads naturally rather than just being present. Those stay human judgement calls.

Rendering

This schema describes data, not a page. What a client actually sees is built by web-apps/demo/build.ts from this file — see its own README.md for how a menu and a referral are each rendered, and how the Hebrew half is chosen for display.