first commit
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user