Partie 2 : Le compilateur — six étapes de transformation M1 → M0
La partie 1 a posé la thèse : le markdown n'est pas le HTML, et cette non-identité opère sur trois plans. Cette partie entre dans le compilateur lui-même — le pipeline de build qui médie la transformation M1 → M0 — et montre ce qui se passe concrètement à chaque étape.
Tout le code cité ici est le code réel du site. Pas de pseudo-code, pas de simplification pédagogique. Le lecteur peut vérifier chaque extrait dans le dépôt source.
Le pipeline en un schéma
Chaque étape reçoit des entrées M1 (ou M1 partiellement transformé) et produit des sorties plus proches de M0. Le pipeline n'est pas un seul passage — c'est une séquence de transformations dont chacune a sa propre dimension de non-identité.
Étape 1 — La génération du TOC : extraction sémantique
Entrée : tous les fichiers .md sous content/, chacun avec son frontmatter YAML.
Sortie : toc.json — un arbre structuré de sections, catégories, items, enfants, headings.
Fichier source : scripts/build-toc.ts
Le TOC n'existe dans aucun fichier markdown individuel. Il est le résultat d'une extraction sémantique : le pipeline scanne tous les fichiers, parse chaque frontmatter, résout les catégories, propage l'héritage parent→enfant, et construit un arbre hiérarchique.
La fonction extractHeadings() illustre cette non-identité. Elle prend du texte markdown plat et produit des objets structurés avec des slugs hiérarchiques calculés :
// scripts/build-toc.ts — extractHeadings()
function extractHeadings(content: string, tooltips?: TooltipMap): Heading[] {
const body: string = content.replace(/^---\n[\s\S]*?\n---\n/, '');
const headings: Heading[] = [];
const parentSlugs: Record<number, string> = {}; // level -> slug
const slugCount: Record<string, number> = {};
let inCode: boolean = false;
for (const line of body.split('\n')) {
if (line.startsWith('```')) { inCode = !inCode; continue; }
if (inCode) continue;
const match = line.match(/^(#{1,3})\s+(.+)$/);
if (!match) continue;
const level: number = match[1]!.length;
const text: string = match[2]!.trim();
const own: string = slugify(text);
// Clear child levels when a new parent appears
for (let l: number = level; l <= 6; l++) delete parentSlugs[l];
// Build hierarchical slug
let slug: string;
if (level <= 1) {
slug = own;
} else {
let parent: string | null = null;
for (let l: number = level - 1; l >= 1; l--) {
if (parentSlugs[l]) { parent = parentSlugs[l] ?? null; break; }
}
slug = parent ? `${parent}--${own}` : own;
}
parentSlugs[level] = slug;
headings.push({ level, text: cleanText, slug, /* ... */ });
}
return headings;
}// scripts/build-toc.ts — extractHeadings()
function extractHeadings(content: string, tooltips?: TooltipMap): Heading[] {
const body: string = content.replace(/^---\n[\s\S]*?\n---\n/, '');
const headings: Heading[] = [];
const parentSlugs: Record<number, string> = {}; // level -> slug
const slugCount: Record<string, number> = {};
let inCode: boolean = false;
for (const line of body.split('\n')) {
if (line.startsWith('```')) { inCode = !inCode; continue; }
if (inCode) continue;
const match = line.match(/^(#{1,3})\s+(.+)$/);
if (!match) continue;
const level: number = match[1]!.length;
const text: string = match[2]!.trim();
const own: string = slugify(text);
// Clear child levels when a new parent appears
for (let l: number = level; l <= 6; l++) delete parentSlugs[l];
// Build hierarchical slug
let slug: string;
if (level <= 1) {
slug = own;
} else {
let parent: string | null = null;
for (let l: number = level - 1; l >= 1; l--) {
if (parentSlugs[l]) { parent = parentSlugs[l] ?? null; break; }
}
slug = parent ? `${parent}--${own}` : own;
}
parentSlugs[level] = slug;
headings.push({ level, text: cleanText, slug, /* ... */ });
}
return headings;
}Le heading markdown ### Niveau 2 — Informationnel devient un objet :
{
"level": 3,
"text": "Niveau 2 — Informationnel : une transformation non-bijective",
"slug": "la-non-identite--trois-niveaux--niveau-2--informationnel"
}{
"level": 3,
"text": "Niveau 2 — Informationnel : une transformation non-bijective",
"slug": "la-non-identite--trois-niveaux--niveau-2--informationnel"
}Le slug hiérarchique la-non-identite--trois-niveaux--niveau-2--informationnel n'existe pas dans le markdown source. Il est calculé par le pipeline en résolvant la chaîne parent→enfant via parentSlugs. C'est de l'information créée — absente du M1, présente dans le M0.
Étape 2 — La validation des liens : cohérence M1
Entrée : tous les fichiers .md sous content/.
Sortie : exit code 0 (tous les liens valides) ou 1 (violations listées).
Fichier source : scripts/validate-md-links.ts
Cette étape est particulière : elle opère entièrement au niveau M1. Elle ne produit pas de HTML. Elle vérifie que chaque lien interne ([texte](../foo.md)) et chaque image () pointe vers un fichier qui existe réellement sur le disque.
Pourquoi valider au niveau M1 ? Parce qu'au niveau M0, les liens ont changé de forme. Un ../foo.md est devenu /content/blog/foo.html — et à ce stade, il est trop tard pour vérifier la cohérence de la source. La validation des liens est une pré-condition de la transformation : elle garantit que le M1 est cohérent avant qu'il soit transformé en M0.
C'est la dimension de non-identité la plus subtile : une opération qui n'a de sens qu'au niveau M1 parce que le M0 a déjà perdu l'information nécessaire (les chemins relatifs en .md).
Étape 3 — Le rendu de page : la transformation proprement dite
Entrée : un fichier .md + toc.json + template HTML.
Sortie : un fichier .html complet avec meta tags, JSON-LD, TOC sidebar, headings ancrés.
Fichier source : scripts/lib/page-renderer.ts + scripts/build-static.ts
C'est ici que l'Aufhebung se matérialise. Le cœur du compilateur est page-renderer.ts, parallélisé via un worker pool pour traiter toutes les pages simultanément.
rewriteLinks() — l'Aufhebung en trois regex
// scripts/lib/page-renderer.ts — rewriteLinks()
export function rewriteLinks(html: string, currentMdPath: string): string {
const currentDir: string = path.posix.dirname(currentMdPath);
return html
// SPA-style anchors: href="#content/foo.md" → href="/content/foo.html"
.replace(/href="#(content\/[^"]+?)\.md(?:::([^"]*?))?"/g,
(_: string, targetMdPath: string, anchor: string | undefined) => {
const frag: string = anchor ? `#${anchor}` : '';
return `href="/${targetMdPath}.html${frag}"`;
})
// Relative links: href="../foo.md" → href="/content/blog/foo.html"
.replace(/href="(\.\.?\/[^"]*?)\.md"/g, (_: string, rel: string) => {
const resolved: string = path.posix.normalize(currentDir + '/' + rel + '.html');
return `href="/${resolved}"`;
})
// Bare relative links: href="foo.md" → href="/content/blog/current/foo.html"
.replace(/href="(?![a-zA-Z][a-zA-Z+.-]*:)([^"/][^"]*?)\.md"/g,
(_: string, rel: string) => {
const resolved: string = path.posix.normalize(currentDir + '/' + rel + '.html');
return `href="/${resolved}"`;
});
}// scripts/lib/page-renderer.ts — rewriteLinks()
export function rewriteLinks(html: string, currentMdPath: string): string {
const currentDir: string = path.posix.dirname(currentMdPath);
return html
// SPA-style anchors: href="#content/foo.md" → href="/content/foo.html"
.replace(/href="#(content\/[^"]+?)\.md(?:::([^"]*?))?"/g,
(_: string, targetMdPath: string, anchor: string | undefined) => {
const frag: string = anchor ? `#${anchor}` : '';
return `href="/${targetMdPath}.html${frag}"`;
})
// Relative links: href="../foo.md" → href="/content/blog/foo.html"
.replace(/href="(\.\.?\/[^"]*?)\.md"/g, (_: string, rel: string) => {
const resolved: string = path.posix.normalize(currentDir + '/' + rel + '.html');
return `href="/${resolved}"`;
})
// Bare relative links: href="foo.md" → href="/content/blog/current/foo.html"
.replace(/href="(?![a-zA-Z][a-zA-Z+.-]*:)([^"/][^"]*?)\.md"/g,
(_: string, rel: string) => {
const resolved: string = path.posix.normalize(currentDir + '/' + rel + '.html');
return `href="/${resolved}"`;
});
}Trois regex, trois cas, un seul résultat : chaque lien .md est supprimé (le chemin relatif markdown disparaît), conservé (la destination logique est la même), et élevé (l'URL est absolue, canonique, navigable par le navigateur). L'Aufhebung en trois lignes de code.
buildMetaTags() — information créée
// scripts/lib/page-renderer.ts — buildMetaTags()
export function buildMetaTags(item, description, htmlPath, isHomePage, baseUrl): string {
const title: string = item ? item.title : 'Stéphane Erard';
const tags: string[] = [];
tags.push(`<title>${escapeHtml(title)} — Stéphane Erard</title>`);
tags.push(`<meta name="description" content="${escapeHtml(description)}">`);
tags.push(`<link rel="canonical" href="${url}">`);
tags.push(`<meta property="og:type" content="article">`);
tags.push(`<meta property="og:title" content="${escapeHtml(title)}">`);
tags.push(`<meta property="article:published_time" content="${item.date}">`);
// ... 15+ meta tags au total
return tags.join('\n ');
}// scripts/lib/page-renderer.ts — buildMetaTags()
export function buildMetaTags(item, description, htmlPath, isHomePage, baseUrl): string {
const title: string = item ? item.title : 'Stéphane Erard';
const tags: string[] = [];
tags.push(`<title>${escapeHtml(title)} — Stéphane Erard</title>`);
tags.push(`<meta name="description" content="${escapeHtml(description)}">`);
tags.push(`<link rel="canonical" href="${url}">`);
tags.push(`<meta property="og:type" content="article">`);
tags.push(`<meta property="og:title" content="${escapeHtml(title)}">`);
tags.push(`<meta property="article:published_time" content="${item.date}">`);
// ... 15+ meta tags au total
return tags.join('\n ');
}Aucune de ces balises n'a d'équivalent dans le fichier markdown. Elles sont créées par le compilateur à partir du frontmatter (qui lui-même est consommé dans le processus). Le HTML sait des choses que le markdown ne sait pas : l'URL canonique, le type OpenGraph, le format de carte Twitter.
buildJsonLd() — données structurées générées
// scripts/lib/page-renderer.ts — buildJsonLd()
export function buildJsonLd(item, description, isHomePage, baseUrl): string | null {
if (item && 'date' in item && item.date) {
return JSON.stringify({
'@context': 'https://schema.org', '@type': 'TechArticle',
headline: item.title, description: description || '',
datePublished: item.date,
author: { '@type': 'Person', name: 'Stéphane Erard' },
});
}
// ...
}// scripts/lib/page-renderer.ts — buildJsonLd()
export function buildJsonLd(item, description, isHomePage, baseUrl): string | null {
if (item && 'date' in item && item.date) {
return JSON.stringify({
'@context': 'https://schema.org', '@type': 'TechArticle',
headline: item.title, description: description || '',
datePublished: item.date,
author: { '@type': 'Person', name: 'Stéphane Erard' },
});
}
// ...
}Le JSON-LD est un bloc <script type="application/ld+json"> injecté dans le <head> du HTML. Il n'a aucune source dans le markdown — il est entièrement synthétisé par le compilateur. C'est l'élévation à son maximum : le HTML contient des données structurées que les moteurs de recherche consomment et que l'auteur n'a jamais écrites.
collectMermaidBlocks() — placeholders pour le sous-compilateur
// scripts/lib/page-renderer.ts — collectMermaidBlocks()
export function collectMermaidBlocks(html: string, pageSlug: string): MermaidCollectResult {
const blocks: MermaidBlock[] = [];
let idx = 0;
const result: string = html.replace(
/<div class="mermaid" data-source="([^"]*)"[\s\S]*?<\/div>/g,
(_: string, encodedSource: string) => {
const id: string = `${pageSlug}--${idx}`;
blocks.push({ id, encodedSource, directives: [] });
idx++;
return `%%MERMAID_${id}%%`;
});
return { html: result, blocks };
}// scripts/lib/page-renderer.ts — collectMermaidBlocks()
export function collectMermaidBlocks(html: string, pageSlug: string): MermaidCollectResult {
const blocks: MermaidBlock[] = [];
let idx = 0;
const result: string = html.replace(
/<div class="mermaid" data-source="([^"]*)"[\s\S]*?<\/div>/g,
(_: string, encodedSource: string) => {
const id: string = `${pageSlug}--${idx}`;
blocks.push({ id, encodedSource, directives: [] });
idx++;
return `%%MERMAID_${id}%%`;
});
return { html: result, blocks };
}Les blocs mermaid du markdown ont déjà été convertis en <div class="mermaid"> par le parser markdown. Mais ce n'est pas leur forme finale — collectMermaidBlocks() les remplace par des placeholders (%%MERMAID_page--0%%) en attendant le rendu SVG de l'étape 4. Le mermaid est dans un état intermédiaire : ni M1 (plus du texte brut), ni M0 (pas encore du SVG) — un état transitoire que seul le pipeline connaît.
Étape 4 — Le rendu Mermaid : non-identité au carré
Entrée : blocs mermaid encodés (texte DSL).
Sortie : fichiers SVG dark + light, cachés par hash dans public/mermaid-svg/.
Fichier source : scripts/lib/mermaid-renderer.ts + scripts/lib/mermaid-manifest.ts
Le rendu Mermaid est une transformation M1→M0 imbriquée dans la transformation M1→M0 principale. Le texte mermaid est un modèle M1 (au sens du DSL mermaid), et le SVG rendu est son instance M0. C'est la non-identité au carré — une transformation dans la transformation.
La machine à états
Chaque bloc mermaid traverse une machine à états explicite :
// scripts/lib/mermaid-renderer.ts
export enum BlockState {
Pending = 'pending',
Rendering = 'rendering', // dark + light in parallel tabs
Writing = 'writing',
Done = 'done',
Failed = 'failed',
}
const TRANSITIONS: Readonly<Record<BlockState, readonly BlockState[]>> = {
[BlockState.Pending]: [BlockState.Rendering],
[BlockState.Rendering]: [BlockState.Writing, BlockState.Failed],
[BlockState.Writing]: [BlockState.Done, BlockState.Failed],
[BlockState.Done]: [],
[BlockState.Failed]: [],
};
export function transition(from: BlockState, to: BlockState): BlockState {
if (!TRANSITIONS[from].includes(to)) {
throw new Error(`Invalid state transition: ${from} → ${to}`);
}
return to;
}// scripts/lib/mermaid-renderer.ts
export enum BlockState {
Pending = 'pending',
Rendering = 'rendering', // dark + light in parallel tabs
Writing = 'writing',
Done = 'done',
Failed = 'failed',
}
const TRANSITIONS: Readonly<Record<BlockState, readonly BlockState[]>> = {
[BlockState.Pending]: [BlockState.Rendering],
[BlockState.Rendering]: [BlockState.Writing, BlockState.Failed],
[BlockState.Writing]: [BlockState.Done, BlockState.Failed],
[BlockState.Done]: [],
[BlockState.Failed]: [],
};
export function transition(from: BlockState, to: BlockState): BlockState {
if (!TRANSITIONS[from].includes(to)) {
throw new Error(`Invalid state transition: ${from} → ${to}`);
}
return to;
}Pending → Rendering → Writing → Done (ou Failed à chaque étape). La machine est explicite, les transitions sont vérifiées, les états invalides sont impossibles. Chaque bloc passe par ces quatre états en traversant un navigateur Puppeteer headless qui rend le diagramme en SVG dans deux thèmes (dark et light) en parallèle.
Le caching content-addressed
// scripts/lib/mermaid-manifest.ts
export function hashMermaidSource(rawSource: string): string {
return createHash('sha256').update(rawSource.trim()).digest('hex').slice(0, 12);
}// scripts/lib/mermaid-manifest.ts
export function hashMermaidSource(rawSource: string): string {
return createHash('sha256').update(rawSource.trim()).digest('hex').slice(0, 12);
}Chaque SVG est nommé par le hash SHA-256 (tronqué à 12 caractères) de son source mermaid. Si le texte source n'a pas changé, le hash est le même, et le SVG n'est pas re-rendu. C'est du caching content-addressed — la même stratégie que git utilise pour ses objets. Le manifest (mermaid-manifest.json) associe chaque block.id à son hash, permettant la détection incrémentale des blocs modifiés.
Étape 5 — Le pipeline d'assets : minification
Entrée : CSS source (css/*.css), JavaScript source (js/*.js), images, polices.
Sortie : public/css/app.min.css, bundles JS minifiés, images copiées, polices copiées.
Fichier source : scripts/build-static.ts
La minification CSS/JS est la même structure de non-identité sur un registre différent. Le CSS source est lisible (pour le développeur), le CSS minifié est optimisé (pour le navigateur). Le JavaScript source a des noms de variables descriptifs, le bundle minifié a des noms d'une lettre. Les deux portent le même programme, mais dans des formes adaptées à des rôles différents — exactement comme markdown et HTML.
Les quatre tâches d'assets (CSS, JS, images, polices) s'exécutent en parallèle — elles sont indépendantes les unes des autres.
Étape 6 — Le nettoyage des orphelins : la cohérence finale
Entrée : le répertoire public/content/.
Sortie : suppression des .html dont le .md source n'existe plus.
Fichier source : scripts/build-static.ts
// scripts/build-static.ts — pruneOrphanHtml()
function pruneOrphanHtml(io: IO, root: string, outputContentDir: string) {
const htmls: string[] = listFilesRecursive(io, outputContentDir,
(n: string) => n.endsWith('.html'));
for (const rel of htmls) {
const filename: string = path.posix.basename(rel);
if (PRUNE_EXCLUDE_FILENAMES.has(filename)) continue;
// Pour chaque .html, vérifier que le .md source existe encore
const mdRel: string = `content/${rel.replace(/\.html$/, '.md')}`;
if (!io.exists(path.join(root, mdRel))) {
io.unlink(path.join(outputContentDir, rel));
removedFiles.push(rel);
}
}
// Bottom-up empty directory cleanup
// ...
}// scripts/build-static.ts — pruneOrphanHtml()
function pruneOrphanHtml(io: IO, root: string, outputContentDir: string) {
const htmls: string[] = listFilesRecursive(io, outputContentDir,
(n: string) => n.endsWith('.html'));
for (const rel of htmls) {
const filename: string = path.posix.basename(rel);
if (PRUNE_EXCLUDE_FILENAMES.has(filename)) continue;
// Pour chaque .html, vérifier que le .md source existe encore
const mdRel: string = `content/${rel.replace(/\.html$/, '.md')}`;
if (!io.exists(path.join(root, mdRel))) {
io.unlink(path.join(outputContentDir, rel));
removedFiles.push(rel);
}
}
// Bottom-up empty directory cleanup
// ...
}Cette étape maintient un invariant : chaque M0 (fichier HTML) a un M1 (fichier markdown) correspondant. Si le markdown source est supprimé, le HTML généré est supprimé aussi. C'est la gestion de la relation inverse de la transformation : le pipeline ne se contente pas de créer des M0, il maintient la cohérence entre les deux niveaux en nettoyant les orphelins.
Le nettoyage bottom-up des répertoires vides est un détail élégant : après avoir supprimé les fichiers HTML orphelins, le pipeline remonte l'arbre des répertoires du plus profond au plus superficiel et supprime chaque répertoire vide. L'arborescence M0 reste un reflet fidèle de l'arborescence M1.
Tableau synoptique
| Étape | Fichier source | Entrée (M1) | Sortie (→ M0) | Non-identité |
|---|---|---|---|---|
| 1. TOC | build-toc.ts |
Frontmatter YAML de tous les .md |
toc.json structuré |
Information créée : arbre, catégories, slugs hiérarchiques |
| 2. Liens | validate-md-links.ts |
Tous les .md |
Exit code 0/1 | Opère uniquement au niveau M1 |
| 3. Rendu | page-renderer.ts |
.md + toc.json + template |
.html complet |
Aufhebung : frontmatter → meta tags, .md → .html, + JSON-LD |
| 4. Mermaid | mermaid-renderer.ts |
Blocs mermaid textuels | SVGs dark/light cachés | Non-identité au carré : M1→M0 dans M1→M0 |
| 5. Assets | build-static.ts |
CSS/JS source | Bundles minifiés | Lisible → optimisé (même registre, autre format) |
| 6. Orphelins | build-static.ts |
public/content/ |
Fichiers HTML supprimés | Maintient l'invariant M0 ↔ M1 |
La partie 3 ferme la boucle : le site qui décrit ce pipeline est produit par ce pipeline.