Plugin Directory

Changeset 3708357


Ignore:
Timestamp:
09/23/2026 02:48:32 AM (4 weeks ago)
Author:
sitehale
Message:

SiteHale 0.7.68

Location:
sitehale/trunk
Files:
6 edited

Legend:

Unmodified
Added
Removed
  • sitehale/trunk/changelog.txt

    r3707986 r3708357  
    33readme.txt carries only recent releases: wp.org truncates a Changelog
    44section past 5,000 words, silently, at import. This file is the full
    5 record, newest first (0.7.67 down to 0.1.0).
     5record, newest first (0.7.68 down to 0.1.0).
     6
     7= 0.7.68 =
     8* Requests for an old `news-sitemap.xml` address now go to your current sitemap instead of returning "not found".
     9* Imports existing SEO settings from two more plugins, Rank Math and SEOPress. A site switching from either keeps its page titles, descriptions, social sharing text and search-engine settings, for posts, pages and category archives alike — and from either one, its title templates, verification codes, social profile links and business details — plus, from Rank Math, any FAQ or How-to information it had added.
    610
    711= 0.7.67 =
  • sitehale/trunk/includes/class-sitehale-seo-import-options.php

    r3668728 r3708357  
    469469         * @return array
    470470         */
     471        /**
     472         * Rank Math's site-level titles/robots options → our slots.
     473         *
     474         * Shapes read off a live Rank Math 1.0.279 install, not from memory:
     475         * `rank-math-options-titles` holds `title_separator`, `homepage_title`,
     476         * `pt_<post_type>_title`/`_description`, `tax_<taxonomy>_title`/
     477         * `_description`, `author_archive_title`, `date_archive_title`,
     478         * `search_title`, `404_title`, and the `noindex_*` toggles.
     479         *
     480         * ⚠️ ITS VARIABLES ARE SINGLE PERCENTS, and one of them is a trap:
     481         * `%term%` is the taxonomy TITLE while `%term_description%` is its
     482         * description, so a naive prefix match maps both to the same slot. The
     483         * longest key is replaced first for that reason — the same care
     484         * `map_yoast_slots` takes with `%%excerpt%%` / `%%excerpt_only%%`.
     485         *
     486         * Anything still carrying `%…%` after translation is REFUSED, never
     487         * published: a template we cannot resolve renders the literal token to
     488         * a crawler, which is the AIOSEO smart-tag lesson in a third syntax.
     489         *
     490         * @param array $titles `rank-math-options-titles`.
     491         * @return array{templates:array,separator:string,robots_defaults:array,drops:array}
     492         */
     493        public static function map_rankmath_slots( $titles ) {
     494                $out = array( 'templates' => array(), 'separator' => '',
     495                              'robots_defaults' => array(), 'drops' => array() );
     496                if ( ! is_array( $titles ) || ! $titles ) {
     497                        return $out;
     498                }
     499
     500                // Longest first: %term_description% must not be eaten by %term%.
     501                $vocab = array(
     502                        '%term_description%' => '#taxonomy_description',
     503                        '%category_title%'   => '#taxonomy_title',
     504                        '%search_query%'     => '#search_term',
     505                        '%customterm%'       => '#taxonomy_title',
     506                        '%sitedesc%'         => '#tagline',
     507                        '%sitename%'         => '#site_title',
     508                        '%excerpt%'          => '#post_excerpt',
     509                        '%seo_title%'        => '#post_title',
     510                        '%pt_plural%'        => '#archive_title',
     511                        '%pt_single%'        => '#archive_title',
     512                        '%category%'         => '#taxonomy_title',
     513                        '%caption%'          => '#attachment_caption',
     514                        '%term%'             => '#taxonomy_title',
     515                        '%title%'            => '#post_title',
     516                        '%name%'             => '#author_name',
     517                        '%date%'             => '#archive_date',
     518                        '%sep%'              => '#separator_sa',
     519                );
     520
     521                $fixed = array(
     522                        'homepage_title'        => 'site_title',
     523                        'homepage_description'  => 'site_description',
     524                        'author_archive_title'  => 'author_title',
     525                        'author_archive_description' => 'author_description',
     526                        'date_archive_title'    => 'date_title',
     527                        'search_title'          => 'search_title',
     528                        '404_title'             => 'error_title',
     529                );
     530
     531                $translate = static function ( $tpl ) use ( $vocab ) {
     532                        // Pagination is ours to add, never theirs to carry.
     533                        $tpl = str_replace( array( '%page%', '%pagenumber%', '%pagetotal%' ), '', $tpl );
     534                        $tpl = strtr( $tpl, $vocab );
     535                        $tpl = trim( preg_replace( '/\s+/u', ' ', $tpl ) );
     536                        // ⚠️ Still in Rank Math syntax = untranslatable. NOT a bare `%`
     537                        // test: "50% off" is a legitimate template literal.
     538                        return preg_match( '/%[a-z][a-z0-9_]*(\([^)]*\))?%/i', $tpl ) ? null : $tpl;
     539                };
     540
     541                foreach ( $titles as $key => $value ) {
     542                        if ( 'title_separator' === $key && is_string( $value ) && '' !== trim( $value ) ) {
     543                                $out['separator'] = self::separator( trim( $value ) );
     544                                continue;
     545                        }
     546                        if ( ! is_string( $value ) || '' === trim( $value ) ) {
     547                                continue;
     548                        }
     549                        $slot = null;
     550                        if ( isset( $fixed[ $key ] ) ) {
     551                                $slot = $fixed[ $key ];
     552                        } elseif ( preg_match( '/^pt_(.+)_title$/', $key, $m ) ) {
     553                                $slot = 'post' === $m[1] ? 'post_title' : 'post_title:' . $m[1];
     554                        } elseif ( preg_match( '/^pt_(.+)_description$/', $key, $m ) ) {
     555                                $slot = 'post' === $m[1] ? 'post_description' : 'post_description:' . $m[1];
     556                        } elseif ( preg_match( '/^tax_(.+)_title$/', $key, $m ) ) {
     557                                $slot = 'term_title:' . $m[1];
     558                        } elseif ( preg_match( '/^tax_(.+)_description$/', $key, $m ) ) {
     559                                $slot = 'term_description:' . $m[1];
     560                        }
     561                        if ( null === $slot ) {
     562                                continue;   // Not a slot we render. Silence, not a drop.
     563                        }
     564                        $tpl = $translate( trim( $value ) );
     565                        if ( null === $tpl ) {
     566                                $out['drops'][ 'template.' . $key ] =
     567                                        'carries Rank Math variables with no equivalent here: ' . trim( $value );
     568                                continue;
     569                        }
     570                        if ( '' !== $tpl ) {
     571                                $out['templates'][ $slot ] = $tpl;
     572                        }
     573                }
     574
     575                // Robots defaults. TRUE FLAGS ONLY, and only the policies our head
     576                // actually honours — `noindex_empty_taxonomies` has no equivalent
     577                // (we do not render a term archive differently for being empty) and
     578                // `disable_author_archives` REDIRECTS rather than noindexes, so
     579                // importing it as noindex would state something the site does not do.
     580                $policy = array(
     581                        'noindex_search'           => 'noindex_search',
     582                        'noindex_archive_subpages' => 'noindex_paginated',
     583                );
     584                foreach ( $policy as $theirs => $ours ) {
     585                        if ( isset( $titles[ $theirs ] ) && 'on' === strtolower( (string) $titles[ $theirs ] ) ) {
     586                                $out['robots_defaults'][ $ours ] = true;
     587                        }
     588                }
     589
     590                // ⚠️ THE PER-TYPE ROBOTS NEED THEIR GATE READ TOO. Rank Math stores
     591                // `<thing>_robots` as a list AND `<thing>_custom_robots` as on/off,
     592                // and the list is inert while the gate is off — a fresh install ships
     593                // `pt_attachment_robots: ['noindex']` with its gate ON and
     594                // `pt_post_robots: ['index']` with its gate OFF. Reading the list
     595                // alone would import directives the site does not serve, which is
     596                // the defect this work has now hit three times.
     597                //
     598                // Only the two our head actually honours are carried: author and
     599                // attachment archives. `date_archive_robots` and the per-taxonomy
     600                // lists have no equivalent — our policy has no key for them — and a
     601                // value stored where nothing reads it is a lie with a long fuse.
     602                $gated = array(
     603                        'author'        => 'noindex_author',
     604                        'pt_attachment' => 'noindex_attachment',
     605                );
     606                foreach ( $gated as $theirs => $ours ) {
     607                        $on   = isset( $titles[ $theirs . '_custom_robots' ] )
     608                                && 'on' === strtolower( (string) $titles[ $theirs . '_custom_robots' ] );
     609                        $list = isset( $titles[ $theirs . '_robots' ] ) && is_array( $titles[ $theirs . '_robots' ] )
     610                                ? array_map( 'strtolower', $titles[ $theirs . '_robots' ] ) : array();
     611                        if ( $on && in_array( 'noindex', $list, true ) ) {
     612                                $out['robots_defaults'][ $ours ] = true;
     613                        }
     614                }
     615
     616                // The share-card shape is a site-level choice our store already has a
     617                // slot for, and it is the difference a parity run sees on every page.
     618                $card = isset( $titles['twitter_card_type'] ) ? (string) $titles['twitter_card_type'] : '';
     619                if ( in_array( $card, array( 'summary', 'summary_large_image' ), true ) ) {
     620                        $out['twitter_card'] = $card;
     621                }
     622                return $out;
     623        }
     624
     625        /**
     626         * Rank Math's knowledge-graph identity and social URLs.
     627         *
     628         * `knowledgegraph_type` is `person` or `company`; only a company is an
     629         * Organization, and importing a person's name as an organization name
     630         * would be a factual claim about the business (rule 3).
     631         *
     632         * @param array $titles  `rank-math-options-titles`.
     633         * @param array $general `rank-math-options-general`.
     634         * @return array{profiles:array,organization:array,tags:array,drops:array}
     635         */
     636        public static function map_rankmath_identity( $titles, $general ) {
     637                $out = array( 'profiles' => array(), 'organization' => array(),
     638                              'tags' => array(), 'drops' => array() );
     639                $titles  = is_array( $titles ) ? $titles : array();
     640                $general = is_array( $general ) ? $general : array();
     641
     642                $type = strtolower( (string) ( isset( $titles['knowledgegraph_type'] ) ? $titles['knowledgegraph_type'] : '' ) );
     643                $name = trim( (string) ( isset( $titles['knowledgegraph_name'] ) ? $titles['knowledgegraph_name'] : '' ) );
     644                if ( 'company' === $type && '' !== $name ) {
     645                        $out['organization']['name'] = $name;
     646                } elseif ( '' !== $name && 'person' === $type ) {
     647                        $out['drops']['organization.name'] =
     648                                'Rank Math calls this site a person, not a company — importing the '
     649                                . 'name as an Organization would state something about the business';
     650                }
     651                $logo = trim( (string) ( isset( $titles['knowledgegraph_logo'] ) ? $titles['knowledgegraph_logo'] : '' ) );
     652                if ( 'company' === $type && 0 === strpos( $logo, 'http' ) ) {
     653                        $out['organization']['logo'] = $logo;
     654                }
     655
     656                // Social profile URLs. A handle where a URL belongs is refused for the
     657                // same reason map_identity() refuses one: sameAs takes URLs, and
     658                // guessing the domain is a claim about an account that may not be ours.
     659                $profiles = array(
     660                        'social_url_facebook'  => 'facebookPageUrl',
     661                        'social_url_instagram' => 'instagramUrl',
     662                        'social_url_linkedin'  => 'linkedinUrl',
     663                        'social_url_youtube'   => 'youtubeUrl',
     664                        'social_url_pinterest' => 'pinterestUrl',
     665                );
     666                foreach ( $profiles as $theirs => $ours ) {
     667                        $value = trim( (string) ( isset( $titles[ $theirs ] ) ? $titles[ $theirs ] : '' ) );
     668                        if ( '' === $value ) {
     669                                continue;
     670                        }
     671                        if ( 0 !== strpos( $value, 'http' ) ) {
     672                                $out['drops'][ 'profile.' . $ours ] = 'not an absolute URL: ' . $value;
     673                                continue;
     674                        }
     675                        $out['profiles'][ $ours ] = $value;
     676                }
     677                $handle = trim( (string) ( isset( $titles['twitter_author_names'] ) ? $titles['twitter_author_names'] : '' ) );
     678                if ( '' !== $handle ) {
     679                        $out['organization']['twitter_handle'] = '@' . ltrim( $handle, '@' );
     680                }
     681
     682                // Webmaster verification, same meta names map_verification() uses.
     683                $verify = array(
     684                        'google_verify'    => 'google-site-verification',
     685                        'bing_verify'      => 'msvalidate.01',
     686                        'yandex_verify'    => 'yandex-verification',
     687                        'baidu_verify'     => 'baidu-site-verification',
     688                        'pinterest_verify' => 'p:domain_verify',
     689                        'norton_verify'    => 'norton-safeweb-site-verification',
     690                );
     691                foreach ( $verify as $theirs => $meta ) {
     692                        $value = trim( (string) ( isset( $general[ $theirs ] ) ? $general[ $theirs ] : '' ) );
     693                        if ( '' !== $value ) {
     694                                $out['tags'][ $meta ] = $value;
     695                        }
     696                }
     697                return $out;
     698        }
     699
     700        /**
     701         * SEOPress's site-level options → our slots.
     702         *
     703         * Shapes read off a live SEOPress install, and the key names checked
     704         * against its own source. Three options carry it:
     705         * `seopress_titles_option_name` (separator, templates, noindex flags),
     706         * `seopress_social_option_name` (knowledge graph, accounts, card) and
     707         * `seopress_advanced_option_name` (verification codes).
     708         *
     709         * ⚠️ ITS TEMPLATES ARE NESTED, not flat: `single_titles[<post_type>]`,
     710         * `tax_titles[<taxonomy>]` and `archive_titles[<post_type>]`, each an
     711         * array of `title`/`description`. A flat scan finds the four archive
     712         * templates and silently misses every per-type one — which is most of
     713         * the site.
     714         *
     715         * ⚠️ AND ITS TAXONOMY TOKENS ARE PER-TAXONOMY: `%%_category_title%%`
     716         * (leading underscore) and `%%tag_title%%`, not one generic term token.
     717         * Ours is generic, so both map to `#taxonomy_title` — and the longest
     718         * key is replaced first so `%%_category_description%%` is not eaten by
     719         * `%%_category_title%%`'s prefix.
     720         *
     721         * @param array $titles `seopress_titles_option_name`.
     722         * @return array{templates:array,separator:string,robots_defaults:array,drops:array}
     723         */
     724        public static function map_seopress_slots( $titles ) {
     725                $out = array( 'templates' => array(), 'separator' => '',
     726                              'robots_defaults' => array(), 'drops' => array() );
     727                if ( ! is_array( $titles ) || ! $titles ) {
     728                        return $out;
     729                }
     730
     731                $vocab = array(
     732                        '%%_category_description%%' => '#taxonomy_description',
     733                        '%%_category_title%%'       => '#taxonomy_title',
     734                        '%%term_description%%'      => '#taxonomy_description',
     735                        '%%tag_description%%'       => '#taxonomy_description',
     736                        '%%search_keywords%%'       => '#search_term',
     737                        '%%archive_title%%'         => '#archive_title',
     738                        '%%archive_date%%'          => '#archive_date',
     739                        '%%post_excerpt%%'          => '#post_excerpt',
     740                        '%%post_author%%'           => '#author_name',
     741                        '%%cpt_plural%%'            => '#archive_title',
     742                        '%%post_title%%'            => '#post_title',
     743                        '%%term_title%%'            => '#taxonomy_title',
     744                        '%%tag_title%%'             => '#taxonomy_title',
     745                        '%%sitetitle%%'             => '#site_title',
     746                        '%%tagline%%'               => '#tagline',
     747                        '%%sep%%'                   => '#separator_sa',
     748                );
     749
     750                $translate = static function ( $tpl ) use ( $vocab ) {
     751                        $tpl = str_replace( array( '%%current_pagination%%', '%%page%%' ), '', (string) $tpl );
     752                        $tpl = strtr( $tpl, $vocab );
     753                        $tpl = trim( preg_replace( '/\s+/u', ' ', $tpl ) );
     754                        // A dangling separator is what a stripped pagination token leaves
     755                        // behind ("%%cpt_plural%% %%current_pagination%% %%sep%%").
     756                        $tpl = trim( preg_replace( '/(\s*#separator_sa\s*)+$/', '', $tpl ) );
     757                        return preg_match( '/%%[^%]+%%/', $tpl ) ? null : $tpl;
     758                };
     759
     760                $take = function ( $slot, $raw, $key ) use ( &$out, $translate ) {
     761                        if ( ! is_string( $raw ) || '' === trim( $raw ) ) {
     762                                return;
     763                        }
     764                        $tpl = $translate( trim( $raw ) );
     765                        if ( null === $tpl ) {
     766                                $out['drops'][ 'template.' . $key ] =
     767                                        'carries SEOPress variables with no equivalent here: ' . trim( $raw );
     768                                return;
     769                        }
     770                        if ( '' !== $tpl ) {
     771                                $out['templates'][ $slot ] = $tpl;
     772                        }
     773                };
     774
     775                if ( ! empty( $titles['seopress_titles_sep'] ) ) {
     776                        $out['separator'] = self::separator( trim( (string) $titles['seopress_titles_sep'] ) );
     777                }
     778                $take( 'site_title', isset( $titles['seopress_titles_home_site_title'] ) ? $titles['seopress_titles_home_site_title'] : '', 'home_site_title' );
     779                $take( 'site_description', isset( $titles['seopress_titles_home_site_desc'] ) ? $titles['seopress_titles_home_site_desc'] : '', 'home_site_desc' );
     780                $take( 'author_title', isset( $titles['seopress_titles_archives_author_title'] ) ? $titles['seopress_titles_archives_author_title'] : '', 'author_title' );
     781                $take( 'date_title', isset( $titles['seopress_titles_archives_date_title'] ) ? $titles['seopress_titles_archives_date_title'] : '', 'date_title' );
     782                $take( 'search_title', isset( $titles['seopress_titles_archives_search_title'] ) ? $titles['seopress_titles_archives_search_title'] : '', 'search_title' );
     783                $take( 'error_title', isset( $titles['seopress_titles_archives_404_title'] ) ? $titles['seopress_titles_archives_404_title'] : '', '404_title' );
     784
     785                $nested = array(
     786                        'seopress_titles_single_titles'  => array( 'post_title', 'post_description' ),
     787                        'seopress_titles_tax_titles'     => array( 'term_title', 'term_description' ),
     788                        'seopress_titles_archive_titles' => array( 'archive_title', 'archive_description' ),
     789                );
     790                foreach ( $nested as $option_key => $slots ) {
     791                        if ( empty( $titles[ $option_key ] ) || ! is_array( $titles[ $option_key ] ) ) {
     792                                continue;
     793                        }
     794                        foreach ( $titles[ $option_key ] as $subtype => $pair ) {
     795                                if ( ! is_array( $pair ) ) {
     796                                        continue;
     797                                }
     798                                foreach ( array( 'title' => 0, 'description' => 1 ) as $which => $i ) {
     799                                        if ( ! isset( $pair[ $which ] ) ) {
     800                                                continue;
     801                                        }
     802                                        $slot = $slots[ $i ];
     803                                        // `post` is the default slot; everything else is qualified,
     804                                        // exactly as the AIOSEO and Yoast mappers do it.
     805                                        if ( 'post_title' === $slot || 'post_description' === $slot ) {
     806                                                $slot = ( 'post' === $subtype ) ? $slot : $slot . ':' . $subtype;
     807                                        } else {
     808                                                $slot = $slot . ':' . $subtype;
     809                                        }
     810                                        $take( $slot, $pair[ $which ], $option_key . '.' . $subtype . '.' . $which );
     811                                }
     812                        }
     813                }
     814
     815                // Noindex flags. '1' is on; only the policies our head honours are
     816                // carried. `archives_date_noindex` has no key here, and storing it
     817                // where nothing reads it would be a value that quietly means nothing.
     818                $flags = array(
     819                        'seopress_titles_archives_author_noindex'       => 'noindex_author',
     820                        'seopress_titles_attachments_noindex'           => 'noindex_attachment',
     821                        'seopress_titles_archives_search_title_noindex' => 'noindex_search',
     822                );
     823                foreach ( $flags as $theirs => $ours ) {
     824                        if ( isset( $titles[ $theirs ] ) && '1' === (string) $titles[ $theirs ] ) {
     825                                $out['robots_defaults'][ $ours ] = true;
     826                        }
     827                }
     828                return $out;
     829        }
     830
     831        /**
     832         * SEOPress's knowledge graph, social accounts, card shape and
     833         * verification codes.
     834         *
     835         * @param array $social   `seopress_social_option_name`.
     836         * @param array $advanced `seopress_advanced_option_name`.
     837         * @return array{profiles:array,organization:array,tags:array,twitter_card:string,drops:array}
     838         */
     839        public static function map_seopress_identity( $social, $advanced ) {
     840                $out = array( 'profiles' => array(), 'organization' => array(),
     841                              'tags' => array(), 'twitter_card' => '', 'drops' => array() );
     842                $social   = is_array( $social ) ? $social : array();
     843                $advanced = is_array( $advanced ) ? $advanced : array();
     844
     845                $type = (string) ( isset( $social['seopress_social_knowledge_type'] ) ? $social['seopress_social_knowledge_type'] : '' );
     846                $name = trim( (string) ( isset( $social['seopress_social_knowledge_name'] ) ? $social['seopress_social_knowledge_name'] : '' ) );
     847                // Person is not an Organization (rule 3, as for Rank Math).
     848                if ( '' !== $name && '' !== $type && 'Person' !== $type ) {
     849                        $out['organization']['name'] = $name;
     850                        $logo = trim( (string) ( isset( $social['seopress_social_knowledge_img'] ) ? $social['seopress_social_knowledge_img'] : '' ) );
     851                        if ( 0 === strpos( $logo, 'http' ) ) {
     852                                $out['organization']['logo'] = $logo;
     853                        }
     854                } elseif ( '' !== $name && 'Person' === $type ) {
     855                        $out['drops']['organization.name'] =
     856                                'SEOPress calls this site a person, not an organization';
     857                }
     858
     859                $profiles = array(
     860                        'seopress_social_accounts_facebook'  => 'facebookPageUrl',
     861                        'seopress_social_accounts_instagram' => 'instagramUrl',
     862                        'seopress_social_accounts_linkedin'  => 'linkedinUrl',
     863                        'seopress_social_accounts_youtube'   => 'youtubeUrl',
     864                        'seopress_social_accounts_pinterest' => 'pinterestUrl',
     865                );
     866                foreach ( $profiles as $theirs => $ours ) {
     867                        $value = trim( (string) ( isset( $social[ $theirs ] ) ? $social[ $theirs ] : '' ) );
     868                        if ( '' === $value ) {
     869                                continue;
     870                        }
     871                        if ( 0 !== strpos( $value, 'http' ) ) {
     872                                $out['drops'][ 'profile.' . $ours ] = 'not an absolute URL: ' . $value;
     873                                continue;
     874                        }
     875                        $out['profiles'][ $ours ] = $value;
     876                }
     877                // ⚠️ SEOPress stores the X account as a USERNAME, not a URL — its own
     878                // field validates `^[A-Za-z0-9_]{1,15}$`. So this one is a handle by
     879                // design and is stored as a handle, where a Rank Math or AIOSEO URL
     880                // would be parsed down to one.
     881                $handle = trim( (string) ( isset( $social['seopress_social_accounts_twitter'] ) ? $social['seopress_social_accounts_twitter'] : '' ) );
     882                if ( '' !== $handle ) {
     883                        $out['organization']['twitter_handle'] = '@' . ltrim( $handle, '@' );
     884                }
     885
     886                // 'large' is its own word for summary_large_image; anything else is
     887                // the default card.
     888                $size = strtolower( (string) ( isset( $social['seopress_social_twitter_card_img_size'] ) ? $social['seopress_social_twitter_card_img_size'] : '' ) );
     889                if ( 'large' === $size ) {
     890                        $out['twitter_card'] = 'summary_large_image';
     891                } elseif ( 'default' === $size ) {
     892                        $out['twitter_card'] = 'summary';
     893                }
     894
     895                $verify = array(
     896                        'seopress_advanced_advanced_google'    => 'google-site-verification',
     897                        'seopress_advanced_advanced_bing'      => 'msvalidate.01',
     898                        'seopress_advanced_advanced_yandex'    => 'yandex-verification',
     899                        'seopress_advanced_advanced_pinterest' => 'p:domain_verify',
     900                        'seopress_advanced_advanced_baidu'     => 'baidu-site-verification',
     901                );
     902                foreach ( $verify as $theirs => $meta ) {
     903                        $value = trim( (string) ( isset( $advanced[ $theirs ] ) ? $advanced[ $theirs ] : '' ) );
     904                        if ( '' !== $value ) {
     905                                $out['tags'][ $meta ] = $value;
     906                        }
     907                }
     908                return $out;
     909        }
     910
     911        /**
     912         * Is SEOPress the plugin actually describing this site's output?
     913         * Same shape and reason as the other two predicates.
     914         */
     915        public static function seopress_is_renderer() {
     916                return defined( 'SEOPRESS_VERSION' )
     917                        && ! defined( 'AIOSEO_VERSION' )
     918                        && ! defined( 'WPSEO_VERSION' )
     919                        && ! defined( 'RANK_MATH_VERSION' );
     920        }
     921
    471922        public static function map_yoast_social( $social ) {
    472923                $out = array();
    … …  
    9591410        }
    9601411
     1412        /**
     1413         * Is Rank Math the plugin actually describing this site's output?
     1414         *
     1415         * Same shape as `yoast_is_renderer()` and for the same reason: a
     1416         * deactivated plugin leaves every one of its options behind, so
     1417         * "its settings exist" says nothing about what the site serves. The
     1418         * constant is defined only while it is running.
     1419         *
     1420         * AIOSEO and Yoast both outrank it: on a site carrying two, the one
     1421         * that renders is the one whose settings are real, and those two are
     1422         * checked first everywhere else in this file.
     1423         */
     1424        public static function rankmath_is_renderer() {
     1425                return defined( 'RANK_MATH_VERSION' )
     1426                        && ! defined( 'AIOSEO_VERSION' )
     1427                        && ! defined( 'WPSEO_VERSION' );
     1428        }
     1429
    9611430        public static function collect() {
    9621431                $read = static function ( $name ) {
    … …  
    10151484                }
    10161485
     1486                // ⚠️ RANK MATH'S SITE-LEVEL OPTIONS, on the same terms as Yoast's:
     1487                // only where it is the one rendering. Its per-post meta has been
     1488                // importable since the Sep 2026 work, and a cutover without these
     1489                // leaves the site with its authored titles and NONE of the templates
     1490                // that produced every other page's — the hollyfrost failure in a
     1491                // different plugin's clothes.
     1492                //
     1493                // FILLS, never stomps: anything AIOSEO or Yoast already gave stands.
     1494                $rm_slots = array( 'templates' => array(), 'separator' => '',
     1495                                   'robots_defaults' => array(), 'drops' => array() );
     1496                $rm_identity = array( 'profiles' => array(), 'organization' => array(),
     1497                                      'tags' => array(), 'drops' => array() );
     1498                if ( self::rankmath_is_renderer() ) {
     1499                        $rm_titles   = $read( 'rank-math-options-titles' );
     1500                        $rm_slots    = self::map_rankmath_slots( $rm_titles );
     1501                        $rm_identity = self::map_rankmath_identity( $rm_titles, $read( 'rank-math-options-general' ) );
     1502                        foreach ( $rm_slots['templates'] as $slot => $tpl ) {
     1503                                if ( ! isset( $slots['templates'][ $slot ] ) ) {
     1504                                        $slots['templates'][ $slot ] = $tpl;
     1505                                }
     1506                        }
     1507                        foreach ( $rm_slots['robots_defaults'] as $k => $v ) {
     1508                                if ( ! isset( $robots_policy[ $k ] ) ) {
     1509                                        $robots_policy[ $k ] = $v;
     1510                                }
     1511                        }
     1512                        if ( ! empty( $rm_slots['twitter_card'] ) && ! isset( $social_policy['twitter_card'] ) ) {
     1513                                $social_policy['twitter_card'] = $rm_slots['twitter_card'];
     1514                        }
     1515                        foreach ( $rm_identity['profiles'] as $k => $v ) {
     1516                                if ( ! isset( $identity['profiles'][ $k ] ) ) {
     1517                                        $identity['profiles'][ $k ] = $v;
     1518                                }
     1519                        }
     1520                        foreach ( $rm_identity['organization'] as $k => $v ) {
     1521                                if ( ! isset( $identity['organization'][ $k ] ) ) {
     1522                                        $identity['organization'][ $k ] = $v;
     1523                                }
     1524                        }
     1525                        foreach ( $rm_identity['tags'] as $k => $v ) {
     1526                                if ( ! isset( $verification['tags'][ $k ] ) ) {
     1527                                        $verification['tags'][ $k ] = $v;
     1528                                }
     1529                        }
     1530                }
     1531
     1532                // SEOPress, on the same terms as the other two: only where it renders.
     1533                $sp_slots = array( 'templates' => array(), 'separator' => '',
     1534                                   'robots_defaults' => array(), 'drops' => array() );
     1535                $sp_identity = array( 'profiles' => array(), 'organization' => array(),
     1536                                      'tags' => array(), 'twitter_card' => '', 'drops' => array() );
     1537                if ( self::seopress_is_renderer() ) {
     1538                        $sp_slots    = self::map_seopress_slots( $read( 'seopress_titles_option_name' ) );
     1539                        $sp_identity = self::map_seopress_identity(
     1540                                $read( 'seopress_social_option_name' ),
     1541                                $read( 'seopress_advanced_option_name' )
     1542                        );
     1543                        foreach ( $sp_slots['templates'] as $slot => $tpl ) {
     1544                                if ( ! isset( $slots['templates'][ $slot ] ) ) {
     1545                                        $slots['templates'][ $slot ] = $tpl;
     1546                                }
     1547                        }
     1548                        foreach ( $sp_slots['robots_defaults'] as $k => $v ) {
     1549                                if ( ! isset( $robots_policy[ $k ] ) ) {
     1550                                        $robots_policy[ $k ] = $v;
     1551                                }
     1552                        }
     1553                        foreach ( $sp_identity['profiles'] as $k => $v ) {
     1554                                if ( ! isset( $identity['profiles'][ $k ] ) ) {
     1555                                        $identity['profiles'][ $k ] = $v;
     1556                                }
     1557                        }
     1558                        foreach ( $sp_identity['organization'] as $k => $v ) {
     1559                                if ( ! isset( $identity['organization'][ $k ] ) ) {
     1560                                        $identity['organization'][ $k ] = $v;
     1561                                }
     1562                        }
     1563                        foreach ( $sp_identity['tags'] as $k => $v ) {
     1564                                if ( ! isset( $verification['tags'][ $k ] ) ) {
     1565                                        $verification['tags'][ $k ] = $v;
     1566                                }
     1567                        }
     1568                        if ( '' !== $sp_identity['twitter_card'] && ! isset( $social_policy['twitter_card'] ) ) {
     1569                                $social_policy['twitter_card'] = $sp_identity['twitter_card'];
     1570                        }
     1571                }
     1572
    10171573                $aioseo_sep = self::separator(
    10181574                        isset( $global['searchAppearance']['global']['separator'] )
    … …  
    10621618                // which is right by luck on asmc (`sc-dash`) and wrong on any
    10631619                // client who chose a pipe or a bullet.
    1064                 'separator' => ( '' !== $aioseo_sep ) ? $aioseo_sep : $yoast_slots['separator'],
     1620                // First renderer with one wins, in the same order as everything else
     1621                // here: AIOSEO, then Yoast, then Rank Math. Each branch above only
     1622                // ran if that plugin is the one rendering, so at most one is set.
     1623                'separator' => ( '' !== $aioseo_sep )
     1624                        ? $aioseo_sep
     1625                        : ( '' !== $yoast_slots['separator']
     1626                                ? $yoast_slots['separator']
     1627                                : ( '' !== $rm_slots['separator'] ? $rm_slots['separator'] : $sp_slots['separator'] ) ),
    10651628                'yoast_drops' => $yoast_slots['drops'],
    10661629                'current'   => get_option( SiteHale_SEO_Store::OPTION, array() ),
  • sitehale/trunk/includes/class-sitehale-seo-migration.php

    r3668728 r3708357  
    200200
    201201        /** Sources this build has an importer for. Anything else has not been read. */
    202         const READ_SOURCES = array( 'aioseo', 'yoast', 'none' );
     202        /**
     203         * Sources the importer may legitimately report having read.
     204         *
     205         * ⚠️ AN UNKNOWN SOURCE HOLDS THE GATE SHUT, which is the safe direction:
     206         * the button deactivates the rival, so a source nobody has a reader for
     207         * must never resolve to "migrated".
     208         *
     209         * Rank Math and SEOPress joined on 2026-09-22, and only once BOTH halves
     210         * were read. Their per-post meta landed first and that was deliberately
     211         * not enough — their taxonomy archives' titles and descriptions live in
     212         * term meta (Yoast's in the `wpseo_taxonomy_meta` option), and a flip on
     213         * a post-only import strips exactly those pages. The term reader is what
     214         * earned them the place, which is why `is_clear()` still demands
     215         * `terms` be `scanned` or `absent` alongside this list.
     216         *
     217         * @var string[]
     218         */
     219        const READ_SOURCES = array( 'aioseo', 'yoast', 'rankmath', 'seopress', 'none' );
    203220
    204221        /**
  • sitehale/trunk/includes/class-sitehale-seo-sitemap.php

    r3707986 r3708357  
    6464        const RETIRED = array(
    6565                'sitemap_index.xml',
     66                // Found by the 404 log on macsolutions within minutes of 0.7.67
     67                // (Garret, 2026-09-22): something still asks for it and gets a 404.
     68                // AIOSEO advertised it on kava too — the robots unit documents that
     69                // it has 404'd since 2026-08-05. A news sitemap is a different KIND
     70                // of sitemap, so the 301 goes to the index like every other name
     71                // here: the index is true, and a 404 is not.
     72                'news-sitemap.xml',
    6673                'sitemap-index.xml',
    6774                'sitemap.rss',
  • sitehale/trunk/readme.txt

    r3707986 r3708357  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 0.7.67
     7Stable tag: 0.7.68
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    196196
    197197== Changelog ==
     198
     199= 0.7.68 =
     200* Requests for an old `news-sitemap.xml` address now go to your current sitemap instead of returning "not found".
     201* Imports existing SEO settings from two more plugins, Rank Math and SEOPress. A site switching from either keeps its page titles, descriptions, social sharing text and search-engine settings, for posts, pages and category archives alike — and from either one, its title templates, verification codes, social profile links and business details — plus, from Rank Math, any FAQ or How-to information it had added.
    198202
    199203= 0.7.67 =
  • sitehale/trunk/sitehale.php

    r3707986 r3708357  
    44 * Plugin URI:        https://sitehale.com/how-it-works/
    55 * Description:       Free Site Checkup (cron, PHP errors, database, updates, admins, SEO basics, disk, mail) plus the companion to the SiteHale managed care service. Sends nothing anywhere until you click Connect.
    6  * Version:           0.7.67
     6 * Version:           0.7.68
    77 * Requires at least: 5.7
    88 * Requires PHP:      7.4
    … …  
    3030}
    3131
    32 define( 'SITEHALE_VERSION', '0.7.67' );
     32define( 'SITEHALE_VERSION', '0.7.68' );
    3333define( 'SITEHALE_PLUGIN_FILE', __FILE__ );
    3434define( 'SITEHALE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
Note: See TracChangeset for help on using the changeset viewer.