Changeset 3763326
- Timestamp:
- 10/24/2026 01:29:26 PM (9 hours ago)
- Location:
- seojuice
- Files:
-
- 6 edited
- 1 copied
-
tags/2.9.0 (copied) (copied from seojuice/trunk )
-
tags/2.9.0/includes/footprint.php (modified) (7 diffs)
-
tags/2.9.0/readme.txt (modified) (3 diffs)
-
tags/2.9.0/seojuice.php (modified) (13 diffs)
-
trunk/includes/footprint.php (modified) (7 diffs)
-
trunk/readme.txt (modified) (3 diffs)
-
trunk/seojuice.php (modified) (13 diffs)
Legend:
- Unmodified
- Added
- Removed
-
seojuice/tags/2.9.0/includes/footprint.php
r3761416 r3763326 212 212 } 213 213 214 function seojuice_record_field_version(int $post_id, int $cs_id, string $field, $before_value, $wrote_value = null ): void {214 function seojuice_record_field_version(int $post_id, int $cs_id, string $field, $before_value, $wrote_value = null, array $meta_before = array(), array $meta_wrote = array()): void { 215 215 $h = seojuice_get_history($post_id); 216 216 $entry = array( 217 'cs' => $cs_id, 'ts' => time(), 'before' => array(),217 'cs' => $cs_id, 'ts' => time(), 'before' => $meta_before, 218 218 'before_fields' => array($field => array('value' => $before_value)), 219 219 ); 220 220 if ($wrote_value !== null) { $entry['wrote_fields'] = array($field => $wrote_value); } 221 if (!empty($meta_wrote)) { $entry['wrote'] = $meta_wrote; } 221 222 $h[] = $entry; 222 223 seojuice_update_post_meta($post_id, '_seojuice_history', wp_json_encode($h)); … … 247 248 } 248 249 250 // Title variables an SEO plugin template can hold; a pin replaces them with the old post_title. 251 function seojuice_title_template_vars(): array { 252 return array('%%title%%', '%title%', '%%post_title%%'); 253 } 254 255 // A history entry written by a heading change: it records post_title in before_fields. 256 function seojuice_history_is_heading(array $e): bool { 257 return ($e['type'] ?? '') !== 'content_diff' 258 && isset($e['before_fields']['post_title']) && is_array($e['before_fields']['post_title']); 259 } 260 261 // A search-title rollback hands its key to the heading entries that passed it through 262 // (spec 2.4). Those entries recorded the title key with before equal to this entry's wrote. 263 // Leaving the key as it is, or restoring a value that is empty or built from post_title, 264 // would let the heading show in the search title. So the key gets the pin the heading 265 // needs (the restored template with the old post_title), and the heading entries take it 266 // over: the first one restores the title's before, so rolling it back removes the pin. 267 // Returns false when no heading entry follows, so the plain splice rules apply. 268 function seojuice_revert_title_into_headings(int $post_id, array &$h, int $i, string $key, array $rec, string $s_wrote, array &$key_out): bool { 269 $chain = array(); 270 $expected = $s_wrote; 271 $cs_id = (int) ($h[$i]['cs'] ?? 0); 272 $follow = null; 273 for ($j = $i + 1, $n = count($h); $j < $n; $j++) { 274 $e = $h[$j]; 275 if ((int) ($e['cs'] ?? 0) === $cs_id || !isset($e['before'][$key])) { continue; } 276 $b = $e['before'][$key]; 277 $passes = seojuice_history_is_heading($e) && !empty($b['present']) 278 && (string) ($b['value'] ?? '') === $expected 279 && isset($e['wrote']) && is_array($e['wrote']) && array_key_exists($key, $e['wrote']); 280 if (!$passes) { $follow = $j; break; } 281 $chain[] = $j; 282 $expected = (string) $e['wrote'][$key]; 283 } 284 if (!$chain) { return false; } 285 286 if ($follow === null && (string) get_post_meta($post_id, $key, true) !== $expected) { 287 $key_out[$key] = 'kept_edit'; 288 return true; 289 } 290 $rec_val = (string) ($rec['value'] ?? ''); 291 $final = array('present' => !empty($rec['present']), 'value' => $rec_val); 292 $tpl = $rec_val; 293 if ($tpl === '' && function_exists('seojuice_default_title_template')) { 294 $post = get_post($post_id); 295 $tpl = seojuice_default_title_template($key, $post ? (string) ($post->post_type ?? 'post') : 'post'); 296 } 297 $has_var = false; 298 foreach (seojuice_title_template_vars() as $v) { if (strpos($tpl, $v) !== false) { $has_var = true; break; } } 299 if ($has_var) { 300 $old = (string) ($h[$chain[0]]['before_fields']['post_title']['value'] ?? ''); 301 $final = array('present' => true, 'value' => str_replace(seojuice_title_template_vars(), $old, $tpl)); 302 } 303 foreach ($chain as $n => $j) { 304 $h[$j]['before'][$key] = $n === 0 ? $rec : $final; 305 $h[$j]['wrote'][$key] = $final['value']; 306 } 307 if ($follow !== null) { 308 $fb = $h[$follow]['before'][$key]; 309 if (!empty($fb['present']) && (string) ($fb['value'] ?? '') === $expected) { $h[$follow]['before'][$key] = $final; } 310 return true; 311 } 312 if ($final['present']) { seojuice_update_post_meta($post_id, $key, $final['value']); $key_out[$key] = 'restored'; } 313 else { delete_post_meta($post_id, $key); $key_out[$key] = 'absent_restored'; } 314 return true; 315 } 316 317 // Without an SEO plugin a heading is held by the pin key. When no search title is live 318 // after a rollback and a heading entry never wrote its pin (it was applied while a 319 // search title overrode the title), write the pin now: the pre-heading post_title. 320 function seojuice_guard_core_pin(int $post_id, array &$h): void { 321 $pin = '_seojuice_h1_pin'; 322 if ((string) get_post_meta($post_id, '_seojuice_seo_title', true) !== '' 323 || (string) get_post_meta($post_id, $pin, true) !== '') { return; } 324 $open = array(); 325 foreach ($h as $j => $e) { 326 if (seojuice_history_is_heading($e) && isset($e['wrote'][$pin]) && (string) $e['wrote'][$pin] === '') { $open[] = $j; } 327 } 328 if (!$open) { return; } 329 $old = (string) ($h[$open[0]]['before_fields']['post_title']['value'] ?? ''); 330 if ($old === '') { return; } 331 seojuice_update_post_meta($post_id, $pin, $old); 332 foreach ($open as $n => $j) { 333 $h[$j]['wrote'][$pin] = $old; 334 if ($n > 0) { $h[$j]['before'][$pin] = array('present' => true, 'value' => $old); } 335 } 336 } 337 338 // Returns skipped (keys and fields left because the client changed them), content_diff, 339 // outcome, and key_outcomes: key or field => restored | kept_edit | absent_restored. 340 // A key handed to a later live change has no outcome: it stays as that change left it. 249 341 function seojuice_revert_changeset(int $post_id, int $cs_id): array { 250 342 $h = seojuice_get_history($post_id); 251 343 $drop = array(); 252 344 $skipped = array(); 345 $key_out = array(); 346 $own_title_touched = false; 253 347 $content_diff_handled = false; 254 348 $outcome = null; … … 270 364 if (!$res['reverted'] && !empty($res['skipped'])) { $skipped[] = 'content'; } 271 365 if (isset($res['outcome'])) { $outcome = $res['outcome']; } 366 $co = (string) ($res['outcome'] ?? ''); 367 if (in_array($co, array('drift_kept', 'marker_stripped'), true)) { $key_out['content'] = 'kept_edit'; } 368 elseif (!empty($res['reverted'])) { $key_out['content'] = 'restored'; } 272 369 } 273 370 } … … 277 374 $wrote = (isset($entry['wrote']) && is_array($entry['wrote'])) ? $entry['wrote'] : null; 278 375 foreach (($entry['before'] ?? array()) as $key => $rec) { 376 if ($key === '_seojuice_seo_title') { $own_title_touched = true; } 377 // A search title followed by heading entries that passed its key through. 378 if ($wrote !== null && array_key_exists($key, $wrote) && !seojuice_history_is_heading($entry) 379 && seojuice_revert_title_into_headings($post_id, $h, $i, (string) $key, $rec, (string) $wrote[$key], $key_out)) { 380 continue; 381 } 279 382 // Only an entry that proves what it wrote may chain: when the next 280 383 // entry's before is that write, hand it this before value. When a client … … 293 396 && (string) get_post_meta($post_id, $key, true) !== (string) $wrote[$key]) { 294 397 $skipped[] = $key; 398 $key_out[$key] = 'kept_edit'; 295 399 continue; 296 400 } 297 if (!empty($rec['present'])) { seojuice_update_post_meta($post_id, $key, $rec['value']); }298 else { delete_post_meta($post_id, $key); }401 if (!empty($rec['present'])) { seojuice_update_post_meta($post_id, $key, $rec['value']); $key_out[$key] = 'restored'; } 402 else { delete_post_meta($post_id, $key); $key_out[$key] = 'absent_restored'; } 299 403 } 300 404 $wf = (isset($entry['wrote_fields']) && is_array($entry['wrote_fields'])) ? $entry['wrote_fields'] : null; … … 311 415 $post = get_post($post_id); 312 416 $cur = ($post && $field === 'post_title') ? (string) $post->post_title : ''; 313 if ($cur !== (string) $wf[$field]) { $skipped[] = $field; continue; }417 if ($cur !== (string) $wf[$field]) { $skipped[] = $field; $key_out[$field] = 'kept_edit'; continue; } 314 418 } 315 419 seojuice_update_post(array('ID' => $post_id, $field => $rec['value'])); 420 $key_out[$field] = 'restored'; 316 421 } 317 422 } … … 320 425 if (!isset($drop[$i])) { $kept[] = $entry; } 321 426 } 427 if ($own_title_touched) { seojuice_guard_core_pin($post_id, $kept); } 322 428 seojuice_update_post_meta($post_id, '_seojuice_history', wp_json_encode($kept)); 323 429 if (!$blocked) { seojuice_drop_persisted($post_id, $cs_id); } 324 return array('skipped' => $skipped, 'content_diff' => $content_diff_handled, 'outcome' => $outcome );430 return array('skipped' => $skipped, 'content_diff' => $content_diff_handled, 'outcome' => $outcome, 'key_outcomes' => $key_out); 325 431 } 326 432 -
seojuice/tags/2.9.0/readme.txt
r3761416 r3763326 2 2 Contributors: seojuice 3 3 Tags: seo, internal links, ai search, meta tags, visibility tracking 4 Stable tag: 2. 8.14 Stable tag: 2.9.0 5 5 Requires at least: 6.2 6 6 Tested up to: 7.1 … … 138 138 139 139 == Changelog == 140 141 = 2.9.0 = 142 * New: approved page headings can be saved in WordPress, with their own confirmation. Your search title stays as it is. 143 * New: Roll back all on a page puts it back as it was before SEOJuice. 144 * Improved: rollback tells you when it kept an edit you made yourself. 140 145 141 146 = 2.8.1 = … … 320 325 == Upgrade Notice == 321 326 327 = 2.9.0 = 328 Approved page headings can now be saved in WordPress, and Roll back all puts a page back as it was before SEOJuice. 329 322 330 = 2.8.1 = 323 331 Rollback fix: rolling back SEOJuice changes in any order now restores the right value and keeps your own edits. -
seojuice/tags/2.9.0/seojuice.php
r3761416 r3763326 4 4 Description: SEOJuice automated on-page SEO optimization with server-side rendering for better performance and crawlability. 5 5 Author: Vadim Kravcenko 6 Version: 2. 8.16 Version: 2.9.0 7 7 Author URI: https://seojuice.io 8 8 License: GPLv2 … … 44 44 // is_ssl() actually cached under. Adds GET /front-page (page_on_front lookup) 45 45 // so meta_update changes targeting the homepage can resolve a post id. 46 const SEOJUICE_PLUGIN_VERSION = '2. 8.1';46 const SEOJUICE_PLUGIN_VERSION = '2.9.0'; 47 47 // API base defaults to production. Overridable (before the plugin loads, e.g. 48 48 // via wp-config.php or an mu-plugin) or through the SEOJUICE_API_BASE env var, … … 2045 2045 2046 2046 function seojuice_read_seo_title($post_id) { 2047 if (seojuice_pin_capable() === 'core') { 2048 return (string) get_post_meta($post_id, SEOJUICE_OWN_TITLE_KEY, true); 2049 } 2047 2050 foreach (seojuice_active_seo_title_keys() as $k) { 2048 2051 $v = (string) get_post_meta($post_id, $k, true); … … 2052 2055 } 2053 2056 2057 const SEOJUICE_OWN_TITLE_KEY = '_seojuice_seo_title'; 2058 const SEOJUICE_PIN_KEY = '_seojuice_h1_pin'; 2059 const SEOJUICE_TITLE_PRIORITY = 15; 2060 2061 // True when a title/SEO plugin we cannot write for controls the document title. 2062 function seojuice_unsupported_title_owner_active() { 2063 return defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO') 2064 || function_exists('the_seo_framework') || defined('THE_SEO_FRAMEWORK_VERSION') 2065 || defined('SLIM_SEO_VER'); 2066 } 2067 2068 // True when a callback that is not ours sits on $tag at a priority above $min_prio 2069 // (null = any priority). 2070 // WordPress core formats document_title with these (default-filters.php); they never replace it. 2071 const SEOJUICE_CORE_TITLE_FORMATTERS = array('wptexturize', 'convert_chars', 'esc_html', 'capital_P_dangit'); 2072 2073 function seojuice_foreign_title_callback($tag, $min_prio = null) { 2074 global $wp_filter; 2075 if (empty($wp_filter[$tag])) { return false; } 2076 $h = $wp_filter[$tag]; 2077 $cbs = is_object($h) && isset($h->callbacks) ? $h->callbacks : (is_array($h) ? $h : array()); 2078 foreach ($cbs as $prio => $fns) { 2079 if ($min_prio !== null && (int) $prio <= $min_prio) { continue; } 2080 foreach ((array) $fns as $fn) { 2081 $f = is_array($fn) && isset($fn['function']) ? $fn['function'] : $fn; 2082 if (is_string($f) && (strpos($f, 'seojuice_') === 0 || in_array($f, SEOJUICE_CORE_TITLE_FORMATTERS, true))) { continue; } 2083 return true; 2084 } 2085 } 2086 return false; 2087 } 2088 2089 // A foreign pre_get_document_title or document_title callback at any priority, or a 2090 // document_title_parts callback after ours, can replace what we render. 2091 function seojuice_foreign_title_conflict() { 2092 return seojuice_foreign_title_callback('pre_get_document_title') 2093 || seojuice_foreign_title_callback('document_title') 2094 || seojuice_foreign_title_callback('document_title_parts', SEOJUICE_TITLE_PRIORITY); 2095 } 2096 2097 // Front-end verdict: $wp_filter during a REST apply can miss filters added only on 2098 // front-end hooks, so a rendered singular request records what it saw. It runs at 2099 // wp_head priority 0 so it also sees title filters added on template_redirect or wp_head. 2100 function seojuice_record_title_render_conflict() { 2101 if (!empty($GLOBALS['seojuice_title_conflict_checked']) || is_admin() || is_feed() || !is_singular()) { return; } 2102 $GLOBALS['seojuice_title_conflict_checked'] = true; 2103 $conflict = seojuice_foreign_title_conflict(); 2104 $cur = get_option('seojuice_title_render_conflict', null); 2105 if (is_array($cur) && !empty($cur['conflict']) === $conflict) { return; } 2106 update_option('seojuice_title_render_conflict', array('conflict' => $conflict, 'at' => time()), false); 2107 } 2108 add_action('wp_head', 'seojuice_record_title_render_conflict', 0); 2109 2110 // 'seo_plugin' | 'core' | 'unsupported'. Spec 2.2a pin-proven gate. 2111 function seojuice_pin_capable() { 2112 if (seojuice_unsupported_title_owner_active()) { return 'unsupported'; } 2113 if (seojuice_active_seo_title_keys()) { return 'seo_plugin'; } 2114 if (!function_exists('current_theme_supports') || !current_theme_supports('title-tag')) { return 'unsupported'; } 2115 if (seojuice_foreign_title_conflict()) { return 'unsupported'; } 2116 $rc = get_option('seojuice_title_render_conflict', null); 2117 if (is_array($rc) && !empty($rc['conflict'])) { return 'unsupported'; } 2118 return 'core'; 2119 } 2120 2121 function seojuice_pin_key() { 2122 $keys = seojuice_active_seo_title_keys(); 2123 return $keys ? $keys[0] : SEOJUICE_PIN_KEY; 2124 } 2125 2126 // Returns the post meta string for $key on a singular front-end view of its post, or ''. 2127 function seojuice_title_meta_for_view($key) { 2128 if (is_admin() || is_feed() || !is_singular()) { return ''; } 2129 $id = (int) get_queried_object_id(); 2130 if ($id <= 0) { return ''; } 2131 return (string) get_post_meta($id, $key, true); 2132 } 2133 2134 function seojuice_filter_pre_document_title($title) { 2135 $v = seojuice_title_meta_for_view(SEOJUICE_OWN_TITLE_KEY); 2136 if ($v === '' || seojuice_pin_capable() !== 'core') { return $title; } 2137 return $v; 2138 } 2139 add_filter('pre_get_document_title', 'seojuice_filter_pre_document_title', SEOJUICE_TITLE_PRIORITY); 2140 2141 function seojuice_filter_document_title_parts($parts) { 2142 // Core builds a static front page title from site name and tagline, not post_title. 2143 if (is_front_page()) { return $parts; } 2144 if (seojuice_title_meta_for_view(SEOJUICE_OWN_TITLE_KEY) !== '') { return $parts; } 2145 $pin = seojuice_title_meta_for_view(SEOJUICE_PIN_KEY); 2146 if ($pin === '' || seojuice_pin_capable() !== 'core') { return $parts; } 2147 if (!is_array($parts)) { $parts = array(); } 2148 $parts['title'] = $pin; 2149 return $parts; 2150 } 2151 add_filter('document_title_parts', 'seojuice_filter_document_title_parts', SEOJUICE_TITLE_PRIORITY); 2152 2054 2153 function seojuice_capabilities() { 2055 $caps = array('persistent_apply', 'footer_backlink', 'url_replace', 'cache_clear', 'apply_reasons', 'content_save_guard', 'seo_title' );2154 $caps = array('persistent_apply', 'footer_backlink', 'url_replace', 'cache_clear', 'apply_reasons', 'content_save_guard', 'seo_title', 'headline'); 2056 2155 if (SEOJUICE_CAP_ALT_INLINE) { 2057 2156 $caps[] = 'alt_inline'; … … 2521 2620 'title' => $post->post_title, 2522 2621 'seo_title' => seojuice_read_seo_title($post_id), 2622 'h1_raw' => seojuice_read_h1_raw($post), 2523 2623 'status' => $post->post_status, 2524 2624 'url' => get_permalink($post->ID), … … 3590 3690 } 3591 3691 3692 // --------------------------------------------------------------------------- 3693 // headline: change the page heading (H1) without moving the search title. 3694 // --------------------------------------------------------------------------- 3695 3696 // Comparison form of a heading: tags stripped, entities decoded, typographic 3697 // quotes/dashes folded to ASCII, whitespace collapsed. 3698 function seojuice_headline_norm($s): string { 3699 $s = html_entity_decode(strip_tags((string) $s), ENT_QUOTES | ENT_HTML5, 'UTF-8'); 3700 $s = strtr($s, array( 3701 "\u{2018}" => "'", "\u{2019}" => "'", "\u{201B}" => "'", "\u{2032}" => "'", 3702 "\u{201C}" => '"', "\u{201D}" => '"', "\u{201E}" => '"', "\u{2033}" => '"', 3703 "\u{2013}" => '-', "\u{2014}" => '-', "\u{2026}" => '...', "\u{00A0}" => ' ', 3704 )); 3705 return trim((string) preg_replace('/\s+/u', ' ', $s)); 3706 } 3707 3708 // Parses a body into a wrapper element; null on failure. 3709 function seojuice_headline_parse(string $content): ?array { 3710 $dom = new DOMDocument(); 3711 $prev = libxml_use_internal_errors(true); 3712 $dom->loadHTML('<?xml encoding="UTF-8" ?><div id="seojuice-root">' . $content . '</div>', 3713 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); 3714 libxml_clear_errors(); libxml_use_internal_errors($prev); 3715 $root = $dom->getElementById('seojuice-root'); 3716 return $root ? array($dom, $root) : null; 3717 } 3718 3719 function seojuice_elementor_has_h1($nodes, array $wanted): bool { 3720 if (!is_array($nodes)) { return false; } 3721 foreach ($nodes as $n) { 3722 if (!is_array($n)) { continue; } 3723 if (($n['widgetType'] ?? '') === 'heading' 3724 && strtolower((string) ($n['settings']['header_size'] ?? '')) === 'h1' 3725 && isset($wanted[seojuice_headline_norm($n['settings']['title'] ?? '')])) { 3726 return true; 3727 } 3728 if (!empty($n['elements']) && seojuice_elementor_has_h1($n['elements'], $wanted)) { return true; } 3729 } 3730 return false; 3731 } 3732 3733 // True when a page builder owns the visible H1: writing post_title there would rename 3734 // menus and leave the heading unchanged. 3735 function seojuice_headline_in_builder(int $post_id, string $content, array $wanted): bool { 3736 if (get_post_meta($post_id, '_fl_builder_enabled', true)) { return true; } 3737 $el = get_post_meta($post_id, '_elementor_data', true); 3738 if (is_string($el) && $el !== '') { $el = json_decode($el, true); } 3739 if (seojuice_elementor_has_h1($el, $wanted)) { return true; } 3740 if (strpos($content, '[vc_custom_heading') !== false 3741 && preg_match_all('/\[vc_custom_heading\b([^\]]*)\]/', $content, $sc)) { 3742 foreach ($sc[1] as $attrs) { 3743 if (!preg_match_all('/(\w+)\s*=\s*"([^"]*)"/', $attrs, $kv, PREG_SET_ORDER)) { continue; } 3744 $a = array(); 3745 foreach ($kv as $pair) { $a[$pair[1]] = $pair[2]; } 3746 $fc = urldecode($a['font_container'] ?? ''); 3747 if (preg_match('/(^|\|)tag:h1(\||$)/i', $fc) && isset($wanted[seojuice_headline_norm(urldecode($a['text'] ?? ''))])) { 3748 return true; 3749 } 3750 } 3751 } 3752 return false; 3753 } 3754 3755 // Content <h1> elements whose text matches $wanted: array('count' => n, 'text' => raw text 3756 // of the single match, 'targetable' => the diff engine would edit that very element). 3757 function seojuice_headline_content_h1(string $content, array $wanted): array { 3758 $out = array('count' => 0, 'text' => '', 'targetable' => false); 3759 $parsed = seojuice_headline_parse($content); 3760 if (!$parsed) { return $out; } 3761 list($dom, $root) = $parsed; 3762 $match = null; 3763 foreach ($dom->getElementsByTagName('h1') as $h) { 3764 if (isset($wanted[seojuice_headline_norm($h->textContent)])) { $out['count']++; $match = $h; } 3765 } 3766 if ($out['count'] !== 1) { return $out; } 3767 $out['text'] = $match->textContent; 3768 $t = seojuice_locate_diff_target($dom, $root, 'h1', $out['text']); 3769 for ($n = $t; $n instanceof DOMNode; $n = $n->parentNode) { 3770 if ($n->isSameNode($match)) { $out['targetable'] = true; break; } 3771 } 3772 return $out; 3773 } 3774 3775 // Raw text the page shows as its H1: the single content <h1>, else raw post_title. 3776 // Never get_the_title(), which wptexturize alters. 3777 function seojuice_read_h1_raw($post): string { 3778 $parsed = seojuice_headline_parse((string) $post->post_content); 3779 if ($parsed) { 3780 $h1s = $parsed[0]->getElementsByTagName('h1'); 3781 if ($h1s->length === 1) { return trim((string) $h1s->item(0)->textContent); } 3782 } 3783 return (string) $post->post_title; 3784 } 3785 3786 function seojuice_default_title_template(string $key, string $post_type): string { 3787 $pt = $post_type !== '' ? $post_type : 'post'; 3788 if ($key === '_yoast_wpseo_title') { 3789 $o = get_option('wpseo_titles', array()); 3790 $t = is_array($o) && isset($o['title-' . $pt]) ? (string) $o['title-' . $pt] : ''; 3791 return $t !== '' ? $t : '%%title%% %%page%% %%sep%% %%sitename%%'; 3792 } 3793 if ($key === 'rank_math_title') { 3794 $o = get_option('rank_math_titles', array()); 3795 $t = is_array($o) && isset($o['pt_' . $pt . '_title']) ? (string) $o['pt_' . $pt . '_title'] : ''; 3796 return $t !== '' ? $t : '%title% %sep% %sitename%'; 3797 } 3798 $o = get_option('seopress_titles_option_name', array()); 3799 $t = is_array($o) && isset($o['seopress_titles_single_titles'][$pt]['title']) 3800 ? (string) $o['seopress_titles_single_titles'][$pt]['title'] : ''; 3801 return $t !== '' ? $t : '%%post_title%% - %%sitetitle%%'; 3802 } 3803 3804 // Pin values to hold the search title steady across a post_title write: key => value 3805 // to store. A value equal to the live one means no pin is needed (or one already holds). 3806 function seojuice_headline_pin_values(int $post_id, string $mode, string $old_title, string $post_type): array { 3807 $vars = array('%%title%%', '%title%', '%%post_title%%'); 3808 $out = array(); 3809 if ($mode === 'core') { 3810 $cur = (string) get_post_meta($post_id, SEOJUICE_PIN_KEY, true); 3811 $own = (string) get_post_meta($post_id, SEOJUICE_OWN_TITLE_KEY, true); 3812 // An earlier pin already holds the pre-heading title; an approved search title 3813 // overrides the whole <title>, so the pin is not needed while it is live. 3814 $out[SEOJUICE_PIN_KEY] = ($cur !== '' || $own !== '') ? $cur : $old_title; 3815 return $out; 3816 } 3817 foreach (seojuice_active_seo_title_keys() as $key) { 3818 $cur = (string) get_post_meta($post_id, $key, true); 3819 $tpl = $cur !== '' ? $cur : seojuice_default_title_template($key, $post_type); 3820 $has = false; 3821 foreach ($vars as $v) { if (strpos($tpl, $v) !== false) { $has = true; break; } } 3822 $out[$key] = $has ? str_replace($vars, $old_title, $tpl) : $cur; 3823 } 3824 return $out; 3825 } 3826 3827 // Applies one `headline` change. Returns array('result', 'reason', 'target', 'content', 3828 // 'content_dirty'). Resolution order: page builder, single content <h1>, raw post_title. 3829 function seojuice_apply_headline(int $post_id, string $content, int $cs_id, array $payload, bool $save_unsafe): array { 3830 $res = array('result' => 'skipped', 'reason' => '', 'target' => '', 'content' => $content, 'content_dirty' => false); 3831 $new = isset($payload['h1']) && is_string($payload['h1']) ? sanitize_text_field($payload['h1']) : ''; 3832 $wanted = array(); 3833 foreach (array('expected_h1', 'crawled_h1') as $k) { 3834 $n = isset($payload[$k]) && is_string($payload[$k]) ? seojuice_headline_norm($payload[$k]) : ''; 3835 if ($n !== '') { $wanted[$n] = true; } 3836 } 3837 if ($new === '' || !$wanted) { $res['reason'] = 'invalid_payload'; return $res; } 3838 if (seojuice_headline_in_builder($post_id, $content, $wanted)) { $res['reason'] = 'h1_in_page_builder'; return $res; } 3839 3840 $h1 = seojuice_headline_content_h1($content, $wanted); 3841 if ($h1['count'] === 1) { 3842 if (!$h1['targetable']) { $res['reason'] = 'unlocatable'; return $res; } 3843 if ($save_unsafe) { $res['reason'] = 'content_unsafe_to_save'; return $res; } 3844 $d = seojuice_apply_content_diff($content, $cs_id, array( 3845 'location_dom' => 'h1', 'original_text' => $h1['text'], 'operation' => 'replace', 3846 'text' => htmlspecialchars($new, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), 3847 )); 3848 if (!$d['applied']) { $res['reason'] = 'unlocatable'; return $res; } 3849 seojuice_record_content_diff_version($post_id, $cs_id, $d['before']); 3850 return array('result' => 'applied', 'reason' => '', 'target' => 'content_h1', 3851 'content' => $d['content'], 'content_dirty' => true); 3852 } 3853 $post = get_post($post_id); 3854 if ($h1['count'] > 1 || !$post || !isset($wanted[seojuice_headline_norm($post->post_title)])) { 3855 $res['reason'] = 'h1_not_found'; 3856 return $res; 3857 } 3858 3859 // A static posts page builds its <title> from single_post_title(), so no pin renders. 3860 $mode = seojuice_pin_capable(); 3861 if ((int) get_option('page_for_posts', 0) === $post_id || $mode === 'unsupported') { 3862 $res['reason'] = 'title_would_change'; 3863 return $res; 3864 } 3865 // wp_update_post re-saves the stored body through the save filters, so a body kses would strip must not be touched. 3866 if ($save_unsafe) { $res['reason'] = 'content_unsafe_to_save'; return $res; } 3867 $old_title = (string) $post->post_title; 3868 $pins = seojuice_headline_pin_values($post_id, $mode, $old_title, (string) ($post->post_type ?? 'post')); 3869 $before = array(); $wrote = array(); 3870 foreach ($pins as $key => $value) { 3871 $present = metadata_exists('post', $post_id, $key); 3872 $cur = (string) get_post_meta($post_id, $key, true); 3873 $before[$key] = array('present' => $present, 'value' => $cur); 3874 if ($value !== $cur) { seojuice_update_post_meta($post_id, $key, $value); } 3875 $wrote[$key] = $value; 3876 } 3877 $updated = seojuice_update_post(array('ID' => $post_id, 'post_title' => $new), true); 3878 if (is_wp_error($updated) || !$updated) { 3879 foreach ($before as $key => $b) { 3880 if ($b['present']) { seojuice_update_post_meta($post_id, $key, $b['value']); } 3881 else { delete_post_meta($post_id, $key); } 3882 } 3883 $res['reason'] = 'update_failed'; 3884 return $res; 3885 } 3886 $after = get_post($post_id); 3887 $wrote_title = $after ? (string) $after->post_title : $new; 3888 seojuice_record_field_version($post_id, $cs_id, 'post_title', $old_title, $wrote_title, $before, $wrote); 3889 $res['result'] = 'applied'; 3890 $res['target'] = 'post_title'; 3891 return $res; 3892 } 3893 3592 3894 function seojuice_rest_apply_changes($request) { 3593 3895 $post_id = intval($request->get_param('id')); … … 3613 3915 $results = array(); 3614 3916 $reasons = array(); 3917 $targets = array(); 3615 3918 $persisted_ids = array(); 3616 3919 $alt_commits = array(); … … 3705 4008 } elseif ($type === 'seo_title') { 3706 4009 $new = isset($payload['title']) ? sanitize_text_field($payload['title']) : ''; 3707 $keys = seojuice_active_seo_title_keys(); 4010 $mode = seojuice_pin_capable(); 4011 $keys = $mode === 'core' ? array(SEOJUICE_OWN_TITLE_KEY) : seojuice_active_seo_title_keys(); 3708 4012 if ($new === '') { 3709 4013 $results[(string) $cs_id] = 'skipped'; … … 3724 4028 $persisted_ids[] = $cs_id; 3725 4029 } 4030 } elseif ($type === 'headline') { 4031 if ($save_unsafe === null) { 4032 $save_unsafe = seojuice_save_would_strip_content((string) $owned->post_content); 4033 } 4034 $res = seojuice_apply_headline($post_id, $content, $cs_id, $payload, $save_unsafe); 4035 $results[(string) $cs_id] = $res['result']; 4036 if ($res['result'] === 'applied') { 4037 $content = $res['content']; 4038 $content_dirty = $content_dirty || $res['content_dirty']; 4039 $targets[(string) $cs_id] = $res['target']; 4040 $persisted_ids[] = $cs_id; 4041 } else { 4042 $reasons[(string) $cs_id] = $res['reason']; 4043 } 3726 4044 } elseif ($type === 'og') { 3727 4045 $map = array( … … 3831 4149 } 3832 4150 3833 return new WP_REST_Response(array('success' => true, 'results' => $results, 'reasons' => $reasons ), 200);4151 return new WP_REST_Response(array('success' => true, 'results' => $results, 'reasons' => $reasons, 'targets' => $targets), 200); 3834 4152 } 3835 4153 … … 3848 4166 $reverted = array(); 3849 4167 $outcomes = array(); 4168 $key_outcomes = array(); 3850 4169 3851 4170 foreach ($ids as $cs_id) { … … 3902 4221 $outcome = $res['outcome'] ?? 'absent'; 3903 4222 } 4223 // Per-key results for title, heading and meta changes; their change-level 4224 // outcome was always 'absent' before, which hid a kept client edit. 4225 $ko = (isset($res['key_outcomes']) && is_array($res['key_outcomes'])) ? $res['key_outcomes'] : array(); 4226 if ($ko) { 4227 $key_outcomes[(string) $cs_id] = $ko; 4228 if ($outcome === null || $outcome === 'absent') { 4229 $outcome = in_array('kept_edit', $ko, true) ? 'drift_kept' : 'restored'; 4230 } 4231 } 3904 4232 if ($blocked) { 3905 4233 $outcome = 'blocked'; … … 3916 4244 } 3917 4245 3918 return new WP_REST_Response(array('success' => true, 'reverted' => $reverted, 'outcomes' => $outcomes ), 200);4246 return new WP_REST_Response(array('success' => true, 'reverted' => $reverted, 'outcomes' => $outcomes, 'key_outcomes' => $key_outcomes), 200); 3919 4247 } 3920 4248 -
seojuice/trunk/includes/footprint.php
r3761416 r3763326 212 212 } 213 213 214 function seojuice_record_field_version(int $post_id, int $cs_id, string $field, $before_value, $wrote_value = null ): void {214 function seojuice_record_field_version(int $post_id, int $cs_id, string $field, $before_value, $wrote_value = null, array $meta_before = array(), array $meta_wrote = array()): void { 215 215 $h = seojuice_get_history($post_id); 216 216 $entry = array( 217 'cs' => $cs_id, 'ts' => time(), 'before' => array(),217 'cs' => $cs_id, 'ts' => time(), 'before' => $meta_before, 218 218 'before_fields' => array($field => array('value' => $before_value)), 219 219 ); 220 220 if ($wrote_value !== null) { $entry['wrote_fields'] = array($field => $wrote_value); } 221 if (!empty($meta_wrote)) { $entry['wrote'] = $meta_wrote; } 221 222 $h[] = $entry; 222 223 seojuice_update_post_meta($post_id, '_seojuice_history', wp_json_encode($h)); … … 247 248 } 248 249 250 // Title variables an SEO plugin template can hold; a pin replaces them with the old post_title. 251 function seojuice_title_template_vars(): array { 252 return array('%%title%%', '%title%', '%%post_title%%'); 253 } 254 255 // A history entry written by a heading change: it records post_title in before_fields. 256 function seojuice_history_is_heading(array $e): bool { 257 return ($e['type'] ?? '') !== 'content_diff' 258 && isset($e['before_fields']['post_title']) && is_array($e['before_fields']['post_title']); 259 } 260 261 // A search-title rollback hands its key to the heading entries that passed it through 262 // (spec 2.4). Those entries recorded the title key with before equal to this entry's wrote. 263 // Leaving the key as it is, or restoring a value that is empty or built from post_title, 264 // would let the heading show in the search title. So the key gets the pin the heading 265 // needs (the restored template with the old post_title), and the heading entries take it 266 // over: the first one restores the title's before, so rolling it back removes the pin. 267 // Returns false when no heading entry follows, so the plain splice rules apply. 268 function seojuice_revert_title_into_headings(int $post_id, array &$h, int $i, string $key, array $rec, string $s_wrote, array &$key_out): bool { 269 $chain = array(); 270 $expected = $s_wrote; 271 $cs_id = (int) ($h[$i]['cs'] ?? 0); 272 $follow = null; 273 for ($j = $i + 1, $n = count($h); $j < $n; $j++) { 274 $e = $h[$j]; 275 if ((int) ($e['cs'] ?? 0) === $cs_id || !isset($e['before'][$key])) { continue; } 276 $b = $e['before'][$key]; 277 $passes = seojuice_history_is_heading($e) && !empty($b['present']) 278 && (string) ($b['value'] ?? '') === $expected 279 && isset($e['wrote']) && is_array($e['wrote']) && array_key_exists($key, $e['wrote']); 280 if (!$passes) { $follow = $j; break; } 281 $chain[] = $j; 282 $expected = (string) $e['wrote'][$key]; 283 } 284 if (!$chain) { return false; } 285 286 if ($follow === null && (string) get_post_meta($post_id, $key, true) !== $expected) { 287 $key_out[$key] = 'kept_edit'; 288 return true; 289 } 290 $rec_val = (string) ($rec['value'] ?? ''); 291 $final = array('present' => !empty($rec['present']), 'value' => $rec_val); 292 $tpl = $rec_val; 293 if ($tpl === '' && function_exists('seojuice_default_title_template')) { 294 $post = get_post($post_id); 295 $tpl = seojuice_default_title_template($key, $post ? (string) ($post->post_type ?? 'post') : 'post'); 296 } 297 $has_var = false; 298 foreach (seojuice_title_template_vars() as $v) { if (strpos($tpl, $v) !== false) { $has_var = true; break; } } 299 if ($has_var) { 300 $old = (string) ($h[$chain[0]]['before_fields']['post_title']['value'] ?? ''); 301 $final = array('present' => true, 'value' => str_replace(seojuice_title_template_vars(), $old, $tpl)); 302 } 303 foreach ($chain as $n => $j) { 304 $h[$j]['before'][$key] = $n === 0 ? $rec : $final; 305 $h[$j]['wrote'][$key] = $final['value']; 306 } 307 if ($follow !== null) { 308 $fb = $h[$follow]['before'][$key]; 309 if (!empty($fb['present']) && (string) ($fb['value'] ?? '') === $expected) { $h[$follow]['before'][$key] = $final; } 310 return true; 311 } 312 if ($final['present']) { seojuice_update_post_meta($post_id, $key, $final['value']); $key_out[$key] = 'restored'; } 313 else { delete_post_meta($post_id, $key); $key_out[$key] = 'absent_restored'; } 314 return true; 315 } 316 317 // Without an SEO plugin a heading is held by the pin key. When no search title is live 318 // after a rollback and a heading entry never wrote its pin (it was applied while a 319 // search title overrode the title), write the pin now: the pre-heading post_title. 320 function seojuice_guard_core_pin(int $post_id, array &$h): void { 321 $pin = '_seojuice_h1_pin'; 322 if ((string) get_post_meta($post_id, '_seojuice_seo_title', true) !== '' 323 || (string) get_post_meta($post_id, $pin, true) !== '') { return; } 324 $open = array(); 325 foreach ($h as $j => $e) { 326 if (seojuice_history_is_heading($e) && isset($e['wrote'][$pin]) && (string) $e['wrote'][$pin] === '') { $open[] = $j; } 327 } 328 if (!$open) { return; } 329 $old = (string) ($h[$open[0]]['before_fields']['post_title']['value'] ?? ''); 330 if ($old === '') { return; } 331 seojuice_update_post_meta($post_id, $pin, $old); 332 foreach ($open as $n => $j) { 333 $h[$j]['wrote'][$pin] = $old; 334 if ($n > 0) { $h[$j]['before'][$pin] = array('present' => true, 'value' => $old); } 335 } 336 } 337 338 // Returns skipped (keys and fields left because the client changed them), content_diff, 339 // outcome, and key_outcomes: key or field => restored | kept_edit | absent_restored. 340 // A key handed to a later live change has no outcome: it stays as that change left it. 249 341 function seojuice_revert_changeset(int $post_id, int $cs_id): array { 250 342 $h = seojuice_get_history($post_id); 251 343 $drop = array(); 252 344 $skipped = array(); 345 $key_out = array(); 346 $own_title_touched = false; 253 347 $content_diff_handled = false; 254 348 $outcome = null; … … 270 364 if (!$res['reverted'] && !empty($res['skipped'])) { $skipped[] = 'content'; } 271 365 if (isset($res['outcome'])) { $outcome = $res['outcome']; } 366 $co = (string) ($res['outcome'] ?? ''); 367 if (in_array($co, array('drift_kept', 'marker_stripped'), true)) { $key_out['content'] = 'kept_edit'; } 368 elseif (!empty($res['reverted'])) { $key_out['content'] = 'restored'; } 272 369 } 273 370 } … … 277 374 $wrote = (isset($entry['wrote']) && is_array($entry['wrote'])) ? $entry['wrote'] : null; 278 375 foreach (($entry['before'] ?? array()) as $key => $rec) { 376 if ($key === '_seojuice_seo_title') { $own_title_touched = true; } 377 // A search title followed by heading entries that passed its key through. 378 if ($wrote !== null && array_key_exists($key, $wrote) && !seojuice_history_is_heading($entry) 379 && seojuice_revert_title_into_headings($post_id, $h, $i, (string) $key, $rec, (string) $wrote[$key], $key_out)) { 380 continue; 381 } 279 382 // Only an entry that proves what it wrote may chain: when the next 280 383 // entry's before is that write, hand it this before value. When a client … … 293 396 && (string) get_post_meta($post_id, $key, true) !== (string) $wrote[$key]) { 294 397 $skipped[] = $key; 398 $key_out[$key] = 'kept_edit'; 295 399 continue; 296 400 } 297 if (!empty($rec['present'])) { seojuice_update_post_meta($post_id, $key, $rec['value']); }298 else { delete_post_meta($post_id, $key); }401 if (!empty($rec['present'])) { seojuice_update_post_meta($post_id, $key, $rec['value']); $key_out[$key] = 'restored'; } 402 else { delete_post_meta($post_id, $key); $key_out[$key] = 'absent_restored'; } 299 403 } 300 404 $wf = (isset($entry['wrote_fields']) && is_array($entry['wrote_fields'])) ? $entry['wrote_fields'] : null; … … 311 415 $post = get_post($post_id); 312 416 $cur = ($post && $field === 'post_title') ? (string) $post->post_title : ''; 313 if ($cur !== (string) $wf[$field]) { $skipped[] = $field; continue; }417 if ($cur !== (string) $wf[$field]) { $skipped[] = $field; $key_out[$field] = 'kept_edit'; continue; } 314 418 } 315 419 seojuice_update_post(array('ID' => $post_id, $field => $rec['value'])); 420 $key_out[$field] = 'restored'; 316 421 } 317 422 } … … 320 425 if (!isset($drop[$i])) { $kept[] = $entry; } 321 426 } 427 if ($own_title_touched) { seojuice_guard_core_pin($post_id, $kept); } 322 428 seojuice_update_post_meta($post_id, '_seojuice_history', wp_json_encode($kept)); 323 429 if (!$blocked) { seojuice_drop_persisted($post_id, $cs_id); } 324 return array('skipped' => $skipped, 'content_diff' => $content_diff_handled, 'outcome' => $outcome );430 return array('skipped' => $skipped, 'content_diff' => $content_diff_handled, 'outcome' => $outcome, 'key_outcomes' => $key_out); 325 431 } 326 432 -
seojuice/trunk/readme.txt
r3761416 r3763326 2 2 Contributors: seojuice 3 3 Tags: seo, internal links, ai search, meta tags, visibility tracking 4 Stable tag: 2. 8.14 Stable tag: 2.9.0 5 5 Requires at least: 6.2 6 6 Tested up to: 7.1 … … 138 138 139 139 == Changelog == 140 141 = 2.9.0 = 142 * New: approved page headings can be saved in WordPress, with their own confirmation. Your search title stays as it is. 143 * New: Roll back all on a page puts it back as it was before SEOJuice. 144 * Improved: rollback tells you when it kept an edit you made yourself. 140 145 141 146 = 2.8.1 = … … 320 325 == Upgrade Notice == 321 326 327 = 2.9.0 = 328 Approved page headings can now be saved in WordPress, and Roll back all puts a page back as it was before SEOJuice. 329 322 330 = 2.8.1 = 323 331 Rollback fix: rolling back SEOJuice changes in any order now restores the right value and keeps your own edits. -
seojuice/trunk/seojuice.php
r3761416 r3763326 4 4 Description: SEOJuice automated on-page SEO optimization with server-side rendering for better performance and crawlability. 5 5 Author: Vadim Kravcenko 6 Version: 2. 8.16 Version: 2.9.0 7 7 Author URI: https://seojuice.io 8 8 License: GPLv2 … … 44 44 // is_ssl() actually cached under. Adds GET /front-page (page_on_front lookup) 45 45 // so meta_update changes targeting the homepage can resolve a post id. 46 const SEOJUICE_PLUGIN_VERSION = '2. 8.1';46 const SEOJUICE_PLUGIN_VERSION = '2.9.0'; 47 47 // API base defaults to production. Overridable (before the plugin loads, e.g. 48 48 // via wp-config.php or an mu-plugin) or through the SEOJUICE_API_BASE env var, … … 2045 2045 2046 2046 function seojuice_read_seo_title($post_id) { 2047 if (seojuice_pin_capable() === 'core') { 2048 return (string) get_post_meta($post_id, SEOJUICE_OWN_TITLE_KEY, true); 2049 } 2047 2050 foreach (seojuice_active_seo_title_keys() as $k) { 2048 2051 $v = (string) get_post_meta($post_id, $k, true); … … 2052 2055 } 2053 2056 2057 const SEOJUICE_OWN_TITLE_KEY = '_seojuice_seo_title'; 2058 const SEOJUICE_PIN_KEY = '_seojuice_h1_pin'; 2059 const SEOJUICE_TITLE_PRIORITY = 15; 2060 2061 // True when a title/SEO plugin we cannot write for controls the document title. 2062 function seojuice_unsupported_title_owner_active() { 2063 return defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO') 2064 || function_exists('the_seo_framework') || defined('THE_SEO_FRAMEWORK_VERSION') 2065 || defined('SLIM_SEO_VER'); 2066 } 2067 2068 // True when a callback that is not ours sits on $tag at a priority above $min_prio 2069 // (null = any priority). 2070 // WordPress core formats document_title with these (default-filters.php); they never replace it. 2071 const SEOJUICE_CORE_TITLE_FORMATTERS = array('wptexturize', 'convert_chars', 'esc_html', 'capital_P_dangit'); 2072 2073 function seojuice_foreign_title_callback($tag, $min_prio = null) { 2074 global $wp_filter; 2075 if (empty($wp_filter[$tag])) { return false; } 2076 $h = $wp_filter[$tag]; 2077 $cbs = is_object($h) && isset($h->callbacks) ? $h->callbacks : (is_array($h) ? $h : array()); 2078 foreach ($cbs as $prio => $fns) { 2079 if ($min_prio !== null && (int) $prio <= $min_prio) { continue; } 2080 foreach ((array) $fns as $fn) { 2081 $f = is_array($fn) && isset($fn['function']) ? $fn['function'] : $fn; 2082 if (is_string($f) && (strpos($f, 'seojuice_') === 0 || in_array($f, SEOJUICE_CORE_TITLE_FORMATTERS, true))) { continue; } 2083 return true; 2084 } 2085 } 2086 return false; 2087 } 2088 2089 // A foreign pre_get_document_title or document_title callback at any priority, or a 2090 // document_title_parts callback after ours, can replace what we render. 2091 function seojuice_foreign_title_conflict() { 2092 return seojuice_foreign_title_callback('pre_get_document_title') 2093 || seojuice_foreign_title_callback('document_title') 2094 || seojuice_foreign_title_callback('document_title_parts', SEOJUICE_TITLE_PRIORITY); 2095 } 2096 2097 // Front-end verdict: $wp_filter during a REST apply can miss filters added only on 2098 // front-end hooks, so a rendered singular request records what it saw. It runs at 2099 // wp_head priority 0 so it also sees title filters added on template_redirect or wp_head. 2100 function seojuice_record_title_render_conflict() { 2101 if (!empty($GLOBALS['seojuice_title_conflict_checked']) || is_admin() || is_feed() || !is_singular()) { return; } 2102 $GLOBALS['seojuice_title_conflict_checked'] = true; 2103 $conflict = seojuice_foreign_title_conflict(); 2104 $cur = get_option('seojuice_title_render_conflict', null); 2105 if (is_array($cur) && !empty($cur['conflict']) === $conflict) { return; } 2106 update_option('seojuice_title_render_conflict', array('conflict' => $conflict, 'at' => time()), false); 2107 } 2108 add_action('wp_head', 'seojuice_record_title_render_conflict', 0); 2109 2110 // 'seo_plugin' | 'core' | 'unsupported'. Spec 2.2a pin-proven gate. 2111 function seojuice_pin_capable() { 2112 if (seojuice_unsupported_title_owner_active()) { return 'unsupported'; } 2113 if (seojuice_active_seo_title_keys()) { return 'seo_plugin'; } 2114 if (!function_exists('current_theme_supports') || !current_theme_supports('title-tag')) { return 'unsupported'; } 2115 if (seojuice_foreign_title_conflict()) { return 'unsupported'; } 2116 $rc = get_option('seojuice_title_render_conflict', null); 2117 if (is_array($rc) && !empty($rc['conflict'])) { return 'unsupported'; } 2118 return 'core'; 2119 } 2120 2121 function seojuice_pin_key() { 2122 $keys = seojuice_active_seo_title_keys(); 2123 return $keys ? $keys[0] : SEOJUICE_PIN_KEY; 2124 } 2125 2126 // Returns the post meta string for $key on a singular front-end view of its post, or ''. 2127 function seojuice_title_meta_for_view($key) { 2128 if (is_admin() || is_feed() || !is_singular()) { return ''; } 2129 $id = (int) get_queried_object_id(); 2130 if ($id <= 0) { return ''; } 2131 return (string) get_post_meta($id, $key, true); 2132 } 2133 2134 function seojuice_filter_pre_document_title($title) { 2135 $v = seojuice_title_meta_for_view(SEOJUICE_OWN_TITLE_KEY); 2136 if ($v === '' || seojuice_pin_capable() !== 'core') { return $title; } 2137 return $v; 2138 } 2139 add_filter('pre_get_document_title', 'seojuice_filter_pre_document_title', SEOJUICE_TITLE_PRIORITY); 2140 2141 function seojuice_filter_document_title_parts($parts) { 2142 // Core builds a static front page title from site name and tagline, not post_title. 2143 if (is_front_page()) { return $parts; } 2144 if (seojuice_title_meta_for_view(SEOJUICE_OWN_TITLE_KEY) !== '') { return $parts; } 2145 $pin = seojuice_title_meta_for_view(SEOJUICE_PIN_KEY); 2146 if ($pin === '' || seojuice_pin_capable() !== 'core') { return $parts; } 2147 if (!is_array($parts)) { $parts = array(); } 2148 $parts['title'] = $pin; 2149 return $parts; 2150 } 2151 add_filter('document_title_parts', 'seojuice_filter_document_title_parts', SEOJUICE_TITLE_PRIORITY); 2152 2054 2153 function seojuice_capabilities() { 2055 $caps = array('persistent_apply', 'footer_backlink', 'url_replace', 'cache_clear', 'apply_reasons', 'content_save_guard', 'seo_title' );2154 $caps = array('persistent_apply', 'footer_backlink', 'url_replace', 'cache_clear', 'apply_reasons', 'content_save_guard', 'seo_title', 'headline'); 2056 2155 if (SEOJUICE_CAP_ALT_INLINE) { 2057 2156 $caps[] = 'alt_inline'; … … 2521 2620 'title' => $post->post_title, 2522 2621 'seo_title' => seojuice_read_seo_title($post_id), 2622 'h1_raw' => seojuice_read_h1_raw($post), 2523 2623 'status' => $post->post_status, 2524 2624 'url' => get_permalink($post->ID), … … 3590 3690 } 3591 3691 3692 // --------------------------------------------------------------------------- 3693 // headline: change the page heading (H1) without moving the search title. 3694 // --------------------------------------------------------------------------- 3695 3696 // Comparison form of a heading: tags stripped, entities decoded, typographic 3697 // quotes/dashes folded to ASCII, whitespace collapsed. 3698 function seojuice_headline_norm($s): string { 3699 $s = html_entity_decode(strip_tags((string) $s), ENT_QUOTES | ENT_HTML5, 'UTF-8'); 3700 $s = strtr($s, array( 3701 "\u{2018}" => "'", "\u{2019}" => "'", "\u{201B}" => "'", "\u{2032}" => "'", 3702 "\u{201C}" => '"', "\u{201D}" => '"', "\u{201E}" => '"', "\u{2033}" => '"', 3703 "\u{2013}" => '-', "\u{2014}" => '-', "\u{2026}" => '...', "\u{00A0}" => ' ', 3704 )); 3705 return trim((string) preg_replace('/\s+/u', ' ', $s)); 3706 } 3707 3708 // Parses a body into a wrapper element; null on failure. 3709 function seojuice_headline_parse(string $content): ?array { 3710 $dom = new DOMDocument(); 3711 $prev = libxml_use_internal_errors(true); 3712 $dom->loadHTML('<?xml encoding="UTF-8" ?><div id="seojuice-root">' . $content . '</div>', 3713 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); 3714 libxml_clear_errors(); libxml_use_internal_errors($prev); 3715 $root = $dom->getElementById('seojuice-root'); 3716 return $root ? array($dom, $root) : null; 3717 } 3718 3719 function seojuice_elementor_has_h1($nodes, array $wanted): bool { 3720 if (!is_array($nodes)) { return false; } 3721 foreach ($nodes as $n) { 3722 if (!is_array($n)) { continue; } 3723 if (($n['widgetType'] ?? '') === 'heading' 3724 && strtolower((string) ($n['settings']['header_size'] ?? '')) === 'h1' 3725 && isset($wanted[seojuice_headline_norm($n['settings']['title'] ?? '')])) { 3726 return true; 3727 } 3728 if (!empty($n['elements']) && seojuice_elementor_has_h1($n['elements'], $wanted)) { return true; } 3729 } 3730 return false; 3731 } 3732 3733 // True when a page builder owns the visible H1: writing post_title there would rename 3734 // menus and leave the heading unchanged. 3735 function seojuice_headline_in_builder(int $post_id, string $content, array $wanted): bool { 3736 if (get_post_meta($post_id, '_fl_builder_enabled', true)) { return true; } 3737 $el = get_post_meta($post_id, '_elementor_data', true); 3738 if (is_string($el) && $el !== '') { $el = json_decode($el, true); } 3739 if (seojuice_elementor_has_h1($el, $wanted)) { return true; } 3740 if (strpos($content, '[vc_custom_heading') !== false 3741 && preg_match_all('/\[vc_custom_heading\b([^\]]*)\]/', $content, $sc)) { 3742 foreach ($sc[1] as $attrs) { 3743 if (!preg_match_all('/(\w+)\s*=\s*"([^"]*)"/', $attrs, $kv, PREG_SET_ORDER)) { continue; } 3744 $a = array(); 3745 foreach ($kv as $pair) { $a[$pair[1]] = $pair[2]; } 3746 $fc = urldecode($a['font_container'] ?? ''); 3747 if (preg_match('/(^|\|)tag:h1(\||$)/i', $fc) && isset($wanted[seojuice_headline_norm(urldecode($a['text'] ?? ''))])) { 3748 return true; 3749 } 3750 } 3751 } 3752 return false; 3753 } 3754 3755 // Content <h1> elements whose text matches $wanted: array('count' => n, 'text' => raw text 3756 // of the single match, 'targetable' => the diff engine would edit that very element). 3757 function seojuice_headline_content_h1(string $content, array $wanted): array { 3758 $out = array('count' => 0, 'text' => '', 'targetable' => false); 3759 $parsed = seojuice_headline_parse($content); 3760 if (!$parsed) { return $out; } 3761 list($dom, $root) = $parsed; 3762 $match = null; 3763 foreach ($dom->getElementsByTagName('h1') as $h) { 3764 if (isset($wanted[seojuice_headline_norm($h->textContent)])) { $out['count']++; $match = $h; } 3765 } 3766 if ($out['count'] !== 1) { return $out; } 3767 $out['text'] = $match->textContent; 3768 $t = seojuice_locate_diff_target($dom, $root, 'h1', $out['text']); 3769 for ($n = $t; $n instanceof DOMNode; $n = $n->parentNode) { 3770 if ($n->isSameNode($match)) { $out['targetable'] = true; break; } 3771 } 3772 return $out; 3773 } 3774 3775 // Raw text the page shows as its H1: the single content <h1>, else raw post_title. 3776 // Never get_the_title(), which wptexturize alters. 3777 function seojuice_read_h1_raw($post): string { 3778 $parsed = seojuice_headline_parse((string) $post->post_content); 3779 if ($parsed) { 3780 $h1s = $parsed[0]->getElementsByTagName('h1'); 3781 if ($h1s->length === 1) { return trim((string) $h1s->item(0)->textContent); } 3782 } 3783 return (string) $post->post_title; 3784 } 3785 3786 function seojuice_default_title_template(string $key, string $post_type): string { 3787 $pt = $post_type !== '' ? $post_type : 'post'; 3788 if ($key === '_yoast_wpseo_title') { 3789 $o = get_option('wpseo_titles', array()); 3790 $t = is_array($o) && isset($o['title-' . $pt]) ? (string) $o['title-' . $pt] : ''; 3791 return $t !== '' ? $t : '%%title%% %%page%% %%sep%% %%sitename%%'; 3792 } 3793 if ($key === 'rank_math_title') { 3794 $o = get_option('rank_math_titles', array()); 3795 $t = is_array($o) && isset($o['pt_' . $pt . '_title']) ? (string) $o['pt_' . $pt . '_title'] : ''; 3796 return $t !== '' ? $t : '%title% %sep% %sitename%'; 3797 } 3798 $o = get_option('seopress_titles_option_name', array()); 3799 $t = is_array($o) && isset($o['seopress_titles_single_titles'][$pt]['title']) 3800 ? (string) $o['seopress_titles_single_titles'][$pt]['title'] : ''; 3801 return $t !== '' ? $t : '%%post_title%% - %%sitetitle%%'; 3802 } 3803 3804 // Pin values to hold the search title steady across a post_title write: key => value 3805 // to store. A value equal to the live one means no pin is needed (or one already holds). 3806 function seojuice_headline_pin_values(int $post_id, string $mode, string $old_title, string $post_type): array { 3807 $vars = array('%%title%%', '%title%', '%%post_title%%'); 3808 $out = array(); 3809 if ($mode === 'core') { 3810 $cur = (string) get_post_meta($post_id, SEOJUICE_PIN_KEY, true); 3811 $own = (string) get_post_meta($post_id, SEOJUICE_OWN_TITLE_KEY, true); 3812 // An earlier pin already holds the pre-heading title; an approved search title 3813 // overrides the whole <title>, so the pin is not needed while it is live. 3814 $out[SEOJUICE_PIN_KEY] = ($cur !== '' || $own !== '') ? $cur : $old_title; 3815 return $out; 3816 } 3817 foreach (seojuice_active_seo_title_keys() as $key) { 3818 $cur = (string) get_post_meta($post_id, $key, true); 3819 $tpl = $cur !== '' ? $cur : seojuice_default_title_template($key, $post_type); 3820 $has = false; 3821 foreach ($vars as $v) { if (strpos($tpl, $v) !== false) { $has = true; break; } } 3822 $out[$key] = $has ? str_replace($vars, $old_title, $tpl) : $cur; 3823 } 3824 return $out; 3825 } 3826 3827 // Applies one `headline` change. Returns array('result', 'reason', 'target', 'content', 3828 // 'content_dirty'). Resolution order: page builder, single content <h1>, raw post_title. 3829 function seojuice_apply_headline(int $post_id, string $content, int $cs_id, array $payload, bool $save_unsafe): array { 3830 $res = array('result' => 'skipped', 'reason' => '', 'target' => '', 'content' => $content, 'content_dirty' => false); 3831 $new = isset($payload['h1']) && is_string($payload['h1']) ? sanitize_text_field($payload['h1']) : ''; 3832 $wanted = array(); 3833 foreach (array('expected_h1', 'crawled_h1') as $k) { 3834 $n = isset($payload[$k]) && is_string($payload[$k]) ? seojuice_headline_norm($payload[$k]) : ''; 3835 if ($n !== '') { $wanted[$n] = true; } 3836 } 3837 if ($new === '' || !$wanted) { $res['reason'] = 'invalid_payload'; return $res; } 3838 if (seojuice_headline_in_builder($post_id, $content, $wanted)) { $res['reason'] = 'h1_in_page_builder'; return $res; } 3839 3840 $h1 = seojuice_headline_content_h1($content, $wanted); 3841 if ($h1['count'] === 1) { 3842 if (!$h1['targetable']) { $res['reason'] = 'unlocatable'; return $res; } 3843 if ($save_unsafe) { $res['reason'] = 'content_unsafe_to_save'; return $res; } 3844 $d = seojuice_apply_content_diff($content, $cs_id, array( 3845 'location_dom' => 'h1', 'original_text' => $h1['text'], 'operation' => 'replace', 3846 'text' => htmlspecialchars($new, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), 3847 )); 3848 if (!$d['applied']) { $res['reason'] = 'unlocatable'; return $res; } 3849 seojuice_record_content_diff_version($post_id, $cs_id, $d['before']); 3850 return array('result' => 'applied', 'reason' => '', 'target' => 'content_h1', 3851 'content' => $d['content'], 'content_dirty' => true); 3852 } 3853 $post = get_post($post_id); 3854 if ($h1['count'] > 1 || !$post || !isset($wanted[seojuice_headline_norm($post->post_title)])) { 3855 $res['reason'] = 'h1_not_found'; 3856 return $res; 3857 } 3858 3859 // A static posts page builds its <title> from single_post_title(), so no pin renders. 3860 $mode = seojuice_pin_capable(); 3861 if ((int) get_option('page_for_posts', 0) === $post_id || $mode === 'unsupported') { 3862 $res['reason'] = 'title_would_change'; 3863 return $res; 3864 } 3865 // wp_update_post re-saves the stored body through the save filters, so a body kses would strip must not be touched. 3866 if ($save_unsafe) { $res['reason'] = 'content_unsafe_to_save'; return $res; } 3867 $old_title = (string) $post->post_title; 3868 $pins = seojuice_headline_pin_values($post_id, $mode, $old_title, (string) ($post->post_type ?? 'post')); 3869 $before = array(); $wrote = array(); 3870 foreach ($pins as $key => $value) { 3871 $present = metadata_exists('post', $post_id, $key); 3872 $cur = (string) get_post_meta($post_id, $key, true); 3873 $before[$key] = array('present' => $present, 'value' => $cur); 3874 if ($value !== $cur) { seojuice_update_post_meta($post_id, $key, $value); } 3875 $wrote[$key] = $value; 3876 } 3877 $updated = seojuice_update_post(array('ID' => $post_id, 'post_title' => $new), true); 3878 if (is_wp_error($updated) || !$updated) { 3879 foreach ($before as $key => $b) { 3880 if ($b['present']) { seojuice_update_post_meta($post_id, $key, $b['value']); } 3881 else { delete_post_meta($post_id, $key); } 3882 } 3883 $res['reason'] = 'update_failed'; 3884 return $res; 3885 } 3886 $after = get_post($post_id); 3887 $wrote_title = $after ? (string) $after->post_title : $new; 3888 seojuice_record_field_version($post_id, $cs_id, 'post_title', $old_title, $wrote_title, $before, $wrote); 3889 $res['result'] = 'applied'; 3890 $res['target'] = 'post_title'; 3891 return $res; 3892 } 3893 3592 3894 function seojuice_rest_apply_changes($request) { 3593 3895 $post_id = intval($request->get_param('id')); … … 3613 3915 $results = array(); 3614 3916 $reasons = array(); 3917 $targets = array(); 3615 3918 $persisted_ids = array(); 3616 3919 $alt_commits = array(); … … 3705 4008 } elseif ($type === 'seo_title') { 3706 4009 $new = isset($payload['title']) ? sanitize_text_field($payload['title']) : ''; 3707 $keys = seojuice_active_seo_title_keys(); 4010 $mode = seojuice_pin_capable(); 4011 $keys = $mode === 'core' ? array(SEOJUICE_OWN_TITLE_KEY) : seojuice_active_seo_title_keys(); 3708 4012 if ($new === '') { 3709 4013 $results[(string) $cs_id] = 'skipped'; … … 3724 4028 $persisted_ids[] = $cs_id; 3725 4029 } 4030 } elseif ($type === 'headline') { 4031 if ($save_unsafe === null) { 4032 $save_unsafe = seojuice_save_would_strip_content((string) $owned->post_content); 4033 } 4034 $res = seojuice_apply_headline($post_id, $content, $cs_id, $payload, $save_unsafe); 4035 $results[(string) $cs_id] = $res['result']; 4036 if ($res['result'] === 'applied') { 4037 $content = $res['content']; 4038 $content_dirty = $content_dirty || $res['content_dirty']; 4039 $targets[(string) $cs_id] = $res['target']; 4040 $persisted_ids[] = $cs_id; 4041 } else { 4042 $reasons[(string) $cs_id] = $res['reason']; 4043 } 3726 4044 } elseif ($type === 'og') { 3727 4045 $map = array( … … 3831 4149 } 3832 4150 3833 return new WP_REST_Response(array('success' => true, 'results' => $results, 'reasons' => $reasons ), 200);4151 return new WP_REST_Response(array('success' => true, 'results' => $results, 'reasons' => $reasons, 'targets' => $targets), 200); 3834 4152 } 3835 4153 … … 3848 4166 $reverted = array(); 3849 4167 $outcomes = array(); 4168 $key_outcomes = array(); 3850 4169 3851 4170 foreach ($ids as $cs_id) { … … 3902 4221 $outcome = $res['outcome'] ?? 'absent'; 3903 4222 } 4223 // Per-key results for title, heading and meta changes; their change-level 4224 // outcome was always 'absent' before, which hid a kept client edit. 4225 $ko = (isset($res['key_outcomes']) && is_array($res['key_outcomes'])) ? $res['key_outcomes'] : array(); 4226 if ($ko) { 4227 $key_outcomes[(string) $cs_id] = $ko; 4228 if ($outcome === null || $outcome === 'absent') { 4229 $outcome = in_array('kept_edit', $ko, true) ? 'drift_kept' : 'restored'; 4230 } 4231 } 3904 4232 if ($blocked) { 3905 4233 $outcome = 'blocked'; … … 3916 4244 } 3917 4245 3918 return new WP_REST_Response(array('success' => true, 'reverted' => $reverted, 'outcomes' => $outcomes ), 200);4246 return new WP_REST_Response(array('success' => true, 'reverted' => $reverted, 'outcomes' => $outcomes, 'key_outcomes' => $key_outcomes), 200); 3919 4247 } 3920 4248
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)