Skip to content

Data formats

The JSON shapes that flow between pipeline stages, and the YAML schema for the ingredient database. This page complements the per-package references — it’s the “what does the data actually look like” companion to their “what functions exist” content.

For this source:

---
title: 'Crepes'
---

## Batter

Mix @flour{200g} and @milk{200ml}.

getAST() returns (annotated, loc offsets omitted for brevity — every node except RecipeAST itself carries one):

{
  "type": "Recipe",
  "meta": { "title": "Crepes" },
  "children": [
    {
      "type": "Section",
      "title": "Batter",
      "retroPlanning": null,
      "intermediateDecl": null,
      "children": [
        {
          "type": "Step",
          "action": null,
          "children": [
            { "type": "Text", "value": "Mix " },
            {
              "type": "Ingredient",
              "name": "flour",
              "modifiers": [],
              "alias": null,
              "preparation": null,
              "composite": null,
              "quantity": {
                "type": "Quantity",
                "value": { "type": "single", "value": 200, "text": "200" },
                "unit": "g",
                "fixed": false
              }
            },
            { "type": "Text", "value": " and " },
            {
              "type": "Ingredient",
              "name": "milk",
              "modifiers": [],
              "quantity": {
                "type": "Quantity",
                "value": { "type": "single", "value": 200, "text": "200" },
                "unit": "ml",
                "fixed": false
              }
            },
            { "type": "Text", "value": "." }
          ]
        }
      ]
    }
  ]
}

See parser.md for the full set of node interfaces and the ASTNodeType enum.

2. Compiled & analyzed recipes (@gram-lang/kitchen, @gram-lang/analyzer)

Section titled “2. Compiled & analyzed recipes (@gram-lang/kitchen, @gram-lang/analyzer)”

compile() produces a CompilationResult; analyze() returns that same shape enriched with mass/nutrition fields (AnalyzedCompilationResult). Diffed below — analyzer-only fields are marked:

{
  "title": "Crepes",
  "slug": "crepes",
  "meta": { "title": "Crepes" },
  "registry": {
    "ingredients": {
      "flour": { "id": "flour", "name": "flour" },
      "milk": { "id": "milk", "name": "milk" }
    },
    "cookware": {}
  },
  "shopping_list": [
    {
      "id": "flour",
      "name": "flour",
      "qty": 200,
      "unit": "g",
      "_usageIds": ["1"],

      "normalizedMass": 200,        // analyzer
      "conversionMethod": "physical", // analyzer
      "isEstimate": false,            // analyzer
      "bakersPercentage": 100          // analyzer, only if baker's math is active
    },
    {
      "id": "milk",
      "name": "milk",
      "qty": 200,
      "unit": "ml",
      "_usageIds": ["2"],

      "normalizedMass": 206,          // analyzer — converted via density if known
      "conversionMethod": "density",  // analyzer
      "isEstimate": true              // analyzer — density came from the ingredient DB, not an explicit override
    }
  ],
  "cookware": [],
  "sections": [
    {
      "title": "Batter",
      "retro_planning": null,
      "ingredients": [ "/* same Usage objects as shopping_list, enriched identically */" ],
      "cookware": [],
      "steps": [
        {
          "type": "step",
          "timings": { "start": 0, "end": 2, "activeDuration": 2 },
          "backgroundTasks": [],
          "content": [
            "Mix ",
            { "id": "flour", "_usageId": "1", "qty": 200, "unit": "g", "normalizedMass": 200 },
            " and ",
            { "id": "milk", "_usageId": "2", "qty": 200, "unit": "ml", "normalizedMass": 206 },
            "."
          ]
        }
      ],
      "metrics": {                     // analyzer, per-section
        "totalMass": 406,
        "massStatus": "estimated",
        "missingMassIngredients": []
      }
    }
  ],
  "warnings": [],
  "metrics": {
    "preparationTime": 2,
    "cookTime": 0,
    "activeTime": 0,
    "totalTime": 2,

    "totalMass": 406,                   // analyzer, global
    "massStatus": "estimated",          // analyzer
    "missingMassIngredients": [],       // analyzer
    "nutrition": {                      // analyzer, only if enableNutritionalEstimation
      "total": { "calories": 748, "protein": 27.7, "carbs": 130.4, "fat": 11.4 },
      "isEstimate": true,
      "coverage": 1
    }
  }
}

Note the compiler’s StepToken vocabulary inside content: plain narrative text is a bare string; ingredients/cookware/references share the Usage shape (no type field, identified by having an id); timers/temperatures/comments/declarations each carry their own lowercase type. This is intentionally distinct from the parser’s PascalCase ASTNodeType — it describes compiled output, not parsed input. See How to Build a Custom UI for a walkthrough of consuming this shape in a frontend framework.

When a recipe imports sub-modules via @use, finalizeComposed() decorates the standard CompilationResult into a ComposedCompilationResult. It adds module metadata to both the root payload and individual spliced sections:

{
  "title": "Lemon Tart",
  "modules": [
    {
      "binding": "dough",
      "uri": "/bases/sweet-pastry-dough.gram",
      "title": "Sweet Pastry Dough",
      "meta": { "title": "Sweet Pastry Dough" },
      "scaleFactor": 1.5,
      "mode": "inline"
    }
  ],
  "sections": [
    {
      "title": "Sweet Pastry Dough",
      "module": {
        "binding": "dough",
        "uri": "/bases/sweet-pastry-dough.gram",
        "title": "Sweet Pastry Dough",
        "mode": "inline"
      },
      "steps": [ "/* spliced steps from the imported module */" ]
    },
    {
      "title": "Assembly",
      "steps": [ "/* host steps */" ]
    }
  ]
}
  • modules: Array of all imported module contributors (ModuleInfo), detailing local binding name, source URI, resolved scale factor, and execution mode ("inline" or "stocked").
  • section.module: Present on any section spliced in from an import (ComposedSection), preserving traceability for UIs and custom renderers.

The database passed to validateIngredientDatabase() / analyze() is a flat Record<string, IngredientData> keyed by ingredient slug. The gram CLI additionally accepts (and unwraps) an optional top-level ingredients: key, so both of these are valid .gram/ingredients.yaml files:

# With the optional wrapper (what `gram init` scaffolds)
ingredients:
  flour:
    name: "All-purpose flour"
    aliases: ["farine"]
    category: "Grains"
    physical:
      density: 0.59       # g/mL — bridges volume units ("1 cup") to mass
      yield: 1.0           # fraction (0, 1] — net/gross ratio after prep waste
      unit_weight: 120     # grams per "unit" (e.g. "1 flour" if ever used bare)
    nutrition:             # all values per 100 g; sodium in mg, everything else in g (calories in kcal)
      calories: 364
      protein: 10.3
      carbs: 76.3
      fat: 1.0
      sugar: 0.3
      fiber: 2.7
      sodium: 2

# Flat form (what validateIngredientDatabase()/analyze() expect directly)
flour:
  name: "All-purpose flour"
  physical:
    density: 0.59

physical and nutrition are both optional — an entry with neither is still valid (it just contributes no mass/nutrition data, surfacing as missingMassIngredients / a MISSING_MACROS warning). Only name is required. nutrition.calories/protein/carbs/fat are required whenever nutrition is present; sugar/fiber/sodium/sat_fat/mono_fat/poly_fat/alcohol are all optional. Units matter and are not inferred: calories is kcal, sodium is milligrams, and every other nutrient is grams — all per 100 g of the raw ingredient. The set, its units and its display order come from @gram-lang/analyzer’s exported NUTRIENTS table, which every tool that reads or writes nutrition data derives from.