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
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@ben-to/builder",
"version": "0.1.0",
"type": "module",
"main": "src/domain.ts",
"types": "src/domain.ts",
"exports": {
"./domain": "./src/domain.ts"
}
}
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
baseFlavorGroupKey,
baseIdsInSameFlavorGroup,
computeRecipeSteps,
createInitialSelection,
primaryBaseIdForVariant,
type BaseOption,
type VariantOption
} from "./domain";
const emptyText = { fr: "", en: "", ko: "", zh: "" };
describe("builder integration (Gherkin @int-builder-*)", () => {
/** @int-builder-continue-after-base */
it("étape base : une sélection avec base permet de poursuivre vers les étapes recette", () => {
const selection = Object.freeze({ ...createInitialSelection(), baseId: "base:onigiri" });
expect(selection.baseId).toBe("base:onigiri");
const merged = computeRecipeSteps({
base: {
id: "base:onigiri",
flavor: "savory",
name: emptyText,
history: emptyText,
ingredients: [{ id: "rice", name: emptyText, info: emptyText, quantity: 1, unit: "g" }],
steps: [{ id: "s1", priority: 1, phase: "prep", dependsOn: [], text: emptyText }]
},
variant: null
});
expect(merged.steps).toHaveLength(1);
});
/** @int-builder-variant-filter */
it("variantes : seules les bases compatibles restent dans le groupe saveur", () => {
const mk = (id: string, slug: string, flavor: "savory" | "sweet"): BaseOption => ({
id,
slug,
flavor,
name: emptyText,
history: emptyText,
ingredients: [],
steps: []
});
const bases = [mk("base:onigiri-savory", "onigiri-savory", "savory"), mk("base:onigiri-sweet", "onigiri-sweet", "sweet")];
expect(baseFlavorGroupKey(bases[0])).toBe("onigiri");
const same = baseIdsInSameFlavorGroup(bases, "base:onigiri-savory");
expect(same).toContain("base:onigiri-savory");
expect(same).toContain("base:onigiri-sweet");
const variant: Pick<VariantOption, "baseIds"> = { baseIds: ["base:onigiri-savory", "base:onigiri-sweet"] };
expect(primaryBaseIdForVariant(variant, "base:onigiri-sweet")).toBe("base:onigiri-sweet");
});
});
+126
View File
@@ -0,0 +1,126 @@
export type LocalizedText = Readonly<Record<"fr" | "en" | "ko" | "zh", string>>;
export type StepKind = "base" | "variant" | "recipe";
/** Découpe dans `/ingredients.png` (grille 32×32 ; coordonnées depuis `ingredient-sprites.bentext`). */
export type CoverSprite = Readonly<{
sheet: string;
col: number;
row: number;
cell: number;
sheetWidth: number;
sheetHeight: number;
}>;
export type Ingredient = Readonly<{
id: string;
name: LocalizedText;
info: LocalizedText;
quantity: number;
unit: string;
coverSprite?: CoverSprite | null;
}>;
export type RecipeStep = Readonly<{
id: string;
priority: number;
phase: "prep" | "cook" | "assemble";
dependsOn: ReadonlyArray<string>;
text: LocalizedText;
}>;
export type BaseOption = Readonly<{
id: string;
flavor: "savory" | "sweet";
name: LocalizedText;
history: LocalizedText;
servings?: number;
ingredients: ReadonlyArray<Ingredient>;
steps: ReadonlyArray<RecipeStep>;
slug?: string;
coverImageUrl?: string | null;
coverSprite?: CoverSprite | null;
tags?: ReadonlyArray<string>;
}>;
export type VariantOption = Readonly<{
id: string;
baseIds: ReadonlyArray<string>;
name: LocalizedText;
story: LocalizedText;
servings?: number;
ingredients: ReadonlyArray<Ingredient>;
steps: ReadonlyArray<RecipeStep>;
slug?: string;
coverImageUrl?: string | null;
coverSprite?: CoverSprite | null;
recipeCoverImageUrl?: string | null;
tags?: ReadonlyArray<string>;
}>;
export type BentoSelection = Readonly<{
baseId: string | null;
variantId: string | null;
}>;
export const createInitialSelection = (): BentoSelection =>
Object.freeze({
baseId: null,
variantId: null
});
/**
* Regroupe les bases « salée / sucrée » partageant le même slug racine (`…-savory` / `…-sweet`).
* Sinon la clé est `id` (base unique).
*/
export const baseFlavorGroupKey = (base: Pick<BaseOption, "id" | "slug">): string => {
const slug = base.slug;
if (typeof slug === "string") {
if (slug.endsWith("-savory")) return slug.slice(0, -"-savory".length);
if (slug.endsWith("-sweet")) return slug.slice(0, -"-sweet".length);
}
return base.id;
};
export const baseIdsInSameFlavorGroup = (bases: ReadonlyArray<BaseOption>, baseId: string): ReadonlyArray<string> => {
const base = bases.find((item) => item.id === baseId);
if (!base) return [];
const key = baseFlavorGroupKey(base);
return bases.filter((item) => baseFlavorGroupKey(item) === key).map((item) => item.id);
};
/** Choisit lid de base catalogue à associer à une variante (plusieurs `baseIds` si groupe salé/sucré). */
export const primaryBaseIdForVariant = (
variant: Pick<VariantOption, "baseIds">,
prevBaseId: string | null
): string | null => {
if (variant.baseIds.length === 0) return prevBaseId;
if (variant.baseIds.length === 1) return variant.baseIds[0] ?? null;
if (prevBaseId && variant.baseIds.includes(prevBaseId)) return prevBaseId;
return variant.baseIds[0] ?? null;
};
/**
* Recette affichée : une variante contient déjà la recette complète (ingrédients + étapes) ;
* la base sert au choix / contexte. Ne pas concaténer base + variante (doublons dans le catalogue).
*/
export const computeRecipeSteps = (input: {
base: BaseOption | null;
variant: VariantOption | null;
}): Readonly<{ servings: number; ingredients: ReadonlyArray<Ingredient>; steps: ReadonlyArray<RecipeStep> }> => {
if (input.variant) {
const steps = [...input.variant.steps].sort((left, right) => left.priority - right.priority);
return Object.freeze({
servings: input.variant.servings ?? 1,
ingredients: Object.freeze([...input.variant.ingredients]),
steps: Object.freeze(steps)
});
}
const ingredients = [...(input.base?.ingredients ?? [])];
const steps = [...(input.base?.steps ?? [])].sort((left, right) => left.priority - right.priority);
return Object.freeze({
servings: input.base?.servings ?? 1,
ingredients: Object.freeze(ingredients),
steps: Object.freeze(steps)
});
};
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
type BaseOption,
baseFlavorGroupKey,
baseIdsInSameFlavorGroup,
computeRecipeSteps,
createInitialSelection,
primaryBaseIdForVariant
} from "./domain";
describe("builder domain", () => {
it("creates an immutable initial selection", () => {
const selection = createInitialSelection();
expect(selection.baseId).toBeNull();
expect(Object.isFrozen(selection)).toBe(true);
});
it("nutilise que la variante (recette complète) quand elle est présente, tri par priorité", () => {
const merged = computeRecipeSteps({
base: {
id: "base",
flavor: "savory",
name: { fr: "", en: "", ko: "", zh: "" },
history: { fr: "", en: "", ko: "", zh: "" },
servings: 4,
ingredients: [{ id: "base-only", name: { fr: "", en: "", ko: "", zh: "" }, info: { fr: "", en: "", ko: "", zh: "" }, quantity: 1, unit: "g" }],
steps: [{ id: "b", priority: 20, phase: "prep", dependsOn: [], text: { fr: "", en: "", ko: "", zh: "" } }]
},
variant: {
id: "variant",
baseIds: ["base"],
name: { fr: "", en: "", ko: "", zh: "" },
story: { fr: "", en: "", ko: "", zh: "" },
servings: 9,
ingredients: [{ id: "v1", name: { fr: "", en: "", ko: "", zh: "" }, info: { fr: "", en: "", ko: "", zh: "" }, quantity: 2, unit: "g" }],
steps: [
{ id: "a", priority: 30, phase: "prep", dependsOn: [], text: { fr: "", en: "", ko: "", zh: "" } },
{ id: "c", priority: 5, phase: "prep", dependsOn: [], text: { fr: "", en: "", ko: "", zh: "" } }
]
}
});
expect(merged.servings).toBe(9);
expect(merged.ingredients.map((i) => i.id)).toEqual(["v1"]);
expect(merged.steps.map((step) => step.id)).toEqual(["c", "a"]);
});
it("regroupe slug …-savory / …-sweet sous une même clé", () => {
expect(
baseFlavorGroupKey({ id: "base:empanadas-savory", slug: "empanadas-savory" })
).toBe("empanadas");
expect(baseFlavorGroupKey({ id: "base:empanadas-sweet", slug: "empanadas-sweet" })).toBe("empanadas");
expect(baseFlavorGroupKey({ id: "base:onigiri", slug: "onigiri" })).toBe("base:onigiri");
});
it("liste les ids de bases dun même groupe saveur", () => {
const empty = { fr: "", en: "", ko: "", zh: "" };
const mk = (id: string, slug: string, flavor: "savory" | "sweet"): BaseOption => ({
id,
slug,
flavor,
name: empty,
history: empty,
ingredients: [],
steps: []
});
const bases = [
mk("base:empanadas-savory", "empanadas-savory", "savory"),
mk("base:empanadas-sweet", "empanadas-sweet", "sweet")
];
expect([...baseIdsInSameFlavorGroup(bases, "base:empanadas-savory")].sort()).toEqual([
"base:empanadas-savory",
"base:empanadas-sweet"
]);
});
it("choisit la base catalogue pour une variante à plusieurs baseIds", () => {
const v = { baseIds: ["base:a", "base:b"] };
expect(primaryBaseIdForVariant(v, "base:b")).toBe("base:b");
expect(primaryBaseIdForVariant(v, null)).toBe("base:a");
});
it("sans variante, utilise uniquement la base", () => {
const merged = computeRecipeSteps({
base: {
id: "base",
flavor: "savory",
name: { fr: "", en: "", ko: "", zh: "" },
history: { fr: "", en: "", ko: "", zh: "" },
servings: 6,
ingredients: [],
steps: [
{ id: "b", priority: 20, phase: "prep", dependsOn: [], text: { fr: "", en: "", ko: "", zh: "" } },
{ id: "a", priority: 10, phase: "prep", dependsOn: [], text: { fr: "", en: "", ko: "", zh: "" } }
]
},
variant: null
});
expect(merged.servings).toBe(6);
expect(merged.steps.map((step) => step.id)).toEqual(["a", "b"]);
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@ben-to/recipes",
"version": "0.1.0",
"type": "module",
"main": "src/catalog.ts",
"types": "src/catalog.ts",
"exports": {
"./catalog": "./src/catalog.ts",
"./domain": "./src/domain.ts",
"./recipe-og-meta": "./src/recipe-og-meta.ts"
},
"dependencies": {
"@ben-to/builder": "0.1.0"
}
}
+34
View File
@@ -0,0 +1,34 @@
import type { BaseOption, VariantOption } from "@ben-to/builder/domain";
export type RecipesIndex = Readonly<{
bases: ReadonlyArray<BaseOption>;
variants: ReadonlyArray<VariantOption>;
}>;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
export const fetchRecipesIndex = async (
catalogUrl = "/recipes/catalog.json"
): Promise<RecipesIndex> => {
const response = await fetch(catalogUrl, { headers: { Accept: "application/json" } });
if (!response.ok) {
throw new Error(`Failed to load recipe catalog (${response.status})`);
}
const payload: unknown = await response.json();
if (!isRecord(payload)) {
throw new Error("Invalid recipe catalog payload");
}
const bases = payload.bases;
const variants = payload.variants;
if (!Array.isArray(bases) || !Array.isArray(variants)) {
throw new Error("Invalid recipe catalog shape");
}
return Object.freeze({
bases: Object.freeze(bases as BaseOption[]),
variants: Object.freeze(variants as VariantOption[])
});
};
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchRecipesIndex } from "./catalog";
const emptyText = { fr: "", en: "", ko: "", zh: "" };
const minimalBase = {
id: "base:x",
flavor: "savory" as const,
name: emptyText,
history: emptyText,
ingredients: [],
steps: []
};
const minimalVariant = {
id: "var:y",
baseIds: ["base:x"],
name: emptyText,
story: emptyText,
ingredients: [],
steps: []
};
describe("fetchRecipesIndex", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("loads and freezes catalog JSON", async () => {
const payload = { bases: [minimalBase], variants: [minimalVariant] };
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Promise.resolve({
ok: true,
json: async () => payload
})
)
);
const index = await fetchRecipesIndex("/custom/catalog.json");
expect(index.bases).toHaveLength(1);
expect(index.variants).toHaveLength(1);
expect(fetch).toHaveBeenCalledWith("/custom/catalog.json", {
headers: { Accept: "application/json" }
});
});
it("throws when response is not ok", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => Promise.resolve({ ok: false, status: 503 }))
);
await expect(fetchRecipesIndex()).rejects.toThrow("503");
});
it("throws when payload is not an object", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Promise.resolve({
ok: true,
json: async () => null
})
)
);
await expect(fetchRecipesIndex()).rejects.toThrow("Invalid recipe catalog payload");
});
it("throws when bases or variants are not arrays", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Promise.resolve({
ok: true,
json: async () => ({ bases: [], variants: null })
})
)
);
await expect(fetchRecipesIndex()).rejects.toThrow("Invalid recipe catalog shape");
});
});
+2
View File
@@ -0,0 +1,2 @@
export const scaleQuantities = (quantity: number, factor: number): number =>
Number((quantity * (Number.isFinite(factor) && factor > 0 ? factor : 1)).toFixed(2));
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { scaleQuantities } from "./domain";
describe("recipe domain", () => {
it("scales ingredient quantities", () => {
expect(scaleQuantities(10, 3)).toBe(30);
});
it("accepte les ratios inférieurs à un", () => {
expect(scaleQuantities(90, 2 / 3)).toBe(60);
});
it("garde un facteur de secours à un pour les valeurs non positives", () => {
expect(scaleQuantities(10, 0)).toBe(10);
});
});
@@ -0,0 +1,181 @@
import type { BaseOption, VariantOption } from "@ben-to/builder/domain";
export type OgLocale = "fr" | "en" | "ko" | "zh";
/** Pour les cartes sociales on suit la langue du document principal (`index.html` en `fr`). */
export const DEFAULT_RECIPE_OG_LOCALE: OgLocale = "fr";
export const escapeHtmlAttribute = (value: string): string =>
value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\r?\n/g, " ");
export const truncatePlainText = (text: string, maxChars: number): string => {
const normalized = text.trim().replace(/\s+/g, " ");
if (normalized.length <= maxChars) return normalized;
const slice = normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd();
return `${slice}`;
};
/** Chemin URL du partage recette (identique au routeur Solid). */
export const buildRecipeSharePath = (baseId: string, variantId: string): string =>
`/r/${encodeURIComponent(baseId)}/${encodeURIComponent(variantId)}`;
export const buildRecipeCanonicalUrl = (siteOrigin: string, baseId: string, variantId: string): string => {
const origin = siteOrigin.replace(/\/$/, "");
return `${origin}${buildRecipeSharePath(baseId, variantId)}`;
};
export const resolveRecipeOgImageUrl = (params: {
readonly siteOrigin: string;
readonly variant: Pick<VariantOption, "recipeCoverImageUrl" | "coverImageUrl">;
readonly fallbackImagePath: string;
}): string => {
const raw =
params.variant.recipeCoverImageUrl ??
params.variant.coverImageUrl ??
null;
if (!raw || raw.trim() === "") {
const path = params.fallbackImagePath.startsWith("/")
? params.fallbackImagePath
: `/${params.fallbackImagePath}`;
return `${params.siteOrigin.replace(/\/$/, "")}${path}`;
}
if (raw.startsWith("http://") || raw.startsWith("https://")) return raw;
const path = raw.startsWith("/") ? raw : `/${raw}`;
return `${params.siteOrigin.replace(/\/$/, "")}${path}`;
};
export const buildRecipeOgTitle = (
base: Pick<BaseOption, "name">,
variant: Pick<VariantOption, "name">,
locale: OgLocale
): string => `${base.name[locale]}${variant.name[locale]}`;
export const buildRecipeOgDescription = (
variant: Pick<VariantOption, "name" | "story">,
locale: OgLocale,
maxChars: number
): string => {
const primary = variant.story[locale]?.trim();
if (primary) return truncatePlainText(primary, maxChars);
return truncatePlainText(variant.name[locale], maxChars);
};
export type RecipeOgTagsInput = Readonly<{
siteOrigin: string;
base: BaseOption;
variant: VariantOption;
baseId: string;
variantId: string;
locale?: OgLocale;
descriptionMaxChars?: number;
fallbackImagePath?: string;
}>;
/** Forme minimale du catalogue pour énumérer les URLs `/r/...` générées au build. */
export type RecipesIndexShape = Readonly<{
bases: readonly BaseOption[];
variants: readonly VariantOption[];
}>;
export const forEachRecipeSharePair = (
catalog: RecipesIndexShape,
fn: (ctx: {
base: BaseOption;
variant: VariantOption;
baseId: string;
variantId: string;
}) => void
): void => {
const baseById = new Map(catalog.bases.map((b) => [b.id, b]));
for (const variant of catalog.variants) {
for (const baseId of variant.baseIds) {
const base = baseById.get(baseId);
if (base) fn({ base, variant, baseId, variantId: variant.id });
}
}
};
export type RecipeOgHeadModel = Readonly<{
title: string;
description: string;
canonicalHref: string;
ogSiteName: string;
ogUrl: string;
ogTitle: string;
ogDescription: string;
ogImage: string;
ogType: "article";
twitterCard: "summary_large_image" | "summary";
}>;
/** Nom de site cohérent entre la page d'accueil (`index.html`) et les pages recette. */
export const RECIPE_OG_SITE_NAME = "Ben-to";
/** Ligne description — les fichiers `index.html` gardent `theme-color` juste après. */
export const renderRecipeDescriptionMeta = (model: RecipeOgHeadModel): string =>
` <meta name="description" content="${escapeHtmlAttribute(model.description)}" />`;
/** Bloc du `<link rel="canonical">` jusquau `<title>` (icônes inchangées en dessous). */
export const renderRecipeCanonicalThroughTitle = (model: RecipeOgHeadModel): string => {
const esc = escapeHtmlAttribute;
return [
` <link rel="canonical" href="${esc(model.canonicalHref)}" />`,
` <meta property="og:site_name" content="${esc(model.ogSiteName)}" />`,
` <meta property="og:url" content="${esc(model.ogUrl)}" />`,
` <meta property="og:title" content="${esc(model.ogTitle)}" />`,
` <meta property="og:description" content="${esc(model.ogDescription)}" />`,
` <meta property="og:type" content="${model.ogType}" />`,
` <meta property="og:image" content="${esc(model.ogImage)}" />`,
` <meta property="og:locale" content="fr_FR" />`,
` <meta name="twitter:card" content="${model.twitterCard}" />`,
` <meta name="twitter:title" content="${esc(model.ogTitle)}" />`,
` <meta name="twitter:description" content="${esc(model.ogDescription)}" />`,
` <meta name="twitter:image" content="${esc(model.ogImage)}" />`,
` <title>${esc(model.title)}</title>`
].join("\n");
};
export const injectRecipeSeoIntoIndexHtml = (indexHtml: string, model: RecipeOgHeadModel): string => {
const withDesc = indexHtml.replace(
/<meta name="description" content="[^"]*" \/>/,
renderRecipeDescriptionMeta(model)
);
return withDesc.replace(
/<link rel="canonical"[\s\S]*?<title>[\s\S]*?<\/title>/,
renderRecipeCanonicalThroughTitle(model)
);
};
export const buildRecipeOgHeadModel = (input: RecipeOgTagsInput): RecipeOgHeadModel => {
const locale = input.locale ?? DEFAULT_RECIPE_OG_LOCALE;
const maxChars = input.descriptionMaxChars ?? 160;
const fallback = input.fallbackImagePath ?? "/branding/og.jpg";
const ogImage = resolveRecipeOgImageUrl({
siteOrigin: input.siteOrigin,
variant: input.variant,
fallbackImagePath: fallback
});
const title = buildRecipeOgTitle(input.base, input.variant, locale);
const description = buildRecipeOgDescription(input.variant, locale, maxChars);
const canonicalHref = buildRecipeCanonicalUrl(input.siteOrigin, input.baseId, input.variantId);
const hasDedicatedCover = Boolean(
input.variant.recipeCoverImageUrl?.trim() || input.variant.coverImageUrl?.trim()
);
return {
title,
description,
canonicalHref,
ogSiteName: RECIPE_OG_SITE_NAME,
ogUrl: canonicalHref,
ogTitle: title,
ogDescription: description,
ogImage,
ogType: "article",
twitterCard: hasDedicatedCover ? "summary_large_image" : "summary"
};
};
@@ -0,0 +1,186 @@
import { describe, expect, it } from "vitest";
import type { BaseOption, VariantOption } from "@ben-to/builder/domain";
import {
buildRecipeCanonicalUrl,
buildRecipeOgHeadModel,
buildRecipeSharePath,
escapeHtmlAttribute,
forEachRecipeSharePair,
injectRecipeSeoIntoIndexHtml,
resolveRecipeOgImageUrl,
truncatePlainText,
type RecipesIndexShape
} from "./recipe-og-meta";
describe("escapeHtmlAttribute", () => {
it("échappe &, guillemets et chevrons", () => {
expect(escapeHtmlAttribute(`a & "b" <c>`)).toBe("a &amp; &quot;b&quot; &lt;c&gt;");
});
});
describe("truncatePlainText", () => {
it("tronque avec ellipse", () => {
expect(truncatePlainText("abcdefghijklmnop", 8)).toBe("abcdefg…");
});
});
describe("buildRecipeSharePath", () => {
it("encode les segments comme le routeur", () => {
expect(buildRecipeSharePath("base:onigiri", "variant:x")).toBe(
"/r/base%3Aonigiri/variant%3Ax"
);
});
});
describe("buildRecipeCanonicalUrl", () => {
it("fusionne origine et chemin", () => {
expect(buildRecipeCanonicalUrl("https://exemple.fr/", "a", "b")).toBe(
"https://exemple.fr/r/a/b"
);
expect(buildRecipeCanonicalUrl("https://exemple.fr", "a", "b")).toBe(
"https://exemple.fr/r/a/b"
);
});
});
describe("resolveRecipeOgImageUrl", () => {
it("absolu ou relatif vers origine", () => {
expect(
resolveRecipeOgImageUrl({
siteOrigin: "https://app.example",
variant: { recipeCoverImageUrl: "/recipe-covers/x.webp", coverImageUrl: null },
fallbackImagePath: "/branding/logo.png"
})
).toBe("https://app.example/recipe-covers/x.webp");
expect(
resolveRecipeOgImageUrl({
siteOrigin: "https://app.example",
variant: { recipeCoverImageUrl: null, coverImageUrl: null },
fallbackImagePath: "/branding/og.jpg"
})
).toBe("https://app.example/branding/og.jpg");
});
});
const minimalBase = (id: string): BaseOption =>
({
id,
flavor: "savory",
name: { fr: "Base", en: "Base", ko: "b", zh: "b" },
history: { fr: "", en: "", ko: "", zh: "" },
ingredients: [],
steps: []
}) as BaseOption;
const minimalVariant = (overrides: Partial<VariantOption>): VariantOption =>
({
id: "variant:v",
baseIds: ["base:b"],
name: { fr: "Var", en: "Var", ko: "v", zh: "v" },
story: { fr: "Une histoire longue ".repeat(20), en: "", ko: "", zh: "" },
ingredients: [],
steps: [],
...overrides
}) as VariantOption;
describe("buildRecipeOgHeadModel", () => {
it("produit un résumé Twitter large si couverture dédiée", () => {
const m = buildRecipeOgHeadModel({
siteOrigin: "https://ben-to.fr",
base: minimalBase("base:b"),
variant: minimalVariant({ recipeCoverImageUrl: "/recipe-covers/a.webp" }),
baseId: "base:b",
variantId: "variant:v"
});
expect(m.twitterCard).toBe("summary_large_image");
expect(m.ogType).toBe("article");
expect(m.ogImage).toContain("/recipe-covers/a.webp");
expect(m.ogSiteName).toBe("Ben-to");
});
it("réduit la description", () => {
const m = buildRecipeOgHeadModel({
siteOrigin: "https://ben-to.fr",
base: minimalBase("base:b"),
variant: minimalVariant({}),
baseId: "base:b",
variantId: "variant:v",
descriptionMaxChars: 40
});
expect(m.description.length).toBeLessThanOrEqual(42);
});
it("tombe sur l'image de couverture og.jpg quand aucune image dédiée", () => {
const m = buildRecipeOgHeadModel({
siteOrigin: "https://ben-to.fr",
base: minimalBase("base:b"),
variant: minimalVariant({ recipeCoverImageUrl: null, coverImageUrl: null }),
baseId: "base:b",
variantId: "variant:v"
});
expect(m.ogImage).toBe("https://ben-to.fr/branding/og.jpg");
expect(m.twitterCard).toBe("summary");
});
});
describe("forEachRecipeSharePair", () => {
it("une entrée par couple basevariante catalogue", () => {
const catalog: RecipesIndexShape = {
bases: [minimalBase("base:a")],
variants: [
{
...minimalVariant({ id: "v1", baseIds: ["base:a"] })
}
]
};
const seen: string[] = [];
forEachRecipeSharePair(catalog, ({ baseId, variantId }) => {
seen.push(`${baseId}:${variantId}`);
});
expect(seen).toEqual(["base:a:v1"]);
});
});
describe("injectRecipeSeoIntoIndexHtml", () => {
const fixture = `<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="OLD" />
<meta name="theme-color" content="#f1e9de" />
<link rel="canonical" href="https://ben-to.fr/" />
<meta property="og:site_name" content="Ben-to" />
<meta property="og:url" content="https://ben-to.fr/" />
<meta property="og:title" content="OLD" />
<meta property="og:description" content="OLD" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://ben-to.fr/branding/onigiri-logo.png" />
<meta name="twitter:card" content="summary" />
<link rel="icon" type="image/png" href="/branding/onigiri-logo.png" />
<title>OLD</title>
<script type="module" src="/src/main.tsx"></script>
</head>
<body></body>
</html>`;
it("injecte titre et og:title", () => {
const base = minimalBase("base:b");
const variant = minimalVariant({ id: "variant:v", recipeCoverImageUrl: "/x.webp" });
const model = buildRecipeOgHeadModel({
siteOrigin: "https://ben-to.fr",
base,
variant,
baseId: "base:b",
variantId: "variant:v"
});
const out = injectRecipeSeoIntoIndexHtml(fixture, model);
expect(out).toContain(`<title>${escapeHtmlAttribute(model.title)}</title>`);
expect(out).toContain(`property="og:title" content="${escapeHtmlAttribute(model.ogTitle)}"`);
expect(out).toContain(`property="og:type" content="article"`);
expect(out).toContain(`property="og:site_name" content="Ben-to"`);
expect(out).toContain(`name="twitter:card" content="${model.twitterCard}"`);
expect(out).toContain(`name="theme-color"`);
expect(out).toContain(`/src/main.tsx`);
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true
},
"include": ["src"]
}