$levels Heading levels to include. * @param string $prefix Anchor prefix. * @param array $exclude Exclusion fragments. * * @return array{content: string, headings: array} */ public static function build( string $content, array $levels, string $prefix, array $exclude ): array { if ( '' === trim( $content ) ) { return array( 'content' => $content, 'headings' => array(), ); } libxml_use_internal_errors( true ); $doc = new \DOMDocument( '1.0', 'UTF-8' ); $wrapped = '
' . $content . '
'; $doc->loadHTML( '' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); $xpath = new \DOMXPath( $doc ); $tag_query = array(); foreach ( $levels as $level ) { $level = (int) $level; if ( $level < 1 || $level > 6 ) { continue; } $tag_query[] = sprintf( '//h%d', $level ); } if ( empty( $tag_query ) ) { return array( 'content' => $content, 'headings' => array(), ); } $nodes = $xpath->query( implode( ' | ', $tag_query ) ); if ( ! $nodes ) { return array( 'content' => $content, 'headings' => array(), ); } $existing_ids = array(); $headings = array(); foreach ( $nodes as $node ) { if ( ! $node instanceof \DOMElement ) { continue; } $level = (int) substr( $node->nodeName, 1 ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property name. $text = trim( $node->textContent ?? '' ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property name. if ( '' === $text ) { continue; } if ( self::is_excluded( $text, $exclude ) ) { continue; } $id = $node->getAttribute( 'id' ); if ( '' === $id ) { $id = self::unique_id( $text, $prefix, $existing_ids ); $node->setAttribute( 'id', $id ); } else { $id = trim( $id ); if ( '' === $id ) { $id = self::unique_id( $text, $prefix, $existing_ids ); $node->setAttribute( 'id', $id ); } } if ( '' !== $id ) { $existing_ids[] = $id; } $headings[] = array( 'level' => $level, 'id' => $id, 'text' => $text, ); } $wrapper = $doc->getElementsByTagName( 'div' )->item( 0 ); $content_html = ''; if ( $wrapper instanceof \DOMElement ) { foreach ( $wrapper->childNodes as $child ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM API property name. $content_html .= $doc->saveHTML( $child ); } } return array( 'content' => $content_html, 'headings' => $headings, ); } /** * Determine if a heading text should be excluded. * * @param string $text Heading text. * @param array $exclude Exclusion fragments. * * @return bool */ private static function is_excluded( string $text, array $exclude ): bool { if ( empty( $exclude ) ) { return false; } $haystack = strtolower( $text ); foreach ( $exclude as $needle ) { $needle = trim( strtolower( $needle ) ); if ( '' === $needle ) { continue; } if ( false !== strpos( $haystack, $needle ) ) { return true; } } return false; } /** * Generate a unique anchor ID. * * @param string $text Heading text. * @param string $prefix Prefix to apply. * @param array $existing Existing IDs. * * @return string */ private static function unique_id( string $text, string $prefix, array $existing ): string { $base = self::slugify( $text ); if ( '' === $base ) { $base = 'section'; } $id = $prefix . $base; if ( ! in_array( $id, $existing, true ) ) { return $id; } $counter = 2; while ( in_array( $id . '-' . $counter, $existing, true ) ) { ++$counter; } return $id . '-' . $counter; } /** * Generate a slug from heading text. * * @param string $text Heading text. * * @return string */ private static function slugify( string $text ): string { $normalized = strtolower( $text ); $normalized = preg_replace( '/[^a-z0-9]+/i', '-', $normalized ); $normalized = is_string( $normalized ) ? $normalized : ''; $normalized = trim( $normalized, '-' ); return $normalized; } }