PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 1.11.0 All 47 releases
thinkrank / includes / editor / bricks / class-toc-element.php

class-toc-element.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/editor/bricks/class-toc-element.php

342 lines 11.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Bricks Table of Contents Element
5 *
6 * The Bricks counterpart of the thinkrank/toc Gutenberg block (#626).
7 *
8 * The block builds its list server-side because it can: a block tree holds the
9 * page's headings and their anchors as stored attributes. A Bricks page's
10 * headings are spread across `heading`, `text-basic`, rich text and nested
11 * components, most of them rendered without an `id` for a link to point at, and
12 * a content template can add more that live on another post entirely. Deriving
13 * the list from stored settings would therefore list headings that the page has
14 * no anchor for, and miss ones it does have.
15 *
16 * So the list is built from the rendered DOM, which is the only place the real
17 * answer exists — the same approach the Elementor widget and Elementor Pro's own
18 * TOC take. The script assigns ids to headings that lack them, then builds the
19 * list from what it found.
20 *
21 * SiteNavigationElement is emitted from that same pass rather than server-side.
22 * That keeps a single source of truth: every entry in the JSON-LD is an anchor
23 * that is genuinely on the page and genuinely reachable, which a server-side
24 * guess could not promise. Google renders pages before extracting structured
25 * data, so script-inserted JSON-LD is read the same as inline.
26 *
27 * @package ThinkRank
28 * @subpackage Editor\Bricks
29 * @since 2.3.1
30 */
31
32 declare(strict_types=1);
33
34 namespace ThinkRank\Editor\Bricks;
35
36 // Prevent direct access
37 if (!defined('ABSPATH')) {
38 exit;
39 }
40
41 /**
42 * TOC Element.
43 *
44 * @since 2.3.1
45 */
46 class TOC_Element extends \Bricks\Element {
47
48 /**
49 * Builder category.
50 *
51 * @var string
52 */
53 public $category = 'thinkrank';
54
55 /**
56 * Element name, matching the Elementor widget's.
57 *
58 * @var string
59 */
60 public $name = 'thinkrank-toc';
61
62 /**
63 * Panel icon.
64 *
65 * @var string
66 */
67 public $icon = 'ti-menu-alt';
68
69 /**
70 * Label shown in the element panel.
71 *
72 * @return string
73 */
74 public function get_label(): string {
75 return esc_html__('Table of Contents (ThinkRank)', 'thinkrank');
76 }
77
78 /**
79 * Panel search terms.
80 *
81 * @return array
82 */
83 public function get_keywords(): array {
84 return ['table of contents', 'toc', 'index', 'anchor', 'thinkrank'];
85 }
86
87 /**
88 * Load the shared block stylesheet, but only on a page using this element.
89 *
90 * @return void
91 */
92 public function enqueue_scripts(): void {
93 wp_enqueue_style('thinkrank-toc-block');
94 }
95
96 /**
97 * Controls.
98 *
99 * @return void
100 */
101 public function set_controls(): void {
102 $this->controls['heading'] = [
103 'label' => esc_html__('Title', 'thinkrank'),
104 'type' => 'text',
105 'default' => esc_html__('Table of Contents', 'thinkrank'),
106 ];
107
108 $this->controls['headingTag'] = [
109 'label' => esc_html__('Heading tag', 'thinkrank'),
110 'type' => 'select',
111 'options' => [
112 'h2' => 'H2',
113 'h3' => 'H3',
114 'h4' => 'H4',
115 'p' => esc_html__('Paragraph', 'thinkrank'),
116 ],
117 'default' => 'h2',
118 'inline' => true,
119 ];
120
121 $this->controls['maxLevel'] = [
122 'label' => esc_html__('Include headings up to', 'thinkrank'),
123 'type' => 'select',
124 'options' => ['2' => 'H2', '3' => 'H3', '4' => 'H4'],
125 'default' => '3',
126 'inline' => true,
127 ];
128
129 $this->controls['listStyle'] = [
130 'label' => esc_html__('List style', 'thinkrank'),
131 'type' => 'select',
132 'options' => [
133 'disc' => esc_html__('Bulleted', 'thinkrank'),
134 'decimal' => esc_html__('Numbered', 'thinkrank'),
135 'none' => esc_html__('Plain', 'thinkrank'),
136 ],
137 'default' => 'disc',
138 'inline' => true,
139 ];
140
141 $this->controls['outputSchema'] = [
142 'label' => esc_html__('Output navigation schema (JSON-LD)', 'thinkrank'),
143 'type' => 'checkbox',
144 'default' => true,
145 'description' => esc_html__('Adds SiteNavigationElement structured data for the listed sections.', 'thinkrank'),
146 ];
147 }
148
149 /**
150 * Render.
151 *
152 * @return void
153 */
154 public function render(): void {
155 $settings = $this->settings;
156
157 $max_level = self::max_level($settings);
158 $list_style = self::list_style($settings);
159
160 // Bricks already puts its own `brxe-<id>` on the root, and
161 // `set_attribute()` appends rather than replaces — setting an `id` here
162 // produced `id="brxe-etoc thinkrank-toc-etoc"`, one invalid id
163 // containing a space that `getElementById()` could never match. A data
164 // attribute is ours alone and collides with nothing.
165 $uid = sanitize_html_class((string) $this->id);
166
167 $this->set_attribute('_root', 'class', 'thinkrank-toc');
168 $this->set_attribute('_root', 'data-thinkrank-toc', $uid);
169
170 $output = '<div ' . $this->render_attributes('_root') . '>';
171
172 $heading = trim((string) ($settings['heading'] ?? ''));
173 if ('' !== $heading) {
174 $output .= sprintf(
175 '<%1$s class="thinkrank-toc__heading">%2$s</%1$s>',
176 esc_html(self::heading_tag($settings)),
177 esc_html($this->render_dynamic_data($heading))
178 );
179 }
180
181 $output .= sprintf(
182 '<nav aria-label="%s"><ul class="thinkrank-toc__list thinkrank-toc__list--%s"></ul></nav>',
183 esc_attr__('Table of contents', 'thinkrank'),
184 esc_attr($list_style)
185 );
186
187 $output .= '</div>';
188
189 echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
190
191 $this->render_builder_script($uid, $max_level, !empty($settings['outputSchema']));
192 }
193
194 /**
195 * The dependency-free script that fills the list in.
196 *
197 * It scans the Bricks content wrapper for h2..maxLevel, gives each heading
198 * an id if it has none, and appends one linked item per heading. With
199 * schema on it then publishes the same anchors as SiteNavigationElement, so
200 * the list and the structured data cannot drift apart.
201 *
202 * The element hides itself when the page has no headings — an empty
203 * "Table of Contents" box is worse than none.
204 *
205 * @param string $uid This element instance's Bricks id.
206 * @param int $max_level Deepest heading level to include.
207 * @param bool $schema Whether to publish SiteNavigationElement.
208 * @return void
209 */
210 private function render_builder_script(string $uid, int $max_level, bool $schema): void {
211 $selector = implode(',', array_map(
212 static fn($level) => 'h' . $level,
213 range(2, max(2, $max_level))
214 ));
215
216 ?>
217 <script>
218 ( function() {
219 var init = function() {
220 var widget = document.querySelector(
221 '[data-thinkrank-toc="' + <?php echo wp_json_encode($uid); ?> + '"]'
222 );
223 if ( ! widget ) {
224 return;
225 }
226 var list = widget.querySelector( '.thinkrank-toc__list' );
227 if ( ! list ) {
228 return;
229 }
230 // Bricks' own content wrapper, so site chrome is not listed.
231 // Widened deliberately rather than taking the nearest match:
232 // closest() returns the FIRST ancestor matching any selector in
233 // the list, and on a Bricks page that is the container the
234 // element was dropped into — which would list only the headings
235 // sharing that container and silently miss the rest of the
236 // article. #brx-content is the wrapper actually meant here.
237 var scope = document.getElementById( 'brx-content' )
238 || widget.closest( 'main, article' )
239 || document.body;
240 var used = {};
241 var entries = [];
242 scope.querySelectorAll( <?php echo wp_json_encode($selector); ?> ).forEach( function( el ) {
243 var text = el.textContent.trim();
244 if ( widget.contains( el ) || ! text ) {
245 return;
246 }
247 if ( ! el.id ) {
248 var base = text.toLowerCase()
249 .normalize( 'NFKD' ).replace( /[̀-ͯ]/g, '' )
250 .replace( /[^a-z0-9\s-]/g, '' ).trim()
251 .replace( /[\s-]+/g, '-' ) || 'section';
252 var id = base, n = 2;
253 while ( used[ id ] || document.getElementById( id ) ) {
254 id = base + '-' + ( n++ );
255 }
256 el.id = id;
257 }
258 used[ el.id ] = true;
259 var li = document.createElement( 'li' );
260 li.className = 'thinkrank-toc__item thinkrank-toc__item--level-' + el.tagName.charAt( 1 );
261 var a = document.createElement( 'a' );
262 a.href = '#' + el.id;
263 a.textContent = text;
264 li.appendChild( a );
265 list.appendChild( li );
266 entries.push( { name: text, id: el.id } );
267 } );
268 if ( ! entries.length ) {
269 widget.hidden = true;
270 return;
271 }
272 <?php if ($schema) : ?>
273 // The canonical, not location.href: a page reached with a
274 // tracking query would otherwise publish anchor URLs carrying
275 // it, so the same section gets a different URL per visitor.
276 var canonical = document.querySelector( 'link[rel="canonical"]' );
277 var base = canonical && canonical.href
278 ? canonical.href.split( '#' )[ 0 ]
279 : window.location.origin + window.location.pathname;
280 var graph = entries.map( function( entry ) {
281 return {
282 '@type': 'SiteNavigationElement',
283 name: entry.name,
284 url: base + '#' + entry.id
285 };
286 } );
287 var tag = document.createElement( 'script' );
288 tag.type = 'application/ld+json';
289 tag.textContent = JSON.stringify( {
290 '@context': 'https://schema.org',
291 '@graph': graph
292 } );
293 widget.appendChild( tag );
294 <?php endif; ?>
295 };
296 if ( 'loading' === document.readyState ) {
297 document.addEventListener( 'DOMContentLoaded', init );
298 } else {
299 init();
300 }
301 } )();
302 </script>
303 <?php
304 }
305
306 /**
307 * Deepest heading level to list.
308 *
309 * @param array $settings Element settings.
310 * @return int
311 */
312 private static function max_level(array $settings): int {
313 $level = (string) ($settings['maxLevel'] ?? '3');
314
315 return in_array($level, ['2', '3', '4'], true) ? (int) $level : 3;
316 }
317
318 /**
319 * List marker style.
320 *
321 * @param array $settings Element settings.
322 * @return string
323 */
324 private static function list_style(array $settings): string {
325 $style = (string) ($settings['listStyle'] ?? 'disc');
326
327 return in_array($style, ['disc', 'decimal', 'none'], true) ? $style : 'disc';
328 }
329
330 /**
331 * A heading tag from the allowed set.
332 *
333 * @param array $settings Element settings.
334 * @return string
335 */
336 private static function heading_tag(array $settings): string {
337 $tag = (string) ($settings['headingTag'] ?? 'h2');
338
339 return in_array($tag, ['h2', 'h3', 'h4', 'p'], true) ? $tag : 'h2';
340 }
341 }
342