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"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@ben-to/i18n",
"version": "0.1.0",
"type": "module",
"main": "src/dictionary.ts",
"types": "src/dictionary.ts",
"exports": {
"./dictionary": "./src/dictionary.ts"
}
}
+436
View File
@@ -0,0 +1,436 @@
export type LocaleCode = "fr" | "en" | "ko" | "zh";
export type Dictionary = Readonly<{
home: string;
language: string;
progress: string;
builderStepBase: string;
builderStepVariant: string;
builderStepRecipe: string;
builderNavToBase: string;
builderNavToVariant: string;
builderNavToRecipe: string;
builderNavVariantDisabled: string;
builderNavRecipeDisabled: string;
selectBase: string;
selectVariant: string;
/** Placeholder `{base}` = nom de la base courante (locale). */
builderVariantBaseReminderTemplate: string;
recipe: string;
ingredients: string;
steps: string;
continue: string;
servings: string;
shareRecipe: string;
shareCopied: string;
shareCopyFailed: string;
recipeShareUrlIntro: string;
printRecipe: string;
/** Placeholders `{name}` = nom dingrédient (locale). */
recipeIngredientCheckboxLabel: string;
/** Placeholder `{n}` = numéro d’étape (1-based). */
recipeStepToggleLabel: string;
decreaseServings: string;
increaseServings: string;
filterAll: string;
filterSavory: string;
filterSweet: string;
loading: string;
loadError: string;
menu: string;
menuClose: string;
navAbout: string;
navTerms: string;
navLegal: string;
navPrivacy: string;
cookieConsentTitle: string;
cookieConsentDescription: string;
cookieConsentAccept: string;
cookieConsentReject: string;
welcomeTourTitle: string;
welcomeTourIntro: string;
welcomeTourBulletSteps: string;
welcomeTourBulletFilters: string;
welcomeTourBulletServings: string;
welcomeTourBulletShare: string;
welcomeTourGifFlowAlt: string;
welcomeTourGifRecipeAlt: string;
welcomeTourCtaStart: string;
welcomeTourCtaClose: string;
backToBuilder: string;
aboutTitle: string;
aboutParagraphs: readonly string[];
termsTitle: string;
termsParagraphs: readonly string[];
legalTitle: string;
legalParagraphs: readonly string[];
privacyTitle: string;
privacyParagraphs: readonly string[];
notFoundTitle: string;
notFoundParagraphs: readonly string[];
}>;
const dictionaries: Readonly<Record<LocaleCode, Dictionary>> = {
fr: {
home: "Retour à laccueil",
language: "Langue",
progress: "Progression",
builderStepBase: "Forme",
builderStepVariant: "Goût",
builderStepRecipe: "Recette",
builderNavToBase: "Aller au choix de la forme",
builderNavToVariant: "Aller au choix du goût",
builderNavToRecipe: "Aller à la fiche recette",
builderNavVariantDisabled: "Étape indisponible : choisissez dabord une forme",
builderNavRecipeDisabled: "Étape indisponible : choisissez une forme et un goût",
selectBase: "Choisir une forme",
selectVariant: "Choisir un goût",
builderVariantBaseReminderTemplate: "{base} : choisissez un goût ci-dessous.",
recipe: "Recette",
ingredients: "Ingrédients",
steps: "Étapes",
continue: "Continuer",
servings: "Portions",
shareRecipe: "Partager",
shareCopied: "La recette a été copiée dans le presse-papiers.",
shareCopyFailed:
"Copie impossible. Utilisez une page sécurisée (HTTPS) ou autorisez laccès au presse-papiers.",
recipeShareUrlIntro: "Ouvrir cette recette dans le navigateur :",
printRecipe: "Imprimer",
recipeIngredientCheckboxLabel: "Marquer comme sorti ou annuler : {name}",
recipeStepToggleLabel: "Étape {n} : marquer comme réalisée ou annuler",
decreaseServings: "Diminuer les portions",
increaseServings: "Augmenter les portions",
filterAll: "Tout",
filterSavory: "Salé",
filterSweet: "Sucré",
loading: "Chargement des recettes…",
loadError: "Impossible de charger le catalogue des recettes.",
menu: "Menu",
menuClose: "Fermer",
navAbout: "À propos",
navTerms: "Conditions générales dutilisation",
navLegal: "Mentions légales",
navPrivacy: "Confidentialité",
cookieConsentTitle: "Cookies et mesure daudience",
cookieConsentDescription:
"Matomo permet de mesurer la fréquentation de lapplication. Aucun cookie de mesure nest déposé sans votre accord. Pour en savoir plus, voir la page Confidentialité.",
cookieConsentAccept: "Accepter",
cookieConsentReject: "Refuser",
welcomeTourTitle: "Bienvenue sur Ben-to",
welcomeTourIntro: "Quelques repères avant de composer votre premier bento :",
welcomeTourBulletSteps:
"Trois étapes : forme, goût, puis fiche recette — la barre de progression en haut résume où vous en êtes.",
welcomeTourBulletFilters: "Filtrez les formes salées ou sucrées pour parcourir le catalogue plus vite.",
welcomeTourBulletServings: "Sur la fiche recette, ajustez les portions selon le nombre de convives.",
welcomeTourBulletShare: "Partagez le lien, copiez ou imprimez la recette depuis la fiche.",
welcomeTourGifFlowAlt: "Animation : choix dune forme, dun goût, puis ouverture de la fiche recette.",
welcomeTourGifRecipeAlt: "Animation : défilement de la fiche recette (ingrédients et étapes).",
welcomeTourCtaStart: "Commencer",
welcomeTourCtaClose: "Fermer",
backToBuilder: "Retour au constructeur",
aboutTitle: "À propos de Ben-to",
aboutParagraphs: [
"Ben-to est une application pour composer des fiches recettes façon bento : choix dune forme, dun goût, puis affichage de la recette avec portions ajustables.",
"Elle sinscrit dans le même univers que le site vitrine BENTO (https://ben-to.fr) : recettes inspirées surtout de Corée, du Japon et de la Chine, pensées pour les bentos et repas à emporter — une fiche à la fois, catalogue ou tirage au hasard, infos pratiques (transport, conservation…), export bentext pour copier ou imprimer.",
"Les contenus intégrés au catalogue (textes, images) sont fournis à titre indicatif ; vérifiez toujours les quantités et le temps de cuisson avant réalisation.",
"Pour le site vitrine, les pages publiques (à propos, confidentialité, etc.) sont prévues sur ben-to.fr (chemins dURL selon la version déployée) ; la présente application peut être configurée sans compte utilisateur, avec stockage local du navigateur pour la progression du constructeur."
],
termsTitle: "Conditions générales dutilisation (CGU)",
termsParagraphs: [
"Lutilisation de lapplication implique lacceptation des présentes conditions. L’éditeur du déploiement peut faire évoluer le service et ces conditions ; la version publiée avec lapplication prévaut.",
"Le catalogue des recettes est fourni avec lapplication pour la version publiée ; une mise à jour peut modifier les contenus embarqués.",
"Une mesure daudience (par ex. analytics.ben-to.fr, type Matomo) peut être activée sur certains déploiements ; dans ce cas, seules des données techniques conformes à la politique de loutil sont traitées.",
"Vous êtes responsable des données que vous saisissez (portions, partage, etc.). Ne communiquez pas dinformations sensibles dans des fonctions de partage non chiffrées.",
"Le service est fourni « en l’état ». Dans les limites du droit applicable, l’éditeur décline toute responsabilité pour dommages indirects ou perte de données locales."
],
legalTitle: "Mentions légales",
legalParagraphs: [
"Projet Ben-to — application compagnon du site recettes BENTO (https://ben-to.fr). Éditeur : BOUTEILLER — SIREN 512 643 214 — SIRET 512 643 214 00020. Directeur de la publication, siège social et contact : https://linktr.ee/kazerlelutin.",
"Hébergement : Infomaniak Network SA — 25 rue Eugène-Marziano, 1227 Les Acacias (Genève), Suisse — https://www.infomaniak.com (infrastructure dhébergement des services liés à ben-to.fr et aux déploiements associés, lorsque applicable).",
"Données personnelles : le responsable de traitement, les finalités, la durée de conservation et vos droits (accès, rectification, effacement, opposition, réclamation auprès de la CNIL ou autorité équivalente) sont ceux de l’éditeur du déploiement ; voir aussi la page « Confidentialité » de cette application et, le cas échéant, celle du site ben-to.fr.",
"Propriété intellectuelle : les marques, textes et visuels tiers restent la propriété de leurs titulaires respectifs."
],
privacyTitle: "Confidentialité",
privacyParagraphs: [
"Cette application Ben-to peut fonctionner sans création de compte. La progression du constructeur (forme, goût) peut être enregistrée localement dans le navigateur (stockage local) selon la configuration.",
"Hors fonctions optionnelles (par ex. mesure daudience décrite ci-dessous), le catalogue et les visuels sont servis depuis les fichiers embarqués dans lapplication, sans appel à un serveur de recettes tiers.",
"Sur certains déploiements, une mesure daudience respectueuse de la vie privée (par ex. analytics.ben-to.fr) peut être activée ; les finalités et durées de conservation sont alors celles décrites dans la politique du site public ben-to.fr lorsquelle est publiée.",
"Pour toute demande relative aux données personnelles : https://linktr.ee/kazerlelutin (voir aussi les mentions légales de cette version)."
],
notFoundTitle: "Page introuvable",
notFoundParagraphs: [
"Cette adresse ne correspond à aucune page du site. Utilisez le menu ou le lien ci-dessous pour revenir au constructeur."
]
},
en: {
home: "Back to home",
language: "Language",
progress: "Progress",
builderStepBase: "Shape",
builderStepVariant: "Flavor",
builderStepRecipe: "Recipe",
builderNavToBase: "Go to shape selection",
builderNavToVariant: "Go to flavor selection",
builderNavToRecipe: "Go to recipe",
builderNavVariantDisabled: "Unavailable: pick a shape first",
builderNavRecipeDisabled: "Unavailable: pick a shape and a flavor",
selectBase: "Select a shape",
selectVariant: "Select a flavor",
builderVariantBaseReminderTemplate: "{base}: pick a flavor below.",
recipe: "Recipe",
ingredients: "Ingredients",
steps: "Steps",
continue: "Continue",
servings: "Servings",
shareRecipe: "Share",
shareCopied: "Recipe copied to your clipboard.",
shareCopyFailed: "Could not copy. Open the app over HTTPS or allow clipboard access.",
recipeShareUrlIntro: "Open this recipe in your browser:",
printRecipe: "Print",
recipeIngredientCheckboxLabel: "Mark as taken out or clear: {name}",
recipeStepToggleLabel: "Step {n}: mark done or undo",
decreaseServings: "Fewer servings",
increaseServings: "More servings",
filterAll: "All",
filterSavory: "Savory",
filterSweet: "Sweet",
loading: "Loading recipes…",
loadError: "Could not load the recipe catalog.",
menu: "Menu",
menuClose: "Close",
navAbout: "About",
navTerms: "Terms of use",
navLegal: "Legal notice",
navPrivacy: "Privacy",
cookieConsentTitle: "Cookies and analytics",
cookieConsentDescription:
"We use Matomo to measure how the app is used. Analytics cookies are only set with your consent. See the Privacy page for details.",
cookieConsentAccept: "Accept",
cookieConsentReject: "Decline",
welcomeTourTitle: "Welcome to Ben-to",
welcomeTourIntro: "A quick overview before you build your first bento:",
welcomeTourBulletSteps:
"Three steps: shape, flavor, then the recipe sheet — the progress bar at the top shows where you are.",
welcomeTourBulletFilters: "Use savory or sweet filters to browse the catalogue faster.",
welcomeTourBulletServings: "On the recipe sheet, adjust servings for the number of people.",
welcomeTourBulletShare: "Share the link, copy, or print the recipe from the sheet.",
welcomeTourGifFlowAlt: "Animation: pick a shape, a flavor, then open the recipe sheet.",
welcomeTourGifRecipeAlt: "Animation: scrolling the recipe sheet (ingredients and steps).",
welcomeTourCtaStart: "Get started",
welcomeTourCtaClose: "Close",
backToBuilder: "Back to builder",
aboutTitle: "About Ben-to",
aboutParagraphs: [
"Ben-to is an app to build bento-style recipe sheets: pick a shape, a flavor, then view the recipe with adjustable servings.",
"It belongs to the same family as the BENTO site (https://ben-to.fr): recipes inspired mainly by Korea, Japan, and China, designed for bentos and meals on the go—one sheet at a time, catalogue or random pick, practical notes (transport, storage…), bentext export to copy or print.",
"Catalogue content (text, images) is indicative; always verify quantities and cooking times before cooking.",
"The public site may publish About and privacy pages on ben-to.fr (URLs depend on the deployment). This app can run without a user account, using browser local storage for builder progress."
],
termsTitle: "Terms of use",
termsParagraphs: [
"By using the app you accept these terms. The deployment operator may change the service and the terms; the version shipped with the app prevails.",
"The recipe catalogue is bundled with each published version of the app; updates may change the embedded content.",
"Some deployments enable privacy-friendly analytics (e.g. analytics.ben-to.fr, Matomo-style); only technical data described in that tools policy is processed.",
"You are responsible for what you enter or share. Do not put sensitive data in unencrypted share flows.",
"The service is provided “as is”. To the extent permitted by law, the operator disclaims liability for indirect damage or local data loss."
],
legalTitle: "Legal notice",
legalParagraphs: [
"Ben-to project — companion app to the BENTO recipe site (https://ben-to.fr). Publisher (France): BOUTEILLER — SIREN 512 643 214 — SIRET 512 643 214 00020. Publication director, registered address, and contact: https://linktr.ee/kazerlelutin.",
"Hosting: Infomaniak Network SA — 25 rue Eugène-Marziano, 1227 Les Acacias, Geneva, Switzerland — https://www.infomaniak.com (hosting infrastructure for ben-to.fr and related deployments, where applicable).",
"Personal data: controller, purposes, retention, and your rights (access, rectification, erasure, objection, complaint to the ICO or equivalent) are those of the deployment operator; see also this apps Privacy page and, where applicable, ben-to.fr.",
"Intellectual property: third-party marks, text, and images remain with their respective owners."
],
privacyTitle: "Privacy",
privacyParagraphs: [
"Ben-to can run without an account. Builder progress (shape, flavor) may be stored locally in the browser (local storage), depending on configuration.",
"Except for optional features (e.g. analytics described below), the catalogue and images are served from files embedded in the app, without calls to a third-party recipe server.",
"Some deployments enable privacy-friendly analytics (e.g. analytics.ben-to.fr); purposes and retention follow the public ben-to.fr policy when published.",
"For personal-data requests: https://linktr.ee/kazerlelutin (see also the legal notice for this build)."
],
notFoundTitle: "Page not found",
notFoundParagraphs: [
"This URL does not match any page. Use the menu or the link below to return to the builder."
]
},
ko: {
home: "처음으로",
language: "언어",
progress: "진행률",
builderStepBase: "모양",
builderStepVariant: "맛",
builderStepRecipe: "레시피",
builderNavToBase: "모양 선택으로 이동",
builderNavToVariant: "맛 선택으로 이동",
builderNavToRecipe: "레시피 화면으로 이동",
builderNavVariantDisabled: "이 단계는 사용할 수 없습니다. 먼저 모양을 고르세요.",
builderNavRecipeDisabled: "이 단계는 사용할 수 없습니다. 모양과 맛을 먼저 고르세요.",
selectBase: "모양 선택",
selectVariant: "맛 선택",
builderVariantBaseReminderTemplate: "{base}: 아래에서 맛을 고르세요.",
recipe: "레시피",
ingredients: "재료",
steps: "단계",
continue: "계속",
servings: "인분",
shareRecipe: "공유",
shareCopied: "레시피가 클립보드에 복사되었습니다.",
shareCopyFailed: "복사할 수 없습니다. HTTPS로 열거나 클립보드 권한을 허용해 주세요.",
recipeShareUrlIntro: "브라우저에서 이 레시피 열기:",
printRecipe: "인쇄",
recipeIngredientCheckboxLabel: "꺼냄 표시 또는 취소: {name}",
recipeStepToggleLabel: "{n}단계: 완료 표시 또는 취소",
decreaseServings: "인분 줄이기",
increaseServings: "인분 늘리기",
filterAll: "전체",
filterSavory: "짭짤",
filterSweet: "달콤",
loading: "레시피 불러오는 중…",
loadError: "레시피 카탈로그를 불러올 수 없습니다.",
menu: "메뉴",
menuClose: "닫기",
navAbout: "소개",
navTerms: "이용 약관",
navLegal: "법적 고지",
navPrivacy: "개인정보",
cookieConsentTitle: "쿠키 및 통계",
cookieConsentDescription:
"Matomo로 이용 현황을 파악합니다. 분석 쿠키는 동의한 경우에만 저장됩니다. 자세한 내용은 개인정보 페이지를 참고하세요.",
cookieConsentAccept: "동의",
cookieConsentReject: "거부",
welcomeTourTitle: "Ben-to에 오신 것을 환영합니다",
welcomeTourIntro: "첫 도시락을 만들기 전에 알아두면 좋은 점입니다.",
welcomeTourBulletSteps:
"세 단계: 모양, 맛, 레시피 카드 — 상단 진행 표시줄로 현재 위치를 확인할 수 있습니다.",
welcomeTourBulletFilters: "짭짤·달콤 필터로 카탈로그를 빠르게 찾아보세요.",
welcomeTourBulletServings: "레시피 카드에서 인분을 조절하세요.",
welcomeTourBulletShare: "카드에서 링크 공유, 복사 또는 인쇄를 할 수 있습니다.",
welcomeTourGifFlowAlt: "애니메이션: 모양과 맛을 고르고 레시피 카드를 엽니다.",
welcomeTourGifRecipeAlt: "애니메이션: 레시피 카드(재료와 단계)를 스크롤합니다.",
welcomeTourCtaStart: "시작하기",
welcomeTourCtaClose: "닫기",
backToBuilder: "빌더로 돌아가기",
aboutTitle: "Ben-to 소개",
aboutParagraphs: [
"Ben-to는 벤토 스타일 레시피 카드를 만드는 앱입니다. 모양과 맛을 고른 뒤 인분을 조절해 레시피를 볼 수 있습니다.",
"공개 사이트 BENTO(https://ben-to.fr)와 같은 계열로, 한국·일본·중국에서 영감을 받은 도시락·휴대 식사 레시피, 실용 정보(운반·보관 등), bentext 보내기 등을 제공합니다.",
"카탈로그의 텍스트·이미지는 참고용이며, 조리 전 분량과 시간을 반드시 확인하세요.",
"공개 페이지(소개, 개인정보 등)는 ben-to.fr에 게시될 수 있습니다. 이 앱은 계정 없이 브라우저 로컬 저장으로 빌더 진행을 저장할 수 있습니다."
],
termsTitle: "이용 약관",
termsParagraphs: [
"앱을 사용하면 아래 조건에 동의한 것으로 간주됩니다. 배포 운영자는 서비스와 조건을 변경할 수 있으며, 앱에 포함된 버전이 우선합니다.",
"레시피 카탈로그는 배포된 앱 버전에 포함되며, 업데이트 시 포함 콘텐츠가 바뀔 수 있습니다.",
"일부 배포에서는 Matomo 유형의 분석(analytics.ben-to.fr 등)이 켜질 수 있으며, 해당 도구 정책에 따른 기술 정보만 처리됩니다.",
"입력·공유 데이터는 사용자 책임입니다. 민감 정보는 암호화되지 않은 공유에 넣지 마세요.",
"서비스는 있는 그대로 제공됩니다. 법이 허용하는 한 간접 손해 등에 대해 운영자는 책임을 지지 않습니다."
],
legalTitle: "법적 고지",
legalParagraphs: [
"Ben-to 프로젝트 — BENTO 레시피 사이트(https://ben-to.fr)의 동반 앱입니다. 프랑스 사업자: BOUTEILLER — SIREN 512 643 214 — SIRET 512 643 214 00020. 편집 책임자, 주소 및 연락처: https://linktr.ee/kazerlelutin.",
"호스팅: Infomaniak Network SA — 25 rue Eugène-Marziano, 1227 Les Acacias, 제네바, 스위스 — https://www.infomaniak.com (ben-to.fr 및 관련 배포의 호스팅 인프라에 해당할 때).",
"개인정보: 처리 책임자, 목적, 보관 기간, 권리(열람·정정·삭제·이의 제기·감독 기관 신고 등)는 배포 운영자의 정책을 따릅니다. 이 앱의 «개인정보» 페이지와 ben-to.fr의 해당 페이지를 함께 확인하세요.",
"지적 재산: 제3자 상표·텍스트·이미지는 각 소유자에게 귀속됩니다."
],
privacyTitle: "개인정보",
privacyParagraphs: [
"Ben-to는 계정 없이 동작할 수 있습니다. 빌더 진행(모양, 맛)은 설정에 따라 브라우저 로컬 저장소에 저장될 수 있습니다.",
"선택 기능(아래 방문 분석 등)을 제외하면, 카탈로그와 이미지는 앱에 포함된 파일에서 제공되며 외부 레시피 서버를 호출하지 않습니다.",
"일부 배포에서는 analytics.ben-to.fr 등 개인정보를 존중하는 방문 분석이 활성화될 수 있으며, 목적·보관은 ben-to.fr에 공개된 정책을 따릅니다.",
"개인정보 관련 요청: https://linktr.ee/kazerlelutin (이 빌드의 법적 고지도 참고하세요)."
],
notFoundTitle: "페이지를 찾을 수 없습니다",
notFoundParagraphs: ["주소가 올바른지 확인하고 아래 링크로 빌더로 돌아가세요."]
},
zh: {
home: "返回首页",
language: "语言",
progress: "进度",
builderStepBase: "形状",
builderStepVariant: "口味",
builderStepRecipe: "食谱",
builderNavToBase: "前往选择形状",
builderNavToVariant: "前往选择口味",
builderNavToRecipe: "前往食谱",
builderNavVariantDisabled: "不可用:请先选择形状",
builderNavRecipeDisabled: "不可用:请先选择形状和口味",
selectBase: "选择形状",
selectVariant: "选择口味",
builderVariantBaseReminderTemplate: "{base}:请在下方选择一个口味。",
recipe: "食谱",
ingredients: "食材",
steps: "步骤",
continue: "继续",
servings: "份数",
shareRecipe: "分享",
shareCopied: "食谱已复制到剪贴板。",
shareCopyFailed: "无法复制。请使用 HTTPS 访问或允许剪贴板权限。",
recipeShareUrlIntro: "在浏览器中打开此食谱:",
printRecipe: "打印",
recipeIngredientCheckboxLabel: "标记已取出或取消:{name}",
recipeStepToggleLabel: "第 {n} 步:标记完成或取消",
decreaseServings: "减少份数",
increaseServings: "增加份数",
filterAll: "全部",
filterSavory: "咸口",
filterSweet: "甜口",
loading: "正在加载食谱…",
loadError: "无法加载食谱目录。",
menu: "菜单",
menuClose: "关闭",
navAbout: "关于",
navTerms: "使用条款",
navLegal: "法律信息",
navPrivacy: "隐私",
cookieConsentTitle: "Cookie 与访问统计",
cookieConsentDescription:
"我们使用 Matomo 了解应用使用情况;仅在您同意后才会放置统计相关 Cookie。详情请参阅隐私页面。",
cookieConsentAccept: "同意",
cookieConsentReject: "拒绝",
welcomeTourTitle: "欢迎使用 Ben-to",
welcomeTourIntro: "在组合第一份便当前,快速了解主要功能:",
welcomeTourBulletSteps: "三步:形状、口味、食谱页 — 顶部进度条显示当前步骤。",
welcomeTourBulletFilters: "使用咸口 / 甜口筛选,更快浏览目录。",
welcomeTourBulletServings: "在食谱页按需调整份数。",
welcomeTourBulletShare: "可在食谱页分享链接、复制或打印。",
welcomeTourGifFlowAlt: "动图:选择形状与口味并打开食谱页。",
welcomeTourGifRecipeAlt: "动图:浏览食谱页(食材与步骤)。",
welcomeTourCtaStart: "开始使用",
welcomeTourCtaClose: "关闭",
backToBuilder: "返回构建器",
aboutTitle: "关于 Ben-to",
aboutParagraphs: [
"Ben-to 用于组合便当式食谱:先选形状与口味,再查看可调整份量的配方。",
"它与公开站点 BENTOhttps://ben-to.fr)同属一个系列:以韩、日、中等地为灵感的便当与外带餐食谱,一次一张卡片、目录或随机抽取,附实用信息(携带、保存等),并支持 bentext 导出以便复制或打印。",
"目录中的文字与图片仅供参考,烹饪前请务必核实用量与时间。",
"公开站点 ben-to.fr 可能提供「关于」「隐私」等页面(路径依部署而定);本应用可无账号运行,并用浏览器本地存储保存构建进度。"
],
termsTitle: "使用条款",
termsParagraphs: [
"使用本应用即表示您同意下列条款。部署运营方可变更服务与条款,以随应用发布的版本为准。",
"食谱目录随已发布的应用版本提供;更新可能会更改内置内容。",
"部分部署可能启用注重隐私的统计(例如 analytics.ben-to.fr、Matomo 类),仅处理该工具政策所述的技术数据。",
"您对输入与分享的数据负责,请勿在未加密的分享中包含敏感信息。",
"服务按「现状」提供。在法律允许范围内,运营方不对间接损失或本地数据丢失承担责任。"
],
legalTitle: "法律信息",
legalParagraphs: [
"Ben-to 项目 — BENTO 食谱站点(https://ben-to.fr)的配套应用。法国经营者:BOUTEILLER — SIREN 512 643 214 — SIRET 512 643 214 00020。注册地址、出版负责人及联系方式:https://linktr.ee/kazerlelutin。",
"托管:Infomaniak Network SA — 25 rue Eugène-Marziano1227 Les Acacias,日内瓦,瑞士 — https://www.infomaniak.comben-to.fr 及相关部署的托管基础设施,如适用)。",
"个人数据:控制者、处理目的、保存期限及您的权利(访问、更正、删除、反对、向监管机构投诉等)以部署运营方为准;请参阅本应用的「隐私」页面及 ben-to.fr 的对应页面(如有)。",
"知识产权:第三方商标、文字与图片归各自权利人所有。"
],
privacyTitle: "隐私",
privacyParagraphs: [
"Ben-to 可无账号使用。构建进度(形状、口味)可按配置保存在浏览器本地存储中。",
"除可选功能(如下述访问统计)外,目录与图片均来自应用内置文件,不会调用第三方食谱服务器。",
"部分部署可能启用 analytics.ben-to.fr 等隐私友好的访问统计;处理目的与保存期限以 ben-to.fr 公开政策为准(如已发布)。",
"个人数据相关请求:https://linktr.ee/kazerlelutin(另请参阅本版本的法律信息)。"
],
notFoundTitle: "页面不存在",
notFoundParagraphs: ["该地址没有对应页面,请通过菜单或下方链接返回构建器。"]
}
};
export const getDictionary = (locale: LocaleCode): Dictionary => dictionaries[locale];
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true
},
"include": ["src"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "@ben-to/privacy",
"version": "0.1.0",
"type": "module",
"exports": {
"./cookie-consent": "./src/cookie-consent.ts"
}
}
@@ -0,0 +1,12 @@
/** Clé localStorage pour le choix cookies / analytics Matomo. */
export const COOKIE_CONSENT_STORAGE_KEY = "ben-to-cookie-consent-v1";
export type StoredAnalyticsConsent = "analytics-accepted" | "analytics-rejected";
export type ConsentParseResult = "unknown" | StoredAnalyticsConsent;
export const normalizeConsentRaw = (raw: string | null | undefined): ConsentParseResult => {
if (raw === "analytics-accepted") return "analytics-accepted";
if (raw === "analytics-rejected") return "analytics-rejected";
return "unknown";
};
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { normalizeConsentRaw } from "./cookie-consent";
describe("normalizeConsentRaw", () => {
it("reconnaît les valeurs stockées", () => {
expect(normalizeConsentRaw("analytics-accepted")).toBe("analytics-accepted");
expect(normalizeConsentRaw("analytics-rejected")).toBe("analytics-rejected");
});
it("retourne unknown sinon", () => {
expect(normalizeConsentRaw(null)).toBe("unknown");
expect(normalizeConsentRaw(undefined)).toBe("unknown");
expect(normalizeConsentRaw("")).toBe("unknown");
expect(normalizeConsentRaw("yes")).toBe("unknown");
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@ben-to/testing",
"version": "0.1.0",
"type": "module",
"main": "src/iso-date.ts",
"types": "src/iso-date.ts",
"exports": {
"./iso-date": "./src/iso-date.ts"
}
}
+1
View File
@@ -0,0 +1 @@
export const buildIsoDate = (value: string): string => new Date(value).toISOString();
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true
},
"include": ["src"]
}