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

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

212 lines 7.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Elementor FAQ Widget
5 *
6 * The Elementor counterpart of the thinkrank/faq Gutenberg block: an inline
7 * Q&A accordion (native <details>, no JS) emitting FAQPage JSON-LD.
8 *
9 * @package ThinkRank
10 * @subpackage Editor\Elementor
11 * @since 1.18.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\Editor\Elementor;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * FAQ Widget.
25 *
26 * @since 1.18.0
27 */
28 class FAQ_Widget extends \Elementor\Widget_Base {
29
30 public function get_name(): string {
31 return 'thinkrank-faq';
32 }
33
34 public function get_title(): string {
35 return __('FAQ (ThinkRank)', 'thinkrank');
36 }
37
38 public function get_icon(): string {
39 return 'eicon-help-o';
40 }
41
42 public function get_categories(): array {
43 return ['thinkrank'];
44 }
45
46 public function get_keywords(): array {
47 return ['faq', 'questions', 'accordion', 'schema', 'thinkrank'];
48 }
49
50 public function get_style_depends(): array {
51 return ['thinkrank-faq-block'];
52 }
53
54 protected function register_controls(): void {
55 $this->start_controls_section('section_content', [
56 'label' => __('FAQ', 'thinkrank'),
57 ]);
58
59 $this->add_control('heading', [
60 'label' => __('Section heading', 'thinkrank'),
61 'type' => \Elementor\Controls_Manager::TEXT,
62 'default' => __('Frequently asked questions', 'thinkrank'),
63 'label_block' => true,
64 ]);
65
66 $this->add_control('heading_tag', [
67 'label' => __('Heading tag', 'thinkrank'),
68 'type' => \Elementor\Controls_Manager::SELECT,
69 'default' => 'h2',
70 'options' => ['h2' => 'H2', 'h3' => 'H3', 'h4' => 'H4', 'p' => __('Paragraph', 'thinkrank')],
71 ]);
72
73 $repeater = new \Elementor\Repeater();
74 $repeater->add_control('question', [
75 'label' => __('Question', 'thinkrank'),
76 'type' => \Elementor\Controls_Manager::TEXT,
77 'label_block' => true,
78 ]);
79 $repeater->add_control('answer', [
80 'label' => __('Answer', 'thinkrank'),
81 'type' => \Elementor\Controls_Manager::WYSIWYG,
82 ]);
83
84 $this->add_control('faqs', [
85 'label' => __('Questions', 'thinkrank'),
86 'type' => \Elementor\Controls_Manager::REPEATER,
87 'fields' => $repeater->get_controls(),
88 'default' => [['question' => '', 'answer' => '']],
89 'title_field' => '{{{ question }}}',
90 ]);
91
92 $this->add_control('first_open', [
93 'label' => __('Open first item by default', 'thinkrank'),
94 'type' => \Elementor\Controls_Manager::SWITCHER,
95 'default' => 'yes',
96 ]);
97
98 $this->add_control('output_schema', [
99 'label' => __('Output FAQ schema (JSON-LD)', 'thinkrank'),
100 'description' => __('Adds FAQPage structured data for rich results.', 'thinkrank'),
101 'type' => \Elementor\Controls_Manager::SWITCHER,
102 'default' => 'yes',
103 ]);
104
105 $this->end_controls_section();
106 }
107
108 protected function render(): void {
109 $settings = $this->get_settings_for_display();
110 $faqs = is_array($settings['faqs'] ?? null) ? $settings['faqs'] : [];
111 $items = array_values(array_filter($faqs, static function ($faq) {
112 return !empty($faq['question']) || !empty($faq['answer']);
113 }));
114
115 if (empty($items)) {
116 return;
117 }
118
119 $heading_tag = in_array($settings['heading_tag'] ?? 'h2', ['h2', 'h3', 'h4', 'p'], true)
120 ? $settings['heading_tag']
121 : 'h2';
122
123 echo '<div class="thinkrank-faq">';
124
125 if (!empty($settings['heading'])) {
126 printf(
127 '<%1$s class="thinkrank-faq__heading">%2$s</%1$s>',
128 esc_html($heading_tag), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
129 esc_html($settings['heading'])
130 );
131 }
132
133 foreach ($items as $index => $faq) {
134 $open = ('yes' === ($settings['first_open'] ?? '')) && 0 === $index ? ' open' : '';
135 echo '<details class="thinkrank-faq__item"' . $open . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
136 echo '<summary class="thinkrank-faq__question">' . esc_html((string) ($faq['question'] ?? '')) . '</summary>';
137 echo '<div class="thinkrank-faq__answer">' . wp_kses_post((string) ($faq['answer'] ?? '')) . '</div>';
138 echo '</details>';
139 }
140
141 echo '</div>';
142
143 $this->maybe_render_schema($settings, $items);
144 }
145
146 /**
147 * Emit FAQPage JSON-LD on the front end (never in the editor preview).
148 *
149 * @param array $settings Widget settings.
150 * @param array $items Non-empty FAQ items.
151 * @return void
152 */
153 private function maybe_render_schema(array $settings, array $items): void {
154
155 // The Schema master switch and the matrix's per-content-type switch.
156 // This widget echoes its own <script> rather than registering with
157 // Schema_Graph, so gating the graph never reached it and it kept
158 // publishing with Schema switched off (#688).
159 if (class_exists('ThinkRank\\Frontend\\Schema_Graph')
160 && !\ThinkRank\Frontend\Schema_Graph::output_allowed()) {
161 return;
162 }
163 if ('yes' !== ($settings['output_schema'] ?? 'yes')) {
164 return;
165 }
166
167 if (\Elementor\Plugin::$instance->editor->is_edit_mode()) {
168 return;
169 }
170
171 // The request's schema graph already merged this widget's questions into
172 // its single FAQPage; emitting again would duplicate it (#355).
173 if (
174 class_exists('ThinkRank\\Frontend\\Schema_Graph')
175 && \ThinkRank\Frontend\Schema_Graph::instance()->absorbed_content_faq()
176 ) {
177 return;
178 }
179
180 $entities = [];
181 foreach ($items as $faq) {
182 $question = trim(wp_strip_all_tags((string) ($faq['question'] ?? '')));
183 $answer = trim((string) ($faq['answer'] ?? ''));
184 if ($question === '' || $answer === '') {
185 continue;
186 }
187 $entities[] = [
188 '@type' => 'Question',
189 'name' => $question,
190 'acceptedAnswer' => [
191 '@type' => 'Answer',
192 'text' => wp_kses_post($answer),
193 ],
194 ];
195 }
196
197 if (empty($entities)) {
198 return;
199 }
200
201 $json = wp_json_encode([
202 '@context' => 'https://schema.org',
203 '@type' => 'FAQPage',
204 'mainEntity' => $entities,
205 ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
206
207 if (false !== $json) {
208 echo '<script type="application/ld+json">' . $json . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
209 }
210 }
211 }
212