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.php

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

353 lines 9.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WP Extra Table of Contents (TOC) Master Module
4 *
5 * Coordinates parsing, rendering, asset enqueueing, placement, shortcodes, and integrations.
6 *
7 * @package WPEXtra\Modules\Common
8 */
9
10 namespace WPEXtra\Modules\Common;
11
12 use WPEXtra\Base;
13 use WPEXtra\Helper;
14 use WPEXtra\Modules\Common\TOC\Parser;
15 use WPEXtra\Modules\Common\TOC\Renderer;
16 use WPEXtra\Modules\Common\TOC\Assets;
17 use WPEXtra\Modules\Common\TOC\Schema;
18 use WPEXtra\Modules\Common\TOC\Compatibility;
19
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit;
22 }
23
24 class TOC extends Base {
25
26 /**
27 * Singleton instance.
28 *
29 * @var self|null
30 */
31 private static $instance = null;
32
33 /**
34 * Headings tree cached for current request sticky drawer & schema.
35 *
36 * @var array
37 */
38 private $current_headings = array();
39
40 /**
41 * Flag to prevent infinite loop or double execution in the_content.
42 *
43 * @var bool
44 */
45 private $is_processing = false;
46
47 /**
48 * Active parser instance.
49 *
50 * @var Parser
51 */
52 private $parser;
53
54 /**
55 * Get singleton instance.
56 *
57 * @return self
58 */
59 public static function instance() {
60 if ( null === self::$instance ) {
61 self::$instance = new self();
62 }
63 return self::$instance;
64 }
65
66 /**
67 * Constructor.
68 */
69 public function __construct() {
70 parent::__construct();
71 self::$instance = $this;
72 $this->parser = new Parser();
73 $this->init();
74 }
75
76 /**
77 * Initialize module.
78 */
79 private function init() {
80 // Init submodules
81 Assets::init();
82 Schema::init();
83 Compatibility::init();
84
85 // Content filters
86 add_filter( 'the_content', array( $this, 'filter_content' ), 100 );
87 add_filter( 'ux_post_content', array( $this, 'filter_content' ), 100 );
88
89 // Shortcodes
90 add_shortcode( 'toc', array( $this, 'shortcode_toc' ) );
91 add_shortcode( 'wp-toc', array( $this, 'shortcode_toc' ) );
92
93 // Footer sticky drawer
94 add_action( 'wp_footer', array( $this, 'render_sticky_footer' ), 15 );
95
96 // Backwards compatibility aliases
97 $this->register_backward_aliases();
98 }
99
100 /**
101 * Main the_content filter handler.
102 *
103 * @param string $content
104 * @return string
105 */
106 public function filter_content( $content ) {
107 if ( empty( $content ) || ! is_string( $content ) || $this->is_processing ) {
108 return $content;
109 }
110
111 if ( is_admin() || is_feed() || is_robots() || is_trackback() ) {
112 return $content;
113 }
114
115 if ( ! is_singular() && ! is_page() && ! is_single() && ! is_front_page() ) {
116 return $content;
117 }
118
119 if ( ! $this->is_eligible_post() ) {
120 return $content;
121 }
122
123 return $this->process_content( $content );
124 }
125
126 /**
127 * Check if current post is eligible for auto-insert.
128 *
129 * @return bool
130 */
131 private function is_eligible_post() {
132 $post = get_post();
133 if ( ! $post ) {
134 return false;
135 }
136
137 // Check homepage inclusion
138 if ( is_front_page() && ! Helper::get_option( 'include_homepage', false ) ) {
139 return false;
140 }
141
142 // Check post type auto insertion
143 $auto_insert_types = (array) Helper::get_option( 'auto_insert_post_types', array( 'post' ) );
144 if ( ! in_array( $post->post_type, $auto_insert_types, true ) ) {
145 return false;
146 }
147
148 // Check excluded post IDs
149 $exclude_posts = Helper::get_option( 'exclude_post_ids', '' );
150 if ( ! empty( $exclude_posts ) ) {
151 $exclude_ids = array_map( 'intval', array_map( 'trim', explode( ',', $exclude_posts ) ) );
152 if ( in_array( (int) $post->ID, $exclude_ids, true ) ) {
153 return false;
154 }
155 }
156
157 // Check excluded category IDs
158 $exclude_cats = Helper::get_option( 'exclude_category_ids', '' );
159 if ( ! empty( $exclude_cats ) ) {
160 $exclude_cat_ids = array_map( 'intval', array_map( 'trim', explode( ',', $exclude_cats ) ) );
161 $post_cats = wp_get_post_categories( $post->ID );
162 if ( array_intersect( $post_cats, $exclude_cat_ids ) ) {
163 return false;
164 }
165 }
166
167 return true;
168 }
169
170 /**
171 * Process content: extract headings, inject IDs, and place TOC box.
172 *
173 * @param string $content
174 * @param array $custom_options
175 * @return string
176 */
177 public function process_content( $content, $custom_options = array() ) {
178 $this->is_processing = true;
179
180 $parsed = $this->parser->parse( $content, $custom_options );
181 $content = $parsed['content'];
182 $headings = $parsed['headings'];
183
184 if ( ! empty( $headings ) ) {
185 $this->current_headings = $headings;
186 Schema::set_headings( $headings );
187
188 // Don't auto insert if content already has shortcode
189 $has_shortcode = has_shortcode( $content, 'toc' ) || has_shortcode( $content, 'wp-toc' );
190
191 if ( ! $has_shortcode ) {
192 $toc_html = Renderer::render( $headings, $custom_options );
193 $content = $this->insert_toc( $content, $toc_html );
194 }
195 }
196
197 $this->is_processing = false;
198 return $content;
199 }
200
201 /**
202 * Insert TOC HTML into content based on position option.
203 *
204 * @param string $content
205 * @param string $toc_html
206 * @return string
207 */
208 private function insert_toc( $content, $toc_html ) {
209 if ( empty( $toc_html ) ) {
210 return $content;
211 }
212
213 $position = Helper::get_option( 'position', 'before' );
214
215 switch ( $position ) {
216 case 'top':
217 return $toc_html . $content;
218
219 case 'bottom':
220 return $content . $toc_html;
221
222 case 'after':
223 // After first heading
224 if ( preg_match( '/(<h[1-6][^>]*>.*?<\/h[1-6]>)/is', $content, $match, PREG_OFFSET_CAPTURE ) ) {
225 $pos = $match[0][1] + strlen( $match[0][0] );
226 return substr( $content, 0, $pos ) . $toc_html . substr( $content, $pos );
227 }
228 return $toc_html . $content;
229
230 case 'afterpara':
231 // After first paragraph
232 if ( preg_match( '/(<\/p>)/i', $content, $match, PREG_OFFSET_CAPTURE ) ) {
233 $pos = $match[0][1] + strlen( $match[0][0] );
234 return substr( $content, 0, $pos ) . $toc_html . substr( $content, $pos );
235 }
236 return $toc_html . $content;
237
238 case 'afterimg':
239 // After first image or figure
240 if ( preg_match( '/(<\/figure>|<img[^>]*>)/i', $content, $match, PREG_OFFSET_CAPTURE ) ) {
241 $pos = $match[0][1] + strlen( $match[0][0] );
242 return substr( $content, 0, $pos ) . $toc_html . substr( $content, $pos );
243 }
244 return $toc_html . $content;
245
246 case 'before':
247 default:
248 // Before first heading
249 if ( preg_match( '/(<h[1-6][^>]*>)/is', $content, $match, PREG_OFFSET_CAPTURE ) ) {
250 $pos = $match[0][1];
251 return substr( $content, 0, $pos ) . $toc_html . substr( $content, $pos );
252 }
253 return $toc_html . $content;
254 }
255 }
256
257 /**
258 * Handle [toc] shortcode execution.
259 *
260 * @param array $atts
261 * @param string $content
262 * @return string
263 */
264 public function shortcode_toc( $atts = array(), $content = '' ) {
265 $atts = shortcode_atts(
266 array(
267 'heading_levels' => '',
268 'counter' => '',
269 'subheading_collapse_mode' => '',
270 'heading_text' => '',
271 'start' => '',
272 'exclude' => '',
273 'exclude_by_class' => '',
274 ),
275 $atts,
276 'toc'
277 );
278
279 $custom_options = array();
280 if ( ! empty( $atts['heading_levels'] ) ) {
281 $custom_options['heading_levels'] = explode( ',', $atts['heading_levels'] );
282 }
283 if ( ! empty( $atts['counter'] ) ) {
284 $custom_options['counter'] = $atts['counter'];
285 }
286 if ( ! empty( $atts['subheading_collapse_mode'] ) ) {
287 $custom_options['subheading_collapse_mode'] = $atts['subheading_collapse_mode'];
288 }
289 if ( ! empty( $atts['heading_text'] ) ) {
290 $custom_options['heading_text'] = $atts['heading_text'];
291 }
292 if ( ! empty( $atts['start'] ) ) {
293 $custom_options['start'] = intval( $atts['start'] );
294 }
295 if ( ! empty( $atts['exclude'] ) ) {
296 $custom_options['exclude'] = $atts['exclude'];
297 }
298 if ( ! empty( $atts['exclude_by_class'] ) ) {
299 $custom_options['exclude_by_class'] = $atts['exclude_by_class'];
300 }
301
302 Assets::enqueue_toc_assets();
303
304 if ( ! empty( $this->current_headings ) ) {
305 return Renderer::render( $this->current_headings, $custom_options );
306 }
307
308 global $post;
309 if ( $post && ! empty( $post->post_content ) ) {
310 $parsed = $this->parser->parse( $post->post_content, $custom_options );
311 if ( ! empty( $parsed['headings'] ) ) {
312 $this->current_headings = $parsed['headings'];
313 Schema::set_headings( $parsed['headings'] );
314 return Renderer::render( $parsed['headings'], $custom_options );
315 }
316 }
317
318 return '';
319 }
320
321 /**
322 * Render sticky drawer & FAB floating button in wp_footer.
323 */
324 public function render_sticky_footer() {
325 if ( empty( $this->current_headings ) || is_admin() || is_feed() ) {
326 return;
327 }
328
329 echo Renderer::render_sticky_footer( $this->current_headings ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
330 }
331
332 /**
333 * Register backward compatibility aliases for third-party integrations.
334 */
335 private function register_backward_aliases() {
336 if ( ! class_exists( 'wpTOC', false ) ) {
337 class_alias( __CLASS__, 'wpTOC' );
338 }
339 if ( ! class_exists( 'wpTOC_Renderer', false ) ) {
340 class_alias( 'WPEXtra\Modules\Common\TOC\Renderer', 'wpTOC_Renderer' );
341 }
342 if ( ! class_exists( 'wpTOC_Assets', false ) ) {
343 class_alias( 'WPEXtra\Modules\Common\TOC\Assets', 'wpTOC_Assets' );
344 }
345 if ( ! class_exists( 'wpTOC_Schema', false ) ) {
346 class_alias( 'WPEXtra\Modules\Common\TOC\Schema', 'wpTOC_Schema' );
347 }
348 if ( ! class_exists( 'wpTOC_Compatibility', false ) ) {
349 class_alias( 'WPEXtra\Modules\Common\TOC\Compatibility', 'wpTOC_Compatibility' );
350 }
351 }
352 }
353