PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.2
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 / class-blocks-manager.php

class-blocks-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.0.2, at includes/editor/class-blocks-manager.php

399 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Gutenberg Blocks Manager
5 *
6 * Registers ThinkRank's editor blocks: enqueues their editor + front-end
7 * assets and injects block-level structured data.
8 *
9 * @package ThinkRank
10 * @subpackage Editor
11 * @since 1.15.x
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\Editor;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Blocks Manager.
25 *
26 * @since 1.15.x
27 */
28 class Blocks_Manager {
29
30 /**
31 * FAQ block name.
32 */
33 private const FAQ_BLOCK = 'thinkrank/faq';
34
35 /**
36 * HowTo block name.
37 */
38 private const HOWTO_BLOCK = 'thinkrank/howto';
39
40 /**
41 * TOC block name.
42 */
43 private const TOC_BLOCK = 'thinkrank/toc';
44
45 /**
46 * Block name → webpack asset handle. Each entry builds
47 * assets/{handle}.js / .css / .asset.php.
48 *
49 * @var array<string,string>
50 */
51 private const BLOCK_ASSETS = [
52 self::FAQ_BLOCK => 'faq-block',
53 self::HOWTO_BLOCK => 'howto-block',
54 self::TOC_BLOCK => 'toc-block',
55 ];
56
57 /**
58 * Wire up hooks.
59 *
60 * @return void
61 */
62 public function init(): void {
63 add_action('enqueue_block_editor_assets', [$this, 'enqueue_editor_assets']);
64 add_action('enqueue_block_assets', [$this, 'enqueue_block_styles']);
65 add_filter('render_block', [$this, 'inject_block_schema'], 10, 2);
66 }
67
68 /**
69 * Enqueue each block's editor script.
70 *
71 * @return void
72 */
73 public function enqueue_editor_assets(): void {
74 foreach (self::BLOCK_ASSETS as $handle) {
75 $asset_path = THINKRANK_PLUGIN_DIR . "assets/{$handle}.asset.php";
76 $asset = file_exists($asset_path)
77 ? include $asset_path
78 : ['dependencies' => ['wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n'], 'version' => THINKRANK_VERSION];
79
80 wp_enqueue_script(
81 "thinkrank-{$handle}",
82 THINKRANK_PLUGIN_URL . "assets/{$handle}.js",
83 $asset['dependencies'] ?? [],
84 $asset['version'] ?? THINKRANK_VERSION,
85 true
86 );
87
88 wp_set_script_translations("thinkrank-{$handle}", 'thinkrank');
89 }
90 }
91
92 /**
93 * Enqueue block stylesheets where blocks render.
94 *
95 * Hooked to enqueue_block_assets — not enqueue_block_editor_assets — so
96 * core mirrors them into the iframed editor canvas instead of only the
97 * editor's outer document. On the front end each loads only when its
98 * block is present.
99 *
100 * @return void
101 */
102 public function enqueue_block_styles(): void {
103 foreach (self::BLOCK_ASSETS as $block_name => $handle) {
104 if (!is_admin() && (!function_exists('has_block') || !has_block($block_name))) {
105 continue;
106 }
107
108 $css = THINKRANK_PLUGIN_DIR . "assets/{$handle}.css";
109 if (!file_exists($css)) {
110 continue;
111 }
112
113 $asset_path = THINKRANK_PLUGIN_DIR . "assets/{$handle}.asset.php";
114 $asset = file_exists($asset_path) ? include $asset_path : [];
115
116 wp_enqueue_style(
117 "thinkrank-{$handle}",
118 THINKRANK_PLUGIN_URL . "assets/{$handle}.css",
119 [],
120 $asset['version'] ?? THINKRANK_VERSION
121 );
122 }
123 }
124
125 /**
126 * Append block-level JSON-LD after a ThinkRank block's rendered output.
127 *
128 * Done server-side (not in the blocks' save output) so the schema is not
129 * stripped by KSES for users without unfiltered_html.
130 *
131 * @param string $block_content Rendered block HTML.
132 * @param array $block Parsed block (name + attrs).
133 * @return string
134 */
135 public function inject_block_schema(string $block_content, array $block): string {
136 $name = $block['blockName'] ?? '';
137 $attrs = $block['attrs'] ?? [];
138
139 if (!isset(self::BLOCK_ASSETS[$name])) {
140 return $block_content;
141 }
142
143 // Schema output is on by default; only skip when explicitly disabled.
144 if (array_key_exists('outputSchema', $attrs) && false === $attrs['outputSchema']) {
145 return $block_content;
146 }
147
148 switch ($name) {
149 case self::FAQ_BLOCK:
150 // Only the post being viewed may claim to be an FAQPage. On an
151 // archive or the blog home the graph's collection pass skips
152 // (it is not is_singular()), so absorption never happens and
153 // every listed post carrying an FAQ block used to emit its own
154 // standalone FAQPage beside a head that already declares
155 // CollectionPage — N FAQPage scripts on one URL.
156 if (!$this->is_faq_schema_context()) {
157 return $block_content;
158 }
159 // The request's schema graph already merged this block's questions
160 // into its single FAQPage, so emitting here would recreate the
161 // duplicate FAQPage the graph exists to prevent (#355).
162 if ($this->faq_absorbed_by_graph()) {
163 return $block_content;
164 }
165 $schema = $this->build_faq_schema($attrs);
166 break;
167 case self::HOWTO_BLOCK:
168 $schema = $this->build_howto_schema($attrs);
169 break;
170 case self::TOC_BLOCK:
171 $schema = $this->build_toc_schema($attrs);
172 break;
173 default:
174 $schema = null;
175 }
176
177 if (null === $schema) {
178 return $block_content;
179 }
180
181 $json = wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
182 if (false === $json) {
183 return $block_content;
184 }
185
186 return $block_content . "\n" . '<script type="application/ld+json">' . $json . '</script>';
187 }
188
189 /**
190 * Whether this render may emit a page-level FAQPage.
191 *
192 * True only while rendering the singular post that is actually being
193 * viewed. A listing (archive, blog home, search) renders many posts under
194 * one URL, and an FAQPage there would describe a document that does not
195 * exist. Outside a front-end query — the editor, a REST render — there is no
196 * page to describe either.
197 *
198 * @since 2.0.1
199 * @return bool
200 */
201 private function is_faq_schema_context(): bool {
202 if (!function_exists('is_singular') || !is_singular()) {
203 return false;
204 }
205
206 $queried_id = (int) get_queried_object_id();
207 $current_id = (int) get_the_ID();
208
209 // A secondary loop inside a singular template can render other posts;
210 // their FAQ content is not this URL's FAQ content.
211 return $queried_id > 0 && $queried_id === $current_id;
212 }
213
214 /**
215 * Whether the schema graph already absorbed this page's FAQ content.
216 *
217 * Falls back to false whenever the graph never ran, so the block keeps its
218 * original standalone behaviour outside a normal front-end render.
219 *
220 * @since 1.32.0
221 * @return bool
222 */
223 private function faq_absorbed_by_graph(): bool {
224 if (!class_exists('ThinkRank\\Frontend\\Schema_Graph')) {
225 return false;
226 }
227
228 return \ThinkRank\Frontend\Schema_Graph::instance()->absorbed_content_faq();
229 }
230
231 /**
232 * FAQPage schema from FAQ block attributes.
233 *
234 * @param array $attrs Block attributes.
235 * @return array|null Schema array, or null when there is nothing to emit.
236 */
237 private function build_faq_schema(array $attrs): ?array {
238 $faqs = $attrs['faqs'] ?? [];
239 if (!is_array($faqs) || empty($faqs)) {
240 return null;
241 }
242
243 $entities = [];
244 foreach ($faqs as $faq) {
245 $question = isset($faq['question']) ? trim(wp_strip_all_tags((string) $faq['question'])) : '';
246 $answer = isset($faq['answer']) ? trim((string) $faq['answer']) : '';
247 if ($question === '' || $answer === '') {
248 continue;
249 }
250
251 $text = wp_kses_post($answer);
252
253 // Yoast-style: a per-item image travels inside the answer HTML, so
254 // rich results can surface it without a separate ImageObject node.
255 $image_url = isset($faq['imageUrl']) ? esc_url((string) $faq['imageUrl']) : '';
256 if ($image_url !== '') {
257 $image_alt = isset($faq['imageAlt']) ? esc_attr((string) $faq['imageAlt']) : '';
258 $text .= ' <img src="' . $image_url . '" alt="' . $image_alt . '" />';
259 }
260
261 $entities[] = [
262 '@type' => 'Question',
263 'name' => $question,
264 'acceptedAnswer' => [
265 '@type' => 'Answer',
266 'text' => $text,
267 ],
268 ];
269 }
270
271 if (empty($entities)) {
272 return null;
273 }
274
275 return [
276 '@context' => 'https://schema.org',
277 '@type' => 'FAQPage',
278 'mainEntity' => $entities,
279 ];
280 }
281
282 /**
283 * HowTo schema from HowTo block attributes.
284 *
285 * Property shape follows Google's HowTo guidelines (and matches what
286 * RankMath emits): name, description, totalTime as ISO 8601, and
287 * HowToStep entries with name/text/image.
288 *
289 * @param array $attrs Block attributes.
290 * @return array|null Schema array, or null when there is nothing to emit.
291 */
292 private function build_howto_schema(array $attrs): ?array {
293 $steps = $attrs['steps'] ?? [];
294 if (!is_array($steps) || empty($steps)) {
295 return null;
296 }
297
298 $step_entities = [];
299 foreach ($steps as $step) {
300 $title = isset($step['title']) ? trim(wp_strip_all_tags((string) $step['title'])) : '';
301 $text = isset($step['text']) ? trim(wp_strip_all_tags((string) $step['text'])) : '';
302 if ($title === '' && $text === '') {
303 continue;
304 }
305
306 $entity = ['@type' => 'HowToStep'];
307 if ($title !== '' && $text !== '') {
308 $entity['name'] = $title;
309 $entity['text'] = $text;
310 } else {
311 // Google requires text; fall back to whichever field is set.
312 $entity['text'] = $text !== '' ? $text : $title;
313 }
314
315 $image_url = isset($step['imageUrl']) ? esc_url_raw((string) $step['imageUrl']) : '';
316 if ($image_url !== '') {
317 $entity['image'] = [
318 '@type' => 'ImageObject',
319 'url' => $image_url,
320 ];
321 }
322
323 $step_entities[] = $entity;
324 }
325
326 if (empty($step_entities)) {
327 return null;
328 }
329
330 $heading = isset($attrs['heading']) ? trim(wp_strip_all_tags((string) $attrs['heading'])) : '';
331 $name = $heading !== '' ? $heading : get_the_title();
332
333 $schema = [
334 '@context' => 'https://schema.org',
335 '@type' => 'HowTo',
336 'name' => $name,
337 'step' => $step_entities,
338 ];
339
340 $description = isset($attrs['description']) ? trim(wp_strip_all_tags((string) $attrs['description'])) : '';
341 if ($description !== '') {
342 $schema['description'] = $description;
343 }
344
345 // ISO 8601 duration, e.g. P1DT2H30M — only when a duration was set.
346 $days = max(0, (int) ($attrs['totalDays'] ?? 0));
347 $hours = max(0, (int) ($attrs['totalHours'] ?? 0));
348 $minutes = max(0, (int) ($attrs['totalMinutes'] ?? 0));
349 if ($days + $hours + $minutes > 0) {
350 $schema['totalTime'] = sprintf('P%dDT%dH%dM', $days, $hours, $minutes);
351 }
352
353 return $schema;
354 }
355
356 /**
357 * SiteNavigationElement schema from TOC block attributes (one element per
358 * listed section — the shape RankMath's TOC block emits).
359 *
360 * @param array $attrs Block attributes.
361 * @return array|null Schema array, or null when there is nothing to emit.
362 */
363 private function build_toc_schema(array $attrs): ?array {
364 $headings = $attrs['headings'] ?? [];
365 if (!is_array($headings) || empty($headings)) {
366 return null;
367 }
368
369 $permalink = get_permalink();
370 if (!is_string($permalink)) {
371 $permalink = '';
372 }
373
374 $elements = [];
375 foreach ($headings as $item) {
376 $content = isset($item['content']) ? trim(wp_strip_all_tags((string) $item['content'])) : '';
377 $anchor = isset($item['anchor']) ? trim((string) $item['anchor']) : '';
378 if ($content === '' || $anchor === '') {
379 continue;
380 }
381
382 $elements[] = [
383 '@type' => 'SiteNavigationElement',
384 'name' => $content,
385 'url' => $permalink . '#' . $anchor,
386 ];
387 }
388
389 if (empty($elements)) {
390 return null;
391 }
392
393 return [
394 '@context' => 'https://schema.org',
395 '@graph' => $elements,
396 ];
397 }
398 }
399