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 / beaver / toc / toc.php

toc.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/beaver/toc/toc.php

320 lines 12.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Beaver Builder Table of Contents Module
5 *
6 * The Beaver counterpart of the thinkrank/toc Gutenberg block, the Elementor
7 * TOC widget and the Bricks TOC element: a heading index built client-side,
8 * optionally publishing SiteNavigationElement JSON-LD (#662).
9 *
10 * The list is built in the browser rather than server-side for the same reason
11 * it is in every other build of this widget: the headings it indexes are
12 * rendered by other modules, and on a Beaver Builder page they may not even be
13 * in this layout — a Themer header or a shortcode can contribute them. Reading
14 * the DOM after paint is the only place all of them exist together.
15 *
16 * @package ThinkRank
17 * @subpackage Editor\Beaver
18 * @since 2.5.0
19 */
20
21 declare(strict_types=1);
22
23 // Prevent direct access
24 if (!defined('ABSPATH')) {
25 exit;
26 }
27
28 /**
29 * Table of Contents Module.
30 *
31 * @since 2.5.0
32 */
33 class ThinkRank_Beaver_TOC_Module extends FLBuilderModule {
34
35 /**
36 * The module slug, and the settings `type` its stored nodes carry.
37 */
38 public const SLUG = 'thinkrank-toc';
39
40 /**
41 * Constructor.
42 */
43 public function __construct() {
44 parent::__construct([
45 'name' => __('Table of Contents (ThinkRank)', 'thinkrank'),
46 'description' => __('An automatic index of the page headings, with navigation schema.', 'thinkrank'),
47 'category' => __('ThinkRank', 'thinkrank'),
48 'slug' => self::SLUG,
49 'dir' => THINKRANK_PLUGIN_DIR . 'includes/editor/beaver/toc/',
50 'url' => THINKRANK_PLUGIN_URL . 'includes/editor/beaver/toc/',
51 'partial_refresh' => true,
52 ]);
53 }
54
55 /**
56 * Render the shell, then the script that fills it.
57 *
58 * @since 2.5.0
59 * @param object|array $settings Module settings.
60 * @return void
61 */
62 public function render_content($settings): void {
63 $settings = ThinkRank_Beaver_FAQ_Module::to_array($settings);
64
65 wp_enqueue_style('thinkrank-toc-block');
66
67 $max_level = self::max_level($settings);
68 $list_style = self::list_style($settings);
69
70 // Beaver Builder already owns the module's own id/classes on its
71 // wrapper, so the script addresses this instance through a data
72 // attribute of ours instead of competing for the id. `$this->node` is
73 // the layout node id, unique per module instance on the page.
74 $uid = sanitize_html_class((string) $this->node);
75
76 $output = '<div class="thinkrank-toc" data-thinkrank-toc="' . esc_attr($uid) . '">';
77
78 $heading = trim((string) ($settings['heading'] ?? ''));
79 if ('' !== $heading) {
80 $output .= sprintf(
81 '<%1$s class="thinkrank-toc__heading">%2$s</%1$s>',
82 esc_html(self::heading_tag($settings)),
83 esc_html($heading)
84 );
85 }
86
87 $output .= sprintf(
88 '<nav aria-label="%s"><ul class="thinkrank-toc__list thinkrank-toc__list--%s"></ul></nav>',
89 esc_attr__('Table of contents', 'thinkrank'),
90 esc_attr($list_style)
91 );
92
93 $output .= '</div>';
94
95 echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
96
97 // The TOC schema is built client-side, so it bypasses both Schema_Graph
98 // and the block filter and kept publishing an ItemList with Schema
99 // switched off (#688).
100 $schema = !empty($settings['output_schema'])
101 && (!class_exists('ThinkRank\\Frontend\\Schema_Graph')
102 || \ThinkRank\Frontend\Schema_Graph::output_allowed());
103
104 $this->render_builder_script($uid, $max_level, $schema);
105 }
106
107 /**
108 * The dependency-free script that fills the list in.
109 *
110 * It scans Beaver Builder's content wrapper for h2..maxLevel, gives each
111 * heading an id if it has none, and appends one linked item per heading.
112 * With schema on it then publishes the same anchors as
113 * SiteNavigationElement, so the list and the structured data cannot drift.
114 *
115 * The module hides itself when the page has no headings — an empty "Table
116 * of Contents" box is worse than none.
117 *
118 * @param string $uid This module instance's layout node id.
119 * @param int $max_level Deepest heading level to include.
120 * @param bool $schema Whether to publish SiteNavigationElement.
121 * @return void
122 */
123 private function render_builder_script(string $uid, int $max_level, bool $schema): void {
124 $selector = implode(',', array_map(
125 static fn($level) => 'h' . $level,
126 range(2, max(2, $max_level))
127 ));
128
129 ?>
130 <script>
131 ( function() {
132 var init = function() {
133 var widget = document.querySelector(
134 '[data-thinkrank-toc="' + <?php echo wp_json_encode($uid); ?> + '"]'
135 );
136 if ( ! widget ) {
137 return;
138 }
139 var list = widget.querySelector( '.thinkrank-toc__list' );
140 if ( ! list ) {
141 return;
142 }
143 // Beaver Builder's own content wrapper for the post in the main
144 // query, so Themer header/footer layouts — which are sibling
145 // .fl-builder-content nodes — are not indexed as page sections.
146 // Deliberately not closest(): that returns the row or column the
147 // module was dropped into, which would list only the headings
148 // sharing that container and silently miss the rest of the page.
149 var scope = document.querySelector( '.fl-builder-content-primary' )
150 || widget.closest( 'main, article' )
151 || document.body;
152 var used = {};
153 var entries = [];
154 scope.querySelectorAll( <?php echo wp_json_encode($selector); ?> ).forEach( function( el ) {
155 var text = el.textContent.trim();
156 if ( widget.contains( el ) || ! text ) {
157 return;
158 }
159 if ( ! el.id ) {
160 var base = text.toLowerCase()
161 .normalize( 'NFKD' ).replace( /[̀-ͯ]/g, '' )
162 .replace( /[^a-z0-9\s-]/g, '' ).trim()
163 .replace( /[\s-]+/g, '-' ) || 'section';
164 var id = base, n = 2;
165 while ( used[ id ] || document.getElementById( id ) ) {
166 id = base + '-' + ( n++ );
167 }
168 el.id = id;
169 }
170 used[ el.id ] = true;
171 var li = document.createElement( 'li' );
172 li.className = 'thinkrank-toc__item thinkrank-toc__item--level-' + el.tagName.charAt( 1 );
173 var a = document.createElement( 'a' );
174 a.href = '#' + el.id;
175 a.textContent = text;
176 li.appendChild( a );
177 list.appendChild( li );
178 entries.push( { name: text, id: el.id } );
179 } );
180 if ( ! entries.length ) {
181 widget.hidden = true;
182 return;
183 }
184 <?php if ($schema) : ?>
185 // The canonical, not location.href: a page reached with a
186 // tracking query would otherwise publish anchor URLs carrying
187 // it, so the same section gets a different URL per visitor.
188 var canonical = document.querySelector( 'link[rel="canonical"]' );
189 var base = canonical && canonical.href
190 ? canonical.href.split( '#' )[ 0 ]
191 : window.location.origin + window.location.pathname;
192 var graph = entries.map( function( entry ) {
193 return {
194 '@type': 'SiteNavigationElement',
195 name: entry.name,
196 url: base + '#' + entry.id
197 };
198 } );
199 var tag = document.createElement( 'script' );
200 tag.type = 'application/ld+json';
201 tag.textContent = JSON.stringify( {
202 '@context': 'https://schema.org',
203 '@graph': graph
204 } );
205 widget.appendChild( tag );
206 <?php endif; ?>
207 };
208 if ( 'loading' === document.readyState ) {
209 document.addEventListener( 'DOMContentLoaded', init );
210 } else {
211 init();
212 }
213 } )();
214 </script>
215 <?php
216 }
217
218 /**
219 * Deepest heading level to index, from the allowed set.
220 *
221 * @param array $settings Module settings.
222 * @return int
223 */
224 private static function max_level(array $settings): int {
225 $level = (int) ($settings['max_level'] ?? 3);
226
227 return in_array($level, [2, 3, 4], true) ? $level : 3;
228 }
229
230 /**
231 * List style from the allowed set.
232 *
233 * @param array $settings Module settings.
234 * @return string
235 */
236 private static function list_style(array $settings): string {
237 $style = (string) ($settings['list_style'] ?? 'disc');
238
239 return in_array($style, ['disc', 'decimal', 'none'], true) ? $style : 'disc';
240 }
241
242 /**
243 * A heading tag from the allowed set.
244 *
245 * @param array $settings Module settings.
246 * @return string
247 */
248 private static function heading_tag(array $settings): string {
249 $tag = (string) ($settings['heading_tag'] ?? 'h2');
250
251 return in_array($tag, ['h2', 'h3', 'h4', 'p'], true) ? $tag : 'h2';
252 }
253 }
254
255 FLBuilder::register_module('ThinkRank_Beaver_TOC_Module', [
256 'general' => [
257 'title' => __('Contents', 'thinkrank'),
258 'sections' => [
259 'content' => [
260 'title' => '',
261 'fields' => [
262 'heading' => [
263 'type' => 'text',
264 'label' => __('Title', 'thinkrank'),
265 'default' => __('Table of Contents', 'thinkrank'),
266 'connections' => ['string'],
267 ],
268 'heading_tag' => [
269 'type' => 'select',
270 'label' => __('Heading tag', 'thinkrank'),
271 'default' => 'h2',
272 'options' => [
273 'h2' => 'H2',
274 'h3' => 'H3',
275 'h4' => 'H4',
276 'p' => __('Paragraph', 'thinkrank'),
277 ],
278 ],
279 'max_level' => [
280 'type' => 'select',
281 'label' => __('Include headings up to', 'thinkrank'),
282 'default' => '3',
283 'options' => ['2' => 'H2', '3' => 'H3', '4' => 'H4'],
284 ],
285 'list_style' => [
286 'type' => 'select',
287 'label' => __('List style', 'thinkrank'),
288 'default' => 'disc',
289 'options' => [
290 'disc' => __('Bulleted', 'thinkrank'),
291 'decimal' => __('Numbered', 'thinkrank'),
292 'none' => __('Plain', 'thinkrank'),
293 ],
294 ],
295 ],
296 ],
297 ],
298 ],
299 'schema' => [
300 'title' => __('Schema', 'thinkrank'),
301 'sections' => [
302 'schema' => [
303 'title' => '',
304 'fields' => [
305 'output_schema' => [
306 'type' => 'select',
307 'label' => __('Output navigation schema (JSON-LD)', 'thinkrank'),
308 'default' => '1',
309 'options' => [
310 '1' => __('Yes', 'thinkrank'),
311 '0' => __('No', 'thinkrank'),
312 ],
313 'help' => __('Adds SiteNavigationElement structured data for the listed sections.', 'thinkrank'),
314 ],
315 ],
316 ],
317 ],
318 ],
319 ]);
320