first commit

This commit is contained in:
2026-07-21 16:50:58 +02:00
commit 256599626e
407 changed files with 30489 additions and 0 deletions
+406
View File
@@ -0,0 +1,406 @@
/**
* 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
};