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

365 lines 11.6 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 // The request's schema graph already merged this block's questions
151 // into its single FAQPage, so emitting here would recreate the
152 // duplicate FAQPage the graph exists to prevent (#355).
153 if ($this->faq_absorbed_by_graph()) {
154 return $block_content;
155 }
156 $schema = $this->build_faq_schema($attrs);
157 break;
158 case self::HOWTO_BLOCK:
159 $schema = $this->build_howto_schema($attrs);
160 break;
161 case self::TOC_BLOCK:
162 $schema = $this->build_toc_schema($attrs);
163 break;
164 default:
165 $schema = null;
166 }
167
168 if (null === $schema) {
169 return $block_content;
170 }
171
172 $json = wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
173 if (false === $json) {
174 return $block_content;
175 }
176
177 return $block_content . "\n" . '<script type="application/ld+json">' . $json . '</script>';
178 }
179
180 /**
181 * Whether the schema graph already absorbed this page's FAQ content.
182 *
183 * Falls back to false whenever the graph never ran, so the block keeps its
184 * original standalone behaviour outside a normal front-end render.
185 *
186 * @since 1.32.0
187 * @return bool
188 */
189 private function faq_absorbed_by_graph(): bool {
190 if (!class_exists('ThinkRank\\Frontend\\Schema_Graph')) {
191 return false;
192 }
193
194 return \ThinkRank\Frontend\Schema_Graph::instance()->absorbed_content_faq();
195 }
196
197 /**
198 * FAQPage schema from FAQ block attributes.
199 *
200 * @param array $attrs Block attributes.
201 * @return array|null Schema array, or null when there is nothing to emit.
202 */
203 private function build_faq_schema(array $attrs): ?array {
204 $faqs = $attrs['faqs'] ?? [];
205 if (!is_array($faqs) || empty($faqs)) {
206 return null;
207 }
208
209 $entities = [];
210 foreach ($faqs as $faq) {
211 $question = isset($faq['question']) ? trim(wp_strip_all_tags((string) $faq['question'])) : '';
212 $answer = isset($faq['answer']) ? trim((string) $faq['answer']) : '';
213 if ($question === '' || $answer === '') {
214 continue;
215 }
216
217 $text = wp_kses_post($answer);
218
219 // Yoast-style: a per-item image travels inside the answer HTML, so
220 // rich results can surface it without a separate ImageObject node.
221 $image_url = isset($faq['imageUrl']) ? esc_url((string) $faq['imageUrl']) : '';
222 if ($image_url !== '') {
223 $image_alt = isset($faq['imageAlt']) ? esc_attr((string) $faq['imageAlt']) : '';
224 $text .= ' <img src="' . $image_url . '" alt="' . $image_alt . '" />';
225 }
226
227 $entities[] = [
228 '@type' => 'Question',
229 'name' => $question,
230 'acceptedAnswer' => [
231 '@type' => 'Answer',
232 'text' => $text,
233 ],
234 ];
235 }
236
237 if (empty($entities)) {
238 return null;
239 }
240
241 return [
242 '@context' => 'https://schema.org',
243 '@type' => 'FAQPage',
244 'mainEntity' => $entities,
245 ];
246 }
247
248 /**
249 * HowTo schema from HowTo block attributes.
250 *
251 * Property shape follows Google's HowTo guidelines (and matches what
252 * RankMath emits): name, description, totalTime as ISO 8601, and
253 * HowToStep entries with name/text/image.
254 *
255 * @param array $attrs Block attributes.
256 * @return array|null Schema array, or null when there is nothing to emit.
257 */
258 private function build_howto_schema(array $attrs): ?array {
259 $steps = $attrs['steps'] ?? [];
260 if (!is_array($steps) || empty($steps)) {
261 return null;
262 }
263
264 $step_entities = [];
265 foreach ($steps as $step) {
266 $title = isset($step['title']) ? trim(wp_strip_all_tags((string) $step['title'])) : '';
267 $text = isset($step['text']) ? trim(wp_strip_all_tags((string) $step['text'])) : '';
268 if ($title === '' && $text === '') {
269 continue;
270 }
271
272 $entity = ['@type' => 'HowToStep'];
273 if ($title !== '' && $text !== '') {
274 $entity['name'] = $title;
275 $entity['text'] = $text;
276 } else {
277 // Google requires text; fall back to whichever field is set.
278 $entity['text'] = $text !== '' ? $text : $title;
279 }
280
281 $image_url = isset($step['imageUrl']) ? esc_url_raw((string) $step['imageUrl']) : '';
282 if ($image_url !== '') {
283 $entity['image'] = [
284 '@type' => 'ImageObject',
285 'url' => $image_url,
286 ];
287 }
288
289 $step_entities[] = $entity;
290 }
291
292 if (empty($step_entities)) {
293 return null;
294 }
295
296 $heading = isset($attrs['heading']) ? trim(wp_strip_all_tags((string) $attrs['heading'])) : '';
297 $name = $heading !== '' ? $heading : get_the_title();
298
299 $schema = [
300 '@context' => 'https://schema.org',
301 '@type' => 'HowTo',
302 'name' => $name,
303 'step' => $step_entities,
304 ];
305
306 $description = isset($attrs['description']) ? trim(wp_strip_all_tags((string) $attrs['description'])) : '';
307 if ($description !== '') {
308 $schema['description'] = $description;
309 }
310
311 // ISO 8601 duration, e.g. P1DT2H30M — only when a duration was set.
312 $days = max(0, (int) ($attrs['totalDays'] ?? 0));
313 $hours = max(0, (int) ($attrs['totalHours'] ?? 0));
314 $minutes = max(0, (int) ($attrs['totalMinutes'] ?? 0));
315 if ($days + $hours + $minutes > 0) {
316 $schema['totalTime'] = sprintf('P%dDT%dH%dM', $days, $hours, $minutes);
317 }
318
319 return $schema;
320 }
321
322 /**
323 * SiteNavigationElement schema from TOC block attributes (one element per
324 * listed section — the shape RankMath's TOC block emits).
325 *
326 * @param array $attrs Block attributes.
327 * @return array|null Schema array, or null when there is nothing to emit.
328 */
329 private function build_toc_schema(array $attrs): ?array {
330 $headings = $attrs['headings'] ?? [];
331 if (!is_array($headings) || empty($headings)) {
332 return null;
333 }
334
335 $permalink = get_permalink();
336 if (!is_string($permalink)) {
337 $permalink = '';
338 }
339
340 $elements = [];
341 foreach ($headings as $item) {
342 $content = isset($item['content']) ? trim(wp_strip_all_tags((string) $item['content'])) : '';
343 $anchor = isset($item['anchor']) ? trim((string) $item['anchor']) : '';
344 if ($content === '' || $anchor === '') {
345 continue;
346 }
347
348 $elements[] = [
349 '@type' => 'SiteNavigationElement',
350 'name' => $content,
351 'url' => $permalink . '#' . $anchor,
352 ];
353 }
354
355 if (empty($elements)) {
356 return null;
357 }
358
359 return [
360 '@context' => 'https://schema.org',
361 '@graph' => $elements,
362 ];
363 }
364 }
365