Moteur HTML (pipeline)
Le moteur de rendu HTML est le cœur du Labo du Yeti : il transforme une page ou un article stocké en base de données en un fichier HTML statique publiable. Tout passe par un pipeline déterministe et idempotent orchestré par backend/src/services/render-service.js, qui s'appuie sur Cheerio pour la manipulation DOM côté serveur. Cette page décrit en détail les 10 étapes du pipeline, les conventions HTML (data-cms, data-ai, data-cms-slot) et le format des templates.
Vue d'ensemble du pipeline
Quand l'utilisateur clique sur Publier une page ou lance un Build complet, la fonction renderPage(page) (ou son équivalent article) est appelée. Elle exécute strictement les étapes suivantes dans l'ordre :
- Chargement du layout actif depuis
templates/layouts/<active>.html - Injection du fragment page/article dans
<main data-cms-slot="content"> - Résolution des variables
{{var}}(cocon, site, contexte) - Injection des
data-cms(clés CMS structurelles depuispage.content) - Injection des
data-ai(texte IA depuisarticle.ai_text+ images depuisai_images) - Masquage des sections vides via
hideEmptyAiSections,hideEmptyRelatedSection,hideEmptySourcesSection - Fallbacks d'images via
fillMissingImages - Extraction SEO depuis le
<head>(title, meta, og:*) - Injection JSON-LD (Article, BreadcrumbList, Organization)
- Écriture du HTML final dans
public/<slug>.html
Chaque étape est pure (entrée HTML → sortie HTML) et peut être testée isolément. Le résultat est ensuite passé au hook d'extension render.html (filter chaînable) avant l'écriture disque.
1. Chargement du layout actif
Le layout est le squelette HTML global du site (header, footer, navigation, balises <head> communes). Il est stocké dans templates/layouts/ et l'actif est désigné par global.layout.active (KV store).
// Pseudo-code simplifié de render-service.js
const layoutName = store.global.layout.active || 'default';
const layoutPath = path.join(paths.templates, 'layouts', `${layoutName}.html`);
const layoutHtml = await fs.readFile(layoutPath, 'utf-8');
const $ = cheerio.load(layoutHtml);
Un layout valide doit contenir exactement un élément <main data-cms-slot="content"></main> qui sert de point d'ancrage pour le fragment. L'API /api/layouts permet d'uploader plusieurs layouts et de basculer l'actif sans rebuild (le prochain rendu utilise le nouveau).
data-cms-slot="content" est absent ou présent plusieurs fois, le rendu échoue avec une erreur 500. Le linter /api/layouts POST vérifie ce point à l'upload.
2. Injection du fragment dans le slot
Le fragment HTML d'une page (stocké dans pages.template_html) ne contient ni <html>, ni <head>, ni <body> : c'est un fragment pur qui s'insère dans le slot principal.
const fragmentHtml = page.template_html;
$('main[data-cms-slot="content"]').html(fragmentHtml);
Pour les articles de blog, le fragment provient de articles.template_html (template global défini dans Réglages → Blog ou template spécifique à l'article). Pour les pages de cocon, le fragment provient de cocon_groups.template_html.
Format d'un fragment
<!-- Fragment d'une page service (pas de html/head/body) -->
<section class="hero">
<h1 data-cms="page.title">Titre par défaut</h1>
<p data-cms="page.intro">Intro par défaut</p>
</section>
<section class="features" data-hide-if-empty="page.features">
<h2 data-cms="page.features.title">Nos atouts</h2>
<ul data-cms="page.features.list"></ul>
</section>
3. Résolution des variables {{var}}
Avant toute injection de contenu, les variables Mustache-like sont remplacées partout dans le DOM (attributs et texte). C'est utilisé essentiellement par les templates de cocon où {{service}} et {{ville}} sont substitués par les valeurs de chaque combinaison cartésienne.
function resolveVariables($, vars) {
let html = $.html();
for (const [key, value] of Object.entries(vars)) {
const re = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g');
html = html.replace(re, escapeHtml(value));
}
return cheerio.load(html);
}
Les variables disponibles côté pages cocon incluent :
{{service}}— nom du service (ex. "plomberie"){{ville}}— nom de la ville (ex. "Liège"){{service.slug}}/{{ville.slug}}— versions slugifiées{{site.name}},{{site.url}}— données globales du site{{parent.slug}}— slug de la page parent (cocon enfant uniquement)
4. Injection des data-cms (contenu structurel)
Les attributs data-cms="..." désignent les champs structurels éditables dans le builder visuel et dans page.content (JSON). Le pipeline les remplit en parcourant l'arbre DOM :
$('[data-cms]').each((_, el) => {
const key = $(el).attr('data-cms');
const value = resolvePath(page.content, key); // ex: 'page.title'
if (value !== undefined && value !== null) {
if (el.tagName === 'img') {
$(el).attr('src', value);
} else if (el.tagName === 'a') {
$(el).attr('href', value);
} else {
$(el).html(value); // accepte HTML inline (gras, italique)
}
}
});
| Élément | Convention data-cms |
Comportement |
|---|---|---|
<h1> à <h6> | page.title | Remplace innerHTML |
<p> / <span> | page.intro | Remplace innerHTML |
<img> | page.hero.image | Remplace src |
<a> | page.cta.url | Remplace href |
<ul> / <ol> | page.features.list | Génère les <li> depuis array |
page.overrides[key] et ont priorité sur page.content[key]. Le pipeline merge ces deux sources avant injection.
5. Injection des data-ai (contenu IA)
Pour le blog, le contenu généré par l'IA est stocké dans deux colonnes distinctes : articles.ai_text (JSON des textes : intro, sections, FAQ, conclusion) et articles.ai_images (JSON des URLs d'images sourcées via Unsplash/Pexels/Pixabay).
// Injection textes IA
$('[data-ai]').each((_, el) => {
const key = $(el).attr('data-ai');
const value = resolvePath(article.ai_text, key);
if (value) $(el).html(value);
});
// Injection images IA
$('[data-ai-image]').each((_, el) => {
const key = $(el).attr('data-ai-image');
const url = resolvePath(article.ai_images, key);
if (url) $(el).attr('src', url);
});
La séparation data-cms / data-ai est volontaire : data-cms = champs structurels édités à la main (titre commercial, CTA, contact) ; data-ai = corps éditorial produit par l'IA et régénérable à volonté sans toucher la structure.
Clé data-ai |
Source | Exemple de contenu |
|---|---|---|
article.intro | ai_text.intro | Paragraphe d'introduction (60-120 mots) |
article.sections[0].title | ai_text.sections | Titre H2 de la section 1 |
article.sections[0].body | ai_text.sections | Corps HTML de la section |
article.faq | ai_text.faq | Questions/réponses (array) |
article.conclusion | ai_text.conclusion | Paragraphe de conclusion |
article.hero.image | ai_images.hero | URL absolue de l'image vedette |
6. Masquage des sections vides
Quand une clé data-cms ou data-ai est vide ou absente, on ne veut pas laisser la section avec un texte par défaut visible. Trois helpers spécifiques cachent les sections vides avec style="display:none" :
hideEmptyAiSections($)
Parcourt tous les [data-hide-if-empty] et applique display:none si la clé pointée est vide. Utilisé pour les sections IA optionnelles (FAQ, stats, témoignages).
$('[data-hide-if-empty]').each((_, el) => {
const key = $(el).attr('data-hide-if-empty');
const value = resolvePath({ ...article.ai_text, ...page.content }, key);
const isEmpty = !value || (Array.isArray(value) && value.length === 0);
if (isEmpty) $(el).attr('style', 'display:none');
});
hideEmptyRelatedSection($)
Spécifique aux pages de cocon enfant : cache la section "Voir plus" si aucune page sœur n'existe encore (typiquement quand on est la première combinaison générée d'un groupe).
hideEmptySourcesSection($)
Spécifique aux articles de blog : cache la section "Sources" si ai_text.sources est vide. Évite d'afficher un bloc bibliographique factice.
display:none et pas suppression DOM ? Pour permettre à un éditeur de re-remplir la section plus tard sans devoir réimporter le template. Le squelette reste intact, seul l'affichage est masqué.
7. Fallbacks d'images (fillMissingImages)
Si une <img> n'a toujours pas de src après les étapes 4 et 5, on injecte une image fallback selon une cascade :
- Si l'élément a
data-fallback="...", utiliser cette URL - Sinon, utiliser
page.fallback_imageouarticle.featured_image - Sinon, utiliser le placeholder global
/assets/img-placeholder.svg
$('img:not([src]), img[src=""]').each((_, el) => {
const fallback = $(el).attr('data-fallback')
|| page.fallback_image
|| '/assets/img-placeholder.svg';
$(el).attr('src', fallback);
});
Cette étape évite définitivement le scénario "image cassée" dans la page publiée, ce qui pénaliserait fortement le SEO et l'UX.
8. Extraction SEO depuis le <head>
Le moteur lit puis met à jour les balises SEO du <head> en se basant sur les champs de la page :
| Balise | Source | Fallback |
|---|---|---|
<title> | page.seo.title | page.title + suffixe site.name |
<meta name="description"> | page.seo.description | Premier paragraphe (max 160 char) |
<link rel="canonical"> | site.url + slug | — |
og:title | page.seo.title | <title> |
og:description | page.seo.description | meta description |
og:image | page.seo.og_image | page.hero.image ou article.featured_image |
og:type | page = website, article = article | website |
9. Injection du JSON-LD
Le moteur injecte automatiquement les blocs JSON-LD utiles au SEO juste avant </head> :
- Article (blog uniquement) : headline, author, datePublished, dateModified, image, publisher
- BreadcrumbList : reconstruit depuis le slug (ex.
/services/plomberie/liege→ 3 niveaux) - Organization : injecté une fois depuis
global.site(name, url, logo, sameAs) - FAQPage (blog) : généré si
ai_text.faqexiste (Q/A formattées)
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "Comment choisir son plombier à Liège",
"datePublished": "2026-05-26T10:00:00Z",
"author": { "@type": "Organization", "name": "Le Labo du Yeti" },
"image": "https://exemple.be/img/hero.jpg"
}
</script>
10. Écriture du HTML final
Le HTML final est sérialisé avec $.html(), passé dans le hook d'extension render.html (filter chaînable, ex. minification, snippet SureFeedback), puis écrit sur disque :
let finalHtml = $.html();
finalHtml = await applyFilter('render.html', finalHtml, { page, slug });
const outputPath = path.join(paths.public, `${slug}.html`);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, finalHtml, 'utf-8');
await emitEvent('page.rendered', { slug, outputPath });
L'extension webp-auto-converter peut par exemple intercepter le HTML pour transformer les src="*.jpg" en src="*.webp" à la volée si la version WebP existe.
Extraction des clés (extractCmsKeys / extractAiKeys)
Le builder visuel et le formulaire d'édition ont besoin de connaître toutes les clés éditables d'un template avant même que le contenu existe. Deux helpers parcourent le HTML brut :
export function extractCmsKeys(html) {
const $ = cheerio.load(html);
const keys = new Set();
$('[data-cms]').each((_, el) => keys.add($(el).attr('data-cms')));
return Array.from(keys);
}
export function extractAiKeys(html) {
const $ = cheerio.load(html);
const keys = new Set();
$('[data-ai]').each((_, el) => keys.add($(el).attr('data-ai')));
$('[data-ai-image]').each((_, el) => keys.add($(el).attr('data-ai-image')));
return Array.from(keys);
}
Ces fonctions sont appelées à chaque upload de template (POST /api/pages, POST /api/blog/templates/upload) pour pré-remplir page.content et article.ai_text avec des clés vides. Le builder peut ainsi afficher la liste complète des champs disponibles, même sur un template fraichement importé.
Format d'un template article-blog complet
Un template article-blog réunit toutes les conventions ci-dessus. Voici sa structure type :
<article class="post">
<!-- Header / Hero -->
<header class="post-hero">
<img data-ai-image="article.hero.image" alt="" />
<h1 data-cms="article.title">Titre</h1>
<p class="meta">
<time data-cms="article.published_at"></time>
· <span data-cms="article.category"></span>
</p>
</header>
<!-- Sommaire sticky (rempli côté client) -->
<aside class="toc"></aside>
<!-- Intro IA -->
<section class="intro">
<p data-ai="article.intro"></p>
</section>
<!-- Sections IA répétables -->
<section data-ai="article.sections">
<h2 data-ai="section.title"></h2>
<div data-ai="section.body"></div>
</section>
<!-- FAQ (masquée si vide) -->
<section class="faq" data-hide-if-empty="article.faq">
<h2>Questions fréquentes</h2>
<dl data-ai="article.faq"></dl>
</section>
<!-- Sources (masquée si vide) -->
<section class="sources" data-hide-if-empty="article.sources">
<h2>Sources</h2>
<ul data-ai="article.sources"></ul>
</section>
<!-- CTA structurel (data-cms, jamais data-ai) -->
<footer class="post-cta">
<a data-cms="article.cta.url" class="btn">
<span data-cms="article.cta.label">En savoir plus</span>
</a>
</footer>
<!-- Formulaire (data-cms-form, géré par forms-service) -->
<form data-cms-form="contact-article">
<input data-cms-field="email" type="email" required />
<button type="submit">Envoyer</button>
</form>
</article>
Récapitulatif des conventions
| Attribut | Rôle | Quand l'utiliser |
|---|---|---|
data-cms="key" |
Champ structurel CMS (édité main, builder visuel) | Titre commercial, CTA, contact, image vedette manuelle |
data-ai="key" |
Champ texte généré par IA | Intro article, sections, FAQ, conclusion |
data-ai-image="key" |
Image générée/sourcée par IA | Hero d'article, illustration de section |
data-cms-slot="content" |
Slot principal du layout (un seul par layout) | Layouts uniquement, jamais dans un fragment |
data-hide-if-empty="key" |
Masque l'élément si la clé est vide | Sections IA optionnelles (FAQ, stats, sources) |
data-cms-form="formId" |
Identifie un formulaire géré par forms-service | Tout formulaire dont la soumission doit être traitée backend |
data-cms-field="name" |
Champ d'un formulaire (mappé vers le payload) | Tous les <input>/<textarea> d'un data-cms-form |
{{var}} |
Variable Mustache résolue à l'étape 3 | Templates cocon ({{service}}, {{ville}}), composition globale |
data-cms et data-ai sur le même élément. Un champ est soit éditorial-IA (régénérable), soit structurel-CMS (édité main). Le pipeline lève une warning au build si les deux co-existent.
Aller plus loin
- Moteur IA — comment
ai_textetai_imagessont peuplés - Builder visuel — édition
data-cmsvia iframe postMessage - Blog IA — pipeline complet d'un article (génération + rendu)
- Cocon SEO — variables cartésiennes et architecture parent/enfant
- Build & Publication — orchestration globale du
buildSite() - Système d'extensions — hook
render.html(filter chaînable)