@@ -8,6 +8,7 @@ class Feed_Shortcode {
88
99 private static $ schema_scripts = array ();
1010 private static $ schemas_processed = false ;
11+ private static $ schemas_output_in_head = false ; // Track if schemas were successfully output in head
1112
1213 public function __construct (Feed_Deserializer $ feed_deserializer ) {
1314 $ this ->feed_deserializer = $ feed_deserializer ;
@@ -40,46 +41,109 @@ public function pre_process_schemas() {
4041
4142 global $ wp_query , $ post ;
4243
43- $ content = '' ;
4444 $ feed_ids = array ();
45+ $ content_sources = array ();
4546
4647 // Check current post/page content
4748 if ($ post && isset ($ post ->post_content )) {
48- $ content . = $ post ->post_content ;
49+ $ content_sources [] = $ post ->post_content ;
4950 }
5051
5152 // Check all posts in the main query (for archive pages, etc.)
5253 if ($ wp_query && isset ($ wp_query ->posts ) && is_array ($ wp_query ->posts )) {
5354 foreach ($ wp_query ->posts as $ query_post ) {
5455 if (isset ($ query_post ->post_content )) {
55- $ content . = $ query_post ->post_content ;
56+ $ content_sources [] = $ query_post ->post_content ;
5657 }
5758 }
5859 }
5960
60- // Extract all opio_feed shortcode IDs from content using regex
61- if (preg_match_all ('/\[opio_feed[^\]]*id=[" \']?(\d+)[" \']?[^\]]*\]/i ' , $ content , $ matches )) {
62- if (!empty ($ matches [1 ])) {
63- $ feed_ids = array_unique (array_map ('intval ' , $ matches [1 ]));
61+ // Check widget areas for shortcodes
62+ // Widgets store content differently, so we need to check widget options
63+ $ all_widgets = wp_get_sidebars_widgets ();
64+ if (is_array ($ all_widgets )) {
65+ foreach ($ all_widgets as $ sidebar_id => $ widget_ids ) {
66+ if (!is_array ($ widget_ids )) continue ;
67+
68+ foreach ($ widget_ids as $ widget_id ) {
69+ // Check text widgets (classic widget)
70+ if (preg_match ('/^text-(\d+)$/ ' , $ widget_id , $ text_match )) {
71+ $ text_widgets = get_option ('widget_text ' , array ());
72+ if (is_array ($ text_widgets )) {
73+ foreach ($ text_widgets as $ widget_instance ) {
74+ if (isset ($ widget_instance ['text ' ])) {
75+ $ content_sources [] = $ widget_instance ['text ' ];
76+ }
77+ }
78+ }
79+ }
80+
81+ // Check custom HTML widgets (WordPress 4.8+)
82+ if (preg_match ('/^custom_html-(\d+)$/ ' , $ widget_id , $ html_match )) {
83+ $ html_widgets = get_option ('widget_custom_html ' , array ());
84+ if (is_array ($ html_widgets )) {
85+ foreach ($ html_widgets as $ widget_instance ) {
86+ if (isset ($ widget_instance ['content ' ])) {
87+ $ content_sources [] = $ widget_instance ['content ' ];
88+ }
89+ }
90+ }
91+ }
92+
93+ // Check block widgets (WordPress 5.8+ / Full Site Editing)
94+ if (preg_match ('/^block-(\d+)$/ ' , $ widget_id , $ block_match )) {
95+ $ block_widgets = get_option ('widget_block ' , array ());
96+ if (is_array ($ block_widgets )) {
97+ foreach ($ block_widgets as $ widget_instance ) {
98+ if (isset ($ widget_instance ['content ' ])) {
99+ $ content_sources [] = $ widget_instance ['content ' ];
100+ }
101+ }
102+ }
103+ }
104+ }
64105 }
65106 }
66107
67- // Also try parsing shortcode attributes for cases where format might differ
68- if (has_shortcode ($ content , 'opio_feed ' )) {
69- // Use WordPress shortcode parser
70- preg_match_all ('/\[opio_feed([^\]]*)\]/i ' , $ content , $ shortcode_matches );
71- if (!empty ($ shortcode_matches [1 ])) {
72- foreach ($ shortcode_matches [1 ] as $ atts_string ) {
73- $ atts = shortcode_parse_atts ($ atts_string );
74- if (isset ($ atts ['id ' ])) {
75- $ feed_ids [] = intval ($ atts ['id ' ]);
108+ // Extract shortcodes from all content sources
109+ foreach ($ content_sources as $ content ) {
110+ if (empty ($ content )) continue ;
111+
112+ // Match: [opio_feed id='52'], [opio_feed id="52"], [opio_feed id=52]
113+ // This regex handles spaces and single/double quotes
114+ if (preg_match_all ('/\[opio_feed[^\]]*id\s*=\s*[" \']?(\d+)[" \']?[^\]]*\]/i ' , $ content , $ matches )) {
115+ if (!empty ($ matches [1 ])) {
116+ foreach ($ matches [1 ] as $ feed_id_str ) {
117+ $ feed_id = absint ($ feed_id_str );
118+ if ($ feed_id > 0 ) {
119+ $ feed_ids [] = $ feed_id ;
120+ }
121+ }
122+ }
123+ }
124+
125+ // Also use WordPress's shortcode parser for robustness
126+ if (has_shortcode ($ content , 'opio_feed ' )) {
127+ $ pattern = get_shortcode_regex (array ('opio_feed ' ));
128+ if (preg_match_all ('/ ' . $ pattern . '/s ' , $ content , $ shortcode_matches )) {
129+ foreach ($ shortcode_matches [3 ] as $ atts_string ) {
130+ if (empty ($ atts_string )) continue ;
131+ $ atts = shortcode_parse_atts ($ atts_string );
132+ if (isset ($ atts ['id ' ])) {
133+ $ feed_id = absint ($ atts ['id ' ]);
134+ if ($ feed_id > 0 ) {
135+ $ feed_ids [] = $ feed_id ;
136+ }
137+ }
76138 }
77139 }
78- $ feed_ids = array_unique ($ feed_ids );
79140 }
80141 }
81142
82- // Process all found feed IDs
143+ // Remove duplicates
144+ $ feed_ids = array_unique (array_filter ($ feed_ids ));
145+
146+ // Process all found feed IDs - fetch schema from feed.op.io BEFORE wp_head
83147 foreach ($ feed_ids as $ feed_id ) {
84148 if ($ feed_id > 0 ) {
85149 $ this ->fetch_and_extract_schema ($ feed_id );
@@ -167,12 +231,18 @@ private function extract_schema_from_html($html) {
167231 * Remove schema scripts from HTML content
168232 */
169233 private function remove_schema_from_html ($ html ) {
234+ // First, remove entire head sections that contain our schema (in case feed has nested head tags)
235+ // Match head tags that contain our schema script
236+ $ head_with_schema_pattern = '/<head[^>]*>.*?<script[^>]*(?:id=[" \']jsonldSchema[" \'][^>]*type=[" \']application\/ld\+json[" \']|type=[" \']application\/ld\+json[" \'][^>]*id=[" \']jsonldSchema[" \'])[^>]*>.*?<\/script>.*?<\/head>/is ' ;
237+ $ html = preg_replace ($ head_with_schema_pattern , '' , $ html );
238+
170239 // Remove script tags with id="jsonldSchema" and type="application/ld+json"
171240 // Handles attributes in any order and with single or double quotes
172- $ pattern = '/<script[^>]*(?:id=[" \']jsonldSchema[" \'][^>]*type=[" \']application\/ld\+json[" \']|type=[" \']application\/ld\+json[" \'][^>]*id=[" \']jsonldSchema[" \'])[^>]*>.*?<\/script>/is ' ;
173- $ html = preg_replace ($ pattern , '' , $ html );
241+ // This catches any remaining schema scripts (even outside head tags)
242+ $ schema_script_pattern = '/<script[^>]*(?:id=[" \']jsonldSchema[" \'][^>]*type=[" \']application\/ld\+json[" \']|type=[" \']application\/ld\+json[" \'][^>]*id=[" \']jsonldSchema[" \'])[^>]*>.*?<\/script>/is ' ;
243+ $ html = preg_replace ($ schema_script_pattern , '' , $ html );
174244
175- // Also remove any stray head tags that might be in the content
245+ // Also remove any stray/empty head tags that might be in the content
176246 // Remove opening <head> tags
177247 $ html = preg_replace ('/<head[^>]*>/i ' , '' , $ html );
178248 // Remove closing </head> tags
@@ -192,12 +262,13 @@ public function start_output_buffering() {
192262 }
193263
194264 // 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- }
265+ // If another plugin started buffering, we'll still add our own buffer on top
266+ // This ensures our callback runs and can inject schemas
267+ $ existing_level = ob_get_level ();
199268
200- // Start output buffering with callback
269+ // Start our own output buffering with callback
270+ // Even if other buffers exist, our callback will be called when WordPress flushes
271+ // The callback modifies the final HTML before it's sent, so Google crawler sees it
201272 ob_start (array ($ this , 'buffer_callback ' ));
202273 }
203274
@@ -240,15 +311,16 @@ public function buffer_callback($buffer) {
240311 // Check if head already contains our schemas
241312 $ has_our_schema = (stripos ($ head_content , 'id="jsonldSchema" ' ) !== false );
242313 if ($ has_our_schema ) {
243- // Schemas already in head, return as-is
314+ // Schemas already in head, mark as successful
315+ self ::$ schemas_output_in_head = true ;
244316 return $ buffer ;
245317 }
246318 }
247319
248320 // Find the closing </head> tag
249321 $ head_position = stripos ($ buffer , '</head> ' );
250322 if ($ head_position === false ) {
251- // No head tag found, return as-is
323+ // No head tag found, return as-is (schema will remain in body)
252324 return $ buffer ;
253325 }
254326
@@ -264,6 +336,9 @@ public function buffer_callback($buffer) {
264336 // Inject schemas before closing </head> tag
265337 $ buffer = substr_replace ($ buffer , $ schema_html . '</head> ' , $ head_position , 7 );
266338
339+ // Mark that schemas were successfully injected via buffer
340+ self ::$ schemas_output_in_head = true ;
341+
267342 return $ buffer ;
268343 }
269344
@@ -286,6 +361,9 @@ public function output_schemas_to_head() {
286361 foreach ($ unique_schemas as $ schema ) {
287362 echo $ schema . "\n" ;
288363 }
364+
365+ // Mark that schemas were successfully output in head
366+ self ::$ schemas_output_in_head = true ;
289367 }
290368
291369 public function init ($ atts ) {
@@ -329,13 +407,22 @@ public function init($atts) {
329407
330408 // Extract schema and store it (even if wp_head already fired - we'll inject via buffer)
331409 $ schemas = $ this ->extract_schema_from_html ($ reviews );
332- if (!empty ($ schemas )) {
410+ $ has_schema = !empty ($ schemas );
411+
412+ if ($ has_schema ) {
333413 // Always store schemas - we'll inject them via output buffer if wp_head already fired
334414 self ::$ schema_scripts = array_merge (self ::$ schema_scripts , $ schemas );
335415 }
336416
337- // Always remove schema scripts from body content
338- $ reviews = $ this ->remove_schema_from_html ($ reviews );
417+ // Remove schema scripts from body content if we've collected schemas
418+ // If schemas are in our collection, they will be (or already were) output in head
419+ // Only keep schema in body if NO schemas were collected at all (safety fallback)
420+ if ($ has_schema || self ::$ schemas_output_in_head || !empty (self ::$ schema_scripts )) {
421+ // We found schema in this feed OR schemas were already processed/output
422+ // Remove schema from body since it will be (or was) in head
423+ $ reviews = $ this ->remove_schema_from_html ($ reviews );
424+ }
425+ // If no schemas were found and none are in collection, leave in body as fallback
339426
340427 // Wrap entire feed content with Nitropack exclusion wrapper
341428 echo '<div data-nitro-exclude="all" data-nitro-ignore="true" data-nitro-no-optimize="true" data-nitro-preserve-ws="true"> ' ;
0 commit comments