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-faq-element.php

class-faq-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-faq-element.php

343 lines 11.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Bricks FAQ Element
5 *
6 * The Bricks counterpart of the thinkrank/faq Gutenberg block and the Elementor
7 * FAQ widget: an inline Q&A accordion built on native <details>, no JS, with
8 * FAQPage JSON-LD (#626).
9 *
10 * The markup and CSS classes are identical to the block's, so the three
11 * builders render the same accordion from the same stylesheet.
12 *
13 * Schema goes through Schema_Graph, not straight to the page. The graph collects
14 * this element's questions from Bricks' stored tree during `wp_head` — see
15 * `Schema_Graph::collect_bricks_faq()` — and merges them into the request's one
16 * FAQPage. By the time this renders, that has already happened, so emitting here
17 * as well would recreate the duplicate the graph exists to prevent (#355). The
18 * inline script below is the fallback for the requests where no graph is
19 * rendered at all.
20 *
21 * @package ThinkRank
22 * @subpackage Editor\Bricks
23 * @since 2.3.1
24 */
25
26 declare(strict_types=1);
27
28 namespace ThinkRank\Editor\Bricks;
29
30 // Prevent direct access
31 if (!defined('ABSPATH')) {
32 exit;
33 }
34
35 /**
36 * FAQ Element.
37 *
38 * @since 2.3.1
39 */
40 class FAQ_Element extends \Bricks\Element {
41
42 /**
43 * Builder category.
44 *
45 * @var string
46 */
47 public $category = 'thinkrank';
48
49 /**
50 * Element name, matching the Elementor widget's.
51 *
52 * @var string
53 */
54 public $name = 'thinkrank-faq';
55
56 /**
57 * Panel icon (Themify, which Bricks bundles).
58 *
59 * @var string
60 */
61 public $icon = 'ti-help-alt';
62
63 /**
64 * Label shown in the element panel.
65 *
66 * @return string
67 */
68 public function get_label(): string {
69 return esc_html__('FAQ (ThinkRank)', 'thinkrank');
70 }
71
72 /**
73 * Panel search terms.
74 *
75 * @return array
76 */
77 public function get_keywords(): array {
78 return ['faq', 'questions', 'accordion', 'schema', 'thinkrank'];
79 }
80
81 /**
82 * Load the shared block stylesheet, but only on a page using this element.
83 *
84 * @return void
85 */
86 public function enqueue_scripts(): void {
87 wp_enqueue_style('thinkrank-faq-block');
88 }
89
90 /**
91 * Controls.
92 *
93 * @return void
94 */
95 public function set_controls(): void {
96 $this->controls['heading'] = [
97 'label' => esc_html__('Section heading', 'thinkrank'),
98 'type' => 'text',
99 'default' => esc_html__('Frequently asked questions', 'thinkrank'),
100 ];
101
102 $this->controls['headingTag'] = [
103 'label' => esc_html__('Heading tag', 'thinkrank'),
104 'type' => 'select',
105 'options' => [
106 'h2' => 'H2',
107 'h3' => 'H3',
108 'h4' => 'H4',
109 'p' => esc_html__('Paragraph', 'thinkrank'),
110 ],
111 'default' => 'h2',
112 'inline' => true,
113 ];
114
115 $this->controls['faqs'] = [
116 'label' => esc_html__('Questions', 'thinkrank'),
117 'type' => 'repeater',
118 'titleProperty' => 'question',
119 'fields' => [
120 'question' => [
121 'label' => esc_html__('Question', 'thinkrank'),
122 'type' => 'text',
123 ],
124 'answer' => [
125 'label' => esc_html__('Answer', 'thinkrank'),
126 'type' => 'editor',
127 ],
128 ],
129 'default' => [
130 [
131 'question' => esc_html__('What is your refund policy?', 'thinkrank'),
132 'answer' => esc_html__('Describe the answer here.', 'thinkrank'),
133 ],
134 ],
135 ];
136
137 $this->controls['firstOpen'] = [
138 'label' => esc_html__('Open first item by default', 'thinkrank'),
139 'type' => 'checkbox',
140 'default' => true,
141 ];
142
143 $this->controls['outputSchema'] = [
144 'label' => esc_html__('Output FAQ schema (JSON-LD)', 'thinkrank'),
145 'type' => 'checkbox',
146 'default' => true,
147 'description' => esc_html__('Adds FAQPage structured data for rich results.', 'thinkrank'),
148 ];
149 }
150
151 /**
152 * Render.
153 *
154 * @return void
155 */
156 public function render(): void {
157 $settings = $this->settings;
158 $items = self::usable_items($settings);
159
160 if (empty($items)) {
161 // An element with nothing in it still needs a box in the builder,
162 // or the author cannot select what they just dropped on the canvas.
163 if (bricks_is_builder_call()) {
164 echo '<div ' . $this->render_attributes('_root') . '>' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
165 . esc_html__('Add a question to get started.', 'thinkrank')
166 . '</div>';
167 }
168 return;
169 }
170
171 $this->set_attribute('_root', 'class', 'thinkrank-faq');
172
173 $heading_tag = self::heading_tag($settings);
174 $first_open = !empty($settings['firstOpen']);
175
176 $output = '<div ' . $this->render_attributes('_root') . '>';
177
178 $heading = trim((string) ($settings['heading'] ?? ''));
179 if ('' !== $heading) {
180 $output .= sprintf(
181 '<%1$s class="thinkrank-faq__heading">%2$s</%1$s>',
182 esc_html($heading_tag),
183 esc_html($this->render_dynamic_data($heading))
184 );
185 }
186
187 foreach ($items as $index => $faq) {
188 $answer = \Bricks\Helpers::parse_editor_content(
189 $this->render_dynamic_data((string) ($faq['answer'] ?? ''))
190 );
191
192 $output .= '<details class="thinkrank-faq__item"' . ($first_open && 0 === $index ? ' open' : '') . '>';
193 $output .= '<summary class="thinkrank-faq__question">'
194 . esc_html($this->render_dynamic_data((string) ($faq['question'] ?? '')))
195 . '</summary>';
196 $output .= '<div class="thinkrank-faq__answer">' . wp_kses_post($answer) . '</div>';
197 $output .= '</details>';
198 }
199
200 $output .= '</div>';
201
202 echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
203
204 $this->maybe_render_schema($settings, $items);
205 }
206
207 /**
208 * The repeater rows worth rendering.
209 *
210 * Public so `Schema_Graph` reads the element exactly as it renders — one
211 * definition of "a usable FAQ item" for both the page and its schema.
212 *
213 * @since 2.3.1
214 * @param array $settings Element settings.
215 * @return array<int,array>
216 */
217 public static function usable_items(array $settings): array {
218 $faqs = is_array($settings['faqs'] ?? null) ? $settings['faqs'] : [];
219
220 return array_values(array_filter($faqs, static function ($faq) {
221 return is_array($faq) && (!empty($faq['question']) || !empty($faq['answer']));
222 }));
223 }
224
225 /**
226 * FAQPage question entities from this element's settings.
227 *
228 * Shared with the graph's collection pass so a question absorbed there and
229 * one printed here can never disagree.
230 *
231 * @since 2.3.1
232 * @param array $settings Element settings.
233 * @return array<int,array>
234 */
235 public static function question_entities(array $settings): array {
236 $entities = [];
237
238 foreach (self::usable_items($settings) as $faq) {
239 $question = trim(wp_strip_all_tags((string) ($faq['question'] ?? '')));
240 $answer = trim((string) ($faq['answer'] ?? ''));
241
242 if ('' === $question || '' === $answer) {
243 continue;
244 }
245
246 $entities[] = [
247 '@type' => 'Question',
248 'name' => $question,
249 'acceptedAnswer' => [
250 '@type' => 'Answer',
251 'text' => wp_kses_post($answer),
252 ],
253 ];
254 }
255
256 return $entities;
257 }
258
259 /**
260 * Whether an element's settings arm its FAQ schema.
261 *
262 * Bricks stores a checkbox as `true` and clears it by dropping the key
263 * entirely, so an absent value is "off" — the control's `'default' => true`
264 * is written into the element's settings when it is created, which is why
265 * every Bricks element tests these with `isset()` rather than defaulting in
266 * PHP (see `animated-typing.php:185`). Defaulting to on here would make the
267 * toggle unclearable.
268 *
269 * @since 2.3.1
270 * @param array $settings Element settings.
271 * @return bool
272 */
273 public static function schema_enabled(array $settings): bool {
274 return !empty($settings['outputSchema']);
275 }
276
277 /**
278 * Emit FAQPage JSON-LD, unless somebody else already has.
279 *
280 * @param array $settings Element settings.
281 * @param array $items Usable FAQ rows.
282 * @return void
283 */
284 private function maybe_render_schema(array $settings, array $items): void {
285
286 // The Schema master switch and the matrix's per-content-type switch.
287 // This widget echoes its own <script> rather than registering with
288 // Schema_Graph, so gating the graph never reached it and it kept
289 // publishing with Schema switched off (#688).
290 if (class_exists('ThinkRank\\Frontend\\Schema_Graph')
291 && !\ThinkRank\Frontend\Schema_Graph::output_allowed()) {
292 return;
293 }
294 unset($items);
295
296 if (!self::schema_enabled($settings)) {
297 return;
298 }
299
300 // Never in the builder canvas: the author is looking at a preview, and
301 // Bricks re-renders elements over AJAX as they type.
302 if (bricks_is_builder_call() || bricks_is_builder()) {
303 return;
304 }
305
306 // The graph collected this element's questions during wp_head and owns
307 // the page's single FAQPage now (#355) — or a Bricks accordion owns it
308 // and the graph stood down (#649). Either way this element stays quiet.
309 if (class_exists('ThinkRank\\Frontend\\Schema_Graph')
310 && \ThinkRank\Frontend\Schema_Graph::instance()->absorbed_content_faq()
311 ) {
312 return;
313 }
314
315 $entities = self::question_entities($settings);
316 if (empty($entities)) {
317 return;
318 }
319
320 $json = wp_json_encode([
321 '@context' => 'https://schema.org',
322 '@type' => 'FAQPage',
323 'mainEntity' => $entities,
324 ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
325
326 if (false !== $json) {
327 echo '<script type="application/ld+json">' . $json . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
328 }
329 }
330
331 /**
332 * A heading tag from the allowed set.
333 *
334 * @param array $settings Element settings.
335 * @return string
336 */
337 private static function heading_tag(array $settings): string {
338 $tag = (string) ($settings['headingTag'] ?? 'h2');
339
340 return in_array($tag, ['h2', 'h3', 'h4', 'p'], true) ? $tag : 'h2';
341 }
342 }
343