Projects/Recipe Site/Architecture Decisions

ADR 030: Cooklang

Context

Our building philosophy is to ride rising tides — adopt open standards that are already gaining community momentum rather than authoring bespoke formats that we must maintain, document, and evangelise alone. Lean and Agile principles reinforce this: eliminate undifferentiated work so development concentrates on genuine USPs — the cooking mode UX, AI-powered ingestion, the social layer, and the experiences no competitor offers. We do not want to build a competing standard or cultivate a new community around a proprietary recipe schema; we want to join the one that already exists.

The recipe site currently stores recipes as TypeScript objects validated with Zod schemas. The format is entirely proprietary: no parser ecosystem, no community tooling, no portability outside this codebase. It also has concrete structural gaps that grow more costly to paper over as the roadmap advances:

  • Scaling quantities in instruction text: The current schema separates ingredients from instructions, so scaling only updates the ingredient list — the same documented limitation that virtually every competitor has, including AnyList, Paprika, and Copy Me That.
  • Inline timers: Step text containing "simmer for 20 minutes" is unstructured prose. A structured schema can associate timers with steps, but inline annotation keeps the timer contextual — where it actually occurs in the flow of the recipe.
  • Shopping list aggregation: Ingredient data is structured per-recipe but there is no standard mechanism for merging quantities across recipes for meal planning.
  • Cookware tracking: Equipment mentioned in recipes has no structured representation, making equipment-based filtering impossible.
  • Sub-recipe composition: Recipes that share components (a sauce, a dough) have no first-class way to express the dependency.

The project proposal explicitly lists Cooklang alongside database-backed models as candidates for the recipe data format. This ADR evaluates adopting Cooklang's .cook format — an open, community-maintained standard with a growing ecosystem — as the canonical recipe content format.

What is Cooklang?

Cooklang is a plain-text markup language for recipes. Ingredients, cookware, and timers are annotated inline within step text using simple syntax:

Add @flour{200%g} and @butter{100%g} to the #bowl{}.
Bake for ~{25%minutes} at 180°C.

The core Rust library cooklang-rs (MIT licensed, actively maintained) provides a parse → scale pipeline with WASM/TypeScript bindings that run in-browser without a server round-trip.

Cooklang has a small but established community across:

The spec is openly developed on GitHub with community-driven discussions. Highly engaged open proposals include temperature syntax (#67, 12 upvotes, 20 replies) for inline annotations like 180°C alongside timers; ingredient substitutions (#68, 15 upvotes) for dietary alternatives and availability fallbacks; and dual-unit / range quantities (#84, 10 upvotes) for simultaneous metric and imperial display. There are 20+ community-built tools — custom Telegram bots, nutrition calculators for dietary management, VS Code extension, and parsers in 12+ languages.

Decision

Adopt Cooklang's .cook format as the canonical recipe content format — for authoring, import, export, and in-browser parsing. .cook is not necessarily the database schema or the storage model long term; it is the interchange format that all other representations derive from.

Recipes become .cook files with YAML frontmatter for metadata (title, cuisine, servings, tags, attribution, status). Parsing uses cooklang-rs via WASM/TypeScript bindings in-browser. Other views — schema.org for SEO, structured relational data for a future database, ML inference inputs, printable cookbook layouts — are derived from the parsed .cook output.

For authoring, .cook files are the ground truth, but contributors should never need to write raw .cook syntax. A recipe editor GUI writes .cook under the hood, just as a rich text editor outputs HTML. This is not a new constraint — the current TypeScript object approach is equally developer-only. The recipe editor UI is required regardless of format choice.

Features This Meets or Exceeds

Phase 1 (MVP): Recipe Browsing & Detail

FeatureStatus
Serving size adjustmentExceeds@flour{200%g} scales quantities in both the ingredient list and inline in instruction text. Fixed quantities use = prefix (@salt{=1%tsp}) to exclude specific ingredients from scaling — e.g. baking powder, seasoning to taste. No competitor implements this correctly.
Unit conversion🔧 Partial — cooklang-rs handles scaling (multiplying quantities by a ratio). Metric↔imperial↔cups conversion requires a unit mapping table that we provide. The format accepts any unit string; the parser gives us the quantity and unit to pass into our own conversion layer.
Structured ingredients✅ Meets — inline annotation is the source of structure; the parser emits a typed ingredient list automatically from step text with no separate schema maintenance.

Phase 2: Kitchen Experience

FeatureStatus
Inline timersExceeds~{25%minutes} and named timers ~eggs{3%minutes} are first-class syntax. Timers are explicit anchors we can attach tap-to-start UI affordances to. The timer is inline in the step where it occurs, keeping context intact rather than separating it into a parallel data structure.
Cookware trackingExceeds (not in current proposal)#slow cooker{}, #cast iron pan{} are first-class syntax parsed into a structured equipment list per recipe. This unlocks equipment-based filtering ("show only slow cooker recipes", "exclude recipes requiring a stand mixer") — a capability no competitor offers.
Cooking mode🔧 Not provided — Cooklang has no polished kitchen UX. The CLI web server targets developers; the spec has no concept of a cooking view. Our large-text, step-by-step, wake-lock, PWA cooking mode remains entirely our responsibility and is our primary USP.

Phase 3: Shopping & Meal Planning

FeatureStatus
Shopping list generationExceeds — CookCLI implements ingredient aggregation and deduplication across multiple recipes. The cooklang-rs WASM bindings provide the same capability in-browser, removing custom aggregation code from our Phase 3 backlog.
Ingredient deduplication across recipes✅ Meets — the parser's ingredient graph merges the same ingredient across recipes by default.

Phase 4: Recipe Ingestion Pipeline

FeatureStatus
AI URL importExceeds — CookCLI includes AI-powered import from URLs supporting OpenAI, Claude, Gemini, and Ollama as providers. CookCLI is a reference implementation demonstrating the approach; we implement our own equivalent using AI APIs directly with .cook as the target output format.
Photo-to-recipe (OCR)🔧 Not provided — CookCLI handles URL import but not image-to-recipe extraction. Our ML pipeline (ml-pipelines/recipe-parsing/) remains essential and is a genuine USP — handwritten cursive OCR is unsolved industry-wide. The target output format is .cook, giving ML a well-defined schema to generate.
Open standard / SEO🔁 Derived view — Cooklang's .cook format is open but not schema.org Recipe. We emit schema.org at build time from cooklang-rs parsed output. .cook and schema.org coexist: one is the content format, the other is the SEO/interoperability view.

Phase 5: Personalization & Social

FeatureStatus
Version history✅ Meets — .cook files in git give the same full commit-level history as the current TypeScript approach. Longer term, if content moves to a database, version history becomes event sourcing on extracted relational data — .cook remains the import/export format either way.
Recipe forks / personalizationExceeds — plain-text files are trivially forkable. Lineage can be tracked in frontmatter. The community has built multi-generational collaborative recipe management patterns on top of Cooklang.
Sub-recipe dependenciesExceeds (not in current proposal)@./Shakshuka sauce{100%g} references another .cook file as a composable ingredient. Recipe composition graphs are first-class Cooklang concepts with no equivalent in our current schema — opening dependency trees, partial shopping lists, and modular recipe construction.

Phase 6: Nutrition & Intelligence

FeatureStatus
Nutritional analysis🌱 Community-proven but not built in — the spec has no nutritional database, but the community has already built nutrition calculators and diabetes management systems on top of Cooklang's structured ingredient output. This is a well-trodden path: structured quantities and unit-annotated ingredients are the hard part, and Cooklang solves that. Integrating a nutrition API or database on top is the remaining step.

Future Ideas

FeatureStatus
"What Can I Make?"✅ Already solved — CookCLI implements pantry management (available ingredients → matching recipes). This is a direct reference implementation for our future feature.
Cookbook / printed recipe book🌱 Reference pattern — CookCLI includes PDF and LaTeX cookbook generation as a reference implementation. Our printed recipe book is a typographically designed premium product feature; we build our own version rather than depending on the CLI.
Federated recipe discovery🌱 Emerging — Cooklang's community recipe index exists but is not production-ready as a federated search system. We can contribute to and consume from it as it matures.

Limitations

  1. No cooking mode — Cooklang has no polished kitchen UX. Building large-text, step-by-step, wake-lock, offline cooking mode is entirely our responsibility.

  2. Recipe editor UI is still required.cook files are plain text and easier to author directly than TypeScript objects, but non-technical contributors still need a GUI. This is not a new constraint: both formats equally require a recipe editor UI to onboard non-developers. .cook files are at least human-readable and writable without a build step.

  3. Several useful features still in community proposal — The most-upvoted open discussions surface real gaps: temperature has no first-class annotation (e.g. inline 180°C alongside timers) per #67; ingredient substitutions for dietary restrictions or availability have no agreed syntax per #68; and dual-unit / range quantities (e.g. both 200g and 7oz, or 6–8 wings) remain unspecified per #84. None of these block adoption today, but we may need to handle them in our own parsing layer before they land in the spec.

  4. Unit conversion tables are ours to maintaincooklang-rs handles quantity scaling. The metric↔imperial↔cups mapping table (knowing that 200g ≈ 7oz, or that 1 cup = 240ml) is our responsibility. The parser provides clean quantity + unit values to plug into our conversion layer; the conversion logic itself is not part of the spec.

  5. No canonical metadata schema — Cuisine, tags, attribution, difficulty, and draft status are YAML frontmatter. Cooklang doesn't standardize these fields, so our frontmatter schema is ours to maintain. Community tooling won't handle custom metadata automatically.

  6. No social or collaboration platform — Cooklang is a file format, not a platform. User accounts, favorites, cooking logs, and recipe fork tracking are entirely outside scope.

  7. Ecosystem breadth is a signal of traction, not a dependency — The existence of parsers in 12+ languages, community nutrition calculators, Obsidian plugins, and custom bots is a positive signal: real people found the format useful enough to build on. We don't depend on any of these directly, and their quality varies. What we actually depend on is cooklang-rs and its WASM bindings — both solid and actively maintained. CookCLI is useful as a reference implementation but warrants evaluation before hard-depending on specific features. The federated index is nascent. Treat community extensions as validation of format fit, not as production infrastructure. Cooklang also ships a native iOS/Android app, but that is irrelevant to us — we wouldn't depend on it, and it actively contradicts our web-first/PWA rationale.

  8. Federated search is emerging, not production — The community recipe index is actively discussed but not a polished discovery mechanism yet.

Data Model Note

Cooklang occupies a specific role in our data model — it is not necessarily the final storage format or the database schema:

  • Source of truth for content.cook files, authored via GUI, stored in git (or a future object store / database)
  • Import / export format — readable by any Cooklang-compatible tool; portability and interoperability without vendor lock-in
  • In-browser parsing — WASM bindings run the parse/scale pipeline client-side for serving size adjustment, shopping list generation, and timer extraction
  • Derived views — schema.org (SEO), structured relational tables (future D1 database), ML inference inputs, and printable cookbook layouts are all generated from the parsed .cook output
  • Version history — git today; event sourcing on extracted relational data if/when content moves to a database. .cook remains the interchange format either way.

Remaining Gaps as Our USP

These are the capabilities Cooklang explicitly does not provide, and where we differentiate:

GapOur Approach
Polished web cooking modeLarge text, step-by-step, wake lock, offline PWA. The premium cooking UX (Crouton, Pestle) is Apple-native only. No competitor offers this in the browser.
Equipment-based filteringCooklang tracks cookware in .cook files; we surface it as a filter dimension — "slow cooker recipes only", "no stand mixer required". No competitor offers this.
Fixed-quantity scaling= prefix prevents certain ingredients from scaling. A correctness guarantee (baking powder, seasoning) that no competitor implements.
Recipe editor GUIA friendly guided UI that writes .cook under the hood, hiding format syntax from non-technical contributors entirely.
AI photo-to-recipe ingestionHandwritten OCR from physical recipe books into .cook format. Cooklang handles URL import; image-to-.cook is our ML pipeline's job. Reliable cursive handwriting OCR remains unsolved industry-wide.
User accounts and social layerFavorites, cooking log, recipe forks with lineage tracking, community reviews. Cooklang has no platform component.
Version history UISurfacing recipe history in a polished changelog UI. No competitor exposes this at all.
Nutritional analysisIntegrating a nutrition API on top of cooklang-rs's structured ingredient output. Community tools have proven the pattern; we connect the data.
schema.org emissionDeriving schema.org Recipe from .cook at build time for SEO rich results. Bridges Cooklang's format with the web standard for search engine interoperability.
Beautiful printed cookbookCookCLI's PDF/LaTeX output is a reference implementation; we build our own typographically designed version rather than depending on the CLI binary.
Spec contributionsActively participating in open spec discussions shapes the format in our interest and builds community presence. High-value open proposals to track or contribute to: temperature syntax (#67), ingredient substitutions (#68), and dual-unit quantities (#84).

Alternatives Considered

Current Approach: TypeScript + Zod Objects

  • Pros: Type safety, build-time validation, no format migration, deep domain layer integration.
  • Cons: Scaling only updates the ingredient list, not instruction text. Timers and cookware have no inline annotation — either NLP detection from prose or a parallel structured schema are the alternatives, both of which are less ergonomic than inline annotation. The format is proprietary to this project with no ecosystem.
  • Decision: Rejected as the long-term content format. The instruction-text scaling gap is a real correctness problem, but the deeper issue is that a bespoke proprietary format means owning a standard rather than joining one. There is no parser ecosystem to leverage, no community to share the maintenance burden, and no portability for users. Cooklang solves the structural gaps and comes with the community; we should ride that tide rather than paddle against it.

Structured Schema with Timer/Cookware Fields

An extended TypeScript schema could associate timers and equipment with individual steps (e.g. {text: "...", timers: [{duration: 25, unit: "min"}], cookware: ["bowl"]}). This achieves machine-readable timers and cookware without adopting an external format.

  • Pros: Keeps the current type-safe schema approach. No ecosystem dependency.
  • Cons: Separates timers and cookware from the prose where they occur, reducing readability. Requires hand-maintenance of parallel data structures. Gives us none of the ecosystem tooling — no WASM parser, no CLI, no community tools. Effectively reinvents Cooklang without the community.
  • Decision: Rejected. Cooklang's inline approach is strictly more ergonomic and comes with production tooling.

RecipeSage's Curly-Brace Markup

RecipeSage uses {quantity} inline in instruction text to scale instruction quantities — solving the same problem Cooklang solves for scaling, but with a proprietary format, no parser library, no community, and no support for timers, cookware, or sub-recipes.

  • Decision: Rejected. Cooklang solves the same problem with a richer spec and an established ecosystem.

Mealie's schema.org Recipe Model

Mealie uses schema.org Recipe as its canonical data model, maximising SEO interoperability.

  • Pros: SEO-native, standardized, importable from any recipe website.
  • Cons: No inline ingredient-in-instruction annotation. No timer syntax. No cookware. No sub-recipe references.
  • Decision: Rejected as the storage format. schema.org remains the SEO/export view, emitted from .cook parsed output at build time.

Database-Backed Models (Cloudflare D1) as Primary Store

Storing recipes directly in D1 enables dynamic features earlier but at cost:

  • Loses git version history for recipe content.
  • Requires schema migrations instead of file edits.
  • Adds operational complexity before product-market fit is validated.
  • Decision: Deferred for recipe content. User-specific data (favorites, cooking log, preferences) moves to D1 as planned. Recipe content stays in .cook files. D1 can hold an extracted relational view of recipe data derived from .cook if query performance requires it. The two coexist.

Consequences

Positive

  • Instruction text scaling — quantities in step text scale with serving size alongside the ingredient list. Fixes the core correctness gap shared by virtually every competitor.
  • Fixed-quantity ingredients= prefix prevents scaling for ingredients where the quantity should not change with servings. A quality-of-life improvement no competitor offers.
  • First-class timers~{25%minutes} makes Phase 2's tap-to-start timers immediately implementable with no NLP and no parallel data structure.
  • Cookware tracking#slow cooker{} unlocks equipment-based filtering as a differentiator.
  • Shopping list aggregationcooklang-rs's built-in ingredient aggregation removes custom multi-recipe shopping list logic from Phase 3.
  • Sub-recipe composition@./sauce{100%g} enables recipe dependency graphs and future features like partial shopping lists and modular recipe construction.
  • In-browser parsing — WASM bindings preserve the static site architecture; no server round-trip required for parse/scale.
  • Community ecosystem — 20+ tools, 12+ language parsers, Obsidian plugin (308 stars), VS Code support, nutrition calculators. Tooling investment benefits from community maintenance.
  • AI URL import — CookCLI's URL-to-recipe AI import is a proven reference implementation for Phase 4; the approach is de-risked and the target format (.cook) is defined.
  • Open interchange format.cook files are human-readable plain text. Users own their data; recipes are portable to any Cooklang-compatible tool. Storage model can evolve independently.
  • ML target format — the photo-to-recipe pipeline has a well-defined structured output format to generate, rather than a bespoke schema.

Negative

  • Recipe migration — existing ~18 TypeScript recipe objects need converting to .cook format plus YAML frontmatter. Manageable at current scale; would be more costly later.
  • Recipe editor UI is now load-bearing.cook syntax is not the blocker (it's simple), but the GUI editor is required to onboard non-technical contributors. This was always true; it is now an explicit dependency rather than an implicit one.
  • Unit conversion tables are ours to own — metric↔imperial↔cups mapping is not provided by the spec. We maintain it; cooklang-rs gives us the parsed quantity and unit to plug in.
  • Metadata schema is ours to own — cuisine, tags, attribution, draft status in frontmatter are our schema. Community tooling won't handle custom metadata automatically.
  • Ecosystem maturity risk — the core library (cooklang-rs, WASM bindings) is solid. The wider ecosystem (some language parsers, CookCLI feature stability, federated index) is thinner. Evaluate each open-source component individually before depending on it.
  • Spec evolution lag — features in active community discussion (temperature syntax, ingredient substitutions, range quantities) may take time to land in the official spec. We accept some forward-looking risk and may need to extend our parsing layer for these before they become official.