Files
2026-07-21 16:50:58 +02:00

407 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 lassociation 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:<alias>`
* dabord, sinon ingrédient « prioritaire », sinon premier ingrédient avec sprite résolu.
* Émet un `console.warn` quand lalias 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
};