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