Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus
Construisez un widget de feedback lecteur sécurisé et SSR-safe pour votre blog Docusaurus — sélection de texte, 4 types de feedback, API PHP avec nonce HMAC, rate limiting, déduplication, dashboard admin et notifications par email.
Traduction automatique
Cet article fait partie de la série Créer des composants Docusaurus :
- Modifier la façon dont Docusaurus génère les balises img
- Surcharger la génération des balises img avec Docusaurus
- Organisez votre contenu Docusaurus avec un composant Series personnalisé
- Ajouter un bouton « retour en haut » dans les articles Docusaurus
- Créer un plugin de recherche/remplacement pour Docusaurus
- Un composant pour afficher des snippets de code dans un blog Docusaurus
- Un composant Docusaurus qui prévient le lecteur que l'article a plus d'un an
- Afficher le changelog de votre article
- Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus
- Best Practice - Personnaliser le flux RSS de Docusaurus pour un contenu complet et des images
- Quelques vérifications sur les images de votre site Docusaurus
- Gestion des tags dans Docusaurus
- Automatiser les résumés TL;DR avec Gemini AI
- Le composant ProjectSetup - Une façon standardisée de partager des structures de projet
- Comment signaler un contenu assisté par IA dans un blog Docusaurus
- Donnez vie à vos tutoriels CLI avec un terminal machine à écrire
- Espiègleries de suricate : semer des easter eggs partout sur mon blog
- Tooltips de code assistés par l'IA dans Docusaurus — Explique-moi comme à un enfant de cinq ans
- Ajouter des réactions de lecteurs à votre blog Docusaurus
- Votre blog est illisible pour une IA. Voici la solution.
- Secouez votre téléphone, surprenez le suricate
- Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus
- Ask My Blog : transformer 259 articles en 2 160 questions cherchables
- La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait
- Dessiner une carte de mon propre blog
- Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus

Un lecteur repère un problème dans votre article — une faute de frappe, une erreur factuelle, une commande obsolète. Il n'a aucun moyen de vous le dire. Il passe à autre chose. Vous n'en saurez jamais rien. Cet article construit de A à Z un widget de feedback à sens unique : le lecteur sélectionne un passage, un popover apparaît avec quatre types de problème (faute de frappe, information incorrecte, contenu obsolète, suggestion), il ajoute éventuellement des détails, et le signalement atterrit dans un fichier JSON sur votre serveur avec une notification par email. L'API PHP est durcie avec des nonces HMAC, un honeypot, du rate limiting et de la déduplication. Le composant React est SSR-safe, utilise une machine à états propre et limite les envois via localStorage. Un dashboard admin associé, sur /typo-dashboard, vous permet de relire tous les signalements par type. Pas de base de données, pas de service tiers, aucune dépendance npm au-delà de ce que Docusaurus embarque déjà.
Votre blog est votre vitrine publique. Une faute de frappe qui traîne, une commande qui ne fonctionne plus, une API qui a changé — chaque lecteur qui tombe sur ce paragraphe vous coûte en crédibilité. Le problème, c'est que la plupart des lecteurs ne prendront pas la peine d'ouvrir une issue GitHub ou d'envoyer un email. Mais si vous rendez la chose vraiment triviale — un clic, là où ils se trouvent — un nombre surprenant le fera.
Cet article construit ce mécanisme de bout en bout.
Démonstration
Le widget est actif sur cette page même. Sélectionnez quelques mots de ce paragraphe avec votre souris (ou votre doigt sur un téléphone) : un popover apparaît juste sous la sélection, avec quatre types de feedback.

Le lecteur en choisit un, puis un petit formulaire lui permet d'ajouter un commentaire facultatif avant l'envoi. Le formulaire le dit clairement : c'est un signal à sens unique — le lecteur ne laisse pas d'email, donc personne n'attend de réponse.
De votre côté, chaque signalement atterrit dans un fichier JSON sur le serveur, vous recevez un email, et une page protégée par token liste tout, du plus récent au plus ancien, avec des compteurs par type :

Comment ça marche
La fonctionnalité se compose de deux moitiés indépendantes qui reprennent le schéma utilisé par le composant Reaction (votes like / dislike) :
Reader selects text
└─ mouseup / touchend fires on <article>
└─ popover shows 4 feedback-type buttons (phase: selecting)
└─ reader picks a type → optional details form (phase: confirming)
└─ POST api/typo.php
└─ validates nonce, honeypot, rate limits, dedup
└─ writes typo-data.json
└─ emails admin (6h cooldown)
- Pas de base de données — les signalements sont ajoutés à un fichier JSON, sous verrou de fichier.
- Pas de service tiers — un simple script PHP posé à côté de vos fichiers statiques.
- Défense en profondeur — neuf couches indépendantes bloquent les bots, les floods et les doublons (détaillées à la fin, pour les curieux).
- SSR-safe — le composant ne rend rien tant que le lecteur n'a pas sélectionné de texte.
Partie 1 — L'API PHP
Pourquoi PHP ?
Docusaurus produit un site statique. Il n'a aucun runtime côté serveur. Si vous êtes sur un hébergement mutualisé (OVH, Infomaniak, o2switch…), un petit PHP script posé à côté de vos fichiers statiques est le backend le moins contraignant possible — pas de process Node à gérer, pas de container Docker, pas de démarrage à froid serverless.
Structure des fichiers sur le serveur
File structure
Le script
L'API répond à trois requêtes : GET distribue un nonce (et, avec ?admin=TOKEN, la liste complète des signalements), POST enregistre un signalement, OPTIONS répond au preflight CORS. Chaque POST traverse les neuf couches décrites dans la section sécurité, à la fin de cet article, avant que quoi que ce soit ne soit écrit.
<?phpdeclare(strict_types=1);
$_envVars = file_exists(__DIR__ . '/.env') ? (parse_ini_file(__DIR__ . '/.env') ?: []) : [];
define('ADMIN_EMAIL', $_envVars['ADMIN_EMAIL'] ?? '');define('ADMIN_TOKEN', $_envVars['ADMIN_TOKEN'] ?? '');define('IP_HASH_SALT', $_envVars['IP_HASH_SALT'] ?? '');define('NONCE_SECRET', $_envVars['NONCE_SECRET'] ?? '');define('SITE_URL', 'https://www.yourdomain.com');
define('NONCE_COOLDOWN_SECONDS', 900);define('NOTIFY_COOLDOWN_SECONDS', 21600);define('GLOBAL_WINDOW', 60);define('GLOBAL_MAX', 20);define('IP_HOURLY_MAX', 10);define('IP_ARTICLE_MAX', 3);
// ── CORS ──────────────────────────────────────────────────────────────────────$allowedOrigins = [SITE_URL, 'http://localhost:3000'];$origin = $_SERVER['HTTP_ORIGIN'] ?? '';if ($origin !== '' && !in_array($origin, $allowedOrigins, true)) { http_response_code(403); exit;}
header('Content-Type: application/json; charset=utf-8');if ($origin !== '') { header("Access-Control-Allow-Origin: $origin"); header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type'); header('Vary: Origin');}if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
// ── Shared helpers ────────────────────────────────────────────────────────────function loadData(string $file): array { if (!file_exists($file)) return []; return json_decode(file_get_contents($file), true) ?: [];}
function saveData(string $file, array $data): void { $fp = fopen($file, 'c+'); if (!$fp) return; if (flock($fp, LOCK_EX)) { ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); flock($fp, LOCK_UN); } fclose($fp);}
function sanitizeSlug(string $raw): string { return substr(preg_replace('/[^a-z0-9\-\/]/', '', strtolower(trim($raw))), 0, 200);}
function jsonError(int $code, string $message): never { http_response_code($code); echo json_encode(['error' => $message]); exit;}
// ── Nonce ─────────────────────────────────────────────────────────────────────function makeNonce(int $window): string { return hash_hmac('sha256', $window . '|typo-report', NONCE_SECRET);}
function validateNonce(string $candidate): bool { $now = (int) floor(time() / NONCE_COOLDOWN_SECONDS); return hash_equals(makeNonce($now), $candidate) || hash_equals(makeNonce($now - 1), $candidate);}
// ── Input validation ──────────────────────────────────────────────────────────function validateAndSanitize(array $body): array { if (($body['website'] ?? '') !== '') return []; // honeypot
$slug = sanitizeSlug($body['slug'] ?? ''); if ($slug === '') jsonError(400, 'Missing slug');
$text = trim($body['text'] ?? ''); if (mb_strlen($text) < 3 || mb_strlen($text) > 150 || !preg_match('/^\P{C}+$/u', $text)) jsonError(400, 'Invalid text');
$allowedTypes = ['typo', 'incorrect', 'outdated', 'suggestion']; $type = trim($body['type'] ?? ''); if (!in_array($type, $allowedTypes, true)) jsonError(400, 'Invalid type');
$comment = trim($body['comment'] ?? ''); if ($comment !== '' && (mb_strlen($comment) > 300 || !preg_match('/^\P{C}+$/u', $comment))) jsonError(400, 'Invalid comment');
$context = mb_substr(trim($body['context'] ?? ''), 0, 300);
return compact('slug', 'text', 'type', 'comment', 'context');}
// ── Rate limiting ─────────────────────────────────────────────────────────────function checkRateLimits(string $slug): void { $file = __DIR__ . '/typo-ratelimit.json'; $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $ipKey = hash('sha256', IP_HASH_SALT . $ip); $now = time();
$fp = fopen($file, 'c+'); if (!$fp || !flock($fp, LOCK_EX)) { if ($fp) fclose($fp); return; }
$raw = stream_get_contents($fp); $data = $raw !== '' ? (json_decode($raw, true) ?: []) : [];
$g = $data['__global__'] ?? ['window_start' => $now, 'count' => 0]; if ($now - $g['window_start'] > GLOBAL_WINDOW) $g = ['window_start' => $now, 'count' => 0]; if (++$g['count'] > GLOBAL_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); } $data['__global__'] = $g;
$ipData = $data[$ipKey] ?? ['hourly' => [], 'articles' => []]; $ipData['hourly'] = array_values(array_filter($ipData['hourly'], fn($ts) => $ts > $now - 3600)); if (count($ipData['hourly']) >= IP_HOURLY_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); }
$artKey = $ipKey . '|' . $slug; $artData = array_values(array_filter($data[$artKey] ?? [], fn($ts) => $ts > $now - 86400)); if (count($artData) >= IP_ARTICLE_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); }
$ipData['hourly'][] = $now; $artData[] = $now; $data[$ipKey] = $ipData; $data[$artKey] = $artData;
ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); flock($fp, LOCK_UN); fclose($fp);}
// ── Dedup + store ─────────────────────────────────────────────────────────────function isDuplicate(string $slug, string $text): bool { $hash = hash('sha256', $slug . '|' . mb_strtolower($text)); foreach (loadData(__DIR__ . '/typo-data.json')[$slug] ?? [] as $r) { if (($r['text_hash'] ?? '') === $hash) return true; } return false;}
function storeReport(array $fields): void { ['slug' => $slug, 'text' => $text, 'type' => $type, 'comment' => $comment, 'context' => $context] = $fields;
$ip = $_SERVER['REMOTE_ADDR'] ?? ''; $fp = fopen(__DIR__ . '/typo-data.json', 'c+'); if (!$fp) return; if (flock($fp, LOCK_EX)) { $raw = stream_get_contents($fp); $data = $raw !== '' ? (json_decode($raw, true) ?: []) : []; $data[$slug][] = [ 'id' => bin2hex(random_bytes(6)), 'type' => $type, 'text' => $text, 'text_hash' => hash('sha256', $slug . '|' . mb_strtolower($text)), 'context' => $context, 'comment' => $comment, 'ts' => time(), 'ip_hash' => hash('sha256', IP_HASH_SALT . $ip), ]; ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); flock($fp, LOCK_UN); } fclose($fp);}
function maybeNotifyTypo(string $slug, string $text, string $type): void { $file = __DIR__ . '/typo-notifications.json'; $throttle = loadData($file); if (time() - ($throttle[$slug] ?? 0) < NOTIFY_COOLDOWN_SECONDS) return;
$typeLabels = [ 'typo' => '🔤 Typo', 'incorrect' => '❌ Incorrect info', 'outdated' => '⏰ Outdated content', 'suggestion' => '💡 Suggestion', ]; $typeLabel = $typeLabels[$type] ?? $type; $articleUrl = SITE_URL . '/' . $slug; $subject = "[Blog] Feedback ($type) on: $slug"; $body = implode("\n", [ "A reader flagged an issue.", "", "Article : $articleUrl", "Type : $typeLabel", "Text : $text", "", "Review all reports: GET " . SITE_URL . "/api/typo.php?admin=TOKEN", ]); $headers = "From: noreply@yourdomain.com\r\nContent-Type: text/plain; charset=utf-8";
if (@mail(ADMIN_EMAIL, $subject, $body, $headers)) { $throttle[$slug] = time(); saveData($file, $throttle); }}
// ── Router ────────────────────────────────────────────────────────────────────$method = $_SERVER['REQUEST_METHOD'];
if ($method === 'GET') { if (array_key_exists('nonce', $_GET)) { echo json_encode(['nonce' => makeNonce((int) floor(time() / NONCE_COOLDOWN_SECONDS))]); exit; } if (array_key_exists('admin', $_GET)) { if (ADMIN_TOKEN === '' || ($_GET['admin'] ?? '') !== ADMIN_TOKEN) jsonError(403, 'Forbidden'); echo json_encode(loadData(__DIR__ . '/typo-data.json')); exit; } jsonError(400, 'Missing parameter');}
if ($method !== 'POST') jsonError(405, 'Method not allowed');
$body = json_decode(file_get_contents('php://input'), true) ?? [];$nonce = $body['nonce'] ?? '';if ($nonce === '' || !validateNonce($nonce)) jsonError(403, 'Invalid nonce');
$fields = validateAndSanitize($body);if ($fields === []) { echo json_encode(['ok' => true]); exit; }
['slug' => $slug, 'text' => $text] = $fields;
checkRateLimits($slug);
if (isDuplicate($slug, $text)) { echo json_encode(['ok' => true]); exit; }
storeReport($fields);maybeNotifyTypo($slug, $text, $fields['type']);
echo json_encode(['ok' => true]);Ce script PHP implémente une API de signalement de typos pour un blog. Il charge la configuration depuis un fichier .env, valide les requêtes CORS, puis expose deux endpoints : l'un pour générer des jetons de sécurité (nonce), l'autre pour recevoir et stocker les signalements de typos avec protection contre les abus via limitation de débit et déduplications.
Protéger les fichiers JSON
Sans la règle .htaccess livrée dans api/.htaccess, n'importe qui peut faire GET https://yourdomain.com/api/typo-data.json et lire tous les signalements.
Les secrets
Le fichier api/.env contient deux secrets. Générez-les avec :
Partie 2 — Le composant React
La sécurité SSR d'abord
Docusaurus rend les pages côté serveur (Node.js) avant de les hydrater dans le navigateur. Tout code qui touche à window, document ou localStorage fera planter la passe SSR s'il s'exécute au niveau du module ou dans le corps du composant.
Les règles :
- Tout accès au DOM se fait dans un
useEffect— il ne s'exécute que dans le navigateur. - L'état est initialisé à
"idle", pas à une valeur lue depuis le navigateur. - Le composant retourne
nullà l'état idle — pas de DOM, pas de mismatch SSR.
Machine à états
Six phases, linéaires avec une seule bifurcation :
idle
└─ user selects text → selecting
└─ user picks a feedback type → confirming
└─ form submitted → submitting
├─ success → done
└─ error → error
done et error affichent un badge que l'on peut fermer et qui remet l'état à idle. selecting et confirming repassent à idle si l'utilisateur clique en dehors du popover. confirming propose un lien « changer » qui ramène à selecting.
Les helpers localStorage
Avant que le popover de sélection du type n'apparaisse, deux gardes côté client s'exécutent :
-
Rate limit — maximum 5 envois par navigateur et par heure. Les timestamps sont stockés dans un tableau JSON dans
localStoragesous la clé"typo_reports". Les entrées de plus de 24 heures sont purgées à chaque écriture. -
Déduplication — un hash FNV-1a de
slug + "|" + lowercase(text)est stocké à côté du timestamp. Si le même texte a déjà été signalé depuis ce navigateur (quel que soit le type), le composant saute directement àdone.
FNV-1a est utilisé plutôt que crypto.subtle.digest parce qu'il est synchrone, et le risque de collision est acceptable dans ce cas d'usage.
function fnv1a(str) {
let hash = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = (hash * 0x01000193) >>> 0;
}
return hash.toString(16);
}
Les types de feedback
Quatre types couvrent la grande majorité des problèmes qu'un lecteur peut remarquer :
const FEEDBACK_TYPES = [
{ id: "typo", icon: "🔤", label: "Typo" },
{ id: "incorrect", icon: "❌", label: "Incorrect" },
{ id: "outdated", icon: "⏰", label: "Outdated" },
{ id: "suggestion", icon: "💡", label: "Suggestion" },
];
Ils sont affichés dans une grille 2×2. L'API PHP valide le champ type contre cette même whitelist — toute autre valeur renvoie un 400.
Capturer le contexte
Quand l'utilisateur sélectionne du texte, le composant enregistre ±100 caractères de texte environnant depuis article.innerText. Ce contexte est envoyé à l'API pour que vous puissiez localiser la phrase exacte sans ouvrir l'article :
const fullText = article.innerText || "";
const idx = fullText.indexOf(text);
contextRef.current = idx !== -1
? fullText.slice(Math.max(0, idx - 100), idx + text.length + 100)
: "";
Positionnement du tooltip
Le popover apparaît juste sous la sélection. window.scrollY + rect.bottom + 8 le place 8px sous le bord inférieur du texte sélectionné. Pour éviter que le formulaire de 280px de large ne déborde du bord droit du viewport :
const left = Math.min(window.scrollX + rect.left, window.innerWidth - 296);
Le composant
import { useState, useEffect, useRef, useCallback, type FormEvent, type JSX,} from "react";import useDocusaurusContext from "@docusaurus/useDocusaurusContext";import styles from "./styles.module.css";
const STORAGE_KEY = "typo_reports"; // [{ts, hash}]const MAX_PER_HOUR = 5;
const FEEDBACK_TYPES = [ { id: "typo", icon: "🔤", label: "Typo" }, { id: "incorrect", icon: "❌", label: "Incorrect" }, { id: "outdated", icon: "⏰", label: "Outdated" }, { id: "suggestion", icon: "💡", label: "Suggestion" },];
interface StoredReport { ts: number; hash: string;}
// FNV-1a 32-bit hash (no crypto needed for client-side dedup)function fnv1a(str: string): string { let hash = 0x811c9dc5; for (let i = 0; i < str.length; i++) { hash ^= str.charCodeAt(i); hash = (hash * 0x01000193) >>> 0; } return hash.toString(16);}
function getStored(): StoredReport[] { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); } catch { return []; }}
function isLocalRateLimited() { const now = Date.now(); return getStored().filter((r) => r.ts > now - 3_600_000).length >= MAX_PER_HOUR;}
function isLocalDuplicate(slug: string, text: string) { const hash = fnv1a(slug + "|" + text.toLowerCase()); return getStored().some((r) => r.hash === hash);}
function recordLocalSubmission(slug: string, text: string) { const now = Date.now(); const hash = fnv1a(slug + "|" + text.toLowerCase()); const pruned = getStored().filter((r) => r.ts > now - 86_400_000); try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...pruned, { ts: now, hash }])); } catch {}}
interface Props { metadata?: { permalink?: string; };}
type Phase = "idle" | "selecting" | "confirming" | "submitting" | "done" | "error";
export default function TypoReport({ metadata }: Props): JSX.Element | null { const { siteConfig } = useDocusaurusContext(); const slug = metadata?.permalink?.replace(/^\/|\/$/g, "") ?? ""; const apiUrl = `${siteConfig.url}/api/typo.php`;
// State machine: idle → selecting → confirming → submitting → done | error const [phase, setPhase] = useState<Phase>("idle"); const [selectedText, setSelectedText] = useState(""); const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 }); const [feedbackType, setFeedbackType] = useState(""); const [comment, setComment] = useState("");
const nonceRef = useRef<string | null>(null); const articleRef = useRef<HTMLElement | null>(null); const wrapperRef = useRef<HTMLDivElement | null>(null); const contextRef = useRef(""); // ±100 chars around selection
// Fetch nonce + wire up DOM listeners on mount. useEffect(() => { if (!slug) return;
fetch(`${apiUrl}?nonce`) .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data?.nonce) nonceRef.current = data.nonce; }) .catch(() => {});
const article = document.querySelector<HTMLElement>("article") || document.querySelector<HTMLElement>(".theme-doc-markdown"); if (!article) return; articleRef.current = article;
// Arrow-assigned, not a `function` declaration: that lets TS carry the // `if (!article) return` narrowing above into this closure, so `article` is // non-null on lines below without a `!` assertion. const handleSelection = () => { const sel = window.getSelection(); if (!sel || sel.isCollapsed) return;
const text = sel.toString().trim(); if (text.length < 3) return;
const range = sel.getRangeAt(0); const rect = range.getBoundingClientRect();
if (!article.contains(range.commonAncestorContainer)) return;
const fullText = article.innerText || ""; const idx = fullText.indexOf(text); contextRef.current = idx !== -1 ? fullText.slice(Math.max(0, idx - 100), idx + text.length + 100) : "";
const top = window.scrollY + rect.bottom + 8; const left = Math.min(window.scrollX + rect.left, window.innerWidth - 296);
setSelectedText(text); setTooltipPos({ top, left }); setPhase("selecting"); };
function handleMouseDown(e: MouseEvent) { if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { setPhase("idle"); } }
article.addEventListener("mouseup", handleSelection); article.addEventListener("touchend", handleSelection); document.addEventListener("mousedown", handleMouseDown); return () => { article.removeEventListener("mouseup", handleSelection); article.removeEventListener("touchend", handleSelection); document.removeEventListener("mousedown", handleMouseDown); }; }, [slug, apiUrl]);
const handleTypeSelect = useCallback( (type: string) => { if (isLocalRateLimited()) { setPhase("error"); return; } if (isLocalDuplicate(slug, selectedText)) { setPhase("done"); return; } setFeedbackType(type); setComment(""); setPhase("confirming"); }, [slug, selectedText], );
const handleSubmit = useCallback( async (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); setPhase("submitting");
let nonce = nonceRef.current; if (!nonce) { try { const r = await fetch(`${apiUrl}?nonce`); const d = r.ok ? await r.json() : null; nonce = d?.nonce ?? null; nonceRef.current = nonce; } catch {} }
const honeypot = (e.currentTarget.elements.namedItem("website") as HTMLInputElement | null) ?.value ?? "";
try { const res = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, text: selectedText, type: feedbackType, comment, context: contextRef.current, website: honeypot, nonce: nonce ?? "", }), }); if (!res.ok) { setPhase("error"); return; } recordLocalSubmission(slug, selectedText); setPhase("done"); } catch { setPhase("error"); } }, [slug, selectedText, feedbackType, comment, apiUrl], );
const handleBack = useCallback(() => setPhase("selecting"), []); const handleCancel = useCallback(() => setPhase("idle"), []); const handleDismiss = useCallback(() => setPhase("idle"), []);
// SSR-safe: render nothing when idle. if (phase === "idle") return null;
const typeInfo = FEEDBACK_TYPES.find((t) => t.id === feedbackType);
return ( <div ref={wrapperRef} className={styles.wrapper} style={{ top: tooltipPos.top, left: tooltipPos.left }} > {phase === "selecting" && ( <div className={styles.typeCard}> <p className={styles.typePrompt}>What kind of issue?</p> <div className={styles.typeGrid}> {FEEDBACK_TYPES.map(({ id, icon, label }) => ( <button key={id} className={styles.typeBtn} onClick={() => handleTypeSelect(id)} > <span className={styles.typeIcon}>{icon}</span> <span>{label}</span> </button> ))} </div> <button className={styles.cancelSmall} onClick={handleCancel}> Cancel </button> </div> )}
{phase === "confirming" && ( <form className={styles.form} onSubmit={handleSubmit}> <div className={styles.selectedPreview}> " {selectedText.length > 80 ? selectedText.slice(0, 80) + "…" : selectedText} " </div> {typeInfo && ( <div className={styles.typeBadge}> {typeInfo.icon} {typeInfo.label} <button type="button" className={styles.changeTypeBtn} onClick={handleBack}> change </button> </div> )} <textarea className={styles.commentInput} placeholder="Optional details (max 300 chars)" maxLength={300} value={comment} onChange={(e) => setComment(e.target.value)} /> {/* Honeypot — bots fill this, humans don't see it */} <input name="website" className={styles.honeypot} tabIndex={-1} autoComplete="off" defaultValue="" /> <div className={styles.actions}> <button type="submit" className={styles.btnPrimary}> Send </button> <button type="button" className={styles.btnSecondary} onClick={handleCancel}> Cancel </button> </div> <p className={styles.disclaimer}>One-way signal — no reply will be sent.</p> </form> )}
{phase === "submitting" && <div className={styles.status}>Sending…</div>}
{phase === "done" && ( <div className={styles.status}> Thanks for the feedback! ✓ <button className={styles.dismissBtn} onClick={handleDismiss} aria-label="Dismiss" > ✕ </button> </div> )}
{phase === "error" && ( <div className={styles.statusError}> Could not send. <button className={styles.dismissBtn} onClick={handleDismiss} aria-label="Dismiss" > ✕ </button> </div> )} </div> );}Ce composant React affiche un widget de signalement de typos qui apparaît quand l'utilisateur sélectionne du texte dans un article. Il gère un flux multi-étapes (sélection du type de problème, confirmation avec commentaire optionnel, envoi) et inclut des protections côté client comme le rate-limiting par heure et la déduplication des signalements basée sur un hash du contenu.
Trois points à noter :
touchendest enregistré en plus demouseuppour que le widget fonctionne sur les appareils tactiles.handleTypeSelectexécute les vérifications de rate limit et de déduplication au moment du choix du type, pas à la sélection — cela évite de consommer un slot de rate limit quand l'utilisateur sélectionne simplement du texte pour le copier.- Le paragraphe
disclaimer(« Signal à sens unique — aucune réponse ne sera envoyée ») est volontaire : le dire explicitement évite la frustration.
Le module CSS utilise partout les propriétés personnalisées --ifm-* de Docusaurus, donc le widget suit votre thème et le mode sombre sans effort supplémentaire.
Partie 3 — L'intégration dans Docusaurus
C'est ici qu'intervient le swizzling de Docusaurus. Plutôt que de modifier directement le fichier du thème en amont, Docusaurus vous permet de copier n'importe quel composant du thème dans src/theme/ et de le surcharger. Si vous n'avez pas encore swizzlé BlogPostItem, lancez :
Cela crée src/theme/BlogPostItem/index.js. Ouvrez-le et ajoutez :
// existing imports…import TypoReport from "@site/src/components/TypoReport";
export default function BlogPostItem({ children, className }) { const { metadata, isBlogPostPage } = useBlogPost(); // …
return ( <> <BlogPostItemContainer className={clsx(containerClassName, className)}> <BlogPostItemHeader /> <BlogPostItemContent>{children}</BlogPostItemContent> <BlogPostItemFooter />
{isBlogPostPage && ( <TypoReport metadata={metadata} /> )} </BlogPostItemContainer> </> );}Ce composant affiche un article de blog avec son en-tête, contenu et pied de page. Il intègre conditionnellement un composant TypoReport qui ne s'affiche que quand on consulte la page complète de l'article, permettant aux lecteurs de signaler des fautes de frappe.
Le garde isBlogPostPage est essentiel — sans lui, le composant se monterait aussi sur la page de liste du blog, où il n'y a pas d'élément article.
Partie 4 — Le dashboard admin
L'endpoint admin PHP (GET ?admin=TOKEN) renvoie du JSON brut. Plutôt que de lire cette réponse brute, une petite page React sur /typo-dashboard la présente dans un format lisible : elle aplatit tous les tableaux de signalements par slug en une seule liste triée du plus récent au plus ancien, et affiche des compteurs récapitulatifs par type en haut.
Le dashboard est protégé par token : il réutilise la même clé reactions_admin_token dans localStorage que le dashboard des réactions, donc vous ne vous authentifiez qu'une fois pour les deux.
Si vous avez une page d'accueil admin, ajoutez cette entrée à son tableau TOOLS dans src/pages/admin.js :
{ icon: "🔤", title: "Feedback Reports", description: "Reader-flagged issues — typos, incorrect info, outdated content, suggestions.", href: "/typo-dashboard", accent: "#e07b00",},Cet objet définit une carte de navigation pour un tableau de bord de signalements. Il contient un émoji comme icône visuelle, un titre descriptif, une explication de son utilité, un lien vers la page correspondante, et une couleur d'accent pour l'interface.
Créer le composant
Créez ces fichiers dans votre site Docusaurus, puis copiez api/.env.example vers api/.env et collez-y les deux secrets générés plus haut :
Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.
Montez ensuite le composant dans src/theme/BlogPostItem/index.js comme montré en partie 3.
Déploiement
L'API utilise declare(strict_types=1) et un type de retour never sur jsonError(), donc votre serveur doit tourner en PHP 8.1 ou plus récent :
Les trois fichiers JSON doivent être accessibles en écriture par le process du serveur web :
Une fois déployé, vérifiez l'aller-retour complet avant d'annoncer la fonctionnalité :
# 1. Fetch a nonceNONCE=$(curl -s "https://yourdomain.com/api/typo.php?nonce" | jq -r .nonce)
# 2. Submit a test reportcurl -s -X POST "https://yourdomain.com/api/typo.php" \ -H "Content-Type: application/json" \ -d "{\"slug\":\"test/smoke\",\"text\":\"hello world\",\"type\":\"typo\",\"comment\":\"\",\"context\":\"\",\"website\":\"\",\"nonce\":\"$NONCE\"}"# → {"ok":true}
# 3. Confirm the admin endpointcurl -s "https://yourdomain.com/api/typo.php?admin=YOUR_TOKEN" | jq .# → {"test/smoke": [{"type":"typo",...}]}
# 4. Confirm .htaccess is protecting the filecurl -I "https://yourdomain.com/api/typo-data.json"# → HTTP/1.1 403 Forbidden
# 5. Confirm POST without a nonce is rejectedcurl -s -X POST "https://yourdomain.com/api/typo.php" \ -H "Content-Type: application/json" \ -d '{"slug":"test","text":"hello world","type":"typo","comment":"","context":"","website":""}'# → {"error":"Invalid nonce"}
# 6. Confirm an invalid type is rejectedcurl -s -X POST "https://yourdomain.com/api/typo.php" \ -H "Content-Type: application/json" \ -d "{\"slug\":\"test\",\"text\":\"hello world\",\"type\":\"question\",\"comment\":\"\",\"context\":\"\",\"website\":\"\",\"nonce\":\"$NONCE\"}"# → {"error":"Invalid type"}Ce script teste une API de signalement de typos en effectuant plusieurs vérifications : d'abord il récupère un jeton de sécurité (nonce) et l'utilise pour soumettre un rapport valide, puis il vérifie que l'endpoint admin fonctionne, que les données sont protégées, et qu'enfin les validations de sécurité rejettent bien les requêtes malformées ou non autorisées.
Nettoyez après le test :
echo '{}' > api/typo-data.json
echo '{}' > api/typo-ratelimit.json
echo '{}' > api/typo-notifications.json
Ce script initialise trois fichiers JSON dans le répertoire api en les créant avec un objet vide comme contenu. C'est une étape de configuration typique pour préparer l'environnement avant le démarrage d'une application qui utilise ces fichiers pour stocker des données.
Conception de la sécurité — couche par couche (sautez si vous voulez juste que ça tourne)
Chaque POST traverse neuf couches indépendantes avant qu'un signalement ne soit écrit.
Couche 1 — CORS
Seuls https://www.yourdomain.com et http://localhost:3000 (dev) sont autorisés comme Origin. Toute autre origine reçoit un 403 silencieux. Les requêtes same-origin n'envoient pas d'en-tête Origin et passent.
Couche 2 — Whitelist des méthodes
L'API ne répond qu'à GET (nonce + admin), POST (envoi) et OPTIONS (preflight). Tout le reste reçoit 405 Method Not Allowed.
Couche 3 — Nonce HMAC
Sans cela, quiconque connaît l'URL de votre API peut envoyer des signalements arbitraires avec une simple commande curl. Le nonce est un token HMAC basé sur le temps :
$nonce = hash_hmac('sha256', floor(time() / 900) . '|typo-report', NONCE_SECRET);
Le token est valable pour la fenêtre de 15 minutes en cours et la précédente (pour éviter une expiration gênante quand un utilisateur récupère le nonce à la minute 14 et envoie à la minute 15). Il n'y a aucun stockage — le token s'auto-valide. Chaque POST doit inclure cette valeur dans son body.
Couche 4 — Validation des entrées, whitelist de types et honeypot
Le honeypot est un <input name="website"> caché, invisible pour les humains mais rempli par les bots. Si website !== "", l'API renvoie 200 OK sans rien stocker.
Les vraies entrées sont ensuite validées :
slug— alphanumérique, tirets, slashes, 200 caractères maxtext— 3 à 150 caractères, Unicode imprimable uniquement (\P{C}— pas de caractères de contrôle)type— doit valoirtypo,incorrect,outdatedousuggestion(toute autre valeur → 400)comment— facultatif, 300 caractères max, même filtre de caractèrescontext— facultatif, 300 caractères max, trimmé
Couche 5 — Rate limit global
Une fenêtre glissante de 60 secondes avec un maximum de 20 requêtes toutes IP confondues. Cela protège des pics soudains. L'état de la fenêtre est stocké sous une clé __global__ dans typo-ratelimit.json, mis à jour dans un unique bloc LOCK_EX.
Couche 6 — Rate limits par IP
Deux limites suivies indépendamment :
- 10 signalements max par IP et par heure glissante
- 3 signalements max par IP, par article, par 24 heures
Les adresses IP ne sont jamais stockées en clair. Elles sont hashées avec un sel secret : hash('sha256', IP_HASH_SALT . $ip). À sens unique — vous ne pouvez pas retrouver l'IP d'origine — mais suffisamment cohérent pour agréger.
Le script lit REMOTE_ADDR, jamais X-Forwarded-For : sur un hébergement mutualisé, X-Forwarded-For peut être falsifié par le client, ce qui permettrait à une seule IP d'en usurper des milliers et de contourner les limites par IP.
Couche 7 — Déduplication
Un hash SHA-256 de slug + "|" + lowercase(text) est stocké dans text_hash sur chaque signalement. Avant l'écriture, les signalements existants pour ce slug sont parcourus. Si le hash existe déjà, l'API renvoie 200 OK sans rien stocker. Cela absorbe silencieusement les envois en double.
Couche 8 — Écritures sous verrou de fichier
PHP sur hébergement mutualisé est multi-process. Chaque écriture utilise fopen + flock(LOCK_EX) + ftruncate + rewind + fwrite + flock(LOCK_UN) + fclose. La vérification du rate limit et l'écriture se font dans le même verrou pour éviter les races TOCTOU.
Couche 9 — Throttle des emails
Vous ne voulez pas d'une boîte mail inondée si un article populaire récolte 50 signalements en une heure. Le système de notification consulte typo-notifications.json pour connaître le timestamp du dernier email par article. Un nouvel email n'est envoyé que si plus de 6 heures se sont écoulées depuis le précédent.
Limitations connues
Débordement du tooltip sur le bord gauche
Le clamp Math.min(left, window.innerWidth - 296) empêche le formulaire de 280px de sortir de l'écran à droite. Un débordement à gauche (sélection près du bord gauche sur des viewports très étroits) n'est pas géré.
NAT en hébergement mutualisé
Les utilisateurs derrière le même NAT (par exemple le Wi-Fi d'un bureau) partagent la même REMOTE_ADDR. Si trois personnes différentes du même bureau signalent des problèmes sur le même article en 24 heures, la quatrième sera bloquée par le rate limit. C'est un compromis acceptable vu la contrainte de l'hébergement mutualisé.
Conclusion
Les lecteurs n'ouvriront jamais une issue GitHub pour vous dire qu'une commande est obsolète — mais ils sélectionneront une phrase et cliqueront sur un bouton. Avec environ 250 lignes de PHP et 300 lignes de React, chacun de ces signaux arrive désormais dans votre boîte mail, avec la phrase exacte et son contexte, sans base de données ni service tiers.
L'architecture est volontairement simple : chaque morceau peut être compris, débogué et modifié en lisant un seul fichier. Si vous voulez aussi que vos lecteurs vous disent ce qu'ils ont aimé, le composant Reaction utilise exactement le même schéma PHP + JSON.
Cet article fait partie de la série Créer des composants Docusaurus :
- Modifier la façon dont Docusaurus génère les balises img
- Surcharger la génération des balises img avec Docusaurus
- Organisez votre contenu Docusaurus avec un composant Series personnalisé
- Ajouter un bouton « retour en haut » dans les articles Docusaurus
- Créer un plugin de recherche/remplacement pour Docusaurus
- Un composant pour afficher des snippets de code dans un blog Docusaurus
- Un composant Docusaurus qui prévient le lecteur que l'article a plus d'un an
- Afficher le changelog de votre article
- Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus
- Best Practice - Personnaliser le flux RSS de Docusaurus pour un contenu complet et des images
- Quelques vérifications sur les images de votre site Docusaurus
- Gestion des tags dans Docusaurus
- Automatiser les résumés TL;DR avec Gemini AI
- Le composant ProjectSetup - Une façon standardisée de partager des structures de projet
- Comment signaler un contenu assisté par IA dans un blog Docusaurus
- Donnez vie à vos tutoriels CLI avec un terminal machine à écrire
- Espiègleries de suricate : semer des easter eggs partout sur mon blog
- Tooltips de code assistés par l'IA dans Docusaurus — Explique-moi comme à un enfant de cinq ans
- Ajouter des réactions de lecteurs à votre blog Docusaurus
- Votre blog est illisible pour une IA. Voici la solution.
- Secouez votre téléphone, surprenez le suricate
- Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus
- Ask My Blog : transformer 259 articles en 2 160 questions cherchables
- La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait
- Dessiner une carte de mon propre blog
- Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus






