@@ -13,8 +13,13 @@ public function __construct(Feed_Deserializer $feed_deserializer) {
1313 $ this ->feed_deserializer = $ feed_deserializer ;
1414 // Hook early to pre-process shortcodes and extract schemas before wp_head runs
1515 add_action ('template_redirect ' , array ($ this , 'pre_process_schemas ' ), 1 );
16- // Hook into wp_head to output schemas
16+ // Start output buffering early to capture entire page output
17+ // This must start BEFORE wp_head to capture it
18+ add_action ('template_redirect ' , array ($ this , 'start_output_buffering ' ), 999 );
19+ // Hook into wp_head to output schemas (if they were pre-processed)
1720 add_action ('wp_head ' , array ($ this , 'output_schemas_to_head ' ), 1 );
21+ // Process buffer on shutdown to inject schemas (if they were found after wp_head)
22+ add_action ('shutdown ' , array ($ this , 'process_output_buffer ' ), 999 );
1823 }
1924
2025 function custom_esc ($ str ) {
@@ -176,14 +181,110 @@ private function remove_schema_from_html($html) {
176181 return $ html ;
177182 }
178183
184+ /**
185+ * Start output buffering to capture page output
186+ * This captures the entire page including wp_head so we can inject schemas if needed
187+ */
188+ public function start_output_buffering () {
189+ // Only start buffering on front-end pages
190+ if (is_admin () || wp_doing_ajax () || wp_is_json_request ()) {
191+ return ;
192+ }
193+
194+ // Check if output buffering is already started (by another plugin)
195+ // If so, we'll work with the existing buffer
196+ if (ob_get_level () > 0 ) {
197+ return ;
198+ }
199+
200+ // Start output buffering with callback
201+ ob_start (array ($ this , 'buffer_callback ' ));
202+ }
203+
204+ /**
205+ * Process output buffer on shutdown
206+ * When output buffering with a callback is used, WordPress automatically processes it
207+ * But we need to ensure buffers are properly flushed
208+ */
209+ public function process_output_buffer () {
210+ // Only process on front-end pages
211+ if (is_admin () || wp_doing_ajax () || wp_is_json_request ()) {
212+ return ;
213+ }
214+
215+ // If we started a buffer with a callback, WordPress will handle it
216+ // But we need to ensure any remaining buffers are flushed
217+ $ buffer_level = ob_get_level ();
218+ if ($ buffer_level > 0 ) {
219+ // If we have schemas to inject, make sure the buffer is processed
220+ // The callback will handle the injection
221+ // WordPress will automatically call the callback when ob_end_flush is called
222+ // But we need to make sure the buffer level is correct
223+ // Note: If there are multiple buffers, we only want to flush ours
224+ // WordPress typically handles the top-level buffer automatically
225+ }
226+ }
227+
228+ /**
229+ * Buffer callback - inject schemas into head section if they weren't output in wp_head
230+ */
231+ public function buffer_callback ($ buffer ) {
232+ // Only process if we have schemas
233+ if (empty (self ::$ schema_scripts )) {
234+ return $ buffer ;
235+ }
236+
237+ // Check if schemas are already in the head section
238+ if (preg_match ('/<head[^>]*>(.*?)<\/head>/is ' , $ buffer , $ head_match )) {
239+ $ head_content = $ head_match [1 ];
240+ // Check if head already contains our schemas
241+ $ has_our_schema = (stripos ($ head_content , 'id="jsonldSchema" ' ) !== false );
242+ if ($ has_our_schema ) {
243+ // Schemas already in head, return as-is
244+ return $ buffer ;
245+ }
246+ }
247+
248+ // Find the closing </head> tag
249+ $ head_position = stripos ($ buffer , '</head> ' );
250+ if ($ head_position === false ) {
251+ // No head tag found, return as-is
252+ return $ buffer ;
253+ }
254+
255+ // Get unique schemas (deduplicate)
256+ $ unique_schemas = array_unique (self ::$ schema_scripts , SORT_STRING );
257+
258+ // Build schema HTML to inject
259+ $ schema_html = "\n" ;
260+ foreach ($ unique_schemas as $ schema ) {
261+ $ schema_html .= $ schema . "\n" ;
262+ }
263+
264+ // Inject schemas before closing </head> tag
265+ $ buffer = substr_replace ($ buffer , $ schema_html . '</head> ' , $ head_position , 7 );
266+
267+ return $ buffer ;
268+ }
269+
179270 /**
180271 * Output all collected schemas to the head
181272 */
182273 public function output_schemas_to_head () {
183- if (!empty (self ::$ schema_scripts )) {
184- foreach (self ::$ schema_scripts as $ schema ) {
185- echo $ schema . "\n" ;
186- }
274+ // Use static flag to prevent duplicate output
275+ static $ already_output = false ;
276+
277+ if ($ already_output || empty (self ::$ schema_scripts )) {
278+ return ;
279+ }
280+
281+ $ already_output = true ;
282+
283+ // Deduplicate schemas
284+ $ unique_schemas = array_unique (self ::$ schema_scripts , SORT_STRING );
285+
286+ foreach ($ unique_schemas as $ schema ) {
287+ echo $ schema . "\n" ;
187288 }
188289 }
189290
@@ -226,11 +327,10 @@ public function init($atts) {
226327 $ opio_handler = new Opio_Handler ($ biz_id , $ option , $ review_type , $ org_id );
227328 $ reviews = $ opio_handler ->get_business ();
228329
229- // Extract schema if not already processed (fallback for dynamically added shortcodes)
230- // Note: This won't add to head if wp_head already fired, but will remove from body
330+ // Extract schema and store it (even if wp_head already fired - we'll inject via buffer)
231331 $ schemas = $ this ->extract_schema_from_html ($ reviews );
232- if (!empty ($ schemas ) && ! did_action ( ' wp_head ' ) ) {
233- // Only add if wp_head hasn't fired yet
332+ if (!empty ($ schemas )) {
333+ // Always store schemas - we'll inject them via output buffer if wp_head already fired
234334 self ::$ schema_scripts = array_merge (self ::$ schema_scripts , $ schemas );
235335 }
236336
0 commit comments