Skip to Content
MoodlePartie 7 — Frontend : JavaScript, React et TailwindReact dans Moodle : le tutoriel de bout en bout

Chapitre 7.4 — React dans Moodle : le tutoriel de bout en bout

Partie 7 : Frontend — Chapitre 4/6 Public : dev React/Next.js. Le chapitre cœur de cible. On a le socle (Mustache 7.2, AMD/core/* 7.3) ; on intègre maintenant React 19 + TypeScript dans un plugin, proprement. Version de référence : Moodle 5.2 (React arrive dans le core avec le helper Mustache {{#react}} et des importmaps ; on couvre aussi la voie « React embarqué » via esbuild).

⏱️ TL;DR

  • Deux stratégies. (A) React natif du core 5.2 : Moodle expose React via importmap et un helper {{#react}} ; on monte un composant sur un nœud rendu par Mustache. (B) React embarqué dans le plugin : on bundle React+TSX avec esbuild en un module AMD autonome, sans dépendre du React du core.
  • On construit local_reactdemo : une page Moodle affichant une SPA React 19 (liste de tâches) alimentée par des web services (Partie 9) via core/ajax en interop AMD.
  • Toolchain (voie B) : package.json + esbuild + un petit wrapper define() qui expose le bundle comme module AMD, tsconfig.json pour TypeScript.
  • Données PHP → React : on passe l’état initial via data-* / props sur le nœud de montage (pas de re-fetch inutile), et on parle au serveur ensuite via core/ajax.
  • Instances multiples : chaque montage cible un nœud {{uniqid}} ; on createRoot par nœud et on démonte proprement au retrait.
  • Verdict : React est justifié pour une UI riche, très interactive, avec état client complexe (éditeur, tableau de bord dynamique, wizard). Pour 90 % des besoins (afficher, un formulaire, une liste), Mustache + core/* suffisent et coûtent moins cher.

🎯 Objectifs

  • Choisir entre React natif du core et React embarqué selon le contexte.
  • Mettre en place une toolchain esbuild + TypeScript produisant un module AMD.
  • Monter un composant React sur un nœud rendu par Moodle, avec données initiales PHP.
  • Faire dialoguer React avec les web services via core/ajax.
  • Gérer instances multiples, cleanup, et le workflow de dev.
  • Répondre honnêtement à « React est-il justifié ici ? ».

1. Deux stratégies, un arbitrage

Avant tout code : faut-il du React ? Puis lequel ?

  • Stratégie A — React natif du core (5.2+). Moodle 5.2 embarque React et l’expose via une importmap et un helper Mustache {{#react}}. Avantage : pas de bundle React dupliqué, version alignée sur le core, léger. Inconvénient : vous dépendez de la version de React fournie par Moodle et de la stabilité de cette API (récente).
  • Stratégie B — React embarqué. Vous bundlez votre propre React (19) + TSX avec esbuild, dans un module AMD autonome. Avantage : contrôle total de la version React et des dépendances, isolation, compatibilité avec des versions de Moodle antérieures. Inconvénient : bundle plus lourd (React inclus), à maintenir.

Ce chapitre détaille la stratégie B (plus portable et instructive), puis résume A.

⚠️ Piège de sur-ingénierie : la question la plus importante n’est pas « A ou B » mais « React ou pas ». Ajouter React à un plugin qui affiche une liste et un formulaire, c’est importer une SPA pour un problème que core/templates + core/modal_form résolvent en 30 lignes. Réservez React aux interfaces réellement riches (voir §9).

💡 Pour un dev React : contrairement à Next.js, il n’y a pas de routeur React ni de SSR React ici. Moodle rend la page (PHP), et React n’est qu’un îlot monté sur un <div>. Vous n’apportez pas une app ; vous apportez un composant interactif dans une page serveur. Mentalement : createRoot sur un nœud, comme un widget intégré dans un site existant.


2. Le plugin cible et son arborescence

local_reactdemo : une page qui monte un composant React de gestion de tâches (proche de local_todolist du ch. 6.3, mais l’UI est une SPA React parlant à des web services).

public/local/reactdemo/ ├── version.php ├── index.php ← la page Moodle qui monte React ├── db/ │ ├── access.php │ └── services.php ← déclare les web services (Partie 9) ├── classes/ │ └── external/ ← external functions (get_tasks, add_task, toggle_task) │ ├── get_tasks.php │ ├── add_task.php │ └── toggle_task.php ├── amd/ │ ├── src/ │ │ └── app.js ← wrapper AMD (généré/écrit) chargé par Moodle │ └── build/ │ └── app.min.js ← bundle esbuild (React + TSX) — PRODUIT ├── react/ ← sources TypeScript/TSX (hors pipeline grunt) │ ├── index.tsx ← point d'entrée : init(elementId, props) │ ├── App.tsx │ ├── api.ts ← interop core/ajax │ └── types.ts ├── package.json ├── tsconfig.json ├── esbuild.mjs ├── templates/ │ └── mount.mustache └── lang/en/local_reactdemo.php

Idée clé : les sources React vivent dans react/ (hors du pipeline Grunt de Moodle) ; esbuild les compile en un fichier AMD déposé dans amd/build/app.min.js, que Moodle charge comme n’importe quel module.


3. La toolchain esbuild → AMD

3.1 package.json

{ "name": "local_reactdemo", "private": true, "type": "module", "scripts": { "build": "node esbuild.mjs", "watch": "node esbuild.mjs --watch" }, "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "esbuild": "^0.24.0", "typescript": "^5.6.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0" } }

3.2 esbuild.mjs — le point délicat : produire de l’AMD

Moodle charge des modules AMD (define(...)). esbuild ne cible pas AMD nativement, mais on peut produire un bundle IIFE et l’envelopper dans un define(). On expose une fonction init comme API du module.

// public/local/reactdemo/esbuild.mjs import esbuild from 'esbuild'; const watch = process.argv.includes('--watch'); /** Wrapper : transforme le bundle en module AMD exposant { init }. */ const amdWrapperPlugin = { name: 'amd-wrapper', setup(build) { build.initialOptions.banner = { js: "define('local_reactdemo/app', [], function() {", }; build.initialOptions.footer = { js: "return window.__local_reactdemo; });", }; }, }; const options = { entryPoints: ['react/index.tsx'], bundle: true, format: 'iife', globalName: '__local_reactdemo', // le module IIFE s'expose sur window.__local_reactdemo outfile: 'amd/build/app.min.js', minify: !watch, sourcemap: watch, jsx: 'automatic', // React 17+ JSX transform (pas d'import React manuel) target: ['es2019'], // compatible avec le loader de Moodle plugins: [amdWrapperPlugin], }; if (watch) { const ctx = await esbuild.context(options); await ctx.watch(); console.log('esbuild: watching…'); } else { await esbuild.build(options); console.log('esbuild: built amd/build/app.min.js'); }

Le résultat amd/build/app.min.js est un module AMD nommé local_reactdemo/app exportant { init }, avec React 19 bundlé dedans.

⚠️ Piège : le fichier est directement placé dans amd/build/ (pas amd/src/ + grunt amd). On court-circuite volontairement le pipeline Grunt de Moodle, car Grunt/Babel ne gère pas notre TSX + bundling React. Conséquence : ce plugin se builde avec npm/esbuild (npm run build), pas avec grunt amd. Documentez-le dans le README, sinon un autre dev cherchera un amd/src/ inexistant.

⚠️ Piège de taille : React + ReactDOM bundlés ≈ 130–150 Ko minifiés. Pour un plugin c’est acceptable ; pour cinq plugins React embarqués sur la même page, vous chargez cinq copies de React. Si vous multipliez les plugins React, envisagez la stratégie A (React partagé du core) ou un module React partagé entre vos plugins.

3.3 tsconfig.json

{ "compilerOptions": { "target": "ES2019", "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "lib": ["DOM", "DOM.Iterable", "ES2021"] }, "include": ["react/**/*"] }

4. Le point d’entrée React et le montage

4.1 react/index.tsx — l’API init du module

// public/local/reactdemo/react/index.tsx import {createRoot, type Root} from 'react-dom/client'; import {App} from './App'; import type {Task} from './types'; // On garde une référence des roots montés pour pouvoir démonter. const roots = new Map<string, Root>(); export interface InitProps { contextId: number; initialTasks: Task[]; } /** * Monte l'app React sur l'élément d'id `elementId`. * Appelée par Moodle via l'interop AMD (voir amd/src/app.js). */ export function init(elementId: string, props: InitProps): void { const el = document.getElementById(elementId); if (!el) { return; } // Évite un double montage sur le même nœud. if (roots.has(elementId)) { return; } const root = createRoot(el); root.render(<App contextId={props.contextId} initialTasks={props.initialTasks} />); roots.set(elementId, root); } /** Démontage propre (si Moodle retire la région, ex. navigation SPA d'un conteneur). */ export function destroy(elementId: string): void { roots.get(elementId)?.unmount(); roots.delete(elementId); }

4.2 amd/src/app.js — le pont AMD que Moodle appelle

Comme Moodle charge local_reactdemo/app, on veut que ce nom pointe vers notre bundle. Le plus simple : le bundle esbuild est déjà define('local_reactdemo/app', …). On peut donc l’appeler directement. Si l’on préfère un fichier amd/src/app.js géré par Grunt qui délègue au bundle, on écrit un mince proxy — mais dans notre montage, le bundle esbuild suffit et se charge tel quel.

💡 Pour un dev React : init(elementId, props) est votre hydrateRoot/createRoot(el).render(<App {...props}/>), exposé sous une API que le monde AMD de Moodle sait appeler. La Map<string, Root> gère les instances multiples — un root par nœud — exactement comme vous géreriez plusieurs mount points d’un widget embarqué.


5. Passer les données PHP → React

Deux données à transmettre au montage : l’état initial (les tâches déjà en base, pour éviter un fetch au premier rendu) et le contextId (pour les web services).

5.1 Le template de montage

{{! local_reactdemo/mount Contexte : uniqid, contextid, tasksjson (JSON échappé des tâches initiales) }} <div id="reactdemo-{{uniqid}}" class="local-reactdemo"></div> {{#js}} require(['local_reactdemo/app'], function(App) { App.init("reactdemo-{{uniqid}}", { contextId: {{contextid}}, initialTasks: {{{tasksjson}}} }); }); {{/js}}

5.2 La page index.php

<?php // public/local/reactdemo/index.php require(__DIR__ . '/../../config.php'); require_login(); $context = context_system::instance(); require_capability('local/reactdemo:use', $context); $PAGE->set_context($context); $PAGE->set_url(new moodle_url('/local/reactdemo/index.php')); $PAGE->set_pagelayout('standard'); $PAGE->set_title(get_string('pluginname', 'local_reactdemo')); $PAGE->set_heading(get_string('pluginname', 'local_reactdemo')); // État initial : on le calcule côté serveur (rapide, pas de fetch au 1er rendu). $tasks = \local_reactdemo\api::get_tasks_for_user($USER->id); // renvoie un tableau formaté echo $OUTPUT->header(); echo $OUTPUT->render_from_template('local_reactdemo/mount', [ 'uniqid' => \core\uniqid(), // ou {{uniqid}} géré par le template 'contextid' => $context->id, // json_encode produit un JSON sûr à injecter en {{{ }}} (données déjà formatées). 'tasksjson' => json_encode(array_values($tasks), JSON_HEX_TAG | JSON_HEX_AMP), ]); echo $OUTPUT->footer();

Deux points de sécurité :

  • Les données de l’état initial doivent être formatées côté serveur (échappement des noms via format_string) avant json_encode. React échappe le texte au rendu, mais on ne veut pas non plus injecter de HTML riche non nettoyé.
  • json_encode(..., JSON_HEX_TAG | JSON_HEX_AMP) évite qu’un </script> dans les données ne casse (ou n’exploite) le bloc {{#js}}. C’est l’équivalent Moodle d’un « JSON-in-HTML safe ».

⚠️ Piège : injecter l’état initial via {{{tasksjson}}} (brut) est volontaire ici (c’est du JSON, pas du HTML), mais uniquement parce qu’on maîtrise l’encodage (json_encode + flags JSON_HEX_*). N’injectez jamais du JSON non encodé, ni des données contenant du HTML brut. Alternative plus stricte : passer le JSON via un attribut data-tasks='{{tasksjson}}' (échappé) et le lire avec JSON.parse(el.dataset.tasks) côté JS.


6. React parle au serveur : interop core/ajax

React ne fait pas de fetch : il passe par core/ajax (chapitre 7.3), importé… depuis le monde AMD. Comme notre bundle est un module AMD, on peut dépendre de core/ajax — mais esbuild ne connaît pas ce module. Solution : le marquer externe et l’obtenir via le loader.

6.1 react/api.ts — appeler les web services

// public/local/reactdemo/react/api.ts import type {Task} from './types'; // core/ajax est fourni par Moodle (AMD), pas bundlé. On le récupère au runtime. type AjaxRequest = {methodname: string; args: Record<string, unknown>}; interface CoreAjax { call(requests: AjaxRequest[]): Promise<unknown>[]; } // Petit accès au loader AMD global de Moodle. declare const require: (deps: string[], cb: (...mods: unknown[]) => void) => void; const getAjax = (): Promise<CoreAjax> => new Promise((resolve) => require(['core/ajax'], (ajax) => resolve(ajax as CoreAjax))); export async function fetchTasks(): Promise<Task[]> { const ajax = await getAjax(); return ajax.call([{methodname: 'local_reactdemo_get_tasks', args: {}}])[0] as Promise<Task[]>; } export async function addTask(name: string): Promise<Task> { const ajax = await getAjax(); return ajax.call([{methodname: 'local_reactdemo_add_task', args: {name}}])[0] as Promise<Task>; } export async function toggleTask(id: number): Promise<Task> { const ajax = await getAjax(); return ajax.call([{methodname: 'local_reactdemo_toggle_task', args: {id}}])[0] as Promise<Task>; }

Pour qu’esbuild ne bundle pas core/ajax mais laisse le require(['core/ajax']) runtime fonctionner, on ne l’importe pas en ES module — on le récupère via le require global (comme ci-dessus). Alternative : ajouter core/ajax aux external d’esbuild et l’injecter dans le define([...]) du wrapper.

6.2 react/App.tsx — le composant

// public/local/reactdemo/react/App.tsx import {useState, useCallback} from 'react'; import type {Task} from './types'; import {addTask, toggleTask} from './api'; interface AppProps { contextId: number; initialTasks: Task[]; } export function App({initialTasks}: AppProps) { const [tasks, setTasks] = useState<Task[]>(initialTasks); const [name, setName] = useState(''); const [busy, setBusy] = useState(false); const onAdd = useCallback(async () => { if (!name.trim()) { return; } setBusy(true); try { const created = await addTask(name.trim()); setTasks((t) => [...t, created]); setName(''); } finally { setBusy(false); } }, [name]); const onToggle = useCallback(async (id: number) => { const updated = await toggleTask(id); setTasks((t) => t.map((task) => (task.id === id ? updated : task))); }, []); return ( <div> <div className="input-group mb-3"> <input className="form-control" value={name} onChange={(e) => setName(e.target.value)} placeholder="Nouvelle tâche" disabled={busy} /> <button className="btn btn-primary" onClick={onAdd} disabled={busy}> Ajouter </button> </div> <ul className="list-group"> {tasks.map((task) => ( <li key={task.id} className="list-group-item d-flex justify-content-between align-items-center" > <span className={task.completed ? 'text-decoration-line-through text-muted' : ''}> {task.name} </span> <input type="checkbox" checked={task.completed} onChange={() => onToggle(task.id)} /> </li> ))} </ul> </div> ); }

C’est du React 19 ordinaire : hooks, état local, appels asynchrones aux web services. On réutilise les classes Bootstrap 5.3 de Moodle pour rester cohérent visuellement (le chapitre 7.5 traite Tailwind si vous préférez).


7. Instances multiples, cleanup, workflow de dev

  • Instances multiples : chaque montage cible reactdemo-{{uniqid}}, un createRoot distinct par nœud, mémorisé dans la Map. Deux blocs React sur une page coexistent sans collision.
  • Cleanup : exposez destroy(elementId) (root.unmount()), à appeler si Moodle retire la région (rare pour une page classique, utile si votre composant vit dans une modale ou un conteneur re-rendu). Sinon, la navigation pleine page nettoie tout naturellement.
  • Workflow de dev : npm run watch (esbuild reconstruit à chaque sauvegarde) + $CFG->cachejs = false côté Moodle. Vous éditez le TSX, esbuild régénère amd/build/app.min.js, vous rechargez la page. Pas de HMR façon Vite (Moodle n’a pas de serveur de dev qui l’injecte), mais un cycle sauvegarde → reload rapide.

⚠️ Piège du cleanup oublié : si votre composant s’ouvre dans une modale rechargée plusieurs fois, ne pas unmount() fait fuir des roots React et des écouteurs (memory leak). Gardez la Map et démontez le root de l’ancien nœud avant d’en monter un nouveau, ou sur l’event de fermeture de la modale.


8. Stratégie A en résumé : le React natif du core (5.2)

Si vous ciblez exclusivement Moodle 5.2+ et acceptez la version de React du core, c’est plus léger :

  • Moodle expose React/ReactDOM via une importmap ; un helper Mustache {{#react}} monte un composant sur un nœud, en lui passant un contexte.
  • Vous écrivez vos composants en s’appuyant sur le React partagé (pas de bundle React dans votre plugin → pages plus légères, pas de doublons entre plugins).
  • Contrepartie : vous suivez la version de React du core et l’API {{#react}} (récente, susceptible d’évoluer). Pour un plugin devant tourner sur des versions antérieures à 5.2, la stratégie A n’est pas disponible → stratégie B.

📚 Aller plus loin : consultez la doc « React in Moodle » sur moodledev.io  (section React/importmaps) pour la syntaxe exacte de {{#react}} en 5.2 — c’est une API jeune, vérifiez la page à jour avant de vous engager en production sur la stratégie A.


9. Le verdict honnête : quand React est-il justifié ?

SituationReco
Afficher des données, une liste, un tableauMustache (7.2)
Un formulaire (même en modale)core/modal_form (7.3)
Rafraîchir un fragment après actioncore/templates + core/ajax
Interface d’édition très interactive (drag&drop d’état complexe, multi-panneaux, undo/redo)core/reactive (7.6) ou React
Wizard multi-étapes avec état riche partagéReact (ou reactive)
Tableau de bord temps réel, visualisations interactivesReact justifié
Vous « aimez React » et l’UI est simpleNon — coût inutile

Le fil : React apporte de la valeur quand l’état client est riche et l’interactivité forte. En dessous, il ajoute un bundle, une toolchain hors-Grunt, une surface de maintenance et un risque de divergence visuelle — pour un gain nul face à core/*. Moodle lui-même a développé core/reactive (ch. 7.6) précisément parce que, pour son éditeur de cours, il voulait la réactivité sans imposer React à tout l’écosystème.

💡 Pour un dev React : le réflexe « tout est un composant React » est votre plus grand piège ici. Moodle est server-first ; React y est un invité ponctuel, pas l’hôte. La bonne question n’est pas « comment mettre React partout » mais « cette interface a-t-elle un état client assez riche pour le mériter ». Souvent, non — et c’est une bonne nouvelle pour la maintenance.


✏️ Exercices

Exercice 1 — A ou B ? Pour chaque cas, stratégie A (React du core), B (embarqué), ou pas de React ? (a) plugin devant tourner sur Moodle 5.0 à 5.2 ; (b) afficher une liste de badges ; (c) éditeur de parcours pédagogique drag&drop avec undo, cible 5.2 uniquement ; (d) cinq petits widgets React sur la même page.

✅ Solution

(a) B (le React natif n’existe qu’en 5.2 ; pour couvrir 5.0–5.1, il faut embarquer) ; (b) Pas de React — Mustache + éventuellement core/ajax ; (c) React (B ou A) justifié : état riche, undo/redo, drag&drop complexe — A possible puisque cible 5.2 ; (d) A (React partagé du core) pour éviter cinq bundles React sur une page ; sinon un module React partagé entre vos plugins. Le point (d) illustre le coût du bundle dupliqué de la stratégie B.

Exercice 2 — Pourquoi pas grunt amd ? Un collègue s’étonne que local_reactdemo n’ait pas de amd/src/*.js buildé par Grunt. Expliquez.

✅ Solution

Le pipeline Grunt/Babel de Moodle ne gère ni le TSX ni le bundling de React. On utilise donc esbuild (via npm run build) pour compiler react/*.tsx + React en un module AMD déposé directement dans amd/build/app.min.js. On court-circuite Grunt volontairement. Il faut le documenter (README) : ce plugin se builde avec npm/esbuild, pas grunt amd, sinon un dev cherchera un amd/src/ inexistant et un rebuild Grunt qui écraserait/ignorerait le bundle.

Exercice 3 — État initial sans fetch. Pourquoi passe-t-on initialTasks en props plutôt que de faire un fetchTasks() dans un useEffect au montage ? Quel est le risque de sécurité à gérer ?

✅ Solution

Passer l’état initial calculé côté serveur évite un aller-retour réseau au premier rendu (l’UI s’affiche immédiatement peuplée), et exploite le fait que la page PHP a déjà accès aux données. Risque : les données injectées dans le {{#js}} doivent être encodées sûrementjson_encode avec JSON_HEX_TAG | JSON_HEX_AMP (pour qu’un </script> ne casse/exploite pas le script), et les textes formatés côté serveur (format_string) avant encodage. Alternative plus stricte : passer le JSON via un attribut data-* échappé et le JSON.parse côté JS.

Exercice 4 — React et web services. Comment React appelle-t-il le serveur, et pourquoi ne pas utiliser fetch('/local/reactdemo/api.php') ?

✅ Solution

Via core/ajax (récupéré au runtime avec le require global AMD, puisqu’esbuild ne le bundle pas), qui appelle des external functions (local_reactdemo_get_tasks, etc., Partie 9). Un fetch direct contournerait la validation serveur (login, contexte, capabilities, types de paramètres) et le sesskey. Le web service reste le seul pont JS→serveur sécurisé, y compris depuis React.

Exercice 5 — Fuite mémoire en modale. Votre composant React est monté dans une modale ouverte/fermée plusieurs fois. Après quelques ouvertures, la page rame. Cause probable et correctif ?

✅ Solution

Chaque ouverture appelle init() et crée un nouveau createRoot sans démonter le précédent → accumulation de roots React, d’écouteurs et de timers (memory leak). Correctif : appeler destroy(elementId) (root.unmount() + suppression de la Map) à la fermeture de la modale (event de fermeture), ou démonter l’ancien root avant de remonter. La Map<string, Root> sert précisément à retrouver et démonter le bon root.


🧠 Quiz de révision

Q1. Quelles sont les deux stratégies pour utiliser React dans Moodle 5.2, et leur principal compromis ?

Réponse

A) React natif du core ({{#react}} + importmap) : léger, React partagé, mais dépend de la version du core et d’une API récente, uniquement 5.2+. B) React embarqué (esbuild → AMD) : contrôle total de la version React et portable sur d’anciennes versions, mais bundle plus lourd (React inclus, dupliqué si plusieurs plugins).

Q2. Comment un composant React est-il monté dans une page Moodle ?

Réponse

Le template rend un <div id="…-{{uniqid}}"> puis, via {{#js}}, require(['component/app'], App => App.init(nodeId, props)). init fait createRoot(el).render(<App {...props}/>). React n’est qu’un îlot monté sur un nœud rendu par le serveur (pas de routeur ni SSR React).

Q3. Pourquoi ce plugin ne se builde-t-il pas avec grunt amd ?

Réponse

Parce que Grunt/Babel ne gère ni le TSX ni le bundling de React. On utilise esbuild (npm run build) pour produire directement amd/build/app.min.js (un module AMD enveloppant React + TSX), en court-circuitant Grunt — à documenter dans le README.

Q4. Comment React récupère-t-il ses données et parle-t-il au serveur ?

Réponse

L’état initial est passé en props depuis PHP (JSON encodé sûrement dans le {{#js}}), évitant un fetch au premier rendu. Ensuite, React appelle les external functions via core/ajax (obtenu au runtime par le require AMD global), jamais en fetch brut.

Q5. Donnez deux cas où React est justifié et deux où il ne l’est pas.

Réponse

Justifié : éditeur très interactif avec état riche (drag&drop, undo/redo), wizard multi-étapes, tableau de bord temps réel/visualisations. Non justifié : afficher une liste/tableau (Mustache), un formulaire (core/modal_form), rafraîchir un fragment (core/templates+core/ajax). Règle : React se mérite par la richesse de l’état client, pas par préférence.

Chapitre suivant : 05 — Tailwind dans Moodle, honnêtement — le problème (Bootstrap 5 global + preflight destructeur), Tailwind v4 avec prefix et sans preflight, scoping sous une classe racine, intégration au build du plugin de ce chapitre, conflits de classes et solutions, et le verdict pragmatique.