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
+223
View File
@@ -0,0 +1,223 @@
/**
* 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 };