Skip to content

@gram-lang/analyzer

Enriches a CompilationResult (from @gram-lang/kitchen) with physical properties — standardized mass, purchasing yield, nutrition estimates, and baker’s percentages — by cross-referencing an ingredient database. This is the only stage that needs a database; parsing and compilation work on any recipe with no external data.

function analyze(
  result: CompilationResult,
  database: Record<string, IngredientData>,
  options?: AnalyzerOptions,
): AnalysisResult

interface AnalysisResult {
  result: AnalyzedCompilationResult;
  missingIngredients: string[]; // ids present in the recipe but absent from `database`
}
import { compile } from '@gram-lang/kitchen';
import { analyze } from '@gram-lang/analyzer';

const compiled = compile(ast);
const { result: analyzed, missingIngredients } = analyze(compiled, myIngredientDatabase);

The ingredient database is the second positional argument, not a field on optionsanalyze(compiled, database, options?).

analyze() is pure and never mutates result. AnalyzedCompilationResult extends CompilationResult — same shape, plus per-usage normalizedMass/conversionMethod/isEstimate/purchasingMass/bakersPercentage fields and a metrics.nutrition block. See Data Formats for a fully annotated example.

All flags default to enabled (!== false checks internally) — pass false to opt out of a given enrichment pass.

OptionTypeDescription
enableMassStandardizationbooleanConvert ingredient quantities into standardized grams.
enableYieldCalculationbooleanApply the ingredient database’s physical.yield (waste factor) when standardizing mass.
enableNutritionalEstimationbooleanCompute metrics.nutrition (calories, macros, optionally per-portion).
enableBakersMathbooleanCompute bakersPercentage relative to the *-marked (or bakersReference) ingredient.
bakersReferencestringExplicit ingredient id to use as the 100% baker’s-percentage base, instead of the * modifier.
portionsnumberOverrides the recipe’s own portions: frontmatter as the divisor for metrics.nutrition.perPortion. Omit to use what the recipe declares.
langstringOptional locale code (e.g. 'en', 'fr') for locale-scoped unit normalization and category sorting.
function validateIngredientDatabase(rawDb: unknown): {
  data: Record<string, IngredientData>;
  rejected: { key: string; message: string }[];
}

Validates entry-by-entry rather than all-or-nothing: one malformed ingredient doesn’t prevent every other valid one from loading. Use data to compile/analyze; surface rejected to the user (e.g. gram db validate). See Data Formats for the IngredientData YAML schema.

function standardizeMass(
  amount: number,
  unit: string,
  database: Record<string, IngredientData>,
  ingredientName?: string,
  overrides?: Record<string, number>, // slug -> density (g/mL) or unit weight (g), from frontmatter `densities:`
  lang?: string,
): { mass: number; method: "physical" | "density" | "unit_weight" | "default" | "explicit"; isEstimate: boolean } | null

function convertUnit(value: number, fromUnit: string, toUnit: string, density?: number, lang?: string): number | null

function applyYield(
  mass: number,
  method: "physical" | "density" | "unit_weight" | "default" | "explicit",
  yieldFactor?: number,
): { normalizedMass: number; purchasingMass?: number }

standardizeMass resolves a quantity to grams via, in order: (1) a direct mass unit (g, kg, oz…), (2) a volume unit converted through the ingredient’s resolved density, (3) an unrecognized unit (clove, slice…) treated as a count via the ingredient’s unit_weight. Returns null — never guesses — when a volume unit has no resolvable density.

convertUnit converts between two arbitrary unit strings, bridging mass↔volume families via density (g/mL) when given. Returns null for an unresolvable cross-family conversion without one.

The base conversion factors standardizeMass/convertUnit use for same-family (mass↔mass, volume↔volume) conversions, before any density is involved:

FamilyUnitFactor (relative to base)
mass (base: g)mg0.001
mass (base: g)g1
mass (base: g)kg1000
mass (base: g)oz28.3495
mass (base: g)lb453.592
mass (base: g)livre500
volume (base: ml)ml1
volume (base: ml)cl10
volume (base: ml)dl100
volume (base: ml)l1000
volume (base: ml)drop0.078
volume (base: ml)smidgen0.156
volume (base: ml)pinch0.3125
volume (base: ml)dash0.625
volume (base: ml)tad1.25
volume (base: ml)tsp4.9289
volume (base: ml)tbsp14.7868
volume (base: ml)cup236.588
volume (base: ml)tasse250
volume (base: ml)pt473.176
volume (base: ml)qt946.353
volume (base: ml)gal3785.41
volume (base: ml)fl oz29.5735
function calculateNutrition(
  ingredients: AnalyzedUsage[],
  database: Record<string, IngredientData>,
  portions?: number,
  massStatus?: 'precise' | 'estimated' | 'incomplete',
): NutritionMetrics

Flattens composites and alternatives (taking the first option), skips optional-modifier and zero-quantity ingredients, and sums nutrition fields (declared per 100g in the database — see Data Formats) scaled by each ingredient’s standardized mass. Returns isEstimate: true if any contributing mass was itself an estimate, and coverage (0–1: fraction of ingredients with known nutrition data).

NutritionMetrics carries the same macro profile on up to three bases:

interface NutritionMetrics {
  total: Macros;          // the whole recipe, at its compiled/scaled quantities
  perPortion?: Macros;    // present whenever a portion count is known
  per100g?: Macros;       // per 100 g of the mass that contributed macros
  portions?: number;      // the divisor perPortion was derived with
  basis?: { mass: number; massStatus?: 'precise' | 'estimated' | 'incomplete' };
  isEstimate: boolean;
  coverage: number;
  warnings?: Warning[];
}

Every basis is derived from the same unrounded sums and rounded independently, so a per-portion figure never compounds the rounding of the total. basis is the denominator behind per100g — see Nutritional Estimation for why it matters and what “per 100 g” means when no cooking loss is modelled.

Which nutrients exist, their units and their rounding all come from a single exported NUTRIENTS table, so gram db enrich, gram db lint, gram db search, the editor hover and every renderer enumerate exactly the same set.

function diffRecipes(a: CompilationResult, b: CompilationResult): DiffResult

Structural diff between two compiled recipes (e.g. two versions of the same file, or a scaled vs. unscaled result) — ingredient quantity/unit changes (with percentChange when comparable), preparation changes, timing deltas, added/removed/changed sections, temperature and timer changes, and frontmatter (meta) changes. hasChanges is true if any category is non-empty.

A section spliced in from an @use import (ProcessedSection.module set) is excluded from the section/preparation/temperature/timer comparisons — otherwise adding a single import would shift every other section’s position and look like the whole recipe changed. Its own import is compared separately instead, by (uri, binding, scale factor), in modules: ModuleDelta[] — a rescaled or re-bound import is reported as "changed".