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

class-howto-element.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/editor/bricks/class-howto-element.php

465 lines 14.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Bricks HowTo Element
5 *
6 * The Bricks counterpart of the thinkrank/howto Gutenberg block and the
7 * Elementor HowTo widget: a step-by-step guide with an optional image per step
8 * and a total time, emitting HowTo JSON-LD (#626).
9 *
10 * HowTo is not a page-level entity, so unlike FAQPage it never competes for the
11 * graph's one page slot and there is nothing to arbitrate — it is printed
12 * beside the element, exactly as the block and the Elementor widget do.
13 *
14 * @package ThinkRank
15 * @subpackage Editor\Bricks
16 * @since 2.3.1
17 */
18
19 declare(strict_types=1);
20
21 namespace ThinkRank\Editor\Bricks;
22
23 // Prevent direct access
24 if (!defined('ABSPATH')) {
25 exit;
26 }
27
28 /**
29 * HowTo Element.
30 *
31 * @since 2.3.1
32 */
33 class Howto_Element extends \Bricks\Element {
34
35 /**
36 * Builder category.
37 *
38 * @var string
39 */
40 public $category = 'thinkrank';
41
42 /**
43 * Element name, matching the Elementor widget's.
44 *
45 * @var string
46 */
47 public $name = 'thinkrank-howto';
48
49 /**
50 * Panel icon.
51 *
52 * @var string
53 */
54 public $icon = 'ti-list-ol';
55
56 /**
57 * Label shown in the element panel.
58 *
59 * @return string
60 */
61 public function get_label(): string {
62 return esc_html__('HowTo (ThinkRank)', 'thinkrank');
63 }
64
65 /**
66 * Panel search terms.
67 *
68 * @return array
69 */
70 public function get_keywords(): array {
71 return ['how to', 'steps', 'instructions', 'guide', 'schema', 'thinkrank'];
72 }
73
74 /**
75 * Load the shared block stylesheet, but only on a page using this element.
76 *
77 * @return void
78 */
79 public function enqueue_scripts(): void {
80 wp_enqueue_style('thinkrank-howto-block');
81 }
82
83 /**
84 * Control groups, so the panel is not one long list.
85 *
86 * @return void
87 */
88 public function set_control_groups(): void {
89 $this->control_groups['duration'] = [
90 'title' => esc_html__('Total time', 'thinkrank'),
91 'tab' => 'content',
92 ];
93
94 $this->control_groups['schema'] = [
95 'title' => esc_html__('Schema', 'thinkrank'),
96 'tab' => 'content',
97 ];
98 }
99
100 /**
101 * Controls.
102 *
103 * @return void
104 */
105 public function set_controls(): void {
106 $this->controls['heading'] = [
107 'label' => esc_html__('Title', 'thinkrank'),
108 'type' => 'text',
109 'placeholder' => esc_html__('How to …', 'thinkrank'),
110 ];
111
112 $this->controls['headingTag'] = [
113 'label' => esc_html__('Heading tag', 'thinkrank'),
114 'type' => 'select',
115 'options' => [
116 'h2' => 'H2',
117 'h3' => 'H3',
118 'h4' => 'H4',
119 'p' => esc_html__('Paragraph', 'thinkrank'),
120 ],
121 'default' => 'h2',
122 'inline' => true,
123 ];
124
125 $this->controls['description'] = [
126 'label' => esc_html__('Description', 'thinkrank'),
127 'type' => 'textarea',
128 ];
129
130 $this->controls['steps'] = [
131 'label' => esc_html__('Steps', 'thinkrank'),
132 'type' => 'repeater',
133 'titleProperty' => 'title',
134 'fields' => [
135 'title' => [
136 'label' => esc_html__('Step title', 'thinkrank'),
137 'type' => 'text',
138 ],
139 'text' => [
140 'label' => esc_html__('Step description', 'thinkrank'),
141 'type' => 'editor',
142 ],
143 'image' => [
144 'label' => esc_html__('Step image', 'thinkrank'),
145 'type' => 'image',
146 ],
147 ],
148 'default' => [
149 [
150 'title' => esc_html__('First step', 'thinkrank'),
151 'text' => esc_html__('Describe what to do here.', 'thinkrank'),
152 ],
153 ],
154 ];
155
156 $this->controls['showNumbers'] = [
157 'label' => esc_html__('Numbered steps', 'thinkrank'),
158 'type' => 'checkbox',
159 'default' => true,
160 ];
161
162 foreach (self::duration_labels() as $key => $label) {
163 $this->controls["total{$key}"] = [
164 'group' => 'duration',
165 'label' => $label,
166 'type' => 'number',
167 'min' => 0,
168 ];
169 }
170
171 $this->controls['outputSchema'] = [
172 'group' => 'schema',
173 'label' => esc_html__('Output HowTo schema (JSON-LD)', 'thinkrank'),
174 'type' => 'checkbox',
175 'default' => true,
176 'description' => esc_html__('Adds HowTo structured data for rich results.', 'thinkrank'),
177 ];
178 }
179
180 /**
181 * Render.
182 *
183 * @return void
184 */
185 public function render(): void {
186 $settings = $this->settings;
187 $items = self::usable_steps($settings);
188
189 if (empty($items)) {
190 if (bricks_is_builder_call()) {
191 echo '<div ' . $this->render_attributes('_root') . '>' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
192 . esc_html__('Add a step to get started.', 'thinkrank')
193 . '</div>';
194 }
195 return;
196 }
197
198 $this->set_attribute('_root', 'class', 'thinkrank-howto');
199
200 $heading_tag = self::heading_tag($settings);
201 $list_tag = empty($settings['showNumbers']) ? 'ul' : 'ol';
202
203 $output = '<div ' . $this->render_attributes('_root') . '>';
204
205 $heading = trim((string) ($settings['heading'] ?? ''));
206 if ('' !== $heading) {
207 $output .= sprintf(
208 '<%1$s class="thinkrank-howto__heading">%2$s</%1$s>',
209 esc_html($heading_tag),
210 esc_html($this->render_dynamic_data($heading))
211 );
212 }
213
214 $description = trim((string) ($settings['description'] ?? ''));
215 if ('' !== $description) {
216 $output .= '<p class="thinkrank-howto__description">'
217 . esc_html($this->render_dynamic_data($description)) . '</p>';
218 }
219
220 $duration = self::format_duration($settings);
221 if ('' !== $duration) {
222 $output .= '<p class="thinkrank-howto__duration"><strong>'
223 . esc_html__('Total time:', 'thinkrank') . '</strong> '
224 . esc_html($duration) . '</p>';
225 }
226
227 $output .= '<' . $list_tag . ' class="thinkrank-howto__steps">';
228
229 foreach ($items as $step) {
230 $output .= '<li class="thinkrank-howto__step">';
231
232 $title = trim((string) ($step['title'] ?? ''));
233 if ('' !== $title) {
234 $output .= '<div class="thinkrank-howto__step-title">'
235 . esc_html($this->render_dynamic_data($title)) . '</div>';
236 }
237
238 $image = self::step_image($step);
239 if (null !== $image) {
240 $output .= sprintf(
241 '<img class="thinkrank-howto__step-image" src="%s" alt="%s" />',
242 esc_url($image['url']),
243 esc_attr($image['alt'])
244 );
245 }
246
247 $text = trim((string) ($step['text'] ?? ''));
248 if ('' !== $text) {
249 $output .= '<div class="thinkrank-howto__step-text">'
250 . wp_kses_post(\Bricks\Helpers::parse_editor_content($this->render_dynamic_data($text)))
251 . '</div>';
252 }
253
254 $output .= '</li>';
255 }
256
257 $output .= '</' . $list_tag . '></div>';
258
259 echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
260
261 $this->maybe_render_schema($settings, $items);
262 }
263
264 /**
265 * The repeater rows worth rendering.
266 *
267 * @since 2.3.1
268 * @param array $settings Element settings.
269 * @return array<int,array>
270 */
271 public static function usable_steps(array $settings): array {
272 $steps = is_array($settings['steps'] ?? null) ? $settings['steps'] : [];
273
274 return array_values(array_filter($steps, static function ($step) {
275 if (!is_array($step)) {
276 return false;
277 }
278
279 return !empty($step['title']) || !empty($step['text']) || null !== self::step_image($step);
280 }));
281 }
282
283 /**
284 * A step's image, resolved through the attachment when there is one.
285 *
286 * Bricks stores `{ id, url, ... }` for an image control. The id is
287 * authoritative: it carries the alt text, and it is how a deleted
288 * attachment degrades to nothing rather than to a dead URL — the same
289 * reasoning as the FAQ block's image handling (#418).
290 *
291 * @since 2.3.1
292 * @param array $step One repeater row.
293 * @return array{url:string,alt:string}|null
294 */
295 private static function step_image(array $step): ?array {
296 $image = is_array($step['image'] ?? null) ? $step['image'] : [];
297 $id = (int) ($image['id'] ?? 0);
298
299 if ($id > 0) {
300 $url = wp_get_attachment_image_url($id, 'full');
301 if (is_string($url) && '' !== $url) {
302 return [
303 'url' => $url,
304 'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true),
305 ];
306 }
307
308 // The id names an attachment that is gone. A stored URL pointing
309 // into the same library is gone with it.
310 return null;
311 }
312
313 $url = trim((string) ($image['url'] ?? ''));
314
315 return '' !== $url ? ['url' => $url, 'alt' => ''] : null;
316 }
317
318 /**
319 * Human-readable total time, '' when unset.
320 *
321 * @param array $settings Element settings.
322 * @return string
323 */
324 private static function format_duration(array $settings): string {
325 [$days, $hours, $minutes] = self::duration_parts($settings);
326
327 $parts = [];
328 if ($days > 0) {
329 /* translators: %d: number of days. */
330 $parts[] = sprintf(_n('%d day', '%d days', $days, 'thinkrank'), $days);
331 }
332 if ($hours > 0) {
333 /* translators: %d: number of hours. */
334 $parts[] = sprintf(_n('%d hour', '%d hours', $hours, 'thinkrank'), $hours);
335 }
336 if ($minutes > 0) {
337 /* translators: %d: number of minutes. */
338 $parts[] = sprintf(_n('%d minute', '%d minutes', $minutes, 'thinkrank'), $minutes);
339 }
340
341 return implode(', ', $parts);
342 }
343
344 /**
345 * Days, hours and minutes as non-negative ints.
346 *
347 * @param array $settings Element settings.
348 * @return array{0:int,1:int,2:int}
349 */
350 private static function duration_parts(array $settings): array {
351 return [
352 max(0, (int) ($settings['totalDays'] ?? 0)),
353 max(0, (int) ($settings['totalHours'] ?? 0)),
354 max(0, (int) ($settings['totalMinutes'] ?? 0)),
355 ];
356 }
357
358 /**
359 * Duration control keys and their labels.
360 *
361 * @return array<string,string>
362 */
363 private static function duration_labels(): array {
364 return [
365 'Days' => esc_html__('Days', 'thinkrank'),
366 'Hours' => esc_html__('Hours', 'thinkrank'),
367 'Minutes' => esc_html__('Minutes', 'thinkrank'),
368 ];
369 }
370
371 /**
372 * Emit HowTo JSON-LD on the front end.
373 *
374 * @param array $settings Element settings.
375 * @param array $items Usable steps.
376 * @return void
377 */
378 private function maybe_render_schema(array $settings, array $items): void {
379
380 // The Schema master switch and the matrix's per-content-type switch.
381 // This widget echoes its own <script> rather than registering with
382 // Schema_Graph, so gating the graph never reached it and it kept
383 // publishing with Schema switched off (#688).
384 if (class_exists('ThinkRank\\Frontend\\Schema_Graph')
385 && !\ThinkRank\Frontend\Schema_Graph::output_allowed()) {
386 return;
387 }
388 if (empty($settings['outputSchema'])) {
389 return;
390 }
391
392 if (bricks_is_builder_call() || bricks_is_builder()) {
393 return;
394 }
395
396 $step_entities = [];
397 foreach ($items as $step) {
398 $title = trim(wp_strip_all_tags((string) ($step['title'] ?? '')));
399 $text = trim(wp_strip_all_tags((string) ($step['text'] ?? '')));
400
401 if ('' === $title && '' === $text) {
402 continue;
403 }
404
405 $entity = ['@type' => 'HowToStep'];
406 if ('' !== $title && '' !== $text) {
407 $entity['name'] = $title;
408 $entity['text'] = $text;
409 } else {
410 $entity['text'] = '' !== $text ? $text : $title;
411 }
412
413 $image = self::step_image($step);
414 if (null !== $image) {
415 $entity['image'] = [
416 '@type' => 'ImageObject',
417 'url' => esc_url_raw($image['url']),
418 ];
419 }
420
421 $step_entities[] = $entity;
422 }
423
424 if (empty($step_entities)) {
425 return;
426 }
427
428 $heading = trim(wp_strip_all_tags((string) ($settings['heading'] ?? '')));
429 $schema = [
430 '@context' => 'https://schema.org',
431 '@type' => 'HowTo',
432 'name' => '' !== $heading ? $heading : (string) get_the_title(),
433 'step' => $step_entities,
434 ];
435
436 $description = trim(wp_strip_all_tags((string) ($settings['description'] ?? '')));
437 if ('' !== $description) {
438 $schema['description'] = $description;
439 }
440
441 [$days, $hours, $minutes] = self::duration_parts($settings);
442 if ($days + $hours + $minutes > 0) {
443 $schema['totalTime'] = sprintf('P%dDT%dH%dM', $days, $hours, $minutes);
444 }
445
446 $json = wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
447
448 if (false !== $json) {
449 echo '<script type="application/ld+json">' . $json . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
450 }
451 }
452
453 /**
454 * A heading tag from the allowed set.
455 *
456 * @param array $settings Element settings.
457 * @return string
458 */
459 private static function heading_tag(array $settings): string {
460 $tag = (string) ($settings['headingTag'] ?? 'h2');
461
462 return in_array($tag, ['h2', 'h3', 'h4', 'p'], true) ? $tag : 'h2';
463 }
464 }
465