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
+4
View File
@@ -0,0 +1,4 @@
# Optionnel — Matomo (mesure daudience). Les deux variables sont requises pour activer
# le tracker et le bandeau de consentement cookies.
# VITE_MATOMO_URL=https://analytics.example.com/
# VITE_MATOMO_SITE_ID=1
+39
View File
@@ -0,0 +1,39 @@
<!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="Composez vos bento idéaux grâce au système forme, goût et recette. Des idées de repas savoureux et faciles à transporter, illustrées en pixel-art." />
<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="Bento — Ben-to" />
<meta property="og:description"
content="Composez vos bento idéaux grâce au système forme, goût et recette. Des idées de repas savoureux et faciles à transporter, illustrées en pixel-art." />
<meta property="og:type" content="website" />
<meta property="og:locale" content="fr_FR" />
<meta property="og:locale:alternate" content="en_US" />
<meta property="og:locale:alternate" content="ko_KR" />
<meta property="og:locale:alternate" content="zh_CN" />
<meta property="og:image" content="https://ben-to.fr/branding/og.jpg" />
<meta property="og:image:alt" content="Logo onigiri pixel-art — Ben-to" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Bento — Ben-to" />
<meta name="twitter:description"
content="Composez vos bento idéaux grâce au système forme, goût et recette. Des idées de repas savoureux et faciles à transporter, illustrées en pixel-art." />
<meta name="twitter:image" content="https://ben-to.fr/branding/og.jpg" />
<meta name="twitter:image:alt" content="Logo onigiri pixel-art — Ben-to" />
<link rel="icon" type="image/png" href="/branding/onigiri-logo.png" />
<link rel="apple-touch-icon" href="/branding/onigiri-logo.png" />
<title>Bento</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@ben-to/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"predev": "node ../../scripts/build-recipes.mjs",
"dev": "vite",
"prebuild": "node ../../scripts/build-recipes.mjs",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@ben-to/builder": "0.1.0",
"@ben-to/i18n": "0.1.0",
"@ben-to/privacy": "0.1.0",
"@ben-to/recipes": "0.1.0",
"@capacitor/core": "^8.4.1",
"@capacitor/share": "^8.0.1",
"@solidjs/router": "^0.16.1",
"solid-js": "^1.9.14"
},
"devDependencies": {
"typescript": "^6.0.3",
"vite": "^8.1.4",
"vite-plugin-solid": "^2.11.8"
}
}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2025 The Cause Project Authors (https://github.com/xconsau/Cause)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright (c) YoonDesign Inc. All Rights Reserved.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Klee Project Authors (https://github.com/fontworks-fonts/Klee)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+22
View File
@@ -0,0 +1,22 @@
,assiette,fouet,baguettes,carnet,,,,,,,,,,,,,,,,,,,
,,,,,,,,,,,,,,,,,,,,,,,
eau|vinaigre de riz|Vinaigre blanc|mirin,huile|jus de citron,jus d'orange,jus de citron vert,lait|crême fraîche liquide entière|Crème fraîche épaisse,chocolat fondu|sauce soja|aromate pistache,purée de sésame noir,moutarde forte,Chapelure panko|Chapelure,,,,,,,,,,,,,,,
,,,,,,,,,,,,,,,,,,,,,,,
onigiri,empenadas,gimbap,radis mariné,kimchi,ramen,tofu,udon,dan bing|Omelette roulée,mandu,muffin,madelaine|madeleine,naan,bao,mandu shiitake,mandu aubergine,onigiri kimchi,blinis,cheese naan fromage,empanada-intensesalty,empanadas-butternut,empanadas-citron,madeleine-chocolat-blanc,guacamole
gateau de savoie,tranche cake,banana cake,carrot cake,cake au citron,cake à l'orange,flan,cheesecake-speculoos,speculoos,financier,financier lin,muffin au sésame noir,muffin aux pépites de chocolat,muffin chocolat blanc,muffin pistache et chocolat blanc,muffin pomme cannelle,muffin menthe et pépites de chocolat,gua bao,,,,,,
citron,orange,pomme rouge|Pomme,pomme verte,poire,,,,,,,,,,,,,,,,,,,
,,,,,,,,,,,,,,,,,,,,,,,
betterave,carotte| carottes râpées,feuille de nori,cornichon|concombre,poivron rouge|poivron,poivron orange,poivron vert,poivron jaune,gingembre,navet,danmuji|radis jaune,aubergine|aubergines,champignon|champignons|shiitake,ail|aïl|ail frais|,radis|radis râpé,tomate|tomates,melon|melons,,,,,,,
haricots noirs,flageolets,haricots rouges,mais,epinard,courge,oignon,piment rouge,ciboulette,ciboule,piment vert,banane|bananes,Avocat|avocats|Avocados,Butternut|butternuts,Feuilles de coriandre|coriandre,Échalote|echalote,menthe|mint,,,,,,,
oeuf|oeufs|œufs|œuf,jaune d'oeuf|jaune d'œufs|jaune d'œuf,blanc d'oeuf|blanc d'œufs|blanc d'œuf,,,,,,,,,,,,,,,,,,,,,
,,,,,,,,,,,,,,,,,,,,,,,
sel|bicarbonate|fleur de sel,poivre,poudre verte,poudre beige|poudre d'amande|poudre d'amandes|canelle en poudre|cannelle|cassonade|sucre brun|Levure boulangère sèche,gingembre en poudre,poudre blanche|sucre en poudre|sucre|fécule|fécule de maïs|levure chimique,poudre rouge|piment de cayenne,poudre orange,,,,,,,,,,,,,,,,
vanille,noisette|noisettes,noix,Muscade|muscade,Clous de girofle |clous de girofle|girofle,,,,,,,,,,,,,,,,,,,
pepite de chocolat au lait,pepites de chocolat noir,pepite de chocolat blanc|pepite chocolat blanc,,,,,,,,,,,,,,,,,,,,,
tablette chocolat|chocolat,tablette chocolat blanc|chocolat blanc,,,,,,,,,,,,,,,,,,,,,,
emmental,beurre|beurre doux,mozzarella,comté|gruyère,cheddar,fromage frais|yaourt grec,fromage râpé,,,,,,,,,,,,,,,,,
,,,,,,,,,,,,,,,,,,,,,,,
farine,sucre de canne,,,,,,,,,,,,,,,,,,,,,,
riz,,,,,,,,,,,,,,,,,,,,,,,
graines de sésame|graines de lin,,,,,,,,,,,,,,,,,,,,,,,
arachide|---,,,,,,,,,,,,,,,,,,,,,,,
1 assiette fouet baguettes carnet
2
3 eau|vinaigre de riz|Vinaigre blanc|mirin huile|jus de citron jus d'orange jus de citron vert lait|crême fraîche liquide entière|Crème fraîche épaisse chocolat fondu|sauce soja|aromate pistache purée de sésame noir moutarde forte Chapelure panko|Chapelure
4
5 onigiri empenadas gimbap radis mariné kimchi ramen tofu udon dan bing|Omelette roulée mandu muffin madelaine|madeleine naan bao mandu shiitake mandu aubergine onigiri kimchi blinis cheese naan fromage empanada-intensesalty empanadas-butternut empanadas-citron madeleine-chocolat-blanc guacamole
6 gateau de savoie tranche cake banana cake carrot cake cake au citron cake à l'orange flan cheesecake-speculoos speculoos financier financier lin muffin au sésame noir muffin aux pépites de chocolat muffin chocolat blanc muffin pistache et chocolat blanc muffin pomme cannelle muffin menthe et pépites de chocolat gua bao
7 citron orange pomme rouge|Pomme pomme verte poire
8
9 betterave carotte| carottes râpées feuille de nori cornichon|concombre poivron rouge|poivron poivron orange poivron vert poivron jaune gingembre navet danmuji|radis jaune aubergine|aubergines champignon|champignons|shiitake ail|aïl|ail frais| radis|radis râpé tomate|tomates melon|melons
10 haricots noirs flageolets haricots rouges mais epinard courge oignon piment rouge ciboulette ciboule piment vert banane|bananes Avocat|avocats|Avocados Butternut|butternuts Feuilles de coriandre|coriandre Échalote|echalote menthe|mint
11 oeuf|oeufs|œufs|œuf jaune d'oeuf|jaune d'œufs|jaune d'œuf blanc d'oeuf|blanc d'œufs|blanc d'œuf
12
13 sel|bicarbonate|fleur de sel poivre poudre verte poudre beige|poudre d'amande|poudre d'amandes|canelle en poudre|cannelle|cassonade|sucre brun|Levure boulangère sèche gingembre en poudre poudre blanche|sucre en poudre|sucre|fécule|fécule de maïs|levure chimique poudre rouge|piment de cayenne poudre orange
14 vanille noisette|noisettes noix Muscade|muscade Clous de girofle |clous de girofle|girofle
15 pepite de chocolat au lait pepites de chocolat noir pepite de chocolat blanc|pepite chocolat blanc
16 tablette chocolat|chocolat tablette chocolat blanc|chocolat blanc
17 emmental beurre|beurre doux mozzarella comté|gruyère cheddar fromage frais|yaourt grec fromage râpé
18
19 farine sucre de canne
20 riz
21 graines de sésame|graines de lin
22 arachide|---
Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://ben-to.fr/sitemap.xml
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://ben-to.fr/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://ben-to.fr/a-propos</loc>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://ben-to.fr/cgu</loc>
<changefreq>yearly</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://ben-to.fr/mentions-legales</loc>
<changefreq>yearly</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://ben-to.fr/confidentialite</loc>
<changefreq>yearly</changefreq>
<priority>0.5</priority>
</url>
</urlset>
Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

+13
View File
@@ -0,0 +1,13 @@
import { readAnalyticsConsent } from "./cookie-consent-storage";
type MatomoWindow = Window &
typeof globalThis & {
_paq?: unknown[][];
};
export const trackEvent = (category: string, action: string, name?: string): void => {
if (readAnalyticsConsent() !== "analytics-accepted") return;
const matomoWindow = window as MatomoWindow;
matomoWindow._paq = matomoWindow._paq ?? [];
matomoWindow._paq.push(["trackEvent", category, action, name ?? ""]);
};
+34
View File
@@ -0,0 +1,34 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./cookie-consent-storage", () => ({
readAnalyticsConsent: vi.fn()
}));
import { readAnalyticsConsent } from "./cookie-consent-storage";
import { trackEvent } from "./analytics";
const readConsent = vi.mocked(readAnalyticsConsent);
describe("trackEvent (Matomo)", () => {
beforeEach(() => {
readConsent.mockReset();
readConsent.mockReturnValue("analytics-rejected");
vi.stubGlobal("window", { _paq: [] as unknown[][] });
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("nenvoie rien sans consentement analytics", () => {
trackEvent("recipe", "share", "x");
expect(window._paq?.length ?? 0).toBe(0);
});
it("pousse un trackEvent Matomo lorsque le consentement est accepté", () => {
readConsent.mockReturnValue("analytics-accepted");
vi.stubGlobal("window", { _paq: [] as unknown[][] });
trackEvent("recipe", "share", "y");
expect(window._paq).toContainEqual(["trackEvent", "recipe", "share", "y"]);
});
});
@@ -0,0 +1,22 @@
import {
COOKIE_CONSENT_STORAGE_KEY,
normalizeConsentRaw,
type ConsentParseResult,
type StoredAnalyticsConsent
} from "@ben-to/privacy/cookie-consent";
export const readAnalyticsConsent = (): ConsentParseResult => {
try {
return normalizeConsentRaw(localStorage.getItem(COOKIE_CONSENT_STORAGE_KEY));
} catch {
return "unknown";
}
};
export const writeAnalyticsConsent = (value: StoredAnalyticsConsent): void => {
try {
localStorage.setItem(COOKIE_CONSENT_STORAGE_KEY, value);
} catch {
/* stockage indisponible (mode privé strict, etc.) */
}
};
+60
View File
@@ -0,0 +1,60 @@
import { readAnalyticsConsent } from "./cookie-consent-storage";
export type MatomoRuntimeConfig = Readonly<{
baseUrl: string;
siteId: string;
}>;
type MatomoWindow = Window &
typeof globalThis & {
_paq?: unknown[][];
};
let injectPromise: Promise<void> | null = null;
export const getMatomoRuntimeConfig = (): MatomoRuntimeConfig | null => {
const rawUrl = import.meta.env.VITE_MATOMO_URL;
const rawSite = import.meta.env.VITE_MATOMO_SITE_ID;
const url = typeof rawUrl === "string" ? rawUrl.trim() : "";
const siteId = typeof rawSite === "string" ? rawSite.trim() : "";
if (!url || !siteId) return null;
const baseUrl = url.endsWith("/") ? url : `${url}/`;
return { baseUrl, siteId };
};
export const isMatomoConfigured = (): boolean => getMatomoRuntimeConfig() !== null;
export const injectMatomoTracker = (cfg: MatomoRuntimeConfig): Promise<void> => {
if (injectPromise) return injectPromise;
injectPromise = new Promise<void>((resolve, reject) => {
const w = window as MatomoWindow;
w._paq = w._paq ?? [];
w._paq.push(["setTrackerUrl", `${cfg.baseUrl}matomo.php`]);
w._paq.push(["setSiteId", cfg.siteId]);
const g = document.createElement("script");
g.async = true;
g.src = `${cfg.baseUrl}matomo.js`;
g.onload = () => {
const mw = window as MatomoWindow;
mw._paq?.push(["enableLinkTracking"]);
resolve();
};
g.onerror = () => reject(new Error("Failed to load matomo.js"));
const s = document.getElementsByTagName("script")[0];
if (s?.parentNode) {
s.parentNode.insertBefore(g, s);
} else {
document.head.appendChild(g);
}
});
return injectPromise;
};
export const trackMatomoPageView = (): void => {
if (readAnalyticsConsent() !== "analytics-accepted") return;
const w = window as MatomoWindow;
if (!w._paq) return;
w._paq.push(["setCustomUrl", window.location.href]);
w._paq.push(["setDocumentTitle", document.title]);
w._paq.push(["trackPageView"]);
};
+12
View File
@@ -0,0 +1,12 @@
import type { BentoSelection } from "@ben-to/builder/domain";
const key = "ben-to.selection";
export const saveSelection = (selection: BentoSelection): void => {
localStorage.setItem(key, JSON.stringify(selection));
};
export const loadSelection = (): BentoSelection | null => {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as BentoSelection) : null;
};
@@ -0,0 +1,54 @@
const STORAGE_KEY = "ben-to.recipe-checklist";
export type RecipeChecklistStored = Readonly<{
ingredientIds: readonly string[];
stepIds: readonly string[];
}>;
type RecipeChecklistMap = Record<string, RecipeChecklistStored>;
const emptyEntry = (): RecipeChecklistStored =>
Object.freeze({ ingredientIds: Object.freeze([]), stepIds: Object.freeze([]) });
const loadMap = (): RecipeChecklistMap => {
if (typeof localStorage === "undefined") return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return parsed as RecipeChecklistMap;
} catch {
return {};
}
};
const saveMap = (map: RecipeChecklistMap): void => {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
} catch {
/* quota / private mode */
}
};
export const loadRecipeChecklist = (recipeKey: string): RecipeChecklistStored => {
const map = loadMap();
const entry = map[recipeKey];
if (!entry || !Array.isArray(entry.ingredientIds) || !Array.isArray(entry.stepIds)) {
return emptyEntry();
}
return Object.freeze({
ingredientIds: Object.freeze([...entry.ingredientIds]),
stepIds: Object.freeze([...entry.stepIds])
});
};
export const saveRecipeChecklist = (recipeKey: string, data: RecipeChecklistStored): void => {
const map = loadMap();
map[recipeKey] = Object.freeze({
ingredientIds: Object.freeze([...data.ingredientIds]),
stepIds: Object.freeze([...data.stepIds])
});
saveMap(map);
};
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
readWelcomeTourDismissed,
WELCOME_TOUR_SCHEMA_VERSION,
WELCOME_TOUR_STORAGE_KEY,
writeWelcomeTourDismissed
} from "./welcome-tour-storage";
const mockStorage = () => {
const map = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
clear: () => void map.clear(),
key: (i: number) => [...map.keys()][i] ?? null,
get length() {
return map.size;
}
} as Storage);
};
describe("welcome tour storage (Gherkin @int-welcome-tour-dismiss)", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
/** @int-welcome-tour-dismiss */
it("enregistre la version courante lorsque le tour est fermé", () => {
mockStorage();
expect(readWelcomeTourDismissed()).toBe(false);
writeWelcomeTourDismissed();
expect(localStorage.getItem(WELCOME_TOUR_STORAGE_KEY)).toBe(WELCOME_TOUR_SCHEMA_VERSION);
expect(readWelcomeTourDismissed()).toBe(true);
});
});
@@ -0,0 +1,21 @@
/** Clé localStorage : fermeture du tour d'accueil pour une version de contenu donnée. */
export const WELCOME_TOUR_STORAGE_KEY = "ben-to.welcome-tour-dismissed";
/** Incrémenter pour réafficher le tour après refonte majeure du contenu. */
export const WELCOME_TOUR_SCHEMA_VERSION = "v1";
export const readWelcomeTourDismissed = (): boolean => {
try {
return localStorage.getItem(WELCOME_TOUR_STORAGE_KEY) === WELCOME_TOUR_SCHEMA_VERSION;
} catch {
return false;
}
};
export const writeWelcomeTourDismissed = (): void => {
try {
localStorage.setItem(WELCOME_TOUR_STORAGE_KEY, WELCOME_TOUR_SCHEMA_VERSION);
} catch {
/* stockage indisponible */
}
};
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
readWelcomeTourDismissed,
WELCOME_TOUR_SCHEMA_VERSION,
WELCOME_TOUR_STORAGE_KEY,
writeWelcomeTourDismissed
} from "./welcome-tour-storage";
const mockStorage = () => {
const map = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
clear: () => void map.clear(),
key: (i: number) => [...map.keys()][i] ?? null,
get length() {
return map.size;
}
} as Storage);
};
describe("welcome-tour-storage", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("readWelcomeTourDismissed est faux sans valeur", () => {
mockStorage();
expect(readWelcomeTourDismissed()).toBe(false);
});
it("write puis read indique dismiss pour la version courante", () => {
mockStorage();
writeWelcomeTourDismissed();
expect(localStorage.getItem(WELCOME_TOUR_STORAGE_KEY)).toBe(WELCOME_TOUR_SCHEMA_VERSION);
expect(readWelcomeTourDismissed()).toBe(true);
});
it("une ancienne valeur ne compte pas comme dismiss", () => {
mockStorage();
localStorage.setItem(WELCOME_TOUR_STORAGE_KEY, "v0");
expect(readWelcomeTourDismissed()).toBe(false);
});
});
+5
View File
@@ -0,0 +1,5 @@
import { render } from "solid-js/web";
import { App } from "./ui/App";
import "./ui/app.css";
render(() => <App />, document.getElementById("root")!);
+18
View File
@@ -0,0 +1,18 @@
import { Route, Router } from "@solidjs/router";
import { BuilderFlow } from "./BuilderFlow";
import { RootLayout } from "./RootLayout";
import { StaticInfoPage } from "./pages/StaticInfoPage";
export function App() {
return (
<Router root={RootLayout}>
<Route path="/" component={BuilderFlow} />
<Route path="/r/:baseId/:variantId" component={BuilderFlow} />
<Route path="/a-propos" component={() => <StaticInfoPage kind="about" />} />
<Route path="/cgu" component={() => <StaticInfoPage kind="terms" />} />
<Route path="/mentions-legales" component={() => <StaticInfoPage kind="legal" />} />
<Route path="/confidentialite" component={() => <StaticInfoPage kind="privacy" />} />
<Route path="*" component={() => <StaticInfoPage kind="notFound" />} />
</Router>
);
}
+126
View File
@@ -0,0 +1,126 @@
import type { RouteSectionProps } from "@solidjs/router";
import type { BentoSelection } from "@ben-to/builder/domain";
import { createInitialSelection } from "@ben-to/builder/domain";
import { fetchRecipesIndex } from "@ben-to/recipes/catalog";
import { getDictionary } from "@ben-to/i18n/dictionary";
import { createEffect, createMemo, createResource, createSignal, Show } from "solid-js";
import { clearBuilderResetBus, readBuilderResetNonce } from "./builder-reset-bus";
import { useAppLocale } from "./app-locale-context";
import { BuilderBaseStep } from "./builder-flow/BuilderBaseStep";
import { BuilderCtaDock } from "./builder-flow/BuilderCtaDock";
import { BuilderStepsNav } from "./builder-flow/BuilderStepsNav";
import { BuilderVariantStep } from "./builder-flow/BuilderVariantStep";
import { RecipeSheetSection } from "./builder-flow/RecipeSheetSection";
import { useBuilderCatalog } from "./builder-flow/use-builder-catalog";
import { useBuilderSteps } from "./builder-flow/use-builder-steps";
import { useRecipeSheet } from "./builder-flow/use-recipe-sheet";
export function BuilderFlow(_props: RouteSectionProps) {
const { locale } = useAppLocale();
const [selection, setSelection] = createSignal<BentoSelection>(createInitialSelection());
const [recipesIndex] = createResource(async () => fetchRecipesIndex());
const t = createMemo(() => getDictionary(locale()));
const steps = useBuilderSteps({
selection,
setSelection,
recipesIndex
});
const catalog = useBuilderCatalog({
recipesIndex,
selection,
flavorFilter: steps.flavorFilter,
locale
});
const recipe = useRecipeSheet({
selection,
currentStep: steps.currentStep,
mergedRecipe: catalog.mergedRecipe,
recipeDisplayTitle: catalog.recipeDisplayTitle,
locale,
t
});
createEffect(() => {
const n = readBuilderResetNonce();
if (n === 0) return;
steps.goHomeWizard();
recipe.resetServingsForGoHome();
clearBuilderResetBus();
});
return (
<main
classList={{
"app-shell": true,
"app-shell--with-dock": steps.canContinue(),
"app-shell--internal-scroll": true,
"app-shell--recipe-scroll": steps.currentStep() === "recipe"
}}
>
<BuilderStepsNav
t={t}
progressStepIndex={steps.progressStepIndex}
progressPercent={steps.progressPercent}
canNavigateToStep={steps.canNavigateToStep}
goToBuilderStep={steps.goToBuilderStep}
currentStep={steps.currentStep}
recipeProgressCompact={recipe.recipeProgressCompact}
/>
<Show when={steps.currentStep() === "base"}>
<div class="builder-scroll">
<BuilderBaseStep
t={t}
locale={locale}
recipesIndex={recipesIndex}
filteredBases={catalog.filteredBases}
flavorFilter={steps.flavorFilter}
setFlavorFilter={steps.setFlavorFilter}
selection={selection}
setSelection={setSelection}
advanceAfterBasePickDouble={steps.advanceAfterBasePickDouble}
onPickCardTouchEndForDouble={steps.onPickCardTouchEndForDouble}
/>
</div>
</Show>
<Show when={steps.currentStep() === "variant"}>
<div class="builder-scroll">
<BuilderVariantStep
t={t}
locale={locale}
currentBase={catalog.currentBase}
availableVariants={catalog.availableVariants}
selection={selection}
setSelection={setSelection}
advanceAfterVariantPickDouble={steps.advanceAfterVariantPickDouble}
onPickCardTouchEndForDouble={steps.onPickCardTouchEndForDouble}
/>
</div>
</Show>
<RecipeSheetSection
currentStep={steps.currentStep}
locale={locale}
t={t}
recipeCoverImageUrl={catalog.recipeCoverImageUrl}
recipeDisplayTitle={catalog.recipeDisplayTitle}
mergedRecipe={catalog.mergedRecipe}
recipe={recipe}
/>
<BuilderCtaDock
canContinue={steps.canContinue}
currentStep={steps.currentStep}
currentBase={catalog.currentBase}
currentVariant={catalog.currentVariant}
locale={locale}
t={t}
nextStep={steps.nextStep}
/>
</main>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { A } from "@solidjs/router";
import type { Accessor } from "solid-js";
import type { Dictionary } from "@ben-to/i18n/dictionary";
export function CookieConsentBanner(props: {
readonly t: Accessor<Dictionary>;
readonly onAccept: () => void;
readonly onReject: () => void;
}) {
return (
<div class="cookie-consent" role="region" aria-labelledby="cookie-consent-heading">
<div class="cookie-consent__inner">
<h2 id="cookie-consent-heading" class="cookie-consent__title">
{props.t().cookieConsentTitle}
</h2>
<p class="cookie-consent__text">{props.t().cookieConsentDescription}</p>
<p class="cookie-consent__privacy">
<A href="/confidentialite" class="cookie-consent__link">
{props.t().navPrivacy}
</A>
</p>
<div class="cookie-consent__actions">
<button
type="button"
class="cookie-consent__btn cookie-consent__btn--primary"
onClick={() => props.onAccept()}
>
{props.t().cookieConsentAccept}
</button>
<button
type="button"
class="cookie-consent__btn cookie-consent__btn--ghost"
onClick={() => props.onReject()}
>
{props.t().cookieConsentReject}
</button>
</div>
</div>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { A, useLocation } from "@solidjs/router";
import type { RouteSectionProps } from "@solidjs/router";
import { createEffect, createSignal, For, onCleanup, onMount, Show } from "solid-js";
import { getDictionary, type LocaleCode } from "@ben-to/i18n/dictionary";
import { readAnalyticsConsent, writeAnalyticsConsent } from "../infra/cookie-consent-storage";
import { readWelcomeTourDismissed, writeWelcomeTourDismissed } from "../infra/welcome-tour-storage";
import { getMatomoRuntimeConfig, injectMatomoTracker, trackMatomoPageView } from "../infra/matomo";
import { bumpBuilderResetBus } from "./builder-reset-bus";
import { AppLocaleProvider, useAppLocale } from "./app-locale-context";
import { CookieConsentBanner } from "./CookieConsentBanner";
import { WelcomeTourModal } from "./WelcomeTourModal";
import { publicAssetHref } from "./builder-flow/public-asset";
const localeOptions: LocaleCode[] = ["fr", "en", "ko", "zh"];
const localeNativeNames: Record<LocaleCode, string> = {
fr: "Français",
en: "English",
ko: "한국어",
zh: "中文"
};
const localeFlags: Record<LocaleCode, string> = {
fr: "🇫🇷",
en: "🇬🇧",
ko: "🇰🇷",
zh: "🇨🇳"
};
function RootLayoutInner(props: RouteSectionProps) {
const { locale, setLocale } = useAppLocale();
const location = useLocation();
const [menuOpen, setMenuOpen] = createSignal(false);
const t = () => getDictionary(locale());
const matomoCfg = getMatomoRuntimeConfig();
const [matomoReady, setMatomoReady] = createSignal(false);
const [consentBannerOpen, setConsentBannerOpen] = createSignal(readAnalyticsConsent() === "unknown");
const [welcomeTourOpen, setWelcomeTourOpen] = createSignal(false);
createEffect(() => {
location.pathname;
setMenuOpen(false);
});
createEffect(() => {
location.pathname;
location.search;
if (!matomoReady()) return;
trackMatomoPageView();
});
const acceptAnalyticsCookies = () => {
writeAnalyticsConsent("analytics-accepted");
setConsentBannerOpen(false);
if (matomoCfg) {
void injectMatomoTracker(matomoCfg).then(() => setMatomoReady(true));
}
};
const rejectAnalyticsCookies = () => {
writeAnalyticsConsent("analytics-rejected");
setConsentBannerOpen(false);
};
const dismissWelcomeTour = () => {
writeWelcomeTourDismissed();
setWelcomeTourOpen(false);
};
createEffect(() => {
location.pathname;
consentBannerOpen();
if (location.pathname !== "/" || consentBannerOpen()) {
setWelcomeTourOpen(false);
return;
}
if (readWelcomeTourDismissed()) {
setWelcomeTourOpen(false);
return;
}
setWelcomeTourOpen(true);
});
onMount(() => {
const consent = readAnalyticsConsent();
if (consent === "unknown") setConsentBannerOpen(true);
if (consent === "analytics-accepted" && matomoCfg) {
void injectMatomoTracker(matomoCfg).then(() => setMatomoReady(true));
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
if (welcomeTourOpen()) {
dismissWelcomeTour();
return;
}
setMenuOpen(false);
}
};
window.addEventListener("keydown", onKeyDown);
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
});
return (
<>
<header class="topbar">
<button
type="button"
class="topbar__burger"
aria-expanded={menuOpen()}
aria-controls="site-nav-drawer"
aria-label={t().menu}
onClick={() => setMenuOpen((open) => !open)}
>
<span class="topbar__burger-line" aria-hidden="true" />
<span class="topbar__burger-line" aria-hidden="true" />
<span class="topbar__burger-line" aria-hidden="true" />
</button>
<h1 class="topbar__title">
<A
href="/"
class="brand"
aria-label={t().home}
end
onClick={(event) => {
if (location.pathname === "/") {
event.preventDefault();
bumpBuilderResetBus();
}
}}
>
<img class="brand__logo" src={publicAssetHref("/branding/onigiri-logo.png")} width="40" height="40" alt="" />
<span class="brand__wordmark" aria-hidden="true">
<span class="brand__ben">Ben</span>
<span class="brand__to">to</span>
</span>
</A>
</h1>
<div class="topbar__locale" role="radiogroup" aria-label={t().language}>
<For each={localeOptions}>
{(code) => (
<button
type="button"
class="topbar__locale-btn"
classList={{ "topbar__locale-btn--active": locale() === code }}
role="radio"
aria-checked={locale() === code}
title={localeNativeNames[code]}
onClick={() => setLocale(code)}
>
<span class="visually-hidden">{localeNativeNames[code]}</span>
<span class="topbar__locale-flag" aria-hidden="true">
{localeFlags[code]}
</span>
</button>
)}
</For>
</div>
</header>
{props.children}
<Show when={menuOpen()}>
<div
class="nav-drawer__backdrop"
role="presentation"
onClick={() => setMenuOpen(false)}
/>
<nav
id="site-nav-drawer"
class="nav-drawer"
aria-label={t().menu}
role="dialog"
aria-modal="true"
>
<div class="nav-drawer__header">
<span class="nav-drawer__title">{t().menu}</span>
<button type="button" class="nav-drawer__close" onClick={() => setMenuOpen(false)}>
{t().menuClose}
</button>
</div>
<ul class="nav-drawer__list">
<li>
<A href="/a-propos" class="nav-drawer__link" onClick={() => setMenuOpen(false)}>
{t().navAbout}
</A>
</li>
<li>
<A href="/cgu" class="nav-drawer__link" onClick={() => setMenuOpen(false)}>
{t().navTerms}
</A>
</li>
<li>
<A href="/mentions-legales" class="nav-drawer__link" onClick={() => setMenuOpen(false)}>
{t().navLegal}
</A>
</li>
<li>
<A href="/confidentialite" class="nav-drawer__link" onClick={() => setMenuOpen(false)}>
{t().navPrivacy}
</A>
</li>
</ul>
</nav>
</Show>
<Show when={consentBannerOpen()}>
<CookieConsentBanner t={t} onAccept={acceptAnalyticsCookies} onReject={rejectAnalyticsCookies} />
</Show>
<WelcomeTourModal t={t} open={welcomeTourOpen} onDismiss={dismissWelcomeTour} />
</>
);
}
/** Enveloppe : locale partagée + barre + menu latéral. */
export function RootLayout(props: RouteSectionProps) {
return (
<AppLocaleProvider>
<RootLayoutInner {...props} />
</AppLocaleProvider>
);
}
+93
View File
@@ -0,0 +1,93 @@
import type { Accessor } from "solid-js";
import { For, Show, createEffect } from "solid-js";
import type { Dictionary } from "@ben-to/i18n/dictionary";
import { publicAssetHref } from "./builder-flow/public-asset";
export function WelcomeTourModal(props: {
readonly t: Accessor<Dictionary>;
readonly open: Accessor<boolean>;
readonly onDismiss: () => void;
}) {
let primaryButton: HTMLButtonElement | undefined;
createEffect(() => {
if (!props.open()) return;
queueMicrotask(() => primaryButton?.focus());
});
const bullets = () =>
[
props.t().welcomeTourBulletSteps,
props.t().welcomeTourBulletFilters,
props.t().welcomeTourBulletServings,
props.t().welcomeTourBulletShare
] as const;
return (
<Show when={props.open()}>
<div
class="welcome-tour__backdrop"
role="presentation"
aria-hidden="true"
onClick={() => props.onDismiss()}
/>
<div
id="welcome-tour-dialog"
class="welcome-tour"
role="dialog"
aria-modal="true"
aria-labelledby="welcome-tour-heading"
aria-describedby="welcome-tour-intro"
>
<div class="welcome-tour__inner">
<h2 id="welcome-tour-heading" class="welcome-tour__title">
{props.t().welcomeTourTitle}
</h2>
<p id="welcome-tour-intro" class="welcome-tour__intro">
{props.t().welcomeTourIntro}
</p>
<ul class="welcome-tour__bullets">
<For each={bullets()}>{(text) => <li>{text}</li>}</For>
</ul>
<div class="welcome-tour__gifs">
<figure class="welcome-tour__figure">
<img
class="welcome-tour__gif"
src={publicAssetHref("/tour/flow-builder.gif")}
alt={props.t().welcomeTourGifFlowAlt}
width={360}
height={202}
loading="lazy"
decoding="async"
/>
</figure>
<figure class="welcome-tour__figure">
<img
class="welcome-tour__gif"
src={publicAssetHref("/tour/recipe-sheet.gif")}
alt={props.t().welcomeTourGifRecipeAlt}
width={360}
height={202}
loading="lazy"
decoding="async"
/>
</figure>
</div>
<div class="welcome-tour__actions">
<button
type="button"
class="welcome-tour__btn welcome-tour__btn--primary"
ref={(el) => (primaryButton = el)}
onClick={() => props.onDismiss()}
>
{props.t().welcomeTourCtaStart}
</button>
<button type="button" class="welcome-tour__btn welcome-tour__btn--ghost" onClick={() => props.onDismiss()}>
{props.t().welcomeTourCtaClose}
</button>
</div>
</div>
</div>
</Show>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { createContext, createSignal, useContext, type ParentProps } from "solid-js";
import type { LocaleCode } from "@ben-to/i18n/dictionary";
export type AppLocaleValue = Readonly<{
locale: () => LocaleCode;
setLocale: (code: LocaleCode) => void;
}>;
const AppLocaleContext = createContext<AppLocaleValue>();
export function AppLocaleProvider(props: ParentProps) {
const [locale, setLocale] = createSignal<LocaleCode>("fr");
const value: AppLocaleValue = {
locale,
setLocale
};
return <AppLocaleContext.Provider value={value}>{props.children}</AppLocaleContext.Provider>;
}
export function useAppLocale(): AppLocaleValue {
const ctx = useContext(AppLocaleContext);
if (!ctx) {
throw new Error("useAppLocale doit être utilisé sous AppLocaleProvider");
}
return ctx;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
import type { Accessor, Resource, Setter } from "solid-js";
import { For, Show } from "solid-js";
import type { BaseOption, BentoSelection } from "@ben-to/builder/domain";
import type { RecipesIndex } from "@ben-to/recipes/catalog";
import type { LocaleCode } from "@ben-to/i18n/dictionary";
import type { DictionaryView } from "./dictionary-view";
import type { FlavorFilter } from "./builder-flow-types";
import { SpriteTile } from "./SpriteTile";
import { publicAssetHref } from "./public-asset";
export function BuilderBaseStep(props: {
readonly t: Accessor<DictionaryView>;
readonly locale: Accessor<LocaleCode>;
readonly recipesIndex: Resource<RecipesIndex | undefined>;
readonly filteredBases: Accessor<BaseOption[]>;
readonly flavorFilter: Accessor<FlavorFilter>;
readonly setFlavorFilter: Setter<FlavorFilter>;
readonly selection: Accessor<BentoSelection>;
readonly setSelection: Setter<BentoSelection>;
readonly advanceAfterBasePickDouble: () => void;
readonly onPickCardTouchEndForDouble: (
cardKey: string,
advance: () => void
) => (e: TouchEvent) => void;
}) {
return (
<section aria-label={props.t().selectBase}>
<div class="filters">
<button
type="button"
class={props.flavorFilter() === "all" ? "chip selected" : "chip"}
onClick={() => props.setFlavorFilter("all")}
>
{props.t().filterAll}
</button>
<button
type="button"
class={props.flavorFilter() === "savory" ? "chip selected" : "chip"}
onClick={() => props.setFlavorFilter("savory")}
>
{props.t().filterSavory}
</button>
<button
type="button"
class={props.flavorFilter() === "sweet" ? "chip selected" : "chip"}
onClick={() => props.setFlavorFilter("sweet")}
>
{props.t().filterSweet}
</button>
</div>
<Show when={props.recipesIndex.loading}>
<p>{props.t().loading}</p>
</Show>
<Show when={props.recipesIndex.error}>
<p role="alert">{props.t().loadError}</p>
</Show>
<div class="cards">
<For each={props.filteredBases()}>
{(base) => (
<button
type="button"
class={`card card--pick ${props.selection().baseId === base.id ? "selected" : ""}`}
onClick={() => props.setSelection((prev) => ({ ...prev, baseId: base.id, variantId: null }))}
onDblClick={() => props.advanceAfterBasePickDouble()}
onTouchEnd={props.onPickCardTouchEndForDouble(`base:${base.id}`, props.advanceAfterBasePickDouble)}
>
<Show when={base.coverImageUrl}>
<img class="card__pick-icon" src={publicAssetHref(base.coverImageUrl)} alt="" />
</Show>
<Show when={!base.coverImageUrl && base.coverSprite}>
<SpriteTile
sprite={base.coverSprite!}
size={32}
class="card__sprite card__pick-sprite"
/>
</Show>
<span class="card__pick-name">{base.name[props.locale()]}</span>
</button>
)}
</For>
</div>
</section>
);
}
@@ -0,0 +1,122 @@
import type { Accessor } from "solid-js";
import { createEffect, onCleanup, Show } from "solid-js";
import type { BaseOption, StepKind, VariantOption } from "@ben-to/builder/domain";
import type { LocaleCode } from "@ben-to/i18n/dictionary";
import type { DictionaryView } from "./dictionary-view";
import { SpriteTile } from "./SpriteTile";
import { publicAssetHref } from "./public-asset";
export function BuilderCtaDock(props: {
readonly canContinue: Accessor<boolean>;
readonly currentStep: Accessor<StepKind>;
readonly currentBase: Accessor<BaseOption | undefined>;
readonly currentVariant: Accessor<VariantOption | undefined>;
readonly locale: Accessor<LocaleCode>;
readonly t: Accessor<DictionaryView>;
readonly nextStep: () => void;
}) {
let dockEl: HTMLDivElement | undefined;
let dockObserver: ResizeObserver | undefined;
const updateDockHeight = () => {
document.documentElement.style.setProperty(
"--cta-dock-height",
props.canContinue() && dockEl ? `${Math.ceil(dockEl.getBoundingClientRect().height)}px` : "0px"
);
};
const observeDock = () => {
dockObserver?.disconnect();
if (!props.canContinue() || !dockEl) return;
dockObserver = new ResizeObserver(updateDockHeight);
dockObserver.observe(dockEl);
updateDockHeight();
};
createEffect(() => {
if (!props.canContinue()) {
dockObserver?.disconnect();
dockObserver = undefined;
document.documentElement.style.setProperty("--cta-dock-height", "0px");
return;
}
observeDock();
});
onCleanup(() => {
dockObserver?.disconnect();
document.documentElement.style.removeProperty("--cta-dock-height");
});
return (
<Show when={props.canContinue()}>
<div
class="cta-dock"
role="presentation"
ref={(el) => {
dockEl = el;
observeDock();
}}
>
<div class="cta-dock__inner">
<Show when={props.currentStep() === "base" && props.currentBase()} keyed>
{(base: BaseOption) => {
const hasMedia = Boolean(base.coverImageUrl || base.coverSprite);
return (
<div class="cta-context" classList={{ "cta-context--with-media": hasMedia }}>
{base.coverImageUrl ? (
<div class="cta-context__media">
<img
class="cta-context__icon"
src={publicAssetHref(base.coverImageUrl)}
alt=""
width="32"
height="32"
/>
</div>
) : base.coverSprite ? (
<div class="cta-context__media">
<SpriteTile sprite={base.coverSprite} size={32} class="cta-context__sprite" />
</div>
) : null}
<p class="cta-context__title">{base.name[props.locale()]}</p>
<p class="cta-context__subtitle">{base.history[props.locale()]}</p>
</div>
);
}}
</Show>
<Show when={props.currentStep() === "variant" && props.currentVariant()} keyed>
{(variant: VariantOption) => {
const hasMedia = Boolean(variant.coverImageUrl || variant.coverSprite);
return (
<div class="cta-context" classList={{ "cta-context--with-media": hasMedia }}>
{variant.coverImageUrl ? (
<div class="cta-context__media">
<img
class="cta-context__icon"
src={publicAssetHref(variant.coverImageUrl)}
alt=""
width="32"
height="32"
/>
</div>
) : variant.coverSprite ? (
<div class="cta-context__media">
<SpriteTile sprite={variant.coverSprite} size={32} class="cta-context__sprite" />
</div>
) : null}
<p class="cta-context__title">{variant.name[props.locale()]}</p>
<p class="cta-context__subtitle">{variant.story[props.locale()]}</p>
</div>
);
}}
</Show>
<button type="button" class="cta" onClick={props.nextStep}>
{props.t().continue}
</button>
</div>
</div>
</Show>
);
}
@@ -0,0 +1,93 @@
import type { Accessor } from "solid-js";
import type { StepKind } from "@ben-to/builder/domain";
import type { DictionaryView } from "./dictionary-view";
export function BuilderStepsNav(props: {
readonly t: Accessor<DictionaryView>;
readonly progressStepIndex: Accessor<number>;
readonly progressPercent: Accessor<number>;
readonly canNavigateToStep: (step: StepKind) => boolean;
readonly goToBuilderStep: (step: StepKind) => void;
readonly currentStep: Accessor<StepKind>;
readonly recipeProgressCompact: Accessor<boolean>;
}) {
return (
<nav
class="builder-steps"
classList={{
"builder-steps--compact": props.currentStep() === "recipe" && props.recipeProgressCompact()
}}
aria-label={props.t().progress}
>
<ol class="builder-steps__list">
<li
class="builder-steps__step"
classList={{
"builder-steps__step--done": props.progressStepIndex() > 0,
"builder-steps__step--current": props.progressStepIndex() === 0
}}
aria-current={props.progressStepIndex() === 0 ? "step" : undefined}
>
<button
type="button"
class="builder-steps__badge"
aria-label={props.t().builderNavToBase}
onClick={() => props.goToBuilderStep("base")}
>
1
</button>
<span class="builder-steps__label">{props.t().builderStepBase}</span>
</li>
<li
class="builder-steps__step"
classList={{
"builder-steps__step--done": props.progressStepIndex() > 1,
"builder-steps__step--current": props.progressStepIndex() === 1
}}
aria-current={props.progressStepIndex() === 1 ? "step" : undefined}
>
<button
type="button"
class="builder-steps__badge"
disabled={!props.canNavigateToStep("variant")}
aria-label={
props.canNavigateToStep("variant")
? props.t().builderNavToVariant
: props.t().builderNavVariantDisabled
}
onClick={() => props.goToBuilderStep("variant")}
>
2
</button>
<span class="builder-steps__label">{props.t().builderStepVariant}</span>
</li>
<li
class="builder-steps__step"
classList={{
"builder-steps__step--done": props.progressStepIndex() > 2,
"builder-steps__step--current": props.progressStepIndex() === 2
}}
aria-current={props.progressStepIndex() === 2 ? "step" : undefined}
>
<button
type="button"
class="builder-steps__badge"
disabled={!props.canNavigateToStep("recipe")}
aria-label={
props.canNavigateToStep("recipe")
? props.t().builderNavToRecipe
: props.t().builderNavRecipeDisabled
}
onClick={() => props.goToBuilderStep("recipe")}
>
3
</button>
<span class="builder-steps__label">{props.t().builderStepRecipe}</span>
</li>
</ol>
<div class="builder-steps__track" aria-hidden="true">
<div class="builder-steps__fill" style={{ width: `${props.progressPercent()}%` }} />
</div>
</nav>
);
}
@@ -0,0 +1,89 @@
import { For, Show } from "solid-js";
import type { Accessor, Setter } from "solid-js";
import type { BaseOption, BentoSelection, VariantOption } from "@ben-to/builder/domain";
import { primaryBaseIdForVariant } from "@ben-to/builder/domain";
import type { LocaleCode } from "@ben-to/i18n/dictionary";
import type { DictionaryView } from "./dictionary-view";
import { SpriteTile } from "./SpriteTile";
import { publicAssetHref } from "./public-asset";
export function BuilderVariantStep(props: {
readonly t: Accessor<DictionaryView>;
readonly locale: Accessor<LocaleCode>;
readonly currentBase: Accessor<BaseOption | undefined>;
readonly availableVariants: Accessor<VariantOption[]>;
readonly selection: () => BentoSelection;
readonly setSelection: Setter<BentoSelection>;
readonly advanceAfterVariantPickDouble: () => void;
readonly onPickCardTouchEndForDouble: (
cardKey: string,
advance: () => void
) => (e: TouchEvent) => void;
}) {
return (
<section aria-label={props.t().selectVariant}>
<Show when={props.currentBase()}>
{(base) => (
<p class="builder-variant-base-reminder" role="status">
<span class="builder-variant-base-reminder__media" aria-hidden="true">
<Show when={base().coverImageUrl}>
<img
class="builder-variant-base-reminder__icon"
src={publicAssetHref(base().coverImageUrl)}
width="28"
height="28"
alt=""
decoding="async"
/>
</Show>
<Show when={!base().coverImageUrl && base().coverSprite}>
<SpriteTile
sprite={base().coverSprite!}
size={28}
class="builder-variant-base-reminder__sprite"
/>
</Show>
</span>
<span class="builder-variant-base-reminder__text">
{props.t().builderVariantBaseReminderTemplate.replace("{base}", base().name[props.locale()])}
</span>
</p>
)}
</Show>
<div class="cards">
<For each={props.availableVariants()}>
{(variant) => (
<button
type="button"
class={`card card--pick ${props.selection().variantId === variant.id ? "selected" : ""}`}
onClick={() =>
props.setSelection((prev) => ({
...prev,
variantId: variant.id,
baseId: primaryBaseIdForVariant(variant, prev.baseId)
}))
}
onDblClick={() => props.advanceAfterVariantPickDouble()}
onTouchEnd={props.onPickCardTouchEndForDouble(
`variant:${variant.id}`,
props.advanceAfterVariantPickDouble
)}
>
<Show when={variant.coverImageUrl}>
<img class="card__pick-icon" src={publicAssetHref(variant.coverImageUrl)} alt="" />
</Show>
<Show when={!variant.coverImageUrl && variant.coverSprite}>
<SpriteTile
sprite={variant.coverSprite!}
size={32}
class="card__sprite card__pick-sprite"
/>
</Show>
<span class="card__pick-name">{variant.name[props.locale()]}</span>
</button>
)}
</For>
</div>
</section>
);
}
@@ -0,0 +1,190 @@
import type { Accessor } from "solid-js";
import { For, Show } from "solid-js";
import type { StepKind } from "@ben-to/builder/domain";
import { computeRecipeSteps } from "@ben-to/builder/domain";
import type { LocaleCode } from "@ben-to/i18n/dictionary";
import type { DictionaryView } from "./dictionary-view";
import { fillRecipeTemplate } from "./recipe-template";
import { SpriteTile } from "./SpriteTile";
import { publicAssetHref } from "./public-asset";
import type { RecipeSheetHandle } from "./use-recipe-sheet";
type MergedRecipeView = ReturnType<typeof computeRecipeSteps>;
export function RecipeSheetSection(props: {
readonly currentStep: Accessor<StepKind>;
readonly locale: Accessor<LocaleCode>;
readonly t: Accessor<DictionaryView>;
readonly recipeCoverImageUrl: Accessor<string | null>;
readonly recipeDisplayTitle: Accessor<string>;
readonly mergedRecipe: Accessor<MergedRecipeView>;
readonly recipe: RecipeSheetHandle;
}) {
const r = props.recipe;
return (
<Show when={props.currentStep() === "recipe"}>
<div
class="recipe-scroll"
ref={(el) => {
r.bindRecipeScrollHost(el);
}}
onScroll={r.onRecipeScroll}
>
<section class="recipe-sheet" aria-labelledby="recipe-sheet-title">
<Show when={props.recipeCoverImageUrl()}>
<div class="recipe-sheet__hero">
<div class="recipe-sheet__hero-media">
<img
class="recipe-sheet__photo"
src={publicAssetHref(props.recipeCoverImageUrl())}
alt={props.recipeDisplayTitle() || props.t().recipe}
width="1200"
height="675"
decoding="async"
fetchpriority="high"
/>
</div>
</div>
</Show>
<h2 class="recipe-sheet__title" id="recipe-sheet-title">
{props.recipeDisplayTitle() || props.t().recipe}
</h2>
<div class="recipe-sheet__actions">
<button type="button" class="recipe-sheet__action" onClick={() => void r.shareRecipe()}>
{props.t().shareRecipe}
</button>
<button type="button" class="recipe-sheet__action" onClick={r.printRecipe}>
{props.t().printRecipe}
</button>
</div>
<Show when={r.shareFeedback()}>
<p class="recipe-sheet__share-feedback" role="status" aria-live="polite">
{r.shareFeedback()}
</p>
</Show>
<div class="servings-stepper" role="group" aria-label={props.t().servings}>
<button
type="button"
class="servings-stepper__btn"
aria-label={props.t().decreaseServings}
disabled={r.servings() <= 1}
onClick={() => r.bumpServings(-1)}
>
</button>
<label class="servings-stepper__field">
<span class="visually-hidden">{props.t().servings}</span>
<input
class="servings-stepper__input"
type="text"
inputMode="numeric"
autocomplete="off"
maxLength={2}
value={r.servingsFieldValue()}
onFocus={r.onServingsFocus}
onInput={r.onServingsInput}
onBlur={r.onServingsBlur}
/>
</label>
<button
type="button"
class="servings-stepper__btn"
aria-label={props.t().increaseServings}
disabled={r.servings() >= 99}
onClick={() => r.bumpServings(1)}
>
+
</button>
</div>
<p class="recipe-sheet__print-servings" aria-hidden="true">
{props.t().servings}: {r.servings()}
</p>
<h3 class="recipe-sheet__subtitle">{props.t().ingredients}</h3>
<ul class="recipe-sheet__ingredient-list">
<For each={r.displayedIngredients()}>
{(ingredient) => (
<li class="recipe-sheet__ingredient-item">
<button
type="button"
class="recipe-sheet__ingredient-row"
classList={{
"recipe-sheet__ingredient-row--done": r.checkedIngredientIds().has(ingredient.id)
}}
aria-pressed={r.checkedIngredientIds().has(ingredient.id)}
title={[
fillRecipeTemplate(props.t().recipeIngredientCheckboxLabel, {
name: ingredient.name[props.locale()]
}),
String(ingredient.info[props.locale()] ?? "").trim()
]
.filter(Boolean)
.join(" — ")}
onClick={() => r.toggleIngredientChecklist(ingredient.id)}
>
<span class="recipe-sheet__ingredient-media" aria-hidden="true">
{ingredient.coverSprite ? (
<SpriteTile
sprite={ingredient.coverSprite}
size={32}
class="recipe-sheet__ingredient-sprite"
/>
) : (
<img
class="recipe-sheet__ingredient-fallback"
src={publicAssetHref("/ingredient-unknown.png")}
width="32"
height="32"
alt=""
decoding="async"
/>
)}
</span>
<span class="recipe-sheet__ingredient-text">
<strong>
{ingredient.quantity}
{ingredient.unit ? ` ${ingredient.unit}` : ""}
</strong>{" "}
{ingredient.name[props.locale()]}
</span>
<span class="recipe-sheet__ingredient-row-check" aria-hidden="true">
<span class="recipe-sheet__ingredient-mark__glyph"></span>
</span>
</button>
</li>
)}
</For>
</ul>
<h3 class="recipe-sheet__subtitle">{props.t().steps}</h3>
<ol class="recipe-sheet__steps-list">
<For each={props.mergedRecipe().steps}>
{(step, index) => (
<li class="recipe-sheet__step-item">
<button
type="button"
class="recipe-sheet__step-btn"
classList={{
"recipe-sheet__step-btn--done": r.doneStepIds().has(step.id)
}}
aria-pressed={r.doneStepIds().has(step.id)}
title={fillRecipeTemplate(props.t().recipeStepToggleLabel, {
n: index() + 1
})}
onClick={() => r.toggleStepChecklist(step.id)}
>
{step.text[props.locale()]}
</button>
</li>
)}
</For>
</ol>
</section>
</div>
</Show>
);
}
@@ -0,0 +1,46 @@
import type { CoverSprite } from "@ben-to/builder/domain";
import { publicAssetHref } from "./public-asset";
/**
* Tuile dune planche de sprites via `<img>` + décalage (plus fiable que `background-image` avec le runtime Solid).
*/
export const SpriteTile = (props: Readonly<{ sprite: CoverSprite; size?: number; class?: string }>) => {
const size = () => props.size ?? 32;
const scale = () => size() / props.sprite.cell;
const imgW = () => Math.round(props.sprite.sheetWidth * scale());
const imgH = () => Math.round(props.sprite.sheetHeight * scale());
const offsetLeft = () => Math.round(-props.sprite.col * size());
const offsetTop = () => Math.round(-props.sprite.row * size());
return (
<span
class={props.class}
aria-hidden="true"
style={{
display: "block",
width: `${size()}px`,
height: `${size()}px`,
overflow: "hidden",
position: "relative",
"flex-shrink": "0"
}}
>
<img
src={publicAssetHref(props.sprite.sheet)}
alt=""
width={imgW()}
height={imgH()}
draggable={false}
decoding="async"
style={{
position: "absolute",
left: `${offsetLeft()}px`,
top: `${offsetTop()}px`,
"max-width": "none",
width: `${imgW()}px`,
height: `${imgH()}px`,
"image-rendering": "pixelated"
}}
/>
</span>
);
};
@@ -0,0 +1 @@
export type FlavorFilter = "all" | "savory" | "sweet";
@@ -0,0 +1,7 @@
import type { StepKind } from "@ben-to/builder/domain";
/** Ordre du wizard builder (base → variante → recette). */
export const BUILDER_STEP_ORDER: StepKind[] = ["base", "variant", "recipe"];
export const PICK_DOUBLE_TAP_MS = 380;
export const PICK_DOUBLE_TAP_PX = 48;
@@ -0,0 +1,4 @@
import { getDictionary } from "@ben-to/i18n/dictionary";
/** Type des chaînes UI du builder (évite de dupliquer `ReturnType` dans chaque sous-composant). */
export type DictionaryView = ReturnType<typeof getDictionary>;
@@ -0,0 +1,8 @@
export const safeDecodePathSegment = (value: string | undefined): string => {
if (!value) return "";
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { safeDecodePathSegment } from "./path-utils";
describe("safeDecodePathSegment", () => {
it("retourne une chaîne vide si absence de valeur", () => {
expect(safeDecodePathSegment(undefined)).toBe("");
});
it("décode un segment dURL correctement encodé", () => {
expect(safeDecodePathSegment("caf%C3%A9")).toBe("café");
});
it("retourne la valeur brute si le décodage échoue", () => {
expect(safeDecodePathSegment("%E0%A4%A")).toBe("%E0%A4%A");
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { publicAssetHref } from "./public-asset";
/**
* Test d'intégration aligné sur le scénario Gherkin `@int-public-asset-base-path` :
* vérifie que tous les chemins d'assets publics référencés en dur dans l'app
* (logo, tour d'accueil, fallback ingrédient, images catalogue) sont résolus
* en respectant `import.meta.env.BASE_URL` (Vite `base`).
*/
const withBaseUrl = (baseUrl: string | undefined) => {
// @ts-expect-error: Vitest laisse `import.meta.env` mutable pour les tests.
import.meta.env.BASE_URL = baseUrl;
};
describe("publicAssetHref (Gherkin @int-public-asset-base-path)", () => {
/** @int-public-asset-base-path */
it("préfixe tous les chemins absolus par BASE_URL quand Vite expose un base path", () => {
const previous = import.meta.env.BASE_URL;
withBaseUrl("/ben-to/");
try {
// Logo (RootLayout)
expect(publicAssetHref("/branding/onigiri-logo.png")).toBe("/ben-to/branding/onigiri-logo.png");
// Tour d'accueil (WelcomeTourModal)
expect(publicAssetHref("/tour/flow-builder.gif")).toBe("/ben-to/tour/flow-builder.gif");
expect(publicAssetHref("/tour/recipe-sheet.gif")).toBe("/ben-to/tour/recipe-sheet.gif");
// Fallback ingrédient (RecipeSheetSection)
expect(publicAssetHref("/ingredient-unknown.png")).toBe("/ben-to/ingredient-unknown.png");
// Images issues du catalogue (cover base / variante + recipeCover)
expect(publicAssetHref("/recipe-covers/onigiri.jpg")).toBe("/ben-to/recipe-covers/onigiri.jpg");
// Chemins déjà relatifs : on les promeut puis on applique le base
expect(publicAssetHref("recipe-covers/onigiri.jpg")).toBe("/ben-to/recipe-covers/onigiri.jpg");
} finally {
withBaseUrl(previous);
}
});
/** @int-public-asset-base-path */
it("laisse les chemins intacts quand BASE_URL est la racine", () => {
const previous = import.meta.env.BASE_URL;
withBaseUrl("/");
try {
expect(publicAssetHref("/branding/onigiri-logo.png")).toBe("/branding/onigiri-logo.png");
expect(publicAssetHref("/ingredient-unknown.png")).toBe("/ingredient-unknown.png");
} finally {
withBaseUrl(previous);
}
});
});
@@ -0,0 +1,9 @@
/** Préfixe Vite `base` pour les fichiers dans `public/`. */
export const publicAssetHref = (path: string | null | undefined): string => {
if (!path) return "";
if (path.startsWith("http://") || path.startsWith("https://")) return path;
const base = import.meta.env.BASE_URL ?? "/";
const normalized = path.startsWith("/") ? path : `/${path}`;
if (base === "/" || base === "") return normalized;
return `${base.replace(/\/$/, "")}${normalized}`;
};
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { publicAssetHref } from "./public-asset";
describe("publicAssetHref", () => {
it("laisse inchangées les URLs absolues http(s)", () => {
expect(publicAssetHref("https://exemple.test/a.png")).toBe("https://exemple.test/a.png");
expect(publicAssetHref("http://localhost/a.png")).toBe("http://localhost/a.png");
});
it("ajoute un slash initial aux chemins relatifs", () => {
expect(publicAssetHref("sprite.png")).toMatch(/^\/?sprite\.png$/);
});
it("conserve les chemins déjà absolus côté site", () => {
expect(publicAssetHref("/icons/a.png")).toMatch(/^\/icons\/a\.png$/);
});
});
@@ -0,0 +1,93 @@
import { Capacitor } from "@capacitor/core";
import { Share } from "@capacitor/share";
import { trackEvent } from "../../infra/analytics";
export type RecipeShareDeps = Readonly<{
title: string;
body: string;
url: string;
variantIdForTrack: string;
strings: { shareCopied: string; shareCopyFailed: string };
setShareFeedback: (msg: string | null) => void;
clearShareFeedbackTimer: () => void;
flashShareFeedback: (message: string) => void;
}>;
/**
* Partage recette : Share natif (Capacitor / Web Share API) avec repli presse-papiers.
* Effets de bord analytics conservés ici pour garder lUI découplée du détail des plateformes.
*/
export async function shareRecipeWithFallback(deps: RecipeShareDeps): Promise<void> {
const trackShareSuccess = () => {
trackEvent("recipe", "share", deps.variantIdForTrack);
};
if (Capacitor.isNativePlatform()) {
try {
await Share.share({
title: deps.title,
text: deps.body,
url: deps.url || undefined,
dialogTitle: deps.title
});
deps.clearShareFeedbackTimer();
deps.setShareFeedback(null);
trackShareSuccess();
return;
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") return;
const msg =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "";
if (/cancel/i.test(msg) || msg.toLowerCase().includes("user did not share")) return;
}
}
const shareWithNative = async () => {
const basePayload: ShareData = { title: deps.title, text: deps.body };
if (deps.url) {
const withUrl: ShareData = { ...basePayload, url: deps.url };
if (typeof navigator.canShare !== "function" || navigator.canShare(withUrl)) {
await navigator.share(withUrl);
return;
}
}
await navigator.share(basePayload);
};
if (typeof navigator.share === "function") {
try {
await shareWithNative();
deps.clearShareFeedbackTimer();
deps.setShareFeedback(null);
trackShareSuccess();
return;
} catch (error: unknown) {
const name =
error instanceof Error || error instanceof DOMException
? error.name
: typeof error === "object" &&
error !== null &&
"name" in error &&
typeof (error as { name: unknown }).name === "string"
? (error as { name: string }).name
: "";
if (name === "AbortError") return;
}
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(deps.body);
deps.flashShareFeedback(deps.strings.shareCopied);
trackShareSuccess();
} else {
deps.flashShareFeedback(deps.strings.shareCopyFailed);
}
} catch {
deps.flashShareFeedback(deps.strings.shareCopyFailed);
}
}
@@ -0,0 +1,7 @@
export const fillRecipeTemplate = (
template: string,
vars: { name?: string; n?: number }
): string =>
template
.replace(/\{name\}/g, vars.name ?? "")
.replace(/\{n\}/g, vars.n !== undefined ? String(vars.n) : "");
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { fillRecipeTemplate } from "./recipe-template";
describe("fillRecipeTemplate", () => {
it("remplace name et n", () => {
expect(fillRecipeTemplate("{name} — #{n}", { name: "Riz", n: 2 })).toBe("Riz — #2");
});
it("tolère des variables absentes", () => {
expect(fillRecipeTemplate("{name}", {})).toBe("");
});
});
@@ -0,0 +1,83 @@
import type { Accessor } from "solid-js";
import { createMemo } from "solid-js";
import type { BaseOption, BentoSelection } from "@ben-to/builder/domain";
import {
baseFlavorGroupKey,
baseIdsInSameFlavorGroup,
computeRecipeSteps
} from "@ben-to/builder/domain";
import type { RecipesIndex } from "@ben-to/recipes/catalog";
import type { LocaleCode } from "@ben-to/i18n/dictionary";
import type { FlavorFilter } from "./builder-flow-types";
export function useBuilderCatalog(props: {
readonly recipesIndex: Accessor<RecipesIndex | undefined>;
readonly selection: Accessor<BentoSelection>;
readonly flavorFilter: Accessor<FlavorFilter>;
readonly locale: Accessor<LocaleCode>;
}) {
const filteredBases = createMemo(() => {
const index = props.recipesIndex();
if (!index) return [];
if (props.flavorFilter() !== "all") {
return index.bases.filter((base) => base.flavor === props.flavorFilter());
}
const seen = new Set<string>();
const out: BaseOption[] = [];
for (const base of index.bases) {
const key = baseFlavorGroupKey(base);
if (seen.has(key)) continue;
seen.add(key);
out.push(base);
}
return out;
});
const currentBase = createMemo(() =>
props.recipesIndex()?.bases.find((base) => base.id === props.selection().baseId)
);
const availableVariants = createMemo(() => {
const index = props.recipesIndex();
if (!index) return [];
const baseId = props.selection().baseId;
if (!baseId) {
return [...(index.variants ?? [])];
}
const groupIds = baseIdsInSameFlavorGroup(index.bases, baseId);
return (index.variants ?? []).filter((variant) => variant.baseIds.some((id) => groupIds.includes(id)));
});
const currentVariant = createMemo(() =>
(props.recipesIndex()?.variants ?? []).find((variant) => variant.id === props.selection().variantId)
);
const mergedRecipe = createMemo(() =>
computeRecipeSteps({
base: currentBase() ?? null,
variant: currentVariant() ?? null
})
);
const recipeCoverImageUrl = createMemo(() => currentVariant()?.recipeCoverImageUrl ?? null);
const recipeDisplayTitle = createMemo(() => {
const base = currentBase();
const variant = currentVariant();
const loc = props.locale();
if (base && variant) return `${base.name[loc]}${variant.name[loc]}`;
if (variant) return variant.name[loc];
if (base) return base.name[loc];
return "";
});
return {
filteredBases,
currentBase,
availableVariants,
currentVariant,
mergedRecipe,
recipeCoverImageUrl,
recipeDisplayTitle
};
}
@@ -0,0 +1,185 @@
import { useLocation, useNavigate, useParams } from "@solidjs/router";
import type { Accessor, Setter } from "solid-js";
import { createEffect, createMemo, createSignal, onMount } from "solid-js";
import type { BentoSelection, StepKind } from "@ben-to/builder/domain";
import { createInitialSelection } from "@ben-to/builder/domain";
import type { RecipesIndex } from "@ben-to/recipes/catalog";
import { loadSelection, saveSelection } from "../../infra/persistence";
import { safeDecodePathSegment } from "./path-utils";
import { BUILDER_STEP_ORDER, PICK_DOUBLE_TAP_MS, PICK_DOUBLE_TAP_PX } from "./constants";
import type { FlavorFilter } from "./builder-flow-types";
export function useBuilderSteps(props: {
readonly selection: Accessor<BentoSelection>;
readonly setSelection: Setter<BentoSelection>;
readonly recipesIndex: Accessor<RecipesIndex | undefined>;
}) {
const params = useParams<{ baseId?: string; variantId?: string }>();
const navigate = useNavigate();
const location = useLocation();
const [currentStep, setCurrentStep] = createSignal<StepKind>("base");
const [flavorFilter, setFlavorFilter] = createSignal<FlavorFilter>("all");
const canContinue = createMemo(() => {
if (currentStep() === "base") return Boolean(props.selection().baseId);
if (currentStep() === "variant") return Boolean(props.selection().variantId);
return false;
});
const nextStep = () => {
const index = BUILDER_STEP_ORDER.indexOf(currentStep());
const next = BUILDER_STEP_ORDER[index + 1];
if (next) setCurrentStep(next);
};
const advanceAfterBasePickDouble = () => {
if (currentStep() !== "base") return;
if (!props.selection().baseId) return;
setCurrentStep("variant");
};
const advanceAfterVariantPickDouble = () => {
if (currentStep() !== "variant") return;
if (!props.selection().variantId) return;
setCurrentStep("recipe");
};
let lastPickTouch: { key: string; t: number; x: number; y: number } | null = null;
const onPickCardTouchEndForDouble =
(cardKey: string, advance: () => void) => (e: TouchEvent) => {
if (e.changedTouches.length !== 1) return;
const touch = e.changedTouches[0];
const now = Date.now();
const x = touch.clientX;
const y = touch.clientY;
const prev = lastPickTouch;
if (
prev &&
prev.key === cardKey &&
now - prev.t <= PICK_DOUBLE_TAP_MS &&
Math.hypot(x - prev.x, y - prev.y) <= PICK_DOUBLE_TAP_PX
) {
lastPickTouch = null;
advance();
return;
}
lastPickTouch = { key: cardKey, t: now, x, y };
};
const progressStepIndex = createMemo(() => BUILDER_STEP_ORDER.indexOf(currentStep()));
const progressPercent = createMemo(() => ((progressStepIndex() + 1) / BUILDER_STEP_ORDER.length) * 100);
const canNavigateToStep = (step: StepKind) => {
if (step === "base") return true;
if (step === "variant") return Boolean(props.selection().baseId);
return Boolean(props.selection().baseId && props.selection().variantId);
};
const goToBuilderStep = (step: StepKind) => {
if (!canNavigateToStep(step)) return;
setCurrentStep(step);
};
const goHomeWizard = () => {
setCurrentStep("base");
props.setSelection(createInitialSelection());
setFlavorFilter("all");
};
onMount(() => {
if (location.pathname.startsWith("/r/")) return;
const stored = loadSelection();
if (stored) props.setSelection(stored);
});
createEffect(() => {
const rawBase = params.baseId;
const rawVariant = params.variantId;
if (!rawBase || !rawVariant) return;
const index = props.recipesIndex();
if (!index) return;
const baseId = safeDecodePathSegment(rawBase);
const variantId = safeDecodePathSegment(rawVariant);
if (!baseId || !variantId) {
navigate("/", { replace: true });
return;
}
const base = index.bases.find((item) => item.id === baseId);
const variant = index.variants.find((item) => item.id === variantId);
if (!base || !variant || !variant.baseIds.includes(base.id)) {
navigate("/", { replace: true });
return;
}
const sel = props.selection();
if (sel.baseId === baseId && sel.variantId === variantId && currentStep() === "recipe") return;
setFlavorFilter("all");
props.setSelection({ baseId, variantId });
setCurrentStep("recipe");
});
createEffect(() => {
const index = props.recipesIndex();
if (!index) return;
const step = currentStep();
const path = location.pathname;
const { baseId, variantId } = props.selection();
if (step === "recipe" && baseId && variantId) {
const target = `/r/${encodeURIComponent(baseId)}/${encodeURIComponent(variantId)}`;
if (path !== target) {
navigate(target, { replace: false });
}
return;
}
if (step !== "recipe" && path.startsWith("/r/")) {
navigate("/", { replace: true });
}
});
createEffect(() => {
saveSelection(props.selection());
});
createEffect(() => {
const index = props.recipesIndex();
if (!index) return;
const baseId = props.selection().baseId;
if (!baseId) return;
const base = index.bases.find((item) => item.id === baseId);
if (!base) {
props.setSelection((prev) => ({ ...prev, baseId: null, variantId: null }));
return;
}
if (flavorFilter() !== "all" && base.flavor !== flavorFilter()) {
props.setSelection((prev) => ({ ...prev, baseId: null, variantId: null }));
}
});
return {
params,
navigate,
location,
currentStep,
setCurrentStep,
flavorFilter,
setFlavorFilter,
canContinue,
nextStep,
advanceAfterBasePickDouble,
advanceAfterVariantPickDouble,
onPickCardTouchEndForDouble,
progressStepIndex,
progressPercent,
canNavigateToStep,
goToBuilderStep,
goHomeWizard
};
}
@@ -0,0 +1,240 @@
import type { Accessor } from "solid-js";
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import type { BentoSelection, StepKind } from "@ben-to/builder/domain";
import { computeRecipeSteps } from "@ben-to/builder/domain";
import { scaleQuantities } from "@ben-to/recipes/domain";
import type { LocaleCode } from "@ben-to/i18n/dictionary";
import type { DictionaryView } from "./dictionary-view";
import { loadRecipeChecklist, saveRecipeChecklist } from "../../infra/recipe-checklist-persistence";
import { trackEvent } from "../../infra/analytics";
import { shareRecipeWithFallback } from "./recipe-share";
type MergedRecipeView = ReturnType<typeof computeRecipeSteps>;
export function useRecipeSheet(props: {
readonly selection: Accessor<BentoSelection>;
readonly currentStep: Accessor<StepKind>;
readonly mergedRecipe: Accessor<MergedRecipeView>;
readonly recipeDisplayTitle: Accessor<string>;
readonly locale: Accessor<LocaleCode>;
readonly t: Accessor<DictionaryView>;
}) {
const [servings, setServings] = createSignal(1);
const [servingsDraft, setServingsDraft] = createSignal<string | null>(null);
const [recipeProgressCompact, setRecipeProgressCompact] = createSignal(false);
const [shareFeedback, setShareFeedback] = createSignal<string | null>(null);
const [checkedIngredientIds, setCheckedIngredientIds] = createSignal(new Set<string>());
const [doneStepIds, setDoneStepIds] = createSignal(new Set<string>());
const baseServings = createMemo(() => Math.max(1, props.mergedRecipe().servings));
const displayedIngredients = createMemo(() => {
const quantityFactor = servings() / baseServings();
return props.mergedRecipe().ingredients.map((ingredient) => ({
...ingredient,
quantity: scaleQuantities(ingredient.quantity, quantityFactor)
}));
});
let recipeScrollHost: HTMLDivElement | undefined;
let shareFeedbackTimer: ReturnType<typeof setTimeout> | undefined;
const clearShareFeedbackTimer = () => {
if (shareFeedbackTimer !== undefined) clearTimeout(shareFeedbackTimer);
};
onCleanup(() => {
clearShareFeedbackTimer();
});
let lastRecipeKey = "";
createEffect(() => {
const selection = props.selection();
const recipeKey = `${selection.baseId ?? ""}:${selection.variantId ?? ""}:${baseServings()}`;
if (recipeKey !== lastRecipeKey) {
lastRecipeKey = recipeKey;
setServings(baseServings());
setServingsDraft(null);
}
});
createEffect(() => {
const variantId = props.selection().variantId;
const recipe = props.mergedRecipe();
const validIngredientIds = new Set(recipe.ingredients.map((item) => item.id));
const validStepIds = new Set(recipe.steps.map((item) => item.id));
if (!variantId) {
setCheckedIngredientIds(new Set<string>());
setDoneStepIds(new Set<string>());
return;
}
const stored = loadRecipeChecklist(variantId);
const ingredients = new Set(stored.ingredientIds.filter((id) => validIngredientIds.has(id)));
const steps = new Set(stored.stepIds.filter((id) => validStepIds.has(id)));
setCheckedIngredientIds(ingredients);
setDoneStepIds(steps);
});
const toggleIngredientChecklist = (id: string) => {
const variantId = props.selection().variantId;
if (!variantId) return;
setCheckedIngredientIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
saveRecipeChecklist(variantId, {
ingredientIds: [...next],
stepIds: [...doneStepIds()]
});
return next;
});
};
const toggleStepChecklist = (id: string) => {
const variantId = props.selection().variantId;
if (!variantId) return;
setDoneStepIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
saveRecipeChecklist(variantId, {
ingredientIds: [...checkedIngredientIds()],
stepIds: [...next]
});
return next;
});
};
createEffect(() => {
if (props.currentStep() !== "recipe") {
setRecipeProgressCompact(false);
return;
}
queueMicrotask(() => {
recipeScrollHost?.scrollTo(0, 0);
setRecipeProgressCompact(false);
});
});
const onRecipeScroll: EventListener = (event) => {
const el = event.currentTarget as HTMLDivElement;
setRecipeProgressCompact(el.scrollTop > 14);
};
const servingsFieldValue = createMemo(() => servingsDraft() ?? String(servings()));
const bumpServings = (delta: number) => {
setServingsDraft(null);
setServings((n) => Math.min(99, Math.max(1, n + delta)));
};
const onServingsFocus = () => {
setServingsDraft(String(servings()));
};
const onServingsInput = (event: InputEvent & { currentTarget: HTMLInputElement }) => {
const digits = event.currentTarget.value.replace(/\D/g, "").slice(0, 2);
setServingsDraft(digits);
};
const onServingsBlur = () => {
const raw = servingsDraft();
setServingsDraft(null);
if (raw === null || raw === "") {
setServings(baseServings());
return;
}
const parsed = Number.parseInt(raw, 10);
setServings(Number.isFinite(parsed) ? Math.min(99, Math.max(1, parsed)) : baseServings());
};
const getRecipeShareUrl = () => {
if (typeof window === "undefined") return "";
const { baseId, variantId } = props.selection();
if (!baseId || !variantId) return "";
const path = `/r/${encodeURIComponent(baseId)}/${encodeURIComponent(variantId)}`;
return new URL(path, window.location.origin).href;
};
const buildRecipeShareText = () => {
const loc = props.locale();
const dict = props.t();
const title = props.recipeDisplayTitle() || dict.recipe;
const lines: string[] = [title, "", `${dict.servings}: ${servings()}`, "", dict.ingredients];
for (const ing of displayedIngredients()) {
lines.push(`${ing.name[loc]}${ing.quantity} ${ing.unit}`);
}
lines.push("", dict.steps);
props.mergedRecipe().steps.forEach((step, index) => {
lines.push(`${index + 1}. ${step.text[loc]}`);
});
const url = getRecipeShareUrl();
if (url) {
lines.push("", dict.recipeShareUrlIntro, url);
}
return lines.join("\n");
};
const flashShareFeedback = (message: string) => {
clearShareFeedbackTimer();
setShareFeedback(message);
shareFeedbackTimer = setTimeout(() => {
setShareFeedback(null);
shareFeedbackTimer = undefined;
}, 4500);
};
const shareRecipe = async () => {
const dict = props.t();
await shareRecipeWithFallback({
title: props.recipeDisplayTitle() || dict.recipe,
body: buildRecipeShareText(),
url: getRecipeShareUrl(),
variantIdForTrack: props.selection().variantId ?? "none",
strings: { shareCopied: dict.shareCopied, shareCopyFailed: dict.shareCopyFailed },
setShareFeedback,
clearShareFeedbackTimer,
flashShareFeedback
});
};
const printRecipe = () => {
window.print();
trackEvent("recipe", "print", props.selection().variantId ?? "none");
};
const resetServingsForGoHome = () => {
setServings(1);
setServingsDraft(null);
};
return {
servings,
servingsDraft,
servingsFieldValue,
bumpServings,
onServingsFocus,
onServingsInput,
onServingsBlur,
checkedIngredientIds,
doneStepIds,
toggleIngredientChecklist,
toggleStepChecklist,
recipeProgressCompact,
shareFeedback,
onRecipeScroll,
shareRecipe,
printRecipe,
resetServingsForGoHome,
/** Callback ref Solid : assigner au conteneur scroll de la feuille recette. */
bindRecipeScrollHost: (el: HTMLDivElement) => {
recipeScrollHost = el;
},
displayedIngredients
};
}
export type RecipeSheetHandle = ReturnType<typeof useRecipeSheet>;
+10
View File
@@ -0,0 +1,10 @@
import { createSignal } from "solid-js";
const [builderResetNonce, setBuilderResetNonce] = createSignal(0);
/** Clic sur le logo alors que lURL est déjà « / » : réinitialiser le parcours sans recharger. */
export const bumpBuilderResetBus = () => setBuilderResetNonce((n) => n + 1);
export const readBuilderResetNonce = () => builderResetNonce();
export const clearBuilderResetBus = () => setBuilderResetNonce(0);
+46
View File
@@ -0,0 +1,46 @@
import { A } from "@solidjs/router";
import { For, createMemo } from "solid-js";
import { getDictionary } from "@ben-to/i18n/dictionary";
import { useAppLocale } from "../app-locale-context";
export type StaticInfoKind = "about" | "terms" | "legal" | "privacy" | "notFound";
type StaticDocContent = Readonly<{
title: string;
paragraphs: readonly string[];
}>;
export function StaticInfoPage(props: { kind: StaticInfoKind }) {
const { locale } = useAppLocale();
const t = createMemo(() => getDictionary(locale()));
const doc = createMemo((): StaticDocContent => {
const d = t();
switch (props.kind) {
case "about":
return { title: d.aboutTitle, paragraphs: d.aboutParagraphs };
case "terms":
return { title: d.termsTitle, paragraphs: d.termsParagraphs };
case "legal":
return { title: d.legalTitle, paragraphs: d.legalParagraphs };
case "privacy":
return { title: d.privacyTitle, paragraphs: d.privacyParagraphs };
default:
return { title: d.notFoundTitle, paragraphs: d.notFoundParagraphs };
}
});
return (
<main class="app-shell static-doc">
<nav class="static-doc__nav" aria-label={t().backToBuilder}>
<A href="/" class="static-doc__back">
{t().backToBuilder}
</A>
</nav>
<article class="static-doc__article">
<h1 class="static-doc__heading">{doc().title}</h1>
<For each={[...doc().paragraphs]}>{(paragraph) => <p class="static-doc__p">{paragraph}</p>}</For>
</article>
</main>
);
}
+17
View File
@@ -0,0 +1,17 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** URL de base Matomo (répertoire contenant matomo.js), ex. https://analytics.example.com/ */
readonly VITE_MATOMO_URL?: string;
/** Identifiant du site Matomo */
readonly VITE_MATOMO_SITE_ID?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
/** File dattente Matomo avant chargement de matomo.js */
interface Window {
_paq?: unknown[][];
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { buildCspHeader, buildServeJson, injectCspMetaIntoHtml, toOrigin } from "./vite-plugin-csp";
/** Petite fabrique de HTML représentative de l'index de l'app. */
const sampleIndexHtml = `<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bento</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
`;
describe("CSP HTML injection (Gherkin @int-csp-html-injection)", () => {
it("le HTML de production est instrumenté avec une meta CSP durcie", () => {
const csp = buildCspHeader("production", null);
const out = injectCspMetaIntoHtml(sampleIndexHtml, csp);
// 1) Présence de la balise meta (regex non gourmande sur la valeur).
expect(out).toMatch(/<meta\s+http-equiv="Content-Security-Policy"\s+content="[^"]+"/);
// 2) Vérification du contenu de la CSP via les directives clés.
const cspValue = out.match(/content="([^"]+)"/)?.[1] ?? "";
expect(cspValue).toContain("default-src 'self'");
expect(cspValue).toContain("frame-ancestors 'none'");
expect(cspValue).toContain("object-src 'none'");
// En production, aucune directive n'autorise unsafe-eval (HMR Vite ne sert qu'en dev).
expect(out).not.toContain("unsafe-eval");
});
});
describe("CSP adapts to Matomo origin (Gherkin @int-csp-matomo-origin)", () => {
it("avec VITE_MATOMO_URL=https://analytics.example.com/, l'origine est ajoutée aux directives externes", () => {
const origin = toOrigin("https://analytics.example.com/");
expect(origin).toBe("https://analytics.example.com");
const csp = buildCspHeader("production", origin);
expect(csp).toMatch(/script-src 'self' 'https:\/\/analytics\.example\.com'/);
expect(csp).toMatch(/connect-src 'self' 'https:\/\/analytics\.example\.com'/);
expect(csp).toMatch(/img-src 'self' 'https:\/\/analytics\.example\.com'/);
// default-src ne s'élargit pas avec Matomo.
expect(csp).toMatch(/default-src 'self'/);
});
});
describe("serve.json headers (Gherkin @int-csp-serve-json)", () => {
it("déclare la CSP sur les HTML et les en-têtes statiques sur tout le contenu", () => {
const sj = buildServeJson(null);
const htmlRule = sj.headers.find((r) => r.source === "**/*.html");
expect(htmlRule).toBeDefined();
const byKey = (key: string) => htmlRule!.headers.find((h) => h.key === key);
expect(byKey("Content-Security-Policy")?.value).toBeDefined();
expect(byKey("X-Content-Type-Options")?.value).toBe("nosniff");
expect(byKey("X-Frame-Options")?.value).toBe("DENY");
expect(byKey("Referrer-Policy")?.value).toBe("strict-origin-when-cross-origin");
const pp = byKey("Permissions-Policy")?.value ?? "";
expect(pp).toContain("camera=()");
expect(pp).toContain("microphone=()");
expect(pp).toContain("geolocation=()");
});
});
+249
View File
@@ -0,0 +1,249 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Connect } from "vite";
import type { Plugin, ResolvedConfig } from "vite";
/** Mode effectif de la construction Vite (sert à choisir la politique CSP). */
export type CspMode = "development" | "production";
/** Options publiques du plugin. */
export type CspPluginOptions = Readonly<{
/**
* Origine Matomo à autoriser (sans slash final). `null` = aucune origine externe
* (Matomo non configuré via `VITE_MATOMO_URL`).
*/
readonly matomoOrigin: string | null;
}>;
/** Une règle CSP (directive + sources). */
export type CspDirective = Readonly<{
readonly name: string;
readonly sources: readonly string[];
}>;
/** Helpers exportés (testés unitairement) : construction de la chaîne CSP. */
/**
* Source `'self'` et tokens spéciaux. Les sources `none`, `self`, `unsafe-inline`,
* `unsafe-eval`, `strict-dynamic` etc. doivent être quotés **uniquement** quand
* ils sont entre quotes, ce qui est notre cas.
*/
const sourceNeedsQuoting = true;
const quote = (s: string): string => (sourceNeedsQuoting ? `'${s}'` : s);
/** Joint deux sources CSP avec un espace. */
const joinSources = (sources: readonly string[]): string => sources.map(quote).join(" ");
/**
* Sérialise une liste de directives CSP en une chaîne `Content-Security-Policy`.
* Chaque directive devient `name source1 source2 …` séparée par `; `.
*/
export const serializeCsp = (directives: readonly CspDirective[]): string =>
directives.map((d) => `${d.name} ${joinSources(d.sources)}`).join("; ");
/** Enlève un slash final d'une origine. */
const stripTrailingSlash = (s: string): string => s.replace(/\/+$/, "");
/** Normalise une URL en origine (`https://host[:port]`). Renvoie `null` si invalide. */
export const toOrigin = (raw: string | null | undefined): string | null => {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
if (!trimmed) return null;
try {
const u = new URL(trimmed);
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
return stripTrailingSlash(`${u.protocol}//${u.host}`);
} catch {
return null;
}
};
/**
* Construit la liste de directives CSP pour le mode donné.
* Sources autorisées :
* - `'self'` partout
* - Origine Matomo (si fournie) sur `script-src`, `connect-src`, `img-src`
* - Dev uniquement : `'unsafe-inline'`, `'unsafe-eval'` (script & style), `ws:`
* / `wss:` (connect-src pour HMR Vite)
*/
export const buildCspDirectives = (
mode: CspMode,
matomoOrigin: string | null
): readonly CspDirective[] => {
const matomo = matomoOrigin ? [matomoOrigin] : [];
const scriptSources: string[] = ["self"];
const styleSources: string[] = ["self"];
const connectSources: string[] = ["self"];
if (mode === "development") {
scriptSources.push("unsafe-inline", "unsafe-eval");
connectSources.push("ws:", "wss:");
}
// `'unsafe-inline'` sur `style-src` est **toujours** requis : Solid pose des styles
// inline (`style={{...}}`) sur des nœuds (cf. `apps/web/src/ui/SpriteTile.tsx`).
// L'attribut `style="..."` est restreint par `style-src-attr` (fallback `style-src`).
styleSources.push("unsafe-inline");
if (matomo.length) {
scriptSources.push(...matomo);
connectSources.push(...matomo);
}
return [
{ name: "default-src", sources: ["self"] },
{ name: "script-src", sources: scriptSources },
{ name: "style-src", sources: styleSources },
{ name: "img-src", sources: ["self", ...matomo] },
{ name: "font-src", sources: ["self"] },
{ name: "connect-src", sources: connectSources },
{ name: "media-src", sources: ["self"] },
{ name: "object-src", sources: ["none"] },
{ name: "base-uri", sources: ["self"] },
{ name: "form-action", sources: ["self"] },
{ name: "frame-ancestors", sources: ["none"] },
{ name: "frame-src", sources: ["none"] }
];
};
/** Construit la chaîne `Content-Security-Policy` pour le mode donné. */
export const buildCspHeader = (mode: CspMode, matomoOrigin: string | null): string =>
serializeCsp(buildCspDirectives(mode, matomoOrigin));
/** Format d'en-têtes pour `serve.json` (cf. https://github.com/vercel/serve#configuration). */
export type ServeJsonHeader = Readonly<{ key: string; value: string }>;
export type ServeJsonRule = Readonly<{ source: string; headers: readonly ServeJsonHeader[] }>;
export type ServeJson = Readonly<{ headers: readonly ServeJsonRule[] }>;
/** En-têtes statiques (indépendants de la CSP). */
const STATIC_SECURITY_HEADERS: ReadonlyArray<{ key: string; value: string }> = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "X-Frame-Options", value: "DENY" },
{
key: "Permissions-Policy",
// Aucune fonctionnalité avancée nécessaire sur la V1 (app statique, pas de WebRTC, pas de paiement).
value: "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
}
];
/**
* Construit l'objet `serve.json` qui sera écrit dans `dist/` (lu par `serve` au runtime).
* Les en-têtes sont scopés sur le glob HTML pour la CSP, et sur un glob catch-all pour le
* reste (ne pas « fuir » la CSP sur des assets qui n'en ont pas besoin ; l'en-tête reste
* valide sur tout contenu textuel, mais limiter la surface évite les bugs).
*/
export const buildServeJson = (matomoOrigin: string | null): ServeJson => ({
headers: [
{
source: "**/*.html",
headers: [
{ key: "Content-Security-Policy", value: buildCspHeader("production", matomoOrigin) },
...STATIC_SECURITY_HEADERS
]
},
{
source: "**/*",
headers: STATIC_SECURITY_HEADERS
}
]
});
/** Injecte la balise `<meta http-equiv="Content-Security-Policy">` dans un HTML. */
export const injectCspMetaIntoHtml = (html: string, csp: string): string => {
const metaTag = `<meta http-equiv="Content-Security-Policy" content="${csp}">`;
// Stratégie : insérer juste après la première balise d'en-tête de charset.
// Si absente, on insère juste après `<head>`.
const charsetMatch = /<meta\s+charset="[^"]*"\s*\/?>/i.exec(html);
if (charsetMatch) {
const insertAt = charsetMatch.index + charsetMatch[0].length;
return `${html.slice(0, insertAt)}\n ${metaTag}${html.slice(insertAt)}`;
}
const headMatch = /<head[^>]*>/i.exec(html);
if (headMatch) {
const insertAt = headMatch.index + headMatch[0].length;
return `${html.slice(0, insertAt)}\n ${metaTag}${html.slice(insertAt)}`;
}
return ` ${metaTag}\n${html}`;
};
/** Vrai si la requête vise un document HTML. */
const isHtmlRequest = (req: IncomingMessage): boolean => {
const accept = String(req.headers.accept ?? "");
// `fetch` côté JS : pas un document ; `curl` sans Accept : on autorise pour le smoke test.
return accept.includes("text/html") || accept.includes("*/*") || accept === "";
};
/** Vrai si la requête vise une page recette (HTML pré-rendu `/r/...`). */
const isRecipeSharePath = (url: string | undefined): boolean => {
if (!url) return false;
try {
const pathname = new URL(url, "http://localhost").pathname.replace(/\/$/, "");
return /^\/r\/[^/]+\/[^/]+$/.test(pathname);
} catch {
return false;
}
};
/** Middleware Vite : pose les en-têtes de sécurité sur les réponses HTML du dev server. */
const makeSecurityHeadersMiddleware = (
cspValue: string
): Connect.NextHandleFunction => {
return function securityHeadersMiddleware(
req: IncomingMessage,
res: ServerResponse,
next: Connect.NextFunction
): void {
if (!isHtmlRequest(req) && !isRecipeSharePath(req.url)) {
next();
return;
}
res.setHeader("Content-Security-Policy", cspValue);
for (const h of STATIC_SECURITY_HEADERS) {
res.setHeader(h.key, h.value);
}
next();
};
};
/**
* Plugin Vite qui injecte la CSP (en meta) dans le HTML de l'app
* et émet les en-têtes de sécurité en dev + un `dist/serve.json` en prod.
*/
export function cspPlugin(options: CspPluginOptions): Plugin {
let config: ResolvedConfig;
const matomoOrigin = options.matomoOrigin;
return {
name: "ben-to-csp",
enforce: "pre",
configResolved(resolved) {
config = resolved;
},
transformIndexHtml(html) {
const mode: CspMode = config.command === "build" ? "production" : "development";
const csp = buildCspHeader(mode, matomoOrigin);
return injectCspMetaIntoHtml(html, csp);
},
configureServer(server) {
const csp = buildCspHeader("development", matomoOrigin);
// Place ce middleware **avant** celui de `recipeSeoPlugin` (enregistré après) pour
// que `res.setHeader` soit effectif avant que `recipeSeoPlugin` n'écrive la réponse.
server.middlewares.use(makeSecurityHeadersMiddleware(csp));
},
async closeBundle() {
if (config.command !== "build") return;
const outDir = path.resolve(config.root, config.build.outDir);
const serveJsonPath = path.join(outDir, "serve.json");
const payload = buildServeJson(matomoOrigin);
await fs.writeFile(serveJsonPath, JSON.stringify(payload, null, 2) + "\n", "utf8");
config.logger.info(
`csp: dist/serve.json écrit (${payload.headers.length} règle(s), matomo=${matomoOrigin ?? "désactivé"
})`
);
}
};
}
+208
View File
@@ -0,0 +1,208 @@
import { describe, expect, it } from "vitest";
import {
buildCspDirectives,
buildCspHeader,
buildServeJson,
injectCspMetaIntoHtml,
serializeCsp,
toOrigin
} from "./vite-plugin-csp";
describe("toOrigin", () => {
it("renvoie null pour une entrée vide ou non string", () => {
expect(toOrigin(null)).toBeNull();
expect(toOrigin(undefined)).toBeNull();
expect(toOrigin("")).toBeNull();
expect(toOrigin(" ")).toBeNull();
});
it("extrait le protocole + host (+ port)", () => {
expect(toOrigin("https://analytics.example.com/")).toBe("https://analytics.example.com");
expect(toOrigin("https://analytics.example.com")).toBe("https://analytics.example.com");
expect(toOrigin("https://analytics.example.com:8443/foo/bar?x=1")).toBe(
"https://analytics.example.com:8443"
);
});
it("refuse les protocoles non http(s)", () => {
expect(toOrigin("file:///etc/passwd")).toBeNull();
expect(toOrigin("javascript:alert(1)")).toBeNull();
expect(toOrigin("data:text/plain,foo")).toBeNull();
});
it("renvoie null pour une URL invalide", () => {
expect(toOrigin("not a url")).toBeNull();
expect(toOrigin("http://")).toBeNull();
});
});
describe("serializeCsp", () => {
it("joint les directives par `; ` et quote chaque source", () => {
const out = serializeCsp([
{ name: "default-src", sources: ["self"] },
{ name: "script-src", sources: ["self", "https://x.test"] }
]);
expect(out).toBe("default-src 'self'; script-src 'self' 'https://x.test'");
});
it("préserve les tokens spéciaux (unsafe-inline, etc.)", () => {
const out = serializeCsp([{ name: "script-src", sources: ["self", "unsafe-inline", "unsafe-eval"] }]);
expect(out).toBe("script-src 'self' 'unsafe-inline' 'unsafe-eval'");
});
});
describe("buildCspDirectives", () => {
it("en prod sans Matomo : aucune origine externe, pas de unsafe-eval, frame-ancestors none", () => {
const dirs = buildCspDirectives("production", null);
const find = (name: string) => dirs.find((d) => d.name === name);
expect(find("default-src")?.sources).toEqual(["self"]);
expect(find("script-src")?.sources).toEqual(["self"]);
// `style-src` inclut `unsafe-inline` même en prod (styles inline Solid).
expect(find("style-src")?.sources).toEqual(["self", "unsafe-inline"]);
expect(find("img-src")?.sources).toEqual(["self"]);
expect(find("font-src")?.sources).toEqual(["self"]);
expect(find("connect-src")?.sources).toEqual(["self"]);
expect(find("media-src")?.sources).toEqual(["self"]);
expect(find("object-src")?.sources).toEqual(["none"]);
expect(find("base-uri")?.sources).toEqual(["self"]);
expect(find("form-action")?.sources).toEqual(["self"]);
expect(find("frame-ancestors")?.sources).toEqual(["none"]);
expect(find("frame-src")?.sources).toEqual(["none"]);
});
it("en prod avec Matomo : autorise l'origine sur script/connect/img", () => {
const dirs = buildCspDirectives("production", "https://analytics.example.com");
expect(dirs.find((d) => d.name === "script-src")?.sources).toEqual([
"self",
"https://analytics.example.com"
]);
expect(dirs.find((d) => d.name === "connect-src")?.sources).toEqual([
"self",
"https://analytics.example.com"
]);
expect(dirs.find((d) => d.name === "img-src")?.sources).toEqual([
"self",
"https://analytics.example.com"
]);
// Pas de Matomo sur font-src / default-src.
expect(dirs.find((d) => d.name === "font-src")?.sources).toEqual(["self"]);
});
it("en dev : autorise unsafe-eval (HMR), ws:/wss:, et unsafe-inline sur style-src", () => {
const dirs = buildCspDirectives("development", null);
expect(dirs.find((d) => d.name === "script-src")?.sources).toEqual([
"self",
"unsafe-inline",
"unsafe-eval"
]);
expect(dirs.find((d) => d.name === "style-src")?.sources).toEqual(["self", "unsafe-inline"]);
expect(dirs.find((d) => d.name === "connect-src")?.sources).toEqual(["self", "ws:", "wss:"]);
// frame-ancestors reste 'none' en dev aussi.
expect(dirs.find((d) => d.name === "frame-ancestors")?.sources).toEqual(["none"]);
});
it("en dev avec Matomo : cumule unsafe-* et l'origine Matomo", () => {
const dirs = buildCspDirectives("development", "https://analytics.example.com");
expect(dirs.find((d) => d.name === "script-src")?.sources).toEqual([
"self",
"unsafe-inline",
"unsafe-eval",
"https://analytics.example.com"
]);
});
});
describe("buildCspHeader", () => {
it("inclut toutes les directives attendues en prod", () => {
const csp = buildCspHeader("production", null);
expect(csp).toContain("default-src 'self'");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toContain("object-src 'none'");
// `unsafe-inline` reste sur `style-src` (styles inline Solid) — seul `unsafe-eval` est
// strictement exclu en prod (HMR Vite ne sert qu'en dev).
expect(csp).not.toContain("unsafe-eval");
});
});
describe("injectCspMetaIntoHtml", () => {
const sample = `<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<title>Test</title>
</head>
<body></body>
</html>
`;
it("insère la balise CSP juste après le charset", () => {
const out = injectCspMetaIntoHtml(sample, "default-src 'self'");
expect(out).toMatch(
/<meta\s+charset="UTF-8"\s*\/>\s*<meta\s+http-equiv="Content-Security-Policy"\s+content="default-src 'self'">/
);
});
it("insère après <head> si pas de charset", () => {
const html = "<html><head><title>X</title></head><body/></html>";
const out = injectCspMetaIntoHtml(html, "default-src 'self'");
expect(out).toMatch(/<head>\s*<meta\s+http-equiv="Content-Security-Policy"/);
});
it("quote correctement la valeur CSP", () => {
const out = injectCspMetaIntoHtml(sample, "default-src 'self'; script-src 'self'");
expect(out).toContain(`content="default-src 'self'; script-src 'self'"`);
});
});
describe("buildServeJson", () => {
it("déclare une règle HTML avec CSP + en-têtes statiques, et une règle catch-all sans CSP", () => {
const sj = buildServeJson(null);
expect(sj.headers).toHaveLength(2);
const htmlRule = sj.headers.find((r) => r.source === "**/*.html");
expect(htmlRule).toBeDefined();
const htmlKeys = htmlRule!.headers.map((h) => h.key);
expect(htmlKeys).toEqual(
expect.arrayContaining([
"Content-Security-Policy",
"X-Content-Type-Options",
"Referrer-Policy",
"X-Frame-Options",
"Permissions-Policy"
])
);
const cspEntry = htmlRule!.headers.find((h) => h.key === "Content-Security-Policy");
expect(cspEntry?.value).not.toContain("unsafe-eval");
expect(cspEntry?.value).toContain("frame-ancestors 'none'");
const allRule = sj.headers.find((r) => r.source === "**/*");
expect(allRule).toBeDefined();
const allKeys = allRule!.headers.map((h) => h.key);
expect(allKeys).not.toContain("Content-Security-Policy");
expect(allKeys).toEqual(
expect.arrayContaining([
"X-Content-Type-Options",
"Referrer-Policy",
"X-Frame-Options",
"Permissions-Policy"
])
);
});
it("la CSP s'adapte à l'origine Matomo fournie", () => {
const sj = buildServeJson("https://analytics.example.com");
const cspEntry = sj.headers
.find((r) => r.source === "**/*.html")!
.headers.find((h) => h.key === "Content-Security-Policy");
expect(cspEntry?.value).toContain("https://analytics.example.com");
});
it("la Permissions-Policy désactive les capacités sensibles", () => {
const sj = buildServeJson(null);
const allHeaders = sj.headers.flatMap((r) => r.headers);
const pp = allHeaders.find((h) => h.key === "Permissions-Policy");
expect(pp?.value).toContain("camera=()");
expect(pp?.value).toContain("microphone=()");
expect(pp?.value).toContain("geolocation=()");
});
});
+109
View File
@@ -0,0 +1,109 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { Plugin, ResolvedConfig } from "vite";
import type { RecipesIndex } from "@ben-to/recipes/catalog";
import {
buildRecipeOgHeadModel,
forEachRecipeSharePair,
injectRecipeSeoIntoIndexHtml
} from "@ben-to/recipes/recipe-og-meta";
const DEFAULT_SITE_ORIGIN = "https://ben-to.fr";
/**
* Pré-rendu des pages recette pour les métadonnées Open Graph / Twitter (HTML initial).
* - **Dev** : middleware qui réinjecte le `<head>` pour les URLs `/r/:base/:variant`.
* - **Build** : écrit `dist/r/<base>/<variant>/index.html` pour chaque couple catalogue.
*/
export function recipeSeoPlugin(): Plugin {
let config: ResolvedConfig;
return {
name: "ben-to-recipe-seo",
configResolved(resolved) {
config = resolved;
},
configureServer(server) {
const root = server.config.root;
const port = server.config.server.port ?? 3000;
server.middlewares.use(async (req, res, next) => {
if (req.method !== "GET" || !req.url) {
next();
return;
}
const pathname = new URL(req.url, "http://localhost").pathname.replace(/\/$/, "");
const match = /^\/r\/([^/]+)\/([^/]+)$/.exec(pathname);
if (!match) {
next();
return;
}
try {
const baseId = decodeURIComponent(match[1]);
const variantId = decodeURIComponent(match[2]);
const catalogPath = path.join(root, "public/recipes/catalog.json");
const indexPath = path.join(root, "index.html");
const [catalogRaw, rawIndexHtml] = await Promise.all([
fs.readFile(catalogPath, "utf8"),
fs.readFile(indexPath, "utf8")
]);
// On passe par le pipeline de transformation HTML de Vite (injecte la CSP
// et tout autre plugin qui utilise `transformIndexHtml`) avant d'injecter
// les balises OG/Twitter recette.
const indexHtml = await server.transformIndexHtml(req.url ?? pathname, rawIndexHtml);
const catalog = JSON.parse(catalogRaw) as RecipesIndex;
const base = catalog.bases.find((b) => b.id === baseId);
const variant = catalog.variants.find((v) => v.id === variantId);
if (!base || !variant || !variant.baseIds.includes(baseId)) {
next();
return;
}
const devOrigin = process.env.BEN_TO_SITE_ORIGIN ?? `http://localhost:${port}`;
const model = buildRecipeOgHeadModel({
siteOrigin: devOrigin,
base,
variant,
baseId,
variantId
});
const html = injectRecipeSeoIntoIndexHtml(indexHtml, model);
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(html);
} catch {
next();
}
});
},
async closeBundle() {
const outDir = path.resolve(config.root, config.build.outDir);
const indexPath = path.join(outDir, "index.html");
const catalogPath = path.join(outDir, "recipes/catalog.json");
const siteOrigin = process.env.BEN_TO_SITE_ORIGIN ?? DEFAULT_SITE_ORIGIN;
const [indexHtml, catalogRaw] = await Promise.all([
fs.readFile(indexPath, "utf8"),
fs.readFile(catalogPath, "utf8")
]);
const catalog = JSON.parse(catalogRaw) as RecipesIndex;
const tasks: Promise<void>[] = [];
forEachRecipeSharePair(catalog, ({ base, variant, baseId, variantId }) => {
tasks.push(
(async () => {
const model = buildRecipeOgHeadModel({
siteOrigin,
base,
variant,
baseId,
variantId
});
const html = injectRecipeSeoIntoIndexHtml(indexHtml, model);
const dir = path.join(outDir, "r", encodeURIComponent(baseId), encodeURIComponent(variantId));
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, "index.html"), html, "utf8");
})()
);
});
await Promise.all(tasks);
config.logger.info(`recipe-seo: ${tasks.length} page(s) OG générée(s) sous dist/r/`);
}
};
}
+28
View File
@@ -0,0 +1,28 @@
import path from "node:path";
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
import { recipeSeoPlugin } from "./vite-plugin-recipe-seo";
import { cspPlugin } from "./vite-plugin-csp";
import { toOrigin } from "./vite-plugin-csp";
const pkg = (relative: string) => path.resolve(__dirname, relative);
const matomoOrigin = toOrigin(process.env.VITE_MATOMO_URL);
export default defineConfig({
plugins: [cspPlugin({ matomoOrigin }), recipeSeoPlugin(), solidPlugin()],
resolve: {
alias: {
"@ben-to/builder/domain": pkg("../../packages/features/builder/src/domain.ts"),
"@ben-to/recipes/catalog": pkg("../../packages/features/recipes/src/catalog.ts"),
"@ben-to/recipes/domain": pkg("../../packages/features/recipes/src/domain.ts"),
"@ben-to/recipes/recipe-og-meta": pkg("../../packages/features/recipes/src/recipe-og-meta.ts"),
"@ben-to/i18n/dictionary": pkg("../../packages/shared/i18n/src/dictionary.ts"),
"@ben-to/testing/iso-date": pkg("../../packages/shared/testing/src/iso-date.ts"),
"@ben-to/privacy/cookie-consent": pkg("../../packages/shared/privacy/src/cookie-consent.ts")
}
},
server: {
port: 3000
}
});