PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.11.0 All 47 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 trunk, at includes/editor/bricks/class-faq-element.php

334 lines 10.5 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 unset($items);
286
287 if (!self::schema_enabled($settings)) {
288 return;
289 }
290
291 // Never in the builder canvas: the author is looking at a preview, and
292 // Bricks re-renders elements over AJAX as they type.
293 if (bricks_is_builder_call() || bricks_is_builder()) {
294 return;
295 }
296
297 // The graph collected this element's questions during wp_head and owns
298 // the page's single FAQPage now (#355) — or a Bricks accordion owns it
299 // and the graph stood down (#649). Either way this element stays quiet.
300 if (class_exists('ThinkRank\\Frontend\\Schema_Graph')
301 && \ThinkRank\Frontend\Schema_Graph::instance()->absorbed_content_faq()
302 ) {
303 return;
304 }
305
306 $entities = self::question_entities($settings);
307 if (empty($entities)) {
308 return;
309 }
310
311 $json = wp_json_encode([
312 '@context' => 'https://schema.org',
313 '@type' => 'FAQPage',
314 'mainEntity' => $entities,
315 ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
316
317 if (false !== $json) {
318 echo '<script type="application/ld+json">' . $json . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
319 }
320 }
321
322 /**
323 * A heading tag from the allowed set.
324 *
325 * @param array $settings Element settings.
326 * @return string
327 */
328 private static function heading_tag(array $settings): string {
329 $tag = (string) ($settings['headingTag'] ?? 'h2');
330
331 return in_array($tag, ['h2', 'h3', 'h4', 'p'], true) ? $tag : 'h2';
332 }
333 }
334