Plugin Directory

Changeset 3763326


Ignore:
Timestamp:
10/24/2026 01:29:26 PM (9 hours ago)
Author:
seojuice
Message:

Release 2.9.0: save approved headings, roll back all

Location:
seojuice
Files:
6 edited
1 copied

Legend:

Unmodified
Added
Removed
  • seojuice/tags/2.9.0/includes/footprint.php

    r3761416 r3763326  
    212212}
    213213
    214 function seojuice_record_field_version(int $post_id, int $cs_id, string $field, $before_value, $wrote_value = null): void {
     214function 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 {
    215215    $h = seojuice_get_history($post_id);
    216216    $entry = array(
    217         'cs' => $cs_id, 'ts' => time(), 'before' => array(),
     217        'cs' => $cs_id, 'ts' => time(), 'before' => $meta_before,
    218218        'before_fields' => array($field => array('value' => $before_value)),
    219219    );
    220220    if ($wrote_value !== null) { $entry['wrote_fields'] = array($field => $wrote_value); }
     221    if (!empty($meta_wrote)) { $entry['wrote'] = $meta_wrote; }
    221222    $h[] = $entry;
    222223    seojuice_update_post_meta($post_id, '_seojuice_history', wp_json_encode($h));
    … …  
    247248}
    248249
     250// Title variables an SEO plugin template can hold; a pin replaces them with the old post_title.
     251function 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.
     256function 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.
     268function 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.
     320function 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.
    249341function seojuice_revert_changeset(int $post_id, int $cs_id): array {
    250342    $h = seojuice_get_history($post_id);
    251343    $drop = array();
    252344    $skipped = array();
     345    $key_out = array();
     346    $own_title_touched = false;
    253347    $content_diff_handled = false;
    254348    $outcome = null;
    … …  
    270364                if (!$res['reverted'] && !empty($res['skipped'])) { $skipped[] = 'content'; }
    271365                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'; }
    272369            }
    273370        }
    … …  
    277374        $wrote = (isset($entry['wrote']) && is_array($entry['wrote'])) ? $entry['wrote'] : null;
    278375        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            }
    279382            // Only an entry that proves what it wrote may chain: when the next
    280383            // entry's before is that write, hand it this before value. When a client
    … …  
    293396                && (string) get_post_meta($post_id, $key, true) !== (string) $wrote[$key]) {
    294397                $skipped[] = $key;
     398                $key_out[$key] = 'kept_edit';
    295399                continue;
    296400            }
    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'; }
    299403        }
    300404        $wf = (isset($entry['wrote_fields']) && is_array($entry['wrote_fields'])) ? $entry['wrote_fields'] : null;
    … …  
    311415                $post = get_post($post_id);
    312416                $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; }
    314418            }
    315419            seojuice_update_post(array('ID' => $post_id, $field => $rec['value']));
     420            $key_out[$field] = 'restored';
    316421        }
    317422    }
    … …  
    320425        if (!isset($drop[$i])) { $kept[] = $entry; }
    321426    }
     427    if ($own_title_touched) { seojuice_guard_core_pin($post_id, $kept); }
    322428    seojuice_update_post_meta($post_id, '_seojuice_history', wp_json_encode($kept));
    323429    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);
    325431}
    326432
  • seojuice/tags/2.9.0/readme.txt

    r3761416 r3763326  
    22Contributors: seojuice
    33Tags: seo, internal links, ai search, meta tags, visibility tracking
    4 Stable tag: 2.8.1
     4Stable tag: 2.9.0
    55Requires at least: 6.2
    66Tested up to: 7.1
    … …  
    138138
    139139== 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.
    140145
    141146= 2.8.1 =
    … …  
    320325== Upgrade Notice ==
    321326
     327= 2.9.0 =
     328Approved page headings can now be saved in WordPress, and Roll back all puts a page back as it was before SEOJuice.
     329
    322330= 2.8.1 =
    323331Rollback 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  
    44Description: SEOJuice automated on-page SEO optimization with server-side rendering for better performance and crawlability.
    55Author: Vadim Kravcenko
    6 Version: 2.8.1
     6Version: 2.9.0
    77Author URI: https://seojuice.io
    88License: GPLv2
    … …  
    4444//   is_ssl() actually cached under. Adds GET /front-page (page_on_front lookup)
    4545//   so meta_update changes targeting the homepage can resolve a post id.
    46 const SEOJUICE_PLUGIN_VERSION = '2.8.1';
     46const SEOJUICE_PLUGIN_VERSION = '2.9.0';
    4747// API base defaults to production. Overridable (before the plugin loads, e.g.
    4848// via wp-config.php or an mu-plugin) or through the SEOJUICE_API_BASE env var,
    … …  
    20452045
    20462046function 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    }
    20472050    foreach (seojuice_active_seo_title_keys() as $k) {
    20482051        $v = (string) get_post_meta($post_id, $k, true);
    … …  
    20522055}
    20532056
     2057const SEOJUICE_OWN_TITLE_KEY = '_seojuice_seo_title';
     2058const SEOJUICE_PIN_KEY       = '_seojuice_h1_pin';
     2059const SEOJUICE_TITLE_PRIORITY = 15;
     2060
     2061// True when a title/SEO plugin we cannot write for controls the document title.
     2062function 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.
     2071const SEOJUICE_CORE_TITLE_FORMATTERS = array('wptexturize', 'convert_chars', 'esc_html', 'capital_P_dangit');
     2072
     2073function 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.
     2091function 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.
     2100function 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}
     2108add_action('wp_head', 'seojuice_record_title_render_conflict', 0);
     2109
     2110// 'seo_plugin' | 'core' | 'unsupported'. Spec 2.2a pin-proven gate.
     2111function 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
     2121function 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 ''.
     2127function 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
     2134function 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}
     2139add_filter('pre_get_document_title', 'seojuice_filter_pre_document_title', SEOJUICE_TITLE_PRIORITY);
     2140
     2141function 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}
     2151add_filter('document_title_parts', 'seojuice_filter_document_title_parts', SEOJUICE_TITLE_PRIORITY);
     2152
    20542153function 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');
    20562155    if (SEOJUICE_CAP_ALT_INLINE) {
    20572156        $caps[] = 'alt_inline';
    … …  
    25212620            'title' => $post->post_title,
    25222621            'seo_title' => seojuice_read_seo_title($post_id),
     2622            'h1_raw' => seojuice_read_h1_raw($post),
    25232623            'status' => $post->post_status,
    25242624            'url' => get_permalink($post->ID),
    … …  
    35903690}
    35913691
     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.
     3698function 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.
     3709function 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
     3719function 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.
     3735function 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).
     3757function 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.
     3777function 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
     3786function 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).
     3806function 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.
     3829function 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
    35923894function seojuice_rest_apply_changes($request) {
    35933895    $post_id = intval($request->get_param('id'));
    … …  
    36133915    $results      = array();
    36143916    $reasons      = array();
     3917    $targets      = array();
    36153918    $persisted_ids = array();
    36163919    $alt_commits   = array();
    … …  
    37054008        } elseif ($type === 'seo_title') {
    37064009            $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();
    37084012            if ($new === '') {
    37094013                $results[(string) $cs_id] = 'skipped';
    … …  
    37244028                $persisted_ids[] = $cs_id;
    37254029            }
     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            }
    37264044        } elseif ($type === 'og') {
    37274045            $map = array(
    … …  
    38314149    }
    38324150
    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);
    38344152}
    38354153
    … …  
    38484166    $reverted = array();
    38494167    $outcomes = array();
     4168    $key_outcomes = array();
    38504169
    38514170    foreach ($ids as $cs_id) {
    … …  
    39024221            $outcome = $res['outcome'] ?? 'absent';
    39034222        }
     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        }
    39044232        if ($blocked) {
    39054233            $outcome = 'blocked';
    … …  
    39164244    }
    39174245
    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);
    39194247}
    39204248
  • seojuice/trunk/includes/footprint.php

    r3761416 r3763326  
    212212}
    213213
    214 function seojuice_record_field_version(int $post_id, int $cs_id, string $field, $before_value, $wrote_value = null): void {
     214function 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 {
    215215    $h = seojuice_get_history($post_id);
    216216    $entry = array(
    217         'cs' => $cs_id, 'ts' => time(), 'before' => array(),
     217        'cs' => $cs_id, 'ts' => time(), 'before' => $meta_before,
    218218        'before_fields' => array($field => array('value' => $before_value)),
    219219    );
    220220    if ($wrote_value !== null) { $entry['wrote_fields'] = array($field => $wrote_value); }
     221    if (!empty($meta_wrote)) { $entry['wrote'] = $meta_wrote; }
    221222    $h[] = $entry;
    222223    seojuice_update_post_meta($post_id, '_seojuice_history', wp_json_encode($h));
    … …  
    247248}
    248249
     250// Title variables an SEO plugin template can hold; a pin replaces them with the old post_title.
     251function 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.
     256function 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.
     268function 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.
     320function 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.
    249341function seojuice_revert_changeset(int $post_id, int $cs_id): array {
    250342    $h = seojuice_get_history($post_id);
    251343    $drop = array();
    252344    $skipped = array();
     345    $key_out = array();
     346    $own_title_touched = false;
    253347    $content_diff_handled = false;
    254348    $outcome = null;
    … …  
    270364                if (!$res['reverted'] && !empty($res['skipped'])) { $skipped[] = 'content'; }
    271365                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'; }
    272369            }
    273370        }
    … …  
    277374        $wrote = (isset($entry['wrote']) && is_array($entry['wrote'])) ? $entry['wrote'] : null;
    278375        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            }
    279382            // Only an entry that proves what it wrote may chain: when the next
    280383            // entry's before is that write, hand it this before value. When a client
    … …  
    293396                && (string) get_post_meta($post_id, $key, true) !== (string) $wrote[$key]) {
    294397                $skipped[] = $key;
     398                $key_out[$key] = 'kept_edit';
    295399                continue;
    296400            }
    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'; }
    299403        }
    300404        $wf = (isset($entry['wrote_fields']) && is_array($entry['wrote_fields'])) ? $entry['wrote_fields'] : null;
    … …  
    311415                $post = get_post($post_id);
    312416                $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; }
    314418            }
    315419            seojuice_update_post(array('ID' => $post_id, $field => $rec['value']));
     420            $key_out[$field] = 'restored';
    316421        }
    317422    }
    … …  
    320425        if (!isset($drop[$i])) { $kept[] = $entry; }
    321426    }
     427    if ($own_title_touched) { seojuice_guard_core_pin($post_id, $kept); }
    322428    seojuice_update_post_meta($post_id, '_seojuice_history', wp_json_encode($kept));
    323429    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);
    325431}
    326432
  • seojuice/trunk/readme.txt

    r3761416 r3763326  
    22Contributors: seojuice
    33Tags: seo, internal links, ai search, meta tags, visibility tracking
    4 Stable tag: 2.8.1
     4Stable tag: 2.9.0
    55Requires at least: 6.2
    66Tested up to: 7.1
    … …  
    138138
    139139== 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.
    140145
    141146= 2.8.1 =
    … …  
    320325== Upgrade Notice ==
    321326
     327= 2.9.0 =
     328Approved page headings can now be saved in WordPress, and Roll back all puts a page back as it was before SEOJuice.
     329
    322330= 2.8.1 =
    323331Rollback fix: rolling back SEOJuice changes in any order now restores the right value and keeps your own edits.
  • seojuice/trunk/seojuice.php

    r3761416 r3763326  
    44Description: SEOJuice automated on-page SEO optimization with server-side rendering for better performance and crawlability.
    55Author: Vadim Kravcenko
    6 Version: 2.8.1
     6Version: 2.9.0
    77Author URI: https://seojuice.io
    88License: GPLv2
    … …  
    4444//   is_ssl() actually cached under. Adds GET /front-page (page_on_front lookup)
    4545//   so meta_update changes targeting the homepage can resolve a post id.
    46 const SEOJUICE_PLUGIN_VERSION = '2.8.1';
     46const SEOJUICE_PLUGIN_VERSION = '2.9.0';
    4747// API base defaults to production. Overridable (before the plugin loads, e.g.
    4848// via wp-config.php or an mu-plugin) or through the SEOJUICE_API_BASE env var,
    … …  
    20452045
    20462046function 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    }
    20472050    foreach (seojuice_active_seo_title_keys() as $k) {
    20482051        $v = (string) get_post_meta($post_id, $k, true);
    … …  
    20522055}
    20532056
     2057const SEOJUICE_OWN_TITLE_KEY = '_seojuice_seo_title';
     2058const SEOJUICE_PIN_KEY       = '_seojuice_h1_pin';
     2059const SEOJUICE_TITLE_PRIORITY = 15;
     2060
     2061// True when a title/SEO plugin we cannot write for controls the document title.
     2062function 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.
     2071const SEOJUICE_CORE_TITLE_FORMATTERS = array('wptexturize', 'convert_chars', 'esc_html', 'capital_P_dangit');
     2072
     2073function 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.
     2091function 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.
     2100function 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}
     2108add_action('wp_head', 'seojuice_record_title_render_conflict', 0);
     2109
     2110// 'seo_plugin' | 'core' | 'unsupported'. Spec 2.2a pin-proven gate.
     2111function 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
     2121function 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 ''.
     2127function 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
     2134function 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}
     2139add_filter('pre_get_document_title', 'seojuice_filter_pre_document_title', SEOJUICE_TITLE_PRIORITY);
     2140
     2141function 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}
     2151add_filter('document_title_parts', 'seojuice_filter_document_title_parts', SEOJUICE_TITLE_PRIORITY);
     2152
    20542153function 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');
    20562155    if (SEOJUICE_CAP_ALT_INLINE) {
    20572156        $caps[] = 'alt_inline';
    … …  
    25212620            'title' => $post->post_title,
    25222621            'seo_title' => seojuice_read_seo_title($post_id),
     2622            'h1_raw' => seojuice_read_h1_raw($post),
    25232623            'status' => $post->post_status,
    25242624            'url' => get_permalink($post->ID),
    … …  
    35903690}
    35913691
     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.
     3698function 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.
     3709function 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
     3719function 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.
     3735function 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).
     3757function 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.
     3777function 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
     3786function 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).
     3806function 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.
     3829function 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
    35923894function seojuice_rest_apply_changes($request) {
    35933895    $post_id = intval($request->get_param('id'));
    … …  
    36133915    $results      = array();
    36143916    $reasons      = array();
     3917    $targets      = array();
    36153918    $persisted_ids = array();
    36163919    $alt_commits   = array();
    … …  
    37054008        } elseif ($type === 'seo_title') {
    37064009            $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();
    37084012            if ($new === '') {
    37094013                $results[(string) $cs_id] = 'skipped';
    … …  
    37244028                $persisted_ids[] = $cs_id;
    37254029            }
     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            }
    37264044        } elseif ($type === 'og') {
    37274045            $map = array(
    … …  
    38314149    }
    38324150
    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);
    38344152}
    38354153
    … …  
    38484166    $reverted = array();
    38494167    $outcomes = array();
     4168    $key_outcomes = array();
    38504169
    38514170    foreach ($ids as $cs_id) {
    … …  
    39024221            $outcome = $res['outcome'] ?? 'absent';
    39034222        }
     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        }
    39044232        if ($blocked) {
    39054233            $outcome = 'blocked';
    … …  
    39164244    }
    39174245
    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);
    39194247}
    39204248
Note: See TracChangeset for help on using the changeset viewer.