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

456 lines 13.9 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 if (empty($settings['outputSchema'])) {
380 return;
381 }
382
383 if (bricks_is_builder_call() || bricks_is_builder()) {
384 return;
385 }
386
387 $step_entities = [];
388 foreach ($items as $step) {
389 $title = trim(wp_strip_all_tags((string) ($step['title'] ?? '')));
390 $text = trim(wp_strip_all_tags((string) ($step['text'] ?? '')));
391
392 if ('' === $title && '' === $text) {
393 continue;
394 }
395
396 $entity = ['@type' => 'HowToStep'];
397 if ('' !== $title && '' !== $text) {
398 $entity['name'] = $title;
399 $entity['text'] = $text;
400 } else {
401 $entity['text'] = '' !== $text ? $text : $title;
402 }
403
404 $image = self::step_image($step);
405 if (null !== $image) {
406 $entity['image'] = [
407 '@type' => 'ImageObject',
408 'url' => esc_url_raw($image['url']),
409 ];
410 }
411
412 $step_entities[] = $entity;
413 }
414
415 if (empty($step_entities)) {
416 return;
417 }
418
419 $heading = trim(wp_strip_all_tags((string) ($settings['heading'] ?? '')));
420 $schema = [
421 '@context' => 'https://schema.org',
422 '@type' => 'HowTo',
423 'name' => '' !== $heading ? $heading : (string) get_the_title(),
424 'step' => $step_entities,
425 ];
426
427 $description = trim(wp_strip_all_tags((string) ($settings['description'] ?? '')));
428 if ('' !== $description) {
429 $schema['description'] = $description;
430 }
431
432 [$days, $hours, $minutes] = self::duration_parts($settings);
433 if ($days + $hours + $minutes > 0) {
434 $schema['totalTime'] = sprintf('P%dDT%dH%dM', $days, $hours, $minutes);
435 }
436
437 $json = wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
438
439 if (false !== $json) {
440 echo '<script type="application/ld+json">' . $json . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
441 }
442 }
443
444 /**
445 * A heading tag from the allowed set.
446 *
447 * @param array $settings Element settings.
448 * @return string
449 */
450 private static function heading_tag(array $settings): string {
451 $tag = (string) ($settings['headingTag'] ?? 'h2');
452
453 return in_array($tag, ['h2', 'h3', 'h4', 'p'], true) ? $tag : 'h2';
454 }
455 }
456