PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 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 All 48 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 2.7.0, at includes/editor/bricks/class-toc-element.php

348 lines 12.2 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 // Built client-side, so it bypasses Schema_Graph and the block filter
192 // and kept publishing an ItemList with Schema switched off (#688).
193 $schema = !empty($settings['outputSchema'])
194 && (!class_exists('ThinkRank\\Frontend\\Schema_Graph')
195 || \ThinkRank\Frontend\Schema_Graph::output_allowed());
196
197 $this->render_builder_script($uid, $max_level, $schema);
198 }
199
200 /**
201 * The dependency-free script that fills the list in.
202 *
203 * It scans the Bricks content wrapper for h2..maxLevel, gives each heading
204 * an id if it has none, and appends one linked item per heading. With
205 * schema on it then publishes the same anchors as SiteNavigationElement, so
206 * the list and the structured data cannot drift apart.
207 *
208 * The element hides itself when the page has no headings — an empty
209 * "Table of Contents" box is worse than none.
210 *
211 * @param string $uid This element instance's Bricks id.
212 * @param int $max_level Deepest heading level to include.
213 * @param bool $schema Whether to publish SiteNavigationElement.
214 * @return void
215 */
216 private function render_builder_script(string $uid, int $max_level, bool $schema): void {
217 $selector = implode(',', array_map(
218 static fn($level) => 'h' . $level,
219 range(2, max(2, $max_level))
220 ));
221
222 ?>
223 <script>
224 ( function() {
225 var init = function() {
226 var widget = document.querySelector(
227 '[data-thinkrank-toc="' + <?php echo wp_json_encode($uid); ?> + '"]'
228 );
229 if ( ! widget ) {
230 return;
231 }
232 var list = widget.querySelector( '.thinkrank-toc__list' );
233 if ( ! list ) {
234 return;
235 }
236 // Bricks' own content wrapper, so site chrome is not listed.
237 // Widened deliberately rather than taking the nearest match:
238 // closest() returns the FIRST ancestor matching any selector in
239 // the list, and on a Bricks page that is the container the
240 // element was dropped into — which would list only the headings
241 // sharing that container and silently miss the rest of the
242 // article. #brx-content is the wrapper actually meant here.
243 var scope = document.getElementById( 'brx-content' )
244 || widget.closest( 'main, article' )
245 || document.body;
246 var used = {};
247 var entries = [];
248 scope.querySelectorAll( <?php echo wp_json_encode($selector); ?> ).forEach( function( el ) {
249 var text = el.textContent.trim();
250 if ( widget.contains( el ) || ! text ) {
251 return;
252 }
253 if ( ! el.id ) {
254 var base = text.toLowerCase()
255 .normalize( 'NFKD' ).replace( /[̀-ͯ]/g, '' )
256 .replace( /[^a-z0-9\s-]/g, '' ).trim()
257 .replace( /[\s-]+/g, '-' ) || 'section';
258 var id = base, n = 2;
259 while ( used[ id ] || document.getElementById( id ) ) {
260 id = base + '-' + ( n++ );
261 }
262 el.id = id;
263 }
264 used[ el.id ] = true;
265 var li = document.createElement( 'li' );
266 li.className = 'thinkrank-toc__item thinkrank-toc__item--level-' + el.tagName.charAt( 1 );
267 var a = document.createElement( 'a' );
268 a.href = '#' + el.id;
269 a.textContent = text;
270 li.appendChild( a );
271 list.appendChild( li );
272 entries.push( { name: text, id: el.id } );
273 } );
274 if ( ! entries.length ) {
275 widget.hidden = true;
276 return;
277 }
278 <?php if ($schema) : ?>
279 // The canonical, not location.href: a page reached with a
280 // tracking query would otherwise publish anchor URLs carrying
281 // it, so the same section gets a different URL per visitor.
282 var canonical = document.querySelector( 'link[rel="canonical"]' );
283 var base = canonical && canonical.href
284 ? canonical.href.split( '#' )[ 0 ]
285 : window.location.origin + window.location.pathname;
286 var graph = entries.map( function( entry ) {
287 return {
288 '@type': 'SiteNavigationElement',
289 name: entry.name,
290 url: base + '#' + entry.id
291 };
292 } );
293 var tag = document.createElement( 'script' );
294 tag.type = 'application/ld+json';
295 tag.textContent = JSON.stringify( {
296 '@context': 'https://schema.org',
297 '@graph': graph
298 } );
299 widget.appendChild( tag );
300 <?php endif; ?>
301 };
302 if ( 'loading' === document.readyState ) {
303 document.addEventListener( 'DOMContentLoaded', init );
304 } else {
305 init();
306 }
307 } )();
308 </script>
309 <?php
310 }
311
312 /**
313 * Deepest heading level to list.
314 *
315 * @param array $settings Element settings.
316 * @return int
317 */
318 private static function max_level(array $settings): int {
319 $level = (string) ($settings['maxLevel'] ?? '3');
320
321 return in_array($level, ['2', '3', '4'], true) ? (int) $level : 3;
322 }
323
324 /**
325 * List marker style.
326 *
327 * @param array $settings Element settings.
328 * @return string
329 */
330 private static function list_style(array $settings): string {
331 $style = (string) ($settings['listStyle'] ?? 'disc');
332
333 return in_array($style, ['disc', 'decimal', 'none'], true) ? $style : 'disc';
334 }
335
336 /**
337 * A heading tag from the allowed set.
338 *
339 * @param array $settings Element settings.
340 * @return string
341 */
342 private static function heading_tag(array $settings): string {
343 $tag = (string) ($settings['headingTag'] ?? 'h2');
344
345 return in_array($tag, ['h2', 'h3', 'h4', 'p'], true) ? $tag : 'h2';
346 }
347 }
348