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) viacore/ajaxen interop AMD.- Toolchain (voie B) :
package.json+ esbuild + un petit wrapperdefine()qui expose le bundle comme module AMD,tsconfig.jsonpour 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 viacore/ajax.- Instances multiples : chaque montage cible un nœud
{{uniqid}}; oncreateRootpar 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_formré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 :createRootsur 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.phpIdé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/(pasamd/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 avecgrunt amd. Documentez-le dans le README, sinon un autre dev cherchera unamd/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 votrehydrateRoot/createRoot(el).render(<App {...props}/>), exposé sous une API que le monde AMD de Moodle sait appeler. LaMap<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) avantjson_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+ flagsJSON_HEX_*). N’injectez jamais du JSON non encodé, ni des données contenant du HTML brut. Alternative plus stricte : passer le JSON via un attributdata-tasks='{{tasksjson}}'(échappé) et le lire avecJSON.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}}, uncreateRootdistinct par nœud, mémorisé dans laMap. 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 = falsecôté Moodle. Vous éditez le TSX, esbuild régénèreamd/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 laMapet 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é ?
| Situation | Reco |
|---|---|
| Afficher des données, une liste, un tableau | Mustache (7.2) |
| Un formulaire (même en modale) | core/modal_form (7.3) |
| Rafraîchir un fragment après action | core/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 interactives | React justifié |
| Vous « aimez React » et l’UI est simple | Non — 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ûrement — json_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
{{#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
<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
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
{{#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
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.