/** * Orchestration du pipeline recettes : agrège les bentext multi-locales, * résout les icônes, fusionne ingrédients / étapes, et sérialise le catalogue. * * Responsabilités : * 1. Charger les définitions de sprites (parsing pur) + métadonnées PNG. * 2. Lire les `.bentext`, fusionner par slug. * 3. Calculer flavour + cover + icônes par ingrédient. * 4. Construire le graph `bases` + `variants` (logique pure). * 5. Écrire `catalog.json` (+ `recipes.json`) + copier les assets vers `apps/web/public`. * * Les responsabilités pures (parsing, classification, graph) sont déléguées * aux modules de `scripts/recipes/`. */ import { promises as fs } from "node:fs"; import path from "node:path"; import { copyBrandingAssets, copyIngredientAssetsToWeb, PATHS, pickCoverImage, readPngDimensions } from "./assets.mjs"; import { buildRecipeGraph, classifyFlavor } from "./classification.mjs"; import { readBentext } from "./parse-bentext.mjs"; import { aliasToIconPx, parseIngredientSpritesBentext, resolveIconPxForIngredientName } from "./sprites.mjs"; import { extractVariantTileSpriteAlias, stripVariantTileSpriteTags } from "../recipe-variant-tile-sprite.mjs"; /** Locales prises en charge dans le pipeline (aligné sur les fichiers bentext versionnés). */ const LOCALES = ["fr", "en", "ko", "ja", "zh"]; /** Charge la grille de sprites et écrit le CSV dérivé (compat / rétro). */ const loadIngredientSpriteDefinitions = async () => { try { const raw = await fs.readFile(PATHS.ingredientSpritesBentextPath, "utf8"); const parsed = parseIngredientSpritesBentext(raw); await fs.writeFile(path.join(PATHS.publicAssetsDir, "ingredient-sprites.csv"), parsed.csv, "utf8"); return { byAlias: parsed.byAlias, preferredAliases: parsed.preferredAliases }; } catch (e) { console.warn("[build-recipes] ingredient-sprites.bentext absent ou erreur:", e?.message ?? e); return { byAlias: {}, preferredAliases: new Set() }; } }; /** Lit `ressources/bentext/*..bentext` et les regroupe par slug. */ const readAllBentext = async () => { const files = await fs.readdir(PATHS.recipesDir); const grouped = new Map(); for (const file of files) { if (!file.endsWith(".bentext")) continue; const [slug, locale] = file.replace(".bentext", "").split("."); const raw = await fs.readFile(path.join(PATHS.recipesDir, file), "utf8"); const parsed = readBentext(raw); const current = grouped.get(slug) ?? { slug, locales: {} }; current.locales[locale] = parsed; grouped.set(slug, current); } return grouped; }; /** Fusionne les ingrédients de toutes les locales en un seul objet {id, names, notes, …}. */ const mergeIngredients = (slug, locales) => { const ingredientMap = new Map(); for (const locale of LOCALES) { const localized = locales[locale]; if (!localized) continue; localized.ingredients.forEach((ingredient, index) => { /* Clé stable par rang : le nom diffère selon la langue (farine / flour / 面粉), * sinon une entrée par locale → liste d’ingrédients multipliée. */ const id = `${slug}-ingredient-${index}`; const current = ingredientMap.get(id) ?? { id, quantity: ingredient.quantity, unit: ingredient.unit, names: {}, notes: {} }; current.names[locale] = ingredient.name; current.notes[locale] = ingredient.note; ingredientMap.set(id, current); }); } return Array.from(ingredientMap.values()).map((ingredient) => ({ ...ingredient, names: { fr: ingredient.names.fr ?? ingredient.names.en, en: ingredient.names.en ?? ingredient.names.fr, ko: ingredient.names.ko ?? ingredient.names.en, zh: ingredient.names.zh ?? ingredient.names.ja ?? ingredient.names.en }, notes: { fr: ingredient.notes.fr ?? "", en: ingredient.notes.en ?? "", ko: ingredient.notes.ko ?? "", zh: ingredient.notes.zh ?? ingredient.notes.ja ?? "" } })); }; /** Fusionne les étapes : `fr` est la référence, chaque locale comble les trous. */ const mergeSteps = (slug, locales) => { const localizedSteps = {}; for (const locale of LOCALES) { const localized = locales[locale]; if (!localized) continue; localizedSteps[locale] = localized.steps.map((step, index) => ({ ...step, text: step.text, index })); } const frSteps = localizedSteps.fr ?? []; return frSteps.map((step, index) => ({ id: step.id, priority: step.priority, phase: step.phase, dependsOn: step.dependsOn, text: { fr: step.text, en: localizedSteps.en?.[index]?.text ?? step.text, ko: localizedSteps.ko?.[index]?.text ?? localizedSteps.en?.[index]?.text ?? step.text, zh: localizedSteps.zh?.[index]?.text ?? localizedSteps.ja?.[index]?.text ?? localizedSteps.en?.[index]?.text ?? step.text } })); }; /** Annote chaque ingrédient avec ses coordonnées pixel (sprite sheet) ou `null`. */ const attachIngredientIcons = (ingredients, spriteByAlias) => ingredients.map((ingredient) => ({ ...ingredient, iconPx: resolveIconPxForIngredientName(ingredient.names.fr ?? "", spriteByAlias) ?? resolveIconPxForIngredientName(ingredient.names.en ?? "", spriteByAlias) ?? null })); /** * Pour chaque slug groupé : sélectionne cover, calcule flavour, fusionne ingrédients/étapes, * résout les icônes, et ajoute l’alias tuile variante (`sprite:xxx`) si présent dans les tags. */ const buildMergedRecipes = async (grouped, spriteByAlias) => { const mergedRecipes = []; for (const entry of grouped.values()) { const fr = entry.locales.fr; if (!fr) { console.warn(`Skipping ${entry.slug}: missing fr locale`); continue; } const tagsRaw = fr.tags; const variantTileSpriteAlias = extractVariantTileSpriteAlias(tagsRaw); const tags = stripVariantTileSpriteTags(tagsRaw); const flavor = classifyFlavor(entry.slug, tags); const coverImageUrl = await pickCoverImage(entry.slug); const mergedIngredients = attachIngredientIcons( mergeIngredients(entry.slug, entry.locales), spriteByAlias ); const mergedSteps = mergeSteps(entry.slug, entry.locales); mergedRecipes.push({ slug: entry.slug, flavor, tags, variantTileSpriteAlias, coverImageUrl, servings: fr.servings, locales: entry.locales, mergedIngredients, mergedSteps }); } return mergedRecipes; }; const writeCatalog = async (grouped, catalog) => { await fs.mkdir(PATHS.outDir, { recursive: true }); await fs.mkdir(PATHS.webPublicDir, { recursive: true }); const rawBundle = Array.from(grouped.values()); await fs.writeFile(path.join(PATHS.outDir, "recipes.json"), JSON.stringify(rawBundle, null, 2), "utf8"); await fs.writeFile(path.join(PATHS.outDir, "catalog.json"), JSON.stringify(catalog, null, 2), "utf8"); await fs.writeFile(path.join(PATHS.webPublicDir, "catalog.json"), JSON.stringify(catalog, null, 2), "utf8"); }; const run = async () => { const { byAlias: spriteByAlias, preferredAliases: preferredVariantTileAliases } = await loadIngredientSpriteDefinitions(); const sheetMeta = await readPngDimensions(PATHS.ingredientsPngPath); await copyBrandingAssets(); const grouped = await readAllBentext(); const mergedRecipes = await buildMergedRecipes(grouped, spriteByAlias); const catalog = buildRecipeGraph(mergedRecipes, sheetMeta, spriteByAlias, preferredVariantTileAliases); await writeCatalog(grouped, catalog); await copyIngredientAssetsToWeb(); console.log( `Generated ${grouped.size} recipes → ${catalog.variants.length} variantes, ${catalog.bases.length} bases canoniques` ); }; // Re-export pour les consommateurs qui voudraient réutiliser la résolution sprite // (ex. audit-recipe-assets ou tests). Le pipeline lui-même n'en dépend pas. export { loadIngredientSpriteDefinitions, aliasToIconPx, run };