first commit
This commit is contained in:
@@ -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 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 };
|
||||
Reference in New Issue
Block a user