Merge pull request #41 from kyuno053/feat/english-matching

feat(recipes): matching anglais pour les tech steps et les ingrédients
This commit is contained in:
kyuno053 2026-08-20 13:13:57 +02:00 committed by GitHub
commit 6178205603
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1467 additions and 61 deletions

View file

@ -41,6 +41,14 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
{ uid: "bunch", type: "COUNT", toBaseFactor: 1 },
{ uid: "sachet", type: "COUNT", toBaseFactor: 1 },
{ uid: "sprig", type: "COUNT", toBaseFactor: 1 },
// US customary units — added for English-language sources (TheMealDB and
// similar routinely state quantities in cups/oz/lb, not metric), so
// `ingredient-matcher.ts` has something to resolve them to instead of
// always falling back to `unitId: null`. Convertible against their
// metric siblings like any other MASS/VOLUME unit.
{ uid: "cup", type: "VOLUME", toBaseFactor: 236.5882 },
{ uid: "ounce", type: "MASS", toBaseFactor: 28.3495 },
{ uid: "pound", type: "MASS", toBaseFactor: 453.5924 },
];
// Cooking-technique catalog (French recipe-step normalization) — a static
@ -60,8 +68,11 @@ export const UNITS: Array<{ uid: string; type: UnitType; toBaseFactor: number }>
// weighted higher than the generic single-verb forms they overlap with
// ("cuire", "sauter") so the more specific technique wins when both match
// the same words. `locale` lets the same technique carry one matching rule
// set per language — every entry below is `"fr"` for now, the field exists
// so other languages can be added later without a schema change.
// set per language — `"fr"` and `"en"` today (the latter mainly for
// English-language sources like TheMealDB), more can be added later
// without a schema change. The two locales are independent rule sets, not
// translations of each other — an English recipe is matched only against
// the `"en"` mappings, never a mix of both.
export const TECH_STEPS: Array<{
uid: string;
mappings: Array<{ locale: string; expression: string; weight: number }>;
@ -70,11 +81,15 @@ export const TECH_STEPS: Array<{
uid: "cook",
mappings: [
{ locale: "fr", expression: "\\bcui(re|sez|sant|sson)\\b|\\bcuit(e|es|s)?\\b", weight: 10 },
{ locale: "en", expression: "\\bcook(s|ed|ing)?\\b", weight: 10 },
],
},
{
uid: "fry",
mappings: [{ locale: "fr", expression: "\\bfri(re|t|te|ts|tes|ture)\\b", weight: 15 }],
mappings: [
{ locale: "fr", expression: "\\bfri(re|t|te|ts|tes|ture)\\b", weight: 15 },
{ locale: "en", expression: "\\bfr(y|ies|ied|ying)\\b", weight: 15 },
],
},
{
uid: "melt",
@ -85,79 +100,131 @@ export const TECH_STEPS: Array<{
"\\bfondre\\b|\\bfondu(e|es|s)?\\b|\\bfaire fondre\\b|\\bfaites fondre\\b|\\bfaire chauffer\\b|\\bfaites chauffer\\b",
weight: 15,
},
{ locale: "en", expression: "\\bmelt(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "deglaze",
mappings: [{ locale: "fr", expression: "\\bd[ée]glac(er|ez|é|ée|age)\\b", weight: 20 }],
mappings: [
{ locale: "fr", expression: "\\bd[ée]glac(er|ez|é|ée|age)\\b", weight: 20 },
{ locale: "en", expression: "\\bdeglaz(e|es|ed|ing)\\b", weight: 20 },
],
},
{
uid: "simmer",
mappings: [{ locale: "fr", expression: "\\bmijot(er|ez|e|ant|é)\\b", weight: 15 }],
mappings: [
{ locale: "fr", expression: "\\bmijot(er|ez|e|ant|é)\\b", weight: 15 },
{ locale: "en", expression: "\\bsimmer(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "boil",
mappings: [
{ locale: "fr", expression: "\\bbouill(ir|ant|ie|ies)\\b|\\b[ée]bullition\\b", weight: 12 },
{ locale: "en", expression: "\\bboil(s|ed|ing)?\\b", weight: 12 },
],
},
{
uid: "roast",
mappings: [{ locale: "fr", expression: "\\br[ôo]tir\\b|\\br[ôo]ti(e|es|s)?\\b", weight: 15 }],
mappings: [
{ locale: "fr", expression: "\\br[ôo]tir\\b|\\br[ôo]ti(e|es|s)?\\b", weight: 15 },
{ locale: "en", expression: "\\broast(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "grill",
mappings: [{ locale: "fr", expression: "\\bgrill(er|ez|é|ée|ées|ade)\\b", weight: 15 }],
mappings: [
{ locale: "fr", expression: "\\bgrill(er|ez|é|ée|ées|ade)\\b", weight: 15 },
{ locale: "en", expression: "\\bgrill(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "panFry",
mappings: [{ locale: "fr", expression: "\\bsaut(er|ez|é|ée|ées|ant)\\b", weight: 12 }],
mappings: [
{ locale: "fr", expression: "\\bsaut(er|ez|é|ée|ées|ant)\\b", weight: 12 },
{
locale: "en",
expression: "\\bsaut[ée](s|ed|ing)?\\b|\\bpan[- ]?fr(y|ies|ied|ying)\\b",
weight: 12,
},
],
},
{
uid: "blanch",
mappings: [{ locale: "fr", expression: "\\bblanch(ir|issez|i|ie|ies|iment)\\b", weight: 18 }],
mappings: [
{ locale: "fr", expression: "\\bblanch(ir|issez|i|ie|ies|iment)\\b", weight: 18 },
{ locale: "en", expression: "\\bblanch(es|ed|ing)?\\b", weight: 18 },
],
},
{
uid: "marinate",
mappings: [{ locale: "fr", expression: "\\bmarin(er|ez|é|ée|ées|ade)\\b", weight: 18 }],
mappings: [
{ locale: "fr", expression: "\\bmarin(er|ez|é|ée|ées|ade)\\b", weight: 18 },
{ locale: "en", expression: "\\bmarinat(e|es|ed|ing)\\b|\\bmarinad(e|es)\\b", weight: 18 },
],
},
{
uid: "chop",
mappings: [{ locale: "fr", expression: "\\bhach(er|ez|é|ée|ées|is)\\b", weight: 15 }],
mappings: [
{ locale: "fr", expression: "\\bhach(er|ez|é|ée|ées|is)\\b", weight: 15 },
{ locale: "en", expression: "\\bchop(s|ped|ping)?\\b", weight: 15 },
],
},
{
uid: "peel",
mappings: [{ locale: "fr", expression: "\\b[ée]pluch(er|ez|é|ée|ées|age)\\b", weight: 15 }],
mappings: [
{ locale: "fr", expression: "\\b[ée]pluch(er|ez|é|ée|ées|age)\\b", weight: 15 },
{ locale: "en", expression: "\\bpeel(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "mince",
mappings: [{ locale: "fr", expression: "\\b[ée]minc(er|ez|é|ée|ées)\\b", weight: 18 }],
mappings: [
{ locale: "fr", expression: "\\b[ée]minc(er|ez|é|ée|ées)\\b", weight: 18 },
{ locale: "en", expression: "\\bminc(e|es|ed|ing)\\b", weight: 18 },
],
},
{
uid: "mix",
mappings: [{ locale: "fr", expression: "\\bm[ée]lang(er|ez|é|ée|ées|e|es)\\b", weight: 10 }],
mappings: [
{ locale: "fr", expression: "\\bm[ée]lang(er|ez|é|ée|ées|e|es)\\b", weight: 10 },
{ locale: "en", expression: "\\bmix(es|ed|ing)?\\b|\\bcombine(s|d)?\\b", weight: 10 },
],
},
{
uid: "whisk",
mappings: [
{ locale: "fr", expression: "\\bfouett(er|ez|é|ée|ées)\\b|\\bau fouet\\b", weight: 15 },
{ locale: "en", expression: "\\bwhisk(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "foldIn",
mappings: [{ locale: "fr", expression: "\\bincorpor(er|ez|é|ée|ées|ant)\\b", weight: 15 }],
mappings: [
{ locale: "fr", expression: "\\bincorpor(er|ez|é|ée|ées|ant)\\b", weight: 15 },
{ locale: "en", expression: "\\bfold(s|ed|ing)? in\\b", weight: 15 },
],
},
{
uid: "setAside",
mappings: [{ locale: "fr", expression: "\\br[ée]serv(er|ez|é|ée|ées)\\b", weight: 15 }],
mappings: [
{ locale: "fr", expression: "\\br[ée]serv(er|ez|é|ée|ées)\\b", weight: 15 },
{ locale: "en", expression: "\\bset(s)? aside\\b|\\bsetting aside\\b", weight: 15 },
],
},
{
uid: "season",
mappings: [{ locale: "fr", expression: "\\bassaisonn(er|ez|é|ée|ées|ement)\\b", weight: 15 }],
mappings: [
{ locale: "fr", expression: "\\bassaisonn(er|ez|é|ée|ées|ement)\\b", weight: 15 },
{ locale: "en", expression: "\\bseason(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "drain",
mappings: [{ locale: "fr", expression: "\\b[ée]goutt(er|ez|é|ée|ées)\\b", weight: 15 }],
mappings: [
{ locale: "fr", expression: "\\b[ée]goutt(er|ez|é|ée|ées)\\b", weight: 15 },
{ locale: "en", expression: "\\bdrain(s|ed|ing)?\\b", weight: 15 },
],
},
{
uid: "brown",
@ -168,17 +235,30 @@ export const TECH_STEPS: Array<{
"\\bfaire revenir\\b|\\bfaites revenir\\b|\\bfais revenir\\b|\\bfaire dorer\\b|\\bfaites dorer\\b",
weight: 25,
},
// Verb forms only (not bare "brown"), which would false-positive on
// ingredient descriptions like "brown sugar"/"brown rice".
{ locale: "en", expression: "\\bbrown(ed|ing)\\b", weight: 25 },
],
},
{
uid: "rest",
mappings: [
{ locale: "fr", expression: "\\blaiss(er|ez|e) reposer\\b|\\breposer\\b", weight: 20 },
// Anchored to "let ... rest"/"rest for" rather than bare "rest",
// which would false-positive on phrases like "the rest of the".
{
locale: "en",
expression: "\\blet (it |them )?rest\\b|\\brest(s|ed|ing)? for\\b",
weight: 20,
},
],
},
{
uid: "preheat",
mappings: [{ locale: "fr", expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b", weight: 20 }],
mappings: [
{ locale: "fr", expression: "\\bpr[ée]chauff(er|ez|é|ée)\\b", weight: 20 },
{ locale: "en", expression: "\\bpreheat(s|ed|ing)?\\b", weight: 20 },
],
},
{
uid: "bake",
@ -189,12 +269,26 @@ export const TECH_STEPS: Array<{
"\\bcuire au four\\b|\\bcuisson au four\\b|\\benfourn(er|ez|é|ée|ées)\\b|\\bau four\\b",
weight: 25,
},
{
locale: "en",
expression: "\\bbak(e|es|ed|ing)\\b|\\bin (a|the) (preheated )?oven\\b",
weight: 25,
},
],
},
{
uid: "plate",
mappings: [
{ locale: "fr", expression: "\\bdress(er|ez|age)\\b", weight: 15 },
{ locale: "en", expression: "\\bplat(e|es|ed|ing)\\b", weight: 15 },
],
},
{ uid: "plate", mappings: [{ locale: "fr", expression: "\\bdress(er|ez|age)\\b", weight: 15 }] },
{
uid: "coat",
mappings: [{ locale: "fr", expression: "\\bnapp(er|ez|é|ée|ées|age)\\b", weight: 15 }],
mappings: [
{ locale: "fr", expression: "\\bnapp(er|ez|é|ée|ées|age)\\b", weight: 15 },
{ locale: "en", expression: "\\bcoat(s|ed|ing)?\\b", weight: 15 },
],
},
];

View file

@ -0,0 +1,191 @@
import { INGREDIENT_LABELS_EN, UNIT_LABELS_EN } from "@batch-cooking/shared";
import { prisma } from "../db/prisma.js";
import { normalizeText } from "./tech-step-matcher.js";
/**
* Resolves the free-text `name`/`unit`/`quantity` a `RecipeSourceAdapter`
* lifts from an English-language source (`ParsedRecipeIngredient`) against
* our own `Ingredient`/`Unit` reference catalogs the ingredient-side
* counterpart to `tech-step-matcher.ts`'s technique detection, built for
* the same reason: an English source's raw text has no idea our catalogs
* even exist.
*
* Unlike tech steps (regex mappings hand-authored per technique),
* ingredient/unit labels are plain hand-written English text
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) matching
* them against arbitrary free text (extra adjectives, plurals, "large diced
* yellow onion" for a catalog entry that's just "Onion") needs its own,
* lighter algorithm: word-tokenize both sides, naively stem for plurals,
* then look for the catalog phrase's tokens as a contiguous run inside the
* ingredient text's tokens. The longest (most specific) matching catalog
* phrase wins, same "specificity resolves overlaps" idea as
* `matchTechSteps`, just without weights (there's no need to rank two
* *unrelated* ingredients only a phrase against its own substrings, e.g.
* "chicken breast" beating bare "chicken").
*
* `matchIngredientName`/`matchUnit`/`extractQuantity` are pure, so they're
* unit-testable without a database (see `test/ingredient-matcher.test.ts`);
* `loadIngredientCatalog`/`loadUnitCatalog` are the only DB-touching pieces,
* meant to be fetched once per request and reused across every ingredient
* line, the same "don't requery per item" convention as
* `loadTechStepMappingRules`.
*/
/** One `Ingredient` row trimmed to what {@link matchIngredientName} needs, alongside its English matching label. */
export interface IngredientMatchEntry {
ingredientId: number;
/** English label from `INGREDIENT_LABELS_EN`, e.g. `"Chicken breast"` — matched against free text, never displayed. */
label: string;
}
/** One `Unit` row trimmed to what {@link matchUnit} needs, alongside its accepted English spellings. */
export interface UnitMatchEntry {
unitId: number;
/** Accepted spellings from `UNIT_LABELS_EN`, e.g. `["tbsp", "tbs", "tablespoon", "tablespoons"]`. */
synonyms: string[];
}
/**
* Naive English stemmer strips a plural suffix so "tomato"/"tomatoes",
* "onion"/"onions", "cherry"/"cherries" compare equal after stemming.
* Deliberately not a real linguistic stemmer: it only has to be
* *consistent* (the same word always stems the same way) since both sides
* of every comparison go through it, not linguistically correct on its
* own see the module doc comment.
*/
function stemWord(word: string): string {
if (word.endsWith("ies") && word.length > 4) return `${word.slice(0, -3)}y`;
if (word.endsWith("es") && word.length > 3) return word.slice(0, -2);
if (word.endsWith("s") && !word.endsWith("ss") && word.length > 3) return word.slice(0, -1);
return word;
}
/** Lowercases, strips diacritics (via `normalizeText`) and splits `text` into stemmed word tokens — empty/non-letter runs are dropped, not kept as empty tokens. */
function tokenize(text: string): string[] {
return normalizeText(text)
.split(/[^a-z]+/)
.filter((word) => word.length > 0)
.map(stemWord);
}
/** Whether `needle` appears as a contiguous run inside `haystack`, at any starting position. */
function containsSubsequence(haystack: string[], needle: string[]): boolean {
if (needle.length === 0 || needle.length > haystack.length) return false;
for (let start = 0; start <= haystack.length - needle.length; start++) {
if (needle.every((word, i) => haystack[start + i] === word)) return true;
}
return false;
}
/**
* Resolves free-text `name` (a `ParsedRecipeIngredient.name`, e.g. `"large
* diced yellow onions"`) to the best-matching `Ingredient` in `catalog`, or
* `null` if nothing matches. Among every catalog entry whose label's words
* all appear as a contiguous run in `name`, the one with the most words
* wins (most specific "chicken breast" over bare "chicken"); ties break
* on the lowest `ingredientId`, for a deterministic result independent of
* catalog order.
*/
export function matchIngredientName(name: string, catalog: IngredientMatchEntry[]): number | null {
const nameTokens = tokenize(name);
if (nameTokens.length === 0) return null;
let best: { ingredientId: number; tokenCount: number } | null = null;
for (const entry of catalog) {
const labelTokens = tokenize(entry.label);
if (!containsSubsequence(nameTokens, labelTokens)) continue;
if (
best === null ||
labelTokens.length > best.tokenCount ||
(labelTokens.length === best.tokenCount && entry.ingredientId < best.ingredientId)
) {
best = { ingredientId: entry.ingredientId, tokenCount: labelTokens.length };
}
}
return best?.ingredientId ?? null;
}
/**
* Resolves free-text `unitText` (e.g. `"tbsp"`, `"Cups"`) to the matching
* `Unit` in `catalog`, or `null` if nothing matches. A unit is a single
* word by convention (see `UNIT_LABELS_EN`), so this is a whole-token
* equality check (after stemming/normalizing), not the substring search
* `matchIngredientName` does `"cup"` shouldn't match inside an unrelated
* longer word.
*/
export function matchUnit(unitText: string, catalog: UnitMatchEntry[]): number | null {
const tokens = tokenize(unitText);
if (tokens.length === 0) return null;
const firstToken = tokens[0];
for (const entry of catalog) {
if (entry.synonyms.some((synonym) => stemWord(normalizeText(synonym)) === firstToken)) {
return entry.unitId;
}
}
return null;
}
/** What {@link extractQuantity} pulls out of a leading numeric expression, alongside what's left of the string after it. */
export interface ExtractedQuantity {
quantity: number | null;
/** `rawText` with the leading quantity (and any separating whitespace) removed — `rawText` unchanged if none was found. */
remainder: string;
}
// Leading "1 1/2", "1/2", "1.5", "1,5" or "2" (optionally followed by a
// hyphenated range like "2-3", in which case only the first number counts —
// good enough for a best-effort quantity, not meant to model ranges.
const LEADING_QUANTITY_PATTERN = /^(\d+)\s+(\d+)\/(\d+)|^(\d+)\/(\d+)|^(\d+(?:[.,]\d+)?)/;
/**
* Pulls a leading quantity off `rawText` (e.g. `"1 1/2 cups flour"` ->
* `{ quantity: 1.5, remainder: "cups flour" }`), supporting a plain
* integer/decimal, a simple fraction (`"1/2"`), or a mixed number
* (`"1 1/2"`). `quantity: null` (remainder === rawText, trimmed) when
* `rawText` doesn't start with a recognizable number e.g. `"salt to
* taste"`, which has none.
*/
export function extractQuantity(rawText: string): ExtractedQuantity {
const trimmed = rawText.trim();
const match = LEADING_QUANTITY_PATTERN.exec(trimmed);
if (!match) return { quantity: null, remainder: trimmed };
let quantity: number;
if (match[1] !== undefined) {
// Mixed number: "1 1/2".
quantity = Number(match[1]) + Number(match[2]) / Number(match[3]);
} else if (match[4] !== undefined) {
// Simple fraction: "1/2".
quantity = Number(match[4]) / Number(match[5]);
} else {
// Plain integer/decimal: "2" or "1.5"/"1,5" — group 6 is guaranteed
// defined here (the only remaining alternative in the pattern), `?? ""`
// is just satisfying the indexed-access type, not a real fallback.
quantity = Number((match[6] ?? "").replace(",", "."));
}
return { quantity, remainder: trimmed.slice(match[0].length).trim() };
}
/** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s — one entry per key with an authored English label (see `INGREDIENT_LABELS_EN`); an ingredient with no English label yet is silently skipped, never a matching target. Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */
export async function loadIngredientCatalog(): Promise<IngredientMatchEntry[]> {
const ingredients = await prisma.ingredient.findMany({ select: { id: true, key: true } });
const catalog: IngredientMatchEntry[] = [];
for (const ingredient of ingredients) {
const label = INGREDIENT_LABELS_EN[ingredient.key];
if (label !== undefined) catalog.push({ ingredientId: ingredient.id, label });
}
return catalog;
}
/** Loads the full `Unit` catalog as {@link UnitMatchEntry}s — one entry per key with authored English synonyms (see `UNIT_LABELS_EN`); a unit with none yet is silently skipped. Meant to be fetched once per request, same reasoning as {@link loadIngredientCatalog}. */
export async function loadUnitCatalog(): Promise<UnitMatchEntry[]> {
const units = await prisma.unit.findMany({ select: { id: true, key: true } });
const catalog: UnitMatchEntry[] = [];
for (const unit of units) {
const synonyms = UNIT_LABELS_EN[unit.key];
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
}
return catalog;
}

View file

@ -1,4 +1,17 @@
import type { ParsedRecipe, ParsedRecipeStep } from "./recipe-source-adapter.js";
import {
type IngredientMatchEntry,
type UnitMatchEntry,
extractQuantity,
loadIngredientCatalog,
loadUnitCatalog,
matchIngredientName,
matchUnit,
} from "./ingredient-matcher.js";
import type {
ParsedRecipe,
ParsedRecipeIngredient,
ParsedRecipeStep,
} from "./recipe-source-adapter.js";
import {
type TechStepMappingRule,
loadTechStepMappingRules,
@ -13,17 +26,20 @@ import {
* technique sequence, the same `techStepIds: number[]` shape
* `Step.techSteps`/`StepTechStep` (schema.prisma) will eventually persist.
*
* Deliberately doesn't touch ingredients resolving free-text ingredient
* lines against our `Ingredient`/`Unit` catalogs is a separate, not-yet-built
* concern (see `ParsedRecipeIngredient`'s doc comment) — and doesn't turn
* the result into a saveable `Recipe` either (no `dietIds`/`visibility`/
* author, a source can't know those). This is one step of the pipeline, not
* the whole thing.
* Also resolves ingredients matching each free-text `ParsedRecipeIngredient`
* line against our `Ingredient`/`Unit` catalogs (`ingredient-matcher.ts`),
* the same "free source text -> our catalog id" idea as tech-step
* detection, just for ingredients/units/quantities instead of technique
* verbs. Like tech-step matching, this doesn't turn the result into a
* saveable `Recipe` either (no `dietIds`/`visibility`/author, a source
* can't know those) this is one step of the pipeline, not the whole
* thing.
*
* `translateRecipeSteps` is pure (takes `techStepMappings` as a plain
* argument, same convention as `matchTechSteps` itself) so it's unit-testable
* without a database; `translateRecipe` is the DB-backed convenience wrapper
* a caller reaches for in practice, mirroring `tech-step-matcher.ts`'s own
* `translateRecipeSteps`/`translateRecipeIngredients` are pure (take their
* matching data as plain arguments, same convention as `matchTechSteps`/
* `matchIngredientName` themselves) so they're unit-testable without a
* database; `translateRecipe` is the DB-backed convenience wrapper a caller
* reaches for in practice, mirroring `tech-step-matcher.ts`'s own
* pure/DB-touching split.
*/
@ -33,18 +49,30 @@ export interface TranslatedRecipeStep extends ParsedRecipeStep {
techStepIds: number[];
}
/** A {@link ParsedRecipe} whose `steps` have been translated — everything else (name, ingredients, portions, …) passes through unchanged. */
export interface TranslatedRecipe extends Omit<ParsedRecipe, "steps"> {
/** A {@link ParsedRecipeIngredient}, after ingredient/unit matching — `quantity` is filled in from `rawText` when the source itself left it `null` (see `extractQuantity`); `ingredientId`/`unitId` are `null` when nothing in the catalog matched. */
export interface TranslatedRecipeIngredient extends ParsedRecipeIngredient {
ingredientId: number | null;
unitId: number | null;
}
/** A {@link ParsedRecipe} whose `steps`/`ingredients` have been translated — everything else (name, portions, …) passes through unchanged. */
export interface TranslatedRecipe extends Omit<ParsedRecipe, "steps" | "ingredients"> {
steps: TranslatedRecipeStep[];
ingredients: TranslatedRecipeIngredient[];
}
/**
* Declares each of `recipe`'s steps' technique sequence against
* `techStepMappings`, leaving everything else about the recipe untouched.
* Pure testable with a hand-built mapping list, no database involved (see
* `translateRecipe` for the DB-backed loader). `techStepMappings` should
* already be filtered to the locale the caller cares about, same
* requirement `matchTechSteps` itself has.
* `techStepMappings`, leaving everything else about the recipe untouched
* including ingredients, which are only stubbed to `TranslatedRecipeIngredient`'s
* shape here (`ingredientId`/`unitId: null`, `quantity`/`unit` untouched);
* actually resolving them is {@link translateRecipeIngredients}'s job, kept
* separate the same way tech-step and ingredient matching are two
* independent concerns everywhere else in this module. Pure testable with
* a hand-built mapping list, no database involved (see `translateRecipe`
* for the DB-backed loader). `techStepMappings` should already be filtered
* to the locale the caller cares about, same requirement `matchTechSteps`
* itself has.
*/
export function translateRecipeSteps(
recipe: ParsedRecipe,
@ -52,6 +80,11 @@ export function translateRecipeSteps(
): TranslatedRecipe {
return {
...recipe,
ingredients: recipe.ingredients.map((ingredient) => ({
...ingredient,
ingredientId: null,
unitId: null,
})),
steps: recipe.steps.map((step) => ({
...step,
techStepIds: matchTechSteps(step.description, techStepMappings),
@ -60,14 +93,39 @@ export function translateRecipeSteps(
}
/**
* Convenience wrapper around {@link translateRecipeSteps} that loads
* `locale`'s mapping catalog itself what a caller reaches for when
* translating a single recipe on its own (e.g. the eventual "import this
* one recipe" endpoint). A caller translating many recipes at once should
* call `loadTechStepMappingRules` once and reuse it across
* `translateRecipeSteps` calls instead, the same "don't requery per item"
* reasoning `recipe.service.ts`'s `createRecipe`/`updateRecipe` already
* follow for manually-authored recipes.
* Resolves each of `ingredients`' free-text `name`/`unit`/`quantity`
* against `ingredientCatalog`/`unitCatalog` (see `ingredient-matcher.ts`).
* Pure testable with hand-built catalogs, no database involved (see
* `translateRecipe` for the DB-backed loader). `quantity` falls back to
* `extractQuantity(rawText)` only when the source itself left it `null`;
* same for `unit` falling back to `extractQuantity`'s `remainder` before
* being matched against `unitCatalog` a source that already states a
* clean unit/quantity is trusted over re-deriving it from `rawText`.
*/
export function translateRecipeIngredients(
ingredients: ParsedRecipeIngredient[],
ingredientCatalog: IngredientMatchEntry[],
unitCatalog: UnitMatchEntry[],
): TranslatedRecipeIngredient[] {
return ingredients.map((ingredient) => {
const ingredientId = matchIngredientName(ingredient.name, ingredientCatalog);
const extracted = extractQuantity(ingredient.rawText);
const quantity = ingredient.quantity ?? extracted.quantity;
const unitText = ingredient.unit ?? extracted.remainder;
const unitId = matchUnit(unitText, unitCatalog);
return { ...ingredient, quantity, ingredientId, unitId };
});
}
/**
* Convenience wrapper around {@link translateRecipeSteps}/
* {@link translateRecipeIngredients} that loads every catalog itself
* what a caller reaches for when translating a single recipe on its own
* (e.g. the eventual "import this one recipe" endpoint). A caller
* translating many recipes at once should load the catalogs once and reuse
* them across calls instead, the same "don't requery per item" reasoning
* `recipe.service.ts`'s `createRecipe`/`updateRecipe` already follow for
* manually-authored recipes.
*
* No user- or recipe-level language preference exists anywhere in the app
* yet (see `tech-step-matcher.ts`'s `loadTechStepMappingRules`) callers
@ -76,11 +134,30 @@ export function translateRecipeSteps(
* mappings will currently get an empty `techStepIds` sequence on every
* step matching-language mappings for that source's language don't exist
* yet, this stage doesn't invent them.
*
* Ingredient/unit matching only has English data today
* (`INGREDIENT_LABELS_EN`/`UNIT_LABELS_EN`, `packages/shared`) for any
* `locale` other than `"en"` this skips `loadIngredientCatalog`/
* `loadUnitCatalog` entirely and leaves every ingredient's `ingredientId`/
* `unitId` at the neutral `null` `translateRecipeSteps` already stubs in,
* the same "no matching-language data" degradation tech-step matching
* already has for a locale with no mappings.
*/
export async function translateRecipe(
recipe: ParsedRecipe,
locale: string,
): Promise<TranslatedRecipe> {
const techStepMappings = await loadTechStepMappingRules(locale);
return translateRecipeSteps(recipe, techStepMappings);
const translated = translateRecipeSteps(recipe, techStepMappings);
if (locale !== "en") return translated;
const [ingredientCatalog, unitCatalog] = await Promise.all([
loadIngredientCatalog(),
loadUnitCatalog(),
]);
return {
...translated,
ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog),
};
}

View file

@ -0,0 +1,218 @@
import { expect } from "chai";
import { prisma } from "../src/db/prisma.js";
import {
type IngredientMatchEntry,
type UnitMatchEntry,
extractQuantity,
loadIngredientCatalog,
loadUnitCatalog,
matchIngredientName,
matchUnit,
} from "../src/lib/ingredient-matcher.js";
import { resetDatabase } from "../test-support/reset-db.js";
describe("ingredient-matcher", () => {
describe("matchIngredientName", () => {
const tomato: IngredientMatchEntry = { ingredientId: 1, label: "Tomato" };
const chicken: IngredientMatchEntry = { ingredientId: 2, label: "Chicken" };
const chickenBreast: IngredientMatchEntry = { ingredientId: 3, label: "Chicken breast" };
const onion: IngredientMatchEntry = { ingredientId: 4, label: "Onion" };
const allPurposeFlour: IngredientMatchEntry = { ingredientId: 5, label: "All-purpose flour" };
const catalog = [tomato, chicken, chickenBreast, onion, allPurposeFlour];
it("matches an exact single-word label", () => {
expect(matchIngredientName("tomato", catalog)).to.equal(tomato.ingredientId);
});
it("is case- and accent-insensitive", () => {
expect(matchIngredientName("TOMATO", catalog)).to.equal(tomato.ingredientId);
expect(matchIngredientName("Tömato", catalog)).to.equal(tomato.ingredientId);
});
it("tolerates a regular plural", () => {
expect(matchIngredientName("tomatoes", catalog)).to.equal(tomato.ingredientId);
expect(matchIngredientName("onions", catalog)).to.equal(onion.ingredientId);
});
it("tolerates extra descriptive words around the match", () => {
expect(matchIngredientName("2 large diced yellow onions", catalog)).to.equal(
onion.ingredientId,
);
});
it("prefers the more specific multi-word label over a shorter one it contains", () => {
expect(matchIngredientName("boneless skinless chicken breasts", catalog)).to.equal(
chickenBreast.ingredientId,
);
});
it("still matches the shorter label when the more specific one isn't mentioned", () => {
expect(matchIngredientName("diced chicken thighs", catalog)).to.equal(chicken.ingredientId);
});
it("matches a hyphenated multi-word label", () => {
expect(matchIngredientName("2 cups all-purpose flour", catalog)).to.equal(
allPurposeFlour.ingredientId,
);
});
it("doesn't false-positive a short label inside an unrelated longer word", () => {
// "egg" must not match inside "eggplant" — whole-token comparison, not substring.
const eggplant: IngredientMatchEntry = { ingredientId: 6, label: "Eggplant" };
const egg: IngredientMatchEntry = { ingredientId: 7, label: "Egg" };
expect(matchIngredientName("eggplant", [egg, eggplant])).to.equal(eggplant.ingredientId);
});
it("returns null when nothing matches", () => {
expect(matchIngredientName("mango", catalog)).to.equal(null);
});
it("returns null for an empty catalog", () => {
expect(matchIngredientName("tomato", [])).to.equal(null);
});
it("returns null for an empty name", () => {
expect(matchIngredientName("", catalog)).to.equal(null);
});
it("breaks a same-specificity tie by the lowest ingredientId", () => {
const onionA: IngredientMatchEntry = { ingredientId: 20, label: "Onion" };
const onionB: IngredientMatchEntry = { ingredientId: 21, label: "Onion" };
expect(matchIngredientName("onion", [onionB, onionA])).to.equal(20);
});
});
describe("matchUnit", () => {
const gram: UnitMatchEntry = { unitId: 1, synonyms: ["g", "gram", "grams"] };
const tablespoon: UnitMatchEntry = {
unitId: 2,
synonyms: ["tbsp", "tbs", "tablespoon", "tablespoons"],
};
const cup: UnitMatchEntry = { unitId: 3, synonyms: ["cup", "cups"] };
const catalog = [gram, tablespoon, cup];
it("matches a full word synonym", () => {
expect(matchUnit("tablespoon", catalog)).to.equal(tablespoon.unitId);
});
it("matches an abbreviation synonym", () => {
expect(matchUnit("tbsp", catalog)).to.equal(tablespoon.unitId);
});
it("matches a plural synonym via the same stemming as ingredients", () => {
expect(matchUnit("cups", catalog)).to.equal(cup.unitId);
});
it("is case-insensitive", () => {
expect(matchUnit("TBSP", catalog)).to.equal(tablespoon.unitId);
});
it("only looks at the first word — ignores trailing text", () => {
expect(matchUnit("cup flour", catalog)).to.equal(cup.unitId);
});
it("doesn't match a short abbreviation inside an unrelated word", () => {
// "g" alone must not match "grated" — whole-token comparison.
expect(matchUnit("grated", catalog)).to.equal(null);
});
it("returns null when nothing matches", () => {
expect(matchUnit("pound", catalog)).to.equal(null);
});
it("returns null for an empty catalog", () => {
expect(matchUnit("cup", [])).to.equal(null);
});
it("returns null for an empty string", () => {
expect(matchUnit("", catalog)).to.equal(null);
});
});
describe("extractQuantity", () => {
it("extracts a plain integer", () => {
expect(extractQuantity("2 onions")).to.deep.equal({ quantity: 2, remainder: "onions" });
});
it("extracts a decimal using a dot", () => {
expect(extractQuantity("1.5 cups flour")).to.deep.equal({
quantity: 1.5,
remainder: "cups flour",
});
});
it("extracts a decimal using a comma", () => {
expect(extractQuantity("1,5 cups flour")).to.deep.equal({
quantity: 1.5,
remainder: "cups flour",
});
});
it("extracts a simple fraction", () => {
expect(extractQuantity("1/2 cup sugar")).to.deep.equal({
quantity: 0.5,
remainder: "cup sugar",
});
});
it("extracts a mixed number", () => {
expect(extractQuantity("1 1/2 cups sugar")).to.deep.equal({
quantity: 1.5,
remainder: "cups sugar",
});
});
it("returns null quantity and the trimmed original text when there's no leading number", () => {
expect(extractQuantity("salt to taste")).to.deep.equal({
quantity: null,
remainder: "salt to taste",
});
});
it("trims surrounding whitespace", () => {
expect(extractQuantity(" 2 eggs ")).to.deep.equal({ quantity: 2, remainder: "eggs" });
});
it("only takes the first number of a hyphenated range", () => {
expect(extractQuantity("2-3 carrots")).to.deep.equal({
quantity: 2,
remainder: "-3 carrots",
});
});
});
describe("loadIngredientCatalog / loadUnitCatalog", () => {
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
it("loads one entry per Ingredient that has an English label, keyed by real ingredientId", async () => {
const tomato = await prisma.ingredient.findFirstOrThrow({ where: { key: "tomato" } });
const ingredientCount = await prisma.ingredient.count();
const catalog = await loadIngredientCatalog();
// Every seeded ingredient has an authored English label (verified at
// generation time — see packages/shared/src/data/catalog-labels-en.ts),
// so nothing should be silently skipped.
expect(catalog).to.have.length(ingredientCount);
const tomatoEntry = catalog.find((entry) => entry.ingredientId === tomato.id);
expect(tomatoEntry?.label).to.equal("Tomato");
});
it("loads one entry per Unit that has English synonyms, keyed by real unitId", async () => {
const cup = await prisma.unit.findFirstOrThrow({ where: { key: "cup" } });
const unitCount = await prisma.unit.count();
const catalog = await loadUnitCatalog();
expect(catalog).to.have.length(unitCount);
const cupEntry = catalog.find((entry) => entry.unitId === cup.id);
expect(cupEntry?.synonyms).to.deep.equal(["cup", "cups"]);
});
});
});

View file

@ -1,7 +1,12 @@
import { expect } from "chai";
import { prisma } from "../src/db/prisma.js";
import type { ParsedRecipe } from "../src/lib/recipe-source-adapter.js";
import { translateRecipe, translateRecipeSteps } from "../src/lib/recipe-translation.js";
import type { IngredientMatchEntry, UnitMatchEntry } from "../src/lib/ingredient-matcher.js";
import type { ParsedRecipe, ParsedRecipeIngredient } from "../src/lib/recipe-source-adapter.js";
import {
translateRecipe,
translateRecipeIngredients,
translateRecipeSteps,
} from "../src/lib/recipe-translation.js";
import type { TechStepMappingRule } from "../src/lib/tech-step-matcher.js";
import { resetDatabase } from "../test-support/reset-db.js";
@ -78,7 +83,16 @@ describe("recipe-translation", () => {
expect(translated.picture).to.equal(recipe.picture);
expect(translated.portions).to.equal(recipe.portions);
expect(translated.sourceUrl).to.equal(recipe.sourceUrl);
expect(translated.ingredients).to.deep.equal(recipe.ingredients);
});
it("stubs every ingredient's ingredientId/unitId to null, leaving the rest of it untouched — actual ingredient matching is translateRecipeIngredients' job", () => {
const recipe = buildParsedRecipe(["Servir"]);
const translated = translateRecipeSteps(recipe, []);
expect(translated.ingredients).to.deep.equal([
{ ...recipe.ingredients[0], ingredientId: null, unitId: null },
]);
});
it("gives every step an empty sequence when there are no mappings at all", () => {
@ -98,6 +112,111 @@ describe("recipe-translation", () => {
});
});
describe("translateRecipeIngredients", () => {
const tomato: IngredientMatchEntry = { ingredientId: 1, label: "Tomato" };
const chicken: IngredientMatchEntry = { ingredientId: 2, label: "Chicken" };
const chickenBreast: IngredientMatchEntry = { ingredientId: 3, label: "Chicken breast" };
const onion: IngredientMatchEntry = { ingredientId: 4, label: "Onion" };
const gram: UnitMatchEntry = { unitId: 10, synonyms: ["g", "gram", "grams"] };
const cup: UnitMatchEntry = { unitId: 11, synonyms: ["cup", "cups"] };
function buildIngredient(
overrides: Partial<ParsedRecipeIngredient> & { rawText: string; name: string },
): ParsedRecipeIngredient {
return { quantity: null, unit: null, ...overrides };
}
it("resolves ingredientId from free-text name, tolerating extra descriptive words and plurals", () => {
const translated = translateRecipeIngredients(
[
buildIngredient({
rawText: "2 large diced yellow onions",
name: "large diced yellow onions",
}),
],
[tomato, chicken, chickenBreast, onion],
[],
);
expect(translated[0].ingredientId).to.equal(onion.ingredientId);
});
it("prefers the more specific multi-word label over a shorter one it contains", () => {
const translated = translateRecipeIngredients(
[
buildIngredient({
rawText: "2 boneless chicken breasts",
name: "boneless chicken breasts",
}),
],
[tomato, chicken, chickenBreast, onion],
[],
);
expect(translated[0].ingredientId).to.equal(chickenBreast.ingredientId);
});
it("returns a null ingredientId when nothing in the catalog matches", () => {
const translated = translateRecipeIngredients(
[buildIngredient({ rawText: "1 mango", name: "mango" })],
[tomato, chicken, chickenBreast, onion],
[],
);
expect(translated[0].ingredientId).to.equal(null);
});
it("extracts a mixed-number quantity and unit from rawText when the source left them null", () => {
const translated = translateRecipeIngredients(
[buildIngredient({ rawText: "1 1/2 cups chicken breast", name: "chicken breast" })],
[chickenBreast],
[cup],
);
expect(translated[0].quantity).to.equal(1.5);
expect(translated[0].unitId).to.equal(cup.unitId);
});
it("trusts the source's own quantity/unit over re-deriving them from rawText", () => {
const translated = translateRecipeIngredients(
[
buildIngredient({
rawText: "some raw text that happens to mention cups",
name: "tomato",
quantity: 3,
unit: "g",
}),
],
[tomato],
[gram, cup],
);
expect(translated[0].quantity).to.equal(3);
expect(translated[0].unitId).to.equal(gram.unitId);
});
it("leaves quantity/unitId null when rawText has neither a leading number nor a recognizable unit", () => {
const translated = translateRecipeIngredients(
[buildIngredient({ rawText: "salt to taste", name: "salt" })],
[],
[],
);
expect(translated[0].quantity).to.equal(null);
expect(translated[0].unitId).to.equal(null);
});
it("leaves rawText/name/description untouched", () => {
const ingredient = buildIngredient({ rawText: "1 cup onions", name: "onions" });
const translated = translateRecipeIngredients([ingredient], [onion], [cup]);
expect(translated[0].rawText).to.equal(ingredient.rawText);
expect(translated[0].name).to.equal(ingredient.name);
});
});
describe("translateRecipe", () => {
beforeEach(async () => {
await resetDatabase();
@ -120,7 +239,7 @@ describe("recipe-translation", () => {
]);
});
it("finds nothing for English text against the French catalog — the current locale gap", async () => {
it("finds nothing for English text against the French catalog — locales are separate rule sets, never mixed", async () => {
// A step lifted verbatim from a real TheMealDB recipe.
const recipe = buildParsedRecipe([
"Bring a large saucepan of salted water to the boil",
@ -132,12 +251,76 @@ describe("recipe-translation", () => {
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[], []]);
});
it("finds nothing for a locale with no mappings, even for text that would otherwise match", async () => {
const recipe = buildParsedRecipe(["Faire mijoter à feu doux"]);
it("resolves real TechStep ids from the seeded English catalog", async () => {
const boil = await prisma.techStep.findFirstOrThrow({ where: { key: "boil" } });
const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } });
// Same two steps as the French/English mismatch test above, this
// time matched against the matching-language catalog.
const recipe = buildParsedRecipe([
"Bring a large saucepan of salted water to the boil",
"Chop the onions finely",
]);
const translated = await translateRecipe(recipe, "en");
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([
[boil.id],
[chop.id],
]);
});
it("finds nothing for a locale with no mappings at all", async () => {
const recipe = buildParsedRecipe(["Faire mijoter à feu doux"]);
const translated = await translateRecipe(recipe, "de");
expect(translated.steps.map((step) => step.techStepIds)).to.deep.equal([[]]);
});
it("resolves real Ingredient/Unit ids from the seeded English catalog for an 'en' translation", async () => {
const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } });
const cup = await prisma.unit.findFirstOrThrow({ where: { key: "cup" } });
const recipe: ParsedRecipe = {
...buildParsedRecipe(["Chop the onions finely"]),
ingredients: [
{ rawText: "1 cup onions, chopped", quantity: null, unit: null, name: "onions" },
],
};
const translated = await translateRecipe(recipe, "en");
expect(translated.ingredients).to.deep.equal([
{
rawText: "1 cup onions, chopped",
quantity: 1,
unit: null,
name: "onions",
ingredientId: onion.id,
unitId: cup.id,
},
]);
});
it("leaves ingredients untouched (no quantity extraction either) for a non-English locale — no matching data exists yet, and the DB isn't even queried for it", async () => {
const recipe: ParsedRecipe = {
...buildParsedRecipe(["Faire mijoter à feu doux"]),
ingredients: [
{ rawText: "1 cup onions, chopped", quantity: null, unit: null, name: "onions" },
],
};
const translated = await translateRecipe(recipe, "fr");
expect(translated.ingredients).to.deep.equal([
{
rawText: "1 cup onions, chopped",
quantity: null,
unit: null,
name: "onions",
ingredientId: null,
unitId: null,
},
]);
});
});
});

View file

@ -108,7 +108,7 @@ describe("Reference data", () => {
const res = await request(app).get("/reference/units");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(14);
expect(res.body).to.have.length(17);
expect(res.body.map((u: { key: string }) => u.key)).to.include("gram");
expect(res.body[0]).to.have.keys(["id", "key", "type", "toBaseFactor"]);
});
@ -122,6 +122,9 @@ describe("Reference data", () => {
expect(byKey("liter")).to.include({ type: "VOLUME", toBaseFactor: 1000 });
expect(byKey("piece")).to.include({ type: "COUNT", toBaseFactor: 1 });
expect(byKey("pinch")).to.include({ type: "COUNT", toBaseFactor: 1 });
expect(byKey("cup")).to.include({ type: "VOLUME", toBaseFactor: 236.5882 });
expect(byKey("ounce")).to.include({ type: "MASS", toBaseFactor: 28.3495 });
expect(byKey("pound")).to.include({ type: "MASS", toBaseFactor: 453.5924 });
});
});
@ -149,7 +152,8 @@ describe("Reference data", () => {
const res = await request(app).get("/reference/tech-steps");
expect(res.body).to.have.length(26);
expect(await prisma.techStepMapping.count()).to.equal(26);
// 26 techniques × one "fr" + one "en" mapping each.
expect(await prisma.techStepMapping.count()).to.equal(52);
});
});

View file

@ -162,18 +162,21 @@ describe("tech-step-matcher", () => {
it("only returns mappings for the requested locale", async () => {
const simmer = await prisma.techStep.findFirstOrThrow({ where: { key: "simmer" } });
// "de" has no seeded mappings at all (unlike "fr"/"en", which the
// real catalog now both populate) — a clean locale to attach one
// synthetic row to without conflating it with real seed data.
await prisma.techStepMapping.create({
data: { techStepId: simmer.id, locale: "en", expression: "\\bsimmer\\b", weight: 15 },
data: { techStepId: simmer.id, locale: "de", expression: "\\bsimmer\\b", weight: 15 },
});
// The seeded catalog (26 "fr" mappings) must be untouched by the extra
// "en" row — same count, and none of them carry the English expression.
// "de" row — same count, and none of them carry its expression.
const frRules = await loadTechStepMappingRules("fr");
expect(frRules).to.have.length(26);
expect(frRules.map((rule) => rule.expression)).to.not.include("\\bsimmer\\b");
const enRules = await loadTechStepMappingRules("en");
expect(enRules).to.deep.equal([
const deRules = await loadTechStepMappingRules("de");
expect(deRules).to.deep.equal([
{ techStepId: simmer.id, expression: "\\bsimmer\\b", weight: 15 },
]);
});

View file

@ -347,7 +347,10 @@
"clove": "gousse",
"bunch": "botte",
"sachet": "sachet",
"sprig": "brin"
"sprig": "brin",
"cup": "tasse",
"ounce": "once",
"pound": "livre"
},
"diets": {
"omnivore": "Omnivore",

View file

@ -0,0 +1,632 @@
/**
* English display/matching labels for the `Ingredient` reference catalog
* (`apps/api/src/db/reference-seed-data.ts`'s `INGREDIENT_GROUPS`), keyed
* by the same `Ingredient.key` used throughout the app. Used by
* `apps/api/src/lib/ingredient-matcher.ts` to resolve free-text ingredient
* lines from English-language recipe sources (e.g. TheMealDB) against our
* catalog the English-language counterpart to `apps/web`'s
* `locales/fr/translation.json` (`catalog.ingredients.<key>`), which
* serves the same role for the French UI. Hand-authored (not derived from
* the key) for the same reason the French labels are: natural food
* vocabulary needs real phrasing, not a mechanical transform.
*/
export const INGREDIENT_LABELS_EN: Record<string, string> = {
// Vegetables
tomato: "Tomato",
onion: "Onion",
shallot: "Shallot",
garlic: "Garlic",
carrot: "Carrot",
zucchini: "Zucchini",
cucumber: "Cucumber",
gherkins: "Gherkins",
bellPepper: "Bell pepper",
mushroom: "Mushroom",
porcini: "Porcini mushrooms",
eggplant: "Eggplant",
broccoli: "Broccoli",
cauliflower: "Cauliflower",
whiteCabbage: "White cabbage",
redCabbage: "Red cabbage",
brusselsSprouts: "Brussels sprouts",
spinach: "Spinach",
swissChard: "Swiss chard",
lettuce: "Lettuce",
arugula: "Arugula",
watercress: "Watercress",
leek: "Leek",
celery: "Celery",
radish: "Radish",
beetroot: "Beetroot",
turnip: "Turnip",
parsnip: "Parsnip",
greenBean: "Green bean",
pea: "Pea",
corn: "Corn",
artichoke: "Artichoke",
fennel: "Fennel",
endive: "Endive",
pumpkin: "Pumpkin",
butternutSquash: "Butternut squash",
asparagus: "Asparagus",
avocado: "Avocado",
potato: "Potato",
sweetPotato: "Sweet potato",
cherryTomato: "Cherry tomatoes",
bokChoy: "Bok choy",
soybeanSprouts: "Soybean sprouts",
shiitake: "Shiitake mushrooms",
daikon: "Daikon radish",
freshGreenChili: "Fresh green chili",
cardoon: "Cardoon",
radicchio: "Radicchio",
romanesco: "Romanesco cauliflower",
kohlrabi: "Kohlrabi",
napaCabbage: "Napa cabbage",
celeriac: "Celeriac",
okra: "Okra",
springOnion: "Spring onion",
redKuriSquash: "Red kuri squash",
rutabaga: "Rutabaga",
samphire: "Samphire",
salsify: "Salsify",
lambsLettuce: "Lamb's lettuce",
escarole: "Escarole",
// Fruits
lemon: "Lemon",
lime: "Lime",
apple: "Apple",
pear: "Pear",
banana: "Banana",
orange: "Orange",
clementine: "Clementine",
grapefruit: "Grapefruit",
strawberry: "Strawberry",
raspberry: "Raspberry",
blueberry: "Blueberry",
blackberry: "Blackberry",
cherry: "Cherry",
apricot: "Apricot",
peach: "Peach",
plum: "Plum",
grape: "Grape",
melon: "Melon",
watermelon: "Watermelon",
pineapple: "Pineapple",
mango: "Mango",
kiwi: "Kiwi",
fig: "Fig",
date: "Date",
lychee: "Lychee",
pomegranate: "Pomegranate",
rhubarb: "Rhubarb",
quince: "Quince",
blackcurrant: "Blackcurrant",
cranberry: "Cranberry",
redcurrant: "Redcurrant",
persimmon: "Persimmon",
nectarine: "Nectarine",
tamarind: "Tamarind",
// Fresh herbs
basil: "Basil",
parsley: "Parsley",
thyme: "Thyme",
rosemary: "Rosemary",
bayLeaf: "Bay leaf",
chives: "Chives",
freshCilantro: "Fresh cilantro",
mint: "Mint",
oregano: "Oregano",
dill: "Dill",
tarragon: "Tarragon",
savory: "Savory",
marjoram: "Marjoram",
sage: "Sage",
chervil: "Chervil",
ginger: "Ginger",
lemongrass: "Lemongrass",
kaffirLime: "Kaffir lime",
// Meats
rabbit: "Rabbit",
groundBeef: "Ground beef",
beefSteak: "Beef steak",
beefRoast: "Beef roast",
vealCutlet: "Veal cutlet",
porkTenderloin: "Pork tenderloin",
porkChop: "Pork chop",
lamb: "Lamb",
legOfLamb: "Leg of lamb",
baconLardons: "Bacon lardons",
bacon: "Bacon",
ham: "Cooked ham",
curedHam: "Cured ham",
sausage: "Sausage",
chorizo: "Chorizo",
merguez: "Merguez",
prosciutto: "Prosciutto",
pancetta: "Pancetta",
mortadella: "Mortadella",
salami: "Salami",
andouille: "Andouille sausage",
andouillette: "Andouillette",
whitePudding: "White pudding",
blackPudding: "Black pudding",
cervelat: "Cervelat",
rillettes: "Rillettes",
dryCuredSausage: "Dry-cured sausage",
bayonneHam: "Bayonne ham",
coppa: "Coppa",
rosetteSausage: "Rosette sausage",
vealLiver: "Veal liver",
vealKidneys: "Veal kidneys",
vealBrain: "Veal brain",
vealSweetbread: "Veal sweetbread",
beefTongue: "Beef tongue",
tripe: "Tripe",
venison: "Venison",
roeDeer: "Roe deer",
wildBoar: "Wild boar",
horseMeat: "Horse meat",
beefHeart: "Beef heart",
foieGras: "Foie gras",
beefMuzzle: "Beef muzzle",
grisonsDriedBeef: "Grisons dried beef",
// Poultry
chicken: "Chicken",
turkey: "Turkey",
duck: "Duck",
duckBreast: "Duck breast",
quail: "Quail",
guineaFowl: "Guinea fowl",
goose: "Goose",
poultryLiver: "Poultry liver",
capon: "Capon",
pigeon: "Pigeon",
pheasant: "Pheasant",
// Fish
salmon: "Salmon",
tuna: "Tuna",
cod: "Cod",
trout: "Trout",
sardine: "Sardine",
anchovy: "Anchovy",
whiting: "Whiting",
surimi: "Surimi",
seaBass: "Sea bass",
seaBream: "Sea bream",
sole: "Sole",
turbot: "Turbot",
hake: "Hake",
pollock: "Pollock",
saithe: "Saithe",
haddock: "Haddock",
mackerel: "Mackerel",
herring: "Herring",
redMullet: "Red mullet",
skate: "Skate",
monkfish: "Monkfish",
halibut: "Halibut",
swordfish: "Swordfish",
carp: "Carp",
pike: "Pike",
perch: "Perch",
tilapia: "Tilapia",
pangasius: "Pangasius",
smokedSalmon: "Smoked salmon",
driedFish: "Dried fish",
eel: "Eel",
plaice: "Plaice",
saltCod: "Salt cod",
lemonSole: "Lemon sole",
scorpionfish: "Scorpionfish",
// Shellfish
shrimp: "Shrimp",
langoustine: "Langoustines",
lobster: "Lobster",
crab: "Crab",
spinyLobster: "Spiny lobster",
mussels: "Mussels",
oysters: "Oysters",
scallops: "Scallops",
squid: "Squid",
octopus: "Octopus",
clams: "Clams",
whelks: "Whelks",
spiderCrab: "Spider crab",
periwinkle: "Periwinkle",
crayfish: "Crayfish",
greyShrimp: "Grey shrimp",
cockle: "Cockle",
snail: "Snail",
cuttlefish: "Cuttlefish",
// Starches
semolina: "Semolina",
couscous: "Couscous",
bulgur: "Bulgur",
polenta: "Polenta",
quinoa: "Quinoa",
pasta: "Pasta",
wholeWheatPasta: "Whole wheat pasta",
rice: "Rice",
basmatiRice: "Basmati rice",
brownRice: "Brown rice",
oats: "Rolled oats",
spaghetti: "Spaghetti",
penne: "Penne",
tagliatelle: "Tagliatelle",
lasagnaSheets: "Lasagna sheets",
gnocchi: "Gnocchi",
arborioRice: "Arborio rice",
riceNoodles: "Rice noodles",
udonNoodles: "Udon noodles",
sobaNoodles: "Soba noodles",
chineseNoodles: "Chinese noodles",
riceVermicelli: "Rice vermicelli",
soyVermicelli: "Soy vermicelli",
stickyRice: "Sticky rice",
sushiRice: "Sushi rice",
jasmineRice: "Jasmine rice",
// Legumes
greenLentils: "Green lentils",
redLentils: "Red lentils",
chickpeas: "Chickpeas",
whiteBeans: "White beans",
kidneyBeans: "Kidney beans",
blackBeans: "Black beans",
splitPeas: "Split peas",
favaBeans: "Fava beans",
edamame: "Edamame",
pintoBeans: "Pinto beans",
flageoletBeans: "Flageolet beans",
goldenLentils: "Golden lentils",
// Nuts, seeds and other dry goods
peanutsShelled: "Peanuts",
almonds: "Almonds",
walnuts: "Walnuts",
hazelnuts: "Hazelnuts",
cashews: "Cashews",
pistachios: "Pistachios",
pecans: "Pecans",
almondPowder: "Ground almonds",
pineNuts: "Pine nuts",
sunflowerSeeds: "Sunflower seeds",
pumpkinSeeds: "Pumpkin seeds",
shreddedCoconut: "Shredded coconut",
raisins: "Raisins",
prunes: "Prunes",
driedApricots: "Dried apricots",
sesameSeeds: "Sesame seeds",
blackMushrooms: "Black mushrooms",
noriSeaweed: "Nori seaweed",
wakameSeaweed: "Wakame seaweed",
kombuSeaweed: "Kombu seaweed",
bambooShoots: "Bamboo shoots",
waterChestnuts: "Water chestnuts",
// Breads
bread: "Bread",
sandwichBread: "Sandwich bread",
wholeWheatBread: "Whole wheat bread",
baguette: "Baguette",
ryeBread: "Rye bread",
breadcrumbs: "Breadcrumbs",
burgerBun: "Burger bun",
briocheBun: "Brioche bun",
hotDogBun: "Hot dog bun",
pitaBread: "Pita bread",
bagel: "Bagel",
naan: "Naan",
wrapBread: "Wrap bread",
vienneseBread: "Vienna bread",
countryBread: "Country bread",
multigrainBread: "Multigrain bread",
breadRoll: "Bread roll",
swedishBread: "Swedish bread",
glutenFreeBread: "Gluten-free bread",
rusk: "Rusk",
croutons: "Croutons",
focaccia: "Focaccia",
ciabatta: "Ciabatta",
cornTortilla: "Corn tortilla",
wheatTortilla: "Wheat tortilla",
breadstick: "Breadstick",
// Raw dough
puffPastry: "Puff pastry",
shortcrustPastry: "Shortcrust pastry",
pizzaDough: "Pizza dough",
sweetShortcrustPastry: "Sweet shortcrust pastry",
// Dairy
milk: "Milk",
butter: "Butter",
cremeFraiche: "Crème fraîche",
liquidCream: "Liquid cream",
cheese: "Cheese",
emmental: "Emmental",
gruyere: "Gruyère",
parmesan: "Parmesan",
mozzarella: "Mozzarella",
goatCheese: "Goat cheese",
feta: "Feta",
comte: "Comté",
fromageBlanc: "Fromage blanc",
mascarpone: "Mascarpone",
yogurt: "Yogurt",
burrata: "Burrata",
ricotta: "Ricotta",
pecorino: "Pecorino",
gorgonzola: "Gorgonzola",
cheddar: "Cheddar",
brie: "Brie",
camembert: "Camembert",
roquefort: "Roquefort",
munster: "Munster cheese",
reblochon: "Reblochon",
cantal: "Cantal",
beaufort: "Beaufort",
saintNectaire: "Saint-Nectaire",
blueCheese: "Blue cheese",
cancoillotte: "Cancoillotte",
tomme: "Tomme cheese",
epoisses: "Époisses",
chaource: "Chaource",
livarot: "Livarot",
pontLeveque: "Pont-l'Évêque",
morbier: "Morbier",
racletteCheese: "Raclette cheese",
fourmeDAmbert: "Fourme d'Ambert",
salers: "Salers",
ossauIraty: "Ossau-Iraty",
vacherin: "Vacherin",
saintMarcellin: "Saint-Marcellin",
neufchatel: "Neufchâtel",
crottinDeChavignol: "Crottin de Chavignol",
abondanceCheese: "Abondance cheese",
carreDeLEst: "Carré de l'Est",
edam: "Edam",
gouda: "Gouda",
mimolette: "Mimolette",
maroilles: "Maroilles",
montDor: "Mont d'Or",
kefir: "Kefir",
greekYogurt: "Greek yogurt",
// Eggs
egg: "Egg",
// Plant-based alternatives
coconutMilk: "Coconut milk",
coconutCream: "Coconut cream",
almondMilk: "Almond milk",
oatMilk: "Oat milk",
tofu: "Tofu",
silkenTofu: "Silken tofu",
// Spices
herbesDeProvence: "Herbes de Provence",
blackPepper: "Black pepper",
paprika: "Paprika",
espelettePepper: "Espelette pepper",
cayennePepper: "Cayenne pepper",
cumin: "Cumin",
curryPowder: "Curry powder",
turmeric: "Turmeric",
cinnamon: "Cinnamon",
nutmeg: "Nutmeg",
saffron: "Saffron",
clove: "Clove",
vanillaBean: "Vanilla bean",
whitePepper: "White pepper",
pinkPepper: "Pink peppercorns",
sichuanPepper: "Sichuan pepper",
smokedPaprika: "Smoked paprika",
birdEyeChili: "Bird's eye chili",
juniperBerries: "Juniper berries",
starAnise: "Star anise",
greenAnise: "Green anise",
fennelSeeds: "Fennel seeds",
sumac: "Sumac",
nigella: "Nigella seeds",
allspice: "Allspice",
colomboPowder: "Colombo powder",
baharat: "Baharat",
horseradish: "Horseradish",
herbSalt: "Herb salt",
celerySalt: "Celery salt",
fleurDeSel: "Fleur de sel",
salt: "Salt",
fiveSpice: "Five-spice powder",
garamMasala: "Garam masala",
corianderSeeds: "Coriander seeds",
cardamom: "Cardamom",
fenugreek: "Fenugreek",
jalapeno: "Jalapeño",
chipotle: "Chipotle",
poblanoPepper: "Poblano pepper",
habanero: "Habanero",
rasElHanout: "Ras el hanout",
zaatar: "Za'atar",
// Sauces
soySauce: "Soy sauce",
mustard: "Mustard",
mayonnaise: "Mayonnaise",
ketchup: "Ketchup",
tabasco: "Tabasco sauce",
worcestershireSauce: "Worcestershire sauce",
fishSauce: "Fish sauce",
wasabi: "Wasabi",
harissa: "Harissa",
curryPaste: "Curry paste",
peanutButter: "Peanut butter",
dijonMustard: "Dijon mustard",
wholegrainMustard: "Wholegrain mustard",
barbecueSauce: "Barbecue sauce",
tartarSauce: "Tartar sauce",
cocktailSauce: "Cocktail sauce",
bearnaiseSauce: "Béarnaise sauce",
hollandaiseSauce: "Hollandaise sauce",
bechamelSauce: "Béchamel sauce",
teriyakiSauce: "Teriyaki sauce",
ponzuSauce: "Ponzu sauce",
chimichurri: "Chimichurri",
redPesto: "Red pesto",
pesto: "Pesto",
oysterSauce: "Oyster sauce",
hoisinSauce: "Hoisin sauce",
sriracha: "Sriracha",
sweetChiliSauce: "Sweet chili sauce",
miso: "Miso",
shrimpPaste: "Shrimp paste",
redCurryPaste: "Red curry paste",
greenCurryPaste: "Green curry paste",
tahini: "Tahini",
aioli: "Aioli",
vinaigrette: "Vinaigrette",
hummus: "Hummus",
// Seasonings — oils, vinegars, wines and other flavorings
oliveOil: "Olive oil",
sunflowerOil: "Sunflower oil",
rapeseedOil: "Rapeseed oil",
coconutOil: "Coconut oil",
sesameOil: "Sesame oil",
ciderVinegar: "Cider vinegar",
whiteVinegar: "White vinegar",
balsamicVinegar: "Balsamic vinegar",
capers: "Capers",
olives: "Olives",
blackOlives: "Black olives",
greenOlives: "Green olives",
whiteWine: "White wine",
redWine: "Red wine",
roseWine: "Rosé wine",
redWineVinegar: "Red wine vinegar",
whiteWineVinegar: "White wine vinegar",
sherryVinegar: "Sherry vinegar",
walnutOil: "Walnut oil",
hazelnutOil: "Hazelnut oil",
peanutOil: "Peanut oil",
chiliOil: "Chili oil",
riceVinegar: "Rice vinegar",
cornOil: "Corn oil",
grapeseedOil: "Grapeseed oil",
soybeanOil: "Soybean oil",
palmOil: "Palm oil",
mirin: "Mirin",
sake: "Sake",
lemonJuice: "Lemon juice",
limeJuice: "Lime juice",
orangeJuice: "Orange juice",
appleJuice: "Apple juice",
grapeJuice: "Grape juice",
tomatoJuice: "Tomato juice",
cranberryJuice: "Cranberry juice",
coffee: "Coffee",
tea: "Tea",
beer: "Beer",
cider: "Cider",
champagne: "Champagne / sparkling wine",
portWine: "Port wine",
vinJaune: "Vin jaune (Jura wine)",
cognac: "Cognac",
rum: "Rum",
whisky: "Whisky",
vodka: "Vodka",
// Bases — flours, stocks and other cooking essentials
wheatFlour: "Wheat flour",
wholeWheatFlour: "Whole wheat flour",
cornFlour: "Corn flour",
buckwheatFlour: "Buckwheat flour",
riceFlour: "Rice flour",
vegetableStockCube: "Vegetable stock cube",
chickenStockCube: "Chicken stock cube",
tomatoPaste: "Tomato paste",
tomatoCoulis: "Tomato coulis",
cannedPeeledTomatoes: "Canned peeled tomatoes",
sunDriedTomatoes: "Sun-dried tomatoes",
vealStock: "Veal stock",
chickenStock: "Chicken stock",
beefStockCube: "Beef stock cube",
fishStockCube: "Fish stock cube",
vegetableBroth: "Vegetable broth",
chickenBroth: "Chicken broth",
beefBroth: "Beef broth",
courtBouillon: "Court bouillon",
dashi: "Dashi (Japanese stock)",
shellfishBisque: "Shellfish bisque",
tapiocaFlour: "Tapioca flour",
masaHarina: "Masa harina",
water: "Water",
sparklingWater: "Sparkling water",
orangeBlossomWater: "Orange blossom water",
roseWater: "Rose water",
fishFumet: "Fish fumet",
// Thickeners and raising agents
bakersYeast: "Baker's yeast",
bakingPowder: "Baking powder",
cornstarch: "Cornstarch",
lupinFlour: "Lupin flour",
gelatin: "Gelatin",
bakingSoda: "Baking soda",
potatoStarch: "Potato starch",
// Sugars
sugar: "Sugar",
honey: "Honey",
mapleSyrup: "Maple syrup",
brownSugar: "Brown sugar",
powderedSugar: "Powdered sugar",
demeraraSugar: "Demerara sugar",
darkChocolate: "Dark chocolate",
milkChocolate: "Milk chocolate",
whiteChocolate: "White chocolate",
chocolateChips: "Chocolate chips",
cocoaPowder: "Cocoa powder",
vanillaExtract: "Vanilla extract",
palmSugar: "Palm sugar",
caneSyrup: "Cane syrup",
};
/**
* English matching synonyms for the `Unit` reference catalog
* (`apps/api/src/db/reference-seed-data.ts`'s `UNITS`), keyed by
* `Unit.key`. Unlike {@link INGREDIENT_LABELS_EN} (one display label per
* entry), a unit needs *several* accepted spellings to be useful for
* matching English recipe text freely mixes full words and abbreviations
* ("tablespoon", "tbsp", "tbs" all appear in the wild), which a single
* label can't capture. Used by `apps/api/src/lib/ingredient-matcher.ts` the
* same way `INGREDIENT_LABELS_EN` is: resolving free text from
* English-language sources against our catalog.
*/
export const UNIT_LABELS_EN: Record<string, string[]> = {
gram: ["g", "gr", "gram", "grams"],
kilogram: ["kg", "kilo", "kilos", "kilogram", "kilograms"],
milliliter: ["ml", "milliliter", "milliliters", "millilitre", "millilitres"],
centiliter: ["cl", "centiliter", "centiliters", "centilitre", "centilitres"],
liter: ["l", "liter", "liters", "litre", "litres"],
tablespoon: ["tbsp", "tbs", "tbl", "tablespoon", "tablespoons"],
teaspoon: ["tsp", "teaspoon", "teaspoons"],
piece: ["piece", "pieces", "pc", "pcs"],
pinch: ["pinch", "pinches"],
slice: ["slice", "slices"],
clove: ["clove", "cloves"],
bunch: ["bunch", "bunches"],
sachet: ["sachet", "sachets", "packet", "packets", "sac", "sacs"],
sprig: ["sprig", "sprigs"],
cup: ["cup", "cups"],
ounce: ["oz", "ounce", "ounces"],
pound: ["lb", "lbs", "pound", "pounds"],
};

View file

@ -3,6 +3,7 @@
// intentional (types, validation schemas, error codes), not an implementation
// detail specific to one side.
export * from "./data/catalog-labels-en.js";
export * from "./errors/error-codes.js";
export * from "./schemas/account.js";
export * from "./schemas/auth.js";