first commit
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 l’id 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("n’utilise 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 d’un 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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.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">` jusqu’au `<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 & "b" <c>");
|
||||
});
|
||||
});
|
||||
|
||||
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 base–variante 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`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 d’ingré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 à l’accueil",
|
||||
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 d’abord 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 l’accè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 d’utilisation",
|
||||
navLegal: "Mentions légales",
|
||||
navPrivacy: "Confidentialité",
|
||||
cookieConsentTitle: "Cookies et mesure d’audience",
|
||||
cookieConsentDescription:
|
||||
"Matomo permet de mesurer la fréquentation de l’application. Aucun cookie de mesure n’est 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 d’une forme, d’un 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 d’une forme, d’un goût, puis affichage de la recette avec portions ajustables.",
|
||||
"Elle s’inscrit 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 d’URL 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 d’utilisation (CGU)",
|
||||
termsParagraphs: [
|
||||
"L’utilisation de l’application implique l’acceptation des présentes conditions. L’éditeur du déploiement peut faire évoluer le service et ces conditions ; la version publiée avec l’application prévaut.",
|
||||
"Le catalogue des recettes est fourni avec l’application pour la version publiée ; une mise à jour peut modifier les contenus embarqués.",
|
||||
"Une mesure d’audience (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 l’outil sont traitées.",
|
||||
"Vous êtes responsable des données que vous saisissez (portions, partage, etc.). Ne communiquez pas d’informations 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 d’hé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 d’audience décrite ci-dessous), le catalogue et les visuels sont servis depuis les fichiers embarqués dans l’application, sans appel à un serveur de recettes tiers.",
|
||||
"Sur certains déploiements, une mesure d’audience 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 lorsqu’elle 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 tool’s 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 app’s 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 用于组合便当式食谱:先选形状与口味,再查看可调整份量的配方。",
|
||||
"它与公开站点 BENTO(https://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-Marziano,1227 Les Acacias,日内瓦,瑞士 — https://www.infomaniak.com(ben-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];
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const buildIsoDate = (value: string): string => new Date(value).toISOString();
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user