@jclind/ingredient-parser
Parse natural-language ingredient strings into structured data, and (optionally) enrich them with vendor-neutral metadata and an estimated price with provenance.
v2 is a ground-up rewrite: the parser is a from-scratch tokenizer with no recipe-ingredient-parser-v3 dependency, the output is richer and lossless (descriptors and preparation are kept, not discarded), and enrichment is key-free on the client. See the Differences from v1 section to migrate.
Install
npm install @jclind/ingredient-parser # v2.x
npm install @jclind/ingredient-parser@1 # pin the previous v1 line
Quick start
Parse only — synchronous, offline, zero dependencies, no key:
import { parse } from '@jclind/ingredient-parser'
parse('1 1/2 cups all-purpose flour, sifted')
// {
// quantity: { value: 1.5, min: 1.5, max: 1.5, isRange: false, isApproximate: false },
// unit: { name: 'cup', plural: 'cups', symbol: 'c', type: 'volume', system: 'us' },
// ingredient:{ name: 'all-purpose flour', descriptors: [], preparation: ['sifted'] },
// containerSize: null,
// purpose: null,
// comment: 'sifted',
// original: '1 1/2 cups all-purpose flour, sifted'
// }
Parse + enrich — async; looks the ingredient up via the proxy and attaches a price. No API key in the client:
import { ingredientParser } from '@jclind/ingredient-parser'
const { parsed, data } = await ingredientParser('200 g chicken breast')
// parsed.ingredient.name === 'chicken breast'
// data?.price === { cents: 178, basis: 'gram', grams: 200, perGramCents: 0.89, confidence: 'high' }
// data is null if the ingredient can't be looked up.
API
parse(input: string): ParsedIngredientV2
The core parser. Throws TypeError for non-string input; never throws for any string.
ingredientParser(input: string, options?: EnricherOptions): Promise<{ parsed, data }>
Parses, then enriches via the proxy. parsed is always present; data is null when the ingredient isn't found. Network/proxy failures throw EnrichError — wrap in try/catch if you need to handle those.
createEnricher(options?) / createProxyProvider(options?)
Lower-level building blocks if you want to enrich an already-parsed object or inject a custom provider/transport. nameCandidates(name) exposes the fallback chain.
parseIngredientString(input: string): ParsedIngredient (legacy)
A v1-compatible adapter that projects the rich result to the flat v1 shape (quantity/unit/unitPlural/symbol/ingredient/minQty/maxQty/comment/originalIngredientString). Use this to migrate from v1 with minimal code changes.
calculatePrice / defaultToGrams / buildImageUrl / UNITS
Exported utilities; UNITS is the full unit registry, CONTAINER_UNITS the subset that holds a measure on the label.
calculatePrice(quantity, unit, prices, toGrams?, ctx?) takes the rest of the parse result as a context object: { container, purpose, name }. Pass it and a can prices by its contents, to taste prices at zero, and tap water stops costing money. Omit it and you get 2.1.0 behaviour.
isFreeIngredient(name) / isNegligibleAmount(purpose, quantity, hasUnit) are exported so a consumer can ask the same questions without going through pricing.
Options (EnricherOptions)
| Option | Type | Default | Description |
|---|---|---|---|
serverUrl |
string |
hosted proxy | Override the proxy base URL |
imageSize |
'100x100' | '250x250' | '500x500' |
'100x100' |
Image CDN size |
includeNutrition |
boolean |
false |
Include the nutrition block |
includeRaw |
boolean |
false |
Attach the raw provider response |
nameFallbacks |
boolean |
true |
On a miss, retry with progressively shorter names |
toGrams |
ToGrams |
mass + density | Custom unit→grams converter for pricing |
transport |
Transport |
fetch |
Inject a fetch-compatible transport (for tests) |
Output
ParsedIngredientV2
{
quantity: { value: number|null, min: number|null, max: number|null, isRange: boolean, isApproximate: boolean }
unit: { name, plural, symbol: string|null, type: 'volume'|'mass'|'count'|'informal', system } | null
ingredient: { name: string, descriptors: string[], preparation: string[] }
containerSize: { value: number, unit: {...} } | null // "1 can (15 oz)" → 15 oz, PER container
purpose: 'to taste'|'as needed'|'for garnish'|'for serving'|'for topping' | null
comment: string | null
original: string
}
Price (provenance, the key richness over v1's bare number)
{
cents: number
basis: 'gram' | 'unit-estimate' | 'free'
grams: number | null
perGramCents: number | null
confidence: 'high' | 'low' // 'high' = exact mass conversion; 'low' = density-estimated / unit fallback
}
What's new in 2.2
Four pricing gaps, all found in one pass over a real recipe. Each was a row reading "needs price" (or a wrong number) where an honest one was available.
Containers price by the size on the label. 1 can (15 oz) black beans used to price as one can, and a can of a bulk good has no honest per-item price, so the row declined. parse now records the parenthetical as containerSize and pricing multiplies it by the count, so 2 cans (14.5 oz) is 29 oz. The measure stays in comment too, so display doesn't change. jar, bottle, tin, carton and container joined the unit registry while I was there — they were missing entirely, so 1 jar (16 oz) salsa was looking up "jar salsa".
A free price basis. A provider price of 0 means "no data" here, so a genuine zero needed its own signal. basis: 'free' at high confidence covers tap water (an exact-match allowlist: coconut, sparkling, tonic and rose water are all still priced) and salt and pepper to taste. The free verdict doesn't depend on a successful lookup, which matters because the proxy has no water entry at all.
purpose is recorded instead of deleted. The trailing phrase is the only thing separating "no amount exists" from "the author forgot one". 1 tsp salt to taste still prices normally, a bare flour stays unknown rather than free, and for garnish is deliberately not free — a garnish is a small amount of a real ingredient. as needed also joined the strip list, fixing a row whose name parsed as "salt as needed" and missed lookup entirely.
Ground spices have densities. Milled spices at 0.5 g/ml, dried leaf herbs at 0.17. The density table stopped at salt, so every 1 tbsp <spice> declined — the biggest coverage hole the 2.1.0 pricing gate left behind. Herbs that are as often fresh as dried are left out on purpose, since parse strips "dried" and "fresh" alike and nothing tells them apart.
Nothing here is a breaking change. New fields are additive, and calculatePrice's new argument is optional.
Differences from v1
v2 is a deliberate breaking change. It was validated with a differential audit of 57,697 inputs run through both parsers; on realistic inputs v2 is equal-or-better on every case. The notable differences:
Richer / lossless output (the main reason to upgrade)
- Structured
quantityobject withmin/max/isRange/isApproximateinstead of three flat fields. unitis an object withtype+system(enables conversion/pricing logic) instead of a bare string.- Descriptors (
fresh,large, …) and preparation (chopped,minced, …) are kept iningredient.descriptors[]/preparation[]rather than silently stripped and lost. Pricecarries provenance (basis/confidence/grams) instead of a single opaque cents number.- Enrichment is key-free on the client (the proxy holds the Spoonacular key).
Bug fixes over v1 (verified in the differential)
3 large eggs→unit: null(v1 returnedunit: "large").2 bay leaves→ ingredient"bay leaf"(v1 returned"").juice of 1 lemon→"lemon"(v1 returned"juice of lemon").about 1 cup oats→"oats"(v1 left"about oats").a cup of sugar→{ qty: 1, unit: "cup", ingredient: "sugar" }(v1 mangled the name to"a of sugar").- Correct plurals:
pinch→"pinches"(v1"pinchs");strip/sprigget plurals (v1 returnednull). - No more consecutive-T corruption, no Italian
q.b.leak.
Intentional contract changes (update consumers accordingly)
commentholds the parenthetical content ("14 oz"), not the literal"(14 oz)". In the rich API it isnullwhen absent (the legacy adapter maps it back to'').- Fractions keep full precision:
1/3→0.3333…(v1 rounded to0.33). More accurate for scaling/pricing — but if anything downstream expected 2-decimal values, account for it. - Migration path: use
parseIngredientStringfor the flat v1 shape during transition.
Known limitations
Documented edge cases (negative quantities, scientific notation, malformed multi-dot numbers, etc.) are catalogued in the project's design notes. None throw; all degrade predictably.
License
ISC