first commit

This commit is contained in:
2026-07-21 16:50:58 +02:00
commit 256599626e
407 changed files with 30489 additions and 0 deletions
+369
View File
@@ -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);
});
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env node
/**
* Point d'entrée historique du pipeline recettes.
*
* Le pipeline a été décomposé en `scripts/recipes/` :
* - `parse-bentext.mjs` : parsing pur des fichiers `.bentext`.
* - `sprites.mjs` : parsing `ingredient-sprites.bentext` + résolution alias → pixels.
* - `assets.mjs` : IO (métadonnées PNG, copie branding/ingrédients, cover image).
* - `classification.mjs`: classification flavour + construction du graph bases/variants.
* - `pipeline.mjs` : orchestration (lit, fusionne, écrit le catalogue).
*
* Ce fichier reste pour ne pas casser `npm run pipeline:recipes` et les hooks
* qui s'appuient sur son nom. Toute nouvelle logique doit vivre dans
* `scripts/recipes/`.
*/
import { run } from "./recipes/pipeline.mjs";
run().catch((error) => {
console.error(error);
process.exit(1);
});
+97
View File
@@ -0,0 +1,97 @@
import { readFileSync, existsSync, writeFileSync } from "node:fs";
import path from "node:path";
import { globSync } from "glob";
const base = process.argv[2] ?? ".";
const outFile = process.argv[3] ?? "pr-quality-body.md";
function readJson(p) {
return JSON.parse(readFileSync(p, "utf8"));
}
const lines = [];
const covPath = path.join(base, "coverage", "coverage-summary.json");
if (existsSync(covPath)) {
const c = readJson(covPath);
const t = c.total;
lines.push("## Couverture (packages métier Vitest)");
lines.push(
`| Métrique | % |`,
`|----------|---|`,
`| Lignes | ${t.lines.pct.toFixed(2)} |`,
`| Branches | ${t.branches.pct.toFixed(2)} |`,
`| Fonctions | ${t.functions.pct.toFixed(2)} |`,
""
);
} else {
lines.push("## Couverture", "_Résumé indisponible (fichier coverage-summary manquant)._", "", "");
}
const a11yPath = path.join(base, "e2e", "a11y-summary.json");
if (existsSync(a11yPath)) {
const a = readJson(a11yPath);
lines.push("## Accessibilité (Axe / Playwright)");
lines.push(`- Violations : **${a.axeViolationCount}**`);
if (a.axeViolationIds?.length) lines.push(`- Ids : ${a.axeViolationIds.join(", ")}`);
lines.push("");
} else {
lines.push("## Accessibilité", "_Résumé Axe indisponible._", "", "");
}
const perfPath = path.join(base, "e2e", "perf-summary.json");
if (existsSync(perfPath)) {
const p = readJson(perfPath);
lines.push("## Performance (timings navigation)");
lines.push("```json");
lines.push(JSON.stringify(p.navigationTiming ?? p, null, 2));
lines.push("```", "");
}
const auditPath = path.join(base, "audit.json");
if (existsSync(auditPath)) {
const audit = readJson(auditPath);
const vulns = audit.vulnerabilities ?? {};
const metaV = audit.metadata?.vulnerabilities;
lines.push("## Sécurité (npm audit)");
if (metaV) {
lines.push(
`- Agrégat : critical ${metaV.critical}, high ${metaV.high}, moderate ${metaV.moderate}, low ${metaV.low}, info ${metaV.info}, total ${metaV.total}`
);
} else {
let high = 0;
let critical = 0;
for (const v of Object.values(vulns)) {
if (v.severity === "high") high++;
if (v.severity === "critical") critical++;
}
lines.push(`- Critical : ${critical}, High : ${high}`);
}
lines.push(`- Détail des paquets affectés : ${Object.keys(vulns).length}`, "");
} else {
lines.push("## Sécurité", "_npm audit non disponible._", "", "");
}
const lhr = globSync(path.join(base, "lhci", "**", "lhr-*.json").replace(/\\/g, "/"))[0];
if (lhr && existsSync(lhr)) {
const rep = readJson(lhr);
const cats = rep.categories ?? {};
const score = (c) => (c?.score != null ? Math.round(c.score * 100) : "n/a");
lines.push("## Lighthouse / SEO");
lines.push(
`| Catégorie | Score |`,
`|-----------|-------|`,
`| Performance | ${score(cats.performance)} |`,
`| Accessibilité | ${score(cats.accessibility)} |`,
`| Bonnes pratiques | ${score(cats["best-practices"])} |`,
`| SEO | ${score(cats.seo)} |`,
""
);
} else {
lines.push("## Lighthouse / SEO", "_Rapport LHCI indisponible._", "", "");
}
lines.push("---", "_Rapport généré par CI Ben-to._");
writeFileSync(outFile, lines.join("\n"), "utf8");
console.log(`Wrote ${outFile}`);
+22
View File
@@ -0,0 +1,22 @@
import { spawnSync } from "node:child_process";
const out = spawnSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACM"], {
encoding: "utf8"
});
if (out.status !== 0) process.exit(out.status ?? 1);
const files = out.stdout
.split("\n")
.map((s) => s.trim())
.filter(Boolean)
.filter((f) => /\.(ts|tsx)$/.test(f));
if (files.length === 0) process.exit(0);
const vitest = spawnSync(
"npx",
["vitest", "related", ...files, "--run", "--project", "unit", "--passWithNoTests"],
{ stdio: "inherit", shell: process.platform === "win32" }
);
process.exit(vitest.status === 0 ? 0 : vitest.status ?? 1);
+37
View File
@@ -0,0 +1,37 @@
# Audit backlog GitHub — Ben-to (plan prod / domaines)
Date de référence : 2026-04-26. Repo : `kazerlelutin/ben-to`.
## Architecture (rappel)
| Rôle | URL |
|------|-----|
| App web (Solid / Vite) | `https://ben-to.fr/` |
| Livre / ancienne vitrine | `https://book.ben-to.fr/` (bascule depuis `ben-to.fr`) |
| Contenus recettes | Dépôt Git (`ressources/bentext`, `ressources/public`) — build hors ligne |
Liens de partage recette : `window.location.origin` + `/r/{base}/{variant}` — cohérent avec le domaine où lapp est servie (`ben-to.fr`).
## Issues MVP existantes (#1#6)
| # | Titre | État | Lecture « traité vs restant » |
|---|--------|------|--------------------------------|
| 1 | MVP: fondations monorepo Solid + Capacitor | **CLOSED** | Traité. |
| 2 | MVP: pipeline .bentext vers JSON canonique | **CLOSED** | Traité (`build-recipes`, sources locales). |
| 3 | MVP: Builder 3 étapes base → variante → recette | **OPEN** | Fonctionnel dans le dépôt (builder, partage, filtres) ; issue à fermer ou à réduire au scope « polish / accessibilité / edge cases ». |
| 4 | MVP: offline-first + sync PocketBase | **OPEN** | Non aligné avec le front web actuel (pas dintégration PocketBase visible dans `apps/web`) ; backlog produit ou à reclarifier. |
| 5 | MVP: qualité/tests et sécurité CI | **OPEN** | CI racine active (lint, typecheck, unit, int, e2e, lighthouse) ; issue à fermer ou à limiter aux « gaps » (ex. mobile-release placeholder). |
| 6 | MVP: delivery CapRover + release mobile | **OPEN** | Web déployable (`Dockerfile`, `captain-definition`) ; release mobile CI encore placeholder dans `.github/workflows/mobile-release.yml`. |
## Nouvelles issues (plan)
Créées sur GitHub via [`create-backlog-prod-issues.sh`](create-backlog-prod-issues.sh) (nécessite `gh` + `jq` authentifiés) :
| Thème | Titres (recherche dans les issues) |
|--------|-------------------------------------|
| Meta | `[Meta] Audit backlog prod — avril 2026` (#7) |
| Catalogue / assets | `Catalogue — icônes bases…`, `Catalogue — icônes pour toutes les variantes`, `Assets — placeholders couvertures…` (#8#10, titres pouvant évoluer) |
| Play / Android | `Play Store — compte…`, `fiche produit`, `URL politique`, `Data safety`, `Android — signature release…` (#11#15) |
| Optionnel | `Tech — i18n domaines…` (#16) |
Relancer `./scripts/github/create-backlog-prod-issues.sh` : les titres déjà ouverts sont **ignorés** (pas de doublon).
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# Crée les issues GitHub du plan « Backlog GitHub, domaines et suite prod »
# si aucune issue ouverte du même titre nexiste déjà.
# Prérequis : GitHub CLI (`gh`) authentifié, `jq` installé.
set -euo pipefail
REPO="${1:-kazerlelutin/ben-to}"
issue_exists() {
local title="$1"
local n
n="$(gh issue list -R "$REPO" --state open --limit 500 --json title | jq --arg t "$title" '[.[] | select(.title == $t)] | length')"
[[ "${n:-0}" -gt 0 ]]
}
create_if_absent() {
local title="$1"
local body_file="$2"
if issue_exists "$title"; then
echo "SKIP (déjà ouvert): $title"
return 0
fi
gh issue create -R "$REPO" -t "$title" -F "$body_file" -l enhancement
echo "OK: $title"
}
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# --- Meta audit ---
cat >"$TMP/meta.md" <<'EOF'
## Synthèse audit backlog (scripts/github/BACKLOG-AUDIT.md)
Voir le fichier **BACKLOG-AUDIT.md** dans `scripts/github/` du dépôt pour le tableau détaillé des issues #1#6 et larchitecture `app` / `book` / contenus (`ressources/`).
**Résumé :** #1 et #2 fermées ; #3#6 ouvertes — interprétation : builder et CI largement avancés côté code ; PocketBase à reclarifier ; delivery mobile à compléter (signing / Gradle).
Prochaine étape : traiter les nouvelles issues « API assets » et « Play » créées par le même plan.
EOF
create_if_absent "[Meta] Audit backlog prod — avril 2026" "$TMP/meta.md"
# --- API / assets ---
cat >"$TMP/api-bases.md" <<'EOF'
## Contexte
Recettes et sprites sont **versionnés dans le dépôt** (`ressources/bentext`, `ressources/public/ingredient-sprites.bentext`). Il manque encore des tuiles cohérentes pour certaines bases canoniques.
## À faire
- Fournir ou ajuster les **sprites / alias** pour les bases : **mandu**, **dan bing**, **muffin**, **madeleine** (même pipeline que les autres bases dans le catalogue).
- Vérifier que `ingredient-sprites.bentext` et `npm run pipeline:recipes` reflètent les slugs / alias attendus.
## Critères
- Les bases concernées affichent une icône correcte dans le builder après `npm run pipeline:recipes`.
EOF
create_if_absent "Catalogue — icônes bases manquantes (mandu, dan bing, muffin, madeleine)" "$TMP/api-bases.md"
cat >"$TMP/api-variants.md" <<'EOF'
## Contexte
Le catalogue expose de nombreuses variantes ; les icônes dingrédients / tuiles doivent être cohérentes pour toutes.
## À faire
- Inventaire dans le dépôt des variantes / alias dingrédients nécessitant une entrée dans `ingredient-sprites.bentext`.
- Couvrir **toutes les variantes** ou documenter une **règle de fallback** (placeholder, sprite générique) lorsque aucune tuile ne correspond.
## Critères
- Aucune variante « cassée » visuellement dans le builder ; fallback explicite documenté.
EOF
create_if_absent "Catalogue — icônes pour toutes les variantes (+ fallback)" "$TMP/api-variants.md"
cat >"$TMP/placeholders.md" <<'EOF'
## Contexte
Certaines recettes nont pas encore de visuel final ; le build gère déjà des placeholders (`ressources/recipe-placeholder-covers`, logique dans `scripts/build-recipes.mjs`).
## À faire
- Définir la **convention** (nommage, par locale ou unique) pour les couvertures manquantes.
- Produire / brancher les **placeholders** dans le dépôt pour toutes les recettes sans image finale.
## Critères
- Build sans trou visuel critique ; liste des recettes encore en placeholder documentée.
EOF
create_if_absent "Assets — placeholders couvertures recettes manquantes" "$TMP/placeholders.md"
# --- Play / Android ---
cat >"$TMP/play-account.md" <<'EOF'
## À faire
- Créer le **compte développeur Google Play** (frais denregistrement one-shot).
- Accepter les accords distributeur ; définir pays / zones de distribution.
## Références
- `appId` : `com.kazerlelutin.bento` (`apps/mobile/capacitor.config.ts`).
EOF
create_if_absent "Play Store — compte développeur Google Play" "$TMP/play-account.md"
cat >"$TMP/play-listing.md" <<'EOF'
## À faire
- Fiche **Play Console** : nom, descriptions courte/longue, captures d’écran, icône haute résolution.
- **Classification du contenu** (questionnaire Play).
- Alignement avec la marque **Bento** / Ben-to.
## Package
- `com.kazerlelutin.bento` — vérifier cohérence avec la fiche avant publication.
EOF
create_if_absent "Play Store — fiche produit (captures, descriptions, classement)" "$TMP/play-listing.md"
cat >"$TMP/play-privacy.md" <<'EOF'
## À faire
- Publier une **URL HTTPS** de politique de confidentialité (obligatoire Play).
- Cohérence avec les mentions légales / confidentialité de lapp (`packages/shared/i18n`, pages statiques).
## Notes
- Peut être servie depuis `ben-to.fr` (domaine apex de lapp) ou autre sous-domaine stable.
EOF
create_if_absent "Play Store — URL politique de confidentialité (HTTPS)" "$TMP/play-privacy.md"
cat >"$TMP/play-data-safety.md" <<'EOF'
## À faire
- Remplir le formulaire **Data safety** (données collectées, partagées, chiffrement, etc.).
- Aligner sur le comportement réel : stockage local (constructeur), catalogue embarqué, mesure daudience optionnelle (Matomo).
## Fichiers utiles
- `apps/web/index.html`, `apps/web/src/infra/analytics.ts`
EOF
create_if_absent "Play Store — déclaration Data safety" "$TMP/play-data-safety.md"
cat >"$TMP/play-signing.md" <<'EOF'
## À faire
- **Keystore** upload / **Play App Signing**.
- Pipeline **AAB** (`bundleRelease`) — aujourdhui `.github/workflows/mobile-release.yml` ne fait quun placeholder après `mobile:sync`.
## Critères
- Documenter release locale (Android Studio) ou CI avec secrets (`KEYSTORE_*`, etc.).
EOF
create_if_absent "Android — signature release + CI (remplacer placeholder mobile-release)" "$TMP/play-signing.md"
# --- Optionnel (regroupement plan §4) ---
cat >"$TMP/optional-infra.md" <<'EOF'
## Périmètre (optionnel / dette)
- **i18n** : formulations `ben-to.fr` vs `book.ben-to.fr` dans `packages/shared/i18n/src/dictionary.ts` (depuis la migration vers le domaine apex, lapp nest plus servie sur `app.ben-to.fr`).
- **CORS** : pertinent seulement si un jour le catalogue ou des assets sont chargés en runtime depuis un autre domaine (hors périmètre actuel : tout est embarqué au build).
- **Redirects** : migration utilisateurs `ben-to.fr` → `book.ben-to.fr` (SEO, signets).
- **Matomo** : si activation du tracker complet → bandeau cookies + mise à jour privacy.
Découper en sous-tickets si besoin.
EOF
create_if_absent "Tech — i18n domaines, CORS bentext runtime, redirects book, Matomo/cookies" "$TMP/optional-infra.md"
echo "Terminé."
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
REPO="${1:-kazerlelutin/ben-to}"
gh issue create -R "$REPO" -t "MVP: fondations monorepo Solid + Capacitor" -b "Mettre en place workspaces, architecture feature-first, scripts qualité."
gh issue create -R "$REPO" -t "MVP: pipeline .bentext vers JSON canonique" -b "Parser ressources/bentext, générer recettes multilingues et corrélation assets."
gh issue create -R "$REPO" -t "MVP: Builder 3 étapes base -> variante -> recette" -b "Progression, filtre, quantités, partage/impression."
gh issue create -R "$REPO" -t "MVP: offline-first + sync PocketBase" -b "Queue locale, stratégie last-write-wins, reprise réseau."
gh issue create -R "$REPO" -t "MVP: qualité/tests et sécurité CI" -b "Unit/int/e2e, Gherkin, audits sécurité, lighthouse."
gh issue create -R "$REPO" -t "MVP: delivery CapRover + release mobile" -b "Déploiement web automatique et pipeline de release Android/iOS."
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Crée les 3 issues post-MVP sur GitHub (nécessite : gh auth login).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
if ! command -v gh >/dev/null 2>&1; then
echo "Installez GitHub CLI : https://cli.github.com/" >&2
exit 1
fi
repo_args=()
if [[ -n "${GITHUB_REPOSITORY:-}" ]]; then
repo_args=( -R "${GITHUB_REPOSITORY}" )
fi
gh issue create "${repo_args[@]}" \
--title "[Refactor] Builder — découpage SOLID et responsabilités claires" \
--body-file "${ROOT}/docs/tickets/issue-builder-solid.body.md"
gh issue create "${repo_args[@]}" \
--title "[Archi] Supprimer les index.ts barrel et verrouiller par règle projet" \
--body-file "${ROOT}/docs/tickets/issue-no-index-barrels.body.md"
gh issue create "${repo_args[@]}" \
--title "[SEO] Métadonnées OG/Twitter par recette (/r/...)" \
--body-file "${ROOT}/docs/tickets/issue-seo-recipe-og.body.md"
echo "OK — 3 issues créées."
+20
View File
@@ -0,0 +1,20 @@
import { promises as fs } from "node:fs";
import path from "node:path";
const requiredDirs = [
"packages/features/builder/src",
"packages/features/recipes/src",
"packages/shared/i18n/src"
];
for (const dir of requiredDirs) {
const absolute = path.resolve(dir);
try {
await fs.access(absolute);
} catch {
console.error(`Missing feature-first folder: ${dir}`);
process.exit(1);
}
}
console.log("Feature-first structure check passed");
@@ -0,0 +1,52 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { glob } from "glob";
const featuresDir = path.resolve("specs/features");
const featureFiles = await glob(path.join(featuresDir, "*.feature"));
/** Tags @int-* portés par les scénarios (intégration Vitest). */
const intTags = new Set();
for (const file of featureFiles) {
const text = await fs.readFile(file, "utf8");
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("@")) continue;
const parts = trimmed.split(/\s+/);
for (const token of parts) {
if (token.startsWith("@int-")) intTags.add(token);
}
}
}
const intTestFiles = await glob(["packages/**/*.int.test.ts", "apps/**/*.int.test.ts"], {
nodir: true
});
const merged = (
await Promise.all(intTestFiles.map(async (f) => ({ file: f, body: await fs.readFile(f, "utf8") })))
).reduce((acc, { file, body }) => {
acc.set(file, body);
return acc;
}, new Map());
const missing = [];
for (const tag of intTags) {
let found = false;
for (const body of merged.values()) {
if (body.includes(tag)) {
found = true;
break;
}
}
if (!found) missing.push(tag);
}
if (missing.length > 0) {
console.error("Scénarios @int-* sans référence dans un fichier *.int.test.ts :");
for (const tag of missing.sort()) console.error(` ${tag}`);
process.exit(1);
}
console.log(`Gherkin ↔ intégration OK (${intTags.size} tag(s) @int-* couverts dans ${intTestFiles.length} fichier(s))`);
+13
View File
@@ -0,0 +1,13 @@
import { promises as fs } from "node:fs";
import path from "node:path";
const featuresDir = path.resolve("specs/features");
const files = await fs.readdir(featuresDir);
const featureFiles = files.filter((file) => file.endsWith(".feature"));
if (featureFiles.length < 2) {
console.error("At least two feature files are required");
process.exit(1);
}
console.log(`Gherkin check passed (${featureFiles.length} files)`);
+12
View File
@@ -0,0 +1,12 @@
import { promises as fs } from "node:fs";
import path from "node:path";
const target = path.resolve("packages/features/builder/src/domain.ts");
const content = await fs.readFile(target, "utf8");
if (!content.includes("Readonly<") || !content.includes("Object.freeze")) {
console.error("Immutability check failed in builder domain");
process.exit(1);
}
console.log("Immutability check passed");
+17
View File
@@ -0,0 +1,17 @@
import { glob } from "glob";
/** Pas de fichiers barrel `src/index.ts` dans les packages métier (imports explicites uniquement). */
const candidates = await glob([
"packages/features/*/src/index.ts",
"packages/features/*/src/index.tsx",
"packages/shared/*/src/index.ts",
"packages/shared/*/src/index.tsx"
]);
if (candidates.length > 0) {
console.error("Barrel index.ts/tsx interdits — supprimer ou renommer en modules nommés :");
for (const file of candidates.sort()) console.error(` ${file}`);
process.exit(1);
}
console.log("No barrel index files under packages/features or packages/shared");
+117
View File
@@ -0,0 +1,117 @@
/**
* Catégories recettes Ben-to (alignées produit : familles & variantes typiques).
* Utilisé par `build-recipes.mjs` pour associer chaque recette à une « base » du catalogue.
*/
/** @typedef {'sweet' | 'savory'} BaseFlavor */
/** Ordre daffichage dans le builder (étape « base »). */
export const RECIPE_BASE_DISPLAY_ORDER = [
"base:onigiri",
"base:gimbap",
"base:mandu",
"base:empanadas-savory",
"base:empanadas-sweet",
"base:galettes",
"base:bao",
"base:muffin",
"base:mini-cakes",
"base:biscuits-secs",
"base:fluffy-cakes",
"base:cakes",
"base:carnet"
];
/** Flavour du bandeau salé / sucré pour la carte « base » (une valeur par catégorie). */
export const RECIPE_BASE_FLAVOR = /** @type {Readonly<Record<string, BaseFlavor>>} */ ({
"base:onigiri": "savory",
"base:gimbap": "savory",
"base:mandu": "savory",
"base:empanadas-savory": "savory",
"base:empanadas-sweet": "sweet",
"base:galettes": "savory",
"base:bao": "savory",
"base:muffin": "sweet",
"base:mini-cakes": "sweet",
"base:biscuits-secs": "sweet",
"base:fluffy-cakes": "sweet",
"base:cakes": "sweet",
"base:carnet": "savory"
});
const normSlug = (slug) => slug.toLowerCase();
const normTags = (tags) => tags.map((t) => t.trim().toLowerCase()).filter(Boolean);
/**
* Associe une recette à un identifiant de base catalogue.
* Règles évaluées du plus spécifique au plus général ; la première correspondance lemporte.
*
* @param {string} slug
* @param {ReadonlyArray<string>} tags Tags bentext (section tags), déjà en minuscules possible
* @returns {string}
*/
export const resolveRecipeBaseId = (slug, tags) => {
const s = normSlug(slug);
const tg = normTags([...tags]);
if (
tg.includes("carnet") ||
tg.includes('cheesecake') ||
tg.includes('carrot-cake')
) return "base:carnet";
if (s.includes("inarizushi") || s.includes("musubi") || s.includes("onigiri")) return "base:onigiri";
if (
s.includes("gimbap") ||
s.includes("kimbap") ||
(s.includes("maki") && !s.includes("makisu"))
) {
return "base:gimbap";
}
if (s.includes("mandu") || s.includes("gyoza") || s.includes("jiaozi") || s.includes("mandoo")) {
return "base:mandu";
}
if (s.includes("empanadas-citron") || s.includes("empanada-citron")) return "base:empanadas-sweet";
if (s.includes("empanad")) return "base:empanadas-savory";
if (
s.includes("dan-bing") ||
s.includes("danbing") ||
s.includes("blini") ||
s.includes("naan") ||
s.includes("jeon") ||
s.includes("paratha") ||
s.includes("cong-you") ||
s.includes("congyou")
) {
return "base:galettes";
}
if (/\b(mantou|baozi)\b/i.test(slug) || /\bbao\b/i.test(slug)) return "base:bao";
if (s.includes("muffin")) return "base:muffin";
if (s.includes("madeleine") || s.includes("financier")) return "base:mini-cakes";
if (s.includes("biscuit")) return "base:biscuits-secs";
if (
s.includes("savoie") ||
s.includes("fluffy") ||
s.includes("gateau") ||
s.includes("gâteau")
) {
return "base:fluffy-cakes";
}
if (s.includes("cake") || s.includes("banana") ||
s.includes("cremeux") ||
s.includes("crémeux")) {
return "base:cakes";
}
return "base:carnet";
};
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { resolveRecipeBaseId } from "./recipe-category.mjs";
describe("resolveRecipeBaseId", () => {
it("met en avant le tag carnet avant toute règle sur le slug", () => {
expect(resolveRecipeBaseId("onigiri-saumon", ["carnet"])).toBe("base:carnet");
});
it("classe les bouchées de riz", () => {
expect(resolveRecipeBaseId("onigiri-thon", [])).toBe("base:onigiri");
expect(resolveRecipeBaseId("inarizushi", [])).toBe("base:onigiri");
expect(resolveRecipeBaseId("spam-musubi", [])).toBe("base:onigiri");
});
it("classe les rouleaux de riz", () => {
expect(resolveRecipeBaseId("gimbap-legumes", [])).toBe("base:gimbap");
expect(resolveRecipeBaseId("kimbap", [])).toBe("base:gimbap");
expect(resolveRecipeBaseId("maki-saumon", [])).toBe("base:gimbap");
});
it("ne confond pas makisu avec maki", () => {
expect(resolveRecipeBaseId("makisu-bambou", [])).not.toBe("base:gimbap");
expect(resolveRecipeBaseId("makisu-bambou", [])).toBe("base:carnet");
});
it("classe mandu / gyoza", () => {
expect(resolveRecipeBaseId("mandu-kimchi", [])).toBe("base:mandu");
expect(resolveRecipeBaseId("gyoza-porc", [])).toBe("base:mandu");
});
it("sépare empanadas sucrées et salées", () => {
expect(resolveRecipeBaseId("empanadas-citron", [])).toBe("base:empanadas-sweet");
expect(resolveRecipeBaseId("empanadas-viande", [])).toBe("base:empanadas-savory");
});
it("classe galettes et bao", () => {
expect(resolveRecipeBaseId("dan-bing", [])).toBe("base:galettes");
expect(resolveRecipeBaseId("blinis", [])).toBe("base:galettes");
expect(resolveRecipeBaseId("naan-fromage", [])).toBe("base:galettes");
expect(resolveRecipeBaseId("bao-porc", [])).toBe("base:bao");
expect(resolveRecipeBaseId("mantou-fourre", [])).toBe("base:bao");
});
it("classe muffins, mini-cakes, biscuits, fluffy cakes et cakes", () => {
expect(resolveRecipeBaseId("muffin-myrtille", [])).toBe("base:muffin");
expect(resolveRecipeBaseId("madeleine-citron", [])).toBe("base:mini-cakes");
expect(resolveRecipeBaseId("biscuit-speculoos", [])).toBe("base:biscuits-secs");
expect(resolveRecipeBaseId("gateau-savoie", [])).toBe("base:fluffy-cakes");
expect(resolveRecipeBaseId("carrot-cake", [])).toBe("base:cakes");
expect(resolveRecipeBaseId("cheesecake-japonais", [])).toBe("base:cakes");
});
it("utilise le carnet comme défaut quand rien ne matche", () => {
expect(resolveRecipeBaseId("recette-inconnue", [])).toBe("base:carnet");
expect(resolveRecipeBaseId("guacamole", ["apéritif", "guacamole", "avocat", "carnet"])).toBe("base:carnet");
});
});
+36
View File
@@ -0,0 +1,36 @@
/**
* Tags bentext réservés au pipeline : ligne `sprite:<alias>` dans le bloc tags (fichier fr).
* Lalias correspond à une entrée de `ressources/public/ingredient-sprites.bentext` (comme les tuiles « base »).
*/
export const VARIANT_TILE_SPRITE_TAG_PREFIX = "sprite:";
const normalizeAliasKey = (s) => s.trim().toLowerCase().replace(/\s+/g, " ");
/**
* @param {ReadonlyArray<string>} tags Tags déjà normalisés (ex. minuscules depuis readBentext)
* @returns {string | null} alias tuile, sans le préfixe
*/
export const extractVariantTileSpriteAlias = (tags) => {
if (!Array.isArray(tags)) return null;
for (const t of tags) {
const s = typeof t === "string" ? t.trim().toLowerCase() : "";
if (!s.startsWith(VARIANT_TILE_SPRITE_TAG_PREFIX)) continue;
const rest = normalizeAliasKey(s.slice(VARIANT_TILE_SPRITE_TAG_PREFIX.length));
if (rest) return rest;
}
return null;
};
/**
* Retire les lignes `sprite:` pour le catalogue et les usages « tags visibles ».
* @param {ReadonlyArray<string>} tags
* @returns {string[]}
*/
export const stripVariantTileSpriteTags = (tags) => {
if (!Array.isArray(tags)) return [];
return tags.filter((t) => {
const s = typeof t === "string" ? t.trim().toLowerCase() : "";
return !s.startsWith(VARIANT_TILE_SPRITE_TAG_PREFIX);
});
};
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import {
extractVariantTileSpriteAlias,
stripVariantTileSpriteTags,
VARIANT_TILE_SPRITE_TAG_PREFIX
} from "./recipe-variant-tile-sprite.mjs";
describe("recipe-variant-tile-sprite", () => {
it("extrait lalias après sprite:", () => {
expect(extractVariantTileSpriteAlias(["bento", "sprite:onigiri", "riz"])).toBe("onigiri");
expect(extractVariantTileSpriteAlias(["sprite:dan bing"])).toBe("dan bing");
});
it("retire les lignes sprite: pour les tags exposés", () => {
expect(stripVariantTileSpriteTags(["bento", "sprite:onigiri", "riz"])).toEqual(["bento", "riz"]);
expect(stripVariantTileSpriteTags([])).toEqual([]);
});
it("expose le préfixe attendu dans bentext", () => {
expect(VARIANT_TILE_SPRITE_TAG_PREFIX).toBe("sprite:");
});
});
+115
View File
@@ -0,0 +1,115 @@
/**
* Assets IO — lecture des métadonnées PNG, copie des fichiers ingrédients
* et branding vers `apps/web/public`, sélection d'une cover recette.
*
* Aucun parsing : ces fonctions touchent le système de fichiers. Le parsing
* bentext / sprite vit dans `parse-bentext.mjs` et `sprites.mjs`.
*/
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
/** Fichiers dingrédients copiés tels quels vers `apps/web/public`. */
const INGREDIENT_ASSET_FILES = ["ingredients.png", "ingredient-sprites.csv", "ingredient-unknown.png"];
/** Fichiers de branding copiés tels quels vers `apps/web/public/branding`. */
export const BRANDING_FILES = ["onigiri-logo.png", "og.jpg"];
export const PATHS = {
repoRoot,
recipesDir: path.join(repoRoot, "ressources/bentext"),
outDir: path.join(repoRoot, "ressources/generated"),
webPublicDir: path.join(repoRoot, "apps/web/public/recipes"),
webCoverDir: path.join(repoRoot, "apps/web/public/recipe-covers"),
webBrandingDir: path.join(repoRoot, "apps/web/public/branding"),
webPublicRoot: path.join(repoRoot, "apps/web/public"),
publicAssetsDir: path.join(repoRoot, "ressources/public"),
placeholderCoversDir: path.join(repoRoot, "ressources/recipe-placeholder-covers"),
/** Grille des tuiles : coordonnées et alias listés dans ce fichier (plus dAPI). */
ingredientSpritesBentextPath: path.join(repoRoot, "ressources/public/ingredient-sprites.bentext"),
ingredientsPngPath: path.join(repoRoot, "ressources/public/ingredients.png")
};
const COVER_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp"];
const readPngDimensions = async (filePath) => {
try {
const buf = await fs.readFile(filePath);
if (buf.length < 24 || buf[0] !== 0x89) return null;
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
return { width, height };
} catch {
return null;
}
};
const copyIngredientAssetsToWeb = async () => {
await fs.mkdir(PATHS.webPublicRoot, { recursive: true });
for (const name of INGREDIENT_ASSET_FILES) {
const src = path.join(PATHS.publicAssetsDir, name);
const dest = path.join(PATHS.webPublicRoot, name);
try {
await fs.copyFile(src, dest);
} catch (error) {
if (error && error.code === "ENOENT") continue;
throw error;
}
}
};
const copyBrandingAssets = async () => {
await fs.mkdir(PATHS.webBrandingDir, { recursive: true });
for (const name of BRANDING_FILES) {
const src = path.join(PATHS.publicAssetsDir, name);
const dest = path.join(PATHS.webBrandingDir, name);
try {
await fs.copyFile(src, dest);
} catch (error) {
if (error && error.code === "ENOENT") {
console.warn(`Branding asset missing, skip: ${name}`);
continue;
}
throw error;
}
}
};
/**
* Sélectionne une cover pour `slug` :
* 1. Image HD dans `ressources/recipe-placeholder-covers/<slug>.png`.
* 2. Sinon, image dans `ressources/public/<slug>.<ext>`.
* Copie le fichier dans `apps/web/public/recipe-covers/` et retourne lURL publique.
*/
const pickCoverImage = async (slug) => {
await fs.mkdir(PATHS.webCoverDir, { recursive: true });
/* Priorité aux visuels HD du dépôt ; sinon ressources/public (override legacy). */
const placeholderPng = path.join(PATHS.placeholderCoversDir, `${slug}.png`);
try {
await fs.access(placeholderPng);
const publicName = `${slug}.png`;
const target = path.join(PATHS.webCoverDir, publicName);
await fs.copyFile(placeholderPng, target);
return `/recipe-covers/${publicName}`;
} catch {
// continue
}
for (const extension of COVER_EXTENSIONS) {
const candidate = path.join(PATHS.publicAssetsDir, `${slug}${extension}`);
try {
await fs.access(candidate);
const publicName = `${slug}${extension}`;
const target = path.join(PATHS.webCoverDir, publicName);
await fs.copyFile(candidate, target);
return `/recipe-covers/${publicName}`;
} catch {
// continue
}
}
return null;
};
export { readPngDimensions, copyIngredientAssetsToWeb, copyBrandingAssets, pickCoverImage };
+406
View File
@@ -0,0 +1,406 @@
/**
* Classification & construction du catalogue recette.
* Pur (aucun IO) — prend en entrée les recettes « fusionnées » issues de
* `pipeline.mjs` et les définitions de sprites résolues par `sprites.mjs`,
* renvoie le graph `{ bases, variants }` qui sera sérialisé en `catalog.json`.
*
* Le tagging des bases (slug/tags → `base:xxx`) reste dans
* `scripts/recipe-category.mjs` (testé indépendamment).
*/
import { RECIPE_BASE_DISPLAY_ORDER, RECIPE_BASE_FLAVOR, resolveRecipeBaseId } from "../recipe-category.mjs";
import {
aliasToIconPx,
buildIngredientCoverSprite,
findAliasForIconPx,
isPreferredVariantAlias,
resolveIconPxForIngredientName
} from "./sprites.mjs";
/** Mots-clés pour la classification sucrée/salée du bandeau de recette. */
const flavorKeywords = {
sweet: ["dessert", "gateau", "gâteau", "cake", "biscuit", "muffin", "financier", "madeleine", "crumble", "crème", "cremeux", "citron", "orange", "banane", "pomme", "chocolat", "sucré", "sucre"],
savory: ["sale", "salé", "bento", "riz", "onigiri", "mandu", "gimbap", "empanada", "naan", "dan-bing", "omelette", "œuf", "oeuf"]
};
/**
* Renvoie `"sweet"` ou `"savory"` selon les mots-clés présents dans le slug
* concaténé aux tags. En cas d’égalité, fallback `"savory"` (préserve le
* comportement historique du pipeline).
*/
const classifyFlavor = (slug, tags) => {
const haystack = `${slug} ${tags.join(" ")}`.toLowerCase();
const sweetHits = flavorKeywords.sweet.filter((keyword) => haystack.includes(keyword)).length;
const savoryHits = flavorKeywords.savory.filter((keyword) => haystack.includes(keyword)).length;
if (sweetHits > savoryHits) return "sweet";
if (savoryHits > sweetHits) return "savory";
return "savory";
};
const buildLocalizedText = (localesMap, fallback) => {
const fr = localesMap.fr?.title ?? fallback;
const en = localesMap.en?.title ?? fr;
const ko = localesMap.ko?.title ?? en;
const zh = localesMap.zh?.title ?? en;
return { fr, en, ko, zh };
};
const buildLocalizedParagraph = (localesMap, fallback) => {
const fr = localesMap.fr?.description ?? fallback;
const en = localesMap.en?.description ?? fr;
const ko = localesMap.ko?.description ?? en;
const zh = localesMap.zh?.description ?? en;
return { fr, en, ko, zh };
};
/** Libellés affichés (familles produit) — voir `scripts/recipe-category.mjs` pour lassociation slug/tags → id. */
const BASE_LABELS = {
"base:onigiri": {
name: { fr: "Bouchées de riz", en: "Rice bites", ko: "밥 한 입", zh: "饭团一口" },
history: {
fr: "Riz compact, façonné, garni ou enveloppé — onigiri, inarizushi, musubi…",
en: "Compact rice, shaped, filled or wrapped—onigiri, inarizushi, musubi…",
ko: "조리해 모양 낸 밥 — 오니기리, 이나리즈시 등.",
zh: "塑形或包裹的米饭一口食——饭团、稻荷寿司等。"
}
},
"base:gimbap": {
name: { fr: "Rouleaux de riz", en: "Rice rolls", ko: "밥 말이", zh: "饭卷" },
history: {
fr: "Riz roulé puis découpé — gimbap, maki-like.",
en: "Rolled and sliced rice—gimbap, maki-style rolls.",
ko: "말아 자른 밥 — 김밥, 마키 스타일.",
zh: "卷起再切的饭——紫菜包饭、卷饭。"
}
},
"base:mandu": {
name: { fr: "Mandu", en: "Mandu / dumplings", ko: "만두", zh: "饺子 / 煎饺" },
history: {
fr: "Raviolis et bouchées farcies — mandu, gyoza, jiaozi…",
en: "Stuffed dumplings—mandu, gyoza, jiaozi…",
ko: "속을 채운 만두류.",
zh: "带馅面团——饺子、锅贴等。"
}
},
"base:empanadas-savory": {
name: { fr: "Empanadas", en: "Empanadas", ko: "엠파나다", zh: "烤饺" },
history: {
fr: "Chaussons farcis — empanadas, hand pies, pastels salés.",
en: "Filled turnovers—empanadas, hand pies, savoury pastels.",
ko: "속을 채운 페이스트리.",
zh: "酥皮咸馅小包。"
}
},
"base:empanadas-sweet": {
name: { fr: "Empanadas", en: "Empanadas", ko: "엠파나다", zh: "烤饺" },
history: {
fr: "Chaussons sucrés — farce dessert.",
en: "Sweet filled turnovers.",
ko: "달콤한 속을 넣은 페이스트리.",
zh: "甜馅酥皮小包。"
}
},
"base:galettes": {
name: { fr: "Galettes", en: "Flatbreads & griddled cakes", ko: "전·난·플랫브레드", zh: "烙饼 / 扁面包" },
history: {
fr: "Pains plats, crêpes, galettes poêlées — naan, dan bing, jeon, paratha…",
en: "Flatbreads and griddled pancakes—naan, dan bing, jeon, paratha…",
ko: "난, 단빙, 전 등 납작한 빵·전병.",
zh: "烙饼、印度烤饼、煎饼类等。"
}
},
"base:bao": {
name: { fr: "Bao", en: "Steamed buns", ko: "바오·만두빵", zh: "包子 / 包点" },
history: {
fr: "Pains vapeur farcis ou garnis — bao salé ou sucré, mantou garni.",
en: "Steamed buns, savoury or sweet filling, stuffed mantou.",
ko: "찐 빵·만두빵.",
zh: "蒸制包点。"
}
},
"base:muffin": {
name: { fr: "Muffins", en: "Muffins", ko: "머핀", zh: "玛芬" },
history: {
fr: "Portion moulée individuelle — muffins sucrés ou salés.",
en: "Individual molded portions—sweet or savoury muffins.",
ko: "한 입 크기 머핀.",
zh: "一人份模烤点心。"
}
},
"base:mini-cakes": {
name: { fr: "Mini-cakes", en: "Small portable cakes", ko: "미니 케이크", zh: "小蛋糕" },
history: {
fr: "Petits gâteaux transportables — madeleines, financiers, mini-cakes.",
en: "Portable small cakes—madeleines, financiers, mini cakes.",
ko: "휴대하기 좋은 작은 케이크.",
zh: "便于携带的小蛋糕。"
}
},
"base:biscuits-secs": {
name: { fr: "Biscuits secs", en: "Crisp cookies", ko: "바삭한 비스킷", zh: "酥脆饼干" },
history: {
fr: "Sec, solide, compatible bureau — cookies, sablés, biscuits croquants.",
en: "Dry, snack-friendly—cookies, shortbread, crisp biscuits.",
ko: "바삭한 과자·쿠키.",
zh: "干爽酥脆饼干。"
}
},
"base:fluffy-cakes": {
name: { fr: "Fluffy cakes", en: "Fluffy cakes", ko: "폭신 케이크", zh: "松软蛋糕" },
history: {
fr: "Gâteaux légers, tranchables ou sandwichables — sponge, génoises, fluffy.",
en: "Light, sliceable or layer cakes—sponge, génoise, fluffy styles.",
ko: "가볍게 썰어 먹는 케이크.",
zh: "松软可分切的蛋糕。"
}
},
"base:cakes": {
name: { fr: "Cakes", en: "Cakes", ko: "케이크", zh: "蛋糕" },
history: {
fr: "Gâteaux type layer cake, carrot cake, banana bread…",
en: "Layer cakes, carrot cake, banana bread-style bakes.",
ko: "당근 케이크, 바나나 브레드 등.",
zh: "胡萝卜蛋糕、香蕉蛋糕等。"
}
},
"base:carnet": {
name: { fr: "Carnet", en: "Notebook", ko: "노트", zh: "手记" },
history: {
fr: "Recettes bonus, hors concept strict Ben-to — à taguer « carnet » dans les tags.",
en: "Bonus recipes outside the core Ben-to grid—tag « carnet » in bentext tags.",
ko: "보조 레시피 — 태그에 carnet.",
zh: "附录食谱——在标签中加 carnet。"
}
}
};
/** Icônes des tuiles « base » : alias dans `ingredient-sprites.bentext` → coordonnées pixels. */
const BASE_SPRITE_ALIASES = {
"base:onigiri": "onigiri",
"base:gimbap": "gimbap",
"base:mandu": "mandu",
"base:empanadas-savory": "empenadas",
"base:empanadas-sweet": "empenadas",
"base:galettes": "naan",
"base:bao": "bao",
"base:muffin": "muffin",
"base:mini-cakes": "madelaine",
"base:biscuits-secs": "muffin",
"base:fluffy-cakes": "gateau de savoie",
"base:cakes": "tranche cake",
"base:carnet": "carnet"
};
const buildBaseIngredientsSteps = (recipe, sheetMeta) => {
const ingredients = recipe.mergedIngredients.map((ingredient) => {
const { iconPx, id, names, notes, quantity, unit } = ingredient;
const coverSprite = buildIngredientCoverSprite(iconPx, sheetMeta);
const row = {
id,
name: {
fr: names.fr ?? names.en,
en: names.en ?? names.fr,
ko: names.ko ?? names.en,
zh: names.zh ?? names.en
},
info: {
fr: notes.fr ?? "",
en: notes.en ?? "",
ko: notes.ko ?? "",
zh: notes.zh ?? ""
},
quantity,
unit
};
if (coverSprite) row.coverSprite = coverSprite;
return row;
});
const steps = recipe.mergedSteps.map((step) => ({
id: step.id,
priority: step.priority,
phase: step.phase,
dependsOn: step.dependsOn,
text: {
fr: step.text.fr,
en: step.text.en,
ko: step.text.ko,
zh: step.text.zh
}
}));
return { ingredients, steps };
};
const buildVariantPayload = (recipe, sheetMeta) => {
const title = buildLocalizedText(recipe.locales, recipe.slug);
const story = buildLocalizedParagraph(recipe.locales, "");
const ingredients = recipe.mergedIngredients.map((ingredient) => {
const { iconPx, id, names, notes, quantity, unit } = ingredient;
const coverSprite = buildIngredientCoverSprite(iconPx, sheetMeta);
const row = {
id: `variant:${recipe.slug}:${id}`,
name: {
fr: names.fr ?? names.en,
en: names.en ?? names.fr,
ko: names.ko ?? names.en,
zh: names.zh ?? names.en
},
info: {
fr: notes.fr ?? "",
en: notes.en ?? "",
ko: notes.ko ?? "",
zh: notes.zh ?? ""
},
quantity,
unit
};
if (coverSprite) row.coverSprite = coverSprite;
return row;
});
const steps = recipe.mergedSteps.map((step) => ({
id: `variant:${recipe.slug}:${step.id}`,
priority: step.priority + 5000,
phase: step.phase,
dependsOn: step.dependsOn.map((dependency) => `variant:${recipe.slug}:${dependency}`),
text: {
fr: step.text.fr,
en: step.text.en,
ko: step.text.ko,
zh: step.text.zh
}
}));
return { title, story, ingredients, steps, servings: recipe.servings };
};
const buildRecipeGraph = (recipes, sheetMeta, spriteByAlias, preferredVariantAliases) => {
const baseBuckets = new Map();
for (const baseId of RECIPE_BASE_DISPLAY_ORDER) {
baseBuckets.set(baseId, []);
}
for (const recipe of recipes) {
const id = resolveRecipeBaseId(recipe.slug, recipe.tags);
baseBuckets.get(id).push(recipe);
}
const bases = [];
for (const baseId of RECIPE_BASE_DISPLAY_ORDER) {
const bucket = baseBuckets.get(baseId) ?? [];
bucket.sort((a, b) => a.slug.localeCompare(b.slug));
const representative = bucket[0] ?? null;
const labels = BASE_LABELS[baseId];
const { ingredients, steps } = representative
? buildBaseIngredientsSteps(representative, sheetMeta)
: { ingredients: [], steps: [] };
const servings = representative?.servings ?? 1;
const tags = representative ? [...representative.tags] : [];
const baseSpriteAlias = BASE_SPRITE_ALIASES[baseId];
const baseIconPx = baseSpriteAlias ? aliasToIconPx(spriteByAlias, baseSpriteAlias) : null;
if (baseSpriteAlias && !baseIconPx) {
console.warn(
`[build-recipes] Icône base absente de ingredient-sprites.bentext (alias « ${baseSpriteAlias} » pour ${baseId}).`
);
}
const baseCoverSprite = buildIngredientCoverSprite(baseIconPx, sheetMeta);
bases.push(
Object.freeze({
id: baseId,
flavor: RECIPE_BASE_FLAVOR[baseId],
name: labels.name,
history: labels.history,
servings,
ingredients: Object.freeze(ingredients),
steps: Object.freeze(steps),
slug: baseId.replace("base:", ""),
coverImageUrl: null,
coverSprite: baseCoverSprite ?? null,
tags: Object.freeze(tags)
})
);
}
const variants = [];
for (const recipe of recipes) {
const baseId = resolveRecipeBaseId(recipe.slug, recipe.tags);
const { title, story, ingredients, steps } = buildVariantPayload(recipe, sheetMeta);
const variantId = `variant:${recipe.slug}`;
const variantCoverSprite = resolveVariantCoverSprite(
recipe,
sheetMeta,
spriteByAlias,
preferredVariantAliases
);
variants.push(
Object.freeze({
id: variantId,
baseIds: Object.freeze([baseId]),
name: title,
story,
servings: recipe.servings,
ingredients: Object.freeze(ingredients),
steps: Object.freeze(steps),
slug: recipe.slug,
coverImageUrl: null,
coverSprite: variantCoverSprite ?? null,
recipeCoverImageUrl: recipe.coverImageUrl ?? null,
tags: Object.freeze(recipe.tags)
})
);
}
return { bases: Object.freeze(bases), variants: Object.freeze(variants) };
};
/**
* Icône tuile garniture : override explicite via tag bentext `sprite:<alias>`
* dabord, sinon ingrédient « prioritaire », sinon premier ingrédient avec sprite résolu.
* Émet un `console.warn` quand lalias explicite est introuvable.
*/
const resolveVariantCoverSprite = (recipe, sheetMeta, spriteByAlias, preferredVariantAliases) => {
const tileAlias = recipe.variantTileSpriteAlias ?? null;
if (tileAlias) {
const sprite = resolveVariantCoverSpriteFromTileAlias(tileAlias, sheetMeta, spriteByAlias);
if (sprite) return sprite;
console.warn(
`[build-recipes] Tuile variante : alias « ${tileAlias} » introuvable (${recipe.slug}), fallback automatique.`
);
}
return pickVariantCoverSprite(recipe, sheetMeta, spriteByAlias, preferredVariantAliases);
};
const resolveVariantCoverSpriteFromTileAlias = (aliasKey, sheetMeta, spriteByAlias) => {
if (!aliasKey || typeof aliasKey !== "string") return null;
const normalized = normalizeAliasKeyLocal(aliasKey);
const px =
resolveIconPxForIngredientName(aliasKey, spriteByAlias) ??
aliasToIconPx(spriteByAlias, normalized);
if (!px || typeof px.x !== "number" || typeof px.y !== "number") return null;
return buildIngredientCoverSprite(px, sheetMeta);
};
const pickVariantCoverSprite = (recipe, sheetMeta, spriteByAlias, preferredAliases) => {
const merged = recipe.mergedIngredients ?? [];
const tryPick = (preferredOnly) => {
for (const ing of merged) {
const px = ing.iconPx;
if (!px || typeof px.x !== "number" || typeof px.y !== "number") continue;
const alias = findAliasForIconPx(spriteByAlias, px);
if (preferredOnly) {
if (!alias || !isPreferredVariantAlias(preferredAliases, alias)) continue;
}
return buildIngredientCoverSprite(px, sheetMeta);
}
return null;
};
return tryPick(true) ?? tryPick(false) ?? null;
};
const normalizeAliasKeyLocal = (s) => s.trim().toLowerCase().replace(/\s+/g, " ");
export {
classifyFlavor,
buildLocalizedText,
buildLocalizedParagraph,
buildBaseIngredientsSteps,
buildVariantPayload,
buildRecipeGraph,
resolveVariantCoverSprite
};
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, it } from "vitest";
import {
buildBaseIngredientsSteps,
buildLocalizedParagraph,
buildLocalizedText,
buildRecipeGraph,
buildVariantPayload,
classifyFlavor
} from "./classification.mjs";
describe("classifyFlavor", () => {
it("retombe sur sweet si mots-clés sucrés dominent", () => {
expect(classifyFlavor("gateau-chocolat", [])).toBe("sweet");
expect(classifyFlavor("muffin-banane", [])).toBe("sweet");
});
it("retombe sur savory si mots-clés salés dominent", () => {
expect(classifyFlavor("onigiri-thon", [])).toBe("savory");
expect(classifyFlavor("naan-fromage", [])).toBe("savory");
});
it("considère aussi les tags (pas seulement le slug)", () => {
expect(classifyFlavor("recette-x", ["bento", "riz", "onigiri"])).toBe("savory");
expect(classifyFlavor("recette-x", ["dessert", "chocolat"])).toBe("sweet");
});
it("fallback savory en cas d'égalité (comportement historique)", () => {
// « cake » sucré + « bento » salé = 1-1
expect(classifyFlavor("cake-bento", [])).toBe("savory");
});
});
describe("buildLocalizedText & buildLocalizedParagraph", () => {
it("cascade fr → en → ko → zh avec fallback en cas de locale manquante", () => {
expect(
buildLocalizedText(
{ fr: { title: "Gâteau" }, en: { title: "Cake" } },
"default"
)
).toEqual({ fr: "Gâteau", en: "Cake", ko: "Cake", zh: "Cake" });
expect(
buildLocalizedText(
{ fr: { title: "Gâteau" } },
"default"
)
).toEqual({ fr: "Gâteau", en: "Gâteau", ko: "Gâteau", zh: "Gâteau" });
expect(buildLocalizedText({}, "default")).toEqual({
fr: "default",
en: "default",
ko: "default",
zh: "default"
});
});
it("buildLocalizedParagraph utilise la clé `description`", () => {
expect(
buildLocalizedParagraph(
{ fr: { description: "Une histoire" }, en: { description: "A story" } },
"fallback"
)
).toEqual({ fr: "Une histoire", en: "A story", ko: "A story", zh: "A story" });
});
});
describe("buildBaseIngredientsSteps & buildVariantPayload", () => {
const sheetMeta = { width: 256, height: 256 };
it("produit ingrédients + steps localisés pour une base", () => {
const recipe = {
slug: "onigiri",
mergedIngredients: [
{
id: "onigiri-ingredient-0",
iconPx: { x: 0, y: 0 },
names: { fr: "Riz", en: "Rice" },
notes: { fr: "note fr", en: "" },
quantity: 100,
unit: "g"
}
],
mergedSteps: [
{ id: "step-1", priority: 10, phase: "cook", dependsOn: [], text: { fr: "Cuire", en: "Cook" } }
]
};
const { ingredients, steps } = buildBaseIngredientsSteps(recipe, sheetMeta);
expect(ingredients).toHaveLength(1);
expect(ingredients[0].id).toBe("onigiri-ingredient-0");
expect(ingredients[0].name.fr).toBe("Riz");
expect(ingredients[0].coverSprite).toBeTruthy();
expect(steps[0]).toMatchObject({
id: "step-1",
priority: 10,
phase: "cook",
dependsOn: [],
text: { fr: "Cuire", en: "Cook" }
});
});
it("préfixe les ids et bump la priority pour les variantes", () => {
const recipe = {
slug: "onigiri",
servings: 3,
locales: { fr: { title: "Onigiri thon" } },
mergedIngredients: [
{
id: "onigiri-ingredient-0",
iconPx: null,
names: { fr: "Riz", en: "Rice" },
notes: { fr: "", en: "" },
quantity: 1,
unit: "g"
}
],
mergedSteps: [
{ id: "step-1", priority: 10, phase: "cook", dependsOn: [], text: { fr: "Cuire", en: "Cook" } },
{ id: "step-2", priority: 20, phase: "cook", dependsOn: ["step-1"], text: { fr: "Servir", en: "Serve" } }
]
};
const { title, story, ingredients, steps, servings } = buildVariantPayload(recipe, sheetMeta);
expect(servings).toBe(3);
expect(title).toEqual({ fr: "Onigiri thon", en: "Onigiri thon", ko: "Onigiri thon", zh: "Onigiri thon" });
expect(story.fr).toBe("");
expect(ingredients[0].id).toBe("variant:onigiri:onigiri-ingredient-0");
expect(steps[0].id).toBe("variant:onigiri:step-1");
expect(steps[0].priority).toBe(5010);
expect(steps[1].dependsOn).toEqual(["variant:onigiri:step-1"]);
});
});
describe("buildRecipeGraph", () => {
const sheetMeta = { width: 256, height: 256 };
const spriteByAlias = {
onigiri: { x: 0, y: 0 },
madelaine: { x: 32, y: 0 },
carnet: { x: 64, y: 0 }
};
const preferredVariantAliases = new Set(["onigiri"]);
it("construit 13 bases dans l'ordre d'affichage", () => {
const { bases } = buildRecipeGraph([], sheetMeta, spriteByAlias, preferredVariantAliases);
expect(bases).toHaveLength(13);
expect(bases.map((b) => b.id)).toEqual([
"base:onigiri",
"base:gimbap",
"base:mandu",
"base:empanadas-savory",
"base:empanadas-sweet",
"base:galettes",
"base:bao",
"base:muffin",
"base:mini-cakes",
"base:biscuits-secs",
"base:fluffy-cakes",
"base:cakes",
"base:carnet"
]);
// base sans recette = pas d'ingrédients, pas d'erreur
expect(bases[0].ingredients).toEqual([]);
});
it("attache la base, les ingrédients et les étapes à la base du slug représentatif", () => {
const recipes = [
{
slug: "onigiri-thon",
servings: 2,
flavor: "savory",
tags: ["bento"],
variantTileSpriteAlias: null,
coverImageUrl: null,
locales: { fr: { title: "Onigiri thon" } },
mergedIngredients: [
{
id: "onigiri-thon-ingredient-0",
iconPx: { x: 0, y: 0 },
names: { fr: "Riz", en: "Rice" },
notes: { fr: "", en: "" },
quantity: 1,
unit: "g"
}
],
mergedSteps: [
{ id: "step-1", priority: 10, phase: "cook", dependsOn: [], text: { fr: "Cuire", en: "Cook" } }
]
}
];
const { bases, variants } = buildRecipeGraph(recipes, sheetMeta, spriteByAlias, preferredVariantAliases);
const onigiriBase = bases.find((b) => b.id === "base:onigiri");
expect(onigiriBase.servings).toBe(2);
expect(onigiriBase.ingredients).toHaveLength(1);
expect(onigiriBase.steps).toHaveLength(1);
expect(onigiriBase.tags).toEqual(["bento"]);
expect(variants).toHaveLength(1);
expect(variants[0].baseIds).toEqual(["base:onigiri"]);
expect(variants[0].servings).toBe(2);
expect(variants[0].slug).toBe("onigiri-thon");
expect(variants[0].coverSprite).toBeTruthy();
});
it("utilise un alias sprite: explicite pour la coverSprite variante", () => {
const recipes = [
{
slug: "onigiri-saumon",
flavor: "savory",
tags: ["bento"],
variantTileSpriteAlias: "madelaine",
coverImageUrl: null,
locales: { fr: { title: "Onigiri saumon" } },
mergedIngredients: [
{
id: "onigiri-saumon-ingredient-0",
iconPx: { x: 0, y: 0 },
names: { fr: "Riz", en: "Rice" },
notes: { fr: "", en: "" },
quantity: 1,
unit: "g"
}
],
mergedSteps: [
{ id: "step-1", priority: 10, phase: "cook", dependsOn: [], text: { fr: "Cuire", en: "Cook" } }
]
}
];
const { variants } = buildRecipeGraph(recipes, sheetMeta, spriteByAlias, preferredVariantAliases);
// madelaine → (32, 0) → col=1, row=0
expect(variants[0].coverSprite).toMatchObject({ col: 1, row: 0 });
});
});
+84
View File
@@ -0,0 +1,84 @@
/**
* Parsing pur des fichiers `.bentext` (sources recette).
* Aucun effet de bord : ne touche pas au système de fichiers, au catalogue,
* ni aux assets. Utilisé par `scripts/build-recipes.mjs`.
*
* Format bentext :
* Title
* Version
* Description
* ---
* Ingredients (une ligne par ingrédient : `Nom|quantité|unité|note`)
* ---
* Steps (une ligne par étape, ordre du fichier = priorité)
* ---
* Notes (optionnel, présent si 6+ blocs `---`)
* Tags (optionnel dès 4 blocs)
* Meta (optionnel dès 5 blocs)
*/
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 splitNonEmptyLines = (raw) =>
raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const readBentext = (raw) => {
const parts = raw.split("---").map((part) => part.trim());
const header = (parts[0] ?? "").split("\n").map((line) => line.trim());
const title = header[0] ?? "";
const version = header[1] ?? "";
const servings = Number.parseInt(version, 10);
const description = header.slice(2).join("\n").trim();
const ingredientsBlock = parts[1] ?? "";
const stepsBlock = parts[2] ?? "";
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] ?? "";
}
const stepLines = splitNonEmptyLines(stepsBlock);
const steps = stepLines.map((line, index) => ({
id: `step-${index + 1}`,
priority: (index + 1) * 10,
phase: "cook",
dependsOn: index === 0 ? [] : [`step-${index}`],
text: line
}));
return {
title,
version,
servings: Number.isFinite(servings) && servings > 0 ? servings : 1,
description,
ingredients: splitNonEmptyLines(ingredientsBlock).map((line) => splitIngredientLine(line)),
steps,
notes: splitNonEmptyLines(notesBlock),
tags: splitNonEmptyLines(tagsBlock).map((line) => line.toLowerCase()),
meta: splitNonEmptyLines(metaBlock)
};
};
export { readBentext, splitIngredientLine };
+176
View File
@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import { readBentext, splitIngredientLine } from "./parse-bentext.mjs";
describe("readBentext", () => {
it("parse header, ingredients, steps (3 blocs)", () => {
const raw = `Blinis
4
Petites crêpes épaisses.
---
Farine|200|g
Œufs|2|pcs
---
Mélanger la farine.
Cuire dans une poêle.
`;
const result = readBentext(raw);
expect(result.title).toBe("Blinis");
expect(result.version).toBe("4");
expect(result.servings).toBe(4);
expect(result.description).toBe("Petites crêpes épaisses.");
expect(result.ingredients).toEqual([
{ name: "Farine", quantity: 200, unit: "g", note: "" },
{ name: "Œufs", quantity: 2, unit: "pcs", note: "" }
]);
expect(result.steps).toEqual([
{ id: "step-1", priority: 10, phase: "cook", dependsOn: [], text: "Mélanger la farine." },
{ id: "step-2", priority: 20, phase: "cook", dependsOn: ["step-1"], text: "Cuire dans une poêle." }
]);
expect(result.notes).toEqual([]);
expect(result.tags).toEqual([]);
expect(result.meta).toEqual([]);
});
it("lit les tags, notes et meta quand 6 blocs --- sont présents", () => {
const raw = `Title
1
Description.
---
Farine|100|g
---
Mélanger.
---
Note A
Note B
---
apéritif
blinis
sprite:blinis
---
Meta X
Meta Y
`;
const result = readBentext(raw);
expect(result.notes).toEqual(["Note A", "Note B"]);
expect(result.tags).toEqual(["apéritif", "blinis", "sprite:blinis"]);
expect(result.meta).toEqual(["Meta X", "Meta Y"]);
});
it("ne place les tags qu'à partir de 4 blocs (sans notes ni meta)", () => {
const raw = `Title
1
Description.
---
Farine|100|g
---
Mélanger.
---
apéritif
blinis
`;
const result = readBentext(raw);
expect(result.tags).toEqual(["apéritif", "blinis"]);
expect(result.notes).toEqual([]);
expect(result.meta).toEqual([]);
});
it("place tags + meta dès 5 blocs (sans notes)", () => {
const raw = `Title
1
Description.
---
Farine|100|g
---
Mélanger.
---
apéritif
---
Meta X
`;
const result = readBentext(raw);
expect(result.tags).toEqual(["apéritif"]);
expect(result.notes).toEqual([]);
expect(result.meta).toEqual(["Meta X"]);
});
it("convertit les tags en minuscules", () => {
const raw = `T
1
D
---
A|1|g
---
S1
---
Bento
CARNET
`;
const result = readBentext(raw);
expect(result.tags).toEqual(["bento", "carnet"]);
});
it("gère les notes et tags vides (blocs absents)", () => {
const raw = `T
D
---
---
S1
`;
const result = readBentext(raw);
expect(result.title).toBe("T");
expect(result.version).toBe("");
expect(result.servings).toBe(1);
expect(result.description).toBe("D");
expect(result.ingredients).toEqual([]);
expect(result.steps).toEqual([{ id: "step-1", priority: 10, phase: "cook", dependsOn: [], text: "S1" }]);
expect(result.notes).toEqual([]);
expect(result.tags).toEqual([]);
expect(result.meta).toEqual([]);
});
it("extrait la note ingrédient après position 3 (parties | supplémentaires)", () => {
const raw = `T
1
D
---
Farine|200|g|type 55
---
Mélanger.
---
---
---
`;
const result = readBentext(raw);
expect(result.ingredients).toEqual([
{ name: "Farine", quantity: 200, unit: "g", note: "type 55" }
]);
});
});
describe("splitIngredientLine", () => {
it("split name | quantity | unit", () => {
expect(splitIngredientLine("Farine|200|g")).toEqual({ name: "Farine", quantity: 200, unit: "g", note: "" });
});
it("renvoie quantity=NaN si non numérique (comportement préservé)", () => {
expect(splitIngredientLine("Sel|abc|c. à c.")).toEqual({
name: "Sel",
quantity: Number.NaN,
unit: "c. à c.",
note: ""
});
});
it("conserve la note avec | à l'intérieur", () => {
expect(splitIngredientLine("Sauce|2|c. à s.|soja | sucrée")).toEqual({
name: "Sauce",
quantity: 2,
unit: "c. à s.",
note: "soja | sucrée"
});
});
});
+223
View File
@@ -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 dingré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 lalias 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 };
+183
View File
@@ -0,0 +1,183 @@
/**
* Sprites ingrédients — parsing de `ingredient-sprites.bentext` et résolution
* alias → coordonnées pixel sur la planche. Pure, sans effet de bord.
*
* Format de `ingredient-sprites.bentext` :
* --- (séparateur de blocs)
* <row> <col> (coordonnées dans la grille, taille de cellule = INGREDIENT_SPRITE_CELL)
* alias 1 | alias 2 (synonymes séparés par `|`, lignes supplémentaires acceptées)
* ---
* ...
*
* Sortie de `parseIngredientSpritesBentext` :
* - byAlias : { [aliasLower]: { x, y, X, y: y } }
* - preferredAliases : Set<aliasLower> (alias des lignes « prioritaires »)
* - csv : rendu CSV (compat / rétro pour les outils existants)
*/
export const INGREDIENT_SPRITE_CELL = 32;
/** Lignes de tuiles « prioritaires » pour la carte variante (aligné sur lancienne grille CSV). */
export const PREFERRED_VARIANT_SPRITE_ROWS = new Set([8, 9, 10, 12, 13, 14, 15, 16]);
const normalizeIngredientAliasKey = (s) => s.trim().toLowerCase().replace(/\s+/g, " ");
const foldAccents = (s) => s.normalize("NFD").replace(/\p{M}+/gu, "").toLowerCase();
/**
* Parse `ingredient-sprites.bentext` : blocs `---` puis ligne `row col`, puis alias (`|` = synonymes).
*/
const parseIngredientSpritesBentext = (raw) => {
const byAlias = {};
const preferredAliases = new Set();
const grid = new Map();
const chunks = raw
.replace(/^\uFEFF/, "")
.trim()
.split(/\r?\n---\r?\n/);
for (const chunk of chunks) {
const trimmedChunk = chunk.trim();
if (!trimmedChunk) continue;
const lines = trimmedChunk
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length < 2) continue;
const coordMatch = lines[0].match(/^(\d+)\s+(\d+)/);
if (!coordMatch) {
if (lines[0] === "---") continue;
console.warn(`[build-recipes] Bloc sprite ignoré (coords invalides): ${lines[0].slice(0, 48)}`);
continue;
}
const row = Number(coordMatch[1]);
const col = Number(coordMatch[2]);
const x = col * INGREDIENT_SPRITE_CELL;
const y = row * INGREDIENT_SPRITE_CELL;
const entry = { x, y, X: x, Y: y };
const aliasLines = lines.slice(1);
const cellText = aliasLines.join("|");
const ck = `${row},${col}`;
grid.set(ck, grid.has(ck) ? `${grid.get(ck)}|${cellText}` : cellText);
const isPreferredRow = PREFERRED_VARIANT_SPRITE_ROWS.has(row);
for (const line of aliasLines) {
for (const part of line.split("|")) {
const key = normalizeIngredientAliasKey(part);
if (!key) continue;
const prev = byAlias[key];
if (!prev) {
byAlias[key] = entry;
} else if (prev.x !== x || prev.y !== y) {
console.warn(
`[build-recipes] Alias sprite « ${key} » en conflit (${prev.x},${prev.y}) vs (${x},${y}) — première définition conservée.`
);
continue;
}
if (isPreferredRow) preferredAliases.add(key);
}
}
}
let maxR = 0;
let maxC = 0;
for (const k of grid.keys()) {
const [r, c] = k.split(",").map(Number);
maxR = Math.max(maxR, r);
maxC = Math.max(maxC, c);
}
const csvLines = [];
for (let r = 0; r <= maxR; r++) {
const cols = [];
for (let c = 0; c <= maxC; c++) {
cols.push(grid.get(`${r},${c}`) ?? "");
}
csvLines.push(cols.join(","));
}
const csv = `${csvLines.join("\n")}\n`;
return { byAlias, preferredAliases, csv };
};
const resolveIconPxForIngredientName = (name, byAlias) => {
if (!name || typeof name !== "string") return null;
const n = normalizeIngredientAliasKey(name);
if (byAlias[n]) return aliasToIconPx(byAlias, n);
const nFold = foldAccents(n);
for (const key of Object.keys(byAlias)) {
if (foldAccents(key) === nFold) return aliasToIconPx(byAlias, key);
}
const segments = n.split(/[,;]|(?:\s+\/\s+)|(?:\s+et\s+)/i).map((s) => s.trim()).filter(Boolean);
for (const seg of segments) {
if (byAlias[seg]) return aliasToIconPx(byAlias, seg);
const sf = foldAccents(seg);
for (const key of Object.keys(byAlias)) {
if (foldAccents(key) === sf) return aliasToIconPx(byAlias, key);
}
}
const keysSorted = Object.keys(byAlias).sort((a, b) => b.length - a.length);
for (const k of keysSorted) {
if (k.length < 3) continue;
if (n.includes(k) || nFold.includes(foldAccents(k))) return aliasToIconPx(byAlias, k);
}
return null;
};
/** Lit les coordonnées pixel dans `byAlias` (API : `X`/`Y` ou `x`/`y`). */
const aliasToIconPx = (byAlias, alias) => {
if (!byAlias || typeof alias !== "string") return null;
const entry = byAlias[alias];
if (!entry || typeof entry !== "object") return null;
const x = typeof entry.x === "number" ? entry.x : typeof entry.X === "number" ? entry.X : NaN;
const y = typeof entry.y === "number" ? entry.y : typeof entry.Y === "number" ? entry.Y : NaN;
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return { x, y };
};
/** Alias manifeste correspondant à des coordonnées exactes sur la planche (pour filtrer les tuiles). */
const findAliasForIconPx = (byAlias, px) => {
if (!byAlias || !px || typeof px.x !== "number" || typeof px.y !== "number") return null;
for (const [alias, entry] of Object.entries(byAlias)) {
if (!entry || typeof entry !== "object") continue;
const x = typeof entry.x === "number" ? entry.x : typeof entry.X === "number" ? entry.X : NaN;
const y = typeof entry.y === "number" ? entry.y : typeof entry.Y === "number" ? entry.Y : NaN;
if (px.x === x && px.y === y) return alias;
}
return null;
};
const isPreferredVariantAlias = (preferredSet, manifestAlias) => {
if (!manifestAlias || preferredSet.size === 0) return false;
const lo = manifestAlias.trim().toLowerCase();
if (preferredSet.has(lo)) return true;
return [...preferredSet].some((p) => p.toLowerCase() === lo);
};
const buildIngredientCoverSprite = (iconPx, sheet) => {
if (!iconPx || typeof iconPx.x !== "number" || typeof iconPx.y !== "number" || !sheet) {
return null;
}
const col = Math.round(iconPx.x / INGREDIENT_SPRITE_CELL);
const row = Math.round(iconPx.y / INGREDIENT_SPRITE_CELL);
return {
sheet: "/ingredients.png",
col,
row,
cell: INGREDIENT_SPRITE_CELL,
sheetWidth: sheet.width,
sheetHeight: sheet.height
};
};
export {
parseIngredientSpritesBentext,
resolveIconPxForIngredientName,
aliasToIconPx,
findAliasForIconPx,
isPreferredVariantAlias,
buildIngredientCoverSprite,
normalizeIngredientAliasKey,
foldAccents
};
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import {
aliasToIconPx,
buildIngredientCoverSprite,
findAliasForIconPx,
foldAccents,
INGREDIENT_SPRITE_CELL,
isPreferredVariantAlias,
normalizeIngredientAliasKey,
parseIngredientSpritesBentext,
resolveIconPxForIngredientName
} from "./sprites.mjs";
describe("parseIngredientSpritesBentext", () => {
it("parse les blocs --- avec coordonnées et alias (| = synonymes)", () => {
const raw = `2 0
eau|vinaigre de riz|Vinaigre blanc
---
0 4
carnet
---
8 1
fromage
`;
const { byAlias, preferredAliases, csv } = parseIngredientSpritesBentext(raw);
// eau → (0, 64) ; carnet → (128, 0) ; fromage → (32, 256) — ligne 8 = preferred
expect(byAlias.eau).toEqual({ x: 0, y: 64, X: 0, Y: 64 });
expect(byAlias["vinaigre de riz"]).toEqual({ x: 0, y: 64, X: 0, Y: 64 });
expect(byAlias["vinaigre blanc"]).toEqual({ x: 0, y: 64, X: 0, Y: 64 });
expect(byAlias.carnet).toEqual({ x: 128, y: 0, X: 128, Y: 0 });
expect(byAlias.fromage).toEqual({ x: 32, y: 256, X: 32, Y: 256 });
expect(preferredAliases.has("fromage")).toBe(true);
expect(preferredAliases.has("eau")).toBe(false);
expect(preferredAliases.has("carnet")).toBe(false);
expect(csv).toContain("carnet");
expect(csv).toContain("fromage");
});
it("ignore un bloc sans coordonnées valides", () => {
const raw = `not_a_coord
eau
---
0 0
couvert
`;
const { byAlias } = parseIngredientSpritesBentext(raw);
expect(byAlias.eau).toBeUndefined();
expect(byAlias.couvert).toEqual({ x: 0, y: 0, X: 0, Y: 0 });
});
it("conserve la première définition en cas de conflit dalias", () => {
const raw = `0 0
eau
---
1 0
eau
`;
const { byAlias } = parseIngredientSpritesBentext(raw);
expect(byAlias.eau).toEqual({ x: 0, y: 0, X: 0, Y: 0 });
});
it("retire le BOM et tolère CRLF entre blocs", () => {
const raw = "\uFEFF0 0\r\ncouvert\r\n---\r\n0 1\r\nassiette\r\n";
const { byAlias } = parseIngredientSpritesBentext(raw);
expect(byAlias.couvert).toBeDefined();
expect(byAlias.assiette).toBeDefined();
});
});
describe("resolveIconPxForIngredientName", () => {
const byAlias = {
riz: { x: 0, y: 0 },
"riz gluant": { x: 32, y: 0 },
avocat: { x: 64, y: 0 }
};
it("trouve lalias direct", () => {
expect(resolveIconPxForIngredientName("riz", byAlias)).toEqual({ x: 0, y: 0 });
});
it("recherche sans accents", () => {
expect(resolveIconPxForIngredientName("AVOCAT", byAlias)).toEqual({ x: 64, y: 0 });
});
it("retombe sur lalias le plus long en inclusion", () => {
expect(resolveIconPxForIngredientName("Riz gluant blanc", byAlias)).toEqual({ x: 32, y: 0 });
});
it("retourne null si pas de correspondance", () => {
expect(resolveIconPxForIngredientName("introuvable", byAlias)).toBeNull();
});
it("gère un nom composé avec / ou et (segments)", () => {
const local = { "eau": { x: 0, y: 0 }, "sel": { x: 32, y: 0 } };
expect(resolveIconPxForIngredientName("Sel / eau", local)).toEqual({ x: 32, y: 0 });
});
});
describe("aliasToIconPx", () => {
it("lit les coordonnées (x, y) ou (X, Y)", () => {
expect(aliasToIconPx({ a: { x: 1, y: 2 } }, "a")).toEqual({ x: 1, y: 2 });
expect(aliasToIconPx({ a: { X: 3, Y: 4 } }, "a")).toEqual({ x: 3, y: 4 });
});
it("retourne null si alias ou coords absentes", () => {
expect(aliasToIconPx({}, "x")).toBeNull();
expect(aliasToIconPx({ a: { x: "a", y: 2 } }, "a")).toBeNull();
});
});
describe("findAliasForIconPx", () => {
it("renvoie lalias correspondant aux coordonnées exactes", () => {
const byAlias = { a: { x: 0, y: 0 }, b: { x: 32, y: 0 } };
expect(findAliasForIconPx(byAlias, { x: 32, y: 0 })).toBe("b");
expect(findAliasForIconPx(byAlias, { x: 100, y: 0 })).toBeNull();
});
});
describe("isPreferredVariantAlias", () => {
it("matche case-insensitive et tolère un set vide", () => {
const preferred = new Set(["fromage", "tomate"]);
expect(isPreferredVariantAlias(preferred, "Fromage")).toBe(true);
expect(isPreferredVariantAlias(preferred, "autre")).toBe(false);
expect(isPreferredVariantAlias(new Set(), "fromage")).toBe(false);
expect(isPreferredVariantAlias(preferred, "")).toBe(false);
});
});
describe("buildIngredientCoverSprite", () => {
const sheet = { width: 512, height: 1024 };
it("renvoie null si coords ou sheet invalides", () => {
expect(buildIngredientCoverSprite(null, sheet)).toBeNull();
expect(buildIngredientCoverSprite({ x: 0, y: 0 }, null)).toBeNull();
});
it("calcule col/row depuis les pixels et fige sheet/cell", () => {
const result = buildIngredientCoverSprite({ x: 64, y: 256 }, sheet);
expect(result).toEqual({
sheet: "/ingredients.png",
col: 2,
row: 8,
cell: INGREDIENT_SPRITE_CELL,
sheetWidth: 512,
sheetHeight: 1024
});
});
});
describe("normalizeIngredientAliasKey & foldAccents", () => {
it("normalise espaces et casse", () => {
expect(normalizeIngredientAliasKey(" Riz gluant ")).toBe("riz gluant");
});
it("plie les accents", () => {
expect(foldAccents("Crème pâtissière")).toBe("creme patissiere");
});
});