/** * Classification & construction du catalogue recette. * Pur (aucun IO) — prend en entrée les recettes « fusionnées » issues de * `pipeline.mjs` et les définitions de sprites résolues par `sprites.mjs`, * renvoie le graph `{ bases, variants }` qui sera sérialisé en `catalog.json`. * * Le tagging des bases (slug/tags → `base:xxx`) reste dans * `scripts/recipe-category.mjs` (testé indépendamment). */ import { RECIPE_BASE_DISPLAY_ORDER, RECIPE_BASE_FLAVOR, resolveRecipeBaseId } from "../recipe-category.mjs"; import { aliasToIconPx, buildIngredientCoverSprite, findAliasForIconPx, isPreferredVariantAlias, resolveIconPxForIngredientName } from "./sprites.mjs"; /** Mots-clés pour la classification sucrée/salée du bandeau de recette. */ const flavorKeywords = { sweet: ["dessert", "gateau", "gâteau", "cake", "biscuit", "muffin", "financier", "madeleine", "crumble", "crème", "cremeux", "citron", "orange", "banane", "pomme", "chocolat", "sucré", "sucre"], savory: ["sale", "salé", "bento", "riz", "onigiri", "mandu", "gimbap", "empanada", "naan", "dan-bing", "omelette", "œuf", "oeuf"] }; /** * Renvoie `"sweet"` ou `"savory"` selon les mots-clés présents dans le slug * concaténé aux tags. En cas d’égalité, fallback `"savory"` (préserve le * comportement historique du pipeline). */ const classifyFlavor = (slug, tags) => { const haystack = `${slug} ${tags.join(" ")}`.toLowerCase(); const sweetHits = flavorKeywords.sweet.filter((keyword) => haystack.includes(keyword)).length; const savoryHits = flavorKeywords.savory.filter((keyword) => haystack.includes(keyword)).length; if (sweetHits > savoryHits) return "sweet"; if (savoryHits > sweetHits) return "savory"; return "savory"; }; const buildLocalizedText = (localesMap, fallback) => { const fr = localesMap.fr?.title ?? fallback; const en = localesMap.en?.title ?? fr; const ko = localesMap.ko?.title ?? en; const zh = localesMap.zh?.title ?? en; return { fr, en, ko, zh }; }; const buildLocalizedParagraph = (localesMap, fallback) => { const fr = localesMap.fr?.description ?? fallback; const en = localesMap.en?.description ?? fr; const ko = localesMap.ko?.description ?? en; const zh = localesMap.zh?.description ?? en; return { fr, en, ko, zh }; }; /** Libellés affichés (familles produit) — voir `scripts/recipe-category.mjs` pour l’association slug/tags → id. */ const BASE_LABELS = { "base:onigiri": { name: { fr: "Bouchées de riz", en: "Rice bites", ko: "밥 한 입", zh: "饭团一口" }, history: { fr: "Riz compact, façonné, garni ou enveloppé — onigiri, inarizushi, musubi…", en: "Compact rice, shaped, filled or wrapped—onigiri, inarizushi, musubi…", ko: "조리해 모양 낸 밥 — 오니기리, 이나리즈시 등.", zh: "塑形或包裹的米饭一口食——饭团、稻荷寿司等。" } }, "base:gimbap": { name: { fr: "Rouleaux de riz", en: "Rice rolls", ko: "밥 말이", zh: "饭卷" }, history: { fr: "Riz roulé puis découpé — gimbap, maki-like.", en: "Rolled and sliced rice—gimbap, maki-style rolls.", ko: "말아 자른 밥 — 김밥, 마키 스타일.", zh: "卷起再切的饭——紫菜包饭、卷饭。" } }, "base:mandu": { name: { fr: "Mandu", en: "Mandu / dumplings", ko: "만두", zh: "饺子 / 煎饺" }, history: { fr: "Raviolis et bouchées farcies — mandu, gyoza, jiaozi…", en: "Stuffed dumplings—mandu, gyoza, jiaozi…", ko: "속을 채운 만두류.", zh: "带馅面团——饺子、锅贴等。" } }, "base:empanadas-savory": { name: { fr: "Empanadas", en: "Empanadas", ko: "엠파나다", zh: "烤饺" }, history: { fr: "Chaussons farcis — empanadas, hand pies, pastels salés.", en: "Filled turnovers—empanadas, hand pies, savoury pastels.", ko: "속을 채운 페이스트리.", zh: "酥皮咸馅小包。" } }, "base:empanadas-sweet": { name: { fr: "Empanadas", en: "Empanadas", ko: "엠파나다", zh: "烤饺" }, history: { fr: "Chaussons sucrés — farce dessert.", en: "Sweet filled turnovers.", ko: "달콤한 속을 넣은 페이스트리.", zh: "甜馅酥皮小包。" } }, "base:galettes": { name: { fr: "Galettes", en: "Flatbreads & griddled cakes", ko: "전·난·플랫브레드", zh: "烙饼 / 扁面包" }, history: { fr: "Pains plats, crêpes, galettes poêlées — naan, dan bing, jeon, paratha…", en: "Flatbreads and griddled pancakes—naan, dan bing, jeon, paratha…", ko: "난, 단빙, 전 등 납작한 빵·전병.", zh: "烙饼、印度烤饼、煎饼类等。" } }, "base:bao": { name: { fr: "Bao", en: "Steamed buns", ko: "바오·만두빵", zh: "包子 / 包点" }, history: { fr: "Pains vapeur farcis ou garnis — bao salé ou sucré, mantou garni.", en: "Steamed buns, savoury or sweet filling, stuffed mantou.", ko: "찐 빵·만두빵.", zh: "蒸制包点。" } }, "base:muffin": { name: { fr: "Muffins", en: "Muffins", ko: "머핀", zh: "玛芬" }, history: { fr: "Portion moulée individuelle — muffins sucrés ou salés.", en: "Individual molded portions—sweet or savoury muffins.", ko: "한 입 크기 머핀.", zh: "一人份模烤点心。" } }, "base:mini-cakes": { name: { fr: "Mini-cakes", en: "Small portable cakes", ko: "미니 케이크", zh: "小蛋糕" }, history: { fr: "Petits gâteaux transportables — madeleines, financiers, mini-cakes.", en: "Portable small cakes—madeleines, financiers, mini cakes.", ko: "휴대하기 좋은 작은 케이크.", zh: "便于携带的小蛋糕。" } }, "base:biscuits-secs": { name: { fr: "Biscuits secs", en: "Crisp cookies", ko: "바삭한 비스킷", zh: "酥脆饼干" }, history: { fr: "Sec, solide, compatible bureau — cookies, sablés, biscuits croquants.", en: "Dry, snack-friendly—cookies, shortbread, crisp biscuits.", ko: "바삭한 과자·쿠키.", zh: "干爽酥脆饼干。" } }, "base:fluffy-cakes": { name: { fr: "Fluffy cakes", en: "Fluffy cakes", ko: "폭신 케이크", zh: "松软蛋糕" }, history: { fr: "Gâteaux légers, tranchables ou sandwichables — sponge, génoises, fluffy.", en: "Light, sliceable or layer cakes—sponge, génoise, fluffy styles.", ko: "가볍게 썰어 먹는 케이크.", zh: "松软可分切的蛋糕。" } }, "base:cakes": { name: { fr: "Cakes", en: "Cakes", ko: "케이크", zh: "蛋糕" }, history: { fr: "Gâteaux type layer cake, carrot cake, banana bread…", en: "Layer cakes, carrot cake, banana bread-style bakes.", ko: "당근 케이크, 바나나 브레드 등.", zh: "胡萝卜蛋糕、香蕉蛋糕等。" } }, "base:carnet": { name: { fr: "Carnet", en: "Notebook", ko: "노트", zh: "手记" }, history: { fr: "Recettes bonus, hors concept strict Ben-to — à taguer « carnet » dans les tags.", en: "Bonus recipes outside the core Ben-to grid—tag « carnet » in bentext tags.", ko: "보조 레시피 — 태그에 carnet.", zh: "附录食谱——在标签中加 carnet。" } } }; /** Icônes des tuiles « base » : alias dans `ingredient-sprites.bentext` → coordonnées pixels. */ const BASE_SPRITE_ALIASES = { "base:onigiri": "onigiri", "base:gimbap": "gimbap", "base:mandu": "mandu", "base:empanadas-savory": "empenadas", "base:empanadas-sweet": "empenadas", "base:galettes": "naan", "base:bao": "bao", "base:muffin": "muffin", "base:mini-cakes": "madelaine", "base:biscuits-secs": "muffin", "base:fluffy-cakes": "gateau de savoie", "base:cakes": "tranche cake", "base:carnet": "carnet" }; const buildBaseIngredientsSteps = (recipe, sheetMeta) => { const ingredients = recipe.mergedIngredients.map((ingredient) => { const { iconPx, id, names, notes, quantity, unit } = ingredient; const coverSprite = buildIngredientCoverSprite(iconPx, sheetMeta); const row = { id, name: { fr: names.fr ?? names.en, en: names.en ?? names.fr, ko: names.ko ?? names.en, zh: names.zh ?? names.en }, info: { fr: notes.fr ?? "", en: notes.en ?? "", ko: notes.ko ?? "", zh: notes.zh ?? "" }, quantity, unit }; if (coverSprite) row.coverSprite = coverSprite; return row; }); const steps = recipe.mergedSteps.map((step) => ({ id: step.id, priority: step.priority, phase: step.phase, dependsOn: step.dependsOn, text: { fr: step.text.fr, en: step.text.en, ko: step.text.ko, zh: step.text.zh } })); return { ingredients, steps }; }; const buildVariantPayload = (recipe, sheetMeta) => { const title = buildLocalizedText(recipe.locales, recipe.slug); const story = buildLocalizedParagraph(recipe.locales, ""); const ingredients = recipe.mergedIngredients.map((ingredient) => { const { iconPx, id, names, notes, quantity, unit } = ingredient; const coverSprite = buildIngredientCoverSprite(iconPx, sheetMeta); const row = { id: `variant:${recipe.slug}:${id}`, name: { fr: names.fr ?? names.en, en: names.en ?? names.fr, ko: names.ko ?? names.en, zh: names.zh ?? names.en }, info: { fr: notes.fr ?? "", en: notes.en ?? "", ko: notes.ko ?? "", zh: notes.zh ?? "" }, quantity, unit }; if (coverSprite) row.coverSprite = coverSprite; return row; }); const steps = recipe.mergedSteps.map((step) => ({ id: `variant:${recipe.slug}:${step.id}`, priority: step.priority + 5000, phase: step.phase, dependsOn: step.dependsOn.map((dependency) => `variant:${recipe.slug}:${dependency}`), text: { fr: step.text.fr, en: step.text.en, ko: step.text.ko, zh: step.text.zh } })); return { title, story, ingredients, steps, servings: recipe.servings }; }; const buildRecipeGraph = (recipes, sheetMeta, spriteByAlias, preferredVariantAliases) => { const baseBuckets = new Map(); for (const baseId of RECIPE_BASE_DISPLAY_ORDER) { baseBuckets.set(baseId, []); } for (const recipe of recipes) { const id = resolveRecipeBaseId(recipe.slug, recipe.tags); baseBuckets.get(id).push(recipe); } const bases = []; for (const baseId of RECIPE_BASE_DISPLAY_ORDER) { const bucket = baseBuckets.get(baseId) ?? []; bucket.sort((a, b) => a.slug.localeCompare(b.slug)); const representative = bucket[0] ?? null; const labels = BASE_LABELS[baseId]; const { ingredients, steps } = representative ? buildBaseIngredientsSteps(representative, sheetMeta) : { ingredients: [], steps: [] }; const servings = representative?.servings ?? 1; const tags = representative ? [...representative.tags] : []; const baseSpriteAlias = BASE_SPRITE_ALIASES[baseId]; const baseIconPx = baseSpriteAlias ? aliasToIconPx(spriteByAlias, baseSpriteAlias) : null; if (baseSpriteAlias && !baseIconPx) { console.warn( `[build-recipes] Icône base absente de ingredient-sprites.bentext (alias « ${baseSpriteAlias} » pour ${baseId}).` ); } const baseCoverSprite = buildIngredientCoverSprite(baseIconPx, sheetMeta); bases.push( Object.freeze({ id: baseId, flavor: RECIPE_BASE_FLAVOR[baseId], name: labels.name, history: labels.history, servings, ingredients: Object.freeze(ingredients), steps: Object.freeze(steps), slug: baseId.replace("base:", ""), coverImageUrl: null, coverSprite: baseCoverSprite ?? null, tags: Object.freeze(tags) }) ); } const variants = []; for (const recipe of recipes) { const baseId = resolveRecipeBaseId(recipe.slug, recipe.tags); const { title, story, ingredients, steps } = buildVariantPayload(recipe, sheetMeta); const variantId = `variant:${recipe.slug}`; const variantCoverSprite = resolveVariantCoverSprite( recipe, sheetMeta, spriteByAlias, preferredVariantAliases ); variants.push( Object.freeze({ id: variantId, baseIds: Object.freeze([baseId]), name: title, story, servings: recipe.servings, ingredients: Object.freeze(ingredients), steps: Object.freeze(steps), slug: recipe.slug, coverImageUrl: null, coverSprite: variantCoverSprite ?? null, recipeCoverImageUrl: recipe.coverImageUrl ?? null, tags: Object.freeze(recipe.tags) }) ); } return { bases: Object.freeze(bases), variants: Object.freeze(variants) }; }; /** * Icône tuile garniture : override explicite via tag bentext `sprite:` * d’abord, sinon ingrédient « prioritaire », sinon premier ingrédient avec sprite résolu. * Émet un `console.warn` quand l’alias explicite est introuvable. */ const resolveVariantCoverSprite = (recipe, sheetMeta, spriteByAlias, preferredVariantAliases) => { const tileAlias = recipe.variantTileSpriteAlias ?? null; if (tileAlias) { const sprite = resolveVariantCoverSpriteFromTileAlias(tileAlias, sheetMeta, spriteByAlias); if (sprite) return sprite; console.warn( `[build-recipes] Tuile variante : alias « ${tileAlias} » introuvable (${recipe.slug}), fallback automatique.` ); } return pickVariantCoverSprite(recipe, sheetMeta, spriteByAlias, preferredVariantAliases); }; const resolveVariantCoverSpriteFromTileAlias = (aliasKey, sheetMeta, spriteByAlias) => { if (!aliasKey || typeof aliasKey !== "string") return null; const normalized = normalizeAliasKeyLocal(aliasKey); const px = resolveIconPxForIngredientName(aliasKey, spriteByAlias) ?? aliasToIconPx(spriteByAlias, normalized); if (!px || typeof px.x !== "number" || typeof px.y !== "number") return null; return buildIngredientCoverSprite(px, sheetMeta); }; const pickVariantCoverSprite = (recipe, sheetMeta, spriteByAlias, preferredAliases) => { const merged = recipe.mergedIngredients ?? []; const tryPick = (preferredOnly) => { for (const ing of merged) { const px = ing.iconPx; if (!px || typeof px.x !== "number" || typeof px.y !== "number") continue; const alias = findAliasForIconPx(spriteByAlias, px); if (preferredOnly) { if (!alias || !isPreferredVariantAlias(preferredAliases, alias)) continue; } return buildIngredientCoverSprite(px, sheetMeta); } return null; }; return tryPick(true) ?? tryPick(false) ?? null; }; const normalizeAliasKeyLocal = (s) => s.trim().toLowerCase().replace(/\s+/g, " "); export { classifyFlavor, buildLocalizedText, buildLocalizedParagraph, buildBaseIngredientsSteps, buildVariantPayload, buildRecipeGraph, resolveVariantCoverSprite };