| @@ -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 |
| @@ -62,8 +67,57 @@ | ||
| 62 | 67 | return $html; |
| 63 | 68 | } |
| 64 | 69 | |
| 65 | 70 | /** |
| 71 | + * Fix malformed self-closing non-void HTML tags before DOM parsing. | |
| 72 | + * | |
| 73 | + * Some themes (e.g. Bootstrap Component Blox) output malformed tags like: | |
| 74 | + * <ul/ class="sub-menu dropdown-menu" /> | |
| 75 | + * SimpleHtmlDom interprets <ul/ as a void element, strips all attributes, | |
| 76 | + * and re-serializes as bare <ul> — breaking Bootstrap dropdown menus. | |
| 77 | + * | |
| 78 | + * This normalizes such tags into proper opening tags: | |
| 79 | + * <ul class="sub-menu dropdown-menu"> | |
| 80 | + * | |
| 81 | + * @param string $html Raw HTML before DOM parsing. | |
| 82 | + * @return string HTML with malformed self-closing non-void tags fixed. | |
| 83 | + */ | |
| 84 | + private function fix_malformed_self_closing_tags($html) { | |
| 85 | + # Non-void elements that must never be self-closing | |
| 86 | + $tags = 'ul|ol|li|div|span|a|p|h[1-6]|section|header|footer|nav|main|article|aside' | |
| 87 | + . '|table|thead|tbody|tfoot|tr|td|th|caption|colgroup' | |
| 88 | + . '|form|fieldset|legend|label|select|option|optgroup|textarea|button' | |
| 89 | + . '|figure|figcaption|details|summary|blockquote|pre|code|dl|dt|dd' | |
| 90 | + . '|video|audio|canvas|iframe|object|embed|map|picture|source'; | |
| 91 | + | |
| 92 | + # Pattern 1: <tag/ attr...> or <tag/ attr... /> — slash after tag name | |
| 93 | + # e.g. <ul/ class="sub-menu dropdown-menu" /> | |
| 94 | + $html = preg_replace( | |
| 95 | + '#<(' . $tags . ')/(\s[^>]*?)\s*/>#si', | |
| 96 | + '<$1$2>', | |
| 97 | + $html | |
| 98 | + ); | |
| 99 | + | |
| 100 | + # Pattern 2: <tag/ attr...> without trailing slash | |
| 101 | + # e.g. <ul/ class="sub-menu"> | |
| 102 | + $html = preg_replace( | |
| 103 | + '#<(' . $tags . ')/(\s)#si', | |
| 104 | + '<$1$2', | |
| 105 | + $html | |
| 106 | + ); | |
| 107 | + | |
| 108 | + # Pattern 3: <tag attr... /> — proper self-closing syntax on non-void element | |
| 109 | + # e.g. <ul class="sub-menu" /> | |
| 110 | + $html = preg_replace( | |
| 111 | + '#<(' . $tags . ')(\s[^>]*?)\s*/>#si', | |
| 112 | + '<$1$2>', | |
| 113 | + $html | |
| 114 | + ); | |
| 115 | + | |
| 116 | + return $html; | |
| 117 | + } | |
| 118 | + | |
| 119 | + /** | |
| 66 | 120 | * Restore case-sensitive SVG and HTML5 attributes that SimpleHtmlDom lowercases. |
| 67 | 121 | * The DOM parser's $lowercase=true flag (required for find() queries) lowercases |
| 68 | 122 | * all attribute names, breaking case-sensitive attributes like viewBox. |
| 69 | 123 | * Applied on final HTML output after all DOM processing is complete. |
| @@ -76,8 +130,617 @@ | ||
| 76 | 130 | ]; |
| 77 | 131 | return str_ireplace(array_keys($map), array_values($map), $html); |
| 78 | 132 | } |
| 79 | 133 | |
| 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('"', '"', $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 | + /** | |
| 322 | + * / Capture ALL <style> blocks before DOM processing. | |
| 323 | + * | |
| 324 | + * SimpleHtmlDom can lose or corrupt <style> blocks during parse/serialize, | |
| 325 | + * especially those without an id attribute (common in custom themes). | |
| 326 | + * This captures every <style> block so we can re-inject any that are lost. | |
| 327 | + * | |
| 328 | + * @param string $html Raw HTML before DOM parsing. | |
| 329 | + * @return array Keyed by id (or content hash for anonymous styles) → full <style> tag. | |
| 330 | + */ | |
| 331 | + private function capture_style_blocks($html) { | |
| 332 | + $blocks = []; | |
| 333 | + if (preg_match_all('/<style[^>]*>.*?<\/style>/si', $html, $matches)) { | |
| 334 | + foreach ($matches[0] as $style_tag) { | |
| 335 | + if (preg_match('/id=["\']([^"\']+)["\']/', $style_tag, $id_match)) { | |
| 336 | + $key = $id_match[1]; | |
| 337 | + } else { | |
| 338 | + $key = 'anon_' . md5($style_tag); | |
| 339 | + } | |
| 340 | + $blocks[$key] = $style_tag; | |
| 341 | + } | |
| 342 | + } | |
| 343 | + return $blocks; | |
| 344 | + } | |
| 345 | + | |
| 346 | + /** | |
| 347 | + * / Re-inject any <style> blocks lost during DOM processing. | |
| 348 | + * | |
| 349 | + * Compares the captured blocks against the final HTML and re-injects any | |
| 350 | + * that disappeared. Named blocks (with id) are checked by id; anonymous | |
| 351 | + * blocks are checked by exact content match. | |
| 352 | + * | |
| 353 | + * @param string $html Processed HTML after DOM modifications. | |
| 354 | + * @param array $original_blocks Blocks returned by capture_style_blocks(). | |
| 355 | + * @return string HTML with lost style blocks restored. | |
| 356 | + */ | |
| 357 | + private function restore_lost_style_blocks($html, $original_blocks) { | |
| 358 | + if (empty($original_blocks)) { | |
| 359 | + return $html; | |
| 360 | + } | |
| 361 | + | |
| 362 | + foreach ($original_blocks as $key => $style_tag) { | |
| 363 | + if (strpos($key, 'anon_') === 0) { | |
| 364 | + # Anonymous block — check if exact content is still present | |
| 365 | + if (strpos($html, $style_tag) === false) { | |
| 366 | + $html = str_replace('</head>', $style_tag . "\n" . '</head>', $html); | |
| 367 | + } | |
| 368 | + } else { | |
| 369 | + # Named block — check if its id is still in the document | |
| 370 | + if (strpos($html, 'id="' . $key . '"') === false | |
| 371 | + && strpos($html, "id='" . $key . "'") === false) { | |
| 372 | + $html = str_replace('</head>', $style_tag . "\n" . '</head>', $html); | |
| 373 | + } | |
| 374 | + } | |
| 375 | + } | |
| 376 | + | |
| 377 | + return $html; | |
| 378 | + } | |
| 379 | + | |
| 380 | + /** | |
| 381 | + * Build the new inner HTML for a heading whose text OTTO is replacing, | |
| 382 | + * preserving the author's inline formatting. | |
| 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 | + * | |
| 441 | + * Lazy-loaded YouTube/video facades embed a full HTML document inside an | |
| 442 | + * iframe's `srcdoc` attribute, entity-encoded: | |
| 443 | + * <iframe srcdoc="<style>*{overflow:hidden}...</style><a>..."></iframe> | |
| 444 | + * SimpleHtmlDom decodes that value and re-emits the inner <style>/<a>/<img> | |
| 445 | + * as REAL nodes hoisted into the document <head>. The facade's global CSS | |
| 446 | + * (`*{overflow:hidden}`, `img,span{position:absolute;width:100%;...}`) then | |
| 447 | + * nukes the entire page layout — the page renders blank below the header even | |
| 448 | + * though all body content is still present (so the element-loss guard can't | |
| 449 | + * see it). We swap each srcdoc value for an opaque token before parsing and | |
| 450 | + * restore it verbatim afterward, so SimpleHtmlDom never touches it. | |
| 451 | + * | |
| 452 | + * @param string $html Raw HTML before DOM parsing. | |
| 453 | + * @param array $store (out) token => original encoded value. | |
| 454 | + * @return string HTML with srcdoc values tokenized. | |
| 455 | + */ | |
| 456 | + private function protect_srcdoc_attributes($html, &$store) { | |
| 457 | + $store = array(); | |
| 458 | + $index = 0; | |
| 459 | + return preg_replace_callback( | |
| 460 | + '/\bsrcdoc\s*=\s*(["\'])(.*?)\1/is', | |
| 461 | + function ($m) use (&$store, &$index) { | |
| 462 | + $token = '__METASYNC_SRCDOC_' . ($index++) . '__'; | |
| 463 | + $store[$token] = $m[2]; | |
| 464 | + return 'srcdoc="' . $token . '"'; | |
| 465 | + }, | |
| 466 | + $html | |
| 467 | + ); | |
| 468 | + } | |
| 469 | + | |
| 470 | + /** | |
| 471 | + * Restore srcdoc values tokenized by protect_srcdoc_attributes(). | |
| 472 | + * Tokens are unique and never altered by SimpleHtmlDom, so a plain | |
| 473 | + * str_replace on the final output is safe. | |
| 474 | + * | |
| 475 | + * @param string $html Processed HTML. | |
| 476 | + * @param array $store Token map from protect_srcdoc_attributes(). | |
| 477 | + * @return string | |
| 478 | + */ | |
| 479 | + private function restore_srcdoc_attributes($html, $store) { | |
| 480 | + if (empty($store)) { | |
| 481 | + return $html; | |
| 482 | + } | |
| 483 | + return str_replace(array_keys($store), array_values($store), $html); | |
| 484 | + } | |
| 485 | + | |
| 486 | + /** | |
| 487 | + * Safety net — strip any lazy-video FACADE <style> that leaked into the | |
| 488 | + * document as a real stylesheet. | |
| 489 | + * | |
| 490 | + * Lazy YouTube/video facades carry this exact CSS reset inside their iframe | |
| 491 | + * srcdoc: *{...overflow:hidden} html,body{height:100%} img,span{position:absolute;width:100%;...} | |
| 492 | + * If anything (SimpleHtmlDom attribute-decode, capture/restore_lost_style_blocks) | |
| 493 | + * hoists it out of the srcdoc into a real <style>, it absolutely-positions every | |
| 494 | + * image and span on the page and clips all overflow — rendering the page blank | |
| 495 | + * below the header. No legitimate global stylesheet ever does this to img,span, | |
| 496 | + * so removing such a block is safe. | |
| 497 | + * | |
| 498 | + * Must run AFTER srcdoc values are tokenized (protect_srcdoc_attributes) so the | |
| 499 | + * encoded copy still living inside the iframe attribute is never matched. | |
| 500 | + * | |
| 501 | + * @param string $html | |
| 502 | + * @return string | |
| 503 | + */ | |
| 504 | + /** | |
| 505 | + * Crash-free guard for full-document string transforms. | |
| 506 | + * | |
| 507 | + * PCRE operations (preg_replace / preg_replace_callback) return NULL when they | |
| 508 | + * hit a limit — most notably "JIT stack limit exhausted" on Divi/page-builder | |
| 509 | + * pages whose large inline <style> blocks defeat tempered-greedy patterns. | |
| 510 | + * Historically that NULL propagated through the rest of the pipeline, so | |
| 511 | + * process_html_directly() returned empty HTML, the render strategy's 50%-size | |
| 512 | + * sanity check rejected it, and the ORIGINAL un-optimised page was served — | |
| 513 | + * OTTO silently did nothing. | |
| 514 | + * | |
| 515 | + * This guard keeps the last-good HTML whenever a transform yields NULL (or an | |
| 516 | + * unexpectedly empty string), so a single failing regex can no longer discard | |
| 517 | + * every OTTO optimisation. It is defence-in-depth: the specific pattern that | |
| 518 | + * triggered (strip_hoisted_facade_styles) has been rewritten to be | |
| 519 | + * non-backtracking, but this guard still protects the rest of the pipeline. | |
| 520 | + * | |
| 521 | + * @param string|null $new Result of the transform. | |
| 522 | + * @param string $prev HTML before the transform (fallback value). | |
| 523 | + * @param string $where Transform name, for the log line. | |
| 524 | + * @return string | |
| 525 | + */ | |
| 526 | + private function otto_guard_html($new, $prev, $where = '') { | |
| 527 | + if ($new === null || (is_string($new) && $new === '' && $prev !== '')) { | |
| 528 | + # A full-document transform ($where) returned NULL (e.g. a PCRE | |
| 529 | + # limit such as "JIT stack limit exhausted"). Keep the last-good | |
| 530 | + # HTML so one failed step can't discard every OTTO optimisation. | |
| 531 | + # Intentionally silent — not logged, to keep customer logs clean. | |
| 532 | + return $prev; | |
| 533 | + } | |
| 534 | + return $new; | |
| 535 | + } | |
| 536 | + | |
| 537 | + private function strip_hoisted_facade_styles($html) { | |
| 538 | + # Non-backtracking implementation. | |
| 539 | + # | |
| 540 | + # The previous single-regex approach used two tempered-greedy segments | |
| 541 | + # ((?:(?!</style>).)*?) to keep the match inside one <style> block. That | |
| 542 | + # runs a negative lookahead for every character, and on Divi/page-builder | |
| 543 | + # pages whose inline <style> blocks are tens of KB it overflows the PCRE | |
| 544 | + # JIT stack ("JIT stack limit exhausted"). preg_replace() then returns | |
| 545 | + # NULL, which discarded the entire OTTO-modified document. | |
| 546 | + # | |
| 547 | + # Instead, isolate each <style>…</style> block with a single lazy .*? | |
| 548 | + # (cheap, no per-char lookahead) and test only that bounded block for the | |
| 549 | + # facade signature. The signature check runs on one small block at a time, | |
| 550 | + # so neither pattern can strain the JIT stack. | |
| 551 | + if (!is_string($html) || stripos($html, '<style') === false) { | |
| 552 | + return $html; | |
| 553 | + } | |
| 554 | + | |
| 555 | + $out = preg_replace_callback( | |
| 556 | + '#<style\b[^>]*>.*?</style>#is', | |
| 557 | + static function ($m) { | |
| 558 | + # Facade reset: "img,span{ … position:absolute … }" inside this block. | |
| 559 | + if (preg_match('#\bimg\s*,\s*span\s*\{[^}]*position\s*:\s*absolute[^}]*\}#is', $m[0])) { | |
| 560 | + return ''; | |
| 561 | + } | |
| 562 | + return $m[0]; | |
| 563 | + }, | |
| 564 | + $html | |
| 565 | + ); | |
| 566 | + | |
| 567 | + # Belt-and-suspenders: if PCRE still fails for any reason, keep the input. | |
| 568 | + return $out === null ? $html : $out; | |
| 569 | + } | |
| 570 | + | |
| 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 | + /** | |
| 616 | + * Apply OTTO's canonical via string replacement (reliable fallback). | |
| 617 | + * | |
| 618 | + * Why the DOM path fails: do_header_replacements() sets the canonical with a | |
| 619 | + * DOM attribute edit ($link->href = …). That edit works in isolation, but | |
| 620 | + * insert_header_html() runs *earlier* in the same pipeline and reassigns | |
| 621 | + * $head->outertext to a raw string (to inject OTTO's schema). In SimpleHtmlDom, | |
| 622 | + * assigning ->outertext freezes that node — <head> now serializes as that | |
| 623 | + * literal string and no longer re-renders from its child node tree. The | |
| 624 | + * canonical <link> is captured inside that frozen string with its OLD href, so | |
| 625 | + * the later $link->href edit is never reflected in the output and the SEO | |
| 626 | + * plugin's (Yoast/Rank Math/AIOSEO) canonical always wins. This is the same | |
| 627 | + * head-freeze that forced title/meta onto string-replacement fallbacks; | |
| 628 | + * canonical was the one case that never got one. | |
| 629 | + * | |
| 630 | + * This pass runs on the final serialized HTML (after the freeze), so it is | |
| 631 | + * immune to the ordering problem. It guarantees exactly one canonical — | |
| 632 | + * OTTO's: it removes every existing <link rel="canonical"> and inserts OTTO's | |
| 633 | + * recommended value (marked data-otto="true") right after <head>. Manual | |
| 634 | + * canonicals (_metasync_canonical_url / meta_canonical) still take priority, | |
| 635 | + * matching the DOM path's protection. | |
| 636 | + * | |
| 637 | + * @param string $html Serialized HTML. | |
| 638 | + * @param array $replacement_data OTTO suggestions. | |
| 639 | + * @return string | |
| 640 | + */ | |
| 641 | + private function apply_canonical_via_string($html, $replacement_data) { | |
| 642 | + if (!is_string($html) || empty($replacement_data['header_replacements']) || !is_array($replacement_data['header_replacements'])) { | |
| 643 | + return $html; | |
| 644 | + } | |
| 645 | + | |
| 646 | + # Find OTTO's canonical recommendation. | |
| 647 | + $canonical = ''; | |
| 648 | + foreach ($replacement_data['header_replacements'] as $item) { | |
| 649 | + if (($item['type'] ?? '') === 'link' && ($item['rel'] ?? '') === 'canonical') { | |
| 650 | + $canonical = $item['recommended_value'] ?? $item['value'] ?? ''; | |
| 651 | + break; | |
| 652 | + } | |
| 653 | + } | |
| 654 | + if (empty($canonical) || !is_string($canonical)) { | |
| 655 | + return $html; | |
| 656 | + } | |
| 657 | + | |
| 658 | + # Validate OTTO's own suggestion too: if the platform payload | |
| 659 | + # carries a corrupted/non-URL canonical, leave the document untouched. | |
| 660 | + $canonical = Metasync_Canonical_Sanitizer::sanitize($canonical); | |
| 661 | + if ($canonical === '') { | |
| 662 | + return $html; | |
| 663 | + } | |
| 664 | + | |
| 665 | + # Respect a manually-set canonical (same protection as the DOM path). | |
| 666 | + # Validated: a legacy row corrupted to "Array" must not count | |
| 667 | + # as a manual canonical — that would suppress OTTO's correct value. | |
| 668 | + if (function_exists('is_singular') && is_singular()) { | |
| 669 | + $post_id = function_exists('get_the_ID') ? get_the_ID() : 0; | |
| 670 | + if ($post_id) { | |
| 671 | + $custom = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, '_metasync_canonical_url', true)); | |
| 672 | + if ($custom === '') { | |
| 673 | + $custom = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, 'meta_canonical', true)); | |
| 674 | + } | |
| 675 | + if ($custom !== '') { | |
| 676 | + return $html; # manual canonical wins — leave the document untouched | |
| 677 | + } | |
| 678 | + } | |
| 679 | + } | |
| 680 | + | |
| 681 | + # Only proceed if there is a <head> to place the tag in (never end up with zero canonical). | |
| 682 | + if (!preg_match('#<head\b[^>]*>#i', $html)) { | |
| 683 | + return $html; | |
| 684 | + } | |
| 685 | + | |
| 686 | + $tag = '<link rel="canonical" href="' . htmlspecialchars($canonical, ENT_QUOTES, 'UTF-8') . '" data-otto="true" />'; | |
| 687 | + | |
| 688 | + # Remove every existing canonical link (bounded per-tag pattern; null-safe). | |
| 689 | + $stripped = preg_replace('#<link\b[^>]*\brel=(["\'])canonical\1[^>]*>\s*#i', '', $html); | |
| 690 | + if (is_string($stripped)) { | |
| 691 | + $html = $stripped; | |
| 692 | + } | |
| 693 | + | |
| 694 | + # Insert OTTO's canonical right after <head> (callback avoids $/\ interpolation from the URL). | |
| 695 | + $inserted = preg_replace_callback('#(<head\b[^>]*>)#i', function ($m) use ($tag) { | |
| 696 | + return $m[1] . "\n" . $tag; | |
| 697 | + }, $html, 1); | |
| 698 | + if (is_string($inserted)) { | |
| 699 | + $html = $inserted; | |
| 700 | + } | |
| 701 | + | |
| 702 | + return $html; | |
| 703 | + } | |
| 704 | + | |
| 705 | + /** | |
| 706 | + * Keep the charset declaration within the first 1024 bytes of <head>. | |
| 707 | + * | |
| 708 | + * OTTO injects meta tags + JSON-LD schema at the top of <head>, which can push | |
| 709 | + * the theme's <meta charset="utf-8"> past the 1024-byte limit that browsers | |
| 710 | + * enforce for in-document charset detection (HTML spec). When that happens — | |
| 711 | + * and a cached/CDN response is served without an HTTP charset header — the | |
| 712 | + * document falls back to the locale encoding (Windows-1252). External | |
| 713 | + * stylesheets that declare no @charset of their own (e.g. a theme rule like | |
| 714 | + * `content:"\2713"` written as a raw UTF-8 ✓) then inherit that wrong encoding | |
| 715 | + * and render as mojibake ("âœ"" instead of "✓"). | |
| 716 | + * | |
| 717 | + * We guarantee a <meta charset="UTF-8"> as the first child of <head> whenever | |
| 718 | + * one isn't already present within the first 1024 bytes. Harmless when a valid | |
| 719 | + * early charset already exists (we skip), and a duplicate later declaration is | |
| 720 | + * ignored by the browser (first one wins). | |
| 721 | + * | |
| 722 | + * @param string $html | |
| 723 | + * @return string | |
| 724 | + */ | |
| 725 | + private function ensure_early_charset_meta($html) { | |
| 726 | + if (!preg_match('/<head\b[^>]*>/i', $html, $m, PREG_OFFSET_CAPTURE)) { | |
| 727 | + return $html; | |
| 728 | + } | |
| 729 | + $inner_start = $m[0][1] + strlen($m[0][0]); | |
| 730 | + | |
| 731 | + # Already declared early enough? Leave it alone (also covers AMP, which | |
| 732 | + # requires charset as the first child). | |
| 733 | + $window = substr($html, $inner_start, 1024); | |
| 734 | + if (preg_match('/<meta[^>]*charset/i', $window)) { | |
| 735 | + return $html; | |
| 736 | + } | |
| 737 | + | |
| 738 | + return substr($html, 0, $inner_start) | |
| 739 | + . '<meta charset="UTF-8">' | |
| 740 | + . substr($html, $inner_start); | |
| 741 | + } | |
| 742 | + | |
| 80 | 743 | # |
| 81 | 744 | function __construct($otto_uuid){ |
| 82 | 745 | |
| 83 | 746 | # set the site uuid using the provided string |
| @@ -122,9 +785,9 @@ | ||
| 122 | 785 | $response = wp_remote_get($url_with_params, $args); |
| 123 | 786 | |
| 124 | 787 | # Check for errors |
| 125 | 788 | if (is_wp_error($response)) { |
| 126 | - 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 | |
| 127 | 790 | return false; |
| 128 | 791 | } |
| 129 | 792 | |
| 130 | 793 | # get the response body |
| @@ -134,9 +797,9 @@ | ||
| 134 | 797 | $response_code = wp_remote_retrieve_response_code($response); |
| 135 | 798 | |
| 136 | 799 | # if no change data skip |
| 137 | 800 | if (empty($body) || $response_code !== 200){ |
| 138 | - 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 | |
| 139 | 802 | return false; |
| 140 | 803 | } |
| 141 | 804 | |
| 142 | 805 | # set the html file path |
| @@ -329,9 +992,11 @@ | ||
| 329 | 992 | |
| 330 | 993 | # Check for timeout or connection errors |
| 331 | 994 | if (is_wp_error($route_html)) { |
| 332 | 995 | $error_msg = $route_html->get_error_message(); |
| 333 | - 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 | + } | |
| 334 | 999 | return false; |
| 335 | 1000 | } |
| 336 | 1001 | |
| 337 | 1002 | # get body |
| @@ -339,21 +1004,87 @@ | ||
| 339 | 1004 | |
| 340 | 1005 | # Get the response code |
| 341 | 1006 | $response_code = wp_remote_retrieve_response_code($route_html); |
| 342 | 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 | + } | |
| 343 | 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 | + | |
| 344 | 1046 | # check not empty |
| 345 | 1047 | if(empty($html_body) || $response_code !== 200){ |
| 346 | - 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 | + } | |
| 347 | 1051 | return false; |
| 348 | 1052 | } |
| 349 | 1053 | |
| 1054 | + # Skip DOM processing on oversized documents to avoid fatal OOM. | |
| 1055 | + if (class_exists('Metasync_Otto_Render_Strategy') | |
| 1056 | + && !Metasync_Otto_Render_Strategy::is_document_processable(strlen($html_body)) | |
| 1057 | + ) { | |
| 1058 | + Metasync_Otto_Render_Strategy::log_oversized_skip('handle_route_html', strlen($html_body)); | |
| 1059 | + return false; | |
| 1060 | + } | |
| 1061 | + | |
| 350 | 1062 | # Remove XML declaration |
| 351 | 1063 | $html_body = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html_body); |
| 352 | 1064 | |
| 1065 | + # / Save ALL original <style> blocks before DOM processing. | |
| 1066 | + # SimpleHtmlDom can lose or corrupt style blocks during parse/serialize — | |
| 1067 | + # not only Divi's id-tagged ones but also anonymous <style> blocks used by | |
| 1068 | + # custom themes for nav collapse CSS, responsive breakpoints, etc. | |
| 1069 | + $original_style_blocks = $this->capture_style_blocks($html_body); | |
| 1070 | + | |
| 1071 | + # Shield entity-encoded srcdoc values (lazy YouTube/video facades) | |
| 1072 | + # so SimpleHtmlDom can't decode them and hoist their global <style> into <head>. | |
| 1073 | + $srcdoc_store = array(); | |
| 1074 | + $html_body = $this->protect_srcdoc_attributes($html_body, $srcdoc_store); | |
| 1075 | + | |
| 353 | 1076 | # Escape bare < in text content (e.g. "<4 microns") before DOM parsing |
| 354 | 1077 | $html_body = $this->sanitize_text_less_than($html_body); |
| 355 | 1078 | |
| 1079 | + # Fix malformed self-closing non-void tags (e.g. <ul/ class="...">) | |
| 1080 | + # before DOM parsing — SimpleHtmlDom strips attributes from these. | |
| 1081 | + $html_body = $this->fix_malformed_self_closing_tags($html_body); | |
| 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 | + | |
| 356 | 1087 | # now that the html is not empty |
| 357 | 1088 | # load it into the simple html dom |
| 358 | 1089 | $this->dom->load($html_body, true, false); |
| 359 | 1090 | |
| @@ -481,13 +1212,19 @@ | ||
| 481 | 1212 | |
| 482 | 1213 | $result_html = preg_replace_callback( |
| 483 | 1214 | '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is', |
| 484 | 1215 | function ($m) use ($current_value, $recommended_value) { |
| 485 | - $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8'))); | |
| 486 | - if ($inner_text === $current_value) { | |
| 487 | - 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]; | |
| 488 | 1219 | } |
| 489 | - 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]; | |
| 490 | 1227 | }, |
| 491 | 1228 | $result_html, |
| 492 | 1229 | -1 |
| 493 | 1230 | ); |
| @@ -495,19 +1232,20 @@ | ||
| 495 | 1232 | } |
| 496 | 1233 | |
| 497 | 1234 | # CRITICAL FIX: Apply image alt text manually via string replacement |
| 498 | 1235 | # DOM changes via SimpleHtmlDom don't persist on Oxygen/page-builder sites using HTTP render path |
| 499 | - $result_html = $this->apply_image_alt_text_via_string($result_html, $replacement_data); | |
| 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'); | |
| 500 | 1237 | |
| 501 | 1238 | # Apply insertions — only if DOM insertion didn't already apply it |
| 502 | 1239 | if (!empty($replacement_data['header_html_insertion'])) { |
| 503 | - $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); | |
| 504 | 1247 | if (strpos($result_html, $header_html_check) === false) { |
| 505 | - $header_html_insertion = preg_replace( | |
| 506 | - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i', | |
| 507 | - '<script$1type=$2application/ld+json$2 data-otto="true"', | |
| 508 | - $replacement_data['header_html_insertion'] | |
| 509 | - ); | |
| 510 | 1248 | $safe_header = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion); |
| 511 | 1249 | $result_html = preg_replace('/(<\/head>)/i', $safe_header . "\n" . '$1', $result_html, 1); |
| 512 | 1250 | } |
| 513 | 1251 | } |
| @@ -529,24 +1267,314 @@ | ||
| 529 | 1267 | $safe_footer = str_replace(array('\\', '$'), array('\\\\', '\\$'), $replacement_data['footer_html_insertion']); |
| 530 | 1268 | $result_html = preg_replace('/(<\/html>)/i', $safe_footer . "\n" . '$1', $result_html, 1); |
| 531 | 1269 | } |
| 532 | 1270 | |
| 533 | - # DEDUPLICATION: Remove duplicate <title>, OG, Twitter tags, canonical, and JSON-LD schema | |
| 534 | - $result_html = $this->deduplicate_title_tags($result_html); | |
| 535 | - $result_html = $this->deduplicate_og_twitter_tags($result_html); | |
| 536 | - $result_html = $this->deduplicate_schema_tags($result_html); | |
| 537 | - $result_html = $this->deduplicate_canonical_tags($result_html); | |
| 1271 | + # Apply OTTO's canonical via string replacement (DOM edit is clobbered by the insert_header_html head-freeze; see apply_canonical_via_string). | |
| 1272 | + $result_html = $this->apply_canonical_via_string($result_html, $replacement_data); | |
| 538 | 1273 | |
| 539 | - # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it) | |
| 540 | - if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) { | |
| 541 | - $result_html = preg_replace('/<head(\s|>)/i', '<head metasync_optimized$1', $result_html, 1); | |
| 1274 | + # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema | |
| 1275 | + $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags'); | |
| 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'); | |
| 1278 | + $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags'); | |
| 1279 | + $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence'); | |
| 1280 | + $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence'); | |
| 1281 | + $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags'); | |
| 1282 | + $result_html = $this->otto_guard_html($this->deduplicate_canonical_tags($result_html), $result_html, 'deduplicate_canonical_tags'); | |
| 1283 | + | |
| 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); | |
| 542 | 1287 | } |
| 543 | 1288 | |
| 544 | - $result_html = $this->restore_case_sensitive_attributes($result_html); | |
| 1289 | + $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes'); | |
| 545 | 1290 | |
| 1291 | + # / Re-inject any <style> blocks lost during processing. | |
| 1292 | + $result_html = $this->otto_guard_html($this->restore_lost_style_blocks($result_html, $original_style_blocks), $result_html, 'restore_lost_style_blocks'); | |
| 1293 | + | |
| 1294 | + # Fix Divi 5 shortcode framework class renumbering (HTTP path) | |
| 1295 | + $result_html = $this->otto_guard_html($this->fix_divi_class_renumbering($result_html), $result_html, 'fix_divi_class_renumbering'); | |
| 1296 | + | |
| 1297 | + # Clean OTTO internal fetch params from HTML output. | |
| 1298 | + # The HTTP render uses wp_remote_get with ?is_otto_page_fetch=1&otto_block_title=1&otto_block_desc=1 | |
| 1299 | + # These leak into form action URLs, canonical links, etc. in the rendered HTML. | |
| 1300 | + $result_html = $this->otto_guard_html($this->clean_otto_fetch_params($result_html), $result_html, 'clean_otto_fetch_params'); | |
| 1301 | + | |
| 1302 | + # Remove any lazy-video facade <style> hoisted into the page | |
| 1303 | + # (runs while srcdoc is still tokenized, so the iframe's own copy is safe). | |
| 1304 | + $result_html = $this->otto_guard_html($this->strip_hoisted_facade_styles($result_html), $result_html, 'strip_hoisted_facade_styles'); | |
| 1305 | + | |
| 1306 | + # Restore the original encoded srcdoc values (undo tokenization). | |
| 1307 | + $result_html = $this->otto_guard_html($this->restore_srcdoc_attributes($result_html, $srcdoc_store), $result_html, 'restore_srcdoc_attributes'); | |
| 1308 | + | |
| 1309 | + # Keep <meta charset> within the first 1024 bytes so external CSS | |
| 1310 | + # (e.g. checkmark content:"✓") doesn't mojibake on cached responses. | |
| 1311 | + $result_html = $this->otto_guard_html($this->ensure_early_charset_meta($result_html), $result_html, 'ensure_early_charset_meta'); | |
| 1312 | + | |
| 1313 | + # undo double-encoded numeric/hex character references produced by the | |
| 1314 | + # bundled simplehtmldom serializer from attributes like data-x-icon-s="". | |
| 1315 | + $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities'); | |
| 1316 | + | |
| 1317 | + # restore literal '&' query-string separators the serializer escaped to | |
| 1318 | + # '&', 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 | + | |
| 1321 | + # Remove any lazy-video facade <style> hoisted into the page | |
| 1322 | + # (runs while srcdoc is still tokenized, so the iframe's own copy is safe). | |
| 1323 | + $result_html = $this->strip_hoisted_facade_styles($result_html); | |
| 1324 | + | |
| 1325 | + # Restore the original encoded srcdoc values (undo tokenization). | |
| 1326 | + $result_html = $this->restore_srcdoc_attributes($result_html, $srcdoc_store); | |
| 1327 | + | |
| 1328 | + # Keep <meta charset> within the first 1024 bytes so external CSS | |
| 1329 | + # (e.g. checkmark content:"✓") doesn't mojibake on cached responses. | |
| 1330 | + $result_html = $this->ensure_early_charset_meta($result_html); | |
| 1331 | + | |
| 1332 | + # undo double-encoded numeric/hex character references produced by the | |
| 1333 | + # bundled simplehtmldom serializer from attributes like data-x-icon-s="". | |
| 1334 | + $result_html = $this->repair_double_encoded_entities($result_html); | |
| 1335 | + | |
| 546 | 1336 | return $result_html; |
| 547 | 1337 | } |
| 548 | 1338 | |
| 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 | + /** | |
| 1443 | + * Repair double-encoded character references in serialized HTML. | |
| 1444 | + * | |
| 1445 | + * The bundled simplehtmldom serializer (HtmlNode::makeup(), reached via | |
| 1446 | + * $this->dom->save() and $root->outertext) runs every attribute value through | |
| 1447 | + * htmlentities(), which escapes the leading '&' of numeric/hex character | |
| 1448 | + * references. Theme icon attributes such as data-x-icon-s="" | |
| 1449 | + * (Themeco X/Cornerstone) therefore become "&#xf3c5", so the browser | |
| 1450 | + * prints the literal text instead of drawing the icon glyph. | |
| 1451 | + * | |
| 1452 | + * Restores only NUMERIC and HEX character references (&#NNN; / &#xHHHH;). | |
| 1453 | + * Bare ampersands in query strings (?a=1&b=2 -> ?a=1&b=2) and named | |
| 1454 | + * entities are intentionally left escaped. | |
| 1455 | + * | |
| 1456 | + * @param string $html Serialized HTML from save()/outertext. | |
| 1457 | + * @return string | |
| 1458 | + */ | |
| 1459 | + private function repair_double_encoded_entities($html) { | |
| 1460 | + # strpos guard: skip the regex entirely when there is nothing to repair | |
| 1461 | + if (!is_string($html) || strpos($html, '&#') === false) { | |
| 1462 | + return $html; | |
| 1463 | + } | |
| 1464 | + return preg_replace('/&(#x[0-9a-fA-F]+;?|#[0-9]+;?)/', '&$1', $html); | |
| 1465 | + } | |
| 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 '&', which HtmlDocument | |
| 1472 | + * decodes to '&' when it parses the attribute - is written back as '&'. | |
| 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&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 '&' that is immediately followed by a parameter name | |
| 1483 | + * and '=', and only inside the listed URL attributes. A '&' 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 & 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 "&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, '&') === 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, '&') === false) { | |
| 1556 | + return $m[0]; | |
| 1557 | + } | |
| 1558 | + | |
| 1559 | + $fixed = preg_replace('/&(?=[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 | + | |
| 549 | 1577 | # do the footer html insertion |
| 550 | 1578 | function do_footer_html_insertion($replacement_data){ |
| 551 | 1579 | |
| 552 | 1580 | # check that we have footer html |
| @@ -647,8 +1675,12 @@ | ||
| 647 | 1675 | if (empty($images)) { |
| 648 | 1676 | return; |
| 649 | 1677 | } |
| 650 | 1678 | |
| 1679 | + # index suggestions by URL path as well, so images authored with | |
| 1680 | + # relative srcs still match OTTO's absolute-URL keys (and vice versa). | |
| 1681 | + $alt_lookup = $this->build_image_alt_lookup($image_data); | |
| 1682 | + | |
| 651 | 1683 | # PERFORMANCE OPTIMIZATION: O(n²) reduced to O(n) |
| 652 | 1684 | # Single pass with hash map lookup instead of nested loop |
| 653 | 1685 | foreach($images AS $key => $image){ |
| 654 | 1686 | # Get image src |
| @@ -658,12 +1690,13 @@ | ||
| 658 | 1690 | continue; |
| 659 | 1691 | } |
| 660 | 1692 | |
| 661 | 1693 | # Hash map lookup O(1) instead of loop O(n) |
| 662 | - if (isset($image_data[$image_src])) { | |
| 1694 | + $alt_text = $this->lookup_image_alt($alt_lookup, $image_src); | |
| 1695 | + if ($alt_text !== null) { | |
| 663 | 1696 | # Set alt text - Note: This may not persist in all cases |
| 664 | 1697 | # Manual string replacement in process_html_directly() ensures it's applied |
| 665 | - $new_alt = htmlspecialchars($image_data[$image_src], ENT_QUOTES, 'UTF-8'); | |
| 1698 | + $new_alt = htmlspecialchars($alt_text, ENT_QUOTES, 'UTF-8'); | |
| 666 | 1699 | |
| 667 | 1700 | # Get current img tag HTML and update alt attribute |
| 668 | 1701 | $current_html = $image->outertext; |
| 669 | 1702 | |
| @@ -677,15 +1710,104 @@ | ||
| 677 | 1710 | $image->outertext = $updated_html; |
| 678 | 1711 | |
| 679 | 1712 | $multi_view_attr = $image->getAttribute('data-et-multi-view'); |
| 680 | 1713 | if (!empty($multi_view_attr)) { |
| 681 | - $this->update_divi_multi_view_alt($image, $image_data[$image_src]); | |
| 1714 | + $this->update_divi_multi_view_alt($image, $alt_text); | |
| 682 | 1715 | } |
| 683 | 1716 | } |
| 684 | 1717 | } |
| 685 | 1718 | } |
| 686 | 1719 | |
| 1720 | + # memoized site host — home_url() runs its filter chain on every | |
| 1721 | + # call and these helpers run per suggestion and per img. | |
| 1722 | + private ?string $site_host_cache = null; | |
| 1723 | + | |
| 687 | 1724 | /** |
| 1725 | + * Get this site's host (lowercase) for same-host URL comparisons. | |
| 1726 | + * | |
| 1727 | + * @return string | |
| 1728 | + */ | |
| 1729 | + private function get_site_host() { | |
| 1730 | + if ($this->site_host_cache === null) { | |
| 1731 | + $host = wp_parse_url(home_url(), PHP_URL_HOST); | |
| 1732 | + $this->site_host_cache = is_string($host) ? strtolower($host) : ''; | |
| 1733 | + } | |
| 1734 | + return $this->site_host_cache; | |
| 1735 | + } | |
| 1736 | + | |
| 1737 | + /** | |
| 1738 | + * Candidate lookup keys for an image URL — the URL itself plus, | |
| 1739 | + * for relative URLs or absolute/protocol-relative URLs on this site's | |
| 1740 | + * host, the bare URL path. URLs on a different host only ever match | |
| 1741 | + * exactly, so a same path on a foreign host cannot produce a false match. | |
| 1742 | + * | |
| 1743 | + * @param string $url Image URL (absolute, protocol-relative, or relative) | |
| 1744 | + * @return array Candidate keys, original URL first | |
| 1745 | + */ | |
| 1746 | + private function get_image_url_variants($url) { | |
| 1747 | + $variants = [$url]; | |
| 1748 | + | |
| 1749 | + if (!is_string($url) || $url === '') { | |
| 1750 | + return $variants; | |
| 1751 | + } | |
| 1752 | + | |
| 1753 | + $host = wp_parse_url($url, PHP_URL_HOST); | |
| 1754 | + if (!empty($host) && strtolower($host) !== $this->get_site_host()) { | |
| 1755 | + # Foreign host: exact match only | |
| 1756 | + return $variants; | |
| 1757 | + } | |
| 1758 | + | |
| 1759 | + $path = wp_parse_url($url, PHP_URL_PATH); | |
| 1760 | + if (is_string($path) && $path !== '' && $path !== $url) { | |
| 1761 | + $variants[] = $path; | |
| 1762 | + } | |
| 1763 | + | |
| 1764 | + return $variants; | |
| 1765 | + } | |
| 1766 | + | |
| 1767 | + /** | |
| 1768 | + * Build the image alt lookup keyed by every URL variant of each | |
| 1769 | + * OTTO suggestion, so relative img srcs match absolute suggestion URLs. | |
| 1770 | + * | |
| 1771 | + * @param array $image_data OTTO body_substitutions.images map (url => alt) | |
| 1772 | + * @return array Expanded lookup (url-or-path => alt) | |
| 1773 | + */ | |
| 1774 | + private function build_image_alt_lookup($image_data) { | |
| 1775 | + $lookup = []; | |
| 1776 | + | |
| 1777 | + foreach ($image_data as $image_url => $alt_text) { | |
| 1778 | + foreach ($this->get_image_url_variants($image_url) as $variant) { | |
| 1779 | + # First suggestion wins when two keys collapse to the same | |
| 1780 | + # path (e.g. a relative and an absolute key for one image); | |
| 1781 | + # exact-src matches still take priority in lookup_image_alt(). | |
| 1782 | + if (!isset($lookup[$variant])) { | |
| 1783 | + $lookup[$variant] = $alt_text; | |
| 1784 | + } | |
| 1785 | + } | |
| 1786 | + } | |
| 1787 | + | |
| 1788 | + return $lookup; | |
| 1789 | + } | |
| 1790 | + | |
| 1791 | + /** | |
| 1792 | + * Resolve the OTTO alt text for an img src, trying the exact src | |
| 1793 | + * first and then its same-host path variant. | |
| 1794 | + * | |
| 1795 | + * @param array $lookup Expanded lookup from build_image_alt_lookup() | |
| 1796 | + * @param string $image_src The img element's src attribute | |
| 1797 | + * @return string|null Alt text, or null when no suggestion matches | |
| 1798 | + */ | |
| 1799 | + private function lookup_image_alt($lookup, $image_src) { | |
| 1800 | + foreach ($this->get_image_url_variants($image_src) as $variant) { | |
| 1801 | + if (isset($lookup[$variant])) { | |
| 1802 | + return $lookup[$variant]; | |
| 1803 | + } | |
| 1804 | + } | |
| 1805 | + | |
| 1806 | + return null; | |
| 1807 | + } | |
| 1808 | + | |
| 1809 | + /** | |
| 688 | 1810 | * Update Divi's multi-view data attribute with alt text |
| 689 | 1811 | * Divi stores image attributes in a JSON structure within data-et-multi-view |
| 690 | 1812 | * |
| 691 | 1813 | * @param object $image The image DOM element |
| @@ -728,8 +1850,13 @@ | ||
| 728 | 1850 | /** |
| 729 | 1851 | * Apply image alt text via string replacement on raw HTML. |
| 730 | 1852 | * Used as a fallback when DOM changes don't persist (Oxygen, Divi, page-builder sites). |
| 731 | 1853 | * |
| 1854 | + * Known limitation (pre-existing): the `<img[^>]*src=` pattern can match a | |
| 1855 | + * `data-src`/`data-lazy-src` attribute that ends with the target URL before | |
| 1856 | + * the real `src`, mis-attributing the alt on lazy-loaded markup. Tracked as | |
| 1857 | + * a follow-up to. | |
| 1858 | + * | |
| 732 | 1859 | * @param string $html The HTML to process |
| 733 | 1860 | * @param array $replacement_data The replacement data containing body_substitutions.images |
| 734 | 1861 | * @return string Modified HTML with alt text applied |
| 735 | 1862 | */ |
| @@ -738,15 +1865,18 @@ | ||
| 738 | 1865 | return $html; |
| 739 | 1866 | } |
| 740 | 1867 | |
| 741 | 1868 | foreach ($replacement_data['body_substitutions']['images'] as $image_url => $alt_text) { |
| 742 | - if (empty($alt_text) || strpos($html, $image_url) === false) { | |
| 1869 | + # prefilter and match on the same-host path variant too, so | |
| 1870 | + # relative img srcs match OTTO's absolute suggestion URLs. | |
| 1871 | + $src_pattern = $this->get_image_src_pattern($image_url); | |
| 1872 | + | |
| 1873 | + if (empty($alt_text) || $src_pattern === null || strpos($html, $src_pattern['needle']) === false) { | |
| 743 | 1874 | continue; |
| 744 | 1875 | } |
| 745 | 1876 | |
| 746 | 1877 | $escaped_alt = htmlspecialchars($alt_text, ENT_QUOTES, 'UTF-8'); |
| 747 | - $escaped_url = preg_quote($image_url, '/'); | |
| 748 | - $img_pattern = '/<img[^>]*src=["\']' . $escaped_url . '["\'][^>]*>/i'; | |
| 1878 | + $img_pattern = '/<img[^>]*src=["\']' . $src_pattern['pattern'] . '["\'][^>]*>/i'; | |
| 749 | 1879 | |
| 750 | 1880 | if (preg_match_all($img_pattern, $html, $img_matches)) { |
| 751 | 1881 | foreach ($img_matches[0] as $original_img) { |
| 752 | 1882 | if (strpos($original_img, $escaped_alt) !== false) { |
| @@ -791,8 +1921,47 @@ | ||
| 791 | 1921 | |
| 792 | 1922 | return $html; |
| 793 | 1923 | } |
| 794 | 1924 | |
| 1925 | + /** | |
| 1926 | + * Build the src match for an OTTO image suggestion URL. | |
| 1927 | + * | |
| 1928 | + * For relative URLs or absolute/protocol-relative URLs on this site's | |
| 1929 | + * host, the regex matches the URL path with an optional scheme+host | |
| 1930 | + * prefix — so a relative img src matches an absolute suggestion URL and | |
| 1931 | + * vice versa. URLs on a foreign host keep the original exact match, so a | |
| 1932 | + * same path on a different host cannot produce a false positive. | |
| 1933 | + * | |
| 1934 | + * @param string|int $image_url OTTO suggestion URL (may arrive as an int | |
| 1935 | + * when it originates from an array key that PHP coerced) | |
| 1936 | + * @return array|null ['needle' => strpos prefilter string, | |
| 1937 | + * 'pattern' => regex fragment ('/' delimiter)] or null | |
| 1938 | + */ | |
| 1939 | + private function get_image_src_pattern($image_url) { | |
| 1940 | + if (!is_string($image_url) || $image_url === '') { | |
| 1941 | + return null; | |
| 1942 | + } | |
| 1943 | + | |
| 1944 | + $host = wp_parse_url($image_url, PHP_URL_HOST); | |
| 1945 | + $site_host = $this->get_site_host(); | |
| 1946 | + | |
| 1947 | + # Foreign host: exact match only | |
| 1948 | + if (!empty($host) && strtolower($host) !== $site_host) { | |
| 1949 | + return ['needle' => $image_url, 'pattern' => preg_quote($image_url, '/')]; | |
| 1950 | + } | |
| 1951 | + | |
| 1952 | + $path = wp_parse_url($image_url, PHP_URL_PATH); | |
| 1953 | + if (!is_string($path) || $path === '') { | |
| 1954 | + return ['needle' => $image_url, 'pattern' => preg_quote($image_url, '/')]; | |
| 1955 | + } | |
| 1956 | + | |
| 1957 | + $prefix = $site_host !== '' ? '(?:(?:https?:)?\/\/' . preg_quote($site_host, '/') . ')?' : ''; | |
| 1958 | + | |
| 1959 | + # Allow an optional query/fragment after the path (e.g. WordPress | |
| 1960 | + # `?ver=` cache-busters) so src="/up/a.jpg?ver=2" still matches. | |
| 1961 | + return ['needle' => $path, 'pattern' => $prefix . preg_quote($path, '/') . '(?:[?#][^"\']*)?']; | |
| 1962 | + } | |
| 1963 | + | |
| 795 | 1964 | # heading substitutions |
| 796 | 1965 | function do_heading_body_substitutions($heading_data){ |
| 797 | 1966 | |
| 798 | 1967 | # loop all data |
| @@ -809,11 +1978,30 @@ | ||
| 809 | 1978 | |
| 810 | 1979 | # check matching text |
| 811 | 1980 | if(trim($heading['current_value'] ?? '') == trim($text ?? '')){ |
| 812 | 1981 | |
| 813 | - # set the text | |
| 814 | - $heading_old->innertext = $heading['recommended_value']; | |
| 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 | + ); | |
| 815 | 1990 | |
| 1991 | + if ($new_inner === null) { | |
| 1992 | + continue; | |
| 1993 | + } | |
| 1994 | + | |
| 1995 | + # replace the tag output, preserving the opening tag's attributes | |
| 1996 | + $outer = $heading_old->outertext; | |
| 1997 | + $open_end = strpos($outer, '>'); | |
| 1998 | + if ($open_end !== false) { | |
| 1999 | + $open_tag = substr($outer, 0, $open_end + 1); | |
| 2000 | + $close_tag = '</' . $heading['type'] . '>'; | |
| 2001 | + $heading_old->outertext = $open_tag . $new_inner . $close_tag; | |
| 2002 | + } | |
| 2003 | + | |
| 816 | 2004 | } |
| 817 | 2005 | } |
| 818 | 2006 | |
| 819 | 2007 | } |
| @@ -913,10 +2101,10 @@ | ||
| 913 | 2101 | } |
| 914 | 2102 | |
| 915 | 2103 | # If it's a protocol-relative URL (//example.com), check if it matches our domain |
| 916 | 2104 | if (strpos($url, '//') === 0) { |
| 917 | - $parsed_home = parse_url($home_url); | |
| 918 | - $parsed_link = parse_url($url); | |
| 2105 | + $parsed_home = wp_parse_url($home_url); | |
| 2106 | + $parsed_link = wp_parse_url($url); | |
| 919 | 2107 | |
| 920 | 2108 | if (isset($parsed_home['host']) && isset($parsed_link['host'])) { |
| 921 | 2109 | if (strtolower($parsed_home['host']) === strtolower($parsed_link['host'])) { |
| 922 | 2110 | return false; |
| @@ -1016,8 +2204,194 @@ | ||
| 1016 | 2204 | # now do the actual html replacements |
| 1017 | 2205 | $body->outertext = '<body' . $attributes_string . '>'.$insert_data['body_top_html_insertion'].$body->innertext . '</body>'; |
| 1018 | 2206 | } |
| 1019 | 2207 | |
| 2208 | + /** | |
| 2209 | + * Resolve the current singular post id reliably inside the OTTO output | |
| 2210 | + * buffer / direct-render context. | |
| 2211 | + * | |
| 2212 | + * get_the_ID() depends on the global $post/loop state, which is not reliable | |
| 2213 | + * once WordPress has finished rendering and the buffer/shutdown pass runs. | |
| 2214 | + * get_queried_object_id() reads the parsed main query and stays correct there | |
| 2215 | + * (same approach as apply_metabox_og_precedence()). Restricted to | |
| 2216 | + * singular views: on archives/home the queried object id can be a term or | |
| 2217 | + * user id, which must not be read as post meta. | |
| 2218 | + * | |
| 2219 | + * @return int Post id, or 0 when not applicable. | |
| 2220 | + */ | |
| 2221 | + private function get_buffer_post_id() { | |
| 2222 | + if (!function_exists('is_singular') || !is_singular()) { | |
| 2223 | + return 0; | |
| 2224 | + } | |
| 2225 | + $post_id = function_exists('get_queried_object_id') ? get_queried_object_id() : 0; | |
| 2226 | + return $post_id ? (int) $post_id : 0; | |
| 2227 | + } | |
| 2228 | + | |
| 2229 | + /** | |
| 2230 | + * Replace the first <meta $attr="$val"> content with $content_escaped. | |
| 2231 | + * | |
| 2232 | + * @param string $html | |
| 2233 | + * @param string $attr "name" or "property" | |
| 2234 | + * @param string $val e.g. "description", "og:description" | |
| 2235 | + * @param string $content_escaped Already HTML-escaped content value | |
| 2236 | + * @param string $marker Provenance marker for the winning tier | |
| 2237 | + * @return string | |
| 2238 | + */ | |
| 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'; | |
| 2254 | + $count = 0; | |
| 2255 | + $new = preg_replace_callback($pattern, function ($m) use ($tag) { | |
| 2256 | + return $tag; | |
| 2257 | + }, $html, 1, $count); | |
| 2258 | + if ($count > 0) { | |
| 2259 | + return $new; | |
| 2260 | + } | |
| 2261 | + # None present — insert after <head>. | |
| 2262 | + return preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($tag) { | |
| 2263 | + return $m[1] . "\n" . $tag; | |
| 2264 | + }, $html, 1); | |
| 2265 | + } | |
| 2266 | + | |
| 2267 | + /** | |
| 2268 | + * Make sure the precedence resolver is loaded before calling it. | |
| 2269 | + * | |
| 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. | |
| 2288 | + * | |
| 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. | |
| 2293 | + * | |
| 2294 | + * OG and Twitter descriptions remain outside this method deliberately; | |
| 2295 | + * social precedence is a separate follow-up scope. | |
| 2296 | + * | |
| 2297 | + * @param string $html Full HTML document (post-dedup). | |
| 2298 | + * @return string | |
| 2299 | + */ | |
| 2300 | + private function apply_custom_seo_precedence($html) { | |
| 2301 | + if ($html === '') { | |
| 2302 | + return $html; | |
| 2303 | + } | |
| 2304 | + $post_id = $this->get_buffer_post_id(); | |
| 2305 | + if (!$post_id) { | |
| 2306 | + return $html; | |
| 2307 | + } | |
| 2308 | + | |
| 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>'; | |
| 2327 | + $count = 0; | |
| 2328 | + $replaced = preg_replace_callback('/<title[^>]*>.*?<\/title>/is', function ($m) use ($title_tag) { | |
| 2329 | + return $title_tag; | |
| 2330 | + }, $html, 1, $count); | |
| 2331 | + if ($count > 0) { | |
| 2332 | + $html = $replaced; | |
| 2333 | + } elseif (stripos($html, '<title') === false) { | |
| 2334 | + $html = preg_replace_callback('/(<head[^>]*>)/i', function ($m) use ($title_tag) { | |
| 2335 | + return $m[1] . "\n" . $title_tag; | |
| 2336 | + }, $html, 1); | |
| 2337 | + } | |
| 2338 | + } | |
| 2339 | + | |
| 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); | |
| 2357 | + } | |
| 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 | + | |
| 2391 | + return $html; | |
| 2392 | + } | |
| 2393 | + | |
| 1020 | 2394 | # this function does the header replacements |
| 1021 | 2395 | function do_header_replacements($replacement_data){ |
| 1022 | 2396 | |
| 1023 | 2397 | # check that we have header replacements |
| @@ -1067,21 +2441,19 @@ | ||
| 1067 | 2441 | |
| 1068 | 2442 | # handle canonical links |
| 1069 | 2443 | if($data['type'] == 'link' && $data['rel'] === 'canonical'){ |
| 1070 | 2444 | |
| 1071 | - # Protect manually-set canonical from OTTO override | |
| 2445 | + # Protect manually-set canonical from OTTO override. | |
| 2446 | + # Validated: a legacy row corrupted to "Array" must not | |
| 2447 | + # count as a manual canonical and block OTTO's correct value. | |
| 1072 | 2448 | if (function_exists('is_singular') && is_singular()) { |
| 1073 | 2449 | $post_id = get_the_ID(); |
| 1074 | 2450 | if ($post_id) { |
| 1075 | - $custom_canonical = get_post_meta($post_id, '_metasync_canonical_url', true); | |
| 1076 | - if (empty($custom_canonical)) { | |
| 1077 | - $custom_canonical = get_post_meta($post_id, 'meta_canonical', true); | |
| 1078 | - // Handle legacy array values | |
| 1079 | - if (is_array($custom_canonical)) { | |
| 1080 | - $custom_canonical = reset($custom_canonical) ?: ''; | |
| 1081 | - } | |
| 2451 | + $custom_canonical = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, '_metasync_canonical_url', true)); | |
| 2452 | + if ($custom_canonical === '') { | |
| 2453 | + $custom_canonical = Metasync_Canonical_Sanitizer::sanitize(get_post_meta($post_id, 'meta_canonical', true)); | |
| 1082 | 2454 | } |
| 1083 | - if (!empty($custom_canonical)) { | |
| 2455 | + if ($custom_canonical !== '') { | |
| 1084 | 2456 | # Manual canonical takes priority — skip OTTO override |
| 1085 | 2457 | continue; |
| 1086 | 2458 | } |
| 1087 | 2459 | } |
| @@ -1089,11 +2461,15 @@ | ||
| 1089 | 2461 | |
| 1090 | 2462 | # find the cannonical dom element |
| 1091 | 2463 | $link = $this->dom->find('link[rel="canonical"]', 0); |
| 1092 | 2464 | |
| 1093 | - # set the link property if not empty | |
| 2465 | + # set the link property if not empty — only to a validated URL | |
| 2466 | + # never inject a corrupted/non-URL suggestion. | |
| 1094 | 2467 | if(!empty($link->href)){ |
| 1095 | - $link->href = $data['recommended_value'] ?? $link->href; | |
| 2468 | + $otto_canonical = Metasync_Canonical_Sanitizer::sanitize($data['recommended_value'] ?? ''); | |
| 2469 | + if ($otto_canonical !== '') { | |
| 2470 | + $link->href = $otto_canonical; | |
| 2471 | + } | |
| 1096 | 2472 | } |
| 1097 | 2473 | |
| 1098 | 2474 | # |
| 1099 | 2475 | continue; |
| @@ -1109,8 +2485,14 @@ | ||
| 1109 | 2485 | |
| 1110 | 2486 | # Skip if OTTO has no value to set - prevents overwriting existing tags (e.g. Yoast) |
| 1111 | 2487 | # with empty content when OTTO has no recommendation for this meta field |
| 1112 | 2488 | $recommended_value = $data['recommended_value'] ?? $data['value'] ?? ''; |
| 2489 | + if (is_object($recommended_value) || is_array($recommended_value)) { | |
| 2490 | + return; | |
| 2491 | + } | |
| 2492 | + if (!is_string($recommended_value)) { | |
| 2493 | + $recommended_value = (string) $recommended_value; | |
| 2494 | + } | |
| 1113 | 2495 | if (empty(trim($recommended_value))) { |
| 1114 | 2496 | return; |
| 1115 | 2497 | } |
| 1116 | 2498 | |
| @@ -1205,9 +2587,9 @@ | ||
| 1205 | 2587 | $meta_tag_fresh = $this->dom->find($meta_selector, 0); |
| 1206 | 2588 | |
| 1207 | 2589 | if ($meta_tag_fresh) { |
| 1208 | 2590 | # Use outertext for replacement |
| 1209 | - $new_value = htmlspecialchars($data['recommended_value'] ?? '', ENT_QUOTES, 'UTF-8'); | |
| 2591 | + $new_value = htmlspecialchars($recommended_value, ENT_QUOTES, 'UTF-8'); | |
| 1210 | 2592 | |
| 1211 | 2593 | # Determine attribute name |
| 1212 | 2594 | $attr_name = !empty($data['name']) ? 'name' : 'property'; |
| 1213 | 2595 | $attr_value = !empty($data['name']) ? $data['name'] : ($data['property'] ?? ''); |
| @@ -1410,8 +2792,221 @@ | ||
| 1410 | 2792 | return $html; |
| 1411 | 2793 | } |
| 1412 | 2794 | |
| 1413 | 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 | + /** | |
| 2822 | + * Apply the per-post "Social Media & Open Graph" meta box precedence over OTTO. | |
| 2823 | + * | |
| 2824 | + * Precedence per tag: an explicit meta box value set by the user wins over OTTO; | |
| 2825 | + * a field left blank falls back to OTTO's value; if OTTO also has none, a sensible | |
| 2826 | + * default fills the gap. Mirrors how the custom SEO sidebar values take priority | |
| 2827 | + * over OTTO (see do_header_replacements()). | |
| 2828 | + * | |
| 2829 | + * Runs *after* deduplicate_og_twitter_tags(), and only within <head>. For a field the | |
| 2830 | + * user explicitly set it removes any existing tag (OTTO's) and re-adds the user's | |
| 2831 | + * value; for the rest it only adds a tag when absent — so OTTO stays authoritative | |
| 2832 | + * where the user left a field blank and no duplicate is produced. OTTO never emits an | |
| 2833 | + * og:image, so this also surfaces a user-set social image on an OTTO-managed page. | |
| 2834 | + * | |
| 2835 | + * Only applies to singular posts/pages: the meta box values are per-post, and on | |
| 2836 | + * archives/home get_queried_object_id() can return a term or user id. | |
| 2837 | + * | |
| 2838 | + * @param string $html Full HTML document (post-dedup). | |
| 2839 | + * @return string | |
| 2840 | + */ | |
| 2841 | + private function apply_metabox_og_precedence($html) { | |
| 2842 | + if (!function_exists('get_queried_object_id') || !function_exists('is_singular')) { | |
| 2843 | + return $html; | |
| 2844 | + } | |
| 2845 | + # Per-post OG only applies to singular posts/pages. On archives/home the queried | |
| 2846 | + # object id can be a term or user id, which must not be read as post meta. | |
| 2847 | + if (!is_singular()) { | |
| 2848 | + return $html; | |
| 2849 | + } | |
| 2850 | + $post_id = get_queried_object_id(); | |
| 2851 | + if (!$post_id) { | |
| 2852 | + return $html; | |
| 2853 | + } | |
| 2854 | + | |
| 2855 | + # Respect the meta box opt-out (only an explicit '0' disables it) | |
| 2856 | + if (get_post_meta($post_id, '_metasync_og_enabled', true) === '0') { | |
| 2857 | + return $html; | |
| 2858 | + } | |
| 2859 | + | |
| 2860 | + # Explicit (user-typed) meta box values. A non-empty value means the user set | |
| 2861 | + # this field themselves and it must take priority over OTTO. | |
| 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'); | |
| 2870 | + $og_image_set = get_post_meta($post_id, '_metasync_og_image', true); | |
| 2871 | + $og_type_set = get_post_meta($post_id, '_metasync_og_type', true); | |
| 2872 | + $tw_card_set = get_post_meta($post_id, '_metasync_twitter_card', true); | |
| 2873 | + $tw_site_set = get_post_meta($post_id, '_metasync_twitter_site', 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'); | |
| 2876 | + $tw_image_set = get_post_meta($post_id, '_metasync_twitter_image', true); | |
| 2877 | + $tw_image_alt_set = get_post_meta($post_id, '_metasync_twitter_image_alt', true); | |
| 2878 | + | |
| 2879 | + # Default OG values the meta box pre-fills (post title / generated excerpt / | |
| 2880 | + # featured image). The meta box PERSISTS these defaults on save, so a non-empty | |
| 2881 | + # _metasync_og_* value alone does not prove the user customized it — a field is a | |
| 2882 | + # genuine override only when its stored value differs from this default. Reuse the | |
| 2883 | + # emitter's own resolver so the defaults match exactly (falling back to empty | |
| 2884 | + # defaults, i.e. treat nothing as customized, if the instance is unavailable). | |
| 2885 | + $defaults = ['title' => '', 'description' => '', 'image' => '']; | |
| 2886 | + if (class_exists('Metasync_OpenGraph') && Metasync_OpenGraph::get_instance()) { | |
| 2887 | + $defaults = Metasync_OpenGraph::get_instance()->get_default_og_values($post_id); | |
| 2888 | + } | |
| 2889 | + | |
| 2890 | + $title_is_custom = ($og_title_set !== '' && $og_title_set !== $defaults['title']); | |
| 2891 | + $desc_is_custom = ($og_desc_set !== '' && $og_desc_set !== $defaults['description']); | |
| 2892 | + $image_is_custom = ($og_image_set !== '' && $og_image_set !== $defaults['image']); | |
| 2893 | + | |
| 2894 | + # Resolved values (explicit value first, then the meta box default). | |
| 2895 | + $og_title = $og_title_set !== '' ? $og_title_set : $defaults['title']; | |
| 2896 | + $og_desc = $og_desc_set !== '' ? $og_desc_set : $defaults['description']; | |
| 2897 | + $og_image = $og_image_set !== '' ? $og_image_set : $defaults['image']; | |
| 2898 | + $og_type = $og_type_set ?: 'article'; | |
| 2899 | + $site_name = get_bloginfo('name'); | |
| 2900 | + | |
| 2901 | + # Twitter inherits the OG value when its own field is blank; a user-customized | |
| 2902 | + # og:image therefore also overrides OTTO's twitter:image (the meta box treats them | |
| 2903 | + # as one). A twitter field is custom if its own value differs from the default OR | |
| 2904 | + # the OG field it inherits was customized. | |
| 2905 | + $tw_card = $tw_card_set ?: 'summary_large_image'; | |
| 2906 | + $tw_title = $tw_title_set !== '' ? $tw_title_set : $og_title; | |
| 2907 | + $tw_desc = $tw_desc_set !== '' ? $tw_desc_set : $og_desc; | |
| 2908 | + $tw_image = $tw_image_set !== '' ? $tw_image_set : $og_image; | |
| 2909 | + | |
| 2910 | + $tw_title_is_custom = ($tw_title_set !== '' && $tw_title_set !== $defaults['title']) || $title_is_custom; | |
| 2911 | + $tw_desc_is_custom = ($tw_desc_set !== '' && $tw_desc_set !== $defaults['description']) || $desc_is_custom; | |
| 2912 | + $tw_image_is_custom = ($tw_image_set !== '' && $tw_image_set !== $defaults['image']) || $image_is_custom; | |
| 2913 | + | |
| 2914 | + # twitter:site: per-post value, else the site-wide handle from Social Meta settings. | |
| 2915 | + # twitter:site / twitter:image:alt are not auto-defaulted by the meta box, so a | |
| 2916 | + # non-empty per-post value is itself the override signal. | |
| 2917 | + $tw_site = $tw_site_set; | |
| 2918 | + if ($tw_site === '' && class_exists('Metasync')) { | |
| 2919 | + $social = Metasync::get_option('social_meta'); | |
| 2920 | + if (is_array($social) && !empty($social['twitter_username'])) { | |
| 2921 | + $tw_site = '@' . ltrim($social['twitter_username'], '@'); | |
| 2922 | + } | |
| 2923 | + } | |
| 2924 | + | |
| 2925 | + # og:type / twitter:card are dropdowns that always carry a value, so they override | |
| 2926 | + # OTTO only when the user picked a non-default option. og:url is auto-populated with | |
| 2927 | + # the permalink on save, so it is fill-only (never an override) to avoid clobbering | |
| 2928 | + # OTTO's canonical URL with a stale/auto value. og:site_name is a site default. | |
| 2929 | + $og_type_override = ($og_type_set !== '' && $og_type_set !== 'article'); | |
| 2930 | + $tw_card_override = ($tw_card_set !== '' && $tw_card_set !== 'summary_large_image'); | |
| 2931 | + | |
| 2932 | + # [attr, property/name, value, is_url, is_override] | |
| 2933 | + $tags = [ | |
| 2934 | + ['property', 'og:title', $og_title, false, $title_is_custom], | |
| 2935 | + ['property', 'og:description', $og_desc, false, $desc_is_custom], | |
| 2936 | + ['property', 'og:image', $og_image, true, $image_is_custom], | |
| 2937 | + ['property', 'og:url', get_permalink($post_id), true, false], | |
| 2938 | + ['property', 'og:type', $og_type, false, $og_type_override], | |
| 2939 | + ['property', 'og:site_name', $site_name, false, false], | |
| 2940 | + ['name', 'twitter:card', $tw_card, false, $tw_card_override], | |
| 2941 | + ['name', 'twitter:site', $tw_site, false, $tw_site_set !== ''], | |
| 2942 | + ['name', 'twitter:title', $tw_title, false, $tw_title_is_custom], | |
| 2943 | + ['name', 'twitter:description', $tw_desc, false, $tw_desc_is_custom], | |
| 2944 | + ['name', 'twitter:image', $tw_image, true, $tw_image_is_custom], | |
| 2945 | + ['name', 'twitter:image:alt', $tw_image_alt_set, false, $tw_image_alt_set !== ''], | |
| 2946 | + ]; | |
| 2947 | + | |
| 2948 | + # Operate only within <head> so meta tags in the body are never touched. | |
| 2949 | + if (!preg_match('/(<head\b[^>]*>)(.*?)(<\/head>)/is', $html, $hm)) { | |
| 2950 | + return $html; | |
| 2951 | + } | |
| 2952 | + $head = $hm[2]; | |
| 2953 | + | |
| 2954 | + # 1) Remove existing head tags for user-overridden properties so the user's value wins. | |
| 2955 | + foreach ($tags as $t) { | |
| 2956 | + list($attr, $key, $val, $is_url, $is_override) = $t; | |
| 2957 | + if ($is_override && $val !== '' && $val !== null) { | |
| 2958 | + $stripped = preg_replace( | |
| 2959 | + '/<meta\s[^>]*' . $attr . '\s*=\s*(["\'])' . preg_quote($key, '/') . '\1[^>]*>\s*/i', | |
| 2960 | + '', | |
| 2961 | + $head | |
| 2962 | + ); | |
| 2963 | + if ($stripped !== null) { | |
| 2964 | + $head = $stripped; | |
| 2965 | + } | |
| 2966 | + } | |
| 2967 | + } | |
| 2968 | + | |
| 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 | + | |
| 2980 | + $additions = ''; | |
| 2981 | + foreach ($tags as $t) { | |
| 2982 | + list($attr, $key, $val, $is_url, $is_override) = $t; | |
| 2983 | + if ($val === '' || $val === null) { | |
| 2984 | + continue; | |
| 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 | + } | |
| 2989 | + if (!$is_override && preg_match('/<meta\s[^>]*' . $attr . '\s*=\s*(["\'])' . preg_quote($key, '/') . '\1/i', $head)) { | |
| 2990 | + continue; # OTTO (or another source) already provides it and the user did not override | |
| 2991 | + } | |
| 2992 | + $content = $is_url ? esc_url($val) : esc_attr($val); | |
| 2993 | + if ($content === '') { | |
| 2994 | + continue; | |
| 2995 | + } | |
| 2996 | + $marker = $is_override ? 'override' : 'fill'; | |
| 2997 | + $additions .= '<meta ' . $attr . '="' . $key . '" content="' . $content . '" data-metasync-og="' . $marker . '">' . "\n"; | |
| 2998 | + } | |
| 2999 | + | |
| 3000 | + # Reassemble the head block (str_replace avoids backreference issues from $ in values). | |
| 3001 | + if ($head === $hm[2] && $additions === '') { | |
| 3002 | + return $html; # nothing removed, nothing added | |
| 3003 | + } | |
| 3004 | + $new_head_block = $hm[1] . $head . $additions . $hm[3]; | |
| 3005 | + return str_replace($hm[0], $new_head_block, $html); | |
| 3006 | + } | |
| 3007 | + | |
| 3008 | + /** | |
| 1414 | 3009 | * Deduplicate meta tags by a specific attribute (property= or name=). |
| 1415 | 3010 | * |
| 1416 | 3011 | * When duplicates exist and one carries a data-otto marker, keep only |
| 1417 | 3012 | * the OTTO version. Otherwise keep the first occurrence. |
| @@ -1431,21 +3026,176 @@ | ||
| 1431 | 3026 | } |
| 1432 | 3027 | |
| 1433 | 3028 | $all_tags = $matches[0]; |
| 1434 | 3029 | |
| 1435 | - # Find the OTTO tag (has data-otto-pixel or data-otto attribute) | |
| 3030 | + # Choose the keeper by precedence: custom sidebar → OTTO → first. | |
| 3031 | + # A user-set SEO sidebar value (data-metasync-seo="custom") must win over | |
| 3032 | + # OTTO, matching deduplicate_description_tags() and the sidebar's documented | |
| 3033 | + # "custom always wins over OTTO" intent. Without this, the generic keeper | |
| 3034 | + # (OTTO-or-first) drops the sidebar's og:/twitter: description in favor of | |
| 3035 | + # OTTO's injected tag. | |
| 3036 | + $custom_tag = null; | |
| 1436 | 3037 | $otto_tag = null; |
| 1437 | 3038 | foreach ($all_tags as $tag) { |
| 1438 | - if (stripos($tag, 'data-otto') !== false) { | |
| 3039 | + if ($custom_tag === null && stripos($tag, 'data-metasync-seo') !== false) { | |
| 3040 | + $custom_tag = $tag; | |
| 3041 | + } | |
| 3042 | + if ($otto_tag === null && stripos($tag, 'data-otto') !== false) { | |
| 1439 | 3043 | $otto_tag = $tag; |
| 3044 | + } | |
| 3045 | + } | |
| 3046 | + | |
| 3047 | + # Determine the keeper: custom sidebar tag, else OTTO tag, else the first tag | |
| 3048 | + $keeper = $custom_tag ?: ($otto_tag ?: $all_tags[0]); | |
| 3049 | + | |
| 3050 | + # Remove all occurrences, then re-insert the keeper at the first position | |
| 3051 | + $first_replaced = false; | |
| 3052 | + $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) { | |
| 3053 | + if (!$first_replaced) { | |
| 3054 | + $first_replaced = true; | |
| 3055 | + return $keeper; | |
| 3056 | + } | |
| 3057 | + return ''; # Remove subsequent duplicates | |
| 3058 | + }, $html); | |
| 3059 | + | |
| 3060 | + return $html; | |
| 3061 | + } | |
| 3062 | + | |
| 3063 | + /** | |
| 3064 | + * Deduplicate <meta name="description"> tags after OTTO processing. | |
| 3065 | + * | |
| 3066 | + * Unlike title, og, twitter, and canonical tags, the plain name="description" | |
| 3067 | + * tag had NO dedup pass, so a page could end up with 2-3 copies when several | |
| 3068 | + * subsystems each emit one: | |
| 3069 | + * - OTTO backend payload (header_html_insertion) → data-otto-pixel="dynamic-seo" | |
| 3070 | + * (spliced in additively before </head>, never replacing an existing tag) | |
| 3071 | + * - MetaSync's persisted-meta wp_head hook → data-metasync-otto="true" | |
| 3072 | + * - MetaSync SEO sidebar custom value → data-metasync-seo="custom" | |
| 3073 | + * | |
| 3074 | + * This runs at the buffer level — after every source has written its tag — | |
| 3075 | + * and keeps exactly ONE, by precedence (matching the SEO sidebar's documented | |
| 3076 | + * "custom always wins over OTTO" intent): | |
| 3077 | + * 1. custom sidebar (data-metasync-seo) | |
| 3078 | + * 2. OTTO (data-otto-pixel OR data-metasync-otto OR data-otto) | |
| 3079 | + * 3. first occurrence | |
| 3080 | + * | |
| 3081 | + * Note: the generic deduplicate_meta_by_attr() keeper-detection only matches | |
| 3082 | + * substring "data-otto", which misses "data-metasync-otto"; this method checks | |
| 3083 | + * both OTTO markers explicitly, so either OTTO source is recognized. | |
| 3084 | + * | |
| 3085 | + * @param string $html Full HTML document. | |
| 3086 | + * @return string HTML with at most one <meta name="description">. | |
| 3087 | + */ | |
| 3088 | + private function deduplicate_description_tags($html) { | |
| 3089 | + if (!is_string($html) || $html === '') { | |
| 3090 | + return $html; | |
| 3091 | + } | |
| 3092 | + | |
| 3093 | + # Match <meta ... name="description" ...> in either attribute order. | |
| 3094 | + # [^>]* is bounded to a single tag; name="twitter:description" is NOT matched | |
| 3095 | + # because the opening quote must be immediately followed by "description". | |
| 3096 | + $pattern = '/<meta\s[^>]*name\s*=\s*["\']description["\'][^>]*\/?>/i'; | |
| 3097 | + | |
| 3098 | + if (preg_match_all($pattern, $html, $matches) <= 1) { | |
| 3099 | + return $html; # 0 or 1 — nothing to deduplicate | |
| 3100 | + } | |
| 3101 | + | |
| 3102 | + $all_tags = $matches[0]; | |
| 3103 | + | |
| 3104 | + # Choose the keeper by precedence: custom sidebar → OTTO → first. | |
| 3105 | + $custom_tag = null; | |
| 3106 | + $otto_tag = null; | |
| 3107 | + foreach ($all_tags as $tag) { | |
| 3108 | + if ($custom_tag === null && stripos($tag, 'data-metasync-seo') !== false) { | |
| 3109 | + $custom_tag = $tag; | |
| 3110 | + } | |
| 3111 | + if ($otto_tag === null && ( | |
| 3112 | + stripos($tag, 'data-otto-pixel') !== false || | |
| 3113 | + stripos($tag, 'data-metasync-otto') !== false || | |
| 3114 | + stripos($tag, 'data-otto') !== false | |
| 3115 | + )) { | |
| 3116 | + $otto_tag = $tag; | |
| 3117 | + } | |
| 3118 | + } | |
| 3119 | + $keeper = $custom_tag ?: ($otto_tag ?: $all_tags[0]); | |
| 3120 | + | |
| 3121 | + # Remove all occurrences, re-inserting the keeper at the first position. | |
| 3122 | + # Callback form avoids backreference injection when the description content | |
| 3123 | + # contains $ followed by digits (e.g. "$50 off"). | |
| 3124 | + $first_replaced = false; | |
| 3125 | + $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) { | |
| 3126 | + if (!$first_replaced) { | |
| 3127 | + $first_replaced = true; | |
| 3128 | + return $keeper; | |
| 3129 | + } | |
| 3130 | + return ''; # Remove subsequent duplicates | |
| 3131 | + }, $html); | |
| 3132 | + | |
| 3133 | + return $html; | |
| 3134 | + } | |
| 3135 | + | |
| 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; | |
| 1440 | 3191 | break; |
| 1441 | 3192 | } |
| 1442 | 3193 | } |
| 1443 | - | |
| 1444 | - # Determine the keeper: OTTO tag if present, otherwise the first tag | |
| 1445 | 3194 | $keeper = $otto_tag ?: $all_tags[0]; |
| 1446 | 3195 | |
| 1447 | - # Remove all occurrences, then re-insert the keeper at the first position | |
| 3196 | + # Callback form avoids backreference injection when the keyword list | |
| 3197 | + # contains $ followed by digits. | |
| 1448 | 3198 | $first_replaced = false; |
| 1449 | 3199 | $html = preg_replace_callback($pattern, function ($m) use ($keeper, &$first_replaced) { |
| 1450 | 3200 | if (!$first_replaced) { |
| 1451 | 3201 | $first_replaced = true; |
| @@ -1499,96 +3249,301 @@ | ||
| 1499 | 3249 | return $html; |
| 1500 | 3250 | } |
| 1501 | 3251 | |
| 1502 | 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 | + /** | |
| 1503 | 3281 | * Deduplicate JSON-LD schema blocks. |
| 1504 | 3282 | * |
| 1505 | - * When OTTO and a third-party SEO plugin both inject <script type="application/ld+json"> | |
| 1506 | - * blocks, keep OTTO's version for any @type that appears in both. | |
| 1507 | - * 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: | |
| 1508 | 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 | + * | |
| 1509 | 3306 | * @param string $html Full HTML. |
| 1510 | 3307 | * @return string |
| 1511 | 3308 | */ |
| 1512 | 3309 | private function deduplicate_schema_tags($html) { |
| 1513 | - // Find all JSON-LD script blocks | |
| 1514 | - $pattern = '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2[^>]*>\s*([\s\S]*?)<\/script>/i'; | |
| 1515 | - 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)) { | |
| 1516 | 3317 | return $html; |
| 1517 | 3318 | } |
| 1518 | 3319 | |
| 1519 | - $otto_by_type = []; // @type => decoded JSON object | |
| 1520 | - $third_by_type = []; // @type => decoded JSON object | |
| 1521 | - $otto_graph = []; // entries from OTTO @graph blocks | |
| 1522 | - $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 | + } | |
| 1523 | 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 | + | |
| 1524 | 3340 | foreach ($matches as $m) { |
| 1525 | - $attrs = $m[1]; | |
| 1526 | - $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]; | |
| 1527 | 3350 | $decoded = json_decode($json_str, true); |
| 1528 | 3351 | if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) { |
| 1529 | - continue; // skip unparseable blocks — leave them in place | |
| 3352 | + continue; // unparseable — leave the block in place | |
| 1530 | 3353 | } |
| 1531 | - $is_otto = stripos($attrs, 'data-otto') !== false; | |
| 1532 | 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 | + | |
| 1533 | 3381 | if (isset($decoded['@graph']) && is_array($decoded['@graph'])) { |
| 1534 | - foreach ($decoded['@graph'] as $entry) { | |
| 1535 | - if (!isset($entry['@type'])) continue; | |
| 1536 | - // JSON-LD allows @type to be a string OR an array of strings. | |
| 1537 | - // Rank Math routinely emits multi-typed entries (e.g. ["Person", "Organization"]). | |
| 1538 | - // Using an array as an offset throws a fatal on PHP 8+, so normalize to scalar. | |
| 1539 | - $type = is_array($entry['@type']) | |
| 1540 | - ? (string) reset($entry['@type']) | |
| 1541 | - : (string) $entry['@type']; | |
| 1542 | - if ($type === '') continue; | |
| 1543 | - if ($is_otto) { | |
| 1544 | - $otto_graph[$type] = $entry; | |
| 1545 | - } else { | |
| 1546 | - $third_graph[$type] = $entry; | |
| 1547 | - } | |
| 3382 | + foreach ($decoded['@graph'] as $node) { | |
| 3383 | + $block['items'][] = ['node' => $node, 'e' => is_array($node) ? $register($node) : null]; | |
| 1548 | 3384 | } |
| 3385 | + $block['shape'] = 'graph'; | |
| 1549 | 3386 | } elseif (isset($decoded['@type'])) { |
| 1550 | - $type = is_array($decoded['@type']) | |
| 1551 | - ? (string) reset($decoded['@type']) | |
| 1552 | - : (string) $decoded['@type']; | |
| 1553 | - if ($type === '') continue; | |
| 1554 | - if ($is_otto) { | |
| 1555 | - $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]); | |
| 1556 | 3427 | } else { |
| 1557 | - $third_by_type[$type] = $decoded; | |
| 3428 | + $by_type[$token] = $holders; | |
| 1558 | 3429 | } |
| 1559 | 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 | + } | |
| 1560 | 3493 | } |
| 1561 | 3494 | |
| 1562 | - // If OTTO provided no schema at all, nothing to deduplicate | |
| 1563 | - if (empty($otto_by_type) && empty($otto_graph)) { | |
| 1564 | - return $html; | |
| 3495 | + $any_evicted = false; | |
| 3496 | + foreach ($entities as $entity) { | |
| 3497 | + if (!$entity['alive']) { | |
| 3498 | + $any_evicted = true; | |
| 3499 | + break; | |
| 3500 | + } | |
| 1565 | 3501 | } |
| 3502 | + if (!$any_evicted) { | |
| 3503 | + return $html; // no collision — every original byte stays | |
| 3504 | + } | |
| 1566 | 3505 | |
| 1567 | - // Remove all JSON-LD blocks from HTML | |
| 1568 | - $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 | + } | |
| 1569 | 3526 | |
| 1570 | - // Re-insert flat (non-@graph) blocks: OTTO wins for matching @type | |
| 1571 | - $kept = array_merge($otto_by_type, array_diff_key($third_by_type, $otto_by_type)); | |
| 1572 | - $rebuilt = ''; | |
| 1573 | - foreach ($kept as $decoded) { | |
| 1574 | - $rebuilt .= '<script type="application/ld+json" data-otto="true">' . | |
| 1575 | - wp_json_encode($decoded) . "</script>\n"; | |
| 1576 | - } | |
| 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 | + } | |
| 1577 | 3533 | |
| 1578 | - // Re-insert merged @graph block (if any entries exist) | |
| 1579 | - $merged_graph = array_merge($third_graph, $otto_graph); // OTTO wins on duplicate @type | |
| 1580 | - if (!empty($merged_graph)) { | |
| 1581 | - $graph_obj = ['@context' => 'https://schema.org', '@graph' => array_values($merged_graph)]; | |
| 1582 | - $rebuilt .= '<script type="application/ld+json" data-otto="true">' . | |
| 1583 | - 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>']; | |
| 1584 | 3542 | } |
| 1585 | 3543 | |
| 1586 | - // Re-inject before </head> | |
| 1587 | - if (!empty($rebuilt)) { | |
| 1588 | - $html = preg_replace_callback('/(<\/head>)/i', function ($m) use ($rebuilt) { | |
| 1589 | - return $rebuilt . $m[1]; | |
| 1590 | - }, $html, 1); | |
| 3544 | + foreach (array_reverse($edits) as $edit) { | |
| 3545 | + $html = substr($html, 0, $edit[0]) . $edit[2] . substr($html, $edit[1]); | |
| 1591 | 3546 | } |
| 1592 | 3547 | |
| 1593 | 3548 | return $html; |
| 1594 | 3549 | } |
| @@ -1607,14 +3562,14 @@ | ||
| 1607 | 3562 | return true; |
| 1608 | 3563 | } |
| 1609 | 3564 | |
| 1610 | 3565 | # Check if amp=1 query parameter is present |
| 1611 | - 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 | |
| 1612 | 3567 | return true; |
| 1613 | 3568 | } |
| 1614 | 3569 | |
| 1615 | 3570 | # Check for other common AMP query parameters |
| 1616 | - 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 | |
| 1617 | 3572 | return true; |
| 1618 | 3573 | } |
| 1619 | 3574 | |
| 1620 | 3575 | return false; |
| @@ -1619,8 +3574,32 @@ | ||
| 1619 | 3574 | |
| 1620 | 3575 | return false; |
| 1621 | 3576 | } |
| 1622 | 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 | + | |
| 1623 | 3602 | # this function insterts header html to the dom |
| 1624 | 3603 | function insert_header_html($data){ |
| 1625 | 3604 | |
| 1626 | 3605 | # check that we have the header html |
| @@ -1633,20 +3612,27 @@ | ||
| 1633 | 3612 | $head = $this->dom->find('head', 0); |
| 1634 | 3613 | |
| 1635 | 3614 | if ($head) { |
| 1636 | 3615 | |
| 1637 | - # 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 | |
| 1638 | 3617 | $is_amp_page = $this->is_amp_page(); |
| 1639 | 3618 | |
| 1640 | - # Append the new HTML at the start of the <head> tag | |
| 1641 | - # For AMP pages: use clean <head> tag without metasync_optimized attribute | |
| 1642 | - # For non-AMP pages: add metasync_optimized attribute to <head> tag | |
| 1643 | - if ($is_amp_page) { | |
| 1644 | - $head->outertext = '<head>' .$data['header_html_insertion']. $head->innertext . '</head>'; | |
| 1645 | - } else { | |
| 1646 | - $head->outertext = '<head metasync_optimized>' .$data['header_html_insertion']. $head->innertext . '</head>'; | |
| 1647 | - } | |
| 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; | |
| 1648 | 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 | + | |
| 1649 | 3635 | } |
| 1650 | 3636 | |
| 1651 | 3637 | # save and reload DOM |
| 1652 | 3638 | $this->save_reload(); |
| @@ -1763,8 +3749,35 @@ | ||
| 1763 | 3749 | |
| 1764 | 3750 | } |
| 1765 | 3751 | |
| 1766 | 3752 | /** |
| 3753 | + * Explicitly free the SimpleHtmlDom node tree and reclaim memory. | |
| 3754 | + * | |
| 3755 | + * SimpleHtmlDom nodes hold circular parent/child references, so simply | |
| 3756 | + * dropping the document does not free the tree until request shutdown. | |
| 3757 | + * HtmlNode::clear() breaks those cycles; we then drop the document, force a | |
| 3758 | + * GC pass, and re-instantiate a fresh empty parser so the instance stays | |
| 3759 | + * reusable for any subsequent route on the same request. | |
| 3760 | + */ | |
| 3761 | + function free_dom(){ | |
| 3762 | + if (isset($this->dom)) { | |
| 3763 | + # Break the node tree's parent/child cycles so GC can reclaim it now. | |
| 3764 | + if (isset($this->dom->root) && is_object($this->dom->root) | |
| 3765 | + && method_exists($this->dom->root, 'clear')) { | |
| 3766 | + $this->dom->root->clear(); | |
| 3767 | + } | |
| 3768 | + unset($this->dom); | |
| 3769 | + } | |
| 3770 | + | |
| 3771 | + # Fresh, empty parser — mirrors the constructor so the object is reusable. | |
| 3772 | + $this->dom = new HtmlDocument(null, true, true, 'UTF-8', false); | |
| 3773 | + | |
| 3774 | + if (function_exists('gc_collect_cycles')) { | |
| 3775 | + gc_collect_cycles(); | |
| 3776 | + } | |
| 3777 | + } | |
| 3778 | + | |
| 3779 | + /** | |
| 1767 | 3780 | * Process HTML directly without HTTP request (for buffer approach) |
| 1768 | 3781 | * This is the FAST path - eliminates the internal wp_remote_get call |
| 1769 | 3782 | * |
| 1770 | 3783 | * CRITICAL: This method is called from the output buffer callback. |
| @@ -1786,14 +3799,39 @@ | ||
| 1786 | 3799 | if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) { |
| 1787 | 3800 | return false; |
| 1788 | 3801 | } |
| 1789 | 3802 | |
| 3803 | + # Skip DOM processing on oversized documents to avoid fatal OOM. | |
| 3804 | + # Returning false makes the buffer callback serve the original HTML | |
| 3805 | + # unmodified — the page still renders, just without OTTO changes. | |
| 3806 | + if (class_exists('Metasync_Otto_Render_Strategy') | |
| 3807 | + && !Metasync_Otto_Render_Strategy::is_document_processable(strlen($html)) | |
| 3808 | + ) { | |
| 3809 | + Metasync_Otto_Render_Strategy::log_oversized_skip('process_html_directly', strlen($html)); | |
| 3810 | + return false; | |
| 3811 | + } | |
| 3812 | + | |
| 1790 | 3813 | # Remove XML declaration if present |
| 1791 | 3814 | $html = preg_replace('/<\?xml[^?]*\?>\s*/i', '', $html); |
| 1792 | 3815 | |
| 3816 | + # / Save ALL original <style> blocks before DOM processing. | |
| 3817 | + $original_style_blocks = $this->capture_style_blocks($html); | |
| 3818 | + | |
| 3819 | + # Shield entity-encoded srcdoc values (lazy YouTube/video facades) | |
| 3820 | + # so SimpleHtmlDom can't decode them and hoist their global <style> into <head>. | |
| 3821 | + $srcdoc_store = array(); | |
| 3822 | + $html = $this->protect_srcdoc_attributes($html, $srcdoc_store); | |
| 3823 | + | |
| 1793 | 3824 | # Escape bare < in text content before DOM parsing |
| 1794 | 3825 | $html = $this->sanitize_text_less_than($html); |
| 1795 | 3826 | |
| 3827 | + # Fix malformed self-closing non-void tags (e.g. <ul/ class="...">) | |
| 3828 | + $html = $this->fix_malformed_self_closing_tags($html); | |
| 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 | + | |
| 1796 | 3834 | # Load HTML into DOM |
| 1797 | 3835 | $this->dom->load($html, true, false); |
| 1798 | 3836 | |
| 1799 | 3837 | # Force UTF-8 charset to preserve emojis and special characters |
| @@ -1975,15 +4013,16 @@ | ||
| 1975 | 4013 | } |
| 1976 | 4014 | |
| 1977 | 4015 | # Apply header HTML insertion (for schema, etc.) — only if DOM insertion didn't already apply it |
| 1978 | 4016 | if (!empty($replacement_data['header_html_insertion'])) { |
| 1979 | - $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); | |
| 1980 | 4024 | if (strpos($result_html, $header_html_check) === false) { |
| 1981 | - $header_html_insertion = preg_replace( | |
| 1982 | - '/<script(\s[^>]*)type\s*=\s*(["\'])application\/ld\+json\2/i', | |
| 1983 | - '<script$1type=$2application/ld+json$2 data-otto="true"', | |
| 1984 | - $replacement_data['header_html_insertion'] | |
| 1985 | - ); | |
| 1986 | 4025 | $header_html = str_replace(array('\\', '$'), array('\\\\', '\\$'), $header_html_insertion); |
| 1987 | 4026 | # Insert before </head> |
| 1988 | 4027 | $result_html = preg_replace('/(<\/head>)/i', $header_html . "\n" . '$1', $result_html, 1); |
| 1989 | 4028 | } |
| @@ -2038,9 +4077,9 @@ | ||
| 2038 | 4077 | } |
| 2039 | 4078 | |
| 2040 | 4079 | # CRITICAL FIX: Apply image alt text manually via string replacement |
| 2041 | 4080 | # DOM changes don't persist, must use string replacement |
| 2042 | - $result_html = $this->apply_image_alt_text_via_string($result_html, $replacement_data); | |
| 4081 | + $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'); | |
| 2043 | 4082 | |
| 2044 | 4083 | # String-based heading fallback for body_substitutions |
| 2045 | 4084 | # DOM changes via SimpleHtmlDom don't persist on Divi/page-builder sites |
| 2046 | 4085 | if (!empty($replacement_data['body_substitutions']['headings']) && is_array($replacement_data['body_substitutions']['headings'])) { |
| @@ -2055,13 +4094,19 @@ | ||
| 2055 | 4094 | |
| 2056 | 4095 | $result_html = preg_replace_callback( |
| 2057 | 4096 | '/(<' . $heading_type . '(?:\s[^>]*)?>)(.*?)(<\/' . $heading_type . '>)/is', |
| 2058 | 4097 | function ($m) use ($current_value, $recommended_value) { |
| 2059 | - $inner_text = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($m[2]), ENT_QUOTES, 'UTF-8'))); | |
| 2060 | - if ($inner_text === $current_value) { | |
| 2061 | - 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]; | |
| 2062 | 4101 | } |
| 2063 | - 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]; | |
| 2064 | 4109 | }, |
| 2065 | 4110 | $result_html, |
| 2066 | 4111 | -1 |
| 2067 | 4112 | ); |
| @@ -2103,41 +4148,115 @@ | ||
| 2103 | 4148 | $result_html |
| 2104 | 4149 | ); |
| 2105 | 4150 | } |
| 2106 | 4151 | |
| 2107 | - # DEDUPLICATION: Remove duplicate <title>, OG, Twitter tags, canonical, and JSON-LD schema | |
| 2108 | - $result_html = $this->deduplicate_title_tags($result_html); | |
| 2109 | - $result_html = $this->deduplicate_og_twitter_tags($result_html); | |
| 2110 | - $result_html = $this->deduplicate_schema_tags($result_html); | |
| 2111 | - $result_html = $this->deduplicate_canonical_tags($result_html); | |
| 4152 | + # Apply OTTO's canonical via string replacement (DOM edit is clobbered by the insert_header_html head-freeze; see apply_canonical_via_string). | |
| 4153 | + $result_html = $this->apply_canonical_via_string($result_html, $replacement_data); | |
| 2112 | 4154 | |
| 4155 | + # DEDUPLICATION: Remove duplicate <title>, meta description, OG, Twitter tags, canonical, and JSON-LD schema | |
| 4156 | + $result_html = $this->otto_guard_html($this->deduplicate_title_tags($result_html), $result_html, 'deduplicate_title_tags'); | |
| 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'); | |
| 4159 | + $result_html = $this->otto_guard_html($this->deduplicate_og_twitter_tags($result_html), $result_html, 'deduplicate_og_twitter_tags'); | |
| 4160 | + $result_html = $this->otto_guard_html($this->apply_metabox_og_precedence($result_html), $result_html, 'apply_metabox_og_precedence'); | |
| 4161 | + $result_html = $this->otto_guard_html($this->apply_custom_seo_precedence($result_html), $result_html, 'apply_custom_seo_precedence'); | |
| 4162 | + $result_html = $this->otto_guard_html($this->deduplicate_schema_tags($result_html), $result_html, 'deduplicate_schema_tags'); | |
| 4163 | + $result_html = $this->otto_guard_html($this->deduplicate_canonical_tags($result_html), $result_html, 'deduplicate_canonical_tags'); | |
| 4164 | + | |
| 2113 | 4165 | # MEMORY OPTIMIZED: Free all large objects and arrays before returning |
| 2114 | 4166 | # This ensures memory is released immediately, especially important for high-traffic sites |
| 2115 | 4167 | unset($final_meta_matches, $matches); |
| 2116 | 4168 | |
| 2117 | - # Clear SimpleHtmlDom internal cache to free memory | |
| 2118 | - # Note: We don't unset $this->dom as the object may be reused | |
| 2119 | - if ($this->dom && method_exists($this->dom, 'clear')) { | |
| 2120 | - $this->dom->clear(); | |
| 2121 | - } | |
| 4169 | + # Explicitly free the SimpleHtmlDom node tree now that the | |
| 4170 | + # result has been serialized to a string. The bundled HtmlDocument has | |
| 4171 | + # no clear() method, so the previous method_exists() guard was a no-op | |
| 4172 | + # and the (circular-referenced) node tree lingered until object | |
| 4173 | + # destruction. free_dom() breaks those references and reclaims memory. | |
| 4174 | + $this->free_dom(); | |
| 2122 | 4175 | |
| 2123 | 4176 | # Clear element cache array |
| 2124 | 4177 | $this->cached_elements = []; |
| 2125 | 4178 | |
| 2126 | - # Ensure metasync_optimized attribute on <head> (post-serialization so dom->clear() can't wipe it) | |
| 2127 | - if (!$this->is_amp_page() && strpos($result_html, 'metasync_optimized') === false) { | |
| 2128 | - $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); | |
| 2129 | 4182 | } |
| 2130 | 4183 | |
| 2131 | - $result_html = $this->restore_case_sensitive_attributes($result_html); | |
| 4184 | + $result_html = $this->otto_guard_html($this->restore_case_sensitive_attributes($result_html), $result_html, 'restore_case_sensitive_attributes'); | |
| 2132 | 4185 | |
| 4186 | + # / Re-inject any <style> blocks lost during processing. | |
| 4187 | + $result_html = $this->otto_guard_html($this->restore_lost_style_blocks($result_html, $original_style_blocks), $result_html, 'restore_lost_style_blocks'); | |
| 4188 | + | |
| 4189 | + # Do NOT renumber Divi classes on the buffer/in-place path. | |
| 4190 | + # fix_divi_class_renumbering() exists to undo the index offset that | |
| 4191 | + # Divi's shortcode framework introduces ONLY during the internal | |
| 4192 | + # wp_remote_get fetch (handle_route_html), where the Theme Builder | |
| 4193 | + # header is counted twice. process_html_directly() processes the | |
| 4194 | + # page Divi already rendered normally — its module numbering and | |
| 4195 | + # Divi's own CSS are already in sync. Renumbering here desynced every | |
| 4196 | + # section/row/column/text/button index (e.g. et_pb_section_10 → _0), | |
| 4197 | + # colliding page modules with header/global modules of the same | |
| 4198 | + # number and collapsing layouts (Divi hero/slider) on Divi sites. | |
| 4199 | + # The renumber stays only on the HTTP-fetch path (handle_route_html). | |
| 4200 | + | |
| 4201 | + # Clean OTTO internal fetch params from HTML output | |
| 4202 | + $result_html = $this->otto_guard_html($this->clean_otto_fetch_params($result_html), $result_html, 'clean_otto_fetch_params'); | |
| 4203 | + | |
| 4204 | + # Remove any lazy-video facade <style> hoisted into the page | |
| 4205 | + # (runs while srcdoc is still tokenized, so the iframe's own copy is safe). | |
| 4206 | + $result_html = $this->otto_guard_html($this->strip_hoisted_facade_styles($result_html), $result_html, 'strip_hoisted_facade_styles'); | |
| 4207 | + | |
| 4208 | + # Restore the original encoded srcdoc values (undo tokenization). | |
| 4209 | + $result_html = $this->otto_guard_html($this->restore_srcdoc_attributes($result_html, $srcdoc_store), $result_html, 'restore_srcdoc_attributes'); | |
| 4210 | + | |
| 4211 | + # Keep <meta charset> within the first 1024 bytes so external CSS | |
| 4212 | + # (e.g. checkmark content:"✓") doesn't mojibake on cached responses. | |
| 4213 | + $result_html = $this->otto_guard_html($this->ensure_early_charset_meta($result_html), $result_html, 'ensure_early_charset_meta'); | |
| 4214 | + | |
| 4215 | + # undo double-encoded numeric/hex character references produced by the | |
| 4216 | + # bundled simplehtmldom serializer from attributes like data-x-icon-s="". | |
| 4217 | + $result_html = $this->otto_guard_html($this->repair_double_encoded_entities($result_html), $result_html, 'repair_double_encoded_entities'); | |
| 4218 | + | |
| 4219 | + # restore literal '&' query-string separators the serializer escaped to | |
| 4220 | + # '&', 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 | + | |
| 4223 | + # Remove any lazy-video facade <style> hoisted into the page | |
| 4224 | + # (runs while srcdoc is still tokenized, so the iframe's own copy is safe). | |
| 4225 | + $result_html = $this->strip_hoisted_facade_styles($result_html); | |
| 4226 | + | |
| 4227 | + # Restore the original encoded srcdoc values (undo tokenization). | |
| 4228 | + $result_html = $this->restore_srcdoc_attributes($result_html, $srcdoc_store); | |
| 4229 | + | |
| 4230 | + # Keep <meta charset> within the first 1024 bytes so external CSS | |
| 4231 | + # (e.g. checkmark content:"✓") doesn't mojibake on cached responses. | |
| 4232 | + $result_html = $this->ensure_early_charset_meta($result_html); | |
| 4233 | + | |
| 4234 | + # undo double-encoded numeric/hex character references produced by the | |
| 4235 | + # bundled simplehtmldom serializer from attributes like data-x-icon-s="". | |
| 4236 | + $result_html = $this->repair_double_encoded_entities($result_html); | |
| 4237 | + | |
| 2133 | 4238 | return $result_html; |
| 2134 | 4239 | |
| 2135 | 4240 | } catch (Exception $e) { |
| 2136 | - 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 | + } | |
| 2137 | 4249 | return false; |
| 2138 | 4250 | } catch (Error $e) { |
| 2139 | - 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 | + } | |
| 2140 | 4259 | return false; |
| 2141 | 4260 | } |
| 2142 | 4261 | } |
| 2143 | 4262 | |
| @@ -2186,25 +4305,237 @@ | ||
| 2186 | 4305 | } |
| 2187 | 4306 | } |
| 2188 | 4307 | } |
| 2189 | 4308 | |
| 2190 | - if ($remove_title && !$has_custom_title) { | |
| 2191 | - # Remove Open Graph and Twitter title tags (keep main <title>) | |
| 2192 | - $title_selectors = [ | |
| 2193 | - 'meta[property=og:title]', | |
| 2194 | - 'meta[name=twitter:title]', | |
| 2195 | - ]; | |
| 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. | |
| 2196 | 4315 | |
| 2197 | - foreach ($title_selectors as $selector) { | |
| 2198 | - $tags = $this->dom->find($selector); | |
| 2199 | - foreach ($tags as $tag) { | |
| 2200 | - $tag->outertext = ''; # Remove the tag | |
| 4316 | + # Save changes | |
| 4317 | + $this->save_reload(); | |
| 4318 | + } | |
| 4319 | + | |
| 4320 | + /** | |
| 4321 | + * Clean OTTO internal fetch query parameters from rendered HTML. | |
| 4322 | + * | |
| 4323 | + * @param string $html The rendered HTML | |
| 4324 | + * @return string HTML with OTTO fetch params removed | |
| 4325 | + */ | |
| 4326 | + private function clean_otto_fetch_params($html) { | |
| 4327 | + $patterns = [ | |
| 4328 | + '/[?&]is_otto_page_fetch=1/', | |
| 4329 | + '/[?&]otto_block_title=1/', | |
| 4330 | + '/[?&]otto_block_desc=1/', | |
| 4331 | + '/[?&]amp;is_otto_page_fetch=1/', | |
| 4332 | + '/[?&]amp;otto_block_title=1/', | |
| 4333 | + '/[?&]amp;otto_block_desc=1/', | |
| 4334 | + ]; | |
| 4335 | + $html = preg_replace($patterns, '', $html); | |
| 4336 | + | |
| 4337 | + # Protect <script>/<style> bodies before the URL cleanup below. | |
| 4338 | + # The href/src/action matcher can otherwise match JS that looks like an | |
| 4339 | + # attribute (e.g. location.href="/x??y", el.src="a??b") and collapse the | |
| 4340 | + # ?? inside it. Swap each block for a placeholder comment, run the cleanup, | |
| 4341 | + # then restore byte-for-byte. | |
| 4342 | + $protected = []; | |
| 4343 | + $html = preg_replace_callback( | |
| 4344 | + '/<(script|style)(\b[^>]*)>([\s\S]*?)<\/\1>/i', | |
| 4345 | + function ($m) use (&$protected) { | |
| 4346 | + $key = '<!--METASYNC_FETCHCLEAN_' . count($protected) . '-->'; | |
| 4347 | + $protected[$key] = $m[0]; | |
| 4348 | + return $key; | |
| 4349 | + }, | |
| 4350 | + $html | |
| 4351 | + ); | |
| 4352 | + | |
| 4353 | + # Normalize leftover query-string separators (?& / ?#) created by the param | |
| 4354 | + # removal above — but ONLY inside URL attributes (href/src/action). | |
| 4355 | + # | |
| 4356 | + # IMPORTANT: never collapse "??". It is valid, load-bearing syntax for | |
| 4357 | + # file-concatenation endpoints — Jetpack Boost (/_jb_static/??<hash>) and | |
| 4358 | + # WordPress core (load-styles.php??... / load-scripts.php??...). Collapsing | |
| 4359 | + # "??" -> "?" points those at a different URL that returns HTTP 400 / empty, | |
| 4360 | + # so every affected CSS/JS bundle fails to load and the page renders broken | |
| 4361 | + # (Kadence + Jetpack Boost "defer non-essential CSS" was the reported case). | |
| 4362 | + # Removing an OTTO fetch param never produces a "??" anyway, so there is | |
| 4363 | + # nothing to normalize there. | |
| 4364 | + # (A previous implementation ran str_replace('??','?') over the ENTIRE | |
| 4365 | + # document, which additionally corrupted inline JavaScript nullish operators.) | |
| 4366 | + $html = preg_replace_callback( | |
| 4367 | + '/\b(href|src|action)(\s*=\s*)(["\'])(.*?)\3/is', | |
| 4368 | + function ($m) { | |
| 4369 | + $url = str_replace(['?&', '?#'], ['?', '#'], $m[4]); | |
| 4370 | + return $m[1] . $m[2] . $m[3] . $url . $m[3]; | |
| 4371 | + }, | |
| 4372 | + $html | |
| 4373 | + ); | |
| 4374 | + | |
| 4375 | + # Restore protected <script>/<style> blocks byte-for-byte. | |
| 4376 | + if (!empty($protected)) { | |
| 4377 | + $html = str_replace(array_keys($protected), array_values($protected), $html); | |
| 4378 | + } | |
| 4379 | + | |
| 4380 | + return $html; | |
| 4381 | + } | |
| 4382 | + | |
| 4383 | + /** | |
| 4384 | + * Fix Divi 5 shortcode framework class renumbering | |
| 4385 | + * | |
| 4386 | + * When Divi 5's shortcode framework loads during the internal HTTP fetch, | |
| 4387 | + * it uses shared counters across all template parts (header + page + footer), | |
| 4388 | + * causing page content element classes to be offset (et_pb_section_0 becomes | |
| 4389 | + * et_pb_section_4, etc.). The cached CSS references 0-based numbering. | |
| 4390 | + * | |
| 4391 | + * Detects the offset per element type and remaps back to 0-based. | |
| 4392 | + * | |
| 4393 | + * @param string $html The processed HTML string | |
| 4394 | + * @return string HTML with corrected Divi class numbering | |
| 4395 | + */ | |
| 4396 | + private function fix_divi_class_renumbering($html) { | |
| 4397 | + # Only run on Divi sites | |
| 4398 | + if (!defined('ET_CORE_VERSION')) { | |
| 4399 | + return $html; | |
| 4400 | + } | |
| 4401 | + | |
| 4402 | + # Only apply to Divi pages with template builder | |
| 4403 | + if (strpos($html, 'et-l et-l--post') === false) { | |
| 4404 | + return $html; | |
| 4405 | + } | |
| 4406 | + | |
| 4407 | + # Locate the page content area (between et-l--post and et-l--footer) | |
| 4408 | + $page_start = strpos($html, 'et-l et-l--post'); | |
| 4409 | + if ($page_start === false) { | |
| 4410 | + return $html; | |
| 4411 | + } | |
| 4412 | + | |
| 4413 | + $footer_start = strpos($html, 'et-l et-l--footer', $page_start); | |
| 4414 | + if ($footer_start === false) { | |
| 4415 | + $footer_start = strlen($html); | |
| 4416 | + } | |
| 4417 | + | |
| 4418 | + $page_content = substr($html, $page_start, $footer_start - $page_start); | |
| 4419 | + | |
| 4420 | + | |
| 4421 | + # Divi element types that get renumbered by the shortcode framework. | |
| 4422 | + # IMPORTANT: 'blog' and 'portfolio' MUST be included — OTTO's HTTP render | |
| 4423 | + # shifts their numbering (et_pb_blog_0 → et_pb_blog_1) because the internal | |
| 4424 | + # wp_remote_get includes the Theme Builder header template in Divi's counter. | |
| 4425 | + # If the blog module class doesn't match between page 1 (OTTO-processed) and | |
| 4426 | + # page 2 (AJAX, no OTTO), Divi's pagination JS can't find the container → empty results. | |
| 4427 | + $types = [ | |
| 4428 | + 'section', 'row', 'column', 'text', 'blurb', 'toggle', | |
| 4429 | + 'button', 'image', 'group_carousel', | |
| 4430 | + 'blog', 'portfolio', 'filterable_portfolio', 'shop', | |
| 4431 | + 'heading', 'divider', 'code', 'icon', 'contact_form_7', | |
| 4432 | + ]; | |
| 4433 | + | |
| 4434 | + $offsets = []; | |
| 4435 | + | |
| 4436 | + # Lookahead excludes two non-instance class shapes so we never rewrite them: | |
| 4437 | + # _tb_ → Theme Builder index suffix (et_pb_column_0_tb_header) | |
| 4438 | + # _\d → Divi column WIDTH fractions (et_pb_column_4_4, _1_2, _1_3, …). | |
| 4439 | + # The width fraction is NOT an instance counter; rewriting it (e.g. | |
| 4440 | + # et_pb_column_4_4 → et_pb_column_0_4) strips the column's width rule from | |
| 4441 | + # Divi's static stylesheet (.et_pb_column_4_4{width:100%}) and collapses the | |
| 4442 | + # layout. Bare instance indices (et_pb_column_16, followed by space/quote) | |
| 4443 | + # still match and renumber as intended. | |
| 4444 | + $instance_lookahead = '(?!_(?:tb_|\d))'; | |
| 4445 | + | |
| 4446 | + foreach ($types as $type) { | |
| 4447 | + # Find the FIRST numbered instance in page content (not _tb_ or width-fraction) | |
| 4448 | + if (preg_match('/et_pb_' . preg_quote($type, '/') . '_(\d+)' . $instance_lookahead . '/', $page_content, $m)) { | |
| 4449 | + $first_num = (int) $m[1]; | |
| 4450 | + if ($first_num > 0) { | |
| 4451 | + $offsets[$type] = $first_num; | |
| 2201 | 4452 | } |
| 2202 | 4453 | } |
| 2203 | 4454 | } |
| 2204 | 4455 | |
| 2205 | - # Save changes | |
| 2206 | - $this->save_reload(); | |
| 4456 | + if (empty($offsets)) { | |
| 4457 | + return $html; | |
| 4458 | + } | |
| 4459 | + | |
| 4460 | + # Remap each element type back to 0-based numbering | |
| 4461 | + foreach ($offsets as $etype => $offset) { | |
| 4462 | + $escaped = preg_quote($etype, '/'); | |
| 4463 | + $html = preg_replace_callback( | |
| 4464 | + '/et_pb_' . $escaped . '_(\d+)' . $instance_lookahead . '/', | |
| 4465 | + function ($m) use ($etype, $offset) { | |
| 4466 | + $num = (int) $m[1]; | |
| 4467 | + if ($num >= $offset) { | |
| 4468 | + return 'et_pb_' . $etype . '_' . ($num - $offset); | |
| 4469 | + } | |
| 4470 | + return $m[0]; | |
| 4471 | + }, | |
| 4472 | + $html | |
| 4473 | + ); | |
| 4474 | + } | |
| 4475 | + | |
| 4476 | + # Remove duplicate JS variable declarations from the shortcode framework | |
| 4477 | + foreach (['et_pb_custom', 'et_frontend_scripts', 'et_builder_utils_params'] as $var_name) { | |
| 4478 | + $needle = 'var ' . $var_name . ' = '; | |
| 4479 | + $first = strpos($html, $needle); | |
| 4480 | + if ($first !== false) { | |
| 4481 | + $second = strpos($html, $needle, $first + strlen($needle)); | |
| 4482 | + if ($second !== false) { | |
| 4483 | + $end = strpos($html, ";\n", $second); | |
| 4484 | + if ($end !== false) { | |
| 4485 | + $html = substr($html, 0, $second) . substr($html, $end + 2); | |
| 4486 | + } | |
| 4487 | + } | |
| 4488 | + } | |
| 4489 | + } | |
| 4490 | + | |
| 4491 | + # Deduplicate animation data (shortcode framework creates duplicate entries) | |
| 4492 | + if (preg_match('/var diviElementAnimationData = (\[.*?\]);/s', $html, $am)) { | |
| 4493 | + $data = json_decode($am[1], true); | |
| 4494 | + if (is_array($data)) { | |
| 4495 | + $seen = []; | |
| 4496 | + $unique = []; | |
| 4497 | + foreach (array_reverse($data) as $e) { | |
| 4498 | + $k = $e['class'] ?? ''; | |
| 4499 | + if ($k !== '' && !isset($seen[$k])) { | |
| 4500 | + $seen[$k] = true; | |
| 4501 | + array_unshift($unique, $e); | |
| 4502 | + } | |
| 4503 | + } | |
| 4504 | + if (count($unique) < count($data)) { | |
| 4505 | + $html = str_replace( | |
| 4506 | + $am[0], | |
| 4507 | + 'var diviElementAnimationData = ' . json_encode($unique, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';', | |
| 4508 | + $html | |
| 4509 | + ); | |
| 4510 | + } | |
| 4511 | + } | |
| 4512 | + } | |
| 4513 | + | |
| 4514 | + # Deduplicate multiview data | |
| 4515 | + if (preg_match('/var diviElementMultiViewData = (\[.*?\]);/s', $html, $mv)) { | |
| 4516 | + $data = json_decode($mv[1], true); | |
| 4517 | + if (is_array($data)) { | |
| 4518 | + $seen = []; | |
| 4519 | + $unique = []; | |
| 4520 | + foreach (array_reverse($data) as $e) { | |
| 4521 | + $k = ($e['selector'] ?? '') . '|' . ($e['action'] ?? ''); | |
| 4522 | + if ($k !== '|' && !isset($seen[$k])) { | |
| 4523 | + $seen[$k] = true; | |
| 4524 | + array_unshift($unique, $e); | |
| 4525 | + } | |
| 4526 | + } | |
| 4527 | + if (count($unique) < count($data)) { | |
| 4528 | + $html = str_replace( | |
| 4529 | + $mv[0], | |
| 4530 | + 'var diviElementMultiViewData = ' . json_encode($unique, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';', | |
| 4531 | + $html | |
| 4532 | + ); | |
| 4533 | + } | |
| 4534 | + } | |
| 4535 | + } | |
| 4536 | + | |
| 4537 | + return $html; | |
| 2207 | 4538 | } |
| 2208 | 4539 | |
| 2209 | 4540 | |
| 2210 | -} | |
| 4541 | +} | |