03-comment-je-travaille/tutos/markdown-accessible-symfony.md

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 FrontMatterExtension retire le bloc ---…--- du rendu (il reste exploitable via getFrontMatter()), 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 : alt obligatoire (alt="" seulement si dĂ©coratif). Le Markdown porte dĂ©jĂ  l'alt (![texte](img)) — 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 /docs rendues ; un test fonctionnel Symfony qui asserte la prĂ©sence de th[scope], lang, un seul h1.
  • 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

Assistant documentaire

Posez une question sur la documentation. Les réponses citent leurs sources — un clic ouvre le document à gauche.

Loading…
Loading the web debug toolbar…
Attempt #