# @jclind/ingredient-parser

> Parse natural-language ingredient strings into structured data, with vendor-neutral enrichment and price provenance

Latest version **2.2.0** (published 2026-08-27) · ISC license · 0 weekly downloads

## Install

```sh
npm install @jclind/ingredient-parser
pnpm add @jclind/ingredient-parser
yarn add @jclind/ingredient-parser
bun add @jclind/ingredient-parser
```

## Health

**Score 70/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; recently updated; high maintenance score; high quality score.

Warnings: low downloads.

## Facts

| | |
|---|---|
| Version | 2.2.0 |
| Published | 2026-08-27 |
| First published | 2023-01-19 |
| Weekly downloads | 0 |
| License | ISC |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Node | >=18 |
| Dependencies | 0 |
| Unpacked size | 152.5 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| GitHub stars | 1 |
| Author | Jesse Lind |
| Maintainers | jclind |
| Keywords | recipe, ingredient, parse, units, price, nutrition |

## Links

- npm: https://www.npmjs.com/package/@jclind/ingredient-parser
- Repository: https://github.com/jclind/ingredient-parser
- Homepage: https://github.com/jclind/ingredient-parser#readme
- Issues: https://github.com/jclind/ingredient-parser/issues
- npm.io page: https://npm.io/package/@jclind/ingredient-parser

## Alternatives

- [babylon](https://npm.io/package/babylon.md) — 5.1M weekly downloads
- [csscolorparser](https://npm.io/package/csscolorparser.md) — 3.7M weekly downloads
- [expr-eval-fork](https://npm.io/package/expr-eval-fork.md) — 1.5M weekly downloads
- [@leeoniya/ufuzzy](https://npm.io/package/@leeoniya/ufuzzy.md) — 247.7K weekly downloads
- [xml-parser](https://npm.io/package/xml-parser.md) — 78.4K weekly downloads

## Recent versions

- 2.2.0 (latest) — 2026-08-27
- 2.0.0 (next) — 2026-06-25
- 2.1.0 — 2026-08-25
- 1.3.4 — 2026-05-14
- 1.3.3 — 2026-05-14
- 1.3.2 — 2026-05-07
- 1.3.1 — 2026-05-06
- 1.3.0 — 2026-05-06
- 1.2.14 — 2026-05-06
- 1.2.13 — 2026-05-05
- 1.2.12 — 2026-05-05
- 1.2.11 — 2026-05-04
- 1.2.10 — 2026-05-04
- 1.2.9 — 2023-10-12
- 1.2.8 — 2023-10-12
- … 54 more at https://npm.io/package/@jclind/ingredient-parser/versions

## README

# @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](#differences-from-v1) section to migrate.

## Install

```bash
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:

```ts
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:

```ts
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`
```ts
{
  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)
```ts
{
  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 `quantity` object with `min`/`max`/`isRange`/`isApproximate` instead of three flat fields.
- `unit` is an object with `type` + `system` (enables conversion/pricing logic) instead of a bare string.
- Descriptors (`fresh`, `large`, …) and preparation (`chopped`, `minced`, …) are **kept** in `ingredient.descriptors[]` / `preparation[]` rather than silently stripped and lost.
- `Price` carries 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 returned `unit: "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`/`sprig` get plurals (v1 returned `null`).
- No more consecutive-T corruption, no Italian `q.b.` leak.

**Intentional contract changes** (update consumers accordingly)
- **`comment`** holds the parenthetical *content* (`"14 oz"`), not the literal `"(14 oz)"`. In the rich API it is `null` when absent (the legacy adapter maps it back to `''`).
- **Fractions keep full precision**: `1/3` → `0.3333…` (v1 rounded to `0.33`). More accurate for scaling/pricing — but if anything downstream expected 2-decimal values, account for it.
- **Migration path:** use `parseIngredientString` for 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

---
_Source: https://npm.io/package/@jclind/ingredient-parser · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
