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 / elementor / class-howto-widget.php

class-howto-widget.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/elementor/class-howto-widget.php

309 lines 10.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Elementor HowTo Widget
5 *
6 * The Elementor counterpart of the thinkrank/howto Gutenberg block: a
7 * step-by-step guide (optional image per step, total time) emitting HowTo
8 * JSON-LD.
9 *
10 * @package ThinkRank
11 * @subpackage Editor\Elementor
12 * @since 1.18.0
13 */
14
15 declare(strict_types=1);
16
17 namespace ThinkRank\Editor\Elementor;
18
19 // Prevent direct access
20 if (!defined('ABSPATH')) {
21 exit;
22 }
23
24 /**
25 * HowTo Widget.
26 *
27 * @since 1.18.0
28 */
29 class Howto_Widget extends \Elementor\Widget_Base {
30
31 public function get_name(): string {
32 return 'thinkrank-howto';
33 }
34
35 public function get_title(): string {
36 return __('HowTo (ThinkRank)', 'thinkrank');
37 }
38
39 public function get_icon(): string {
40 return 'eicon-number-field';
41 }
42
43 public function get_categories(): array {
44 return ['thinkrank'];
45 }
46
47 public function get_keywords(): array {
48 return ['how to', 'steps', 'instructions', 'schema', 'thinkrank'];
49 }
50
51 public function get_style_depends(): array {
52 return ['thinkrank-howto-block'];
53 }
54
55 protected function register_controls(): void {
56 $this->start_controls_section('section_content', [
57 'label' => __('Guide', 'thinkrank'),
58 ]);
59
60 $this->add_control('heading', [
61 'label' => __('Title', 'thinkrank'),
62 'type' => \Elementor\Controls_Manager::TEXT,
63 'placeholder' => __('How to …', 'thinkrank'),
64 'label_block' => true,
65 ]);
66
67 $this->add_control('heading_tag', [
68 'label' => __('Heading tag', 'thinkrank'),
69 'type' => \Elementor\Controls_Manager::SELECT,
70 'default' => 'h2',
71 'options' => ['h2' => 'H2', 'h3' => 'H3', 'h4' => 'H4', 'p' => __('Paragraph', 'thinkrank')],
72 ]);
73
74 $this->add_control('description', [
75 'label' => __('Description', 'thinkrank'),
76 'type' => \Elementor\Controls_Manager::TEXTAREA,
77 ]);
78
79 $repeater = new \Elementor\Repeater();
80 $repeater->add_control('title', [
81 'label' => __('Step title', 'thinkrank'),
82 'type' => \Elementor\Controls_Manager::TEXT,
83 'label_block' => true,
84 ]);
85 $repeater->add_control('text', [
86 'label' => __('Step description', 'thinkrank'),
87 'type' => \Elementor\Controls_Manager::WYSIWYG,
88 ]);
89 $repeater->add_control('image', [
90 'label' => __('Step image', 'thinkrank'),
91 'type' => \Elementor\Controls_Manager::MEDIA,
92 ]);
93
94 $this->add_control('steps', [
95 'label' => __('Steps', 'thinkrank'),
96 'type' => \Elementor\Controls_Manager::REPEATER,
97 'fields' => $repeater->get_controls(),
98 'default' => [['title' => '', 'text' => '']],
99 'title_field' => '{{{ title }}}',
100 ]);
101
102 $this->add_control('show_numbers', [
103 'label' => __('Numbered steps', 'thinkrank'),
104 'type' => \Elementor\Controls_Manager::SWITCHER,
105 'default' => 'yes',
106 ]);
107
108 $this->end_controls_section();
109
110 $this->start_controls_section('section_duration', [
111 'label' => __('Total time', 'thinkrank'),
112 ]);
113
114 foreach (['days' => __('Days', 'thinkrank'), 'hours' => __('Hours', 'thinkrank'), 'minutes' => __('Minutes', 'thinkrank')] as $key => $label) {
115 $this->add_control("total_{$key}", [
116 'label' => $label,
117 'type' => \Elementor\Controls_Manager::NUMBER,
118 'min' => 0,
119 'default' => 0,
120 ]);
121 }
122
123 $this->end_controls_section();
124
125 $this->start_controls_section('section_schema', [
126 'label' => __('Schema', 'thinkrank'),
127 ]);
128
129 $this->add_control('output_schema', [
130 'label' => __('Output HowTo schema (JSON-LD)', 'thinkrank'),
131 'description' => __('Adds HowTo structured data for rich results.', 'thinkrank'),
132 'type' => \Elementor\Controls_Manager::SWITCHER,
133 'default' => 'yes',
134 ]);
135
136 $this->end_controls_section();
137 }
138
139 protected function render(): void {
140 $settings = $this->get_settings_for_display();
141 $steps = is_array($settings['steps'] ?? null) ? $settings['steps'] : [];
142 $items = array_values(array_filter($steps, static function ($step) {
143 return !empty($step['title']) || !empty($step['text']) || !empty($step['image']['url']);
144 }));
145
146 if (empty($items)) {
147 return;
148 }
149
150 $heading_tag = in_array($settings['heading_tag'] ?? 'h2', ['h2', 'h3', 'h4', 'p'], true)
151 ? $settings['heading_tag']
152 : 'h2';
153 $numbered = 'yes' === ($settings['show_numbers'] ?? 'yes');
154 $list_tag = $numbered ? 'ol' : 'ul';
155
156 echo '<div class="thinkrank-howto">';
157
158 if (!empty($settings['heading'])) {
159 printf(
160 '<%1$s class="thinkrank-howto__heading">%2$s</%1$s>',
161 esc_html($heading_tag), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
162 esc_html($settings['heading'])
163 );
164 }
165
166 if (!empty($settings['description'])) {
167 echo '<p class="thinkrank-howto__description">' . esc_html($settings['description']) . '</p>';
168 }
169
170 $duration = $this->format_duration($settings);
171 if ('' !== $duration) {
172 echo '<p class="thinkrank-howto__duration"><strong>' . esc_html__('Total time:', 'thinkrank') . '</strong> ' . esc_html($duration) . '</p>';
173 }
174
175 echo '<' . $list_tag . ' class="thinkrank-howto__steps">'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
176 foreach ($items as $step) {
177 echo '<li class="thinkrank-howto__step">';
178 if (!empty($step['title'])) {
179 echo '<div class="thinkrank-howto__step-title">' . esc_html((string) $step['title']) . '</div>';
180 }
181 if (!empty($step['image']['url'])) {
182 printf(
183 '<img class="thinkrank-howto__step-image" src="%s" alt="%s" />',
184 esc_url((string) $step['image']['url']),
185 esc_attr((string) get_post_meta((int) ($step['image']['id'] ?? 0), '_wp_attachment_image_alt', true))
186 );
187 }
188 if (!empty($step['text'])) {
189 echo '<div class="thinkrank-howto__step-text">' . wp_kses_post((string) $step['text']) . '</div>';
190 }
191 echo '</li>';
192 }
193 echo '</' . $list_tag . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
194
195 echo '</div>';
196
197 $this->maybe_render_schema($settings, $items);
198 }
199
200 /**
201 * Human-readable total time, '' when unset.
202 *
203 * @param array $settings Widget settings.
204 * @return string
205 */
206 private function format_duration(array $settings): string {
207 $days = max(0, (int) ($settings['total_days'] ?? 0));
208 $hours = max(0, (int) ($settings['total_hours'] ?? 0));
209 $minutes = max(0, (int) ($settings['total_minutes'] ?? 0));
210
211 $parts = [];
212 if ($days > 0) {
213 /* translators: %d: number of days. */
214 $parts[] = sprintf(_n('%d day', '%d days', $days, 'thinkrank'), $days);
215 }
216 if ($hours > 0) {
217 /* translators: %d: number of hours. */
218 $parts[] = sprintf(_n('%d hour', '%d hours', $hours, 'thinkrank'), $hours);
219 }
220 if ($minutes > 0) {
221 /* translators: %d: number of minutes. */
222 $parts[] = sprintf(_n('%d minute', '%d minutes', $minutes, 'thinkrank'), $minutes);
223 }
224
225 return implode(', ', $parts);
226 }
227
228 /**
229 * Emit HowTo JSON-LD on the front end (never in the editor preview).
230 *
231 * @param array $settings Widget settings.
232 * @param array $items Non-empty steps.
233 * @return void
234 */
235 private function maybe_render_schema(array $settings, array $items): void {
236
237 // The Schema master switch and the matrix's per-content-type switch.
238 // This widget echoes its own <script> rather than registering with
239 // Schema_Graph, so gating the graph never reached it and it kept
240 // publishing with Schema switched off (#688).
241 if (class_exists('ThinkRank\\Frontend\\Schema_Graph')
242 && !\ThinkRank\Frontend\Schema_Graph::output_allowed()) {
243 return;
244 }
245 if ('yes' !== ($settings['output_schema'] ?? 'yes')) {
246 return;
247 }
248
249 if (\Elementor\Plugin::$instance->editor->is_edit_mode()) {
250 return;
251 }
252
253 $step_entities = [];
254 foreach ($items as $step) {
255 $title = trim(wp_strip_all_tags((string) ($step['title'] ?? '')));
256 $text = trim(wp_strip_all_tags((string) ($step['text'] ?? '')));
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 if (!empty($step['image']['url'])) {
270 $entity['image'] = [
271 '@type' => 'ImageObject',
272 'url' => esc_url_raw((string) $step['image']['url']),
273 ];
274 }
275
276 $step_entities[] = $entity;
277 }
278
279 if (empty($step_entities)) {
280 return;
281 }
282
283 $heading = trim(wp_strip_all_tags((string) ($settings['heading'] ?? '')));
284 $schema = [
285 '@context' => 'https://schema.org',
286 '@type' => 'HowTo',
287 'name' => $heading !== '' ? $heading : get_the_title(),
288 'step' => $step_entities,
289 ];
290
291 $description = trim(wp_strip_all_tags((string) ($settings['description'] ?? '')));
292 if ($description !== '') {
293 $schema['description'] = $description;
294 }
295
296 $days = max(0, (int) ($settings['total_days'] ?? 0));
297 $hours = max(0, (int) ($settings['total_hours'] ?? 0));
298 $minutes = max(0, (int) ($settings['total_minutes'] ?? 0));
299 if ($days + $hours + $minutes > 0) {
300 $schema['totalTime'] = sprintf('P%dDT%dH%dM', $days, $hours, $minutes);
301 }
302
303 $json = wp_json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
304 if (false !== $json) {
305 echo '<script type="application/ld+json">' . $json . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
306 }
307 }
308 }
309