224 lines
7.9 KiB
JavaScript
224 lines
7.9 KiB
JavaScript
/**
|
||
* 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 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 };
|