Si vous avez déjà cassé un bloc Gutenberg en essayant d’ajouter un simple rel="noopener" avec une regex, vous avez déjà rencontré le vrai problème : le HTML réel n’est pas une chaîne “facile”, c’est un langage avec des règles, des attributs, des guillemets, des entités et des cas limites.
Depuis WordPress 6.2, le core embarque WP_HTML_Tag_Processor : un parseur “orienté tags” conçu pour faire des modifications ciblées (attributs, classes, style, suppression/ajout) sans jouer à l’apprenti sorcier avec preg_replace(). En WordPress 6.9.4 (avril 2026), c’est devenu un outil très fiable pour durcir et normaliser du HTML produit par des éditeurs, des shortcodes, des builders ou des contenus importés.
Le problème / Le besoin
Le besoin typique : vous recevez du HTML (contenu d’article, champ ACF, sortie d’un shortcode, widget, module de builder) et vous devez le modifier de façon sûre :
- Ajouter
rel="nofollow noopener"ettarget="_blank"sur certains liens. - Injecter une classe sur des
imgou desfigureselon des règles métier. - Supprimer des attributs dangereux ou invalides, sans détruire la mise en page.
- Normaliser des ancres, des
aria-*, ou des data-attributes pour un script.
À la fin, vous saurez faire deux choses, proprement :
- Écrire un filtre WordPress qui parcourt le HTML et modifie uniquement ce qui doit l’être, sans regex.
- Encapsuler cette logique dans une mini-architecture “plugin-like” (services, hooks, tests manuels reproductibles) compatible WordPress 6.9.4 et PHP 8.1+.
Résumé rapide
- On évite les regex pour parser du HTML (elles cassent sur les cas réels).
- On utilise WP_HTML_Tag_Processor pour itérer sur des tags et manipuler leurs attributs.
- On branche le code sur
the_content(et variantes) avec des garde-fous (admin, REST, feeds, shortcodes). - On applique des règles concrètes : liens externes, liens “download”, ancres internes, images.
- On ajoute une variante “avancée” : configuration via filtre + container de services minimal.
- On teste avec des contenus Gutenberg, Elementor, Divi 5, Avada, et on documente les pièges.
Quand utiliser cette solution
- Durcissement SEO/sécurité : ajouter
rel="noopener"sur tous lestarget="_blank", ou forcerugc/sponsoredselon une règle. - Migration / nettoyage : contenu importé depuis un autre CMS avec des attributs incohérents.
- Interop builders : Divi/Elementor/Avada génèrent du HTML stable mais dense ; vous voulez cibler des patterns simples (liens, images, wrappers).
- Accessibilité : ajouter des
aria-label(avec prudence) ou corriger des attributs invalides. - Instrumentation : ajouter des
data-*pour analytics “first-party” sans toucher aux templates.
Quand ne PAS utiliser cette solution
- Vous devez restructurer en profondeur le document (déplacer des nœuds, re-nester des éléments). WP_HTML_Tag_Processor est excellent pour des modifications ciblées, pas pour un DOM complet.
- Vous manipulez du HTML massif (pages très longues, milliers de tags) sur chaque requête. Dans ce cas, préférez un traitement à l’enregistrement (hook
save_post) ou un job WP-CLI. - Vous avez déjà une source structurée (blocs) : si vous pouvez intervenir au niveau block rendering (ex.
render_blockou filtres de blocs), c’est souvent plus propre que de repasser sur le HTML final. - Vous pensez “sécurité XSS” : WP_HTML_Tag_Processor n’est pas un sanitizer. Pour filtrer/autoriser du HTML, utilisez KSES (
wp_kses(),wp_kses_post()), et traitez la sortie au bon endroit.
Prérequis / avant de commencer
- WordPress 6.9.4 (ou supérieur) et PHP 8.1+.
- Un environnement de staging (ou local) avec une copie de votre base. J’ai souvent vu ce type de snippet casser la mise en page parce qu’il touchait aussi des fragments HTML d’un builder.
- Un moyen de déployer proprement : plugin maison, MU-plugin, ou thème enfant. Évitez de coller ça “au hasard” dans
functions.phpsur production. - Si vous utilisez un plugin de snippets, vérifiez qu’il ne désactive pas le code sur fatal error (sinon vous perdez le contrôle du déploiement).
Sources officielles utiles (gardez-les ouvertes) :
- WP_HTML_Tag_Processor (référence)
- Filtre the_content
- wp_parse_url()
- wp_kses_post()
- parse_url() (PHP)
L’approche naïve (et pourquoi l’éviter)
Le code que je vois encore dans des thèmes premium (et parfois dans des plugins) ressemble à ça : une regex qui “trouve des <a>” et injecte des attributs.
<?php
// Exemple à NE PAS copier : regex fragile sur HTML.
add_filter('the_content', function ($html) {
// Ajoute target="_blank" sur tous les liens externes (supposé).
$html = preg_replace(
'~<as+([^>]*href=["']https?://[^"']+["'][^>]*)~i',
'<a $1 target="_blank" rel="noopener">',
$html
);
return $html;
}, 20);
Ce qui se passe en coulisses :
- La regex ne gère pas correctement les attributs déjà présents (
targetdupliqué,relécrasé, attributs sans guillemets, espaces bizarres). - Elle peut matcher des faux positifs (ex. HTML dans un script, ou attributs encodés).
- Elle casse sur des cas valides (attributs multi-lignes, ordre des attributs, entités).
- Vous finissez avec des pages où certains liens deviennent invalides, et vous ne le voyez qu’après purge de cache/CDN.
La bonne approche — tutoriel pas à pas
Objectif concret
On va écrire un mini-plugin qui modifie le HTML de contenu pour :
- Ajouter target= »_blank » + rel= »noopener noreferrer » sur les liens externes (sauf exceptions).
- Ajouter rel= »nofollow ugc » sur les liens externes dans les commentaires (optionnel).
- Forcer decoding= »async » et une classe sur les
imgqui n’en ont pas (utile pour CSS). - Ne jamais toucher aux liens internes, aux ancres
#, ni aux liens mailto/tel.
Étape 1 — Créer un MU-plugin (recommandé)
Créez le fichier : wp-content/mu-plugins/bpcab-html-tag-processor.php. Les MU-plugins sont chargés tôt et évitent les “désactivations accidentelles” par un admin.
Étape 2 — Mettre en place une petite architecture (services + hooks)
Sur des sites pro, je préfère éviter les fonctions globales qui s’empilent. On va faire simple : une classe “Plugin”, une classe “Transformer”, et un bootstrap.
Étape 3 — Transformer le HTML avec WP_HTML_Tag_Processor
Le principe : on instancie le processeur avec une chaîne HTML, puis on itère tag par tag avec next_tag(). Quand le tag courant nous intéresse, on lit/modifie ses attributs.
Étape 4 — Brancher sur les bons hooks
the_contentpour le contenu principal.widget_text_content(si vous avez encore des widgets texte avec HTML).- Optionnel :
comment_textpour les commentaires (attention au rendu et à KSES).
Étape 5 — Ajouter des garde-fous
On évite d’exécuter le transformateur dans l’admin, sur les feeds, sur les requêtes REST, et sur les previews si ça vous pose des surprises.
Code complet
<?php
/**
* Plugin Name: BPCAB - HTML Tag Processor (sans regex)
* Description: Manipule certains tags HTML (liens, images) via WP_HTML_Tag_Processor, compatible WP 6.9.4+ / PHP 8.1+.
* Author: Votre équipe
* Version: 1.0.0
*
* À placer dans: wp-content/mu-plugins/bpcab-html-tag-processor.php
*/
declare(strict_types=1);
if (!defined('ABSPATH')) {
exit;
}
/**
* Mini container très simple (pas de dépendance externe).
* Sur un gros projet, vous remplacerez ça par votre container maison.
*/
final class BPCAB_Container {
/** @var array<string, callable> */
private array $factories = [];
/** @var array<string, mixed> */
private array $instances = [];
public function set(string $id, callable $factory): void {
$this->factories[$id] = $factory;
}
public function get(string $id) {
if (array_key_exists($id, $this->instances)) {
return $this->instances[$id];
}
if (!isset($this->factories[$id])) {
throw new RuntimeException("Service introuvable: {$id}");
}
$this->instances[$id] = ($this->factories[$id])($this);
return $this->instances[$id];
}
}
/**
* Responsable des transformations HTML.
*/
final class BPCAB_Html_Transformer {
/**
* Transforme un fragment HTML.
*
* @param string $html HTML source (non vide idéalement).
* @param array $config Configuration (host, exceptions, etc.).
* @return string HTML transformé.
*/
public function transform(string $html, array $config): string {
if ($html === '') {
return $html;
}
// WP_HTML_Tag_Processor existe dans WP 6.2+ ; on cible WP 6.9.4 donc OK.
if (!class_exists('WP_HTML_Tag_Processor')) {
// Fallback ultra conservateur : on ne modifie rien.
return $html;
}
$processor = new WP_HTML_Tag_Processor($html);
$site_host = (string) ($config['site_host'] ?? '');
$link_exceptions = (array) ($config['link_exceptions'] ?? []);
$add_img_class = (string) ($config['img_class'] ?? 'has-bpcab-img');
$force_img_decoding = (bool) ($config['force_img_decoding_async'] ?? true);
// Itération sur tous les tags.
while ($processor->next_tag()) {
$tag = $processor->get_tag();
if ($tag === 'A') {
$this->process_anchor($processor, $site_host, $link_exceptions);
continue;
}
if ($tag === 'IMG') {
$this->process_img($processor, $add_img_class, $force_img_decoding);
continue;
}
}
return $processor->get_updated_html();
}
/**
* Règles sur les liens.
*/
private function process_anchor(WP_HTML_Tag_Processor $p, string $site_host, array $exceptions): void {
$href = $p->get_attribute('href');
if (!is_string($href) || $href === '') {
return;
}
// Ne pas toucher aux ancres, mailto, tel, javascript: (même si c'est déjà une mauvaise idée).
if ($href[0] === '#') {
return;
}
$scheme = strtolower((string) wp_parse_url($href, PHP_URL_SCHEME));
if (in_array($scheme, ['mailto', 'tel', 'javascript'], true)) {
return;
}
// Liens relatifs = internes, on ne touche pas.
$host = (string) wp_parse_url($href, PHP_URL_HOST);
if ($host === '') {
return;
}
// Exceptions métier (CDN, sous-domaines, partenaires).
foreach ($exceptions as $allowed_host) {
$allowed_host = (string) $allowed_host;
if ($allowed_host !== '' && strcasecmp($host, $allowed_host) === 0) {
return;
}
}
// Si on n'a pas de host de site, on ne prend pas de risque.
if ($site_host === '') {
return;
}
$is_external = (strcasecmp($host, $site_host) !== 0);
if (!$is_external) {
return;
}
// Ajoute target="_blank" si absent.
$target = $p->get_attribute('target');
if (!is_string($target) || $target === '') {
$p->set_attribute('target', '_blank');
}
// Rel: fusionner sans dupliquer.
$rel = $p->get_attribute('rel');
$rel_tokens = [];
if (is_string($rel) && $rel !== '') {
// Tokenisation simple par espaces.
$rel_tokens = preg_split('/s+/', trim($rel)) ?: [];
}
$must_have = ['noopener', 'noreferrer'];
foreach ($must_have as $token) {
if (!in_array($token, $rel_tokens, true)) {
$rel_tokens[] = $token;
}
}
$p->set_attribute('rel', implode(' ', $rel_tokens));
}
/**
* Règles sur les images.
*/
private function process_img(WP_HTML_Tag_Processor $p, string $class_to_add, bool $force_decoding_async): void {
// Ajout de class sans écraser l'existant.
$existing = $p->get_attribute('class');
$classes = [];
if (is_string($existing) && $existing !== '') {
$classes = preg_split('/s+/', trim($existing)) ?: [];
}
if ($class_to_add !== '' && !in_array($class_to_add, $classes, true)) {
$classes[] = $class_to_add;
$p->set_attribute('class', implode(' ', $classes));
}
// decoding="async" si absent.
if ($force_decoding_async) {
$decoding = $p->get_attribute('decoding');
if (!is_string($decoding) || $decoding === '') {
$p->set_attribute('decoding', 'async');
}
}
}
}
/**
* Plugin bootstrap: enregistre les hooks.
*/
final class BPCAB_Html_Tag_Processor_Plugin {
public function __construct(
private BPCAB_Html_Transformer $transformer
) {}
public function register(): void {
add_filter('the_content', [$this, 'filter_content'], 20);
add_filter('widget_text_content', [$this, 'filter_content'], 20);
// Optionnel: commentaires. À activer seulement si vous maîtrisez votre pipeline KSES.
// add_filter('comment_text', [$this, 'filter_comment'], 20);
}
/**
* Filtre principal.
*/
public function filter_content(string $content): string {
if ($this->should_skip()) {
return $content;
}
$config = $this->get_config();
return $this->transformer->transform($content, $config);
}
/**
* Exemple séparé pour les commentaires (souvent plus restrictifs).
*/
public function filter_comment(string $comment_html): string {
if ($this->should_skip()) {
return $comment_html;
}
$config = $this->get_config();
// Sur commentaires, vous pouvez décider d'ajouter "ugc nofollow" plutôt que noopener/noreferrer.
// Ici, on réutilise la même config pour rester simple.
return $this->transformer->transform($comment_html, $config);
}
private function should_skip(): bool {
// Évite l'admin, les feeds, et la plupart des contextes non-front.
if (is_admin() || is_feed()) {
return true;
}
// REST API: si vous rendez du contenu via REST, vous ne voulez pas forcément modifier ici.
if (defined('REST_REQUEST') && REST_REQUEST) {
return true;
}
// Cron: inutile.
if (wp_doing_cron()) {
return true;
}
return false;
}
private function get_config(): array {
$home = home_url('/');
$host = (string) wp_parse_url($home, PHP_URL_HOST);
$config = [
'site_host' => $host,
'link_exceptions' => [
// Exemples: votre CDN, un sous-domaine considéré “interne”.
// 'cdn.example.com',
// 'static.example.com',
],
'img_class' => 'has-bpcab-img',
'force_img_decoding_async' => true,
];
/**
* Permet d'ajuster la config sans modifier le fichier MU-plugin.
* Usage: add_filter('bpcab_html_transformer_config', fn($c) => ...);
*/
return apply_filters('bpcab_html_transformer_config', $config);
}
}
// Bootstrap.
add_action('plugins_loaded', static function (): void {
$container = new BPCAB_Container();
$container->set('transformer', static fn() => new BPCAB_Html_Transformer());
$container->set('plugin', static fn(BPCAB_Container $c) => new BPCAB_Html_Tag_Processor_Plugin($c->get('transformer')));
/** @var BPCAB_Html_Tag_Processor_Plugin $plugin */
$plugin = $container->get('plugin');
$plugin->register();
}, 0);
Explication du code
Lecture “simple” (ce que ça fait)
À chaque affichage de contenu, on passe la chaîne HTML dans WP_HTML_Tag_Processor. Le processeur se déplace de tag en tag. Quand il rencontre un <a>, on regarde href :
- Si c’est interne (relatif, ou même host que le site), on ne touche pas.
- Si c’est externe, on ajoute
target="_blank"(si absent) et on s’assure querelcontientnoopener noreferrer.
Quand il rencontre un <img>, on ajoute une classe et decoding="async" si absent.
Lecture technique (hooks, API, edge cases)
- Hook :
the_contentest un filtre, pas une action. On retourne la chaîne modifiée, sinon WordPress affichera l’original. Référence : the_content. - Garde-fous :
is_admin()évite d’impacter l’éditeur.REST_REQUESTévite de modifier des réponses JSON (j’ai déjà vu des API headless casser parce que le HTML était “normalisé” à un endroit inattendu). - Détection externe/interne : on s’appuie sur
wp_parse_url()(wrapper WP) plutôt queparse_url()direct, pour rester cohérent avec WordPress. Référence : wp_parse_url(). - Exceptions : la liste
link_exceptionsgère les cas réels (CDN, sous-domaines). Sans ça, vous allez “externaliser” vos assets internes. - WP_HTML_Tag_Processor : on utilise
next_tag(),get_tag(),get_attribute(),set_attribute(),get_updated_html(). Référence : WP_HTML_Tag_Processor. - Pourquoi pas DOMDocument : DOMDocument est puissant mais souvent trop strict (encodage, HTML5, wrappers implicites). Le processeur de WP est plus pragmatique pour des fragments issus d’éditeurs.
À propos des tickets core
Le composant HTML API a été développé sur plusieurs cycles. Si vous voulez suivre l’historique et les débats (notamment sur la portée “tag processor” vs DOM complet), surveillez :
Variantes et cas d’usage
Variante 1 — Ajouter nofollow/ugc uniquement sur certains domaines
Cas réel : vous avez des liens partenaires “autorisés” et le reste doit être nofollow ugc. Plutôt que de tout coder en dur, exposez un filtre de config.
<?php
// À placer dans un plugin classique ou functions.php (thème enfant), pas dans le MU-plugin.
add_filter('bpcab_html_transformer_config', function (array $config): array {
$config['link_exceptions'] = [
'trusted-partner.example',
'cdn.example.com',
];
return $config;
});
Pour appliquer nofollow ugc, vous pouvez modifier $must_have dans process_anchor() selon le host. Gardez la fusion de tokens, sinon vous écrasez des valeurs existantes (ex. sponsored).
Variante 2 — Ne toucher qu’à une zone (ex. un wrapper spécifique)
Sur des pages builders, modifier “tout le contenu” peut avoir des effets secondaires. Une stratégie que j’utilise : limiter le traitement à des fragments identifiables (ex. un wrapper <div class="entry-content"> déjà isolé par WordPress, ou un shortcode).
Approche simple : appliquez le transformateur dans un shortcode qui encadre le HTML que vous contrôlez.
<?php
add_shortcode('bpcab_transform', function ($atts, $content = '') {
if (!is_string($content) || $content === '') {
return '';
}
// Attention: do_shortcode() sur du contenu utilisateur peut avoir des effets.
$content = do_shortcode($content);
$transformer = new BPCAB_Html_Transformer();
$config = [
'site_host' => (string) wp_parse_url(home_url('/'), PHP_URL_HOST),
'link_exceptions' => [],
'img_class' => 'has-bpcab-img',
'force_img_decoding_async' => true,
];
return $transformer->transform($content, $config);
});
Variante 3 — Traitement à l’enregistrement (éviter le coût runtime)
Si vous avez des pages très lourdes, faites le traitement une fois au save_post (avec toutes les précautions : autosave, révisions, permissions). Je le fais souvent sur des sites multilingues où le rendu est déjà coûteux.
Attention : vous modifiez le contenu stocké en base, ce qui peut surprendre les éditeurs.
<?php
add_action('save_post', function (int $post_id, WP_Post $post, bool $update): void {
// Sécurité et garde-fous.
if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
return;
}
if (!current_user_can('edit_post', $post_id)) {
return;
}
if ($post->post_type !== 'post') {
return;
}
// Éviter la boucle infinie: on retire temporairement le hook.
remove_action('save_post', __FUNCTION__, 10);
$transformer = new BPCAB_Html_Transformer();
$config = [
'site_host' => (string) wp_parse_url(home_url('/'), PHP_URL_HOST),
'link_exceptions' => [],
'img_class' => 'has-bpcab-img',
'force_img_decoding_async' => true,
];
$updated = $transformer->transform((string) $post->post_content, $config);
// Ne mettez à jour que si ça change réellement.
if ($updated !== $post->post_content) {
wp_update_post([
'ID' => $post_id,
'post_content' => $updated,
]);
}
// On remet le hook.
add_action('save_post', __FUNCTION__, 10, 3);
}, 10, 3);
Compatibilité Divi 5 / Elementor / Avada
Divi 5
Divi 5 sort souvent du HTML très imbriqué avec des liens dans des modules (boutons, blurb, CTA). Le filtrage the_content fonctionne en général, mais j’ai vu des surprises quand Divi injecte des liens via JS (dans ce cas, votre filtre serveur ne les verra pas).
- Si vos liens sont générés côté serveur (modules classiques), votre filtre suffit.
- Si certains liens apparaissent après interaction (JS), vous devrez appliquer une stratégie front (JS) en complément.
Elementor
Elementor stocke le layout en meta et rend un HTML final dans le contenu. En pratique, the_content est souvent le bon point d’entrée. Deux points d’attention :
- Certains widgets ajoutent déjà
rel. Votre fusion de tokens évite de casser leur logique. - Si vous utilisez un cache HTML (plugin de cache + minification), purge obligatoire après changement de règle.
Avada (Fusion Builder)
Avada utilise beaucoup de shortcodes. Selon la configuration, une partie du HTML est produite par do_shortcode dans the_content. Bonne nouvelle : votre filtre s’applique après (priorité 20), donc vous modifiez le HTML final.
- Si vous voyez que vos modifications “n’arrivent pas”, augmentez la priorité (ex. 50) pour passer après d’autres filtres.
- Sur certains sites, Avada ajoute des liens dans des attributs JSON encodés : ne tentez pas de manipuler ça avec le Tag Processor, ce n’est pas du HTML.
Vérifications après mise en place
- Créez une page de test avec :
- Un lien interne relatif
/contact - Un lien interne absolu
https://votre-site.tld/contact - Un lien externe
https://example.org - Un lien
mailto:et un#ancre - Une image avec et sans attribut
class
- Un lien interne relatif
- Vérifiez le HTML rendu (Inspecteur navigateur) :
- Le lien externe a
target="_blank"etrelcontientnoopener noreferrer - Le lien interne n’a pas été modifié
imgadecoding="async"(si absent avant) et la classe ajoutée
- Le lien externe a
- Si vous avez un plugin de cache : purge + test en navigation privée. J’ai souvent vu des gens “débugger” 30 minutes alors qu’ils regardaient une version cache.
Tableau de diagnostic rapide
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| Les liens externes ne changent pas | Hook non exécuté (builder), priorité trop basse, cache HTML | Ajoutez un error_log temporaire dans filter_content(), purgez le cache |
Augmentez la priorité (50), purge cache, ciblez un autre hook (ex. rendu de bloc) |
| Les liens internes deviennent “externes” | Host mal détecté (multisite, domaine différent, reverse proxy) | Loggez $site_host et $host |
Ajoutez link_exceptions ou adaptez la règle (sous-domaines) |
| HTML cassé sur certaines pages | Le snippet s’applique à un fragment non-HTML ou contenu encodé | Identifiez le contenu source (shortcode/builder) et isolez | Limitez le traitement (shortcode wrapper, conditions, post types) |
| Fatal error “Class WP_HTML_Tag_Processor not found” | Code exécuté trop tôt sur un environnement WP trop vieux | Vérifiez la version WP et l’ordre de chargement | Mettre à jour WP (6.9.4), ou garder le class_exists + fallback |
Si ça ne marche pas
- Vérifiez l’emplacement du fichier : un MU-plugin doit être directement dans
wp-content/mu-plugins/, pas dans un sous-dossier (sauf loader). - Vérifiez la version PHP : ce code utilise
declare(strict_types=1)et des typed properties. En dessous de PHP 8.1, vous allez avoir des erreurs. Contrôlez dans Outils > Santé du site. - Videz les caches : plugin de cache, cache serveur, CDN, cache navigateur.
- Testez une page “nue” (thème Twenty Twenty-* temporaire, pas de builder) pour confirmer que le hook
the_contentpasse bien. - Contrôlez la priorité : si un autre plugin réécrit le contenu après vous, votre résultat disparaît. Montez à 50 ou 99.
- Confirmez le contexte : si vous testez via l’API REST, le code skip (volontairement). Désactivez le garde-fou REST si nécessaire.
- Loggez intelligemment : loggez seulement sur une page de test, sinon vous allez spammer
debug.log.
Pièges et erreurs courantes
| Erreur | Cause | Solution |
|---|---|---|
| Code collé dans le mauvais fichier | Ajouté dans un plugin de snippets désactivé ou dans un thème parent mis à jour | Utilisez un MU-plugin ou un plugin maison versionné |
| Parse error: “unexpected token” | Point-virgule oublié, accolade manquante | Collez le code tel quel, puis passez-le dans un linter PHP |
| Confusion action/filtre | Utilisation de add_action('the_content', ...) |
Utilisez add_filter et retournez la chaîne |
| Le HTML est modifié deux fois | Hook appliqué sur the_content + un autre filtre similaire, ou contenu rendu plusieurs fois |
Ajoutez une condition (post type, is_main_query, marqueur), ou réduisez les hooks |
Les liens mailto: deviennent bizarres |
Règle “externe” trop large | Excluez explicitement les schémas mailto/tel |
| Les liens du menu ne changent pas | Les menus ne passent pas par the_content |
Utilisez nav_menu_link_attributes pour les menus (alternative plus adaptée) |
| Ça marche en local, pas en prod | Cache agressif, minification, edge cache | Purge complète + bypass cache + test avec un paramètre d’URL |
| “Class WP_HTML_Tag_Processor not found” | WordPress trop ancien sur un environnement, ou code exécuté hors WP | Mettre à jour WP, protéger avec class_exists, vérifier ABSPATH |
| Règles incohérentes en multisite | Domaine du site différent, mapping, sous-domaines | Basez-vous sur home_url() du site courant + exceptions |
Conseils sécurité, performance et maintenance
Sécurité
- Ne confondez pas manipulation HTML et sanitation. Si votre problème est “je reçois du HTML non fiable”, passez par KSES : wp_kses_post().
- Ajouter
target="_blank"sansrel="noopener"expose au tabnabbing. Ici, on forcenoopenersystématiquement sur les liens externes. - Évitez de “corriger”
javascript:en le transformant. Le plus sûr est de ne pas y toucher ici et de bloquer via KSES / politiques d’édition.
Performance
- Le Tag Processor est rapide pour des transformations locales, mais reste un traitement de chaîne à chaque rendu. Sur des pages très longues, mesurez (Query Monitor, Blackfire, Tideways).
- Si vous avez un cache de page complet, le coût est amorti. Sans cache, envisagez le traitement à l’enregistrement (variante 3).
- Évitez de faire des appels réseau (ex. vérifier un domaine) pendant la transformation.
Maintenance
- Gardez les règles dans une config filtrable (
bpcab_html_transformer_config) pour éviter de modifier le MU-plugin. - Documentez les exceptions de domaines. C’est le point qui finit toujours en “hotfix” le vendredi soir.
- Testez après mise à jour de builder : certains changent légèrement le HTML (classes, wrappers) et vous pouvez toucher plus que prévu.
Ressources
- Référence officielle : WP_HTML_Tag_Processor
- Filtre the_content
- wp_parse_url()
- wp_kses_post()
- Trac : composant HTML API (suivi des changements core)
- GitHub : PRs liées à WP_HTML_Tag_Processor
- PHP.net : parse_url()
FAQ
WP_HTML_Tag_Processor remplace-t-il DOMDocument ?
Non. DOMDocument est un DOM complet, utile si vous devez déplacer des nœuds et restructurer. WP_HTML_Tag_Processor est optimisé pour parcourir des tags et modifier des attributs de façon robuste sur des fragments HTML.
Puis-je l’utiliser pour sécuriser du HTML utilisateur contre le XSS ?
Non. Pour ça, utilisez KSES (wp_kses_post() ou wp_kses()) et appliquez les règles au bon moment (souvent à l’enregistrement ou avant affichage selon le contexte).
Pourquoi ne pas utiliser nav_menu_link_attributes pour les liens ?
Si votre cible est le menu, c’est la meilleure option. Le Tag Processor est utile quand le HTML vient d’un champ ou du contenu, pas d’une API structurée qui expose déjà des filtres d’attributs.
Est-ce compatible Gutenberg / blocs ?
Oui, parce que vous modifiez le HTML rendu. Si vous pouvez intervenir au niveau d’un bloc spécifique (ex. via render_block), c’est parfois plus précis et plus performant.
Pourquoi mon lien externe n’a pas reçu target="_blank" ?
Trois causes fréquentes : lien relatif (donc considéré interne), règle d’exception (host dans link_exceptions), ou votre HTML est généré côté client (JS) après le rendu.
Que faire avec les sous-domaines (blog.example.com vs www.example.com) ?
Ajoutez-les dans link_exceptions si vous les considérez internes, ou implémentez une règle “même domaine racine” (plus délicate, surtout avec des TLD composés).
Pourquoi vous utilisez plugins_loaded et pas init ?
Parce que ce code ne dépend pas des rewrite rules ou d’objets initialisés tard. plugins_loaded permet d’enregistrer les filtres tôt, et reste prévisible en MU-plugin.
Ça peut casser le cache HTML ?
Non, mais ça peut vous donner l’impression que “ça ne marche pas” si vous ne purgez pas. Sur un site avec cache full-page + CDN, purgez aux deux niveaux.
Puis-je ajouter des attributs data-* avec le Tag Processor ?
Oui. Utilisez set_attribute('data-foo', 'bar') sur les tags ciblés. Gardez une convention stable, sinon vous allez vous battre avec des scripts front divergents.
Comment valider que je n’ai pas créé de HTML invalide ?
Testez un échantillon de pages et passez le HTML dans un validateur (ou au minimum, inspectez le DOM dans le navigateur). Le Tag Processor limite les dégâts typiques des regex, mais une règle métier mal pensée peut toujours produire des attributs incohérents.