Aller au contenu

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