Builder visuel
Le builder visuel du Labo du Yeti est un éditeur inline de type Elementor, capable d'éditer en place toute page HTML servie par le CMS. Il fonctionne sur un principe simple : la page réelle est rendue dans une <iframe>, un script injecté côté preview écoute les clics, et une sidebar contextuelle dans le shell React applique les modifications via postMessage. Toutes les retouches sont persistées dans page.overrides côté base de données, sans toucher au template HTML d'origine.
storeType="pages"), pages cocon parent et enfant (storeType="cocon") et articles de blog (storeType="blog"). Le storeType conditionne uniquement les endpoints API et l'extraction des champs.
Vue d'ensemble
L'approche retenue évite deux écueils classiques des builders visuels : la mutation directe du DOM (impossible à versionner) et la régénération complète du HTML à chaque modification (coûteuse, casse les balises dynamiques d'extensions). Ici, le template reste immuable, et le builder produit un objet overrides qui décrit la liste des transformations à appliquer au rendu : nouveau texte, nouvel src, nouvelle couleur, nouvelle balise. La fusion template + overrides est faite côté serveur lors du build statique et côté preview à la volée.
Le builder a été livré en cinq phases incrémentales. Chacune fonctionne sans les suivantes et a été stabilisée avant de passer à la phase d'après.
Phase 1 — Iframe et sélection visuelle
Overlay iframe
Le builder occupe l'écran entier : à gauche une iframe qui charge la page de preview (/api/pages/:id/preview ou /api/cocon/pages/:id/preview), à droite une sidebar React de 380 px. L'iframe n'est pas isolée par sandbox car elle doit pouvoir communiquer librement avec le parent. La sécurité repose sur le fait que les deux contextes sont servis par le même backend authentifié.
Au chargement de l'iframe, le backend injecte automatiquement preview-overlay.js juste avant la balise de fermeture </body>. Ce script ne fait partie ni du template ni des overrides : il est ajouté uniquement lorsque la requête provient du builder, identifiée via un cookie de session ou un token de preview.
<!-- Injecté dynamiquement par le serveur de preview -->
<script src="/builder/preview-overlay.js"></script>
<link rel="stylesheet" href="/builder/preview-overlay.css" />
Communication postMessage
Le script preview-overlay.js joue trois rôles. Il intercepte le clic sur n'importe quel élément portant un attribut data-cms-*. Il dessine un cadre orange autour de l'élément ciblé au survol (outline + label). Il émet un message postMessage structuré vers la fenêtre parente avec les coordonnées du clic, l'attribut data-cms identifié et un snapshot de l'élément.
| Direction | Type | Payload | Usage |
|---|---|---|---|
| iframe → parent | cms:element-clicked | { cmsKey, kind, tag, text, html, attrs } | Ouvre la sidebar sur l'élément |
| iframe → parent | cms:ready | { elements: [...] } | Inventaire au chargement |
| parent → iframe | cms:apply-update | { cmsKey, patch } | Applique une modif live |
| parent → iframe | cms:highlight | { cmsKey } | Survol depuis la sidebar |
| parent → iframe | cms:scroll-to | { cmsKey } | Centre l'élément à l'écran |
CmsKeyField et inventaire
Chaque élément éditable du template doit porter un identifiant unique sous la forme data-cms-text="hero.title", data-cms-image="hero.bg", data-cms-video, data-cms-link, etc. La composante CmsKeyField côté React extrait cette clé et la normalise (la valeur après le préfixe data-cms- donne le kind, la valeur de l'attribut donne le path dans overrides).
Phase 2 — Sidebar contextuelle 3 onglets
Quand un élément est sélectionné, la sidebar affiche trois onglets : Contenu, Style, Avancé. L'onglet actif par défaut est Contenu. Les onglets sont rendus paresseusement : tant qu'aucun élément n'est sélectionné, la sidebar affiche un état vide (Cliquez sur un élément de la page pour commencer).
Onglet Contenu
Le rendu de l'onglet Contenu dépend du kind de l'élément cliqué :
text—<textarea>auto-resize, soumission aublurou via raccourci Ctrl+S.image— preview de la source actuelle, bouton Choisir dans la médiathèque (ouvre une modale) ou Uploader, champ alt, champ title.video— sélecteur média identique, plus optionsautoplay,loop,muted,controls.iframe— champ URL (validé), champtitlea11y, allowfullscreen.link— champhref, target, rel, libellé.html— éditeur multiligne pour fragments riches (utilisé avec parcimonie).
La mise à jour est optimistic : l'iframe applique le patch immédiatement via cms:apply-update, le store Zustand côté builder enregistre le delta dans page.overrides, et un debounce de 800 ms déclenche le PUT /api/pages/:id (ou /api/cocon/pages/:id ou /api/blog/articles/:id selon storeType).
Onglet Style (Phase 3)
L'onglet Style propose des contrôles sûrs, c'est-à-dire des modifications qui ne peuvent pas casser la mise en page :
| Contrôle | Élément cible | Propriété CSS | Stockage |
|---|---|---|---|
| Couleur du texte | text, link, button | color | overrides.{key}.style.color |
| Alignement | text, container | text-align | overrides.{key}.style.textAlign |
| Gras / Italique | text inline | font-weight / font-style | overrides.{key}.style.* |
| Fond bouton | button | background-color | overrides.{key}.style.backgroundColor |
Les modifications sont appliquées en style inline sur l'élément ciblé (jamais via classes Tailwind générées). Ce choix garantit la rétrocompatibilité avec n'importe quel template, sans dépendance à une feuille de styles externe versionnée.
Onglet Avancé (Phase 4)
L'onglet Avancé contient pour l'instant un seul réglage : la balise dynamique. Sur un élément texte, l'utilisateur peut choisir parmi h1, h2, h3, h4, h5, h6, p et span. Le changement de balise est stocké dans overrides.{key}.tags_html.
Côté preview, le remplacement de balise est fait dans le DOM via une fonction swapTagPreserveContent(node, newTag) qui :
- Clone l'élément cible dans la nouvelle balise.
- Recopie l'intégralité des attributs (sauf ceux interdits par la nouvelle balise).
- Re-parente les enfants et conserve les data-cms.
- Émet un event
cms:tag-swappedpour resynchroniser l'inventaire.
function swapTagPreserveContent(node, newTag) {
const replacement = document.createElement(newTag);
for (const attr of node.attributes) {
replacement.setAttribute(attr.name, attr.value);
}
while (node.firstChild) replacement.appendChild(node.firstChild);
node.replaceWith(replacement);
return replacement;
}
Phase 5 — Polish et ergonomie
La cinquième phase a regroupé les retouches qui transforment un prototype fonctionnel en outil quotidien.
- Raccourcis clavier :
Ctrl+Sforce la sauvegarde immédiate,Escferme la sidebar,Ctrl+Zannule la dernière modification (historique en mémoire 50 étapes). - Persistance de la sélection : au rechargement de l'iframe (changement de viewport ou rebuild), la sélection courante est retrouvée via le
cmsKeystocké dans le store Zustand. - Badge « modifié » : chaque élément de l'inventaire dont la clé apparaît dans
overridesest marqué d'un point orange. La liste des éléments modifiés est aussi disponible dans le panneau latéral droit. - Indicateur de sauvegarde : trois états dans la topbar du builder — Sauvegardé (par défaut), Modifications non sauvegardées (debounce en attente), Sauvegarde… (PUT en cours).
- Responsive preview : trois presets de largeur d'iframe (mobile 375 px, tablette 768 px, desktop pleine largeur) pour vérifier le rendu sans quitter le builder.
Mécanisme applyUpdate
La fonction applyUpdate(cmsKey, patch) est le point d'entrée unique côté preview-overlay.js pour modifier le DOM en réponse à un message du parent. Elle gère les cas suivants :
| Patch | Cible DOM | Action |
|---|---|---|
{ text } | élément texte | node.textContent = patch.text |
{ html } | élément html | node.innerHTML = sanitize(patch.html) |
{ src } | img / video / iframe | node.src = patch.src + reload si vidéo |
{ alt, title } | img | setAttribute respectifs |
{ href, target, rel } | a | setAttribute respectifs |
{ style } | tout élément | Object.assign(node.style, patch.style) |
{ tag } | tout élément | swapTagPreserveContent(node, patch.tag) |
Toute combinaison de ces clés peut être passée dans un seul patch (par exemple { text, style, tag } pour une modification atomique). L'ordre d'application est : tag en premier (sinon la référence DOM change), puis style, puis le contenu.
Stockage des overrides
Les overrides sont persistés dans la colonne JSON overrides de la table pages (ou pages cocon, ou articles). La structure est plate, indexée par cmsKey.
{
"hero.title": {
"text": "Bienvenue au Labo du Yeti",
"style": { "color": "#0F1B3D", "textAlign": "center" },
"tags_html": "h1"
},
"hero.cta": {
"text": "Démarrer maintenant",
"style": { "backgroundColor": "#F58220", "color": "#FFFFFF" },
"href": "/contact"
},
"hero.bg": {
"src": "/uploads/media/hero-2026.webp",
"alt": "Atelier vu de l'intérieur"
}
}
Le serveur applique les overrides au moment du rendu : pour chaque clé présente, il retrouve le ou les nœuds correspondants via data-cms-* avec Cheerio, et applique les patchs dans le même ordre que applyUpdate. Le résultat est mis en cache jusqu'à la prochaine sauvegarde.
overrides rétablit la valeur d'origine sans toucher au template.
Token de preview pour les brouillons
Les pages cocon en cours de génération vivent dans le dossier brouillons/ et ne sont pas exposées publiquement. Pour qu'elles soient affichables dans l'iframe du builder, le backend émet un preview token court-lived : un JWT signé valable cinq minutes, contenant l'ID de la page et l'ID utilisateur.
| Endpoint | Méthode | Description |
|---|---|---|
/api/cocon/preview-token | POST | Émet un token pour la page demandée |
/brouillons/{id}.html?token=... | GET | Sert le HTML brouillon si token valide |
Le token est vérifié au moment de la requête HTTP par un middleware dédié. En cas de signature invalide ou d'expiration, la requête est rejetée en 401. Côté builder, le token est rafraîchi automatiquement toutes les quatre minutes pour couvrir les longues sessions d'édition.
Réutilisabilité par storeType
Le builder est un composant React unique paramétré par storeType. Ce paramètre détermine :
storeType | Endpoint GET | Endpoint PUT | Endpoint preview |
|---|---|---|---|
pages | /api/pages/:id | /api/pages/:id | /api/pages/:id/preview |
cocon | /api/cocon/pages/:id | /api/cocon/pages/:id | /api/cocon/pages/:id/preview |
blog | /api/blog/articles/:id | /api/blog/articles/:id | /api/blog/articles/:id/preview |
Les trois flux partagent strictement la même UI, le même preview-overlay.js, la même logique de sauvegarde optimistic et la même structure overrides. Cette uniformité est l'un des choix d'architecture les plus rentables du projet : une seule base de code à maintenir pour trois cas d'usage métier différents.
Sécurité et optimistic locking
Chaque PUT passe par le contrôle if_updated_at : le builder envoie la dernière valeur d'updated_at connue, et le serveur retourne 409 Conflict si la page a été modifiée par un autre utilisateur entre-temps. Le builder affiche alors une bannière proposant de recharger la version distante (en perdant les modifications locales) ou de forcer l'écriture.
Les routes API exigent requireAuth + requireRole(owner, admin, webmaster, editor) + requireCapability sur la feature concernée (pages, cocon ou blog). Le rôle viewer n'est jamais autorisé à modifier, mais peut ouvrir le builder en lecture seule : la sidebar est désactivée, l'inventaire reste navigable.
Fichiers clés
frontend/src/components/builder/Builder.jsx— composant racine, gèrestoreTypeet l'orchestration des onglets.frontend/src/components/builder/CmsKeyField.jsx— extraction et normalisation desdata-cms-*.frontend/src/components/builder/tabs/{Content,Style,Advanced}Tab.jsx— trois onglets contextuels.backend/src/public/builder/preview-overlay.js— script injecté dans l'iframe (sélection, postMessage, applyUpdate).backend/src/services/preview-token.js— émission et vérification des JWT preview.backend/src/services/render-service.js— fusion template + overrides via Cheerio au moment du rendu serveur.
Références croisées
- Moteur HTML — comment
overridesest appliqué au rendu serveur. - Base de données — schéma des colonnes
overridesetupdated_at. - Authentification & RBAC — middlewares
requireRoleetrequireCapability. - Cocon SEO — flux brouillon, preview token, génération parent/enfant.
- Blog IA — usage du builder pour les articles.