| 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 |
|