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.

Pourquoi un moteur custom ? Pour rester 100 % HTML statique, sans runtime client, sans framework JS imposé. Le contenu généré est lisible par n'importe quel navigateur ou crawler dès la première requête (SEO-first).

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 :

  1. Chargement du layout actif depuis templates/layouts/<active>.html
  2. Injection du fragment page/article dans <main data-cms-slot="content">
  3. Résolution des variables {{var}} (cocon, site, contexte)
  4. Injection des data-cms (clés CMS structurelles depuis page.content)
  5. Injection des data-ai (texte IA depuis article.ai_text + images depuis ai_images)
  6. Masquage des sections vides via hideEmptyAiSections, hideEmptyRelatedSection, hideEmptySourcesSection
  7. Fallbacks d'images via fillMissingImages
  8. Extraction SEO depuis le <head> (title, meta, og:*)
  9. Injection JSON-LD (Article, BreadcrumbList, Organization)
  10. É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).

Convention stricte. Si 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 :

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.titleRemplace innerHTML
<p> / <span>page.introRemplace innerHTML
<img>page.hero.imageRemplace src
<a>page.cta.urlRemplace href
<ul> / <ol>page.features.listGénère les <li> depuis array
Override par page. Les valeurs éditées dans le builder visuel sont stockées dans 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.introai_text.introParagraphe d'introduction (60-120 mots)
article.sections[0].titleai_text.sectionsTitre H2 de la section 1
article.sections[0].bodyai_text.sectionsCorps HTML de la section
article.faqai_text.faqQuestions/réponses (array)
article.conclusionai_text.conclusionParagraphe de conclusion
article.hero.imageai_images.heroURL 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');
});

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.

Why 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 :

  1. Si l'élément a data-fallback="...", utiliser cette URL
  2. Sinon, utiliser page.fallback_image ou article.featured_image
  3. 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 :

BaliseSourceFallback
<title>page.seo.titlepage.title + suffixe site.name
<meta name="description">page.seo.descriptionPremier paragraphe (max 160 char)
<link rel="canonical">site.url + slug
og:titlepage.seo.title<title>
og:descriptionpage.seo.descriptionmeta description
og:imagepage.seo.og_imagepage.hero.image ou article.featured_image
og:typepage = website, article = articlewebsite

9. Injection du JSON-LD

Le moteur injecte automatiquement les blocs JSON-LD utiles au SEO juste avant </head> :

<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
Règle d'or. Ne jamais mélanger 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