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

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

313 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 * 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 $this->render_builder_script($uid, $max_level, !empty($settings['output_schema']));
98 }
99
100 /**
101 * The dependency-free script that fills the list in.
102 *
103 * It scans Beaver Builder's content wrapper for h2..maxLevel, gives each
104 * heading an id if it has none, and appends one linked item per heading.
105 * With schema on it then publishes the same anchors as
106 * SiteNavigationElement, so the list and the structured data cannot drift.
107 *
108 * The module hides itself when the page has no headings — an empty "Table
109 * of Contents" box is worse than none.
110 *
111 * @param string $uid This module instance's layout node id.
112 * @param int $max_level Deepest heading level to include.
113 * @param bool $schema Whether to publish SiteNavigationElement.
114 * @return void
115 */
116 private function render_builder_script(string $uid, int $max_level, bool $schema): void {
117 $selector = implode(',', array_map(
118 static fn($level) => 'h' . $level,
119 range(2, max(2, $max_level))
120 ));
121
122 ?>
123 <script>
124 ( function() {
125 var init = function() {
126 var widget = document.querySelector(
127 '[data-thinkrank-toc="' + <?php echo wp_json_encode($uid); ?> + '"]'
128 );
129 if ( ! widget ) {
130 return;
131 }
132 var list = widget.querySelector( '.thinkrank-toc__list' );
133 if ( ! list ) {
134 return;
135 }
136 // Beaver Builder's own content wrapper for the post in the main
137 // query, so Themer header/footer layouts — which are sibling
138 // .fl-builder-content nodes — are not indexed as page sections.
139 // Deliberately not closest(): that returns the row or column the
140 // module was dropped into, which would list only the headings
141 // sharing that container and silently miss the rest of the page.
142 var scope = document.querySelector( '.fl-builder-content-primary' )
143 || widget.closest( 'main, article' )
144 || document.body;
145 var used = {};
146 var entries = [];
147 scope.querySelectorAll( <?php echo wp_json_encode($selector); ?> ).forEach( function( el ) {
148 var text = el.textContent.trim();
149 if ( widget.contains( el ) || ! text ) {
150 return;
151 }
152 if ( ! el.id ) {
153 var base = text.toLowerCase()
154 .normalize( 'NFKD' ).replace( /[̀-ͯ]/g, '' )
155 .replace( /[^a-z0-9\s-]/g, '' ).trim()
156 .replace( /[\s-]+/g, '-' ) || 'section';
157 var id = base, n = 2;
158 while ( used[ id ] || document.getElementById( id ) ) {
159 id = base + '-' + ( n++ );
160 }
161 el.id = id;
162 }
163 used[ el.id ] = true;
164 var li = document.createElement( 'li' );
165 li.className = 'thinkrank-toc__item thinkrank-toc__item--level-' + el.tagName.charAt( 1 );
166 var a = document.createElement( 'a' );
167 a.href = '#' + el.id;
168 a.textContent = text;
169 li.appendChild( a );
170 list.appendChild( li );
171 entries.push( { name: text, id: el.id } );
172 } );
173 if ( ! entries.length ) {
174 widget.hidden = true;
175 return;
176 }
177 <?php if ($schema) : ?>
178 // The canonical, not location.href: a page reached with a
179 // tracking query would otherwise publish anchor URLs carrying
180 // it, so the same section gets a different URL per visitor.
181 var canonical = document.querySelector( 'link[rel="canonical"]' );
182 var base = canonical && canonical.href
183 ? canonical.href.split( '#' )[ 0 ]
184 : window.location.origin + window.location.pathname;
185 var graph = entries.map( function( entry ) {
186 return {
187 '@type': 'SiteNavigationElement',
188 name: entry.name,
189 url: base + '#' + entry.id
190 };
191 } );
192 var tag = document.createElement( 'script' );
193 tag.type = 'application/ld+json';
194 tag.textContent = JSON.stringify( {
195 '@context': 'https://schema.org',
196 '@graph': graph
197 } );
198 widget.appendChild( tag );
199 <?php endif; ?>
200 };
201 if ( 'loading' === document.readyState ) {
202 document.addEventListener( 'DOMContentLoaded', init );
203 } else {
204 init();
205 }
206 } )();
207 </script>
208 <?php
209 }
210
211 /**
212 * Deepest heading level to index, from the allowed set.
213 *
214 * @param array $settings Module settings.
215 * @return int
216 */
217 private static function max_level(array $settings): int {
218 $level = (int) ($settings['max_level'] ?? 3);
219
220 return in_array($level, [2, 3, 4], true) ? $level : 3;
221 }
222
223 /**
224 * List style from the allowed set.
225 *
226 * @param array $settings Module settings.
227 * @return string
228 */
229 private static function list_style(array $settings): string {
230 $style = (string) ($settings['list_style'] ?? 'disc');
231
232 return in_array($style, ['disc', 'decimal', 'none'], true) ? $style : 'disc';
233 }
234
235 /**
236 * A heading tag from the allowed set.
237 *
238 * @param array $settings Module settings.
239 * @return string
240 */
241 private static function heading_tag(array $settings): string {
242 $tag = (string) ($settings['heading_tag'] ?? 'h2');
243
244 return in_array($tag, ['h2', 'h3', 'h4', 'p'], true) ? $tag : 'h2';
245 }
246 }
247
248 FLBuilder::register_module('ThinkRank_Beaver_TOC_Module', [
249 'general' => [
250 'title' => __('Contents', 'thinkrank'),
251 'sections' => [
252 'content' => [
253 'title' => '',
254 'fields' => [
255 'heading' => [
256 'type' => 'text',
257 'label' => __('Title', 'thinkrank'),
258 'default' => __('Table of Contents', 'thinkrank'),
259 'connections' => ['string'],
260 ],
261 'heading_tag' => [
262 'type' => 'select',
263 'label' => __('Heading tag', 'thinkrank'),
264 'default' => 'h2',
265 'options' => [
266 'h2' => 'H2',
267 'h3' => 'H3',
268 'h4' => 'H4',
269 'p' => __('Paragraph', 'thinkrank'),
270 ],
271 ],
272 'max_level' => [
273 'type' => 'select',
274 'label' => __('Include headings up to', 'thinkrank'),
275 'default' => '3',
276 'options' => ['2' => 'H2', '3' => 'H3', '4' => 'H4'],
277 ],
278 'list_style' => [
279 'type' => 'select',
280 'label' => __('List style', 'thinkrank'),
281 'default' => 'disc',
282 'options' => [
283 'disc' => __('Bulleted', 'thinkrank'),
284 'decimal' => __('Numbered', 'thinkrank'),
285 'none' => __('Plain', 'thinkrank'),
286 ],
287 ],
288 ],
289 ],
290 ],
291 ],
292 'schema' => [
293 'title' => __('Schema', 'thinkrank'),
294 'sections' => [
295 'schema' => [
296 'title' => '',
297 'fields' => [
298 'output_schema' => [
299 'type' => 'select',
300 'label' => __('Output navigation schema (JSON-LD)', 'thinkrank'),
301 'default' => '1',
302 'options' => [
303 '1' => __('Yes', 'thinkrank'),
304 '0' => __('No', 'thinkrank'),
305 ],
306 'help' => __('Adds SiteNavigationElement structured data for the listed sections.', 'thinkrank'),
307 ],
308 ],
309 ],
310 ],
311 ],
312 ]);
313