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

325 lines 10.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 $schema = match ($name) {
149 self::FAQ_BLOCK => $this->build_faq_schema($attrs),
150 self::HOWTO_BLOCK => $this->build_howto_schema($attrs),
151 self::TOC_BLOCK => $this->build_toc_schema($attrs),
152 default => null,
153 };
154
155 if (null === $schema) {
156 return $block_content;
157 }
158
159 $json = wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
160 if (false === $json) {
161 return $block_content;
162 }
163
164 return $block_content . "\n" . '<script type="application/ld+json">' . $json . '</script>';
165 }
166
167 /**
168 * FAQPage schema from FAQ block attributes.
169 *
170 * @param array $attrs Block attributes.
171 * @return array|null Schema array, or null when there is nothing to emit.
172 */
173 private function build_faq_schema(array $attrs): ?array {
174 $faqs = $attrs['faqs'] ?? [];
175 if (!is_array($faqs) || empty($faqs)) {
176 return null;
177 }
178
179 $entities = [];
180 foreach ($faqs as $faq) {
181 $question = isset($faq['question']) ? trim(wp_strip_all_tags((string) $faq['question'])) : '';
182 $answer = isset($faq['answer']) ? trim((string) $faq['answer']) : '';
183 if ($question === '' || $answer === '') {
184 continue;
185 }
186
187 $entities[] = [
188 '@type' => 'Question',
189 'name' => $question,
190 'acceptedAnswer' => [
191 '@type' => 'Answer',
192 'text' => wp_kses_post($answer),
193 ],
194 ];
195 }
196
197 if (empty($entities)) {
198 return null;
199 }
200
201 return [
202 '@context' => 'https://schema.org',
203 '@type' => 'FAQPage',
204 'mainEntity' => $entities,
205 ];
206 }
207
208 /**
209 * HowTo schema from HowTo block attributes.
210 *
211 * Property shape follows Google's HowTo guidelines (and matches what
212 * RankMath emits): name, description, totalTime as ISO 8601, and
213 * HowToStep entries with name/text/image.
214 *
215 * @param array $attrs Block attributes.
216 * @return array|null Schema array, or null when there is nothing to emit.
217 */
218 private function build_howto_schema(array $attrs): ?array {
219 $steps = $attrs['steps'] ?? [];
220 if (!is_array($steps) || empty($steps)) {
221 return null;
222 }
223
224 $step_entities = [];
225 foreach ($steps as $step) {
226 $title = isset($step['title']) ? trim(wp_strip_all_tags((string) $step['title'])) : '';
227 $text = isset($step['text']) ? trim(wp_strip_all_tags((string) $step['text'])) : '';
228 if ($title === '' && $text === '') {
229 continue;
230 }
231
232 $entity = ['@type' => 'HowToStep'];
233 if ($title !== '' && $text !== '') {
234 $entity['name'] = $title;
235 $entity['text'] = $text;
236 } else {
237 // Google requires text; fall back to whichever field is set.
238 $entity['text'] = $text !== '' ? $text : $title;
239 }
240
241 $image_url = isset($step['imageUrl']) ? esc_url_raw((string) $step['imageUrl']) : '';
242 if ($image_url !== '') {
243 $entity['image'] = [
244 '@type' => 'ImageObject',
245 'url' => $image_url,
246 ];
247 }
248
249 $step_entities[] = $entity;
250 }
251
252 if (empty($step_entities)) {
253 return null;
254 }
255
256 $heading = isset($attrs['heading']) ? trim(wp_strip_all_tags((string) $attrs['heading'])) : '';
257 $name = $heading !== '' ? $heading : get_the_title();
258
259 $schema = [
260 '@context' => 'https://schema.org',
261 '@type' => 'HowTo',
262 'name' => $name,
263 'step' => $step_entities,
264 ];
265
266 $description = isset($attrs['description']) ? trim(wp_strip_all_tags((string) $attrs['description'])) : '';
267 if ($description !== '') {
268 $schema['description'] = $description;
269 }
270
271 // ISO 8601 duration, e.g. P1DT2H30M — only when a duration was set.
272 $days = max(0, (int) ($attrs['totalDays'] ?? 0));
273 $hours = max(0, (int) ($attrs['totalHours'] ?? 0));
274 $minutes = max(0, (int) ($attrs['totalMinutes'] ?? 0));
275 if ($days + $hours + $minutes > 0) {
276 $schema['totalTime'] = sprintf('P%dDT%dH%dM', $days, $hours, $minutes);
277 }
278
279 return $schema;
280 }
281
282 /**
283 * SiteNavigationElement schema from TOC block attributes (one element per
284 * listed section — the shape RankMath's TOC block emits).
285 *
286 * @param array $attrs Block attributes.
287 * @return array|null Schema array, or null when there is nothing to emit.
288 */
289 private function build_toc_schema(array $attrs): ?array {
290 $headings = $attrs['headings'] ?? [];
291 if (!is_array($headings) || empty($headings)) {
292 return null;
293 }
294
295 $permalink = get_permalink();
296 if (!is_string($permalink)) {
297 $permalink = '';
298 }
299
300 $elements = [];
301 foreach ($headings as $item) {
302 $content = isset($item['content']) ? trim(wp_strip_all_tags((string) $item['content'])) : '';
303 $anchor = isset($item['anchor']) ? trim((string) $item['anchor']) : '';
304 if ($content === '' || $anchor === '') {
305 continue;
306 }
307
308 $elements[] = [
309 '@type' => 'SiteNavigationElement',
310 'name' => $content,
311 'url' => $permalink . '#' . $anchor,
312 ];
313 }
314
315 if (empty($elements)) {
316 return null;
317 }
318
319 return [
320 '@context' => 'https://schema.org',
321 '@graph' => $elements,
322 ];
323 }
324 }
325