`, `id="…"` posé par l'auteur) : le cadastre ne connaît que les * titres H2-H6 qu'il a lui-même indexés ; * · les articles paginés ne changent rien — le registre porte l'ARTICLE entier. * * @package AIFORYA_Table_Of_Contents */ defined( 'ABSPATH' ) || exit; if ( ! class_exists( 'Aiforya_Toc_Diagnostic', false ) ) : /** * Cherche les liens internes dont le fragment ne désigne plus rien. */ final class Aiforya_Toc_Diagnostic { /** * Nombre d'articles lus par lot lors d'un audit de site. * Volontairement bas : un audit qui fait expirer la requête ne rend aucun résultat, * et un audit qui rend un résultat partiel SANS le dire est pire. */ public const LOT = 25; /** * Instance unique. * * @var self|null */ private static $instance = null; /** * Le cadastre, source de vérité des adresses. * * @var Aiforya_Toc_Cadastre */ private $cadastre; /** * Mémoïsation des résolutions d'URL vers un identifiant d'article. * * @var array */ private $cible_cache = array(); /** * Constructeur privé. */ private function __construct() { $this->cadastre = Aiforya_Toc_Cadastre::get_instance(); } /** * Instance unique. * * @return self */ public static function get_instance(): self { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Les liens internes AVEC FRAGMENT trouvés dans un contenu. * * ⚠ On lit le contenu BRUT, pas le rendu : `the_content` déclencherait les shortcodes * et les filtres d'autres extensions, donc un résultat qui dépend de ce qui est * activé ce jour-là. Un diagnostic doit rendre la même chose deux fois de suite. * * @param string $contenu Contenu brut d'un article. * @return array */ public function liens_avec_fragment( string $contenu ): array { if ( '' === trim( $contenu ) ) { return array(); } if ( ! preg_match_all( '/]*href=(["\'])(.*?)\1[^>]*>(.*?)<\/a>/is', $contenu, $m, PREG_SET_ORDER ) ) { return array(); } $out = array(); foreach ( $m as $lien ) { $href = html_entity_decode( trim( $lien[2] ), ENT_QUOTES, 'UTF-8' ); $pos = strpos( $href, '#' ); if ( false === $pos ) { continue; } $fragment = substr( $href, $pos + 1 ); if ( '' === $fragment ) { continue; } $out[] = array( 'href' => $href, 'fragment' => $fragment, 'texte' => trim( wp_strip_all_tags( $lien[3] ) ), ); } return $out; } /** * L'article que désigne une URL, ou 0. * * Un lien purement fragmentaire (`#section`) désigne l'article qui le porte — * c'est le cas le plus fréquent dans un sommaire, et l'oublier ferait passer * tous les liens internes d'un article pour « hors champ ». * * @param string $href URL du lien, fragment compris. * @param int $porteur Article qui contient le lien. * @return int Identifiant de l'article cible, ou 0 si l'URL ne désigne pas un article. */ public function cible( string $href, int $porteur ): int { /* ⛔ PAS `strtok( $href, '#' )` : il SAUTE les délimiteurs de tête, donc `#intro` * lui rend `intro` au lieu d'une chaîne vide — et le lien purement fragmentaire, * le plus fréquent dans un sommaire, perdait son article porteur. Trouvé par le * banc au premier tir, pas par relecture. */ $diese = strpos( $href, '#' ); $sans_fragment = ( false === $diese ) ? $href : substr( $href, 0, $diese ); if ( '' === $sans_fragment ) { return $porteur; } if ( isset( $this->cible_cache[ $sans_fragment ] ) ) { return $this->cible_cache[ $sans_fragment ]; } $id = (int) url_to_postid( $sans_fragment ); $this->cible_cache[ $sans_fragment ] = $id; return $id; } /** * L'état d'un fragment donné, pour un article cible donné. * * @param int $cible Article vers lequel pointe le lien (0 = inconnu). * @param string $fragment Fragment du lien, sans le croisillon. * @return array{etat: string, vers: string} état et, si connu, l'adresse courante. */ public function etat_du_fragment( int $cible, string $fragment ): array { if ( $cible <= 0 ) { return array( 'etat' => 'hors-champ', 'vers' => '', ); } $registre = $this->cadastre->registre( $cible ); if ( empty( $registre ) ) { // Pas de registre = pas de mesure. Ce n'est PAS un lien rompu. return array( 'etat' => 'sans-registre', 'vers' => '', ); } $resolu = $this->cadastre->resoudre( $cible, $fragment ); if ( null === $resolu ) { return array( 'etat' => 'rompu', 'vers' => '', ); } if ( $resolu['id'] === $fragment ) { return array( 'etat' => 'ok', 'vers' => $resolu['id'], ); } // Le fragment est un ALIAS : le lien marche, grâce au registre. return array( 'etat' => 'alias', 'vers' => $resolu['id'], ); } /** * Diagnostic d'UN article : les liens qu'il porte, et leur état. * * @param int $post_id Identifiant de l'article à examiner. * @return array{total: int, par_etat: array, liens: array>} */ public function analyser_article( int $post_id ): array { $post = get_post( $post_id ); $vide = array( 'total' => 0, 'par_etat' => array(), 'liens' => array(), ); if ( ! $post instanceof WP_Post ) { return $vide; } $liens = $this->liens_avec_fragment( (string) $post->post_content ); $par_etat = array(); $sortie = array(); foreach ( $liens as $lien ) { $cible = $this->cible( $lien['href'], $post_id ); $etat = $this->etat_du_fragment( $cible, $lien['fragment'] ); $par_etat[ $etat['etat'] ] = ( $par_etat[ $etat['etat'] ] ?? 0 ) + 1; $sortie[] = array( 'source' => $post_id, 'cible' => $cible, 'href' => $lien['href'], 'fragment' => $lien['fragment'], 'texte' => $lien['texte'], 'etat' => $etat['etat'], 'vers' => $etat['vers'], ); } return array( 'total' => count( $sortie ), 'par_etat' => $par_etat, 'liens' => $sortie, ); } /** * Diagnostic du SITE ENTIER, par lots. * * ⚠ Il rend TOUJOURS `reste` : le nombre d'articles non encore examinés. Un audit * qui s'arrête en chemin et n'annonce pas ce qu'il n'a pas vu produit un « 0 lien * rompu » qui ne veut rien dire. Tant que `reste > 0`, le total est PARTIEL et la * sortie le dit. * * @param int $offset Rang du premier article du lot. * @param int $lot Taille du lot (borné à self::LOT). * @return array{examines: int, reste: int, total_articles: int, par_etat: array, rompus: array>} */ public function analyser_site( int $offset = 0, int $lot = self::LOT ): array { $lot = max( 1, min( $lot, self::LOT ) ); $types = get_post_types( array( 'public' => true ), 'names' ); unset( $types['attachment'] ); $requete = new WP_Query( array( 'post_type' => array_values( $types ), 'post_status' => 'publish', 'posts_per_page' => $lot, 'offset' => max( 0, $offset ), 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => false, 'ignore_sticky_posts' => true, 'update_post_term_cache' => false, ) ); $total = (int) $requete->found_posts; $par_etat = array(); $rompus = array(); $vus = 0; foreach ( $requete->posts as $post ) { ++$vus; $r = $this->analyser_article( (int) $post->ID ); foreach ( $r['par_etat'] as $etat => $n ) { $par_etat[ $etat ] = ( $par_etat[ $etat ] ?? 0 ) + $n; } foreach ( $r['liens'] as $lien ) { if ( 'rompu' === $lien['etat'] ) { $rompus[] = $lien; } } } wp_reset_postdata(); $examines = max( 0, $offset ) + $vus; return array( 'examines' => $examines, 'reste' => max( 0, $total - $examines ), 'total_articles' => $total, 'par_etat' => $par_etat, 'rompus' => $rompus, ); } } endif;