Files
ben-to/scripts/audit-recipe-assets.mjs
T
2026-07-21 16:50:58 +02:00

370 lines
11 KiB
JavaScript

#!/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);
});