PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | otto/Otto_html_class.php +1189 -156 2.6.21 → 2.7.2 View file →
@@ -14,8 +14,13 @@
14 14 */
15 15
16 16 Class Metasync_otto_html{
17 17
18 + # Marker announcing that OTTO processed this <head>. Deliberately a comment
19 + # rather than an attribute on the tag — see mark_head_optimized().
20 + const HEAD_OPTIMIZED_TOKEN = 'metasync_optimized';
21 + const HEAD_OPTIMIZED_MARKER = '<!--metasync_optimized-->';
22 +
18 23 # html dom
19 24 private $dom;
20 25
21 26 # the file path to save to
@@ -126,8 +131,195 @@
126 131 return str_ireplace(array_keys($map), array_values($map), $html);
127 132 }
128 133
129 134 /**
135 + * Quote unquoted attribute values before DOM parsing.
136 + *
137 + * Per the HTML spec an unquoted attribute value ends at whitespace, so
138 + * browsers read this correctly:
139 + *
140 + * data-autoplay-viewport=80%
141 + * data-mousewheel="false"
142 + * data-type="carousel"
143 + *
144 + * SimpleHtmlDom does not stop at the whitespace. It runs to the next quote
145 + * character, so the value becomes
146 + * `80%\n data-mousewheel="false"\n data-type="carousel"...` and EVERY
147 + * following attribute on the tag is swallowed into it and lost.
148 + *
149 + * Supreme Modules' image carousel emits exactly that markup. The attributes
150 + * lost after it include data-type, data-slider-orientation and
151 + * data-slideshow-to-show, so Swiper initialises on defaults, stacks all
152 + * slides and grows the page by one slide-set per autoplay tick until it is
153 + * tens of thousands of pixels tall.
154 + *
155 + * Normalising to `data-autoplay-viewport="80%"` before parsing makes the
156 + * markup unambiguous, so no attribute is lost. Already-quoted values are
157 + * left untouched, and <script>/<style> bodies are shielded so their
158 + * contents are never mistaken for tags.
159 + *
160 + * @param string $html Raw HTML before DOM parsing.
161 + * @return string HTML with every unquoted attribute value quoted.
162 + */
163 + private function quote_unquoted_attribute_values($html) {
164 + # Shield <script>/<style> bodies — their contents can contain '<' and '='
165 + # and must never be treated as markup.
166 + $protected = array();
167 + $html = preg_replace_callback(
168 + '/<(script|style)\b([^>]*)>(.*?)<\/\1>/is',
169 + function ($m) use (&$protected) {
170 + $token = '<!--METASYNC_ATTRQ_' . count($protected) . '-->';
171 + $protected[$token] = $m[0];
172 + return $token;
173 + },
174 + $html
175 + );
176 +
177 + # Walk real tags only. The alternation lets a quoted value contain '>'
178 + # so the tag boundary is found correctly. Possessive quantifiers (*+ / ++)
179 + # are deliberate: the three branches are mutually exclusive on their first
180 + # character, so backtracking can never produce a different match, and
181 + # forbidding it removes the catastrophic-backtracking risk this shape
182 + # otherwise carries on markup with many quotes and no '>'.
183 + $result = preg_replace_callback(
184 + '/<([a-zA-Z][a-zA-Z0-9:-]*+)((?:[^>"\']++|"[^"]*+"|\'[^\']*+\')*+)>/s',
185 + function ($m) {
186 + if (strpos($m[2], '=') === false) {
187 + return $m[0];
188 + }
189 +
190 + $attrs = $this->quote_unquoted_values_in_attr_region($m[2]);
191 +
192 + # null means nothing needed changing — return the ORIGINAL tag so
193 + # a document of well-formed HTML comes back byte-identical rather
194 + # than merely equivalent.
195 + return $attrs === null ? $m[0] : '<' . $m[1] . $attrs . '>';
196 + },
197 + $html
198 + );
199 +
200 + if ($result === null) {
201 + $result = $html;
202 + }
203 +
204 + if (!empty($protected)) {
205 + $result = str_replace(array_keys($protected), array_values($protected), $result);
206 + }
207 +
208 + return $result;
209 + }
210 +
211 + /**
212 + * Quote the unquoted values inside one tag's attribute region.
213 + *
214 + * Must be a tokeniser, not a regex. A flat pattern over the region cannot
215 + * tell an attribute from ordinary text inside an already-quoted value, and
216 + * would wreck common markup — `content="width=device-width,
217 + * initial-scale=1"` looks like it contains an `initial-scale=1` attribute.
218 + * Quoted values are therefore copied through verbatim and never inspected.
219 + *
220 + * @param string $attrs Attribute region: everything between the tag name and '>'.
221 + * @return string|null Rewritten region, or null when nothing needed changing.
222 + */
223 + private function quote_unquoted_values_in_attr_region($attrs) {
224 + $len = strlen($attrs);
225 + $out = '';
226 + $i = 0;
227 + $changed = false;
228 +
229 + while ($i < $len) {
230 + $c = $attrs[$i];
231 +
232 + # Whitespace between attributes — preserve exactly.
233 + if ($c === ' ' || $c === "\t" || $c === "\n" || $c === "\r" || $c === "\f" || $c === "\v") {
234 + $out .= $c;
235 + $i++;
236 + continue;
237 + }
238 +
239 + # Attribute name.
240 + if (preg_match('/\G[a-zA-Z_:][a-zA-Z0-9_:.\-]*+/', $attrs, $nm, 0, $i)) {
241 + $out .= $nm[0];
242 + $i += strlen($nm[0]);
243 +
244 + # Look past optional whitespace for '='. No '=' means a valueless
245 + # boolean attribute; leave it alone.
246 + $j = $i;
247 + while ($j < $len && strpos(" \t\n\r\f\v", $attrs[$j]) !== false) {
248 + $j++;
249 + }
250 + if ($j >= $len || $attrs[$j] !== '=') {
251 + continue;
252 + }
253 +
254 + $out .= substr($attrs, $i, $j - $i) . '=';
255 + $i = $j + 1;
256 +
257 + # Whitespace after '='.
258 + while ($i < $len && strpos(" \t\n\r\f\v", $attrs[$i]) !== false) {
259 + $out .= $attrs[$i];
260 + $i++;
261 + }
262 +
263 + if ($i >= $len) {
264 + continue;
265 + }
266 +
267 + # Quoted value — copy verbatim through the closing quote so its
268 + # contents are never treated as attributes.
269 + if ($attrs[$i] === '"' || $attrs[$i] === "'") {
270 + $quote = $attrs[$i];
271 + $end = strpos($attrs, $quote, $i + 1);
272 + if ($end === false) {
273 + $out .= substr($attrs, $i);
274 + $i = $len;
275 + } else {
276 + $out .= substr($attrs, $i, $end - $i + 1);
277 + $i = $end + 1;
278 + }
279 + continue;
280 + }
281 +
282 + # Unquoted value — runs to the next whitespace. ('>' cannot occur:
283 + # it would have ended the tag before this region.)
284 + $start = $i;
285 + while ($i < $len && strpos(" \t\n\r\f\v", $attrs[$i]) === false) {
286 + $i++;
287 + }
288 + $value = substr($attrs, $start, $i - $start);
289 +
290 + if ($value === '') {
291 + continue;
292 + }
293 +
294 + # Only rewrite when it actually prevents a loss. SimpleHtmlDom
295 + # mis-parses an unquoted value by running from it to the NEXT
296 + # QUOTE CHARACTER, swallowing everything between. With no quote
297 + # left in this region there is nothing to swallow and the value
298 + # already parses correctly — so leave that markup byte-identical
299 + # rather than churn it. (OceanWP's `style=color:#;` as the final
300 + # attribute is the common benign case.)
301 + $rest = substr($attrs, $i);
302 + if (strpos($rest, '"') === false && strpos($rest, "'") === false) {
303 + $out .= $value;
304 + continue;
305 + }
306 +
307 + $out .= '"' . str_replace('"', '&quot;', $value) . '"';
308 + $changed = true;
309 + continue;
310 + }
311 +
312 + # Anything else (stray punctuation, a '/' before '>', malformed input)
313 + # — copy through untouched.
314 + $out .= $c;
315 + $i++;
316 + }
317 +
318 + return $changed ? $out : null;
319 + }
320 +
321 + /**
130 322 * / Capture ALL <style> blocks before DOM processing.
131 323 *
132 324 * SimpleHtmlDom can lose or corrupt <style> blocks during parse/serialize,
133 325 * especially those without an id attribute (common in custom themes).
@@ -185,10 +377,68 @@
185 377 return $html;
186 378 }
187 379
188 380 /**
189 - * Protect entity-encoded `srcdoc` attribute values before DOM parsing.
381 + * Build the new inner HTML for a heading whose text OTTO is replacing,
382 + * preserving the author's inline formatting.
190 383 *
384 + * Headings are matched by their stripped text, so the recommended value is
385 + * plain text with no way to know how it maps onto the original markup.
386 + * Writing it back directly would discard whatever was inside — a coloured
387 + * <span>, <em>, <br> line breaks — and visibly restyle the page.
388 + *
389 + * Two cases are safe:
390 + * - the heading is already plain text -> replace it
391 + * - the heading is ONE nested inline wrapper -> keep the wrappers,
392 + * chain around a single run of text swap only the text
393 + *
394 + * Anything else is ambiguous: sibling elements, <br>-separated lines, or
395 + * text mixed with elements have no single place the new sentence belongs.
396 + * Those return null so the caller leaves the heading alone — an unapplied
397 + * suggestion is better than silently restyling the customer's content.
398 + *
399 + * @param string $inner_html Current inner HTML of the heading.
400 + * @param string $replacement_text New text, already escaped by the caller.
401 + * @return string|null New inner HTML, or null to skip this heading.
402 + */
403 + public static function build_heading_inner_html($inner_html, $replacement_text) {
404 + $inner = trim($inner_html);
405 +
406 + # Plain text — nothing to preserve.
407 + if (strpos($inner, '<') === false) {
408 + return $replacement_text;
409 + }
410 +
411 + # Inline elements whose wrapper carries the styling worth keeping.
412 + # Deliberately excludes <br>: it encodes line structure, not styling,
413 + # and its presence means the heading has more than one text run.
414 + $inline = 'span|em|strong|b|i|u|a|small|mark|abbr|cite|q|s|sub|sup|font';
415 +
416 + $open = '';
417 + $close = '';
418 +
419 + # Peel wrappers outermost-in. The greedy (.*) means a heading with
420 + # sibling elements (e.g. <em>A</em><br><em>B</em>) leaves stray tags in
421 + # $inner, which the ambiguity check below then rejects.
422 + while (preg_match('#^<(' . $inline . ')(\s[^>]*)?>(.*)</\1\s*>$#is', $inner, $m)) {
423 + # Group 2 (the attribute list) is optional but always captured as ''
424 + # when absent, because group 3 follows it.
425 + $open .= '<' . $m[1] . $m[2] . '>';
426 + $close = '</' . $m[1] . '>' . $close;
427 + $inner = trim($m[3]);
428 +
429 + if (strpos($inner, '<') === false) {
430 + return $open . $replacement_text . $close;
431 + }
432 + }
433 +
434 + # Still markup left after peeling — ambiguous, so don't touch it.
435 + return null;
436 + }
437 +
438 + /**
439 + * / Protect entity-encoded `srcdoc` attribute values before DOM parsing.
440 + *
191 441 * Lazy-loaded YouTube/video facades embed a full HTML document inside an
192 442 * iframe's `srcdoc` attribute, entity-encoded:
193 443 * <iframe srcdoc="&lt;style&gt;*{overflow:hidden}...&lt;/style&gt;&lt;a&gt;..."></iframe>
194 444 * SimpleHtmlDom decodes that value and re-emits the inner <style>/<a>/<img>
@@ -318,8 +568,52 @@
318 568 return $out === null ? $html : $out;
319 569 }
320 570
321 571 /**
572 + * Mark the <head> as OTTO-processed without altering the <head> tag itself.
573 + *
574 + * This marker used to be an attribute — <head metasync_optimized> — which
575 + * removed the literal string "<head>" from the document. Anything that
576 + * injects into the head with a literal str_replace('<head>', ...) then found
577 + * no match on the real tag and silently matched the *next* literal "<head>"
578 + * further down the page. That next one can sit inside a JS comment, a <pre>
579 + * block or escaped documentation text; injecting a <script>...</script> there
580 + * closes the enclosing script early, strands the rest of its code as a text
581 + * node in <head>, and the browser therefore ends <head> at that point —
582 + * pushing title, canonical, OG/Twitter and JSON-LD into <body>, where
583 + * crawlers and social scrapers ignore them.
584 + *
585 + * A comment placed immediately after the tag carries the same signal and
586 + * leaves the tag byte-identical. Note that an HTML minifier may strip
587 + * comments, so treat the <meta name="otto"> tag as the durable marker; this
588 + * one is a convenience for support and QA.
589 + *
590 + * Only the first <head> is marked. The real tag is always the first one in
591 + * the document, so this pass cannot wander the way a bare literal match can.
592 + *
593 + * @param string $html Serialized HTML.
594 + * @return string
595 + */
596 + private function mark_head_optimized($html)
597 + {
598 + # Idempotent, and also a no-op on pages still carrying the legacy
599 + # attribute form (e.g. HTML already in a page cache).
600 + if ($html === '' || strpos($html, self::HEAD_OPTIMIZED_TOKEN) !== false) {
601 + return $html;
602 + }
603 +
604 + $out = preg_replace(
605 + '/(<head(?:\s[^>]*)?>)/i',
606 + '$1' . self::HEAD_OPTIMIZED_MARKER,
607 + $html,
608 + 1
609 + );
610 +
611 + # Belt-and-suspenders: if PCRE fails for any reason, keep the input.
612 + return $out === null ? $html : $out;
613 + }
614 +
615 + /**
322 616 * Apply OTTO's canonical via string replacement (reliable fallback).
323 617 *
324 618 * Why the DOM path fails: do_header_replacements() sets the canonical with a
325 619 * DOM attribute edit ($link->href = …). That edit works in isolation, but
@@ -491,9 +785,9 @@
491 785 $response = wp_remote_get($url_with_params, $args);
492 786
493 787 # Check for errors
494 788 if (is_wp_error($response)) {
495 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API call failed - ' . $response->get_error_message());
789 + error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API call failed - ' . $response->get_error_message()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
496 790 return false;
497 791 }
498 792
499 793 # get the response body
@@ -503,9 +797,9 @@
503 797 $response_code = wp_remote_retrieve_response_code($response);
504 798
505 799 # if no change data skip
506 800 if (empty($body) || $response_code !== 200){
507 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API returned empty or non-200. Code: ' . $response_code);
801 + error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': API returned empty or non-200. Code: ' . $response_code); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- unreachable in production
508 802 return false;
509 803 }
510 804
511 805 # set the html file path
@@ -698,9 +992,11 @@
698 992
699 993 # Check for timeout or connection errors
700 994 if (is_wp_error($route_html)) {
701 995 $error_msg = $route_html->get_error_message();
702 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - wp_remote_get error: ' . $error_msg . ' for route: ' . $route);
996 + if (function_exists('metasync_otto_report_render_failure')) {
997 + metasync_otto_report_render_failure('HTTP_FETCH_FAILED', 'Internal fetch failed: ' . $error_msg, [], 'warning');
998 + }
703 999 return false;
704 1000 }
705 1001
706 1002 # get body
@@ -708,12 +1004,51 @@
708 1004
709 1005 # Get the response code
710 1006 $response_code = wp_remote_retrieve_response_code($route_html);
711 1007
1008 + // Requests follows redirects and returns the final body with its final
1009 + // status. Do not put that body on the original route when a redirect
1010 + // changed the URL: doing so creates a soft redirect with mismatched
1011 + // content and SEO metadata. The normal WordPress request can handle
1012 + // the redirect, and OTTO will process the destination on its own URL.
1013 + $effective_url = $this->get_effective_fetch_url($route_html);
1014 + if ($effective_url !== '' && !$this->fetch_url_matches_route($effective_url, $route, $request_body)) {
1015 + return false;
1016 + }
712 1017
1018 + # Capture the internal fetch's `Set-Cookie`. On the HTTP path OTTO keeps only the
1019 + # body below and would otherwise discard this — but it is both the form/session
1020 + # signal for the caching decision AND (same-origin only) the session the visitor
1021 + # needs for their forms to work. $request_body is the URL actually fetched (it may
1022 + # be rewritten to localhost for tunnels), so only allow pass-through when its
1023 + # scheme+host match the visitor's; otherwise we would set a wrong-domain cookie.
1024 + if (class_exists('Metasync_Otto_Render_Strategy')) {
1025 + $fetch_set_cookie = wp_remote_retrieve_header($route_html, 'set-cookie');
1026 + $fetch_parts = wp_parse_url($request_body);
1027 + $visitor_host = preg_replace('/:\d+$/', '', $_SERVER['HTTP_HOST'] ?? '');
1028 + $same_origin = !empty($fetch_parts['host'])
1029 + && strcasecmp($fetch_parts['host'], $visitor_host) === 0
1030 + && ((($fetch_parts['scheme'] ?? '') === 'https') === (bool) is_ssl());
1031 + Metasync_Otto_Render_Strategy::set_http_fetch_cookie($fetch_set_cookie, $same_origin);
1032 +
1033 + # Also record the internal response's own `Cache-Control`. When the page's
1034 + # render starts a PHP session, PHP emits its session cache-limiter headers
1035 + # (`no-store, no-cache, must-revalidate`) — and it does so whether the session
1036 + # is new OR resumed, so this catches session pages that set no NEW cookie and
1037 + # would otherwise look cacheable. It is read as a signal only and never
1038 + # forwarded to the visitor: our own no-cache request header is not echoed into
1039 + # the response, so this cannot make ordinary pages uncacheable.
1040 + Metasync_Otto_Render_Strategy::set_http_fetch_cache_control(
1041 + wp_remote_retrieve_header($route_html, 'cache-control')
1042 + );
1043 + }
1044 +
1045 +
713 1046 # check not empty
714 1047 if(empty($html_body) || $response_code !== 200){
715 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ' DEBUG: FAILED - Empty body or non-200 status for route: ' . $route);
1048 + if (function_exists('metasync_otto_report_render_failure')) {
1049 + metasync_otto_report_render_failure('HTTP_FETCH_EMPTY', 'Internal fetch returned empty body or non-200 status (HTTP ' . intval($response_code) . ')', [], 'warning');
1050 + }
716 1051 return false;
717 1052 }
718 1053
719 1054 # Skip DOM processing on oversized documents to avoid fatal OOM.
@@ -744,8 +1079,12 @@
744 1079 # Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
745 1080 # before DOM parsing — SimpleHtmlDom strips attributes from these.
746 1081 $html_body = $this->fix_malformed_self_closing_tags($html_body);
747 1082
1083 + # Quote unquoted attribute values (e.g. data-autoplay-viewport=80%) —
1084 + # SimpleHtmlDom swallows every following attribute into such a value.
1085 + $html_body = $this->quote_unquoted_attribute_values($html_body);
1086 +
748 1087 # now that the html is not empty
749 1088 # load it into the simple html dom
750 1089 $this->dom->load($html_body, true, false);
751 1090
@@ -873,13 +1212,19 @@
873 1212
874 1213 $result_html = preg_replace_callback(
875 1214 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
876 1215 function ($m) use ($current_value, $recommended_value) {
877 - $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
878 - if ($inner_text === $current_value) {
879 - return $m[1] . $recommended_value . $m[3];
1216 + $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(wp_strip_all_tags($m[2]), ENT_QUOTES, 'UTF-8')));
1217 + if ($inner_text !== $current_value) {
1218 + return $m[0];
880 1219 }
881 - return $m[0];
1220 + # Keep the author's inline formatting; skip when it can't
1221 + # be preserved rather than flattening the heading.
1222 + $new_inner = self::build_heading_inner_html($m[2], $recommended_value);
1223 + if ($new_inner === null) {
1224 + return $m[0];
1225 + }
1226 + return $m[1] . $new_inner . $m[3];
882 1227 },
883 1228 $result_html,
884 1229 -1
885 1230 );
@@ -891,15 +1236,16 @@
891 1236 $result_html = $this->otto_guard_html($this->apply_image_alt_text_via_string($result_html, $replacement_data), $result_html, 'apply_image_alt_text_via_string');
892 1237
893 1238 # Apply insertions — only if DOM insertion didn't already apply it
894 1239 if (!empty($replacement_data['header_html_insertion'])) {
895 - $header_html_check = trim($replacement_data['header_html_insertion']);
1240 + # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM
1241 + # path inserts the same bytes, so the already-applied check must compare
1242 + # against the stamped form or it would insert a second copy.
1243 + $header_html_insertion = $this->stamp_otto_json_ld(
1244 + metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
1245 + );
1246 + $header_html_check = trim($header_html_insertion);
896 1247 if (strpos($result_html, $header_html_check) === false) {
897 - $header_html_insertion = preg_replace(
898 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
899 - '<script$1type=$2application/ld+json$2 data-otto="true"',
900 - $replacement_data['header_html_insertion']
901 - );
902 1248 $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
903 1249 $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1);
904 1250 }
905 1251 }
@@ -927,8 +1273,9 @@
927 1273
928 1274 # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema
929 1275 $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags');
930 1276 $result_html = $this->otto_guard_html($this->deduplicate_description_tags($result_html), $result_html, 'deduplicate_description_tags');
1277 + $result_html = $this->otto_guard_html($this->deduplicate_keywords_tags($result_html), $result_html, 'deduplicate_keywords_tags');
931 1278 $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags');
932 1279 $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence');
933 1280 $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
934 1281 $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
@@ -933,11 +1280,11 @@
933 1280 $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
934 1281 $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
935 1282 $result_html = $this->otto_guard_html($this->deduplicate_canonical_tags($result_html), $result_html, 'deduplicate_canonical_tags');
936 1283
937 - # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
938 - if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
939 - $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
1284 + # Mark the <head> as processed (post-serialization so dom->clear() can't wipe it)
1285 + if (!$this->is_amp_page()) {
1286 + $result_html = $this->mark_head_optimized($result_html);
940 1287 }
941 1288
942 1289 $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes');
943 1290
@@ -966,8 +1313,12 @@
966 1313 # undo double-encoded numeric/hex character references produced by the
967 1314 # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
968 1315 $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities');
969 1316
1317 + # restore literal '&' query-string separators the serializer escaped to
1318 + # '&amp;', which silently truncates multi-family Google Fonts URLs.
1319 + $result_html = $this->otto_guard_html($this->repair_query_string_ampersands($result_html), $result_html, 'repair_query_string_ampersands');
1320 +
970 1321 # Remove any lazy-video facade <style> hoisted into the page
971 1322 # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
972 1323 $result_html = $this->strip_hoisted_facade_styles($result_html);
973 1324
@@ -985,8 +1336,111 @@
985 1336 return $result_html;
986 1337 }
987 1338
988 1339 /**
1340 + * Read the final URL from the WordPress HTTP response when Requests has
1341 + * followed one or more redirects. Older HTTP transports may not expose it;
1342 + * in that case the empty result leaves the existing fail-open behavior.
1343 + *
1344 + * @param mixed $response wp_remote_get() response.
1345 + * @return string Final URL, or an empty string when unavailable.
1346 + */
1347 + private function get_effective_fetch_url($response)
1348 + {
1349 + if (!is_array($response) || empty($response['http_response']) || !is_object($response['http_response'])) {
1350 + return '';
1351 + }
1352 +
1353 + $http_response = $response['http_response'];
1354 + if (!method_exists($http_response, 'get_response_object')) {
1355 + return '';
1356 + }
1357 +
1358 + $requests_response = $http_response->get_response_object();
1359 + if (!is_object($requests_response) || empty($requests_response->url) || !is_string($requests_response->url)) {
1360 + return '';
1361 + }
1362 +
1363 + return $requests_response->url;
1364 + }
1365 +
1366 + /**
1367 + * Determine whether a followed response still represents the requested
1368 + * route. The internal fetch may use a rewritten host for tunnel support,
1369 + * so both the original and transport hosts are accepted; the path and
1370 + * non-OTTO query parameters must remain identical.
1371 + *
1372 + * @param string $effective_url Final URL reported by Requests.
1373 + * @param string $route Original route supplied to OTTO.
1374 + * @param string $fetch_url URL actually passed to wp_remote_get().
1375 + * @return bool True when no meaningful redirect changed the route.
1376 + */
1377 + private function fetch_url_matches_route($effective_url, $route, $fetch_url)
1378 + {
1379 + $effective = wp_parse_url($effective_url);
1380 + $requested = wp_parse_url($route);
1381 + $transport = wp_parse_url($fetch_url);
1382 + if (!is_array($effective) || !is_array($requested)) {
1383 + return true;
1384 + }
1385 +
1386 + $effective_path = isset($effective['path']) ? $effective['path'] : '/';
1387 + $requested_path = isset($requested['path']) ? $requested['path'] : '/';
1388 + if ($effective_path !== $requested_path) {
1389 + return false;
1390 + }
1391 +
1392 + $allowed_hosts = [];
1393 + foreach ([$requested, $transport] as $parts) {
1394 + if (!empty($parts['host'])) {
1395 + $host = strtolower($parts['host']);
1396 + if (!empty($parts['port'])) {
1397 + $host .= ':' . $parts['port'];
1398 + }
1399 + $allowed_hosts[] = $host;
1400 + }
1401 + }
1402 + if (!empty($effective['host'])) {
1403 + $effective_host = strtolower($effective['host']);
1404 + if (!empty($effective['port'])) {
1405 + $effective_host .= ':' . $effective['port'];
1406 + }
1407 + if (!empty($allowed_hosts) && !in_array($effective_host, $allowed_hosts, true)) {
1408 + return false;
1409 + }
1410 + }
1411 +
1412 + return $this->without_otto_fetch_params($effective['query'] ?? '')
1413 + === $this->without_otto_fetch_params($requested['query'] ?? '');
1414 + }
1415 +
1416 + /**
1417 + * Remove parameters used only to mark OTTO's internal request.
1418 + *
1419 + * @param string $query Query string without the leading '?'.
1420 + * @return string Query with OTTO markers removed.
1421 + */
1422 + private function without_otto_fetch_params($query)
1423 + {
1424 + $query = (string) $query;
1425 + if ($query === '') {
1426 + return '';
1427 + }
1428 +
1429 + $parts = [];
1430 + foreach (explode('&', $query) as $part) {
1431 + $name = strstr($part, '=', true);
1432 + $name = $name === false ? $part : $name;
1433 + if (in_array($name, ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'], true)) {
1434 + continue;
1435 + }
1436 + $parts[] = $part;
1437 + }
1438 +
1439 + return implode('&', $parts);
1440 + }
1441 +
1442 + /**
989 1443 * Repair double-encoded character references in serialized HTML.
990 1444 *
991 1445 * The bundled simplehtmldom serializer (HtmlNode::makeup(), reached via
992 1446 * $this->dom->save() and $root->outertext) runs every attribute value through
@@ -1009,8 +1463,118 @@
1009 1463 }
1010 1464 return preg_replace('/&amp;(#x[0-9a-fA-F]+;?|#[0-9]+;?)/', '&$1', $html);
1011 1465 }
1012 1466
1467 + /**
1468 + * Restore literal '&' query-string separators inside URL attributes.
1469 + *
1470 + * HtmlNode::makeup() serialises every attribute through htmlentities(), so a
1471 + * separator that arrived as a bare '&' - or as '&#038;', which HtmlDocument
1472 + * decodes to '&' when it parses the attribute - is written back as '&amp;'.
1473 + * Both forms decode to '&' in a conforming HTML parser, so the browser is
1474 + * unaffected. A consumer that reads the raw attribute WITHOUT decoding
1475 + * entities sees a different URL, and Cloudflare Fonts is one of those: it
1476 + * refetches fonts.googleapis.com/css2?family=A&amp;family=B, Google treats
1477 + * only the first 'family=' as a real parameter (the rest arrive named
1478 + * 'amp;family'), and every font after the first is dropped from the response.
1479 + * The page keeps its font-family declarations but loses the @font-face rules,
1480 + * so text silently falls back to a system font.
1481 + *
1482 + * Rewrites only a '&amp;' that is immediately followed by a parameter name
1483 + * and '=', and only inside the listed URL attributes. A '&amp;' elsewhere is
1484 + * left escaped, and the mandatory '=' in the lookahead (with ';' excluded
1485 + * from the name class) means no ambiguous ampersand ('&' + alphanumerics +
1486 + * ';') is ever produced.
1487 + *
1488 + * All three delimiter forms the serializer can emit are handled: double
1489 + * quoted, single quoted, and unquoted - makeup() preserves HDOM_QUOTE_NO and
1490 + * emits no delimiter for a value with no whitespace, which is common on
1491 + * minified themes. ENT_COMPAT also leaves "'" unescaped, so a double-quoted
1492 + * value may legitimately contain an apostrophe.
1493 + *
1494 + * The attribute list stops at attributes whose value is always a URL.
1495 + * 'content' is deliberately excluded because it also carries prose
1496 + * (<meta name="description" content="A &amp; B">), where unescaping would
1497 + * corrupt visible text. Attributes read only via JavaScript getAttribute()
1498 + * do not need it either - the DOM decodes entities for them; only consumers
1499 + * that scan raw HTML are affected.
1500 + *
1501 + * <script>/<style>/<textarea>/<noscript> bodies and comments are shielded
1502 + * first. Those are verbatim or RCDATA regions, not markup: rewriting bytes
1503 + * there would edit a JS string literal, or turn "&amp;copy=2" in a textarea
1504 + * into "(c)=2" once the browser decodes it.
1505 + *
1506 + * Plain negated character classes only - no tempered-greedy lookaheads, which
1507 + * blow the PCRE JIT stack on pages with very large inline blocks.
1508 + *
1509 + * @param string $html Serialized HTML from save()/outertext.
1510 + * @return string
1511 + */
1512 + private function repair_query_string_ampersands($html) {
1513 + # strpos guard: skip the regex entirely when there is nothing to repair
1514 + if (strpos($html, '&amp;') === false) {
1515 + return $html;
1516 + }
1517 +
1518 + # Shield verbatim / RCDATA regions and comments before touching anything.
1519 + # Masking also shrinks large inline blocks to a token, so the attribute
1520 + # pass never walks them.
1521 + $shielded = array();
1522 + $masked = preg_replace_callback(
1523 + '/<(script|style|textarea|noscript)\b[^>]*>.*?<\/\1>|<!--.*?-->/is',
1524 + function ($m) use (&$shielded) {
1525 + $token = '<!--METASYNC_QSA_' . count($shielded) . '-->';
1526 + $shielded[$token] = $m[0];
1527 + return $token;
1528 + },
1529 + $html
1530 + );
1531 +
1532 + # preg_* returns null on a PCRE failure; keep the original document.
1533 + if ($masked === null) {
1534 + return $html;
1535 + }
1536 +
1537 + $repaired = preg_replace_callback(
1538 + '/(\s(?:href|src|srcset|data-src|data-srcset|poster|action)\s*=\s*)(?:"([^"<>]*)"|\'([^\'<>]*)\'|([^\s"\'<>]+))/i',
1539 + function ($m) {
1540 + # Pick the delimiter branch that matched. An empty value carries
1541 + # no query string, so the ambiguity between an empty quoted value
1542 + # and an unset group cannot change the outcome.
1543 + if (isset($m[4]) && $m[4] !== '') { // @phpstan-ignore notIdentical.alwaysTrue (preg fills non-participating groups with '' - this check is what tells the unquoted branch from the quoted ones)
1544 + $quote = '';
1545 + $value = $m[4];
1546 + } elseif (isset($m[3]) && $m[3] !== '') {
1547 + $quote = "'";
1548 + $value = $m[3];
1549 + } else {
1550 + $quote = '"';
1551 + $value = isset($m[2]) ? $m[2] : '';
1552 + }
1553 +
1554 + # Only a value carrying a query string can hold separators.
1555 + if (strpos($value, '?') === false || strpos($value, '&amp;') === false) {
1556 + return $m[0];
1557 + }
1558 +
1559 + $fixed = preg_replace('/&amp;(?=[A-Za-z0-9_.\[\]%\-]+=)/', '&', $value);
1560 +
1561 + if ($fixed === null) {
1562 + return $m[0];
1563 + }
1564 +
1565 + return $m[1] . $quote . $fixed . $quote;
1566 + },
1567 + $masked
1568 + );
1569 +
1570 + if ($repaired === null) {
1571 + return $html;
1572 + }
1573 +
1574 + return empty($shielded) ? $repaired : strtr($repaired, $shielded);
1575 + }
1576 +
1013 1577 # do the footer html insertion
1014 1578 function do_footer_html_insertion($replacement_data){
1015 1579
1016 1580 # check that we have footer html
@@ -1414,15 +1978,28 @@
1414 1978
1415 1979 # check matching text
1416 1980 if(trim($heading['current_value'] ?? '') == trim($text ?? '')){
1417 1981
1418 - # replace entire tag output — preserves attributes, removes all children (elements + text nodes)
1982 + # Rebuild the inner HTML, keeping the author's inline
1983 + # formatting. Returns null when the markup can't be preserved
1984 + # unambiguously — leave that heading alone rather than strip
1985 + # its <span style>/<em>/<br> and restyle the page.
1986 + $new_inner = self::build_heading_inner_html(
1987 + $heading_old->innertext,
1988 + $heading['recommended_value']
1989 + );
1990 +
1991 + if ($new_inner === null) {
1992 + continue;
1993 + }
1994 +
1995 + # replace the tag output, preserving the opening tag's attributes
1419 1996 $outer = $heading_old->outertext;
1420 1997 $open_end = strpos($outer, '>');
1421 1998 if ($open_end !== false) {
1422 1999 $open_tag = substr($outer, 0, $open_end + 1);
1423 2000 $close_tag = '</' . $heading['type'] . '>';
1424 - $heading_old->outertext = $open_tag . $heading['recommended_value'] . $close_tag;
2001 + $heading_old->outertext = $open_tag . $new_inner . $close_tag;
1425 2002 }
1426 2003
1427 2004 }
1428 2005 }
@@ -1524,10 +2101,10 @@
1524 2101 }
1525 2102
1526 2103 # If it's a protocol-relative URL (//example.com), check if it matches our domain
1527 2104 if (strpos($url, '//') === 0) {
1528 - $parsed_home = parse_url($home_url);
1529 - $parsed_link = parse_url($url);
2105 + $parsed_home = wp_parse_url($home_url);
2106 + $parsed_link = wp_parse_url($url);
1530 2107
1531 2108 if (isset($parsed_home['host']) && isset($parsed_link['host'])) {
1532 2109 if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) {
1533 2110 return false;
@@ -1649,21 +2226,32 @@
1649 2226 return $post_id ? (int) $post_id : 0;
1650 2227 }
1651 2228
1652 2229 /**
1653 - * Replace the first <meta $attr="$val"> content with $content_escaped (marked
1654 - * data-metasync-seo="custom"); insert one after <head> if none is present.
1655 - * Helper for apply_custom_seo_precedence().
2230 + * Replace the first <meta $attr="$val"> content with $content_escaped.
1656 2231 *
1657 2232 * @param string $html
1658 2233 * @param string $attr "name" or "property"
1659 2234 * @param string $val e.g. "description", "og:description"
1660 2235 * @param string $content_escaped Already HTML-escaped content value
2236 + * @param string $marker Provenance marker for the winning tier
1661 2237 * @return string
1662 2238 */
1663 - private function force_custom_meta($html, $attr, $val, $content_escaped) {
1664 - $tag = '<meta ' . $attr . '="' . $val . '" content="' . $content_escaped . '" data-metasync-seo="custom" />';
1665 - $pattern = '/<meta\s[^>]*' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i';
2239 + private function force_custom_meta($html, $attr, $val, $content_escaped, $marker = 'custom') {
2240 + $marker_attribute = $marker === 'otto'
2241 + ? ' data-metasync-otto="true"'
2242 + : ' data-metasync-seo="custom"';
2243 + $tag = '<meta ' . $attr . '="' . $val . '" content="' . $content_escaped . '"' . $marker_attribute . ' />';
2244 + # The optional prefix group must end on whitespace or a quote so the
2245 + # attribute matched is $attr itself, not something merely ending in it:
2246 + # a bare [^>]*name= also matches data-name="keywords", so a third party's
2247 + # unrelated tag was consumed and rewritten while the real one survived —
2248 + # destroying their value AND leaving the duplicate the dedup had just
2249 + # collapsed. Making the group optional keeps the common
2250 + # <meta name="..." ...> form matching, where <meta\s has already consumed
2251 + # the only separator. Same rule as deduplicate_keywords_tags(); this
2252 + # helper serves title, description and keywords, so all three are covered.
2253 + $pattern = '/<meta\s(?:[^>]*[\s"\'])?' . preg_quote($attr, '/') . '\s*=\s*["\']' . preg_quote($val, '/') . '["\'][^>]*\/?>/i';
1666 2254 $count = 0;
1667 2255 $new = preg_replace_callback($pattern, function ($m) use ($tag) {
1668 2256 return $tag;
1669 2257 }, $html, 1, $count);
@@ -1676,23 +2264,37 @@
1676 2264 }, $html, 1);
1677 2265 }
1678 2266
1679 2267 /**
1680 - * Enforce the SEO-sidebar custom title/description as the FINAL word over OTTO.
2268 + * Make sure the precedence resolver is loaded before calling it.
1681 2269 *
1682 - * The DOM-pass guards (do_header_replacements/handle_meta_element) already skip
1683 - * OTTO for these fields, but later steps re-introduce OTTO's value:
1684 - * - the manual string-replacement title pass re-applies OTTO's title;
1685 - * - OTTO's description arrives via header_html_insertion (data-otto) and the
1686 - * dedup keeps it whenever no data-metasync-seo tag is present — which is
1687 - * exactly the case when the post is synced to a third-party SEO plugin and
1688 - * the sidebar deferred emitting its own tag (output_seo_meta_description()).
2270 + * @return bool
2271 + */
2272 + private static function precedence_available() {
2273 + if (class_exists('Metasync_Seo_Precedence')) {
2274 + return true;
2275 + }
2276 +
2277 + $file = dirname(__DIR__) . '/includes/class-metasync-seo-precedence.php';
2278 + if (is_readable($file)) {
2279 + require_once $file;
2280 + }
2281 +
2282 + return class_exists('Metasync_Seo_Precedence');
2283 + }
2284 +
2285 + /**
2286 + * Enforce the resolved SEO title, description and keywords as the FINAL
2287 + * word over OTTO.
1689 2288 *
1690 - * Running LAST — after all dedup/precedence steps — this makes the user's
1691 - * explicit sidebar value authoritative regardless of how the tag was produced,
1692 - * mirroring apply_metabox_og_precedence() for the OG meta box. Singular views
1693 - * only; a value must be non-empty (explicitly set) to take precedence.
2289 + * The resolver owns the global custom-versus-OTTO order. This final buffer
2290 + * pass must use it too: otherwise the setting works through the sidebar
2291 + * and third-party filters but OTTO's later string pass puts the custom
2292 + * value back on the page.
1694 2293 *
2294 + * OG and Twitter descriptions remain outside this method deliberately;
2295 + * social precedence is a separate follow-up scope.
2296 + *
1695 2297 * @param string $html Full HTML document (post-dedup).
1696 2298 * @return string
1697 2299 */
1698 2300 private function apply_custom_seo_precedence($html) {
@@ -1703,12 +2305,26 @@
1703 2305 if (!$post_id) {
1704 2306 return $html;
1705 2307 }
1706 2308
1707 - # Title — force the custom sidebar title into the (single, post-dedup) <title>.
1708 - $custom_title = get_post_meta($post_id, '_metasync_seo_title', true);
1709 - if (!empty($custom_title) && is_string($custom_title)) {
1710 - $title_tag = '<title>' . htmlspecialchars($custom_title, ENT_QUOTES, 'UTF-8') . '</title>';
2309 + # This runs inside the output buffer, where an unresolvable class is a
2310 + # fatal that takes the whole page down — otto_guard_html() only catches
2311 + # a NULL return, not a thrown Error. A partial update can leave newer
2312 + # PHP files beside an older committed autoload classmap, so resolve the
2313 + # file directly before trusting the class, and leave the document
2314 + # untouched if it is genuinely absent. Same reasoning as
2315 + # Metasync_SEO_Conflict_Handler::precedence_available().
2316 + if (!self::precedence_available()) {
2317 + return $html;
2318 + }
2319 +
2320 + # Title — replace the resolved page title into the (single, post-dedup) <title>.
2321 + $resolved_title = Metasync_Seo_Precedence::resolve(
2322 + $post_id,
2323 + Metasync_Seo_Precedence::FIELD_TITLE
2324 + );
2325 + if (!empty($resolved_title['value'])) {
2326 + $title_tag = '<title>' . htmlspecialchars($resolved_title['value'], ENT_QUOTES, 'UTF-8') . '</title>';
1711 2327 $count = 0;
1712 2328 $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) {
1713 2329 return $title_tag;
1714 2330 }, $html, 1, $count);
@@ -1720,19 +2336,59 @@
1720 2336 }, $html, 1);
1721 2337 }
1722 2338 }
1723 2339
1724 - # Description — force the custom sidebar description into the SEO
1725 - # <meta name="description"> only. og:/twitter: description are social tags
1726 - # owned by the OG meta box and apply_metabox_og_precedence() (which runs
1727 - # earlier); this pass must NOT touch them, or it would override a
1728 - # separately user-set og:description with the plain SEO description.
1729 - $custom_desc = get_post_meta($post_id, '_metasync_seo_desc', true);
1730 - if (!empty($custom_desc) && is_string($custom_desc)) {
1731 - $esc = htmlspecialchars($custom_desc, ENT_QUOTES, 'UTF-8');
1732 - $html = $this->force_custom_meta($html, 'name', 'description', $esc);
2340 + # Description — replace the resolved page description into the SEO
2341 + # <meta name="description"> only. OG/Twitter description is social
2342 + # precedence and remains outside this setting's scope.
2343 + $resolved_description = Metasync_Seo_Precedence::resolve(
2344 + $post_id,
2345 + Metasync_Seo_Precedence::FIELD_DESCRIPTION
2346 + );
2347 + if (!empty($resolved_description['value'])) {
2348 + $esc = htmlspecialchars($resolved_description['value'], ENT_QUOTES, 'UTF-8');
2349 + $marker = Metasync_Seo_Precedence::is_otto_value(
2350 + $post_id,
2351 + Metasync_Seo_Precedence::FIELD_DESCRIPTION,
2352 + $resolved_description
2353 + )
2354 + ? 'otto'
2355 + : 'custom';
2356 + $html = $this->force_custom_meta($html, 'name', 'description', $esc, $marker);
1733 2357 }
1734 2358
2359 + # Keywords — the same final pass as the description above, on the chain
2360 + # the resolver owns, so the global priority setting governs this tag
2361 + # too. OTTO's keywords tag arrives via header_html_insertion carrying
2362 + # a data-otto marker and is spliced in after wp_head, so it is always
2363 + # the later tag and always wins deduplicate_keywords_tags(); forcing
2364 + # the resolved winner here is what keeps the losing tier reachable at
2365 + # all — the single-printing-owner rule the description branch follows.
2366 + #
2367 + # The tier this forces is NOT guaranteed customer-typed, unlike the
2368 + # title and description keys above: the meta_keywords persistence flag
2369 + # writes OTTO's value straight to _metasync_focus_keyword, and neither
2370 + # that conditional write nor the clone cleaner (which strips only the
2371 + # _metasync_otto_* keys) ever clears it, so a stale flag copy can
2372 + # outrank a newer suggestion under custom-first. That key sharing is
2373 + # tracked separately — the fix is a key of its own for the persisted
2374 + # keyword, not a special case here.
2375 + $resolved_keyword = Metasync_Seo_Precedence::resolve(
2376 + $post_id,
2377 + Metasync_Seo_Precedence::FIELD_FOCUS_KEYWORD
2378 + );
2379 + if (!empty($resolved_keyword['value'])) {
2380 + $esc = htmlspecialchars($resolved_keyword['value'], ENT_QUOTES, 'UTF-8');
2381 + $marker = Metasync_Seo_Precedence::is_otto_value(
2382 + $post_id,
2383 + Metasync_Seo_Precedence::FIELD_FOCUS_KEYWORD,
2384 + $resolved_keyword
2385 + )
2386 + ? 'otto'
2387 + : 'custom';
2388 + $html = $this->force_custom_meta($html, 'name', 'keywords', $esc, $marker);
2389 + }
2390 +
1735 2391 return $html;
1736 2392 }
1737 2393
1738 2394 # this function does the header replacements
@@ -2136,8 +2792,34 @@
2136 2792 return $html;
2137 2793 }
2138 2794
2139 2795 /**
2796 + * Read one of the meta box's social title/description keys with the "Auto Draft"
2797 + * placeholder collapsed to ''.
2798 + *
2799 + * Delegates to Metasync_OpenGraph so the placeholder definition lives in one
2800 + * place. Falls back to a raw read when that class — or that method on it — is
2801 + * unavailable: this buffer filter runs on the front end, and a partially updated
2802 + * install can pair a newer otto/ file with an older includes/ one, where calling
2803 + * a method the loaded class doesn't define would fatal the page rather than
2804 + * degrade.
2805 + *
2806 + * @param int $post_id
2807 + * @param string $key
2808 + * @return string
2809 + */
2810 + private function social_meta($post_id, $key) {
2811 + // PHPStan resolves the literal class-string against the current source and so
2812 + // sees the method as always present; the check is deliberate runtime
2813 + // version-skew defence. Suppressed the same way otto_pixel.php does.
2814 + // @phpstan-ignore-next-line function.alreadyNarrowedType
2815 + if (method_exists('Metasync_OpenGraph', 'get_social_meta')) {
2816 + return Metasync_OpenGraph::get_social_meta($post_id, $key);
2817 + }
2818 + return (string) get_post_meta($post_id, $key, true);
2819 + }
2820 +
2821 + /**
2140 2822 * Apply the per-post "Social Media & Open Graph" meta box precedence over OTTO.
2141 2823 *
2142 2824 * Precedence per tag: an explicit meta box value set by the user wins over OTTO;
2143 2825 * a field left blank falls back to OTTO's value; if OTTO also has none, a sensible
@@ -2176,16 +2858,22 @@
2176 2858 }
2177 2859
2178 2860 # Explicit (user-typed) meta box values. A non-empty value means the user set
2179 2861 # this field themselves and it must take priority over OTTO.
2180 - $og_title_set = get_post_meta($post_id, '_metasync_og_title', true);
2181 - $og_desc_set = get_post_meta($post_id, '_metasync_og_description', true);
2862 + #
2863 + # The four social title/description keys are read through
2864 + # Metasync_OpenGraph::get_social_meta(), which collapses the "Auto Draft"
2865 + # placeholder to ''. Without that, a row polluted by the meta box pre-fill on a
2866 + # brand-new post reads as a deliberate override here (it differs from the real
2867 + # title) and would beat OTTO's correct og:title.
2868 + $og_title_set = $this->social_meta($post_id, '_metasync_og_title');
2869 + $og_desc_set = $this->social_meta($post_id, '_metasync_og_description');
2182 2870 $og_image_set = get_post_meta($post_id, '_metasync_og_image', true);
2183 2871 $og_type_set = get_post_meta($post_id, '_metasync_og_type', true);
2184 2872 $tw_card_set = get_post_meta($post_id, '_metasync_twitter_card', true);
2185 2873 $tw_site_set = get_post_meta($post_id, '_metasync_twitter_site', true);
2186 - $tw_title_set = get_post_meta($post_id, '_metasync_twitter_title', true);
2187 - $tw_desc_set = get_post_meta($post_id, '_metasync_twitter_description', true);
2874 + $tw_title_set = $this->social_meta($post_id, '_metasync_twitter_title');
2875 + $tw_desc_set = $this->social_meta($post_id, '_metasync_twitter_description');
2188 2876 $tw_image_set = get_post_meta($post_id, '_metasync_twitter_image', true);
2189 2877 $tw_image_alt_set = get_post_meta($post_id, '_metasync_twitter_image_alt', true);
2190 2878
2191 2879 # Default OG values the meta box pre-fills (post title / generated excerpt /
@@ -2278,8 +2966,18 @@
2278 2966 }
2279 2967 }
2280 2968
2281 2969 # 2) Build additions: overrides (removed above) + gap-fills for anything still absent.
2970 + #
2971 + # When a third-party SEO plugin is active the conflict handler may have suppressed
2972 + # that plugin's og:title / twitter:title so MetaSync can supply the replacement
2973 + # (Metasync_SEO_Sidebar::output_seo_meta_description). Gap-filling those two tags
2974 + # here would inject the meta-box post-title default over that replacement, so skip
2975 + # them in that case. User-customized overrides are unaffected — they were already
2976 + # applied above.
2977 + $seo_plugin_active = class_exists('Metasync_SEO_Conflict_Handler')
2978 + && Metasync_SEO_Conflict_Handler::get_instance()->has_active_seo_plugin();
2979 +
2282 2980 $additions = '';
2283 2981 foreach ($tags as $t) {
2284 2982 list($attr, $key, $val, $is_url, $is_override) = $t;
2285 2983 if ($val === '' || $val === null) {
@@ -2284,8 +2982,11 @@
2284 2982 list($attr, $key, $val, $is_url, $is_override) = $t;
2285 2983 if ($val === '' || $val === null) {
2286 2984 continue;
2287 2985 }
2986 + if (!$is_override && $seo_plugin_active && ($key === 'og:title' || $key === 'twitter:title')) {
2987 + continue; # never gap-fill the post title over a third-party plugin's own title
2988 + }
2288 2989 if (!$is_override && preg_match('/<meta\s[^>]*' . $attr . '\s*=\s*(["\'])' . preg_quote($key, '/') . '\1/i', $head)) {
2289 2990 continue; # OTTO (or another source) already provides it and the user did not override
2290 2991 }
2291 2992 $content = $is_url ? esc_url($val) : esc_attr($val);
@@ -2432,8 +3133,82 @@
2432 3133 return $html;
2433 3134 }
2434 3135
2435 3136 /**
3137 + * Remove duplicate <meta name="keywords"> tags from HTML.
3138 + *
3139 + * OTTO ships its keywords tag inside header_html_insertion, and MetaSync's
3140 + * own SEO output now emits one too whenever the precedence chain resolves a
3141 + * keyword — so a server-side-rendered page can carry both.
3142 + *
3143 + * The keeper is OTTO's tag, or the first occurrence when no OTTO tag is
3144 + * present. Unlike deduplicate_description_tags() there is no
3145 + * customer-authored branch here: print_metatag() emits no data attribute, so
3146 + * a keywords tag never carries data-metasync-seo="custom" at this point in
3147 + * the pipeline. The customer tier is re-applied afterwards by
3148 + * apply_custom_seo_precedence(), which runs later and stamps the marker via
3149 + * force_custom_meta(). Collapsing to OTTO's tag here is therefore safe: it is
3150 + * not the final word on what the page renders.
3151 + *
3152 + * @param string $html Full HTML document.
3153 + * @return string HTML with at most one <meta name="keywords">.
3154 + */
3155 + private function deduplicate_keywords_tags($html) {
3156 + # No is_string() guard, unlike deduplicate_description_tags() beside it.
3157 + # That asymmetry is forced, not an oversight: @param string makes the
3158 + # check provably always-true at PHPStan level 5, and the description
3159 + # sibling's copy is one of a fixed number of occurrences the baseline
3160 + # pins by count, so adding another fails the build. Both methods are
3161 + # only ever called with the buffer string.
3162 + if ($html === '') {
3163 + return $html;
3164 + }
3165 +
3166 + # Match <meta ... name="keywords" ...> in either attribute order.
3167 + #
3168 + # The optional prefix group must end on whitespace or a quote so the
3169 + # attribute is `name`, not something merely ending in it: a bare
3170 + # [^>]*name= also matches data-name="keywords". Making the group
3171 + # optional keeps the common `<meta name="keywords" ...>` form matching,
3172 + # where <meta\s has already consumed the only separator.
3173 + #
3174 + # The opening quote must be immediately followed by "keywords", so
3175 + # name="news_keywords" is not matched.
3176 + $pattern = '/<meta\s(?:[^>]*[\s"\'])?name\s*=\s*["\']keywords["\'][^>]*\/?>/i';
3177 +
3178 + if (preg_match_all($pattern, $html, $matches) <= 1) {
3179 + return $html; # 0 or 1 — nothing to deduplicate
3180 + }
3181 +
3182 + $all_tags = $matches[0];
3183 +
3184 + $otto_tag = null;
3185 + foreach ($all_tags as $tag) {
3186 + if (stripos($tag, 'data-otto-pixel') !== false ||
3187 + stripos($tag, 'data-metasync-otto') !== false ||
3188 + stripos($tag, 'data-otto') !== false
3189 + ) {
3190 + $otto_tag = $tag;
3191 + break;
3192 + }
3193 + }
3194 + $keeper = $otto_tag ?: $all_tags[0];
3195 +
3196 + # Callback form avoids backreference injection when the keyword list
3197 + # contains $ followed by digits.
3198 + $first_replaced = false;
3199 + $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) {
3200 + if (!$first_replaced) {
3201 + $first_replaced = true;
3202 + return $keeper;
3203 + }
3204 + return ''; # Remove subsequent duplicates
3205 + }, $html);
3206 +
3207 + return $html;
3208 + }
3209 +
3210 + /**
2436 3211 * Remove duplicate <link rel="canonical"> tags from HTML.
2437 3212 *
2438 3213 * When OTTO injects a canonical via header_html_insertion and MetaSync's
2439 3214 * SEO output (or WordPress core) has already emitted one, keep only the
@@ -2474,96 +3249,301 @@
2474 3249 return $html;
2475 3250 }
2476 3251
2477 3252 /**
3253 + * Normalize a JSON-LD `@type` into a list of lowercased type tokens.
3254 + *
3255 + * JSON-LD allows `@type` to be a string OR an array of strings, and Rank Math
3256 + * routinely emits multi-typed nodes (e.g. ["WebPage", "CollectionPage"]).
3257 + * Collapsing such a node to its first token hides it from a plain-typed
3258 + * duplicate, so every token is returned and indexed separately.
3259 + *
3260 + * @param mixed $raw The raw `@type` value.
3261 + * @return string[] Unique lowercased tokens, possibly empty.
3262 + */
3263 + private function schema_type_tokens($raw) {
3264 + $tokens = [];
3265 +
3266 + foreach ((is_array($raw) ? $raw : [$raw]) as $type) {
3267 + if (is_array($type) || is_object($type) || is_bool($type) || $type === null) {
3268 + continue;
3269 + }
3270 + $type = strtolower(trim((string) $type));
3271 + if ($type === '') {
3272 + continue;
3273 + }
3274 + $tokens[$type] = true;
3275 + }
3276 +
3277 + return array_keys($tokens);
3278 + }
3279 +
3280 + /**
2478 3281 * Deduplicate JSON-LD schema blocks.
2479 3282 *
2480 - * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json">
2481 - * blocks, keep OTTO's version for any @type that appears in both.
2482 - * Third-party blocks whose @type is not covered by OTTO are preserved.
3283 + * Two nodes collide when they share an `@id`, or when one claims an `@type`
3284 + * token the page already carries. Collisions resolve strictly in OTTO's
3285 + * favour:
2483 3286 *
3287 + * - an OTTO node (marked `data-otto`) displaces every colliding
3288 + * third-party node, whichever appeared first in the document;
3289 + * - two OTTO nodes describing the same entity keep the first;
3290 + * - two third-party nodes never evict each other, even when they share a
3291 + * type: a Yoast graph legitimately carries two distinct ImageObjects,
3292 + * and MetaSync's own breadcrumb / local-business blocks are third-party
3293 + * output this pass has no mandate to fold.
3294 + *
3295 + * When OTTO contributed no node at all the document comes back untouched —
3296 + * a third-party-only page is not ours to rewrite. Edits are offset splices
3297 + * into the original document, so a block that loses nothing keeps its exact
3298 + * bytes (attributes, `class`, `@context`, position — head, body or footer)
3299 + * and nothing is ever relocated.
3300 + *
3301 + * Blocks are left exactly where they are when their JSON does not parse,
3302 + * when they carry neither `@type` nor `@graph`, or when they sit inside an
3303 + * HTML comment or <noscript> — a schema node the site disabled by wrapping
3304 + * it in a comment must stay disabled, not be re-published by this pass.
3305 + *
2484 3306 * @param string $html Full HTML.
2485 3307 * @return string
2486 3308 */
2487 3309 private function deduplicate_schema_tags($html) {
2488 - // Find all JSON-LD script blocks
2489 - $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i';
2490 - if (preg_match_all($pattern, $html, $matches, PREG_SET_ORDER) <= 1) {
3310 + // Find all JSON-LD script blocks. Group 1 is the FULL attribute string —
3311 + // capturing only the part before `type=` would hide a `data-otto` marker
3312 + // that sits after it, which is where every insertion path puts it. The
3313 + // type VALUE is prefix-matched so `application/ld+json; charset=UTF-8`
3314 + // is recognised too.
3315 + $pattern = '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>\s*([\s\S]*?)<\/script>/i';
3316 + if (!preg_match_all($pattern, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
2491 3317 return $html;
2492 3318 }
2493 3319
2494 - $otto_by_type = []; // @type => decoded JSON object
2495 - $third_by_type = []; // @type => decoded JSON object
2496 - $otto_graph = []; // entries from OTTO @graph blocks
2497 - $third_graph = []; // entries from third-party @graph blocks
3320 + // Byte ranges this pass must not reach into. A commented-out (or
3321 + // <noscript>-wrapped) schema block is deliberately disabled output.
3322 + $masked = [];
3323 + if (preg_match_all('/<!--[\s\S]*?-->|<noscript\b[^>]*>[\s\S]*?<\/noscript>/i', $html, $comments, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
3324 + foreach ($comments as $comment) {
3325 + $masked[] = [$comment[0][1], $comment[0][1] + strlen($comment[0][0])];
3326 + }
3327 + }
2498 3328
3329 + // `data-otto` must be a whole attribute: `data-otto="true"` or a bare
3330 + // `data-otto`. A substring test would also catch a hypothetical
3331 + // `data-otto-thing="x"` and misattribute that block to OTTO.
3332 + $is_otto_attr = '/(^|\s)data-otto(\s*=|\s|$)/i';
3333 +
3334 + // Blocks able to take part, and their entities in document order. An
3335 + // entity is one addressable node: a flat block's document, one
3336 + // `@graph` entry, or one node of a top-level array.
3337 + $blocks = [];
3338 + $entities = [];
3339 +
2499 3340 foreach ($matches as $m) {
2500 - $attrs = $m[1];
2501 - $json_str = $m[3];
3341 + $start = $m[0][1];
3342 + foreach ($masked as $range) {
3343 + if ($start >= $range[0] && $start < $range[1]) {
3344 + continue 2;
3345 + }
3346 + }
3347 +
3348 + $attrs = $m[1][0];
3349 + $json_str = $m[3][0];
2502 3350 $decoded = json_decode($json_str, true);
2503 3351 if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
2504 - continue; // skip unparseable blocks — leave them in place
3352 + continue; // unparseable — leave the block in place
2505 3353 }
2506 - $is_otto = stripos($attrs, 'data-otto') !== false;
2507 3354
3355 + $is_otto = preg_match($is_otto_attr, $attrs) === 1;
3356 + $end = $start + strlen($m[0][0]);
3357 +
3358 + // A trailing newline swallowed on full removal keeps the output tidy.
3359 + $end_ws = $end;
3360 + if (substr($html, $end, 2) === "\r\n") {
3361 + $end_ws = $end + 2;
3362 + } elseif (substr($html, $end, 1) === "\n") {
3363 + $end_ws = $end + 1;
3364 + }
3365 +
3366 + $register = function (array $node) use (&$entities, $is_otto) {
3367 + // Keyed like metasync_otto_structured_entity_key(): @id first,
3368 + // @type tokens for the type fold. Entries with neither stay in
3369 + // their block but cannot collide, so they keep entity === null.
3370 + $types = isset($node['@type']) ? $this->schema_type_tokens($node['@type']) : [];
3371 + $id = (!empty($node['@id']) && is_string($node['@id'])) ? '@id:' . strtolower(trim($node['@id'])) : '';
3372 + if ($types === [] && $id === '') {
3373 + return null;
3374 + }
3375 + $entities[] = ['otto' => $is_otto, 'types' => $types, 'id' => $id, 'alive' => true];
3376 + return count($entities) - 1;
3377 + };
3378 +
3379 + $block = ['start' => $start, 'end' => $end, 'end_ws' => $end_ws, 'attrs' => $attrs, 'decoded' => $decoded, 'items' => []];
3380 +
2508 3381 if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
2509 - foreach ($decoded['@graph'] as $entry) {
2510 - if (!isset($entry['@type'])) continue;
2511 - // JSON-LD allows @type to be a string OR an array of strings.
2512 - // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]).
2513 - // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar.
2514 - $type = is_array($entry['@type'])
2515 - ? (string) reset($entry['@type'])
2516 - : (string) $entry['@type'];
2517 - if ($type === '') continue;
2518 - if ($is_otto) {
2519 - $otto_graph[$type] = $entry;
2520 - } else {
2521 - $third_graph[$type] = $entry;
2522 - }
3382 + foreach ($decoded['@graph'] as $node) {
3383 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
2523 3384 }
3385 + $block['shape'] = 'graph';
2524 3386 } elseif (isset($decoded['@type'])) {
2525 - $type = is_array($decoded['@type'])
2526 - ? (string) reset($decoded['@type'])
2527 - : (string) $decoded['@type'];
2528 - if ($type === '') continue;
2529 - if ($is_otto) {
2530 - $otto_by_type[$type] = $decoded;
3387 + $block['shape'] = 'flat';
3388 + $block['items'][] = ['node' => $decoded, 'e' => $register($decoded)];
3389 + } elseif (isset($decoded[0])) {
3390 + // A bare top-level array: [{"@type":"WebPage",...}, ...]. The
3391 + // persistence path already folds this shape; the render path
3392 + // used to skip it entirely, leaving its duplicates standing.
3393 + $block['shape'] = 'list';
3394 + foreach ($decoded as $node) {
3395 + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null];
3396 + }
3397 + } else {
3398 + continue; // neither @type nor @graph nor array — nothing to key on
3399 + }
3400 +
3401 + $blocks[] = $block;
3402 + }
3403 +
3404 + foreach ($entities as $entity) {
3405 + if ($entity['otto']) {
3406 + $otto_present = true;
3407 + break;
3408 + }
3409 + }
3410 + if (empty($otto_present)) {
3411 + return $html; // OTTO contributed no schema — a third-party page is not ours to rewrite
3412 + }
3413 +
3414 + // Fold. Type claims map a token to EVERY alive holder, so a later OTTO
3415 + // node can displace a whole set of third-party duplicates at once.
3416 + /** @var array<string, int> $by_id */
3417 + $by_id = []; // '@id:...' => entity index
3418 + /** @var array<string, int[]> $by_type */
3419 + $by_type = []; // type token => entity index[]
3420 +
3421 + $release = function ($i) use (&$by_id, &$by_type, &$entities) {
3422 + foreach ($entities[$i]['types'] as $token) {
3423 + $holders = $by_type[$token] ?? [];
3424 + $holders = array_values(array_diff($holders, [$i]));
3425 + if ($holders === []) {
3426 + unset($by_type[$token]);
2531 3427 } else {
2532 - $third_by_type[$type] = $decoded;
3428 + $by_type[$token] = $holders;
2533 3429 }
2534 3430 }
3431 + $id = $entities[$i]['id'];
3432 + if ($id !== '' && ($by_id[$id] ?? -1) === $i) {
3433 + unset($by_id[$id]);
3434 + }
3435 + };
3436 +
3437 + foreach ($entities as $i => $entity) {
3438 + $alive = true;
3439 +
3440 + // The same real-world entity twice, whatever @type each copy carries.
3441 + if ($entity['id'] !== '' && isset($by_id[$entity['id']])) {
3442 + $occupant = $by_id[$entity['id']];
3443 + if ($entity['otto'] && !$entities[$occupant]['otto']) {
3444 + $entities[$occupant]['alive'] = false;
3445 + $release($occupant);
3446 + unset($by_id[$entity['id']]); // re-claimed below if the entity survives
3447 + } else {
3448 + $alive = false; // first copy stands (OTTO's, or the earlier OTTO's)
3449 + }
3450 + }
3451 +
3452 + if ($alive) {
3453 + foreach ($entity['types'] as $token) {
3454 + $holders = $by_type[$token] ?? [];
3455 + if ($holders === []) {
3456 + continue;
3457 + }
3458 + $otto_holder = false;
3459 + foreach ($holders as $holder) {
3460 + if ($entities[$holder]['otto']) {
3461 + $otto_holder = true;
3462 + break;
3463 + }
3464 + }
3465 + if ($otto_holder) {
3466 + $alive = false; // an OTTO node already holds this token
3467 + break;
3468 + }
3469 + if ($entity['otto']) {
3470 + // OTTO displaces every third-party holder of the token and
3471 + // releases ALL their claims, not just this one — otherwise
3472 + // tokens the evicted node held alone would stay blocked and
3473 + // later, legitimate nodes of that type would lose.
3474 + foreach ($holders as $holder) {
3475 + $entities[$holder]['alive'] = false;
3476 + $release($holder);
3477 + }
3478 + }
3479 + // Third party meeting third-party holders: both stand.
3480 + }
3481 + }
3482 +
3483 + if ($alive) {
3484 + foreach ($entity['types'] as $token) {
3485 + $by_type[$token][] = $i;
3486 + }
3487 + if ($entity['id'] !== '') {
3488 + $by_id[$entity['id']] = $i;
3489 + }
3490 + } else {
3491 + $entities[$i]['alive'] = false;
3492 + }
2535 3493 }
2536 3494
2537 - // If OTTO provided no schema at all, nothing to deduplicate
2538 - if (empty($otto_by_type) && empty($otto_graph)) {
2539 - return $html;
3495 + $any_evicted = false;
3496 + foreach ($entities as $entity) {
3497 + if (!$entity['alive']) {
3498 + $any_evicted = true;
3499 + break;
3500 + }
2540 3501 }
3502 + if (!$any_evicted) {
3503 + return $html; // no collision — every original byte stays
3504 + }
2541 3505
2542 - // Remove all JSON-LD blocks from HTML
2543 - $html = preg_replace($pattern, '', $html);
3506 + // Rebuild only the blocks that lost something, back-to-front so the
3507 + // recorded offsets of earlier blocks stay valid.
3508 + $edits = [];
3509 + foreach ($blocks as $block) {
3510 + $kept = [];
3511 + $lost = 0;
3512 + foreach ($block['items'] as $item) {
3513 + if ($item['e'] === null || $entities[$item['e']]['alive']) {
3514 + $kept[] = $item['node'];
3515 + } else {
3516 + $lost++;
3517 + }
3518 + }
3519 + if ($lost === 0) {
3520 + continue;
3521 + }
3522 + if ($kept === []) {
3523 + $edits[] = [$block['start'], $block['end_ws'], ''];
3524 + continue;
3525 + }
2544 3526
2545 - // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type
2546 - $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type));
2547 - $rebuilt = '';
2548 - foreach ($kept as $decoded) {
2549 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
2550 - wp_json_encode($decoded) . "</script>\n";
2551 - }
3527 + if ($block['shape'] === 'list') {
3528 + $payload = $kept;
3529 + } else { // graph — a flat block that lost its only entity is removed above
3530 + $payload = $block['decoded'];
3531 + $payload['@graph'] = $kept;
3532 + }
2552 3533
2553 - // Re-insert merged @graph block (if any entries exist)
2554 - $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type
2555 - if (!empty($merged_graph)) {
2556 - $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)];
2557 - $rebuilt .= '<script type="application/ld+json" data-otto="true">' .
2558 - wp_json_encode($graph_obj) . "</script>\n";
3534 + $json = metasync_safe_json_ld_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
3535 + if (!is_string($json) || $json === '') {
3536 + continue; // never drop a block whose payload failed to re-encode
3537 + }
3538 +
3539 + // The original attribute string is reused verbatim so class, id and
3540 + // the data-otto marker itself survive the rebuild.
3541 + $edits[] = [$block['start'], $block['end'], '<script' . $block['attrs'] . '>' . $json . '</script>'];
2559 3542 }
2560 3543
2561 - // Re-inject before </head>
2562 - if (!empty($rebuilt)) {
2563 - $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) {
2564 - return $rebuilt . $m[1];
2565 - }, $html, 1);
3544 + foreach (array_reverse($edits) as $edit) {
3545 + $html = substr($html, 0, $edit[0]) . $edit[2] . substr($html, $edit[1]);
2566 3546 }
2567 3547
2568 3548 return $html;
2569 3549 }
@@ -2582,14 +3562,14 @@
2582 3562 return true;
2583 3563 }
2584 3564
2585 3565 # Check if amp=1 query parameter is present
2586 - if (isset($_GET['amp']) && $_GET['amp'] == '1') {
3566 + if (isset($_GET['amp']) && $_GET['amp'] == '1') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection
2587 3567 return true;
2588 3568 }
2589 3569
2590 3570 # Check for other common AMP query parameters
2591 - if (isset($_GET['amp']) && !empty($_GET['amp'])) {
3571 + if (isset($_GET['amp']) && !empty($_GET['amp'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only AMP detection
2592 3572 return true;
2593 3573 }
2594 3574
2595 3575 return false;
@@ -2594,8 +3574,32 @@
2594 3574
2595 3575 return false;
2596 3576 }
2597 3577
3578 + # function to mark OTTO's own JSON-LD blocks so deduplicate_schema_tags() can
3579 + # tell them apart from a third-party SEO plugin's blocks. Every insertion path
3580 + # (DOM, string fallback, HTTP fallback) runs the fragment through this so the
3581 + # bytes they insert are identical and the "already applied" checks still match.
3582 + private function stamp_otto_json_ld($html){
3583 +
3584 + $stamped = preg_replace_callback(
3585 + '/<script(\s[^>]*type\s*=\s*(["\'])application\/ld\+json[^"\'>]*\2[^>]*)>/i',
3586 + function ($m) {
3587 + # idempotent: a block already carrying a data-otto attribute —
3588 + # anywhere in the tag, not just before `type` — is left alone,
3589 + # never given a second one
3590 + if (preg_match('/(^|\s)data-otto(\s|=|$)/i', $m[1])) {
3591 + return $m[0];
3592 + }
3593 + return '<script' . $m[1] . ' data-otto="true">';
3594 + },
3595 + $html
3596 + );
3597 +
3598 + # preg_replace returns null on a PCRE failure — never hand back less than we got
3599 + return is_string($stamped) ? $stamped : $html;
3600 + }
3601 +
2598 3602 # this function insterts header html to the dom
2599 3603 function insert_header_html($data){
2600 3604
2601 3605 # check that we have the header html
@@ -2608,20 +3612,27 @@
2608 3612 $head = $this->dom->find('head', 0);
2609 3613
2610 3614 if ($head) {
2611 3615
2612 - # Check if this is an AMP page - if so, don't add metasync_optimized attribute
3616 + # Check if this is an AMP page - if so, skip the processed marker
2613 3617 $is_amp_page = $this->is_amp_page();
2614 3618
2615 - # Append the new HTML at the start of the <head> tag
2616 - # For AMP pages: use clean <head> tag without metasync_optimized attribute
2617 - # For non-AMP pages: add metasync_optimized attribute to <head> tag
2618 - if ($is_amp_page) {
2619 - $head->outertext = '<head>' .$data['header_html_insertion']. $head->innertext . '</head>';
2620 - } else {
2621 - $head->outertext = '<head metasync_optimized>' .$data['header_html_insertion']. $head->innertext . '</head>';
2622 - }
3619 + # Append the new HTML at the start of the <head> tag. The tag itself
3620 + # stays a literal '<head>' either way, so plugins that inject with a
3621 + # literal str_replace('<head>', ...) still match the real tag —
3622 + # see mark_head_optimized() for what breaks when it does not.
3623 + $marker = $is_amp_page ? '' : self::HEAD_OPTIMIZED_MARKER;
2623 3624
3625 + # Escape JSON-LD payload closers so a `</script>` inside a JSON
3626 + # string value cannot terminate the element early (XSS/page corruption).
3627 + # Then stamp the blocks as OTTO's, exactly as the string and HTTP
3628 + # fallbacks do, so the deduper can attribute them.
3629 + $escaped_header_insertion = $this->stamp_otto_json_ld(
3630 + metasync_escape_json_ld_blocks_in_html($data['header_html_insertion'])
3631 + );
3632 +
3633 + $head->outertext = '<head>' . $marker . $escaped_header_insertion . $head->innertext . '</head>';
3634 +
2624 3635 }
2625 3636
2626 3637 # save and reload DOM
2627 3638 $this->save_reload();
@@ -2815,8 +3826,12 @@
2815 3826
2816 3827 # Fix malformed self-closing non-void tags (e.g. <ul/ class="...">)
2817 3828 $html = $this->fix_malformed_self_closing_tags($html);
2818 3829
3830 + # Quote unquoted attribute values (e.g. data-autoplay-viewport=80%) —
3831 + # SimpleHtmlDom swallows every following attribute into such a value.
3832 + $html = $this->quote_unquoted_attribute_values($html);
3833 +
2819 3834 # Load HTML into DOM
2820 3835 $this->dom->load($html, true, false);
2821 3836
2822 3837 # Force UTF-8 charset to preserve emojis and special characters
@@ -2998,15 +4013,16 @@
2998 4013 }
2999 4014
3000 4015 # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it
3001 4016 if (!empty($replacement_data['header_html_insertion'])) {
3002 - $header_html_check = trim($replacement_data['header_html_insertion']);
4017 + # Escape JSON-LD payload closers and stamp OTTO's blocks first; the DOM
4018 + # path inserts the same bytes, so the already-applied check must compare
4019 + # against the stamped form or it would insert a second copy.
4020 + $header_html_insertion = $this->stamp_otto_json_ld(
4021 + metasync_escape_json_ld_blocks_in_html($replacement_data['header_html_insertion'])
4022 + );
4023 + $header_html_check = trim($header_html_insertion);
3003 4024 if (strpos($result_html, $header_html_check) === false) {
3004 - $header_html_insertion = preg_replace(
3005 - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i',
3006 - '<script$1type=$2application/ld+json$2 data-otto="true"',
3007 - $replacement_data['header_html_insertion']
3008 - );
3009 4025 $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion);
3010 4026 # Insert before </head>
3011 4027 $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1);
3012 4028 }
@@ -3078,13 +4094,19 @@
3078 4094
3079 4095 $result_html = preg_replace_callback(
3080 4096 '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is',
3081 4097 function ($m) use ($current_value, $recommended_value) {
3082 - $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8')));
3083 - if ($inner_text === $current_value) {
3084 - return $m[1] . $recommended_value . $m[3];
4098 + $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(wp_strip_all_tags($m[2]), ENT_QUOTES, 'UTF-8')));
4099 + if ($inner_text !== $current_value) {
4100 + return $m[0];
3085 4101 }
3086 - return $m[0];
4102 + # Keep the author's inline formatting; skip when it can't
4103 + # be preserved rather than flattening the heading.
4104 + $new_inner = self::build_heading_inner_html($m[2], $recommended_value);
4105 + if ($new_inner === null) {
4106 + return $m[0];
4107 + }
4108 + return $m[1] . $new_inner . $m[3];
3087 4109 },
3088 4110 $result_html,
3089 4111 -1
3090 4112 );
@@ -3132,8 +4154,9 @@
3132 4154
3133 4155 # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema
3134 4156 $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags');
3135 4157 $result_html = $this->otto_guard_html($this->deduplicate_description_tags($result_html), $result_html, 'deduplicate_description_tags');
4158 + $result_html = $this->otto_guard_html($this->deduplicate_keywords_tags($result_html), $result_html, 'deduplicate_keywords_tags');
3136 4159 $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags');
3137 4160 $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence');
3138 4161 $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence');
3139 4162 $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags');
@@ -3152,11 +4175,11 @@
3152 4175
3153 4176 # Clear element cache array
3154 4177 $this->cached_elements = [];
3155 4178
3156 - # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it)
3157 - if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) {
3158 - $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1);
4179 + # Mark the <head> as processed (post-serialization so dom->clear() can't wipe it)
4180 + if (!$this->is_amp_page()) {
4181 + $result_html = $this->mark_head_optimized($result_html);
3159 4182 }
3160 4183
3161 4184 $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes');
3162 4185
@@ -3192,8 +4215,12 @@
3192 4215 # undo double-encoded numeric/hex character references produced by the
3193 4216 # bundled simplehtmldom serializer from attributes like data-x-icon-s="&#xf3c5".
3194 4217 $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities');
3195 4218
4219 + # restore literal '&' query-string separators the serializer escaped to
4220 + # '&amp;', which silently truncates multi-family Google Fonts URLs.
4221 + $result_html = $this->otto_guard_html($this->repair_query_string_ampersands($result_html), $result_html, 'repair_query_string_ampersands');
4222 +
3196 4223 # Remove any lazy-video facade <style> hoisted into the page
3197 4224 # (runs while srcdoc is still tokenized, so the iframe's own copy is safe).
3198 4225 $result_html = $this->strip_hoisted_facade_styles($result_html);
3199 4226
@@ -3210,12 +4237,26 @@
3210 4237
3211 4238 return $result_html;
3212 4239
3213 4240 } catch (Exception $e) {
3214 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Exception in process_html_directly - ' . $e->getMessage());
4241 + if (function_exists('metasync_otto_report_render_failure')) {
4242 + metasync_otto_report_render_failure(
4243 + 'MODIFICATION_EXCEPTION',
4244 + 'OTTO rewrite threw: ' . $e->getMessage(),
4245 + ['exception' => get_class($e)],
4246 + 'error'
4247 + );
4248 + }
3215 4249 return false;
3216 4250 } catch (Error $e) {
3217 - error_log('MetaSync ' . Metasync::get_whitelabel_otto_name() . ': Error in process_html_directly - ' . $e->getMessage());
4251 + if (function_exists('metasync_otto_report_render_failure')) {
4252 + metasync_otto_report_render_failure(
4253 + 'MODIFICATION_ERROR',
4254 + 'OTTO rewrite errored: ' . $e->getMessage(),
4255 + ['error' => get_class($e)],
4256 + 'error'
4257 + );
4258 + }
3218 4259 return false;
3219 4260 }
3220 4261 }
3221 4262
@@ -3264,23 +4305,15 @@
3264 4305 }
3265 4306 }
3266 4307 }
3267 4308
3268 - if ($remove_title && !$has_custom_title) {
3269 - # Remove Open Graph and Twitter title tags (keep main <title>)
3270 - $title_selectors = [
3271 - 'meta[property=og:title]',
3272 - 'meta[name=twitter:title]',
3273 - ];
4309 + # Preserve third-party og:title/twitter:title tags here. They used to be
4310 + # stripped whenever $remove_title was set, but that also removes them
4311 + # when no custom title exists to replace them, leaving pages with no
4312 + # og:title/twitter:title at all. OTTO's own title/OG dedup and
4313 + # precedence passes already reconcile duplicates, so this branch no
4314 + # longer needs to delete those tags.
3274 4315
3275 - foreach ($title_selectors as $selector) {
3276 - $tags = $this->dom->find($selector);
3277 - foreach ($tags as $tag) {
3278 - $tag->outertext = ''; # Remove the tag
3279 - }
3280 - }
3281 - }
3282 -
3283 4316 # Save changes
3284 4317 $this->save_reload();
3285 4318 }
3286 4319
@@ -3504,5 +4537,5 @@
3504 4537 return $html;
3505 4538 }
3506 4539
3507 4540
3508 -}
4541 +}