PD-232 — Plan d'implémentation
📚 Navigation User Story
| Document | | | ---------- | -- | | 📋 [Spécification](PD-232-specification.md) | | | 🛠️ **Plan d'implémentation** | *(ce document)* | | ✅ [Critères d'acceptation](PD-232-acceptability.md) | | | 📝 [Retour d'expérience](PD-232-rex.md) | | [← Retour à site-vitrine](../PD-225-epic.md) · [↑ Index User Story](index.md)
1. Découpage en composants
Structure Content Collections
src/
├── content/
│ ├── config.ts # Schéma Zod des collections
│ └── faq/
│ ├── fr/
│ │ ├── security/
│ │ │ ├── 01-acces-documents.md
│ │ │ └── 02-zero-knowledge.md
│ │ ├── legal/
│ │ │ └── 01-valeur-legale.md
│ │ └── ...
│ └── en/
│ ├── security/
│ │ ├── 01-document-access.md
│ │ └── 02-zero-knowledge.md
│ └── ...
├── pages/
│ ├── fr/
│ │ └── faq.astro # Page FAQ FR
│ └── en/
│ └── faq.astro # Page FAQ EN
└── components/
└── FAQSection.astro # Composant accordéon FAQ
Composants clés
| Composant | Responsabilité |
content/config.ts | Validation schéma FAQ (Zod) |
content/faq/**/*.md | Entrées FAQ versionnées |
faq.astro | Page FAQ avec getCollection() |
FAQSection.astro | Rendu accordéon accessible |
2. Flux techniques
Ingestion des FAQ
Build Astro
│
▼
getCollection('faq')
│
├── Lit tous les fichiers src/content/faq/**/*.md
├── Valide frontmatter avec Zod
├── Parse Markdown → HTML
└── Retourne collection typée
│
▼
Rendu page FAQ
│
├── Groupe par catégorie
├── Trie par order
└── Génère <details> HTML
Ajout d'une question
1. Créer fichier src/content/faq/fr/category/XX-slug.md
2. Remplir frontmatter (question, category, order, lang)
3. Rédiger réponse en Markdown
4. Commit + Push
5. Build régénère la FAQ
3. Mapping invariants → mécanismes
| Invariant | Mécanisme technique |
| INV-1 : FAQ versionnée GitLab | Fichiers .md dans src/content/faq/ |
Schéma Content Collection
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const faqCollection = defineCollection({
type: 'content',
schema: z.object({
question: z.string().min(10),
category: z.enum([
'security',
'legal',
'business',
'usage',
'apps',
'transfer',
'data',
'misc'
]),
order: z.number().int().positive(),
lang: z.enum(['fr', 'en']),
translationKey: z.string().optional()
})
});
export const collections = {
faq: faqCollection
};
Exemple fichier FAQ
---
question: "ProbatioVault peut-il accéder à mes documents ?"
category: "security"
order: 1
lang: "fr"
translationKey: "security-document-access"
---
**Non.** ProbatioVault utilise une architecture **zero-knowledge** :
- Vos documents sont chiffrés sur votre appareil
- Le chiffrement se fait **avant** l'envoi
- Nous n'avons **jamais** accès à vos contenus
> La clé de déchiffrement n'existe que sur vos appareils.
Page FAQ
---
// src/pages/fr/faq.astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import FAQSection from '../../components/FAQSection.astro';
import { faqPageSchema } from '../../seo/schemas/faq';
const lang = 'fr';
// Récupérer toutes les FAQ françaises
const allFaq = await getCollection('faq', ({ data }) => data.lang === lang);
// Grouper par catégorie
const categories = {
security: { title: "Sécurité & Confidentialité", icon: "🔐", items: [] },
legal: { title: "Valeur probatoire & conformité", icon: "⚖️", items: [] },
business: { title: "Entreprises & Professionnels", icon: "🏢", items: [] },
usage: { title: "Fonctionnement & Utilisation", icon: "👤", items: [] },
apps: { title: "Applications & Plateformes", icon: "📱", items: [] },
transfer: { title: "Transfert & partage", icon: "🔄", items: [] },
data: { title: "Données & Portabilité", icon: "💾", items: [] },
misc: { title: "Divers", icon: "🧠", items: [] }
};
// Remplir et trier
for (const entry of allFaq) {
const cat = entry.data.category;
if (categories[cat]) {
categories[cat].items.push(entry);
}
}
for (const cat of Object.values(categories)) {
cat.items.sort((a, b) => a.data.order - b.data.order);
}
// JSON-LD FAQPage
const jsonLd = faqPageSchema(allFaq);
---
<BaseLayout
title="FAQ — Questions fréquentes | ProbatioVault"
description="Toutes les réponses à vos questions sur ProbatioVault"
ogImage="/og/faq-fr.png"
jsonLd={jsonLd}
>
<main id="main-content">
<h1>Questions fréquentes</h1>
{Object.entries(categories)
.filter(([_, cat]) => cat.items.length > 0)
.map(([key, category]) => (
<FAQSection
id={key}
title={category.title}
icon={category.icon}
items={category.items}
/>
))}
</main>
</BaseLayout>
Composant FAQSection
---
// src/components/FAQSection.astro
interface Props {
id: string;
title: string;
icon: string;
items: any[];
}
const { id, title, icon, items } = Astro.props;
---
<section class="faq-category" id={id}>
<h2>
<span class="icon" aria-hidden="true">{icon}</span>
{title}
</h2>
<div class="faq-list">
{items.map(async (entry) => {
const { Content } = await entry.render();
return (
<details class="faq-item">
<summary>
<span class="question">{entry.data.question}</span>
</summary>
<div class="answer">
<Content />
</div>
</details>
);
})}
</div>
</section>
<style>
.faq-item {
border-bottom: 1px solid var(--color-gris-clair);
padding: var(--space-md) 0;
}
summary {
cursor: pointer;
font-weight: var(--font-weight-semibold);
min-height: 48px;
display: flex;
align-items: center;
}
summary::-webkit-details-marker {
display: none;
}
.answer {
padding-top: var(--space-sm);
color: var(--color-text-secondary);
}
</style>
Schéma JSON-LD FAQPage
// src/seo/schemas/faq.ts
export function faqPageSchema(entries: any[]) {
return {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": entries.map(entry => ({
"@type": "Question",
"name": entry.data.question,
"acceptedAnswer": {
"@type": "Answer",
"text": entry.body // Markdown brut ou HTML
}
}))
};
}
4. Gestion des erreurs
| Erreur | Cause | Mitigation |
| Frontmatter invalide | Champ manquant | Zod validation (build fail) |
| Markdown cassé | Syntaxe incorrecte | Lint Markdown |
| Catégorie inconnue | Enum non respectée | Zod strict enum |
| Order dupliqué | Même numéro | Warning (non bloquant) |
Validation CI
#!/bin/bash
# scripts/validate-faq.sh
# Vérifier parité FR/EN
FR_COUNT=$(find src/content/faq/fr -name "*.md" | wc -l)
EN_COUNT=$(find src/content/faq/en -name "*.md" | wc -l)
if [ "$FR_COUNT" != "$EN_COUNT" ]; then
echo "WARNING: FAQ FR ($FR_COUNT) ≠ EN ($EN_COUNT)"
fi
# Vérifier que chaque fichier FR a son équivalent EN
for fr_file in src/content/faq/fr/**/*.md; do
en_file="${fr_file/fr/en}"
if [ ! -f "$en_file" ]; then
echo "WARNING: Traduction manquante: $en_file"
fi
done
5. Impacts sécurité
| Aspect | Mesure |
| XSS Markdown | Sanitization Astro native |
| Injection frontmatter | Validation Zod |
| Path traversal | Pas d'import dynamique utilisateur |
6. Hypothèses techniques
| Hypothèse | Justification |
| Content Collections stable | Feature Astro 2.0+ |
| Zod disponible | Dépendance Astro |
| Markdown standard | Pas d'extensions custom |
| FAQ < 100 entrées | Performance acceptable |
7. Points de vigilance
| Point | Risque | Action |
| Synchronisation FR/EN | FAQ désalignées | Script CI + translationKey |
| Performance | Trop de FAQ | Pagination si >100 |
| SEO | JSON-LD tronqué | Limiter taille réponses |
| Accessibilité | <details> non lu | Tester VoiceOver/NVDA |
| Migration | FAQ hardcodées existantes | Script de migration |
Fichiers à créer
| Fichier | Description |
src/content/config.ts | Schéma Zod |
src/content/faq/fr/**/*.md | ~27 entrées FR |
src/content/faq/en/**/*.md | ~27 entrées EN |
src/pages/fr/faq.astro | Page FAQ FR |
src/pages/en/faq.astro | Page FAQ EN |
src/components/FAQSection.astro | Composant accordéon |
src/seo/schemas/faq.ts | JSON-LD FAQPage |
scripts/validate-faq.sh | Validation CI |