diff --git a/.gitea/ISSUE_TEMPLATE/nouvo-zouti.yaml b/.gitea/ISSUE_TEMPLATE/nouvo-zouti.yaml new file mode 100644 index 0000000..3d5ca76 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/nouvo-zouti.yaml @@ -0,0 +1,51 @@ +name: "Nouvo zouti — Proposer un outil" +description: "Pwopozé on zouti lib pou lis-la / Proposer un outil libre pour l'annuaire" +title: "[zouti] " +labels: + - "nouvo-zouti" +body: + - type: input + id: nom + attributes: + label: "Non a zouti-la / Nom de l'outil" + placeholder: "ex. Signal" + validations: + required: true + - type: input + id: url + attributes: + label: "Sit ofisyèl / Site officiel" + placeholder: "https://…" + validations: + required: true + - type: input + id: licence + attributes: + label: "Licence (identifiant SPDX si tu le connais)" + placeholder: "ex. AGPL-3.0" + - type: dropdown + id: difficulte + attributes: + label: "Difikilté / Difficulté d'installation" + options: + - "🟢 Débitan / Débutant" + - "🟡 Entèmédia / Intermédiaire" + - "🔴 Avansé / Avancé" + validations: + required: true + - type: textarea + id: pourquoi + attributes: + label: "Poukisa i konté ? / Pourquoi il compte ?" + description: "En quoi cet outil sert la souveraineté numérique ? Qui devrait s'en servir ?" + validations: + required: true + - type: checkboxes + id: verifs + attributes: + label: "Vérifications" + options: + - label: "L'outil est libre / open-source (code source publié)" + required: true + - label: "J'ai vérifié qu'il n'est pas déjà dans l'annuaire" + required: true diff --git a/content/categories.json b/content/categories.json index 90039da..145a863 100644 --- a/content/categories.json +++ b/content/categories.json @@ -61,7 +61,7 @@ }, { "slug": "ia", - "title": "\"L'IA conversationnelle\"", + "title": "L'IA conversationnelle", "order": 15, "part": 9 }, @@ -85,7 +85,7 @@ }, { "slug": "os", - "title": "\"Systèmes d'exploitation libres\"", + "title": "Systèmes d'exploitation libres", "order": 19, "part": 12 } diff --git a/content/outils/bitwarden.md b/content/outils/bitwarden.md index f2fab57..abf6503 100644 --- a/content/outils/bitwarden.md +++ b/content/outils/bitwarden.md @@ -14,30 +14,20 @@ lang: "fr" ## what -Un coffre-fort à mots de passe qui les mémorise, les génère et les remplit pour vous, sur tous - vos appareils. +Un coffre-fort à mots de passe qui les mémorise, les génère et les remplit pour toi, sur tous tes appareils. ## why -Gratuit, audité, multiplateforme, et chiffré de bout en bout. Vous n'avez plus qu'un seul mot de - passe fort à retenir. Il est même auto-hébergeable (via Vaultwarden) pour ne dépendre de - personne. +Gratuit, audité, multiplateforme, chiffré de bout en bout. Tu n'as plus qu'un seul mot de passe fort à retenir — et le jour où tu veux ne dépendre de personne, il est auto-hébergeable (via Vaultwarden). ## who -Tout le monde. C'est le point de départ de toute la sécurité : un mot de passe unique et fort - par service. +Tout le monde. C'est la fondation de toute ta sécurité : un mot de passe unique et fort par service, sans effort de mémoire. ## install
    -
  1. - Créez un compte sur bitwarden.com et choisissez un mot de passe maître long (une - phrase). Ne l'oubliez jamais : il n'est pas récupérable. -
  2. -
  3. Installez l'extension de navigateur et l'application mobile.
  4. -
  5. - Au fil de vos connexions, laissez Bitwarden enregistrer puis remplacer vos anciens mots de - passe par des mots de passe générés. -
  6. -
+
  • Crée un compte sur bitwarden.com et choisis un mot de passe maître long — une phrase entière. Ne l'oublie jamais : il n'est pas récupérable, et c'est voulu.
  • +
  • Installe l'extension de navigateur et l'application mobile.
  • +
  • Au fil de tes connexions, laisse Bitwarden enregistrer puis remplacer tes vieux mots de passe par des mots de passe générés.
  • + diff --git a/content/outils/brave.md b/content/outils/brave.md index 9cce23d..838ecce 100644 --- a/content/outils/brave.md +++ b/content/outils/brave.md @@ -14,31 +14,20 @@ lang: "fr" ## what -Un navigateur basé sur Chromium qui bloque publicités et traqueurs par défaut, avec en prime une - fenêtre privée connectée à Tor. +Un navigateur basé sur Chromium qui bloque publicités et traqueurs par défaut — avec en prime une fenêtre privée connectée à Tor. ## why -C'est l'option la plus simple pour quelqu'un qui vient de Chrome : même ergonomie, mais sans le - pistage. Il bloque les pubs YouTube nativement, propose une visio chiffrée (Brave Talk, basée - sur Jitsi) et un accès Tor en un clic, pratique pour récupérer un site bloqué. +C'est le changement le plus indolore quand tu viens de Chrome : même ergonomie, sans le pistage. Il bloque les pubs YouTube nativement, propose une visio chiffrée (Brave Talk, basé sur Jitsi) et un accès Tor en un clic — pratique pour récupérer un site bloqué. ## who -Les débutants qui veulent un changement indolore depuis Chrome. (Ignorez les fonctions - crypto/pubs récompensées si elles ne vous intéressent pas.) +Celles et ceux qui veulent quitter Chrome sans rien réapprendre. (Les fonctions crypto et pubs récompensées ? Ignore-les, elles se désactivent.) ## install
      -
    1. - Installez Brave depuis brave.com. -
    2. -
    3. - Réglez les « Boucliers » (Shields) sur « agressif », et désactivez Brave Rewards si vous ne - voulez pas de pubs. -
    4. -
    5. - Pour Tor : menu > « Nouvelle fenêtre privée avec Tor », puis attendez « Tor connecté ». -
    6. -
    +
  • Installe Brave depuis brave.com.
  • +
  • Règle les « Boucliers » (Shields) sur « agressif », et désactive Brave Rewards si tu ne veux pas de pubs.
  • +
  • Pour Tor : menu > « Nouvelle fenêtre privée avec Tor », puis attends « Tor connecté ».
  • + diff --git a/content/outils/gcf/bitwarden.md b/content/outils/gcf/bitwarden.md new file mode 100644 index 0000000..150a60d --- /dev/null +++ b/content/outils/gcf/bitwarden.md @@ -0,0 +1,25 @@ +--- +name: "Bitwarden" +slug: "bitwarden" +lang: "gcf" +--- + +## what + +On kòf a modpas ki ka sonjé-yo, jénéré-yo é ranpli-yo pou vou, asi tout apawèy a-w. + +## why + +Gratis, odité, miltiplatfòwm, chifré bout-an-bout. Ou ni yenki on sèl modpas fò pou sonjé — é jou ou vlé pa dépann di pèsonn, ou pé oto-ébèjé'y (èvè Vaultwarden). + +## who + +Tout moun. Sé fondasyon a tout sékirité a-w : on modpas inik é fò pou chak sèwvis, san fòsé mémwa a-w. + +## install + +
      +
    1. Kréyé on kont asi bitwarden.com é chwazi on modpas mèt long — on fraz antyé. Pa janmé oublié'y : i pa rékipérab, é sé fè èsprè.
    2. +
    3. Enstalé èkstansyon navigatè-la é aplikasyon mobil-la.
    4. +
    5. O fi a konèksyon a-w, kité Bitwarden anrèjistré é apré ranplasé vyé modpas a-w pa modpas jénéré.
    6. +
    diff --git a/content/outils/gcf/brave.md b/content/outils/gcf/brave.md new file mode 100644 index 0000000..14879b9 --- /dev/null +++ b/content/outils/gcf/brave.md @@ -0,0 +1,25 @@ +--- +name: "Brave" +slug: "brave" +lang: "gcf" +--- + +## what + +On navigatè basé asi Chromium ki ka bloké piblisité é trakè pa défo — èvè an plis on fenèt privé konèkté asi Tor. + +## why + +Sé chanjman pi dous-la lè ou ka sòti an Chrome : menm èwgonomi, san pistaj-la. I ka bloké pib YouTube dirèk, i ka pwopozé on vizyo chifré (Brave Talk, basé asi Jitsi) é on aksè Tor an on klik — pratik pou ratrapé on sit bloké. + +## who + +Moun ki vlé kité Chrome san riaprann ayen. (Fonksyon crypto é pib rékonpansé ? Inyoré-yo, yo ka tenn.) + +## install + +
      +
    1. Enstalé Brave dépi brave.com.
    2. +
    3. Réglé « Bouklyé »-la (Shields) asi « agrésif », é tenn Brave Rewards si ou pa vlé pib.
    4. +
    5. Pou Tor : mèni > « Nouvo fenèt privé èvè Tor », apré atann « Tor konèkté ».
    6. +
    diff --git a/content/outils/gcf/protonmail.md b/content/outils/gcf/protonmail.md new file mode 100644 index 0000000..87a5216 --- /dev/null +++ b/content/outils/gcf/protonmail.md @@ -0,0 +1,26 @@ +--- +name: "Proton Mail" +slug: "protonmail" +lang: "gcf" +--- + +## what + +On bwèt imèl chifré ki ka ranplasé Gmail oben Outlook — é kont pivo a tout ékosistèm Proton-la. + +## why + +Basé an Swis, andéwò a jiridiksyon « Five Eyes »-la. Konpatib OpenPGP : chifraj-la otomatik ant kont Proton é lòt sèwvis PGP. Kouryé a-w, sé korèspondans a-w — pa on matyè prèmyé pou piblisité. + +## who + +Tout moun. Sé pi bon pòt pou sòti an Google : louvè kont-la, rès a suit-la (ajanda, drive, VPN) ka suiv tousèl. + +## install + +
      +
    1. Kréyé on kont asi proton.me (òf gratis-la ka sifi pou koumansé).
    2. +
    3. Rapatriyé vyé mésaj é kontak a-w èvè Easy Switch (entégré).
    4. +
    5. Mèt on rédirèksyon dépi Gmail é prévien kontak a-w di nouvo adrès a-w.
    6. +
    7. Enstalé aplikasyon mobil-la, limé doub otantifikasyon-la — é Proton Bridge si ou ka rété asi Thunderbird.
    8. +
    diff --git a/content/outils/gcf/quad9.md b/content/outils/gcf/quad9.md new file mode 100644 index 0000000..d6f6b82 --- /dev/null +++ b/content/outils/gcf/quad9.md @@ -0,0 +1,24 @@ +--- +name: "Quad9" +slug: "quad9" +lang: "gcf" +--- + +## what + +On rézolvè DNS chifré, swis é san bi likratif, ki ka bloké an plis domèn malfézan yo ja konnèt. + +## why + +Founisè aksè a-w ka vwè chak sit ou ka vizité gras a DNS-la. Quad9 ka chifré dimann-lasa yo, i pa ka gadé adrès IP a-w, é i ka pwotéjé-w kont lamèkantri an menm tan. On réglaj « fè'y é oublié'y ». + +## who + +Tout moun — sitou si ou vlé on vré gany san enstalé ayen anplis. + +## install + +
      +
    1. Asi Android : Réglaj > Rézo > DNS privé > matjé dns.quad9.net.
    2. +
    3. Asi iPhone/Mac : enstalé profil DoH-la dépi quad9.net. Asi PC : konfiguré DNS-over-HTTPS an navigatè-la oben an sistèm-la.
    4. +
    diff --git a/content/outils/gcf/signal.md b/content/outils/gcf/signal.md new file mode 100644 index 0000000..fc73114 --- /dev/null +++ b/content/outils/gcf/signal.md @@ -0,0 +1,26 @@ +--- +name: "Signal" +slug: "signal" +lang: "gcf" +--- + +## what + +On mésajri chifré pou tèks, apèl, vizyo é group a-w. Sé ranplasan dirèk a WhatsApp — menm senplisité, san Meta dèyè. + +## why + +Chifraj bout-an-bout pa défo, san bi likratif, protokòl odité ki ka sèwvi référans toupatou. Prèské pon métadoné pa ka rété, é ékip-la ja di : yo ké kité Léwòp olyé yo enstalé on skannaj. Chak moun ou ka fè pasé, sé on konvèsasyon anplis ki ka sòti an survéyans-la. + +## who + +Tout moun, jòdi-la menm. Sé prèmyé jès-la, é pi itil-la : koumansé èvè group a fanmi a-w. Yenki on kontrent : on niméwo téléfòn — ou pé séré'y dèyè on non itilizatè. + +## install + +
      +
    1. Enstalé dépi signal.org (App Store, Google Play, oben APK dirèk asi Android).
    2. +
    3. Konfirmé niméwo a-w pa SMS, apré chwazi on kòd PIN.
    4. +
    5. Réglaj > Konfidansyalité : kréyé on non itilizatè pou séré niméwo a-w, é limé vèwou enskripsyon-la.
    6. +
    7. Envité moun a-w — group fanmi-la avan. Asi òwdinatè, lyanné aplikasyon biwo-la èvè téléfòn a-w.
    8. +
    diff --git a/content/outils/gcf/ublock.md b/content/outils/gcf/ublock.md new file mode 100644 index 0000000..2a10957 --- /dev/null +++ b/content/outils/gcf/ublock.md @@ -0,0 +1,24 @@ +--- +name: "uBlock Origin" +slug: "ublock" +lang: "gcf" +--- + +## what + +Èkstansyon-la ki ka bloké piblisité, trakè é èskrip èspyon. Jès pi rantab a tout gid-lasa : dis ségonn, éfè imedya. + +## why + +Piblisité an liny sé prensipal chimen a survéyans é profilaj. Bloké'y, sé koupé pi gwo moso a pistaj-la — é an menm balan paj a-w ka chajé pi vit é fòfé data a-w ka respiré. + +## who + +Tout moun, asi Firefox kon asi navigatè Chromium. Si ou ka fè yenki on sèl jès jòdi-la, fè ta-lasa. + +## install + +
      +
    1. Louvè magazen èkstansyon a navigatè a-w é chèché « uBlock Origin » (orijinal-la, pa Raymond Hill).
    2. +
    3. Enstalé'y. I ka travay lamenm, san pon réglaj.
    4. +
    diff --git a/content/outils/protonmail.md b/content/outils/protonmail.md index 7f163ed..0129191 100644 --- a/content/outils/protonmail.md +++ b/content/outils/protonmail.md @@ -13,31 +13,21 @@ lang: "fr" ## what -Une boîte mail chiffrée qui remplace Gmail ou Outlook, et le compte pivot de tout l'écosystème - Proton. +Une boîte mail chiffrée qui remplace Gmail ou Outlook — et le compte pivot de tout l'écosystème Proton. ## why -Basé en Suisse, hors juridiction des « Five Eyes ». Compatible OpenPGP : chiffrement automatique - entre comptes Proton et vers les autres services PGP. +Basée en Suisse, hors juridiction des « Five Eyes ». Compatible OpenPGP : le chiffrement est automatique entre comptes Proton et vers les autres services PGP. Ton courrier, c'est ta correspondance — pas une matière première publicitaire. ## who -Tout le monde. C'est le meilleur point de départ pour quitter Google (voir section 05 pour le - reste de la suite). +Tout le monde. C'est la meilleure porte de sortie de Google : ouvre le compte, le reste de la suite (agenda, drive, VPN) suit tout seul. ## install
      -
    1. - Créez un compte sur proton.me (offre gratuite suffisante pour commencer). -
    2. -
    3. - Importez vos anciens messages et contacts avec Easy Switch (intégré). -
    4. -
    5. Mettez une redirection depuis Gmail et prévenez vos contacts de votre nouvelle adresse.
    6. -
    7. - Installez l'application mobile, activez la double authentification, et utilisez Proton - Bridge si vous tenez à Thunderbird. -
    8. -
    +
  • Crée un compte sur proton.me (l'offre gratuite suffit largement pour commencer).
  • +
  • Rapatrie tes anciens messages et contacts avec Easy Switch (intégré).
  • +
  • Mets une redirection depuis Gmail et préviens tes contacts de ta nouvelle adresse.
  • +
  • Installe l'application mobile, active la double authentification — et Proton Bridge si tu tiens à Thunderbird.
  • + diff --git a/content/outils/quad9.md b/content/outils/quad9.md index 512e6d0..40b4b5b 100644 --- a/content/outils/quad9.md +++ b/content/outils/quad9.md @@ -13,26 +13,19 @@ lang: "fr" ## what -Un résolveur DNS chiffré, suisse et à but non lucratif, qui bloque en plus les domaines - malveillants connus. +Un résolveur DNS chiffré, suisse et à but non lucratif, qui bloque en plus les domaines malveillants connus. ## why -Juridiction suisse, pas de conservation de votre adresse IP, protection contre l'hameçonnage et - les logiciels malveillants. Un excellent réglage « installer et oublier ». +Ton fournisseur d'accès voit chaque site que tu visites via le DNS. Quad9 chiffre ces requêtes, ne conserve pas ton adresse IP, et te protège de l'hameçonnage au passage. Un réglage « fais-le et oublie-le ». ## who -Tout le monde, en particulier ceux qui veulent la simplicité et une bonne juridiction. +Tout le monde — surtout si tu veux un gain réel sans rien installer de nouveau. ## install
      -
    1. - Sur Android : Réglages > Réseau > DNS privé > saisissez dns.quad9.net. -
    2. -
    3. - Sur iPhone/Mac : installez le profil DoH depuis quad9.net. Sur PC : configurez le - DNS-over-HTTPS dans le navigateur ou le système. -
    4. -
    +
  • Sur Android : Réglages > Réseau > DNS privé > saisis dns.quad9.net.
  • +
  • Sur iPhone/Mac : installe le profil DoH depuis quad9.net. Sur PC : configure le DNS-over-HTTPS dans le navigateur ou le système.
  • + diff --git a/content/outils/signal.md b/content/outils/signal.md index 2ddcd48..78c6351 100644 --- a/content/outils/signal.md +++ b/content/outils/signal.md @@ -14,30 +14,21 @@ lang: "fr" ## what -Une messagerie chiffrée pour vos textos, appels, visios et groupes. C'est le remplaçant direct - de WhatsApp, en aussi simple. +Une messagerie chiffrée pour tes textos, appels, visios et groupes. C'est le remplaçant direct de WhatsApp — aussi simple, sans Meta derrière. ## why -Chiffrement de bout en bout par défaut, à but non lucratif, protocole audité qui sert de - référence mondiale. Presque aucune métadonnée conservée, et l'équipe a promis de quitter l'UE - plutôt que d'installer un scan. +Chiffrement de bout en bout par défaut, à but non lucratif, protocole audité qui sert de référence mondiale. Presque aucune métadonnée gardée, et l'équipe a promis de quitter l'UE plutôt que d'installer un scan. Chaque proche que tu fais basculer, c'est une conversation de plus qui sort de la surveillance. ## who -Tout le monde, dès aujourd'hui. C'est le premier geste et le plus utile. Seule contrainte : un - numéro de téléphone, que l'on peut masquer derrière un nom d'utilisateur. +Tout le monde, dès aujourd'hui. C'est le premier geste, et le plus utile : commence par ton groupe de famille. Seule contrainte : un numéro de téléphone — que tu peux cacher derrière un nom d'utilisateur. ## install
      -
    1. - Installez depuis signal.org (App Store, Google Play, ou l'APK direct sur Android). -
    2. -
    3. Confirmez votre numéro par SMS, puis choisissez un code PIN.
    4. -
    5. - Réglages > Confidentialité : créez un nom d'utilisateur pour cacher votre numéro, et - activez le verrou d'inscription. -
    6. -
    7. Invitez vos proches. Sur ordinateur, liez l'application de bureau à votre téléphone.
    8. -
    +
  • Installe depuis signal.org (App Store, Google Play, ou l'APK direct sur Android).
  • +
  • Confirme ton numéro par SMS, puis choisis un code PIN.
  • +
  • Réglages > Confidentialité : crée un nom d'utilisateur pour cacher ton numéro, et active le verrou d'inscription.
  • +
  • Invite tes proches — le groupe famille d'abord. Sur ordinateur, lie l'application de bureau à ton téléphone.
  • + diff --git a/content/outils/ublock.md b/content/outils/ublock.md index 253a181..75216e1 100644 --- a/content/outils/ublock.md +++ b/content/outils/ublock.md @@ -13,24 +13,19 @@ lang: "fr" ## what -L'extension de navigateur qui bloque publicités, traqueurs et scripts espions. Le geste le plus - rentable, en dix secondes. +L'extension qui bloque publicités, traqueurs et scripts espions. Le geste le plus rentable de tout ce guide : dix secondes, effet immédiat. ## why -La publicité en ligne est le principal vecteur de surveillance et de profilage. La bloquer coupe - l'essentiel du pistage, accélère les pages et réduit les risques de logiciels malveillants. +La publicité en ligne est le principal vecteur de surveillance et de profilage. La bloquer, c'est couper l'essentiel du pistage — et au passage tes pages chargent plus vite et ton forfait data respire. ## who -Tout le monde, sur Firefox comme sur les navigateurs Chromium. +Tout le monde, sur Firefox comme sur les navigateurs Chromium. Si tu ne fais qu'un seul geste aujourd'hui, fais celui-là. ## install
      -
    1. - Ouvrez le magasin d'extensions de votre navigateur et cherchez « uBlock Origin » (l'original, - par Raymond Hill). -
    2. -
    3. Installez-le. Il fonctionne aussitôt, sans réglage nécessaire.
    4. -
    +
  • Ouvre le magasin d'extensions de ton navigateur et cherche « uBlock Origin » (l'original, par Raymond Hill).
  • +
  • Installe-le. Il travaille aussitôt, sans aucun réglage.
  • + diff --git a/docs/CONTENT_GUIDE.md b/docs/CONTENT_GUIDE.md index 3296150..ed0ee22 100644 --- a/docs/CONTENT_GUIDE.md +++ b/docs/CONTENT_GUIDE.md @@ -46,6 +46,17 @@ le contrat de validation (ADR-001). - Exemples et localisation ancrés territoire — jamais de Lorem ipsum hexagonal (doctrine §4.2). +### Fiches de référence pour le ton + +Six fiches sont réécrites au registre OKI et servent de modèle : `signal`, +`protonmail`, `brave`, `bitwarden`, `ublock`, `quad9`. Avant d'écrire ou de +réviser une fiche, relis-en deux. Ce qui change par rapport au ton d'origine : +tutoiement systématique, le geste avant la technique (« dix secondes, effet +immédiat »), l'enjeu politique dit sans détour (« ta correspondance — pas une +matière première publicitaire »), et un ancrage collectif (« commence par ton +groupe de famille »). Le script de migration ne régénère jamais une fiche +existante (`--force` pour tout écraser) : les réécritures sont protégées. + ## Créole guadeloupéen (gcf) - Les **micro-textes de l'interface** (boutons, filtres, états vides, marqueur @@ -55,10 +66,13 @@ le contrat de validation (ADR-001). - ⚠️ **Relecture par un locuteur natif requise** : les chaînes gcf actuelles sont un premier jet à valider avant mise en production. La graphie de référence à trancher (GEREC-F ou autre) doit être notée ici une fois choisie. -- La traduction intégrale des fiches est un chantier éditorial séparé : - le format prévu est un fichier jumeau `content/outils/gcf/.md` avec - `lang: "gcf"` (le chargeur l'ignorera tant que la locale de contenu n'est - pas activée). +- Chaque fiche peut avoir sa **jumelle créole** : `content/outils/gcf/.md` + (frontmatter minimal : `name`, `slug`, `lang: "gcf"` ; mêmes sections + `## what/why/who/install`). Elle s'affiche automatiquement quand la personne + choisit Kréyòl, avec un bandeau « tradiksyon an chantyé » ; sans jumelle, + la version française s'affiche avec un bandeau « póko tradui ». Six jumelles + pilotes existent (mêmes slugs que les fiches de référence ci-dessus) — + **premier jet non relu**, à valider avant d'en faire des modèles. - Termes fixés : « zouti » (outil), « Ou pa manké ayen » (fin de liste), « Chwazi zouti ou » (héros), niveaux « Débitan / Entèmédia / Avansé ». diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..b3e2d0a --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,140 @@ +# Déployer lage-chat-control sur un VPS + +Le site est **100 % statique** (ADR-001) : le déploiement consiste à poser le +contenu du dossier `frontend/build/` derrière un serveur web. Pas de base de +données, pas de service Node à maintenir, pas de tâche cron. Un miroir du site += une copie du dossier. + +## 0. Prérequis + +- Un VPS (1 vCPU / 512 Mo suffisent très largement) sous Debian/Ubuntu +- Un nom de domaine pointant sur le VPS (enregistrement A/AAAA) +- En local : Node ≥ 22.12 et pnpm (le build se fait **sur ta machine**, + le VPS n'a besoin ni de Node ni de pnpm) + +## 1. Construire le site + +```sh +git clone https://labola.o-k-i.net/cyber-mawonaj/lage-chat-control.git +cd lage-chat-control/frontend +pnpm install +pnpm build # produit frontend/build/ (~60 pages statiques) +``` + +## 2. Option A — Caddy (recommandé : TLS automatique) + +```sh +# sur le VPS +sudo apt install caddy +sudo mkdir -p /srv/lage-chat-control +``` + +`/etc/caddy/Caddyfile` : + +```caddy +exemple.o-k-i.net { + root * /srv/lage-chat-control + file_server + encode zstd gzip + + # SvelteKit adapter-static : pages pré-générées + fallback + try_files {path} {path}.html {path}/index.html /404.html + + header { + # le service worker doit pouvoir se mettre à jour + Cache-Control "no-cache" /service-worker.js + # assets immuables (noms hachés) + Cache-Control "public, max-age=31536000, immutable" /_app/immutable/* + # en-têtes de sécurité + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "no-referrer" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + # CSP stricte : le site n'a aucune dépendance externe + Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'" + } +} +``` + +Le TLS (Let's Encrypt) est automatique, rien d'autre à faire. + +## 3. Option B — nginx + +```sh +sudo apt install nginx certbot python3-certbot-nginx +``` + +`/etc/nginx/sites-available/lage-chat-control` : + +```nginx +server { + server_name exemple.o-k-i.net; + root /srv/lage-chat-control; + index index.html; + + # pages pré-générées : /outils → outils.html, fallback 404 + location / { + try_files $uri $uri.html $uri/index.html /404.html; + } + + location /_app/immutable/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + location = /service-worker.js { + add_header Cache-Control "no-cache"; + } + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'" always; + + gzip on; + gzip_types text/html text/css application/javascript application/json image/svg+xml; +} +``` + +```sh +sudo ln -s /etc/nginx/sites-available/lage-chat-control /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +sudo certbot --nginx -d exemple.o-k-i.net # TLS +``` + +## 4. Publier (et republier) + +```sh +# depuis ta machine, après chaque `pnpm build` +rsync -avz --delete frontend/build/ vps:/srv/lage-chat-control/ +``` + +C'est l'intégralité du « pipeline de déploiement ». Pour automatiser : +un runner Forgejo Actions sur labola peut exécuter build + rsync à chaque +push sur `main` (à ajouter quand le besoin se présente, pas avant). + +## 5. Option C — YunoHost + +Le site s'installe avec l'app **`my_webapp`** (site statique) : + +1. Installe `my_webapp` depuis le catalogue, choisis le domaine/chemin. +2. Copie le contenu de `frontend/build/` dans le dossier `www/` de l'app + (SFTP ou rsync avec l'utilisateur dédié créé par l'app). +3. Dans la config nginx avancée de l'app, ajoute le bloc `try_files` ci-dessus. + +YunoHost gère TLS, HSTS et le renouvellement des certificats. + +## 6. Option D — Docker + +Le `Dockerfile` et `docker/nginx.conf` du dépôt (hérités du site Astro) +servent déjà un site statique durci ; adapter le chemin de copie vers +`frontend/build/` si tu veux cette voie. Utile pour l'infra OKI si tout +le reste tourne déjà en conteneurs — sinon les options A/B sont plus simples. + +## 7. Vérifier après mise en ligne + +- [ ] `curl -sI https://exemple.o-k-i.net | grep -i content-security` → CSP présente +- [ ] La page `/outils` répond (et `/outils?cat=messagerie` filtre) +- [ ] DevTools > Application > Service worker : enregistré ; passer hors-ligne + et recharger une fiche déjà visitée → elle s'affiche +- [ ] observatory.mozilla.org : note A/A+ attendue avec les en-têtes ci-dessus +- [ ] Aucune requête sortante vers un domaine tiers (onglet Réseau) diff --git a/frontend/package.json b/frontend/package.json index 0eb5ea0..9f7847a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,14 +10,17 @@ "build": "vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "migrate": "node scripts/migrate-tools.mjs" + "migrate": "node scripts/migrate-tools.mjs", + "audit:a11y": "node scripts/audit-a11y.mjs" }, "engines": { "node": ">=22.12" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@fontsource/atkinson-hyperlegible": "^5.2.8", "@fontsource/fraunces": "^5.2.9", + "@playwright/test": "1.61.1", "@sveltejs/adapter-static": "^3.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index a9887b6..82a5e49 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -8,12 +8,18 @@ importers: .: devDependencies: + '@axe-core/playwright': + specifier: ^4.12.1 + version: 4.12.1(playwright-core@1.61.1) '@fontsource/atkinson-hyperlegible': specifier: ^5.2.8 version: 5.2.8 '@fontsource/fraunces': specifier: ^5.2.9 version: 5.2.9 + '@playwright/test': + specifier: 1.61.1 + version: 1.61.1 '@sveltejs/adapter-static': specifier: ^3.0.0 version: 3.0.10(@sveltejs/kit@2.69.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.56.4)(vite@7.3.6(@types/node@26.1.1)))(svelte@5.56.4)(typescript@5.9.3)(vite@7.3.6(@types/node@26.1.1))) @@ -44,6 +50,11 @@ importers: packages: + '@axe-core/playwright@4.12.1': + resolution: {integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -222,6 +233,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -432,6 +448,10 @@ packages: resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} + axe-core@4.12.1: + resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + engines: {node: '>=4'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -480,6 +500,11 @@ packages: picomatch: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -527,6 +552,16 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.16: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} @@ -636,6 +671,11 @@ packages: snapshots: + '@axe-core/playwright@4.12.1(playwright-core@1.61.1)': + dependencies: + axe-core: 4.12.1 + playwright-core: 1.61.1 + '@esbuild/aix-ppc64@0.28.1': optional: true @@ -737,6 +777,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@polka/url@1.0.0-next.29': {} '@rollup/rollup-android-arm-eabi@4.62.2': @@ -877,6 +921,8 @@ snapshots: aria-query@5.3.1: {} + axe-core@4.12.1: {} + axobject-query@4.1.0: {} chokidar@4.0.3: @@ -930,6 +976,9 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -959,6 +1008,14 @@ snapshots: picomatch@4.0.5: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.16: dependencies: nanoid: 3.3.15 diff --git a/frontend/scripts/audit-a11y.mjs b/frontend/scripts/audit-a11y.mjs new file mode 100644 index 0000000..65e181a --- /dev/null +++ b/frontend/scripts/audit-a11y.mjs @@ -0,0 +1,24 @@ +// Audit axe-core des pages clés servies sur localhost:4173 +import { chromium } from '@playwright/test' +import { AxeBuilder } from '@axe-core/playwright' + +const PAGES = ['/', '/outils', '/outils/signal', '/outils/tor', '/contribuer'] +const browser = await chromium.launch() +const context = await browser.newContext() +const page = await context.newPage() +let total = 0 + +for (const path of PAGES) { + await page.goto(`http://localhost:4173${path}`, { waitUntil: 'networkidle' }) + const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag22aa']).analyze() + total += results.violations.length + console.log(`\n═══ ${path} — ${results.violations.length} violation(s)`) + for (const v of results.violations) { + console.log(` [${v.impact}] ${v.id}: ${v.help}`) + for (const n of v.nodes.slice(0, 3)) console.log(` → ${n.target.join(' ')}`) + } +} + +await browser.close() +console.log(`\nTOTAL: ${total} violation(s)`) +process.exit(total > 0 ? 1 : 0) diff --git a/frontend/scripts/migrate-tools.mjs b/frontend/scripts/migrate-tools.mjs index 1278328..02a599d 100644 --- a/frontend/scripts/migrate-tools.mjs +++ b/frontend/scripts/migrate-tools.mjs @@ -8,6 +8,7 @@ * Idempotent : relançable, écrase la sortie. Usage : pnpm migrate */ import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises' +import { existsSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -31,7 +32,7 @@ function parseFrontmatter(raw) { if (i === -1) continue const key = line.slice(0, i).trim() let value = line.slice(i + 1).trim() - if (/^'.*'$/.test(value)) value = value.slice(1, -1) + if (/^'.*'$/.test(value) || /^".*"$/.test(value)) value = value.slice(1, -1) meta[key] = /^\d+$/.test(value) ? Number(value) : value } return [meta, raw.slice(m[0].length)] @@ -105,6 +106,12 @@ for (const file of files) { continue } seen.add(slug) + // ne jamais écraser une fiche retravaillée à la main (--force pour tout régénérer) + const outPath = path.join(OUT, 'outils', `${slug}.md`) + if (existsSync(outPath) && !process.argv.includes('--force')) { + written++ + continue + } const dir = dirById.get(slug) const nameOverride = slot(block, 'name') @@ -135,7 +142,7 @@ for (const file of files) { .map(([key, html]) => `## ${key}\n\n${html}`) .join('\n\n') - await writeFile(path.join(OUT, 'outils', `${slug}.md`), `---\n${front}\n---\n\n${sections}\n`) + await writeFile(outPath, `---\n${front}\n---\n\n${sections}\n`) written++ } } diff --git a/frontend/src/lib/adopted.svelte.ts b/frontend/src/lib/adopted.svelte.ts new file mode 100644 index 0000000..a111c5c --- /dev/null +++ b/frontend/src/lib/adopted.svelte.ts @@ -0,0 +1,39 @@ +import { browser } from '$app/environment' + +/** + * « Jès fèt » — les outils que la personne a adoptés. + * Effet Zeigarnik utilisé positivement (doctrine §1.2) : une progression + * choisie, pas un streak. Stocké uniquement sur l'appareil, jamais envoyé + * nulle part (pas de compte, pas de télémétrie — doctrine §3.3 : aucune + * métrique anxiogène ; le compteur n'apparaît qu'après le premier geste). + */ +const STORAGE_KEY = 'oki-jes' + +function initial(): string[] { + if (!browser) return [] + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as string[]) : [] + } catch { + return [] + } +} + +const state = $state({ slugs: initial() }) + +function persist() { + if (browser) localStorage.setItem(STORAGE_KEY, JSON.stringify(state.slugs)) +} + +export function isAdopted(slug: string): boolean { + return state.slugs.includes(slug) +} + +export function adoptedCount(): number { + return state.slugs.length +} + +export function toggleAdopted(slug: string) { + state.slugs = isAdopted(slug) ? state.slugs.filter((s) => s !== slug) : [...state.slugs, slug] + persist() +} diff --git a/frontend/src/lib/components/AdoptButton.svelte b/frontend/src/lib/components/AdoptButton.svelte new file mode 100644 index 0000000..35898c5 --- /dev/null +++ b/frontend/src/lib/components/AdoptButton.svelte @@ -0,0 +1,28 @@ + + + + + + diff --git a/frontend/src/lib/components/OfflineBadge.svelte b/frontend/src/lib/components/OfflineBadge.svelte new file mode 100644 index 0000000..2c6c196 --- /dev/null +++ b/frontend/src/lib/components/OfflineBadge.svelte @@ -0,0 +1,31 @@ + + + (offline = false)} onoffline={() => (offline = true)} /> + +{#if offline} +

    + {t('offline.badge')} — {t('offline.detail')} +

    +{/if} + + diff --git a/frontend/src/lib/components/SearchBar.svelte b/frontend/src/lib/components/SearchBar.svelte index 187abb0..330baa1 100644 --- a/frontend/src/lib/components/SearchBar.svelte +++ b/frontend/src/lib/components/SearchBar.svelte @@ -7,8 +7,9 @@ // raccourci « / » desktop (doctrine §2.2 — le clavier est roi) function onkeydown(e: KeyboardEvent) { if (e.key === '/' && document.activeElement !== input) { - const target = e.target as HTMLElement - if (target.closest('input, textarea, [contenteditable]')) return + const target = e.target + if (target instanceof HTMLElement && target.closest('input, textarea, [contenteditable]')) + return e.preventDefault() input.focus() } diff --git a/frontend/src/lib/components/ShortcutsDialog.svelte b/frontend/src/lib/components/ShortcutsDialog.svelte new file mode 100644 index 0000000..e681514 --- /dev/null +++ b/frontend/src/lib/components/ShortcutsDialog.svelte @@ -0,0 +1,99 @@ + + + + + + +

    {t('shortcuts.title')}

    +
    + {#each KEYS as { key, label } (key)} +
    +
    {key}
    +
    {t(label)}
    +
    + {/each} +
    +
    + +
    +
    + + diff --git a/frontend/src/lib/components/ToolCard.svelte b/frontend/src/lib/components/ToolCard.svelte index 3664778..ed6e1d6 100644 --- a/frontend/src/lib/components/ToolCard.svelte +++ b/frontend/src/lib/components/ToolCard.svelte @@ -1,5 +1,6 @@ @@ -17,17 +35,16 @@ + + +
    {@render children()}
    @@ -35,9 +52,7 @@ diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index aee7f1d..c6b9632 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -1,11 +1,27 @@ import { loadContent } from '$lib/content/tools.server' +import { ARCS, arcOf } from '$lib/site' export function load() { const { tools, categories } = loadContent() + const countBy = new Map() + for (const tool of tools) { + countBy.set(tool.category, (countBy.get(tool.category) ?? 0) + 1) + } + // le sommaire : catégories groupées par arc (Fondations → Contrôle → Avancé) + const arcs = ARCS.map((arc) => ({ + id: arc.id, + difficulty: arc.difficulty, + categories: categories + .filter((c) => arcOf(c.part) === arc.id) + .map((c) => ({ ...c, count: countBy.get(c.slug) ?? 0 })) + })) return { - categories, + arcs, toolCount: tools.length, - // les premiers gestes : outils débutant, un par catégorie - starters: tools.filter((t) => t.difficulty === 'beginner').slice(0, 3) + // les premiers gestes : outils débutant mis en avant + starters: tools + .filter((t) => t.difficulty === 'beginner') + .slice(0, 3) + .map(({ sections: _sections, ...card }) => card) } } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 69806be..b80cad9 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,8 +1,12 @@ @@ -16,6 +20,52 @@

    {t('hero.title')}

    {t('hero.subtitle')}

    {t('hero.cta')} · {data.toolCount} {t('directory.results')}

    + {#if jes > 0} +

    + + {t('adopted.progressBefore')} + {jes} + {t('adopted.progressAfter')} +

    + {/if} + + + +
    +

    {t('intro.title')}

    +

    {t('intro.p1')}

    +

    {t('intro.p2')}

    +

    {t('intro.p3')}

    +
    + + +
    +

    {t('sommaire.title')}

    +

    {t('sommaire.sub')}

    +
      + {#each data.arcs as arc, i (arc.id)} +
    1. +

      + + {t(`arc.${arc.id}`)} + {t(`difficulty.${arc.difficulty}`)} +

      + +
    2. + {/each} +
    @@ -29,15 +79,6 @@
    -
    -

    {t('directory.filterCategory')}

    -
      - {#each data.categories as cat (cat.slug)} -
    • {cat.title}
    • - {/each} -
    -
    - diff --git a/frontend/src/routes/contribuer/+page.svelte b/frontend/src/routes/contribuer/+page.svelte new file mode 100644 index 0000000..6719337 --- /dev/null +++ b/frontend/src/routes/contribuer/+page.svelte @@ -0,0 +1,91 @@ + + + + {t('contrib.title')} — {t('site.name')} + + + +
    +

    {t('contrib.title')}

    +

    {t('contrib.intro')}

    +
    + +
      + {#each WAYS as way, i (way.key)} +
    1. +

      {t(`contrib.${way.key}`)}

      +

      {t(`contrib.${way.key}Body`)}

      +

      {t(`contrib.${way.key}Cta`)} ↗

      +
    2. + {/each} +
    + +
    +

    {t('contrib.repo')}

    +

    {t('contrib.repoBody')}

    +

    {t('contrib.repoCta')} ↗

    +
    + + diff --git a/frontend/src/routes/outils/+page.svelte b/frontend/src/routes/outils/+page.svelte index b7740b7..b2dca94 100644 --- a/frontend/src/routes/outils/+page.svelte +++ b/frontend/src/routes/outils/+page.svelte @@ -74,10 +74,11 @@
    -

    + +

    {filtered.length} {filtered.length === 1 ? t('directory.resultOne') : t('directory.results')} -

    +

    {#if filtered.length === 0}

    {t('directory.empty')}

    @@ -124,6 +125,8 @@ .count { color: var(--oki-ink-2); font-size: var(--text-0); + font-family: var(--font-text); + font-weight: 400; } .empty { diff --git a/frontend/src/routes/outils/[slug]/+page.server.ts b/frontend/src/routes/outils/[slug]/+page.server.ts index 0eec6df..3df62ad 100644 --- a/frontend/src/routes/outils/[slug]/+page.server.ts +++ b/frontend/src/routes/outils/[slug]/+page.server.ts @@ -6,9 +6,10 @@ export function entries() { } export function load({ params }: { params: { slug: string } }) { - const { tools, categories } = loadContent() + const { tools, categories, gcf } = loadContent() const tool = tools.find((t) => t.slug === params.slug) if (!tool) error(404) const category = categories.find((c) => c.slug === tool.category) - return { tool, category } + // la jumelle créole si elle existe (affichée selon la langue choisie) + return { tool, category, gcf: gcf[tool.slug] ?? null } } diff --git a/frontend/src/routes/outils/[slug]/+page.svelte b/frontend/src/routes/outils/[slug]/+page.svelte index aefaa30..52ca290 100644 --- a/frontend/src/routes/outils/[slug]/+page.svelte +++ b/frontend/src/routes/outils/[slug]/+page.svelte @@ -1,9 +1,14 @@