370 lines
11 KiB
JavaScript
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);
|
|
});
|