Tuto — Rendre du Markdown accessible en Symfony (pipeline CDX-MD)
Public visé : développeur back qui sert du Markdown depuis Symfony (ex. le viewer
/docs, cf. specs/docs-web.md).
Objectif : transformer du Markdown en HTML accessible (RGAA 4.1 AA / WCAG 2.1 AA), en
respectant le profil CDX-MD (markdown.md).
Référence transverse : Accessibilité — interface utilisateur.
Pourquoi c'est un cas piégeux
Un convertisseur Markdown « par défaut » produit un HTML valide mais pas accessible :
tableaux sans scope, images sans alt contrôlé, blocs de code sans langue, HTML brut non
filtré. L'accessibilité ne s'ajoute pas après coup dans le template : elle se joue dans le
pipeline de rendu, en cinq étapes (profil CDX-MD, markdown.md) :
1. Pré-traitement des directives Codexia (cdx:include…) 2. Parsing Markdown (CommonMark + GFM) 3. Génération HTML 4. Post-traitement accessibilité (scope, thead, alt, language-…) 5. Sanitization finale (whitelist HTML)
Étape 0 — Installer le moteur
On s'appuie sur league/commonmark (implémentation CommonMark + GFM de référence en PHP) :
composer require league/commonmark
Étape 1 — Configurer parseur et extensions (étapes 2-3)
CommonMark de base + extensions GFM + extraction du cartouche front-matter (strippé du
rendu, comme le viewer /docs). Le HTML brut est échappé par défaut (on durcit en
étape 5).
// src/Markdown/MarkdownRenderer.php use League\CommonMark\Environment\Environment; use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; use League\CommonMark\Extension\GithubFlavoredMarkdownExtension; use League\CommonMark\Extension\FrontMatter\FrontMatterExtension; use League\CommonMark\MarkdownConverter; $config = [ 'html_input' => 'escape', // HTML brut échappé (sécurité par défaut) 'allow_unsafe_links' => false, // pas de javascript:, data:, etc. ]; $environment = new Environment($config); $environment->addExtension(new CommonMarkCoreExtension()); $environment->addExtension(new GithubFlavoredMarkdownExtension()); // tables, task lists, autolinks… $environment->addExtension(new FrontMatterExtension()); // isole le cartouche YAML $converter = new MarkdownConverter($environment); $html = $converter->convert($markdown)->getContent();
Le
FrontMatterExtensionretire le bloc---…---du rendu (il reste exploitable viagetFrontMatter()), ce qui évite que le cartouche pollue la page et l'index RAG (cf.pilotage/rag-optimisation.md§ 2.3).
Étape 2 — Pré-traiter les directives internes (étape 1)
Le profil CDX-MD définit des directives en commentaires HTML (invisibles dans un éditeur
standard), ex. <!-- cdx:include("guides/intro.md") -->. Elles se résolvent avant le
parsing, avec garde anti-traversée et anti-boucle (markdown.md) :
function preprocessDirectives(string $md, string $docRoot, int $depth = 0): string { if ($depth > 5) { return $md; // détection de boucle (max 5 niveaux) } return preg_replace_callback( '/<!--\s*cdx:include\((["\'])(.+?)\1\)\s*-->/', function (array $m) use ($docRoot, $depth) { $rel = $m[2]; // refuser chemins absolus et traversées if (str_contains($rel, '..') || str_starts_with($rel, '/')) { return '<!-- include refusé : chemin invalide -->'; } $path = realpath($docRoot . '/' . $rel); if ($path === false || !str_starts_with($path, realpath($docRoot))) { return '<!-- include introuvable -->'; // avertissement non bloquant } return preprocessDirectives(file_get_contents($path), $docRoot, $depth + 1); }, $md ); }
Étape 3 — Post-traiter l'accessibilité (étape 4)
league/commonmark génère déjà <thead>/<th> pour les tables GFM, mais sans
scope, et les blocs de code clôturés portent class="language-xxx". On comble le reste
sur le HTML généré, via DOMDocument :
function postprocessAccessibility(string $html): string { if (trim($html) === '') { return $html; } $doc = new DOMDocument(); // UTF-8 + fragment (pas de <html><body> ajoutés au rendu) libxml_use_internal_errors(true); $doc->loadHTML('<?xml encoding="UTF-8"?><div id="cdx-root">' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); libxml_clear_errors(); // Tables : scope sur les en-têtes de colonne foreach ($doc->getElementsByTagName('th') as $th) { if (!$th->hasAttribute('scope')) { $th->setAttribute('scope', 'col'); } } // Task lists : checkbox désactivée + libellé pour le lecteur d'écran foreach (iterator_to_array($doc->getElementsByTagName('input')) as $input) { if ($input->getAttribute('type') === 'checkbox') { $input->setAttribute('disabled', 'disabled'); $input->setAttribute('aria-label', $input->hasAttribute('checked') ? 'Fait' : 'À faire'); } } $root = $doc->getElementById('cdx-root'); $out = ''; foreach ($root->childNodes as $child) { $out .= $doc->saveHTML($child); } return $out; }
À contrôler à cette étape (profil CDX-MD, markdown.md) :
- Titres : hiérarchie sans saut de niveau (un seul
<h1>, pas de<h3>après<h1>). À valider à l'écriture du doc ; un linter peut le refuser au commit. - Images :
altobligatoire (alt=""seulement si décoratif). Le Markdown porte déjà l'alt() — la responsabilité est rédactionnelle, à rappeler aux auteurs. - Liens : libellé explicite (pas de « cliquez ici »). Idem, rédactionnel.
- Code :
<pre><code class="language-…">— préservé par GFM ; brancher ici une coloration côté serveur (ex. un highlighter PHP) si voulue, sans perte de contenu.
Étape 4 — Sanitizer (whitelist) (étape 5)
Le corpus est de confiance (notre propre doc), mais la défense en profondeur impose une
whitelist de sortie (anti-injection, cf. specs/docs-web.md).
Deux niveaux :
- Suffisant pour du contenu de confiance :
html_input => 'escape'(Ă©tape 1) neutralise dĂ©jĂ le HTML brut hostile. - Strict (recommandĂ© pour servir au public) : passer le HTML final dans un sanitizer Ă
whitelist, ex. HTML Purifier, en n'autorisant que les balises/attributs sémantiques
(titres, listes, tables avec
scope,a[href],img[src|alt],pre,code[class]…) :
composer require ezyang/htmlpurifier
$cfg = HTMLPurifier_Config::createDefault(); $cfg->set('HTML.Allowed', 'h1,h2,h3,h4,p,ul,ol,li,strong,em,a[href|title],img[src|alt],' . 'table,thead,tbody,tr,th[scope],td,pre,code[class],blockquote,hr'); $cfg->set('Attr.AllowedFrameTargets', []); // pas de target=_blank non maîtrisé $clean = (new HTMLPurifier($cfg))->purify($html);
Assemblage
$md = preprocessDirectives($rawMarkdown, $docRoot); // 1 $html = $converter->convert($md)->getContent(); // 2-3 $html = postprocessAccessibility($html); // 4 $html = $purifier->purify($html); // 5 // -> injecter $html dans le template (déjà accessible)
Tester l'accessibilité
- Automatique :
axe-core/ Pa11y sur quelques pages/docsrendues ; un test fonctionnel Symfony qui asserte la présence deth[scope],lang, un seulh1. - Manuel : navigation clavier seul (Tab/Shift+Tab), lecteur d'écran (NVDA/VoiceOver), zoom 200 %. Voir la checklist Accessibilité — interface utilisateur.
- Sémantique tables : vérifier qu'un lecteur d'écran annonce bien les en-têtes de
colonne (effet du
scope).
Pour aller plus loin
- Profil Markdown Codexia :
markdown.md - Spécification du viewer
/docs:specs/docs-web.md - Navigation treeview accessible (tuto compagnon) :
treeview-accessible.md - Optimisation RAG (cartouche strippé, métadonnées) :
pilotage/rag-optimisation.md league/commonmark: https://commonmark.thephpleague.com/