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 / 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.7.0, at includes/editor/beaver/howto/howto.php

438 lines 14.9 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
240 // The Schema master switch and the matrix's per-content-type switch.
241 // This widget echoes its own <script> rather than registering with
242 // Schema_Graph, so gating the graph never reached it and it kept
243 // publishing with Schema switched off (#688).
244 if (class_exists('ThinkRank\\Frontend\\Schema_Graph')
245 && !\ThinkRank\Frontend\Schema_Graph::output_allowed()) {
246 return;
247 }
248 if (empty($settings['output_schema']) || self::in_builder()) {
249 return;
250 }
251
252 $step_entities = [];
253 foreach ($items as $step) {
254 $title = trim(wp_strip_all_tags((string) ($step['title'] ?? '')));
255 $text = trim(wp_strip_all_tags((string) ($step['text'] ?? '')));
256
257 if ('' === $title && '' === $text) {
258 continue;
259 }
260
261 $entity = ['@type' => 'HowToStep'];
262 if ('' !== $title && '' !== $text) {
263 $entity['name'] = $title;
264 $entity['text'] = $text;
265 } else {
266 $entity['text'] = '' !== $text ? $text : $title;
267 }
268
269 $image = self::step_image($step);
270 if (null !== $image) {
271 $entity['image'] = [
272 '@type' => 'ImageObject',
273 'url' => esc_url_raw($image['url']),
274 ];
275 }
276
277 $step_entities[] = $entity;
278 }
279
280 if (empty($step_entities)) {
281 return;
282 }
283
284 $heading = trim(wp_strip_all_tags((string) ($settings['heading'] ?? '')));
285 $schema = [
286 '@context' => 'https://schema.org',
287 '@type' => 'HowTo',
288 'name' => '' !== $heading ? $heading : (string) get_the_title(),
289 'step' => $step_entities,
290 ];
291
292 $description = trim(wp_strip_all_tags((string) ($settings['description'] ?? '')));
293 if ('' !== $description) {
294 $schema['description'] = $description;
295 }
296
297 [$days, $hours, $minutes] = self::duration_parts($settings);
298 if ($days + $hours + $minutes > 0) {
299 $schema['totalTime'] = sprintf('P%dDT%dH%dM', $days, $hours, $minutes);
300 }
301
302 $json = wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
303
304 if (false !== $json) {
305 echo '<script type="application/ld+json">' . $json . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
306 }
307 }
308
309 /**
310 * A heading tag from the allowed set.
311 *
312 * @param array $settings Module settings.
313 * @return string
314 */
315 private static function heading_tag(array $settings): string {
316 $tag = (string) ($settings['heading_tag'] ?? 'h2');
317
318 return in_array($tag, ['h2', 'h3', 'h4', 'p'], true) ? $tag : 'h2';
319 }
320 }
321
322 FLBuilder::register_settings_form('thinkrank_howto_step_form', [
323 'title' => __('Step', 'thinkrank'),
324 'tabs' => [
325 'general' => [
326 'title' => __('General', 'thinkrank'),
327 'sections' => [
328 'general' => [
329 'title' => '',
330 'fields' => [
331 'title' => [
332 'type' => 'text',
333 'label' => __('Step title', 'thinkrank'),
334 'connections' => ['string'],
335 ],
336 'text' => [
337 'type' => 'editor',
338 'label' => __('Step description', 'thinkrank'),
339 'media_buttons' => false,
340 'connections' => ['string'],
341 ],
342 'image' => [
343 'type' => 'photo',
344 'label' => __('Step image', 'thinkrank'),
345 'show_remove' => true,
346 ],
347 ],
348 ],
349 ],
350 ],
351 ],
352 ]);
353
354 FLBuilder::register_module('ThinkRank_Beaver_Howto_Module', [
355 'general' => [
356 'title' => __('Steps', 'thinkrank'),
357 'sections' => [
358 'content' => [
359 'title' => '',
360 'fields' => [
361 'heading' => [
362 'type' => 'text',
363 'label' => __('Title', 'thinkrank'),
364 'placeholder' => __('How to …', 'thinkrank'),
365 'connections' => ['string'],
366 ],
367 'heading_tag' => [
368 'type' => 'select',
369 'label' => __('Heading tag', 'thinkrank'),
370 'default' => 'h2',
371 'options' => [
372 'h2' => 'H2',
373 'h3' => 'H3',
374 'h4' => 'H4',
375 'p' => __('Paragraph', 'thinkrank'),
376 ],
377 ],
378 'description' => [
379 'type' => 'textarea',
380 'label' => __('Description', 'thinkrank'),
381 'rows' => 4,
382 'connections' => ['string'],
383 ],
384 'steps' => [
385 'type' => 'form',
386 'label' => __('Step', 'thinkrank'),
387 'form' => 'thinkrank_howto_step_form',
388 'preview_text' => 'title',
389 'multiple' => true,
390 ],
391 'show_numbers' => [
392 'type' => 'select',
393 'label' => __('Numbered steps', 'thinkrank'),
394 'default' => '1',
395 'options' => [
396 '1' => __('Yes', 'thinkrank'),
397 '0' => __('No', 'thinkrank'),
398 ],
399 ],
400 ],
401 ],
402 ],
403 ],
404 'duration' => [
405 'title' => __('Duration', 'thinkrank'),
406 'sections' => [
407 'duration' => [
408 'title' => '',
409 'fields' => [
410 'total_days' => ['type' => 'unit', 'label' => __('Days', 'thinkrank'), 'slider' => false],
411 'total_hours' => ['type' => 'unit', 'label' => __('Hours', 'thinkrank'), 'slider' => false],
412 'total_minutes' => ['type' => 'unit', 'label' => __('Minutes', 'thinkrank'), 'slider' => false],
413 ],
414 ],
415 ],
416 ],
417 'schema' => [
418 'title' => __('Schema', 'thinkrank'),
419 'sections' => [
420 'schema' => [
421 'title' => '',
422 'fields' => [
423 'output_schema' => [
424 'type' => 'select',
425 'label' => __('Output HowTo schema (JSON-LD)', 'thinkrank'),
426 'default' => '1',
427 'options' => [
428 '1' => __('Yes', 'thinkrank'),
429 '0' => __('No', 'thinkrank'),
430 ],
431 'help' => __('Adds HowTo structured data for rich results.', 'thinkrank'),
432 ],
433 ],
434 ],
435 ],
436 ],
437 ]);
438