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

howto.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.5.0, at includes/editor/beaver/howto/howto.php

429 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 * Beaver Builder HowTo Module
5 *
6 * The Beaver counterpart of the thinkrank/howto Gutenberg block, the Elementor
7 * HowTo widget and the Bricks HowTo element: ordered steps with optional images
8 * and a total time, emitting HowTo JSON-LD (#662).
9 *
10 * Markup and CSS classes match the block's, so all four builders render from
11 * the same stylesheet.
12 *
13 * Unlike FAQ, HowTo is not collected by `Schema_Graph`: the graph de-duplicates
14 * FAQPage because a page can gather questions from several sources, and no
15 * equivalent exists for HowTo. This module therefore owns its own JSON-LD, the
16 * same arrangement the Bricks element uses.
17 *
18 * @package ThinkRank
19 * @subpackage Editor\Beaver
20 * @since 2.5.0
21 */
22
23 declare(strict_types=1);
24
25 // Prevent direct access
26 if (!defined('ABSPATH')) {
27 exit;
28 }
29
30 /**
31 * HowTo Module.
32 *
33 * @since 2.5.0
34 */
35 class ThinkRank_Beaver_Howto_Module extends FLBuilderModule {
36
37 /**
38 * The module slug, and the settings `type` its stored nodes carry.
39 */
40 public const SLUG = 'thinkrank-howto';
41
42 /**
43 * Constructor.
44 */
45 public function __construct() {
46 parent::__construct([
47 'name' => __('HowTo (ThinkRank)', 'thinkrank'),
48 'description' => __('A numbered list of steps that emits HowTo schema.', 'thinkrank'),
49 'category' => __('ThinkRank', 'thinkrank'),
50 'slug' => self::SLUG,
51 'dir' => THINKRANK_PLUGIN_DIR . 'includes/editor/beaver/howto/',
52 'url' => THINKRANK_PLUGIN_URL . 'includes/editor/beaver/howto/',
53 'partial_refresh' => true,
54 ]);
55 }
56
57 /**
58 * Render the steps.
59 *
60 * @since 2.5.0
61 * @param object|array $settings Module settings.
62 * @return void
63 */
64 public function render_content($settings): void {
65 $settings = ThinkRank_Beaver_FAQ_Module::to_array($settings);
66 $items = self::usable_steps($settings);
67
68 if (empty($items)) {
69 if (self::in_builder()) {
70 echo '<div class="thinkrank-howto">' . esc_html__('Add a step to get started.', 'thinkrank') . '</div>';
71 }
72 return;
73 }
74
75 wp_enqueue_style('thinkrank-howto-block');
76
77 $heading_tag = self::heading_tag($settings);
78 $list_tag = empty($settings['show_numbers']) ? 'ul' : 'ol';
79
80 $output = '<div class="thinkrank-howto">';
81
82 $heading = trim((string) ($settings['heading'] ?? ''));
83 if ('' !== $heading) {
84 $output .= sprintf(
85 '<%1$s class="thinkrank-howto__heading">%2$s</%1$s>',
86 esc_html($heading_tag),
87 esc_html($heading)
88 );
89 }
90
91 $description = trim((string) ($settings['description'] ?? ''));
92 if ('' !== $description) {
93 $output .= '<p class="thinkrank-howto__description">' . esc_html($description) . '</p>';
94 }
95
96 $duration = self::format_duration($settings);
97 if ('' !== $duration) {
98 $output .= '<p class="thinkrank-howto__duration"><strong>'
99 . esc_html__('Total time:', 'thinkrank') . '</strong> '
100 . esc_html($duration) . '</p>';
101 }
102
103 $output .= '<' . $list_tag . ' class="thinkrank-howto__steps">';
104
105 foreach ($items as $step) {
106 $output .= '<li class="thinkrank-howto__step">';
107
108 $title = trim((string) ($step['title'] ?? ''));
109 if ('' !== $title) {
110 $output .= '<div class="thinkrank-howto__step-title">' . esc_html($title) . '</div>';
111 }
112
113 $image = self::step_image($step);
114 if (null !== $image) {
115 $output .= sprintf(
116 '<img class="thinkrank-howto__step-image" src="%s" alt="%s" />',
117 esc_url($image['url']),
118 esc_attr($image['alt'])
119 );
120 }
121
122 $text = trim((string) ($step['text'] ?? ''));
123 if ('' !== $text) {
124 $output .= '<div class="thinkrank-howto__step-text">' . wp_kses_post($text) . '</div>';
125 }
126
127 $output .= '</li>';
128 }
129
130 $output .= '</' . $list_tag . '></div>';
131
132 echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
133
134 $this->maybe_render_schema($settings, $items);
135 }
136
137 /**
138 * The repeater rows worth rendering.
139 *
140 * @since 2.5.0
141 * @param array $settings Module settings.
142 * @return array<int,array>
143 */
144 public static function usable_steps(array $settings): array {
145 $steps = is_array($settings['steps'] ?? null) ? $settings['steps'] : [];
146
147 return array_values(array_filter($steps, static function ($step) {
148 return is_array($step) && (!empty($step['title']) || !empty($step['text']) || !empty($step['image']));
149 }));
150 }
151
152 /**
153 * A step's image, resolved to a url and alt text.
154 *
155 * Beaver Builder's photo field stores an attachment id, so the url is
156 * looked up rather than read off the setting.
157 *
158 * @since 2.5.0
159 * @param array $step One step row.
160 * @return array{url:string,alt:string}|null
161 */
162 private static function step_image(array $step): ?array {
163 $id = (int) ($step['image'] ?? 0);
164 if ($id <= 0) {
165 return null;
166 }
167
168 $url = wp_get_attachment_image_url($id, 'large');
169 if (!$url) {
170 return null;
171 }
172
173 return [
174 'url' => (string) $url,
175 'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true),
176 ];
177 }
178
179 /**
180 * The duration fields as [days, hours, minutes].
181 *
182 * @since 2.5.0
183 * @param array $settings Module settings.
184 * @return array{0:int,1:int,2:int}
185 */
186 private static function duration_parts(array $settings): array {
187 return [
188 max(0, (int) ($settings['total_days'] ?? 0)),
189 max(0, (int) ($settings['total_hours'] ?? 0)),
190 max(0, (int) ($settings['total_minutes'] ?? 0)),
191 ];
192 }
193
194 /**
195 * The duration as human-readable text, or '' when nothing is set.
196 *
197 * @since 2.5.0
198 * @param array $settings Module settings.
199 * @return string
200 */
201 private static function format_duration(array $settings): string {
202 [$days, $hours, $minutes] = self::duration_parts($settings);
203
204 $parts = [];
205 if ($days > 0) {
206 /* translators: %d: number of days. */
207 $parts[] = sprintf(_n('%d day', '%d days', $days, 'thinkrank'), $days);
208 }
209 if ($hours > 0) {
210 /* translators: %d: number of hours. */
211 $parts[] = sprintf(_n('%d hour', '%d hours', $hours, 'thinkrank'), $hours);
212 }
213 if ($minutes > 0) {
214 /* translators: %d: number of minutes. */
215 $parts[] = sprintf(_n('%d minute', '%d minutes', $minutes, 'thinkrank'), $minutes);
216 }
217
218 return implode(' ', $parts);
219 }
220
221 /**
222 * Whether Beaver Builder's editor is rendering this.
223 *
224 * @since 2.5.0
225 * @return bool
226 */
227 private static function in_builder(): bool {
228 return class_exists('\\FLBuilderModel') && (bool) \FLBuilderModel::is_builder_active();
229 }
230
231 /**
232 * Emit HowTo JSON-LD on the front end.
233 *
234 * @param array $settings Module settings.
235 * @param array $items Usable steps.
236 * @return void
237 */
238 private function maybe_render_schema(array $settings, array $items): void {
239 if (empty($settings['output_schema']) || self::in_builder()) {
240 return;
241 }
242
243 $step_entities = [];
244 foreach ($items as $step) {
245 $title = trim(wp_strip_all_tags((string) ($step['title'] ?? '')));
246 $text = trim(wp_strip_all_tags((string) ($step['text'] ?? '')));
247
248 if ('' === $title && '' === $text) {
249 continue;
250 }
251
252 $entity = ['@type' => 'HowToStep'];
253 if ('' !== $title && '' !== $text) {
254 $entity['name'] = $title;
255 $entity['text'] = $text;
256 } else {
257 $entity['text'] = '' !== $text ? $text : $title;
258 }
259
260 $image = self::step_image($step);
261 if (null !== $image) {
262 $entity['image'] = [
263 '@type' => 'ImageObject',
264 'url' => esc_url_raw($image['url']),
265 ];
266 }
267
268 $step_entities[] = $entity;
269 }
270
271 if (empty($step_entities)) {
272 return;
273 }
274
275 $heading = trim(wp_strip_all_tags((string) ($settings['heading'] ?? '')));
276 $schema = [
277 '@context' => 'https://schema.org',
278 '@type' => 'HowTo',
279 'name' => '' !== $heading ? $heading : (string) get_the_title(),
280 'step' => $step_entities,
281 ];
282
283 $description = trim(wp_strip_all_tags((string) ($settings['description'] ?? '')));
284 if ('' !== $description) {
285 $schema['description'] = $description;
286 }
287
288 [$days, $hours, $minutes] = self::duration_parts($settings);
289 if ($days + $hours + $minutes > 0) {
290 $schema['totalTime'] = sprintf('P%dDT%dH%dM', $days, $hours, $minutes);
291 }
292
293 $json = wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
294
295 if (false !== $json) {
296 echo '<script type="application/ld+json">' . $json . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
297 }
298 }
299
300 /**
301 * A heading tag from the allowed set.
302 *
303 * @param array $settings Module settings.
304 * @return string
305 */
306 private static function heading_tag(array $settings): string {
307 $tag = (string) ($settings['heading_tag'] ?? 'h2');
308
309 return in_array($tag, ['h2', 'h3', 'h4', 'p'], true) ? $tag : 'h2';
310 }
311 }
312
313 FLBuilder::register_settings_form('thinkrank_howto_step_form', [
314 'title' => __('Step', 'thinkrank'),
315 'tabs' => [
316 'general' => [
317 'title' => __('General', 'thinkrank'),
318 'sections' => [
319 'general' => [
320 'title' => '',
321 'fields' => [
322 'title' => [
323 'type' => 'text',
324 'label' => __('Step title', 'thinkrank'),
325 'connections' => ['string'],
326 ],
327 'text' => [
328 'type' => 'editor',
329 'label' => __('Step description', 'thinkrank'),
330 'media_buttons' => false,
331 'connections' => ['string'],
332 ],
333 'image' => [
334 'type' => 'photo',
335 'label' => __('Step image', 'thinkrank'),
336 'show_remove' => true,
337 ],
338 ],
339 ],
340 ],
341 ],
342 ],
343 ]);
344
345 FLBuilder::register_module('ThinkRank_Beaver_Howto_Module', [
346 'general' => [
347 'title' => __('Steps', 'thinkrank'),
348 'sections' => [
349 'content' => [
350 'title' => '',
351 'fields' => [
352 'heading' => [
353 'type' => 'text',
354 'label' => __('Title', 'thinkrank'),
355 'placeholder' => __('How to …', 'thinkrank'),
356 'connections' => ['string'],
357 ],
358 'heading_tag' => [
359 'type' => 'select',
360 'label' => __('Heading tag', 'thinkrank'),
361 'default' => 'h2',
362 'options' => [
363 'h2' => 'H2',
364 'h3' => 'H3',
365 'h4' => 'H4',
366 'p' => __('Paragraph', 'thinkrank'),
367 ],
368 ],
369 'description' => [
370 'type' => 'textarea',
371 'label' => __('Description', 'thinkrank'),
372 'rows' => 4,
373 'connections' => ['string'],
374 ],
375 'steps' => [
376 'type' => 'form',
377 'label' => __('Step', 'thinkrank'),
378 'form' => 'thinkrank_howto_step_form',
379 'preview_text' => 'title',
380 'multiple' => true,
381 ],
382 'show_numbers' => [
383 'type' => 'select',
384 'label' => __('Numbered steps', 'thinkrank'),
385 'default' => '1',
386 'options' => [
387 '1' => __('Yes', 'thinkrank'),
388 '0' => __('No', 'thinkrank'),
389 ],
390 ],
391 ],
392 ],
393 ],
394 ],
395 'duration' => [
396 'title' => __('Duration', 'thinkrank'),
397 'sections' => [
398 'duration' => [
399 'title' => '',
400 'fields' => [
401 'total_days' => ['type' => 'unit', 'label' => __('Days', 'thinkrank'), 'slider' => false],
402 'total_hours' => ['type' => 'unit', 'label' => __('Hours', 'thinkrank'), 'slider' => false],
403 'total_minutes' => ['type' => 'unit', 'label' => __('Minutes', 'thinkrank'), 'slider' => false],
404 ],
405 ],
406 ],
407 ],
408 'schema' => [
409 'title' => __('Schema', 'thinkrank'),
410 'sections' => [
411 'schema' => [
412 'title' => '',
413 'fields' => [
414 'output_schema' => [
415 'type' => 'select',
416 'label' => __('Output HowTo schema (JSON-LD)', 'thinkrank'),
417 'default' => '1',
418 'options' => [
419 '1' => __('Yes', 'thinkrank'),
420 '0' => __('No', 'thinkrank'),
421 ],
422 'help' => __('Adds HowTo structured data for rich results.', 'thinkrank'),
423 ],
424 ],
425 ],
426 ],
427 ],
428 ]);
429