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

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

393 lines 13.7 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 FAQ Module
5 *
6 * The Beaver counterpart of the thinkrank/faq Gutenberg block, the Elementor
7 * FAQ widget and the Bricks FAQ element: an inline Q&A accordion built on
8 * native <details>, no JS, with FAQPage JSON-LD (#662).
9 *
10 * The markup and CSS classes are identical to the block's, so all four builders
11 * 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 module's questions from Beaver Builder's stored layout during `wp_head` —
15 * see `Schema_Graph::collect_beaver_faq()` — and merges them into the request's
16 * one FAQPage. By the time this renders, that has already happened, so emitting
17 * here as well would recreate the duplicate the graph exists to prevent (#355).
18 * The inline block below is the fallback for requests where no graph is rendered.
19 *
20 * The class is deliberately in the global namespace with a `ThinkRank_` prefix
21 * rather than under `ThinkRank\Editor\Beaver`: Beaver Builder resolves modules
22 * by class-name string, derives paths from a `ReflectionClass` over them, and
23 * every module in its own tree and its ecosystem is global. Following the host's
24 * convention is worth more here than namespace tidiness.
25 *
26 * @package ThinkRank
27 * @subpackage Editor\Beaver
28 * @since 2.5.0
29 */
30
31 declare(strict_types=1);
32
33 // Prevent direct access
34 if (!defined('ABSPATH')) {
35 exit;
36 }
37
38 /**
39 * FAQ Module.
40 *
41 * @since 2.5.0
42 */
43 class ThinkRank_Beaver_FAQ_Module extends FLBuilderModule {
44
45 /**
46 * The module slug, and the settings `type` its stored nodes carry.
47 *
48 * Shared with `Schema_Graph`, which matches stored layout nodes on it.
49 */
50 public const SLUG = 'thinkrank-faq';
51
52 /**
53 * Constructor.
54 */
55 public function __construct() {
56 parent::__construct([
57 'name' => __('FAQ (ThinkRank)', 'thinkrank'),
58 'description' => __('An accordion of questions and answers that emits FAQPage schema.', 'thinkrank'),
59 'category' => __('ThinkRank', 'thinkrank'),
60 'slug' => self::SLUG,
61 // `dir`/`url` are passed explicitly. Beaver Builder can infer them
62 // from ABSPATH, but that inference breaks when the plugin is
63 // symlinked or WordPress is in a subdirectory, and the module would
64 // then look for its frontend template under a path that does not
65 // exist.
66 'dir' => THINKRANK_PLUGIN_DIR . 'includes/editor/beaver/faq/',
67 'url' => THINKRANK_PLUGIN_URL . 'includes/editor/beaver/faq/',
68 // Re-render over AJAX while typing instead of reloading the page.
69 'partial_refresh' => true,
70 ]);
71 }
72
73 /**
74 * Render the accordion.
75 *
76 * Called from `includes/frontend.php`, which is the only entry point
77 * Beaver Builder offers.
78 *
79 * @since 2.5.0
80 * @param object|array $settings Module settings.
81 * @return void
82 */
83 public function render_content($settings): void {
84 $settings = self::to_array($settings);
85 $items = self::usable_items($settings);
86
87 if (empty($items)) {
88 // A module with nothing in it still needs a box in the builder, or
89 // the author cannot select what they just dropped on the canvas.
90 if (self::in_builder()) {
91 echo '<div class="thinkrank-faq">' . esc_html__('Add a question to get started.', 'thinkrank') . '</div>';
92 }
93 return;
94 }
95
96 wp_enqueue_style('thinkrank-faq-block');
97
98 $heading_tag = self::heading_tag($settings);
99 $first_open = !empty($settings['first_open']);
100
101 $output = '<div class="thinkrank-faq">';
102
103 $heading = trim((string) ($settings['heading'] ?? ''));
104 if ('' !== $heading) {
105 $output .= sprintf(
106 '<%1$s class="thinkrank-faq__heading">%2$s</%1$s>',
107 esc_html($heading_tag),
108 esc_html($heading)
109 );
110 }
111
112 foreach ($items as $index => $faq) {
113 $output .= '<details class="thinkrank-faq__item"' . ($first_open && 0 === $index ? ' open' : '') . '>';
114 $output .= '<summary class="thinkrank-faq__question">'
115 . esc_html((string) ($faq['question'] ?? ''))
116 . '</summary>';
117 $output .= '<div class="thinkrank-faq__answer">'
118 . wp_kses_post((string) ($faq['answer'] ?? ''))
119 . '</div>';
120 $output .= '</details>';
121 }
122
123 $output .= '</div>';
124
125 echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
126
127 $this->maybe_render_schema($settings);
128 }
129
130 /**
131 * Normalize Beaver Builder settings to an array.
132 *
133 * Settings arrive as a stdClass, and repeater rows inside them are objects
134 * too, so a plain `(array)` cast leaves the rows untouched. Everything
135 * downstream — including `Schema_Graph`, which reads the same rows straight
136 * out of postmeta — wants arrays all the way down.
137 *
138 * @since 2.5.0
139 * @param mixed $settings Raw settings.
140 * @return array<string,mixed>
141 */
142 public static function to_array($settings): array {
143 if (is_object($settings)) {
144 $settings = get_object_vars($settings);
145 }
146
147 if (!is_array($settings)) {
148 return [];
149 }
150
151 foreach ($settings as $key => $value) {
152 if (is_object($value) || is_array($value)) {
153 $settings[$key] = json_decode((string) wp_json_encode($value), true) ?? [];
154 }
155 }
156
157 return $settings;
158 }
159
160 /**
161 * The repeater rows worth rendering.
162 *
163 * Public so `Schema_Graph` reads the module exactly as it renders — one
164 * definition of "a usable FAQ item" for both the page and its schema.
165 *
166 * @since 2.5.0
167 * @param array $settings Module settings.
168 * @return array<int,array>
169 */
170 public static function usable_items(array $settings): array {
171 $faqs = is_array($settings['faqs'] ?? null) ? $settings['faqs'] : [];
172
173 return array_values(array_filter($faqs, static function ($faq) {
174 return is_array($faq) && (!empty($faq['question']) || !empty($faq['answer']));
175 }));
176 }
177
178 /**
179 * FAQPage question entities from this module's settings.
180 *
181 * Shared with the graph's collection pass so a question absorbed there and
182 * one printed here can never disagree.
183 *
184 * @since 2.5.0
185 * @param array $settings Module settings.
186 * @return array<int,array>
187 */
188 public static function question_entities(array $settings): array {
189 $entities = [];
190
191 foreach (self::usable_items($settings) as $faq) {
192 $question = trim(wp_strip_all_tags((string) ($faq['question'] ?? '')));
193 $answer = trim((string) ($faq['answer'] ?? ''));
194
195 if ('' === $question || '' === $answer) {
196 continue;
197 }
198
199 $entities[] = [
200 '@type' => 'Question',
201 'name' => $question,
202 'acceptedAnswer' => [
203 '@type' => 'Answer',
204 'text' => wp_kses_post($answer),
205 ],
206 ];
207 }
208
209 return $entities;
210 }
211
212 /**
213 * Whether a module's settings arm its FAQ schema.
214 *
215 * Beaver Builder writes an unchecked checkbox as the string `'0'`, not an
216 * absent key, so `empty()` is the correct test and a cleared toggle really
217 * does clear. This differs from Bricks, where clearing drops the key
218 * entirely — the same-looking check has a different reason behind it in
219 * each builder.
220 *
221 * @since 2.5.0
222 * @param array $settings Module settings.
223 * @return bool
224 */
225 public static function schema_enabled(array $settings): bool {
226 return !empty($settings['output_schema']);
227 }
228
229 /**
230 * Whether Beaver Builder's editor is rendering this.
231 *
232 * Covers both the builder page itself and the AJAX partial refreshes it
233 * fires while the author types.
234 *
235 * @since 2.5.0
236 * @return bool
237 */
238 private static function in_builder(): bool {
239 return class_exists('\\FLBuilderModel') && (bool) \FLBuilderModel::is_builder_active();
240 }
241
242 /**
243 * Emit FAQPage JSON-LD, unless somebody else already has.
244 *
245 * @param array $settings Module settings.
246 * @return void
247 */
248 private function maybe_render_schema(array $settings): void {
249 if (!self::schema_enabled($settings)) {
250 return;
251 }
252
253 // Never in the builder canvas: the author is looking at a preview, and
254 // Beaver Builder re-renders modules over AJAX as they type.
255 if (self::in_builder()) {
256 return;
257 }
258
259 // The graph collected this module's questions during wp_head and owns
260 // the page's single FAQPage now (#355). Either way this module stays
261 // quiet.
262 if (class_exists('ThinkRank\\Frontend\\Schema_Graph')
263 && \ThinkRank\Frontend\Schema_Graph::instance()->absorbed_content_faq()
264 ) {
265 return;
266 }
267
268 $entities = self::question_entities($settings);
269 if (empty($entities)) {
270 return;
271 }
272
273 $json = wp_json_encode([
274 '@context' => 'https://schema.org',
275 '@type' => 'FAQPage',
276 'mainEntity' => $entities,
277 ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
278
279 if (false !== $json) {
280 echo '<script type="application/ld+json">' . $json . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
281 }
282 }
283
284 /**
285 * A heading tag from the allowed set.
286 *
287 * @param array $settings Module settings.
288 * @return string
289 */
290 private static function heading_tag(array $settings): string {
291 $tag = (string) ($settings['heading_tag'] ?? 'h2');
292
293 return in_array($tag, ['h2', 'h3', 'h4', 'p'], true) ? $tag : 'h2';
294 }
295 }
296
297 /**
298 * The repeater row form. Registered separately because Beaver Builder resolves
299 * a `type => form` field by id, unlike Elementor's and Bricks' inline repeaters.
300 */
301 FLBuilder::register_settings_form('thinkrank_faq_item_form', [
302 'title' => __('Question', 'thinkrank'),
303 'tabs' => [
304 'general' => [
305 'title' => __('General', 'thinkrank'),
306 'sections' => [
307 'general' => [
308 'title' => '',
309 'fields' => [
310 'question' => [
311 'type' => 'text',
312 'label' => __('Question', 'thinkrank'),
313 'connections' => ['string'],
314 ],
315 'answer' => [
316 'type' => 'editor',
317 'label' => __('Answer', 'thinkrank'),
318 'media_buttons' => false,
319 'connections' => ['string'],
320 ],
321 ],
322 ],
323 ],
324 ],
325 ],
326 ]);
327
328 FLBuilder::register_module('ThinkRank_Beaver_FAQ_Module', [
329 'general' => [
330 'title' => __('Questions', 'thinkrank'),
331 'sections' => [
332 'content' => [
333 'title' => '',
334 'fields' => [
335 'heading' => [
336 'type' => 'text',
337 'label' => __('Section heading', 'thinkrank'),
338 'default' => __('Frequently asked questions', 'thinkrank'),
339 'connections' => ['string'],
340 ],
341 'heading_tag' => [
342 'type' => 'select',
343 'label' => __('Heading tag', 'thinkrank'),
344 'default' => 'h2',
345 'options' => [
346 'h2' => 'H2',
347 'h3' => 'H3',
348 'h4' => 'H4',
349 'p' => __('Paragraph', 'thinkrank'),
350 ],
351 ],
352 'faqs' => [
353 'type' => 'form',
354 'label' => __('Question', 'thinkrank'),
355 'form' => 'thinkrank_faq_item_form',
356 'preview_text' => 'question',
357 'multiple' => true,
358 ],
359 'first_open' => [
360 'type' => 'select',
361 'label' => __('Open first item by default', 'thinkrank'),
362 'default' => '1',
363 'options' => [
364 '1' => __('Yes', 'thinkrank'),
365 '0' => __('No', 'thinkrank'),
366 ],
367 ],
368 ],
369 ],
370 ],
371 ],
372 'schema' => [
373 'title' => __('Schema', 'thinkrank'),
374 'sections' => [
375 'schema' => [
376 'title' => '',
377 'fields' => [
378 'output_schema' => [
379 'type' => 'select',
380 'label' => __('Output FAQ schema (JSON-LD)', 'thinkrank'),
381 'default' => '1',
382 'options' => [
383 '1' => __('Yes', 'thinkrank'),
384 '0' => __('No', 'thinkrank'),
385 ],
386 'help' => __('Adds FAQPage structured data for rich results.', 'thinkrank'),
387 ],
388 ],
389 ],
390 ],
391 ],
392 ]);
393