Skip to content

Commit f5db018

Browse files
committed
Add TOC class for auto-generating table of contents from article headings
Fixes #75
1 parent 0ca53c8 commit f5db018

1 file changed

Lines changed: 215 additions & 0 deletions

File tree

includes/frontend/class-toc.php

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
<?php
2+
/**
3+
* Table of Contents module
4+
*
5+
* @package WebberZone\Knowledge_Base
6+
*/
7+
8+
namespace WebberZone\Knowledge_Base\Frontend;
9+
10+
use WebberZone\Knowledge_Base\Util\Hook_Registry;
11+
12+
if ( ! defined( 'WPINC' ) ) {
13+
die;
14+
}
15+
16+
/**
17+
* TOC Class.
18+
*
19+
* @since 3.0.0
20+
*/
21+
class TOC {
22+
23+
/**
24+
* Whether the TOC has been injected on this page load.
25+
*
26+
* @since 3.0.0
27+
* @var bool
28+
*/
29+
private static bool $injected = false;
30+
31+
/**
32+
* Constructor.
33+
*
34+
* @since 3.0.0
35+
*/
36+
public function __construct() {
37+
if ( \wzkb_get_option( 'show_toc' ) ) {
38+
Hook_Registry::add_filter( 'the_content', array( $this, 'inject_toc' ) );
39+
}
40+
}
41+
42+
/**
43+
* Filter callback: prepend TOC to article content.
44+
*
45+
* @since 3.0.0
46+
*
47+
* @param string $content Post content.
48+
* @return string Content with TOC prepended, or original content unchanged.
49+
*/
50+
public function inject_toc( string $content ): string {
51+
if ( self::$injected || ! is_singular( 'wz_knowledgebase' ) || ! in_the_loop() || ! is_main_query() ) {
52+
return $content;
53+
}
54+
55+
$result = self::process_content( $content );
56+
57+
if ( empty( $result['toc'] ) ) {
58+
return $content;
59+
}
60+
61+
self::$injected = true;
62+
63+
return $result['toc'] . $result['content'];
64+
}
65+
66+
/**
67+
* Parse headings in content, add anchor IDs, and build TOC HTML.
68+
*
69+
* @since 3.0.0
70+
*
71+
* @param string $content Post content.
72+
* @param array $args {
73+
* Optional arguments.
74+
*
75+
* @type int $heading_depth Max heading level to include (2–6). Default from setting.
76+
* @type int $min_headings Minimum headings required to show TOC. Default from setting.
77+
* @type string $title TOC title text. Default from setting.
78+
* }
79+
* @return array {
80+
* @type string $toc TOC HTML, or empty string if below minimum headings.
81+
* @type string $content Content with anchor IDs added to headings, or original if TOC suppressed.
82+
* }
83+
*/
84+
public static function process_content( string $content, array $args = array() ): array {
85+
$defaults = array(
86+
'heading_depth' => (int) \wzkb_get_option( 'toc_heading_depth', 4 ),
87+
'min_headings' => (int) \wzkb_get_option( 'toc_min_headings', 3 ),
88+
'title' => (string) \wzkb_get_option( 'toc_title', __( 'Table of Contents', 'knowledgebase' ) ),
89+
);
90+
$args = wp_parse_args( $args, $defaults );
91+
92+
$max_level = max( 2, min( 6, (int) $args['heading_depth'] ) );
93+
$levels = implode( '', range( 2, $max_level ) );
94+
$pattern = '/<h([' . $levels . '])(\s[^>]*)?>(.*?)<\/h\1>/si';
95+
96+
$headings = array();
97+
$used_ids = array();
98+
99+
$modified_content = preg_replace_callback(
100+
$pattern,
101+
static function ( $found ) use ( &$headings, &$used_ids ) {
102+
$level = (int) $found[1];
103+
$attrs = isset( $found[2] ) ? $found[2] : '';
104+
$inner = $found[3];
105+
$text = wp_strip_all_tags( $inner );
106+
107+
if ( preg_match( '/\bid=["\']([^"\']+)["\']/', $attrs, $id_match ) ) {
108+
$id = $id_match[1];
109+
$new_tag = '<h' . $level . $attrs . '>' . $inner . '</h' . $level . '>';
110+
} else {
111+
$id = sanitize_title( $text );
112+
$base_id = $id;
113+
$suffix = 1;
114+
while ( in_array( $id, $used_ids, true ) ) {
115+
$id = $base_id . '-' . $suffix;
116+
++$suffix;
117+
}
118+
$new_tag = '<h' . $level . $attrs . ' id="' . esc_attr( $id ) . '">' . $inner . '</h' . $level . '>';
119+
}
120+
121+
$used_ids[] = $id;
122+
$headings[] = array(
123+
'level' => $level,
124+
'text' => $text,
125+
'id' => $id,
126+
);
127+
128+
return $new_tag;
129+
},
130+
$content
131+
);
132+
133+
$min_headings = max( 1, (int) $args['min_headings'] );
134+
if ( count( $headings ) < $min_headings ) {
135+
return array(
136+
'toc' => '',
137+
'content' => $content,
138+
);
139+
}
140+
141+
return array(
142+
'toc' => self::build_toc_html( $headings, $args ),
143+
'content' => (string) $modified_content,
144+
);
145+
}
146+
147+
/**
148+
* Build TOC HTML from a headings array.
149+
*
150+
* @since 3.0.0
151+
*
152+
* @param array $headings Array of heading entries, each with 'level', 'text', 'id'.
153+
* @param array $args Arguments including 'title'.
154+
* @return string TOC HTML.
155+
*/
156+
private static function build_toc_html( array $headings, array $args ): string {
157+
if ( empty( $headings ) ) {
158+
return '';
159+
}
160+
161+
$title = isset( $args['title'] ) ? (string) $args['title'] : '';
162+
$output = '<nav class="wzkb-toc" aria-label="' . esc_attr__( 'Table of Contents', 'knowledgebase' ) . '">';
163+
164+
if ( '' !== $title ) {
165+
$output .= '<p class="wzkb-toc-title">' . esc_html( $title ) . '</p>';
166+
}
167+
168+
$stack = array();
169+
$output .= '<ul class="wzkb-toc-list">';
170+
171+
foreach ( $headings as $heading ) {
172+
$level = (int) $heading['level'];
173+
$link = '<a href="#' . esc_attr( $heading['id'] ) . '">' . esc_html( $heading['text'] ) . '</a>';
174+
175+
if ( empty( $stack ) ) {
176+
$stack[] = $level;
177+
} elseif ( $level > end( $stack ) ) {
178+
$output .= '<ul>';
179+
$stack[] = $level;
180+
} elseif ( end( $stack ) === $level ) {
181+
$output .= '</li>';
182+
} else {
183+
while ( ! empty( $stack ) && end( $stack ) > $level ) {
184+
$output .= '</li></ul>';
185+
array_pop( $stack );
186+
}
187+
if ( ! empty( $stack ) && end( $stack ) === $level ) {
188+
$output .= '</li>';
189+
} else {
190+
$stack[] = $level;
191+
}
192+
}
193+
194+
$output .= '<li>' . $link;
195+
}
196+
197+
while ( ! empty( $stack ) ) {
198+
$output .= '</li></ul>';
199+
array_pop( $stack );
200+
}
201+
202+
$output .= '</nav>';
203+
204+
/**
205+
* Filters the TOC HTML output.
206+
*
207+
* @since 3.0.0
208+
*
209+
* @param string $output TOC HTML.
210+
* @param array $headings Headings array.
211+
* @param array $args Arguments.
212+
*/
213+
return apply_filters( 'wzkb_toc', $output, $headings, $args );
214+
}
215+
}

0 commit comments

Comments
 (0)