PluginProbe
WP EXtra – One Click Optimize / trunk
WP EXtra – One Click Optimize vtrunk
8.7.1 8.6.8 8.7.0 trunk 5.9 8.0 8.5.0 8.5.4 8.5.5 8.6.0 8.6.1 8.6.2 8.6.3 8.6.5
wp-extra / src / Modules / Common / TOC / Parser.php

Parser.php in WP EXtra – One Click Optimize trunk, at src/Modules/Common/TOC/Parser.php

221 lines 5.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * TOC Parser
4 *
5 * Lightweight, high-performance heading extractor and anchor generator using WordPress core functions.
6 *
7 * @package WPEXtra\Modules\Common\TOC
8 */
9
10 namespace WPEXtra\Modules\Common\TOC;
11
12 use WPEXtra\Helper;
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 class Parser {
19
20 /**
21 * Unique anchor IDs tracked per request to avoid collisions.
22 *
23 * @var array<string, int>
24 */
25 private $anchor_counts = array();
26
27 /**
28 * Parse content and extract TOC headings.
29 *
30 * @param string $content Raw HTML content.
31 * @param array $custom_options Optional runtime option overrides.
32 * @return array{content: string, headings: array, count: int}
33 */
34 public function parse( $content, $custom_options = array() ) {
35 $this->anchor_counts = array();
36
37 if ( empty( $content ) || ! is_string( $content ) ) {
38 return array(
39 'content' => $content,
40 'headings' => array(),
41 'count' => 0,
42 );
43 }
44
45 $heading_levels = isset( $custom_options['heading_levels'] )
46 ? (array) $custom_options['heading_levels']
47 : (array) Helper::get_option( 'heading_levels', array( '1', '2', '3', '4', '5', '6' ) );
48
49 if ( empty( $heading_levels ) ) {
50 return array(
51 'content' => $content,
52 'headings' => array(),
53 'count' => 0,
54 );
55 }
56
57 $levels_pattern = implode( '', array_map( 'intval', $heading_levels ) );
58 $pattern = '/<h([' . $levels_pattern . '])([^>]*)>(.*?)<\/h\1>/is';
59
60 if ( ! preg_match_all( $pattern, $content, $matches, PREG_SET_ORDER ) ) {
61 return array(
62 'content' => $content,
63 'headings' => array(),
64 'count' => 0,
65 );
66 }
67
68 $min_start = isset( $custom_options['start'] )
69 ? (int) $custom_options['start']
70 : (int) Helper::get_option( 'start', 4 );
71
72 $exclude_pattern = isset( $custom_options['exclude'] )
73 ? (string) $custom_options['exclude']
74 : (string) Helper::get_option( 'exclude', '' );
75
76 $exclude_classes = isset( $custom_options['exclude_by_class'] )
77 ? (string) $custom_options['exclude_by_class']
78 : (string) Helper::get_option( 'exclude_by_class', '' );
79
80 $exclude_class_list = array_filter( array_map( 'trim', explode( ',', $exclude_classes ) ) );
81 $exclude_patterns = array_filter( array_map( 'trim', explode( '|', $exclude_pattern ) ) );
82
83 $headings_flat = array();
84 $replacements = array();
85
86 foreach ( $matches as $index => $match ) {
87 $full_tag = $match[0];
88 $level = (int) $match[1];
89 $attributes = $match[2];
90 $inner_html = $match[3];
91
92 $clean_text = trim( wp_strip_all_tags( $inner_html ) );
93 if ( '' === $clean_text ) {
94 continue;
95 }
96
97 // Check exclude by class
98 if ( ! empty( $exclude_class_list ) && preg_match( '/class=["\']([^"\']+)["\']/i', $attributes, $class_match ) ) {
99 $tag_classes = preg_split( '/\s+/', $class_match[1] );
100 if ( array_intersect( $tag_classes, $exclude_class_list ) ) {
101 continue;
102 }
103 }
104
105 // Check exclude by wildcard pattern
106 if ( ! empty( $exclude_patterns ) && $this->is_excluded_by_pattern( $clean_text, $exclude_patterns ) ) {
107 continue;
108 }
109
110 // Extract or generate anchor ID
111 $anchor_id = '';
112 if ( preg_match( '/id=["\']([^"\']+)["\']/i', $attributes, $id_match ) ) {
113 $anchor_id = sanitize_title( $id_match[1] );
114 }
115
116 if ( empty( $anchor_id ) ) {
117 $anchor_id = $this->generate_unique_anchor( $clean_text );
118 $new_attrs = ' id="' . esc_attr( $anchor_id ) . '"' . $attributes;
119 $new_tag = '<h' . $level . $new_attrs . '>' . $inner_html . '</h' . $level . '>';
120 $replacements[ $full_tag ] = $new_tag;
121 }
122
123 $headings_flat[] = array(
124 'id' => $anchor_id,
125 'title' => $clean_text,
126 'inner_html' => $inner_html,
127 'level' => $level,
128 );
129 }
130
131 $total_count = count( $headings_flat );
132 if ( $total_count < $min_start ) {
133 return array(
134 'content' => $content,
135 'headings' => array(),
136 'count' => $total_count,
137 );
138 }
139
140 // Apply ID replacements to content
141 if ( ! empty( $replacements ) ) {
142 $content = str_replace( array_keys( $replacements ), array_values( $replacements ), $content );
143 }
144
145 $tree = $this->build_hierarchy_tree( $headings_flat );
146
147 return array(
148 'content' => $content,
149 'headings' => $tree,
150 'count' => $total_count,
151 );
152 }
153
154 /**
155 * Generate a unique anchor ID from heading text.
156 *
157 * @param string $text
158 * @return string
159 */
160 private function generate_unique_anchor( $text ) {
161 $slug = sanitize_title( $text );
162 if ( empty( $slug ) ) {
163 $slug = 'heading';
164 }
165
166 if ( ! isset( $this->anchor_counts[ $slug ] ) ) {
167 $this->anchor_counts[ $slug ] = 1;
168 return $slug;
169 }
170
171 $this->anchor_counts[ $slug ]++;
172 return $slug . '-' . $this->anchor_counts[ $slug ];
173 }
174
175 /**
176 * Check if heading matches any exclusion wildcard pattern.
177 *
178 * @param string $text
179 * @param array $patterns
180 * @return bool
181 */
182 private function is_excluded_by_pattern( $text, array $patterns ) {
183 foreach ( $patterns as $pattern ) {
184 $regex = '/^' . str_replace( '\*', '.*', preg_quote( $pattern, '/' ) ) . '$/iu';
185 if ( preg_match( $regex, $text ) ) {
186 return true;
187 }
188 }
189 return false;
190 }
191
192 /**
193 * Convert flat heading list into a nested hierarchical tree.
194 *
195 * @param array $headings
196 * @return array
197 */
198 private function build_hierarchy_tree( array $headings ) {
199 $tree = array();
200 $stack = array();
201
202 foreach ( $headings as &$item ) {
203 $item['children'] = array();
204
205 while ( ! empty( $stack ) && end( $stack )['level'] >= $item['level'] ) {
206 array_pop( $stack );
207 }
208
209 if ( empty( $stack ) ) {
210 $tree[] = &$item;
211 } else {
212 $stack[ count( $stack ) - 1 ]['children'][] = &$item;
213 }
214 $stack[] = &$item;
215 }
216 unset( $item );
217
218 return $tree;
219 }
220 }
221