value( 'enabled', true ) ) { return; } wp_enqueue_style( 'aiforya-toc', AIFORYA_TOC_URL . 'assets/css/aiforya-toc.css', [], AIFORYA_TOC_VERSION ); } /** * Ce contenu doit-il être traité (contexte singulier + type éligible) ? * * @return bool */ /** * Le contexte permet-il d'afficher un sommaire ? * * ⛔ LE FILTRE PAR TYPE DE CONTENU NE S'APPLIQUE PAS AU CODE COURT, et c'est le * correctif du 16/08/2026 : Greg a posé [aiforya_toc] sur une page, et RIEN ne * s'est affiché. Le réglage « Types de contenu » ne cochait que les articles, donc * `is_eligible_context()` rendait false et le marqueur était simplement retiré — * sans erreur, sans message, sans trace. * * Or ce réglage répond à la question « où le sommaire doit-il apparaître TOUT * SEUL ? ». Un utilisateur qui écrit le code court a déjà répondu à cette * question-là : il le veut ICI. Lui opposer un réglage d'insertion automatique, * c'est ignorer une demande explicite au nom d'une préférence par défaut. * Le même raisonnement vaut déjà pour `min_headings`, que le code court ignore * depuis toujours (l. 123) : le placement manuel prime. Le filtre par type était * la dernière porte qui ne suivait pas cette règle. * * ⚠ CE QUI CONTINUE DE S'APPLIQUER AU CODE COURT : le réglage « Activer le * sommaire ». Celui-là est un interrupteur général, pas une préférence de * placement — s'il est éteint, plus rien ne s'affiche nulle part, et c'est ce que * son libellé promet. * * @param bool $manuel Le sommaire est-il demandé par le code court ? * @return bool */ private function is_eligible_context( bool $manuel = false ): bool { if ( is_feed() || is_admin() ) { return false; } if ( ! is_singular() || ! in_the_loop() || ! is_main_query() ) { return false; } $settings = Aiforya_Toc_Settings::get_instance(); if ( ! $settings->value( 'enabled', true ) ) { return false; } if ( $manuel ) { return true; } $post_types = (array) $settings->value( 'post_types', [ 'post' ] ); $current = (string) get_post_type(); return in_array( $current, $post_types, true ); } /** * Filtre principal : ajoute les ancres aux titres et insère le sommaire. * * @param string $content Contenu HTML. * @return string */ public function filter_content( $content ): string { $content = (string) $content; // Le code court est-il présent dans ce contenu ? (placeholder posé au rendu) $has_placeholder = ( false !== strpos( $content, '' ) ); if ( '' === trim( $content ) || ! $this->is_eligible_context( $has_placeholder ) ) { return $this->strip_placeholder( $content ); } $settings = Aiforya_Toc_Settings::get_instance(); $max_level = (int) $settings->value( 'max_level', 6 ); /* L'article dont on lit le cadastre. `get_the_ID()` plutot que la boucle globale : * on est deja garde par `is_singular() && in_the_loop() && is_main_query()`. */ $post_id = (int) get_the_ID(); $processed = $this->process( $content, $max_level, $post_id ); /* Article paginé () : le sommaire couvre TOUT l'article, pas la * seule page servie. C'est la raison pour laquelle une page SANS titre doit * quand même pouvoir en afficher un — d'où le fait qu'on ne sorte plus dès que * `process()` rend null. */ $paged = $this->post_pages(); $is_split = ! empty( $paged['pages'] ); // Rien à faire (pas de titre exploitable, et pas d'autres pages où en chercher). if ( null === $processed && ! $is_split ) { return $this->strip_placeholder( $content ); } $current_headings = ( null !== $processed ) ? $processed['headings'] : []; if ( null !== $processed ) { $content = $processed['content']; } $headings = $is_split ? $this->headings_across_pages( $paged['pages'], $paged['current'], $current_headings, $max_level, $post_id ) : $current_headings; if ( empty( $headings ) ) { return $this->strip_placeholder( $content ); } $toc_html = $this->build_toc( $headings, (string) $settings->value( 'title_text', __( 'Contents', 'aiforya-table-of-contents' ) ), $paged['current'] ); if ( '' === $toc_html ) { return $this->strip_placeholder( $content ); } // Placement manuel via le code court : on remplace le marqueur. if ( $has_placeholder ) { return str_replace( '', $toc_html, $content ); } // Insertion automatique (si activée et seuil atteint). $auto_insert = (bool) $settings->value( 'auto_insert', true ); $min_headings = (int) $settings->value( 'min_headings', 3 ); if ( ! $auto_insert || count( $headings ) < $min_headings ) { return $content; } return $this->insert_before_first_heading( $content, $toc_html ); } /** * Retire tout marqueur de code court résiduel. * * @param string $content Contenu HTML. * @return string */ private function strip_placeholder( string $content ): string { return str_replace( '', '', $content ); } /** * Code court [aiforya_toc] — rend le sommaire du contenu courant. * * @param array|string $atts Attributs (non utilisés). * @return string */ public function shortcode( $atts = [] ): string { // Le sommaire est calculé par filter_content (priorité 20) qui s'exécute // après le rendu des codes courts ; on renvoie un marqueur remplacé ensuite. return ''; } /** * Analyse le contenu, ajoute les ancres et renvoie titres + HTML modifié. * * @param string $content Contenu HTML. * @param int $max_level Niveau HTML max inclus (2..6). * @param int $post_id Article dont on lit le cadastre (0 = aucun, ancres dérivées du texte). * @return array{content: string, headings: array}|null */ private function process( string $content, int $max_level, int $post_id = 0 ): ?array { $max_level = max( 2, min( 6, $max_level ) ); if ( ! class_exists( 'DOMDocument' ) ) { return null; } // Détection rapide : au moins un titre dans la plage. if ( ! preg_match( '/]/i', $content ) ) { return null; } $dom = new DOMDocument(); $prev = libxml_use_internal_errors( true ); /* Préserver l'UTF-8 avant loadHTML(), qui suppose du Latin-1 sans indication. * * ⛔ L'ANCIENNE VERSION PASSAIT PAR `mb_convert_encoding` AVEC SON ENCODAGE * D'ENTITÉS HTML — déprécié depuis PHP 8.2. Elle émettait un avertissement * VISIBLE EN HAUT DE PAGE sur le site de test, donc dans le rendu public, * au-dessus du contenu. * (La forme exacte n'est pas recopiée ici : un commentaire qui cite mot pour * mot ce qu'il annule laisse la chose retrouvable par une simple recherche, * et fait croire qu'elle est encore là. On décrit, on ne reproduit pas.) * * ⭐ AUCUN de nos contrôles ne pouvait le voir, et c'est le vrai enseignement : * `php -l` ne lit que la syntaxe ; Plugin Check n'a AUCUN check de compatibilité * PHP (son seul « compatibility » porte sur les fonctions WordPress) ; et notre * test d'installation LIT bien le journal PHP mais ne visite que la page * d'accueil et /wp-login.php — deux URL où ce code ne s'exécute jamais. * Une dépréciation ne se voit qu'en EXÉCUTANT le chemin concerné. * * Remplacement : `mb_encode_numericentity` fait exactement le même travail — * transformer les caractères non-ASCII en entités que loadHTML comprend — et * c'est la fonction que le message de dépréciation recommande lui-même. * La plage [0x80, 0x10FFFF] couvre tout ce qui sort de l'ASCII. */ $prepared = $content; if ( function_exists( 'mb_encode_numericentity' ) ) { $prepared = mb_encode_numericentity( $content, [ 0x80, 0x10FFFF, 0, 0x1FFFFF ], 'UTF-8' ); } $wrapped = '
' . $prepared . '
'; $loaded = $dom->loadHTML( $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); libxml_clear_errors(); libxml_use_internal_errors( $prev ); if ( ! $loaded ) { return null; } $xpath = new DOMXPath( $dom ); $query = []; for ( $lvl = 2; $lvl <= $max_level; $lvl++ ) { $query[] = '//h' . $lvl; } $nodes = $xpath->query( implode( ' | ', $query ) ); if ( false === $nodes || 0 === $nodes->length ) { return null; } $headings = []; $used_ids = []; foreach ( $nodes as $node ) { if ( ! ( $node instanceof DOMElement ) ) { continue; } $level = (int) substr( $node->nodeName, 1 ); $text = trim( (string) $node->textContent ); if ( '' === $text ) { continue; } $id = $node->getAttribute( 'id' ); /* ⭐ LE CADASTRE PASSE AVANT LE LIBELLÉ. Si cette section a déjà une adresse * permanente, c'est ELLE qu'on sert — même si le titre a changé depuis. Et les * libellés qu'elle a portés autrefois sont posés en ancres invisibles juste * avant, pour que les liens d'hier continuent d'aboutir. * * ⚠ Un `id` écrit à la main par l'auteur dans son HTML l'emporte sur tout : il * a explicitement demandé cette adresse-là, on ne la lui reprend pas. */ if ( '' === $id ) { $slug = $this->slugify( $text ); $cadastre = Aiforya_Toc_Cadastre::get_instance()->resoudre( $post_id, $slug ); if ( null !== $cadastre ) { $id = $cadastre['id']; $this->poser_alias( $dom, $node, $cadastre['alias'], $used_ids ); } else { $id = $this->unique_id( $slug, $used_ids ); } $node->setAttribute( 'id', $id ); } else { $id = $this->unique_id( $id, $used_ids ); } $used_ids[ $id ] = true; $headings[] = [ 'level' => $level, 'text' => $text, 'id' => $id, ]; } if ( empty( $headings ) ) { return null; } // Ré-extraction du contenu interne du wrapper. Avec LIBXML_HTML_NOIMPLIED, // l'élément racine du document EST notre
. $root = $dom->getElementById( 'aiforya-toc-root' ); if ( null === $root ) { $root = $xpath->query( '//div[@id="aiforya-toc-root"]' )->item( 0 ); } if ( null === $root && null !== $dom->documentElement ) { $root = $dom->documentElement; } if ( ! ( $root instanceof DOMElement ) ) { return null; } $out = ''; foreach ( $root->childNodes as $child ) { $out .= $dom->saveHTML( $child ); } return [ 'content' => $out, 'headings' => $headings, ]; } /** * Les pages d'un article découpé par , et le numéro de la page servie. * * WordPress fait déjà tout le travail : `setup_postdata()` remplit `$pages` (le contenu * BRUT de chaque page), `$page` (la page demandée) et `$multipage`. On ne redécoupe rien * à la main — refaire le `explode()` soi-même, c'est réimplémenter une règle qui a des * cas particuliers (balise en tête de contenu, contenu vide) et diverger d'elle un jour. * * @return array{pages: array, current: int} `pages` vide si l'article n'est pas paginé. */ private function post_pages(): array { global $pages, $page, $multipage; $list = ( ! empty( $multipage ) && is_array( $pages ) ) ? array_values( $pages ) : []; if ( count( $list ) < 2 ) { return [ 'pages' => [], 'current' => 1, ]; } $current = max( 1, (int) $page ); return [ 'pages' => $list, 'current' => min( $current, count( $list ) ), ]; } /** * Les titres de TOUTES les pages, chacun avec le numéro de page où il se trouve. * * ⭐ POURQUOI LES ANCRES RESTENT PAR PAGE, ET POURQUOI C'EST LE POINT DÉLICAT * * Chaque page d'un article paginé est un document HTML séparé : une ancre n'a besoin * d'être unique QUE dans sa page. Numéroter les identifiants à l'échelle de l'article * entier coûterait deux choses, l'une pire que l'autre : * 1. les identifiants déjà en circulation changeraient (liens partagés cassés) ; * 2. le rendu de la page 3 devrait connaître les pages 1 et 2 pour savoir s'il doit * écrire `intro` ou `intro-2` — c'est-à-dire dépendre d'un état qu'il n'a pas. * * ⛔ MAIS SI ON NE FAIT RIEN, LE LIEN INTER-PAGES POINTE À CÔTÉ, et c'est silencieux. * `process()` remet `$used_ids` à zéro à chaque appel. Un sommaire qui aurait numéroté * les ancres sur l'article entier écrirait « page 3, #intro-2 » alors que la page 3, * rendue seule, nomme ce titre `intro`. Le lien mène nulle part et rien ne le signale. * * ⇒ LA SORTIE EST DE PRÉDIRE, PAS DE RENUMÉROTER : on rejoue `process()` page par page, * exactement comme le rendu de chaque page le fera. `process()` est une fonction pure de * son contenu — même entrée, même sortie — donc les identifiants prédits sont ceux qui * seront réellement écrits. C'est ce qui rend le lien inter-pages vérifiable. * * ⚠ PORTÉE DÉCLARÉE, à ne pas élargir : pour les AUTRES pages on lit le contenu BRUT * (`$pages`), pas le contenu filtré. Un titre produit par un code court ou par un filtre * tiers n'y figure donc pas, et il manquera au sommaire. Le contenu brut porte bien les * `

` des blocs (le balisage de bloc entoure le HTML, il ne le remplace pas) et ceux * de l'éditeur classique — c'est-à-dire la quasi-totalité des cas réels. La page COURANTE, * elle, est toujours lue APRÈS filtrage : sur elle, rien n'est manqué. * * @param array $pages Contenu brut de chaque page. * @param int $current Numéro (1-based) de la page servie. * @param array $current_headings Titres déjà relevés sur la page servie. * @param int $max_level Niveau HTML max inclus. * @param int $post_id Article dont on lit le cadastre. * @return array */ private function headings_across_pages( array $pages, int $current, array $current_headings, int $max_level, int $post_id = 0 ): array { $all = []; foreach ( $pages as $index => $raw ) { $number = (int) $index + 1; // La page servie est déjà analysée, et elle l'a été sur le contenu FILTRÉ : // la ré-analyser sur le brut serait une seconde mesure, moins bonne, du même fait. if ( $number === $current ) { foreach ( $current_headings as $heading ) { $heading['page'] = $number; $all[] = $heading; } continue; } $processed = $this->process( (string) $raw, $max_level, $post_id ); if ( null === $processed ) { continue; } foreach ( $processed['headings'] as $heading ) { $heading['page'] = $number; $all[] = $heading; } } return $all; } /** * L'adresse d'une page d'un article paginé. * * Reproduit la règle de WordPress pour les liens de pagination interne (celle que * `wp_link_pages()` emploie) : réécriture d'URL ou paramètre `page`, cas particulier de * la page d'accueil statique. On la réécrit plutôt que d'appeler la fonction interne du * cœur : une fonction préfixée par un souligné n'est pas une API publique, et s'y * appuyer, c'est parier sur ce que WordPress ne promet pas. * * @param int $number Numéro de page (1-based). * @return string Adresse absolue, ou chaîne vide si le permalien est indisponible. */ private function link_to_page( int $number ): string { $permalink = get_permalink(); if ( ! is_string( $permalink ) || '' === $permalink ) { return ''; } if ( $number <= 1 ) { return $permalink; } $post = get_post(); if ( ! get_option( 'permalink_structure' ) || ( $post instanceof WP_Post && in_array( $post->post_status, [ 'draft', 'pending' ], true ) ) ) { return add_query_arg( 'page', $number, $permalink ); } if ( $post instanceof WP_Post && 'page' === get_option( 'show_on_front' ) && (int) get_option( 'page_on_front' ) === (int) $post->ID ) { global $wp_rewrite; $base = ( isset( $wp_rewrite->pagination_base ) && '' !== $wp_rewrite->pagination_base ) ? $wp_rewrite->pagination_base : 'page'; return trailingslashit( $permalink ) . user_trailingslashit( $base . '/' . $number, 'single_paged' ); } return trailingslashit( $permalink ) . user_trailingslashit( $number, 'single_paged' ); } /** * Construit la navigation HTML (liste imbriquée) du sommaire. * * @param array $headings Titres. * @param string $title Intitulé du bloc. * @param int $current_page Page servie (1 si non paginé). * @return string */ private function build_toc( array $headings, string $title, int $current_page = 1 ): string { if ( empty( $headings ) ) { return ''; } $base_level = 6; foreach ( $headings as $h ) { $base_level = min( $base_level, (int) $h['level'] ); } $html = ''; return $html; } /** * Pose les anciennes adresses d'une section en ancres invisibles, juste avant son titre. * * C'est la moitié du cadastre qu'on ne voit pas et qui fait tout le travail : le lien * `#introduction` partagé il y a deux ans doit continuer d'aboutir, même si la section * s'appelle « Avant-propos » aujourd'hui et sert l'adresse permanente. * * Un `` vide, `aria-hidden` : le lecteur d'écran ne l'annonce pas, la mise en page * ne bouge pas, et le navigateur y saute exactement comme sur un titre. * * @param DOMDocument $dom Le document. * @param DOMElement $node Le titre devant lequel poser les ancres. * @param array $alias Les anciennes adresses. * @param array $used_ids Identifiants déjà servis sur CETTE page. * @return void */ private function poser_alias( DOMDocument $dom, DOMElement $node, array $alias, array &$used_ids ): void { $parent = $node->parentNode; if ( null === $parent ) { return; } foreach ( $alias as $ancien ) { // Une adresse déjà servie sur cette page ne se redouble pas : deux éléments // portant le même id rendraient le document invalide, et le saut imprévisible. if ( '' === $ancien || isset( $used_ids[ $ancien ] ) ) { continue; } $span = $dom->createElement( 'span' ); $span->setAttribute( 'id', $ancien ); $span->setAttribute( 'class', 'aiforya-toc-alias' ); $span->setAttribute( 'aria-hidden', 'true' ); $parent->insertBefore( $span, $node ); $used_ids[ $ancien ] = true; } } /** * Insère le sommaire avant le premier titre, ou en tête du contenu. * * @param string $content Contenu HTML. * @param string $toc_html Sommaire HTML. * @return string */ private function insert_before_first_heading( string $content, string $toc_html ): string { if ( preg_match( '/]/i', $content, $m, PREG_OFFSET_CAPTURE ) ) { $pos = (int) $m[0][1]; return substr( $content, 0, $pos ) . $toc_html . substr( $content, $pos ); } return $toc_html . $content; } /** * Convertit un texte de titre en identifiant d'ancre. * * @param string $text Texte source. * @return string */ private function slugify( string $text ): string { $slug = sanitize_title( $text ); if ( '' === $slug ) { $slug = 'section'; } return $slug; } /** * Garantit l'unicité d'un identifiant d'ancre. * * @param string $id Identifiant candidat. * @param array $used_ids Identifiants déjà attribués. * @return string */ private function unique_id( string $id, array $used_ids ): string { $id = '' !== $id ? $id : 'section'; if ( ! isset( $used_ids[ $id ] ) ) { return $id; } $n = 2; while ( isset( $used_ids[ $id . '-' . $n ] ) ) { $n++; } return $id . '-' . $n; } } endif;