Chapitre 7.2 — Mustache en profondeur
Partie 7 : Frontend — Chapitre 2/6 Public : dev React/Next.js. On a posé (ch. 7.1) le modèle « rendu serveur PHP + îlots JS ». On attaque la brique de rendu HTML : Mustache. Version de référence : Moodle 5.2 (Bootstrap 5.3, Mustache.php côté serveur + mustache.js côté client).
⏱️ TL;DR
- Mustache est un moteur de template logic-less : pas de
if/for, seulement des sections ({{#x}}…{{/x}}), des inversions ({{^x}}…{{/x}}), des variables ({{x}}) et des partials ({{> …}}).{{x}}échappe le HTML (sûr par défaut) ;{{{x}}}ne l’échappe pas (dangereux — à réserver au HTML déjà nettoyé côté PHP parformat_text).- Même template rendu côté serveur (
$OUTPUT->render_from_template) et côté client (core/templates) : cohérence garantie, base de l’hydratation.- Helpers Moodle dans les templates :
{{#str}},{{#pix}},{{#js}},{{#userdate}},{{#shortentext}},{{#cleanstr}},{{uniqid}}— ils remplacent la logique interdite.- Le contexte vient de PHP (un tableau via
export_for_template) ou de JS (un objet passé àrenderForPromise).- Résolution des templates :
component/nom→<plugin>/templates/nom.mustache, surchargeable par le thème (theme/xxx/templates/component/nom.mustache).- Le
{{#js}}injecte du JS lié au template ;{{uniqid}}génère un id unique par rendu (essentiel pour les instances multiples).- Chaque template documente son contexte attendu dans un commentaire
{{! … }}avec un exemple JSON.
🎯 Objectifs
- Écrire des templates Mustache complets : sections, inversions, partials, héritage.
- Choisir en connaissance de cause entre
{{ }}(échappé) et{{{ }}}(brut) — et éviter les failles XSS. - Utiliser les helpers Moodle pour remplacer la logique interdite.
- Rendre un template depuis PHP et depuis JS avec le même contexte.
- Comprendre la résolution et la surcharge des templates par les thèmes.
1. Pourquoi « logic-less », et pourquoi c’est un choix
Mustache refuse volontairement la logique dans le template : pas de condition arbitraire, pas de boucle avec index, pas d’expression. Vous ne pouvez qu’itérer sur une liste et tester la présence/vacuité d’une valeur. Toute la préparation (formatage, calculs, mise en forme des URLs) se fait avant, en PHP, dans export_for_template() (chapitre 6.3).
Ce choix est structurant dans Moodle : il force une séparation nette view-model (PHP) ↔ présentation (Mustache), garantit que le même template peut être rendu côté serveur et côté client (mustache.js ne peut pas exécuter du PHP), et rend les templates surchargeables par les thèmes sans casser de logique métier.
💡 Pour un dev React : Mustache est l’opposé de JSX. En JSX vous mettez de la logique dans la vue (
{items.filter(...).map(...)}) ; en Mustache, toute la logique est remontée dans le view-model. Pensez à un composant de présentation pur dont les props sont déjà entièrement calculées — mais poussé à l’extrême : le template ne sait même pas faire unif a > b. Si vous vous surprenez à vouloir de la logique dans le.mustache, c’est le signal qu’elle doit remonter dansexport_for_template().
2. La syntaxe, exhaustivement
2.1 Variables
Bonjour {{firstname}} {{lastname}}.{{firstname}} insère la valeur en l’échappant (les <, >, &, " deviennent des entités HTML). C’est le comportement par défaut et sûr.
Pour insérer du HTML sans échappement :
{{{formattedcontent}}}⚠️ Piège XSS majeur :
{{{ }}}(triple accolade) désactive l’échappement. Ne l’utilisez que pour du HTML déjà produit et nettoyé côté PHP parformat_text()/format_string(). Y injecter une donnée utilisateur brute = faille XSS directe. Règle : 99 % du temps{{ }};{{{ }}}uniquement pour du contenu formaté de confiance (une description d’activité passée parformat_text, par exemple).
2.2 Sections (présence / itération)
Une section {{#x}}…{{/x}} se comporte selon le type de x :
{{! x est un booléen ou une valeur : rendu si "truthy" }}
{{#hasgrade}}
<p>Note : {{grade}}/{{grademax}}</p>
{{/hasgrade}}
{{! x est une liste : itération, le contexte devient chaque élément }}
<ul>
{{#items}}
<li>{{name}} — {{points}} pts</li>
{{/items}}
</ul>- Si
hasgradeest vrai (ou une valeur non vide), le bloc est rendu une fois. - Si
itemsest une liste, le bloc est rendu pour chaque élément, et à l’intérieur{{name}}désigne la propriété de l’élément courant. - Si la valeur est fausse/vide/liste vide, le bloc n’est pas rendu.
2.3 Inversions (le « sinon »)
{{#items}}
<li>{{name}}</li>
{{/items}}
{{^items}}
<p class="text-muted">Aucun élément.</p>
{{/items}}{{^items}}…{{/items}} (accent circonflexe) est rendu seulement si items est faux/vide. C’est l’unique forme de « else » de Mustache : on teste la vacuité.
2.4 Partials (inclusion)
{{! inclut le template core « pix_icon », en lui passant le contexte courant }}
{{> core/pix_icon }}
{{! inclusion d'un partial du plugin }}
{{> mod_simplecheck/item_row }}{{> component/nom }} inclut un autre template, qui reçoit le contexte courant. C’est la composition de Mustache — l’équivalent d’un sous-composant.
2.5 Héritage de blocs ({{< }} et {{$ }})
Mustache-Moodle supporte l’héritage de templates (parent + blocs surchargeables), très utilisé pour les layouts :
{{! templates/mypage.mustache — hérite d'un template parent et remplit ses blocs }}
{{< theme_boost/columns2 }}
{{$ region-main }}
<h2>Mon contenu principal</h2>
{{> mod_simplecheck/checklist }}
{{/ region-main }}
{{/ theme_boost/columns2 }}{{< parent }}: « j’hérite deparent».{{$ block }}…{{/ block }}: je remplis (ou surcharge) le bloc nomméblockdéfini par le parent avec{{$ block }}(valeur par défaut).
💡 Pour un dev React :
{{< }}/{{$ }}= un layout à slots (commechildren+ slots nommés, ou les layouts imbriqués de Next.js). Le parent définit la structure et des « trous » nommés ; l’enfant les remplit. Les partials{{> }}sont vos sous-composants ; les sections{{# }}sontmap/conditionnel ; l’inversion{{^ }}est le fallback.
2.6 Commentaires et documentation du contexte
{{!
mod_simplecheck/checklist
Contexte attendu :
* canedit bool
* items array
* id int
* name string
* checked bool
Exemple :
{
"canedit": true,
"items": [
{"id": 1, "name": "Lire le cours", "checked": true},
{"id": 2, "name": "Rendre l'exercice", "checked": false}
]
}
}}{{! … }} est un commentaire (non rendu). Convention Moodle forte : chaque template documente en tête son contexte attendu avec un exemple JSON. Ce n’est pas cosmétique — l’outil Template library (§7) et le linter s’en servent, et c’est votre seule « signature de props ».
3. Les helpers Moodle : remplacer la logique interdite
Puisqu’on ne peut pas coder dans le template, Moodle fournit des helpers (des sections spéciales interprétées par le moteur). Les essentiels :
| Helper | Rôle | Exemple |
|---|---|---|
{{#str}} | chaîne de langue | {{#str}}submit, core{{/str}} |
{{#cleanstr}} | chaîne nettoyée (sans HTML) | {{#cleanstr}}yes, core{{/cleanstr}} |
{{#pix}} | icône | {{#pix}}t/edit, core, Edit{{/pix}} |
{{#js}} | JS inline lié au template | {{#js}} require(['core/toast'], t => t.add('Hi')); {{/js}} |
{{#userdate}} | date formatée pour l’utilisateur | {{#userdate}}{{timestamp}}, {{#str}}strftimedate,core{{/str}}{{/userdate}} |
{{#shortentext}} | tronque un texte | {{#shortentext}}50, {{longtext}}{{/shortentext}} |
{{uniqid}} | id unique par rendu | id="region-{{uniqid}}" |
3.1 {{#str}} — jamais de texte en dur
<button type="submit" class="btn btn-primary">
{{#str}}savechanges, core{{/str}}
</button>Comme en PHP (get_string), aucun libellé n’est écrit en dur. Le format est {{#str}}identifiant, component{{/str}}. Pour passer un paramètre {$a} :
{{#str}}welcomeuser, mod_simplecheck, {{fullname}}{{/str}}3.2 {{#pix}} — les icônes
{{#pix}}i/checkedcircle, core, {{#str}}completed, completion{{/str}}{{/pix}}Format : {{#pix}}clé-icône, component, texte-alternatif{{/pix}}. Cela produit le markup d’icône correct (FontAwesome 6 en 5.2), thémable via l’icon map (Partie 8).
3.3 {{#js}} — attacher du JavaScript à un template
<div id="chart-{{uniqid}}" data-region="chart"></div>
{{#js}}
require(['mod_simplecheck/chart'], function(Chart) {
Chart.init("chart-{{uniqid}}");
});
{{/js}}{{#js}} place le code dans la file JS de la page (exécuté après chargement des dépendances). Combiné à {{uniqid}}, c’est le pattern d’initialisation d’instance : chaque rendu du template a son id unique, donc plusieurs instances du même composant sur une page ne se marchent pas dessus.
⚠️ Piège des instances multiples : sans
{{uniqid}}, deux rendus du même template partagent le mêmeid, et le JS n’en initialise qu’un (ou les casse tous les deux). Tout composant potentiellement multiple doit s’ancrer sur{{uniqid}}(ou un id dérivé d’un identifiant métier unique), jamais sur un id statique.
💡 Pour un dev React :
{{#js}}+{{uniqid}}est l’hydratation manuelle d’un îlot. Le serveur rend le HTML (le « SSR ») avec un id unique, et un petit script « monte » le comportement dessus (le « hydrate ») — exactement ce que fait React sous le capot, mais ici vous l’écrivez explicitement, composant par composant. Le chapitre 7.3 détaille le côté JS.
4. Le contexte : de PHP et de JS
Un template ne fait rien sans contexte (ses données). Deux sources.
4.1 Depuis PHP (rendu serveur)
// dans un renderer (rappel ch. 6.3)
$context = [
'canedit' => true,
'items' => [
['id' => 1, 'name' => format_string($item1->name), 'checked' => true],
['id' => 2, 'name' => format_string($item2->name), 'checked' => false],
],
];
echo $OUTPUT->render_from_template('mod_simplecheck/checklist', $context);Le contexte est un tableau associatif (souvent produit par export_for_template()). Toutes les données textuelles y sont déjà échappées/filtrées (format_string, format_text) : c’est là, en PHP, que se joue la sécurité de sortie, pas dans le template.
4.2 Depuis JS (rendu client)
import Templates from 'core/templates';
const context = {
canedit: true,
items: [
{id: 1, name: 'Lire le cours', checked: true},
{id: 2, name: 'Rendre l\'exercice', checked: false},
],
};
const {html, js} = await Templates.renderForPromise('mod_simplecheck/checklist', context);
Templates.replaceNodeContents(target, html, js);Le même fichier .mustache est rendu côté client par core/templates (qui utilise mustache.js et récupère le template via web service si besoin). C’est la raison d’être du logic-less : un template purement déclaratif se rend indifféremment en PHP ou en JS, garantissant que le serveur et le client produisent le même HTML.
⚠️ Piège de sécurité côté JS : quand vous rendez côté client à partir de données venant d’un web service, ces données doivent avoir été formatées côté serveur dans l’external function (via
external_format_string/external_format_text) — car mustache.js échappe le HTML de{{ }}mais n’applique pas les filtres Moodle. La règle « nettoyage en sortie côté serveur » reste valable même pour le rendu client.
5. Résolution et surcharge des templates
Quand vous demandez mod_simplecheck/checklist, Moodle résout ainsi :
- Le thème courant a-t-il
theme/<theme>/templates/mod_simplecheck/checklist.mustache? → il gagne (surcharge). - Un thème parent (Boost) l’a-t-il ? → il gagne.
- Sinon, le plugin :
mod/simplecheck/templates/checklist.mustache.
Cette résolution par convention de nommage est ce qui permet à un thème de redessiner n’importe quel composant sans toucher au plugin (Partie 8). Le nom component/nom mappe sur <dossier-du-component>/templates/nom.mustache, sauf surcharge de thème.
💡 Pour un dev React : c’est un système de résolution de composants par override, un peu comme un
themequi remplace des composants par leur chemin (pensez au « component shadowing » de certains frameworks, ou au swizzling de Docusaurus). Vous ne modifiez pas le composant d’origine : vous fournissez, à un emplacement conventionnel, une version qui prend le dessus.
6. Un template complet, commenté
Rassemblons tout sur la checklist du chapitre 6.5, version enrichie (édition, uniqid, JS d’instance) :
{{!
mod_simplecheck/checklist
Contexte :
* cansubmit bool — l'utilisateur peut-il cocher ?
* formaction string — URL de soumission
* sesskey string — jeton CSRF
* hasitems bool
* items array
* id int
* name string — DÉJÀ échappé côté PHP (format_string)
* checked bool
* percentage int
}}
<div class="mod-simplecheck" id="simplecheck-{{uniqid}}">
<div class="progress mb-3" role="progressbar"
aria-valuenow="{{percentage}}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar" style="width: {{percentage}}%;">{{percentage}}%</div>
</div>
{{#hasitems}}
<form method="post" action="{{formaction}}">
<input type="hidden" name="sesskey" value="{{sesskey}}">
<ul class="list-unstyled">
{{#items}}
<li class="mb-2">
<label class="d-flex align-items-center">
<input type="checkbox" name="check[{{id}}]" value="1"
class="me-2"
{{#checked}}checked{{/checked}}
{{^cansubmit}}disabled{{/cansubmit}}>
<span class="{{#checked}}text-decoration-line-through text-muted{{/checked}}">
{{name}}
</span>
</label>
</li>
{{/items}}
</ul>
{{#cansubmit}}
<button type="submit" class="btn btn-primary">
{{#str}}savechanges, core{{/str}}
</button>
{{/cansubmit}}
</form>
{{/hasitems}}
{{^hasitems}}
<div class="alert alert-info">{{#str}}noitems, mod_simplecheck{{/str}}</div>
{{/hasitems}}
</div>
{{#js}}
require(['mod_simplecheck/checklist'], function(Checklist) {
Checklist.init("simplecheck-{{uniqid}}");
});
{{/js}}Tout ce qu’on a vu s’y trouve : commentaire de contexte documenté, {{uniqid}} pour l’unicité, sections ({{#items}}, {{#checked}}), inversions ({{^hasitems}}, {{^cansubmit}}), attributs conditionnels ({{#checked}}checked{{/checked}}), helpers ({{#str}}), et {{#js}} d’initialisation. Zéro logique : percentage, hasitems, cansubmit ont tous été calculés en PHP.
⚠️ Piège : les attributs conditionnels comme
{{#checked}}checked{{/checked}}sont l’idiome correct pour « ajouter l’attribut si vrai ». N’essayez pas d’écrirechecked="{{checked}}"(rendraitchecked="1"ouchecked="", ambigu selon les navigateurs). La section vide-ou-présente est la bonne approche.
7. Outils : Template library, mode designer, lint
Moodle offre un outillage dédié aux templates :
- Template library (Administration → Développement → Template library) : liste tous les templates disponibles, affiche leur commentaire de contexte, et permet de rendre un template avec un contexte JSON à la volée. Idéal pour prototyper un
.mustachesans écrire de PHP. themedesignermode/cachetemplates: en dev, désactivez le cache des templates (Développement) pour voir vos modifications sans purge. En prod, le cache est actif (performance).- Mustache lint :
grunt mustache(ou la CImoodle-plugin-ci) valide la syntaxe, l’accessibilité de base et la présence du commentaire de contexte. À faire tourner avant de publier (Partie 11).
📚 Aller plus loin : la référence est moodledev.io/docs/5.2/guides/templates . Pour de vrais exemples, lisez
lib/templates/(templates du core :single_button,pix_icon,notification_*) et lestemplates/demod_forum— parmi les plus soignés du core.
✏️ Exercices
Exercice 1 — Échappé ou brut ?
Pour chaque donnée, {{ }} ou {{{ }}} ? (a) le nom d’un utilisateur saisi dans son profil ; (b) une description d’activité passée par format_text() en PHP ; (c) un message d’erreur d’une chaîne de langue ; (d) le contenu HTML d’un éditeur WYSIWYG déjà nettoyé côté serveur.
✅ Solution
(a) {{ }} (donnée utilisateur brute → échapper impérativement) ; (b) {{{ }}} (déjà formaté/nettoyé par format_text, contient du HTML légitime) ; (c) {{ }} ou plutôt {{#str}} directement (texte simple) ; (d) {{{ }}} (HTML de confiance, déjà nettoyé). Règle : {{{ }}} uniquement pour du HTML produit et assaini côté PHP ; partout ailleurs {{ }}.
Exercice 2 — Traduire une logique React.
En JSX : {items.length ? <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul> : <p>Vide</p>}. Écrivez l’équivalent Mustache et dites où va la préparation.
✅ Solution
{{#hasitems}}
<ul>
{{#items}}<li>{{name}}</li>{{/items}}
</ul>
{{/hasitems}}
{{^hasitems}}
<p>Vide</p>
{{/hasitems}}La préparation (hasitems = !empty($items), et chaque name échappé via format_string) se fait en PHP dans export_for_template(). Mustache ne sait pas tester items.length : on lui fournit un booléen hasitems déjà calculé, et on utilise section + inversion.
Exercice 3 — Instances multiples cassées.
Un développeur ancre son JS sur id="chart" en dur dans le template. Sur une page avec deux instances du bloc, une seule s’initialise. Diagnostic et correctif ?
✅ Solution
Deux rendus produisent deux éléments avec le même id="chart" : les ids HTML doivent être uniques, document.getElementById('chart') ne renvoie que le premier, et le JS n’initialise qu’une instance (ou les corrompt). Correctif : utiliser {{uniqid}} — id="chart-{{uniqid}}" — et passer cet id au module dans le {{#js}} (Chart.init("chart-{{uniqid}}")). Chaque rendu a alors un id unique et les deux instances coexistent.
Exercice 4 — Surcharge par thème.
Un client veut changer le HTML de la checklist de mod_simplecheck sans modifier le plugin (pour survivre aux upgrades du plugin). Comment ?
✅ Solution
Créer, dans le thème du client, le fichier theme/<theme>/templates/mod_simplecheck/checklist.mustache : grâce à la résolution par convention, il prend le dessus sur mod/simplecheck/templates/checklist.mustache. Le plugin reste intact (upgrades sûrs) et le thème porte la personnalisation. Contrainte : le template surchargé doit respecter le même contexte (mêmes variables) que l’original, et il faut maintenir cette surcharge si le plugin change son contexte (Partie 8 détaille ce coût).
Exercice 5 — Rendu client sécurisé.
Vous rendez mod_simplecheck/checklist côté JS avec des données venant d’un web service. Un name d’item contient <script>. Que se passe-t-il, et comment garantir la sécurité ?
✅ Solution
Dans le template, {{name}} est échappé par mustache.js → le <script> s’affiche comme texte, pas d’exécution : bon réflexe de base. Mais mustache.js n’applique pas les filtres Moodle ni le nettoyage HTML riche ; donc si vous utilisiez {{{name}}} (brut), ce serait une faille. La garantie complète : formater côté serveur dans l’external function (external_format_string/external_format_text) avant de renvoyer les données au JS, et n’utiliser {{ }} (échappé) que pour du texte. Sécurité de sortie = responsabilité du serveur, même en rendu client.
🧠 Quiz de révision
Q1. Pourquoi Mustache est-il « logic-less » et quelle conséquence pratique pour un dev ?
Réponse
export_for_template() ; le template ne fait qu’itérer et tester la vacuité.Q2. Différence entre {{x}}, {{{x}}}, {{#x}} et {{^x}} ?
Réponse
{{x}} = variable échappée (sûre) ; {{{x}}} = variable brute non échappée (réservée au HTML déjà nettoyé côté PHP) ; {{#x}}…{{/x}} = section (rendue si truthy, ou itérée si liste) ; {{^x}}…{{/x}} = inversion (rendue si faux/vide — le « else »).Q3. À quoi servent {{#str}}, {{#pix}}, {{#js}} et {{uniqid}} ?
Réponse
{{#str}} insère une chaîne de langue (pas de texte en dur) ; {{#pix}} insère une icône thémable ; {{#js}} attache du JavaScript au template (exécuté après chargement des deps) ; {{uniqid}} génère un id unique par rendu, indispensable pour les instances multiples et l’init JS.Q4. Comment le même template est-il rendu côté serveur et côté client, et pourquoi est-ce possible ?
Réponse
$OUTPUT->render_from_template(...) (Mustache.php), côté client via core/templates.renderForPromise(...) (mustache.js). C’est possible parce que le template est logic-less : purement déclaratif, il ne dépend d’aucun code PHP et produit le même HTML avec le même contexte des deux côtés.Q5. Comment un thème surcharge-t-il un template de plugin, et quelle est la contrainte ?
Réponse
theme/<theme>/templates/<component>/<nom>.mustache, qui prend le dessus sur <component>/templates/<nom>.mustache via la résolution par convention. Contrainte : respecter le même contexte (mêmes variables) et maintenir la surcharge si le composant d’origine évolue.Chapitre suivant : 03 — JavaScript Moodle en profondeur — écrire un module dans amd/src/, le build (npm + grunt amd + watch), la structure init/main, et l’écosystème core/* (ajax, str, notification, modal, templates, fragment, toast, pub/sub) avec exemples complets. La couche qui anime les templates de ce chapitre.