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

224 lines
7.9 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.
/**
* 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/*.<locale>.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 dingré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 lalias 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 };