PluginProbe ʕ •ᴥ•ʔ
FrontBlocks for Gutenberg/GeneratePress / trunk
FrontBlocks for Gutenberg/GeneratePress vtrunk
trunk 0.2.0 0.2.1 0.2.2 0.2.3 0.2.4 0.2.5 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.2.0 1.2.1 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 ci-artifacts
frontblocks / includes / Frontend / FaqSchema.php
frontblocks / includes / Frontend Last commit date
Animations.php 1 month ago BackButton.php 7 months ago BeforeAfter.php 1 month ago BlockPatterns.php 4 months ago Carousel.php 1 week ago ColumnsSameHeight.php 1 week ago ContainerEdgeAlignment.php 4 weeks ago Counter.php 1 month ago DownloadButton.php 1 week ago Events.php 4 weeks ago FaqSchema.php 1 week ago FluidTypography.php 4 weeks ago Gallery.php 8 months ago GravityFormsInline.php 1 month ago Headline.php 4 weeks ago InsertPost.php 1 month ago ProductCategories.php 4 weeks ago ReadingProgress.php 7 months ago ReadingTime.php 8 months ago ShapeAnimations.php 4 weeks ago StackedImages.php 4 months ago StickyColumn.php 4 weeks ago SvgUpload.php 4 weeks ago Testimonials.php 8 months ago TextAnimation.php 1 month ago UserText.php 4 weeks ago
FaqSchema.php
218 lines
1 <?php
2 /**
3 * FAQ Schema (JSON-LD) module for FrontBlocks.
4 *
5 * @package FrontBlocks
6 * @author Closemarketing
7 * @copyright 2025 Closemarketing
8 * @version 1.0
9 */
10
11 namespace FrontBlocks\Frontend;
12
13 defined( 'ABSPATH' ) || exit;
14
15 /**
16 * FaqSchema class.
17 *
18 * Collects Q&A pairs from core/details and generateblocks/accordion blocks
19 * that have frblFaqSchema enabled, then outputs JSON-LD in the footer.
20 * Supports FAQPage and HowTo schema types.
21 *
22 * @since 1.0.0
23 */
24 class FaqSchema {
25
26 /**
27 * Collected schema items grouped by type.
28 * Shape: array<string, array<array{question: string, answer: string}>>
29 *
30 * @var array
31 */
32 private array $schema_groups = array();
33
34 /**
35 * Constructor.
36 */
37 public function __construct() {
38 add_filter( 'render_block_core/accordion', array( $this, 'collect_details_block' ), 10, 2 );
39 add_filter( 'render_block_generateblocks/container', array( $this, 'collect_gb_accordion_block' ), 10, 2 );
40 add_action( 'wp_footer', array( $this, 'output_json_ld' ), 99 );
41 add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_assets' ) );
42 }
43
44 /**
45 * Collect FAQ entry from a core/details block when frblFaqSchema is enabled.
46 *
47 * @param string $block_content Rendered HTML.
48 * @param array $block Block data.
49 * @return string Unchanged block HTML.
50 */
51 public function collect_details_block( string $block_content, array $block ): string {
52 if ( empty( $block['attrs']['frblFaqSchema'] ) ) {
53 return $block_content;
54 }
55
56 $schema_type = $block['attrs']['frblSchemaType'] ?? 'FAQPage';
57
58 preg_match_all( '/<span[^>]+class="[^"]*wp-block-accordion-heading__toggle-title[^"]*"[^>]*>(.*?)<\/span>/is', $block_content, $questions );
59 preg_match_all( '/<div[^>]+class="[^"]*wp-block-accordion-panel[^"]*"[^>]*>(.*?)<\/div>\s*(?:<\/div>|$)/is', $block_content, $answers );
60
61 foreach ( $questions[1] as $i => $raw_question ) {
62 $question = trim( html_entity_decode( wp_strip_all_tags( $raw_question ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
63 $answer = isset( $answers[1][ $i ] ) ? trim( html_entity_decode( wp_strip_all_tags( $answers[1][ $i ] ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) : '';
64
65 if ( '' !== $question && '' !== $answer ) {
66 $this->schema_groups[ $schema_type ][] = array(
67 'question' => $question,
68 'answer' => $answer,
69 );
70 }
71 }
72
73 return $block_content;
74 }
75
76 /**
77 * Collect FAQ entries from a GenerateBlocks accordion container when frblFaqSchema is enabled.
78 *
79 * @param string $block_content Rendered HTML.
80 * @param array $block Block data.
81 * @return string Unchanged block HTML.
82 */
83 public function collect_gb_accordion_block( string $block_content, array $block ): string {
84 if ( empty( $block['attrs']['frblFaqSchema'] ) ) {
85 return $block_content;
86 }
87 if ( ( $block['attrs']['variantRole'] ?? '' ) !== 'accordion' ) {
88 return $block_content;
89 }
90
91 $schema_type = $block['attrs']['frblSchemaType'] ?? 'FAQPage';
92
93 $dom = new \DOMDocument();
94 libxml_use_internal_errors( true );
95 $dom->loadHTML( '<?xml encoding="utf-8" ?><meta charset="utf-8">' . $block_content );
96 libxml_clear_errors();
97 $xpath = new \DOMXPath( $dom );
98
99 $toggle_texts = $xpath->query( '//*[contains(@class,"gb-accordion__toggle")]//span[contains(@class,"gb-button-text")]' );
100 $content_nodes = $xpath->query( '//*[starts-with(@id,"gb-accordion-content-")]' );
101
102 $questions = array();
103 foreach ( $toggle_texts as $node ) {
104 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
105 $questions[] = trim( html_entity_decode( $node->textContent, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
106 }
107
108 $answers = array();
109 foreach ( $content_nodes as $node ) {
110 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
111 $answers[] = trim( html_entity_decode( $node->textContent, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
112 }
113
114 foreach ( $questions as $i => $question ) {
115 $answer = $answers[ $i ] ?? '';
116 if ( '' !== $question && '' !== $answer ) {
117 $this->schema_groups[ $schema_type ][] = array(
118 'question' => $question,
119 'answer' => $answer,
120 );
121 }
122 }
123
124 return $block_content;
125 }
126
127 /**
128 * Build a FAQPage JSON-LD array.
129 *
130 * @param array $items Q&A pairs.
131 * @return array Schema array.
132 */
133 private function build_faq_page( array $items ): array {
134 $entities = array();
135 foreach ( $items as $item ) {
136 $entities[] = array(
137 '@type' => 'Question',
138 'name' => $item['question'],
139 'acceptedAnswer' => array(
140 '@type' => 'Answer',
141 'text' => $item['answer'],
142 ),
143 );
144 }
145
146 return array(
147 '@context' => 'https://schema.org',
148 '@type' => 'FAQPage',
149 'mainEntity' => $entities,
150 );
151 }
152
153 /**
154 * Build a HowTo JSON-LD array.
155 *
156 * @param array $items Q&A pairs used as steps.
157 * @return array Schema array.
158 */
159 private function build_how_to( array $items ): array {
160 $steps = array();
161 foreach ( $items as $item ) {
162 $steps[] = array(
163 '@type' => 'HowToStep',
164 'name' => $item['question'],
165 'text' => $item['answer'],
166 );
167 }
168
169 return array(
170 '@context' => 'https://schema.org',
171 '@type' => 'HowTo',
172 'name' => get_the_title(),
173 'step' => $steps,
174 );
175 }
176
177 /**
178 * Output JSON-LD scripts in the footer, one per schema type.
179 *
180 * @return void
181 */
182 public function output_json_ld(): void {
183 if ( empty( $this->schema_groups ) ) {
184 return;
185 }
186
187 foreach ( $this->schema_groups as $type => $items ) {
188 if ( 'HowTo' === $type ) {
189 $schema = $this->build_how_to( $items );
190 } else {
191 $schema = $this->build_faq_page( $items );
192 }
193
194 echo '<script type="application/ld+json">' . wp_json_encode( $schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) . '</script>' . "\n";
195 }
196 }
197
198 /**
199 * Enqueue editor assets for the Schema inspector controls.
200 *
201 * @return void
202 */
203 public function enqueue_editor_assets(): void {
204 $asset_file = FRBL_PLUGIN_PATH . 'assets/faq-schema/frontblocks-faq-schema.js';
205 if ( ! file_exists( $asset_file ) ) {
206 return;
207 }
208
209 wp_enqueue_script(
210 'frontblocks-faq-schema',
211 FRBL_PLUGIN_URL . 'assets/faq-schema/frontblocks-faq-schema.js',
212 array( 'wp-hooks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n', 'wp-compose' ),
213 FRBL_VERSION,
214 true
215 );
216 }
217 }
218