#!/usr/bin/env node import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { RECIPE_BASE_DISPLAY_ORDER, resolveRecipeBaseId } from "./recipe-category.mjs"; import { extractVariantTileSpriteAlias, stripVariantTileSpriteTags } from "./recipe-variant-tile-sprite.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const recipesDir = path.join(repoRoot, "ressources/bentext"); const publicAssetsDir = path.join(repoRoot, "ressources/public"); const placeholderCoversDir = path.join(repoRoot, "ressources/recipe-placeholder-covers"); const ingredientSpritesBentextPath = path.join(publicAssetsDir, "ingredient-sprites.bentext"); const locales = ["fr", "en", "ko", "ja", "zh"]; const coverExtensions = [".jpg", ".jpeg", ".png", ".webp"]; const args = new Set(process.argv.slice(2)); const normalizeAliasKey = (value) => value.trim().toLowerCase().replace(/\s+/g, " "); const foldAccents = (value) => value.normalize("NFD").replace(/\p{M}+/gu, "").toLowerCase(); const splitIngredientLine = (line) => { const parts = line.split("|").map((token) => token.trim()); const name = parts[0] ?? ""; const quantity = Number(parts[1] ?? "0"); const unit = parts[2] ?? ""; const note = parts.slice(3).join(" | ").trim(); return { name, quantity, unit, note }; }; const readBentext = (raw) => { const parts = raw.split("---").map((part) => part.trim()); const header = (parts[0] ?? "").split("\n").map((line) => line.trim()); let notesBlock = ""; let tagsBlock = ""; let metaBlock = ""; if (parts.length === 4) { tagsBlock = parts[3] ?? ""; } else if (parts.length === 5) { tagsBlock = parts[3] ?? ""; metaBlock = parts[4] ?? ""; } else if (parts.length >= 6) { notesBlock = parts[3] ?? ""; tagsBlock = parts[4] ?? ""; metaBlock = parts[5] ?? ""; } return { title: header[0] ?? "", version: header[1] ?? "", description: header.slice(2).join("\n").trim(), ingredients: (parts[1] ?? "") .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => splitIngredientLine(line)), steps: (parts[2] ?? "") .split("\n") .map((line) => line.trim()) .filter(Boolean), notes: notesBlock .split("\n") .map((line) => line.trim()) .filter(Boolean), tags: tagsBlock .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => line.toLowerCase()), meta: metaBlock .split("\n") .map((line) => line.trim()) .filter(Boolean) }; }; const parseIngredientSpritesBentext = (raw) => { const byAlias = {}; const blocks = raw .replace(/^\uFEFF/, "") .split(/^\s*---\s*$/m) .map((block) => block.trim()) .filter(Boolean); for (const block of blocks) { const lines = block .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); const coordMatch = (lines[0] ?? "").match(/^(\d+)\s+(\d+)$/); if (!coordMatch) continue; const row = Number(coordMatch[1]); const col = Number(coordMatch[2]); const x = col * 32; const y = row * 32; for (const line of lines.slice(1)) { for (const part of line.split("|")) { const key = normalizeAliasKey(part); if (!key) continue; byAlias[key] ??= { x, y, row, col }; } } } return byAlias; }; const aliasToIconPx = (byAlias, alias) => { const entry = byAlias[alias]; if (!entry) return null; return { x: entry.x, y: entry.y }; }; const resolveIconPxForName = (name, byAlias) => { if (!name || typeof name !== "string") return null; const normalized = normalizeAliasKey(name); if (byAlias[normalized]) return aliasToIconPx(byAlias, normalized); const folded = foldAccents(normalized); for (const key of Object.keys(byAlias)) { if (foldAccents(key) === folded) return aliasToIconPx(byAlias, key); } const segments = normalized .split(/[,;]|(?:\s+\/\s+)|(?:\s+et\s+)/i) .map((segment) => segment.trim()) .filter(Boolean); for (const segment of segments) { if (byAlias[segment]) return aliasToIconPx(byAlias, segment); const segmentFolded = foldAccents(segment); for (const key of Object.keys(byAlias)) { if (foldAccents(key) === segmentFolded) return aliasToIconPx(byAlias, key); } } const keysByLength = Object.keys(byAlias).sort((left, right) => right.length - left.length); for (const key of keysByLength) { if (key.length < 3) continue; if (normalized.includes(key) || folded.includes(foldAccents(key))) { return aliasToIconPx(byAlias, key); } } return null; }; const fileExists = async (filePath) => { try { await fs.access(filePath); return true; } catch { return false; } }; const findRecipeCover = async (slug) => { const placeholder = path.join(placeholderCoversDir, `${slug}.png`); if (await fileExists(placeholder)) { return { source: "ressources/recipe-placeholder-covers", file: path.relative(repoRoot, placeholder) }; } for (const extension of coverExtensions) { const candidate = path.join(publicAssetsDir, `${slug}${extension}`); if (await fileExists(candidate)) { return { source: "ressources/public", file: path.relative(repoRoot, candidate) }; } } return null; }; const loadRecipes = async () => { const files = await fs.readdir(recipesDir); const grouped = new Map(); for (const file of files.sort()) { const match = file.match(/^(.+)\.([a-z]{2})\.bentext$/); if (!match) continue; const [, slug, locale] = match; const raw = await fs.readFile(path.join(recipesDir, file), "utf8"); const current = grouped.get(slug) ?? { slug, locales: {} }; current.locales[locale] = readBentext(raw); grouped.set(slug, current); } return [...grouped.values()].sort((left, right) => left.slug.localeCompare(right.slug)); }; const collectMissingIngredientIcons = (recipe, spriteByAlias) => { const maxIngredients = Math.max( ...locales.map((locale) => recipe.locales[locale]?.ingredients.length ?? 0) ); const missing = []; for (let index = 0; index < maxIngredients; index += 1) { const frName = recipe.locales.fr?.ingredients[index]?.name ?? ""; const enName = recipe.locales.en?.ingredients[index]?.name ?? ""; const displayName = frName || enName; if (!displayName) continue; const icon = resolveIconPxForName(frName, spriteByAlias) ?? resolveIconPxForName(enName, spriteByAlias); if (icon) continue; missing.push({ index, name: displayName, fallbackName: enName && enName !== displayName ? enName : null }); } return missing; }; const buildAudit = async () => { const spriteByAlias = parseIngredientSpritesBentext( await fs.readFile(ingredientSpritesBentextPath, "utf8") ); const recipes = await loadRecipes(); const report = { totals: { recipes: 0, spriteAliases: Object.keys(spriteByAlias).length }, missingIngredientIcons: [], variantsWithoutCustomSprite: [], invalidCustomSprites: [], recipesWithoutPhotos: [], tastesByShape: Object.fromEntries( RECIPE_BASE_DISPLAY_ORDER.map((baseId) => [baseId, 0]) ) }; const missingIngredientsByName = new Map(); for (const recipe of recipes) { const fr = recipe.locales.fr; if (!fr) continue; report.totals.recipes += 1; const rawTags = fr.tags; const spriteAlias = extractVariantTileSpriteAlias(rawTags); const visibleTags = stripVariantTileSpriteTags(rawTags); const baseId = resolveRecipeBaseId(recipe.slug, visibleTags); const title = fr.title || recipe.slug; report.tastesByShape[baseId] = (report.tastesByShape[baseId] ?? 0) + 1; if (!spriteAlias) { report.variantsWithoutCustomSprite.push({ slug: recipe.slug, title, baseId }); } else if (!resolveIconPxForName(spriteAlias, spriteByAlias)) { report.invalidCustomSprites.push({ slug: recipe.slug, title, baseId, alias: spriteAlias }); } const cover = await findRecipeCover(recipe.slug); if (!cover) { report.recipesWithoutPhotos.push({ slug: recipe.slug, title, baseId }); } for (const missing of collectMissingIngredientIcons(recipe, spriteByAlias)) { const key = normalizeAliasKey(missing.name); const current = missingIngredientsByName.get(key) ?? { name: missing.name, fallbackName: missing.fallbackName, recipes: [] }; current.recipes.push({ slug: recipe.slug, title, ingredientIndex: missing.index + 1 }); missingIngredientsByName.set(key, current); } } report.missingIngredientIcons = [...missingIngredientsByName.values()].sort((left, right) => left.name.localeCompare(right.name) ); report.variantsWithoutCustomSprite.sort((left, right) => left.slug.localeCompare(right.slug)); report.invalidCustomSprites.sort((left, right) => left.slug.localeCompare(right.slug)); report.recipesWithoutPhotos.sort((left, right) => left.slug.localeCompare(right.slug)); return report; }; const printList = (title, items, formatItem) => { console.log(`\n${title} (${items.length})`); if (items.length === 0) { console.log(" OK"); return; } for (const item of items) { console.log(` - ${formatItem(item)}`); } }; const printHumanReport = (report) => { console.log("Audit assets recettes"); console.log(`Recettes FR analysees: ${report.totals.recipes}`); console.log(`Alias de sprites ingredients: ${report.totals.spriteAliases}`); printList("Ingredients sans icone", report.missingIngredientIcons, (item) => { const recipes = item.recipes.map((recipe) => recipe.slug).join(", "); const fallback = item.fallbackName ? ` / ${item.fallbackName}` : ""; return `${item.name}${fallback} -> ${recipes}`; }); printList("Gouts sans tag sprite: personnalise", report.variantsWithoutCustomSprite, (item) => `${item.slug} (${item.baseId})` ); printList("Tags sprite: invalides", report.invalidCustomSprites, (item) => `${item.slug} (${item.baseId}) -> ${item.alias}` ); printList("Recettes sans photo/cover", report.recipesWithoutPhotos, (item) => `${item.slug} (${item.baseId})` ); console.log("\nNombre de gouts par forme"); for (const baseId of RECIPE_BASE_DISPLAY_ORDER) { console.log(` - ${baseId}: ${report.tastesByShape[baseId] ?? 0}`); } }; const hasMissingAssets = (report) => report.missingIngredientIcons.length > 0 || report.variantsWithoutCustomSprite.length > 0 || report.invalidCustomSprites.length > 0 || report.recipesWithoutPhotos.length > 0; const run = async () => { const report = await buildAudit(); if (args.has("--json")) { console.log(JSON.stringify(report, null, 2)); } else { printHumanReport(report); } if (args.has("--fail-on-missing") && hasMissingAssets(report)) { process.exitCode = 1; } }; run().catch((error) => { console.error(error); process.exit(1); });