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" et target="_blank" sur certains liens.
  • Injecter une classe sur des img ou des figure selon 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

  1. On évite les regex pour parser du HTML (elles cassent sur les cas réels).
  2. On utilise WP_HTML_Tag_Processor pour itérer sur des tags et manipuler leurs attributs.
  3. On branche le code sur the_content (et variantes) avec des garde-fous (admin, REST, feeds, shortcodes).
  4. On applique des règles concrètes : liens externes, liens “download”, ancres internes, images.
  5. On ajoute une variante “avancée” : configuration via filtre + container de services minimal.
  6. 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 les target="_blank", ou forcer ugc/sponsored selon 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_block ou 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.php sur 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) :

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 (target dupliqué, 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 img qui 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_content pour le contenu principal.
  • widget_text_content (si vous avez encore des widgets texte avec HTML).
  • Optionnel : comment_text pour 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 que rel contient noopener noreferrer.

Quand il rencontre un <img>, on ajoute une classe et decoding="async" si absent.

Lecture technique (hooks, API, edge cases)

  • Hook : the_content est 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 que parse_url() direct, pour rester cohérent avec WordPress. Référence : wp_parse_url().
  • Exceptions : la liste link_exceptions gè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
  • Vérifiez le HTML rendu (Inspecteur navigateur) :
    • Le lien externe a target="_blank" et rel contient noopener noreferrer
    • Le lien interne n’a pas été modifié
    • img a decoding="async" (si absent avant) et la classe ajoutée
  • 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

  1. Vérifiez l’emplacement du fichier : un MU-plugin doit être directement dans wp-content/mu-plugins/, pas dans un sous-dossier (sauf loader).
  2. 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.
  3. Videz les caches : plugin de cache, cache serveur, CDN, cache navigateur.
  4. Testez une page “nue” (thème Twenty Twenty-* temporaire, pas de builder) pour confirmer que le hook the_content passe bien.
  5. Contrôlez la priorité : si un autre plugin réécrit le contenu après vous, votre résultat disparaît. Montez à 50 ou 99.
  6. Confirmez le contexte : si vous testez via l’API REST, le code skip (volontairement). Désactivez le garde-fou REST si nécessaire.
  7. 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" sans rel="noopener" expose au tabnabbing. Ici, on force noopener systé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

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.