Cours Vibe coding avec l'IA · Module 5: Clone de Reddit
Support Markdown - Texte formaté
Un pirate veut lister les provisions du voyage à puces et mettre en gras l'avertissement sur le Kraken, mais le champ de texte n'accepte que du texte brut. Autoriser le HTML serait dangereux : quelqu'un pourrait coller un script. Reddit, GitHub et Discord règlent ça avec Markdown.
Qu'est-ce que Markdown
Markdown est un langage simple de mise en forme de texte : titres, gras, listes et liens s'écrivent avec des caractères ordinaires, lisibles même sans rendu :
1# Titre 1
2## Titre 2
3
4**gras** et *italique*
5
6- Puce
7- Liste
8
9[Lien](https://example.com)
10
11```code```Un dièse en début de ligne crée un titre, un tiret un élément de liste, et le gras se compose de trois parties sur une ligne : **, le texte, puis **. Tu stockes ce texte en base comme n'importe quel autre et ne le transformes en post formaté qu'à l'affichage.
La bibliothèque react-markdown
Pour afficher du Markdown dans un composant React, on utilise la bibliothèque react-markdown :
1npm install react-markdownLe composant ReactMarkdown reçoit le texte Markdown comme enfant et le transforme en éléments React ordinaires :
1import ReactMarkdown from 'react-markdown';
2
3function Post({ content }: { content: string }) {
4 return (
5 <div className="prose">
6 <ReactMarkdown>{content}</ReactMarkdown>
7 </div>
8 );
9}
10
11// Utilisation
12const markdown = `
13# Récit de pirate
14
15Ceci est du **gras** et ceci est de l'*italique*.
16
17- Rhum
18- Trésor
19- Aventure
20
21[Black Pearl](https://example.com)
22`;
23
24<Post content={markdown} />react-markdown construit un arbre d'éléments au lieu d'injecter du HTML tout fait, il est donc sûr par défaut : le HTML brut du contenu ne s'exécute pas, il s'affiche comme du texte. On met la classe prose sur la div qui l'entoure, car les versions récentes de la bibliothèque n'acceptent plus className directement.
Les styles avec Tailwind Typography
Tailwind réinitialise volontairement les styles des titres et des listes, donc le Markdown rendu ressemble à un bloc gris uniforme. Le plugin @tailwindcss/typography ajoute des styles tout prêts :
1npm install @tailwindcss/typographyDans Tailwind 3, qu'on trouve encore dans beaucoup d'anciens projets, le plugin s'activait dans le fichier de configuration :
1module.exports = {
2 plugins: [
3 require('@tailwindcss/typography'),
4 ],
5}Tailwind 4 ne détecte plus ce fichier automatiquement : tu ajoutes le plugin en une ligne dans ton fichier CSS principal :
1@import "tailwindcss";
2@plugin "@tailwindcss/typography";Dans les deux versions, le résultat est le même : tu disposes de la classe prose, à mettre sur le conteneur du contenu :
1<div className="prose prose-blue max-w-none">
2 <ReactMarkdown>{content}</ReactMarkdown>
3</div>prose ajoute les styles par défaut, prose-sm et prose-lg réduisent ou agrandissent le texte, prose-invert prépare une version pour fond sombre, et max-w-none retire la largeur maximale que prose fixe pour le confort de lecture. Les thèmes de couleur intégrés ne sont que des nuances de gris, comme prose-slate, donc prose-blue seul ne change rien sans configuration personnelle ; pour colorer les liens, le plus simple est le modificateur prose-a:text-blue-600.
Un éditeur avec aperçu en direct
On place un champ de texte à côté d'un aperçu :
1import { useState } from 'react';
2import ReactMarkdown from 'react-markdown';
3
4function MarkdownEditor() {
5 const [markdown, setMarkdown] = useState(`# Salut les pirates !
6
7Écris ton **markdown** ici...
8
9- Fonctionnalité 1
10- Fonctionnalité 2
11`);
12
13 return (
14 <div className="grid grid-cols-2 gap-4 h-screen">
15 {/* Éditeur */}
16 <div>
17 <h2 className="font-bold mb-2">Éditeur</h2>
18 <textarea
19 value={markdown}
20 onChange={(e) => setMarkdown(e.target.value)}
21 className="w-full h-full border rounded p-4 font-mono"
22 />
23 </div>
24
25 {/* Aperçu */}
26 <div>
27 <h2 className="font-bold mb-2">Aperçu</h2>
28 <div className="prose border rounded p-4 h-full overflow-auto">
29 <ReactMarkdown>{markdown}</ReactMarkdown>
30 </div>
31 </div>
32 </div>
33 );
34}L'état markdown alimente à la fois le textarea et ReactMarkdown, donc l'aperçu change à chaque frappe. La grille grid-cols-2 partage l'écran entre l'éditeur et l'aperçu.
La coloration du code
Les pirates collent aussi des extraits de code, plus lisibles en couleurs :
1npm install react-syntax-highlighter
2npm install @types/react-syntax-highlighterUn composant code personnalisé, passé via l'option components, remplace l'élément par défaut par un bloc de code coloré :
1import ReactMarkdown from 'react-markdown';
2import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
3import { tomorrow } from 'react-syntax-highlighter/dist/cjs/styles/prism';
4
5function MarkdownPost({ content }: { content: string }) {
6 return (
7 <ReactMarkdown
8 components={{
9 code({ node, inline, className, children, ...props }) {
10 const match = /language-(\w+)/.exec(className || '');
11 return !inline && match ? (
12 <SyntaxHighlighter
13 style={tomorrow}
14 language={match[1]}
15 PreTag="div"
16 {...props}
17 >
18 {String(children).replace(/\n$/, '')}
19 </SyntaxHighlighter>
20 ) : (
21 <code className={className} {...props}>
22 {children}
23 </code>
24 );
25 }
26 }}
27 >
28 {content}
29 </ReactMarkdown>
30 );
31}L'expression régulière extrait le langage de la classe language-... que reçoit un bloc de code en Markdown. Les versions récentes de react-markdown ne transmettent plus le paramètre inline, mais rien ne casse ici : le code en ligne n'a pas de classe de langage et finit dans un simple <code>.
Sécurité et GitHub Flavored Markdown
Ce sont les utilisateurs qui écrivent les posts, il faut donc décider à quoi on fait confiance :
1npm install remark-gfm rehype-sanitizeAprès l'installation, tu passes les deux plugins au composant dans deux tableaux séparés, remarkPlugins et rehypePlugins :
1import ReactMarkdown from 'react-markdown';
2import remarkGfm from 'remark-gfm';
3import rehypeSanitize from 'rehype-sanitize';
4
5function SafeMarkdown({ content }: { content: string }) {
6 return (
7 <ReactMarkdown
8 remarkPlugins={[remarkGfm]}
9 rehypePlugins={[rehypeSanitize]}
10 >
11 {content}
12 </ReactMarkdown>
13 );
14}remarkGfm ajoute le GitHub Flavored Markdown : tableaux, listes de tâches, texte barré et liens automatiques. rehypeSanitize nettoie le HTML selon une liste d'éléments autorisés. Sans le plugin rehype-raw, react-markdown n'exécute de toute façon pas le HTML brut ; la désinfection devient indispensable dès que tu autorises le HTML dans les posts.
La barre d'outils
Tous les pirates ne retiennent pas la syntaxe, alors on ajoute des boutons qui insèrent des fragments tout prêts :
1function MarkdownToolbar({ onInsert }: { onInsert: (text: string) => void }) {
2 return (
3 <div className="flex gap-2 mb-2 border-b pb-2">
4 <button onClick={() => onInsert('**gras**')} className="px-2 py-1 hover:bg-gray-100 rounded">
5 <strong>B</strong>
6 </button>
7 <button onClick={() => onInsert('*italique*')} className="px-2 py-1 hover:bg-gray-100 rounded">
8 <em>I</em>
9 </button>
10 <button onClick={() => onInsert('[lien](url)')} className="px-2 py-1 hover:bg-gray-100 rounded">
11 Lien
12 </button>
13 <button onClick={() => onInsert('`code`')} className="px-2 py-1 hover:bg-gray-100 rounded font-mono">
14 </>
15 </button>
16 <button onClick={() => onInsert('- Élément de liste')} className="px-2 py-1 hover:bg-gray-100 rounded">
17 • Liste
18 </button>
19 </div>
20 );
21}
22
23function MarkdownEditor() {
24 const [markdown, setMarkdown] = useState("");
25 const textareaRef = useRef<HTMLTextAreaElement>(null);
26
27 const handleInsert = (text: string) => {
28 const textarea = textareaRef.current;
29 if (!textarea) return;
30
31 const start = textarea.selectionStart;
32 const end = textarea.selectionEnd;
33 const before = markdown.substring(0, start);
34 const after = markdown.substring(end);
35
36 setMarkdown(before + text + after);
37
38 // Met le focus et sélectionne le texte inséré
39 setTimeout(() => {
40 textarea.focus();
41 textarea.setSelectionRange(start, start + text.length);
42 }, 0);
43 };
44
45 return (
46 <div>
47 <MarkdownToolbar onInsert={handleInsert} />
48 <textarea
49 ref={textareaRef}
50 value={markdown}
51 onChange={(e) => setMarkdown(e.target.value)}
52 className="w-full h-64 border rounded p-4 font-mono"
53 />
54 <div className="prose mt-4">
55 <ReactMarkdown>{markdown}</ReactMarkdown>
56 </div>
57 </div>
58 );
59}handleInsert insère le texte à la position du curseur (selectionStart et selectionEnd), puis sélectionne le fragment inséré. setTimeout attend que React mette à jour le champ, sinon la sélection tomberait sur l'ancien texte.
Conseil du Capitaine Barbe-Rouge : n'affiche pas le contenu des utilisateurs avec dangerouslySetInnerHTML. Quand l'IA propose ce raccourci, demande-lui react-markdown, et n'autorise le HTML qu'avec rehype-sanitize.
À retenir : tu stockes le Markdown comme du texte brut et tu l'affiches avec react-markdown et la classe prose. L'éditeur ci-dessous montre encore la liste des communautés de la leçon précédente, et tu construiras toi-même l'éditeur Markdown avec aperçu en direct dans l'un des exercices après cette leçon.
Code de cette leçon : App.jsx
1// Communautés avec filtrage
2import { useState } from 'react';
3
4const communities = [
5 { id: 'treasure', name: 'r/TreasureHunting', icon: '', members: 12450 },
6 { id: 'battles', name: 'r/SeaBattles', icon: '', members: 8920 },
7 { id: 'life', name: 'r/PirateLife', icon: '', members: 25670 },
8 { id: 'ships', name: 'r/ShipBuilding', icon: '', members: 5430 },
9];
10
11const allPosts = [
12 { id: 1, community: 'treasure', title: 'J\'ai trouvé un trésor !', score: 234 },
13 { id: 2, community: 'battles', title: 'Ma première bataille', score: 89 },
14 { id: 3, community: 'life', title: 'La vie en mer', score: 156 },
15 { id: 4, community: 'treasure', title: 'La meilleure carte', score: 312 },
16 { id: 5, community: 'ships', title: 'Comment réparer un navire ?', score: 67 },
17 { id: 6, community: 'life', title: 'Proverbes de pirates', score: 445 },
18];
19
20export default function App() {
21 const [selected, setSelected] = useState(null);
22
23 const filtered = selected ? allPosts.filter(p => p.community === selected) : allPosts;
24
25 return (
26 <div className="min-h-screen bg-gray-100 p-4">
27 <div className="max-w-6xl mx-auto flex gap-4">
28 <aside className="w-64">
29 <div className="bg-white rounded-lg shadow p-4 sticky top-4">
30 <h2 className="font-bold mb-3">Communautés</h2>
31 <button
32 onClick={() => setSelected(null)}
33 className={`w-full text-left px-3 py-2 rounded mb-1 ${!selected ? 'bg-gray-100 font-medium' : 'hover:bg-gray-50'}`}
34 >
35 Toutes
36 </button>
37 {communities.map(c => (
38 <button
39 key={c.id}
40 onClick={() => setSelected(c.id)}
41 className={`w-full text-left px-3 py-2 rounded mb-1 flex items-center gap-2 ${selected === c.id ? 'bg-gray-100 font-medium' : 'hover:bg-gray-50'}`}
42 >
43 <span className="text-xl">{c.icon}</span>
44 <div className="flex-1 min-w-0">
45 <div className="text-sm font-medium truncate">{c.name}</div>
46 <div className="text-xs text-gray-500">{(c.members / 1000).toFixed(1)}K</div>
47 </div>
48 </button>
49 ))}
50 </div>
51 </aside>
52
53 <main className="flex-1">
54 {selected && (
55 <div className="bg-white rounded-lg shadow mb-4 p-6">
56 <div className="flex items-center gap-3">
57 <div className="text-5xl">{communities.find(c => c.id === selected)?.icon}</div>
58 <div>
59 <h1 className="text-2xl font-bold">{communities.find(c => c.id === selected)?.name}</h1>
60 <p className="text-gray-600">{communities.find(c => c.id === selected)?.members.toLocaleString()} membres</p>
61 </div>
62 </div>
63 </div>
64 )}
65
66 <div className="space-y-2">
67 {filtered.map(post => (
68 <div key={post.id} className="bg-white rounded-lg shadow p-4 flex items-center gap-3">
69 <div className="text-center">
70 <div className="text-xl">▲</div>
71 <div className="font-bold text-orange-500">{post.score}</div>
72 <div className="text-xl">▼</div>
73 </div>
74 <div className="flex-1">
75 <div className="text-xs text-gray-500 mb-1">
76 {communities.find(c => c.id === post.community)?.icon} {communities.find(c => c.id === post.community)?.name}
77 </div>
78 <h3 className="font-bold">{post.title}</h3>
79 </div>
80 </div>
81 ))}
82 </div>
83 </main>
84 </div>
85 </div>
86 );
87}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Qu'est-ce que Markdown dans le contexte des posts d'un forum ?
2. Quelle bibliothèque utilise-t-on pour afficher du Markdown dans un composant React ?
Tâches pratiques dans le jeu
- Éditeur de code
Éditeur Markdown avec aperçu en direct
- Tri horizontal
Ordonne la notation du texte en gras en Markdown (sur une seule ligne, de gauche à droite) :