Plugin Directory

Changeset 3759017


Ignore:
Timestamp:
10/21/2026 08:09:03 PM (3 days ago)
Author:
easyrankly
Message:

Release 2.0.0

Location:
easyrankly/trunk
Files:
107 added
5 deleted
3 edited

Legend:

Unmodified
Added
Removed
  • easyrankly/trunk/easyrankly.php

    r3588731 r3759017  
    11<?php
    22/**
    3  * Plugin Name: EasyRankly
    4  * Plugin URI:  https://easyrankly.com
    5  * Description: Lightweight, modular, developer-first SEO essentials for WordPress.
    6  * Version:     1.0.0
    7  * Requires at least: 6.5
    8  * Requires PHP: 8.0
    9  * Author:      Enthony Sclafani
    10  * Text Domain: easyrankly
    11  * Domain Path: /languages
    12  * License:     GPL-2.0-or-later
    13  * Network:     true
     3 * Plugin Name:       EasyRankly
     4 * Plugin URI:        https://easyrankly.com
     5 * Description:       Lightweight SEO built only on native WordPress APIs: metadata, redirects, sitemaps, custom code and multilingual.
     6 * Version:           2.0.0
     7 * Requires at least: 7.0
     8 * Requires PHP:      8.0
     9 * Author:            EasyRankly
     10 * Author URI:        https://easyrankly.com/
     11 * License:           GPL-2.0-or-later
     12 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
     13 * Text Domain:       easyrankly
    1414 *
    1515 * @package EasyRankly
    1616 */
    1717
    18 if ( ! defined( 'ABSPATH' ) ) {
    19         exit;
    20 }
     18namespace EasyRankly;
    2119
    22 // Bail if WordPress loads this file twice in one request (e.g. during a ZIP update).
    23 if ( defined( 'ERANKLY_VERSION' ) ) {
    24         return;
    25 }
     20defined( 'ABSPATH' ) || exit;
    2621
    27 define( 'ERANKLY_VERSION', '1.0.0' );
    28 define( 'ERANKLY_FILE', __FILE__ );
    29 define( 'ERANKLY_PATH', plugin_dir_path( __FILE__ ) );
    30 define( 'ERANKLY_URL', plugin_dir_url( __FILE__ ) );
    31 define( 'ERANKLY_OPTION', 'erankly_settings' );
    32 define( 'ERANKLY_VERSION_OPTION', 'erankly_version' );
    33 define( 'ERANKLY_SETUP_STATUS_OPTION', 'erankly_setup_wizard_status' );
    34 define( 'ERANKLY_REWRITE_FLUSH_OPTION', 'erankly_flush_rewrite_rules' );
    35 define( 'ERANKLY_SITEMAP_TRANSIENT_PREFIX', 'erankly_sitemap_' );
    36 define( 'ERANKLY_SITEMAP_CACHE_VERSION_OPTION', 'erankly_sitemap_cache_version' );
     22const VERSION     = '2.0.0';
     23const PLUGIN_FILE = __FILE__;
    3724
    38 require_once ERANKLY_PATH . 'includes/helpers.php';
     25require_once __DIR__ . '/src/autoload.php';
    3926
    40 /**
    41  * Gets a plugin option using network storage on Multisite.
    42  *
    43  * @param string $key           Option name.
    44  * @param mixed  $default_value Default value.
    45  * @return mixed
    46  */
    47 function erankly_get_plugin_option( string $key, mixed $default_value = false ): mixed {
    48         return is_multisite() ? get_site_option( $key, $default_value ) : get_option( $key, $default_value );
    49 }
    50 
    51 /**
    52  * Updates a plugin option using network storage on Multisite.
    53  *
    54  * @param string $key   Option name.
    55  * @param mixed  $value Value to store.
    56  * @return void
    57  */
    58 function erankly_update_plugin_option( string $key, mixed $value ): void {
    59         if ( is_multisite() ) {
    60                 update_site_option( $key, $value );
    61         } else {
    62                 // The settings array is read on every request, so autoload it; other options aren't.
    63                 update_option( $key, $value, ERANKLY_OPTION === $key );
    64         }
    65 
    66         if ( ERANKLY_OPTION === $key ) {
    67                 erankly_clear_settings_cache();
    68         }
    69 }
    70 require_once ERANKLY_PATH . 'includes/compatibility.php';
    71 require_once ERANKLY_PATH . 'includes/meta.php';
    72 require_once ERANKLY_PATH . 'includes/robots.php';
    73 require_once ERANKLY_PATH . 'includes/redirects.php';
    74 require_once ERANKLY_PATH . 'includes/seo-checklist.php';
    75 
    76 if ( is_admin() ) {
    77         require_once ERANKLY_PATH . 'includes/admin.php';
    78 }
    79 
    80 /**
    81  * Boots the plugin after all plugins are available for compatibility checks.
    82  *
    83  * @return void
    84  */
    85 function erankly_bootstrap(): void {
    86         add_action( 'init', 'erankly_register_meta' );
    87         add_action( 'init', 'erankly_register_rewrites' );
    88         add_action( 'init', 'erankly_maybe_flush_after_upgrade', 20 );
    89         add_action( 'init', 'erankly_maybe_flush_rewrite_rules', 30 );
    90 
    91         if ( is_multisite() ) {
    92                 require_once ERANKLY_PATH . 'includes/multilingual.php';
    93                 if ( erankly_multilingual_enabled() ) {
    94                         erankly_ml_boot();
    95                 }
    96         }
    97 
    98         if ( erankly_bloat_enabled() ) {
    99                 require_once ERANKLY_PATH . 'includes/bloat.php';
    100                 erankly_bloat_bootstrap();
    101         }
    102 
    103         // These core sitemap filters run unless another SEO plugin owns sitemaps, so per-post
    104         // "Hide from search results" still applies in /wp-sitemap.xml even when our Sitemap module is off.
    105         if ( ! erankly_should_suppress_sitemaps() ) {
    106                 require_once ERANKLY_PATH . 'includes/sitemap.php';
    107                 add_filter( 'wp_sitemaps_posts_query_args', 'erankly_filter_core_sitemap_posts_query_args', 20, 2 );
    108                 add_filter( 'wp_sitemaps_taxonomies_query_args', 'erankly_filter_core_sitemap_terms_query_args', 20, 2 );
    109                 add_filter( 'wp_sitemaps_post_types', 'erankly_filter_core_sitemap_post_types', 20 );
    110                 add_filter( 'wp_sitemaps_taxonomies', 'erankly_filter_core_sitemap_taxonomies', 20 );
    111                 add_filter( 'wp_sitemaps_add_provider', 'erankly_filter_core_sitemap_add_provider', 20, 2 );
    112 
    113                 if ( erankly_sitemap_enabled() ) {
    114                         // Specialised sitemaps (image, video, news) that require non-standard XML
    115                         // namespaces are still served as EasyRankly virtual files.
    116                         require_once ERANKLY_PATH . 'includes/class-erankly-specialist-sitemaps-provider.php';
    117                         add_action(
    118                                 'init',
    119                                 function () {
    120                                         wp_register_sitemap_provider( 'erankly', new ERankly_Specialist_Sitemaps_Provider() );
    121                                 }
    122                         );
    123                         add_action( 'template_redirect', 'erankly_maybe_render_virtual_files', 0 );
    124                 }
    125         }
    126 
    127         if ( erankly_sitemap_enabled() ) {
    128                 // Cache invalidation for specialised sitemaps (image / video / news).
    129                 add_action( 'save_post', 'erankly_flush_sitemap_cache_for_post' );
    130                 add_action( 'deleted_post', 'erankly_flush_sitemap_cache_for_deleted_post' );
    131                 add_action( 'transition_post_status', 'erankly_flush_sitemap_cache_for_status', 10, 3 );
    132                 add_action( 'profile_update', 'erankly_flush_sitemap_cache' );
    133                 add_action( 'user_register', 'erankly_flush_sitemap_cache' );
    134                 add_action( 'deleted_user', 'erankly_flush_sitemap_cache' );
    135                 add_action( 'created_term', 'erankly_flush_sitemap_cache' );
    136                 add_action( 'edited_term', 'erankly_flush_sitemap_cache' );
    137                 add_action( 'delete_term', 'erankly_flush_sitemap_cache' );
    138                 add_action( 'added_term_meta', 'erankly_flush_sitemap_cache_for_term_meta', 10, 3 );
    139                 add_action( 'updated_term_meta', 'erankly_flush_sitemap_cache_for_term_meta', 10, 3 );
    140                 add_action( 'deleted_term_meta', 'erankly_flush_sitemap_cache_for_term_meta', 10, 3 );
    141         }
    142 
    143         if ( erankly_health_enabled() ) {
    144                 require_once ERANKLY_PATH . 'includes/health.php';
    145                 erankly_health_boot();
    146         }
    147 
    148         if ( is_admin() ) {
    149                 erankly_admin_bootstrap();
    150         }
    151 
    152         erankly_seo_checklist_boot();
    153 
    154         if ( erankly_is_frontend_html_request() ) {
    155                 add_action( 'wp', 'erankly_bootstrap_frontend_modules', 1 );
    156         }
    157 
    158         add_action( 'rest_api_init', 'erankly_register_user_search_route' );
    159         add_filter( 'robots_txt', 'erankly_filter_robots_txt', 20, 2 );
    160         add_action( 'parse_request', 'erankly_force_robots_txt_request' );
    161         add_action( 'pre_get_posts', 'erankly_filter_visibility_queries' );
    162         add_action( 'added_post_meta', 'erankly_invalidate_visibility_exclusion_cache', 10, 3 );
    163         add_action( 'updated_post_meta', 'erankly_invalidate_visibility_exclusion_cache', 10, 3 );
    164         add_action( 'deleted_post_meta', 'erankly_invalidate_visibility_exclusion_cache', 10, 3 );
    165 
    166         if ( is_multisite() ) {
    167                 add_action( 'update_site_option_' . ERANKLY_OPTION, 'erankly_handle_network_settings_updated', 10, 3 );
    168                 add_action( 'wp_initialize_site', 'erankly_on_new_blog_site' );
    169         } else {
    170                 add_action( 'update_option_' . ERANKLY_OPTION, 'erankly_handle_settings_updated', 10, 2 );
    171         }
    172 }
    173 add_action( 'plugins_loaded', 'erankly_bootstrap', 5 );
    174 
    175 /**
    176  * Loads frontend-only modules after WordPress has resolved an HTML request.
    177  *
    178  * REST requests normally terminate before the wp hook, so they do not parse the
    179  * canonical, social, schema, or breadcrumb implementations.
    180  */
    181 function erankly_bootstrap_frontend_modules(): void {
    182         if ( (bool) erankly_get_setting( 'enable_breadcrumbs', 1 ) ) {
    183                 require_once ERANKLY_PATH . 'includes/breadcrumbs.php';
    184         } elseif ( ! function_exists( 'erankly_breadcrumbs' ) ) {
    185                 /**
    186                  * Preserves the public template API while the breadcrumb module is off.
    187                  *
    188                  * @param array<string,mixed> $args Ignored arguments.
    189                  * @return string
    190                  */
    191                 function erankly_breadcrumbs( array $args = array() ): string {
    192                         unset( $args );
    193                         return '';
    194                 }
    195         }
    196 
    197         // template_redirect runs after this wp:1 action, so the callback is defined in time.
    198         if ( 'none' !== (string) erankly_get_setting( 'attachment_redirect', 'none' ) ) {
    199                 add_action( 'template_redirect', 'erankly_redirect_attachment' );
    200         }
    201 
    202         if ( ! erankly_should_output_head() ) {
    203                 return;
    204         }
    205 
    206         require_once ERANKLY_PATH . 'includes/canonical.php';
    207         require_once ERANKLY_PATH . 'includes/opengraph.php';
    208         require_once ERANKLY_PATH . 'includes/schema.php';
    209 
    210         remove_action( 'wp_head', 'rel_canonical' );
    211         remove_action( 'wp_head', 'wp_generator' );
    212         add_filter( 'the_generator', '__return_empty_string' );
    213         add_filter( 'pre_get_document_title', 'erankly_filter_document_title', 20 );
    214         add_filter( 'document_title_parts', 'erankly_filter_document_title_parts', 20 );
    215         add_action( 'wp_head', 'erankly_render_head', 1 );
    216         add_filter( 'wp_robots', 'erankly_filter_wp_robots', 20 );
    217 }
    218 
    219 /**
    220  * Runs on plugin activation.
    221  *
    222  * @return void
    223  */
    224 function erankly_activate(): void {
    225         $is_new_install = false === erankly_get_plugin_option( ERANKLY_OPTION, false );
    226 
    227         if ( is_multisite() ) {
    228                 if ( $is_new_install ) {
    229                         add_site_option( ERANKLY_OPTION, erankly_default_settings() );
    230                 }
    231 
    232                 add_site_option( ERANKLY_VERSION_OPTION, ERANKLY_VERSION );
    233         } else {
    234                 if ( $is_new_install ) {
    235                         add_option( ERANKLY_OPTION, erankly_default_settings(), '', 'yes' );
    236                 }
    237 
    238                 add_option( ERANKLY_VERSION_OPTION, ERANKLY_VERSION, '', 'no' );
    239         }
    240 
    241         if ( $is_new_install ) {
    242                 erankly_update_plugin_option( ERANKLY_SETUP_STATUS_OPTION, 'pending' );
    243         }
    244 
    245         erankly_register_rewrites();
    246         flush_rewrite_rules();
    247 }
    248 register_activation_hook( ERANKLY_FILE, 'erankly_activate' );
    249 
    250 /**
    251  * Schedules a rewrite-rules flush whenever the plugin version changes.
    252  *
    253  * Runs on every init but exits immediately when the stored version matches.
    254  * On a version mismatch (i.e. after a plugin update) it updates the stored
    255  * version and sets the flush flag so erankly_maybe_flush_rewrite_rules()
    256  * rebuilds the rules on the same request.
    257  *
    258  * @return void
    259  */
    260 function erankly_maybe_flush_after_upgrade(): void {
    261         $stored = (string) erankly_get_plugin_option( ERANKLY_VERSION_OPTION, '' );
    262 
    263         if ( ERANKLY_VERSION === $stored ) {
    264                 return;
    265         }
    266 
    267         erankly_update_plugin_option( ERANKLY_VERSION_OPTION, ERANKLY_VERSION );
    268 
    269         if ( is_multisite() ) {
    270                 foreach ( get_sites( array( 'fields' => 'ids' ) ) as $site_id ) {
    271                         switch_to_blog( (int) $site_id );
    272                         update_option( ERANKLY_REWRITE_FLUSH_OPTION, 1, false );
    273                         restore_current_blog();
    274                 }
    275         } else {
    276                 update_option( ERANKLY_REWRITE_FLUSH_OPTION, 1, false );
    277         }
    278 }
    279 
    280 /**
    281  * Adapter for the update_site_option_ hook, which passes args in a different order.
    282  *
    283  * @param string $option    Option name.
    284  * @param mixed  $value     New value.
    285  * @param mixed  $old_value Previous value.
    286  * @return void
    287  */
    288 function erankly_handle_network_settings_updated( string $option, mixed $value, mixed $old_value ): void {
    289         erankly_handle_settings_updated( $old_value, $value );
    290 }
    291 
    292 /**
    293  * Initialises rewrite rules for a newly created network site.
    294  *
    295  * @param WP_Site $new_site The newly created site object.
    296  * @return void
    297  */
    298 function erankly_on_new_blog_site( WP_Site $new_site ): void {
    299         if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
    300                 require_once ABSPATH . 'wp-admin/includes/plugin.php';
    301         }
    302 
    303         if ( ! is_plugin_active_for_network( plugin_basename( ERANKLY_FILE ) ) ) {
    304                 return;
    305         }
    306 
    307         switch_to_blog( (int) $new_site->blog_id );
    308         erankly_register_rewrites();
    309         flush_rewrite_rules( false );
    310         restore_current_blog();
    311 }
    312 
    313 /**
    314  * Handles settings updates that affect feature bootstrapping.
    315  *
    316  * @param mixed $old_value Previous option value.
    317  * @param mixed $value     New option value.
    318  * @return void
    319  */
    320 function erankly_handle_settings_updated( mixed $old_value, mixed $value ): void {
    321         erankly_clear_settings_cache();
    322 
    323         $old_sitemap_enabled = is_array( $old_value ) && ! empty( $old_value['enable_sitemap'] );
    324         $new_sitemap_enabled = is_array( $value ) && ! empty( $value['enable_sitemap'] );
    325 
    326         if ( $old_sitemap_enabled !== $new_sitemap_enabled ) {
    327                 erankly_flush_sitemap_cache();
    328 
    329                 if ( is_multisite() ) {
    330                         foreach ( get_sites( array( 'fields' => 'ids' ) ) as $site_id ) {
    331                                 switch_to_blog( (int) $site_id );
    332                                 update_option( ERANKLY_REWRITE_FLUSH_OPTION, 1, false );
    333                                 restore_current_blog();
    334                         }
    335                 } else {
    336                         update_option( ERANKLY_REWRITE_FLUSH_OPTION, 1, false );
    337                 }
    338 
    339                 return;
    340         }
    341 
    342         if ( $new_sitemap_enabled ) {
    343                 erankly_flush_sitemap_cache();
    344         }
    345 }
    346 
    347 /**
    348  * Flushes rewrite rules after feature settings have changed.
    349  *
    350  * @return void
    351  */
    352 function erankly_maybe_flush_rewrite_rules(): void {
    353         if ( ! (bool) get_option( ERANKLY_REWRITE_FLUSH_OPTION, 0 ) ) {
    354                 return;
    355         }
    356 
    357         delete_option( ERANKLY_REWRITE_FLUSH_OPTION );
    358         flush_rewrite_rules( false );
    359 }
    360 
    361 /**
    362  * Runs on plugin deactivation.
    363  *
    364  * @return void
    365  */
    366 function erankly_deactivate(): void {
    367         // Clear the daily 404-retention cron; erankly_health_boot() reschedules it when active.
    368         wp_clear_scheduled_hook( 'erankly_health_prune_404_cron' );
    369 
    370         if ( is_multisite() ) {
    371                 foreach ( get_sites( array( 'fields' => 'ids' ) ) as $erankly_site_id ) {
    372                         switch_to_blog( (int) $erankly_site_id );
    373                         erankly_flush_sitemap_cache();
    374                         flush_rewrite_rules();
    375                         restore_current_blog();
    376                 }
    377         } else {
    378                 erankly_flush_sitemap_cache();
    379                 flush_rewrite_rules();
    380         }
    381 }
    382 register_deactivation_hook( ERANKLY_FILE, 'erankly_deactivate' );
    383 
    384 /**
    385  * Registers the REST route for the admin user search autocomplete.
    386  *
    387  * @return void
    388  */
    389 function erankly_register_user_search_route(): void {
    390         register_rest_route(
    391                 'erankly/v1',
    392                 '/users/search',
    393                 array(
    394                         'methods'             => WP_REST_Server::READABLE,
    395                         'callback'            => 'erankly_rest_user_search',
    396                         'permission_callback' => static fn() => current_user_can( 'manage_options' ) || current_user_can( 'manage_network_options' ),
    397                         'args'                => array(
    398                                 'q' => array(
    399                                         'default'           => '',
    400                                         'type'              => 'string',
    401                                         'sanitize_callback' => 'sanitize_text_field',
    402                                 ),
    403                         ),
    404                 )
    405         );
    406 }
    407 
    408 /**
    409  * Handles the user search REST request.
    410  *
    411  * Returns up to 20 users matching the query. The search is scoped to the caller's
    412  * capability: network administrators (manage_network_options) may search across the
    413  * whole network, while site administrators are restricted to the current site's users
    414  * to prevent cross-site user enumeration. On single-site the blog_id arg is ignored.
    415  *
    416  * @param WP_REST_Request $request Request object.
    417  * @return WP_REST_Response
    418  */
    419 function erankly_rest_user_search( WP_REST_Request $request ): WP_REST_Response {
    420         $query = (string) $request->get_param( 'q' );
    421 
    422         $args = array(
    423                 'number'  => 20,
    424                 'orderby' => 'display_name',
    425                 'order'   => 'ASC',
    426                 'fields'  => array( 'ID', 'display_name' ),
    427         );
    428 
    429         // Only network admins may enumerate users across the whole network; everyone
    430         // else (site admins) is restricted to the current site's users.
    431         if ( is_multisite() && current_user_can( 'manage_network_options' ) ) {
    432                 $args['blog_id'] = 0; // 0 = network-wide on multisite.
    433         } else {
    434                 $args['blog_id'] = get_current_blog_id(); // Restrict to the current site.
    435         }
    436 
    437         if ( '' !== $query ) {
    438                 $args['search']         = '*' . $query . '*';
    439                 $args['search_columns'] = array( 'user_login', 'user_nicename', 'display_name', 'user_email' );
    440         }
    441 
    442         $users   = get_users( $args ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.get_users_get_users -- intentional admin-only user lookup with strict capability check.
    443         $results = array();
    444 
    445         foreach ( $users as $user ) {
    446                 if ( ! isset( $user->ID, $user->display_name ) ) {
    447                         continue;
    448                 }
    449 
    450                 $results[] = array(
    451                         'id'   => (int) $user->ID,
    452                         /* translators: 1: User display name, 2: User ID. */
    453                         'text' => sprintf( __( '%1$s (ID: %2$d)', 'easyrankly' ), $user->display_name, $user->ID ),
    454                 );
    455         }
    456 
    457         return new WP_REST_Response( $results, 200 );
    458 }
     27( new Plugin() )->register();
  • easyrankly/trunk/readme.txt

    r3588731 r3759017  
    11=== EasyRankly ===
    22Contributors: easyrankly
    3 Tags: seo, schema, sitemap, redirects, breadcrumbs
    4 Requires at least: 6.5
    5 Tested up to: 7.0
     3Tags: seo, redirects, sitemap, multilingual
     4Requires at least: 7.0
     5Tested up to: 7.1
    66Requires PHP: 8.0
    7 Stable tag: 1.0.0
     7Stable tag: 2.0.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    1010
    11 Lightweight, modular, developer-first SEO essentials for WordPress.
     11Lightweight SEO built only on native WordPress APIs. No custom tables, no cron jobs, no frontend scripts.
    1212
    1313== Description ==
    1414
    15 EasyRankly provides only the technical SEO primitives a lean WordPress site needs:
     15EasyRankly covers the SEO basics of a WordPress site with the tools WordPress already has. It adds no database tables, schedules no background jobs and loads nothing extra for your visitors: the only thing it adds to your pages is their SEO tags and structured data.
    1616
    17 * Document title integration.
    18 * Meta description.
    19 * Canonical URL.
    20 * Robots meta through the native WordPress robots API.
    21 * Open Graph and Twitter cards.
    22 * Modular JSON-LD schema graph.
    23 * Extended Organization identity details and optional single-location LocalBusiness schema.
    24 * Optional XML sitemap index, post type sitemaps with image entries, taxonomy sitemaps, and SEO-aware author sitemap.
    25 * Virtual robots.txt integration.
    26 * Breadcrumb function.
    27 * Granular noindex, nofollow, noarchive, and sitemap exclusion controls.
    28 * Per-content search and archive query exclusion controls.
    29 * Global Schema settings with repeatable custom JSON-LD blocks.
    30 * Targeting rules for applying global schema blocks to front page, posts page, singular content, post type archives, and search results.
    31 * Dynamic variables for content metadata, social metadata, and schema fields.
    32 * Optional redirect management with exact, wildcard, and regex matching.
    33 * Optional Health monitoring with frequent 404 detection (no user data is tracked; paths are anonymized and stored locally).
    34 * Optional per-content breadcrumb names for shorter visible breadcrumbs and BreadcrumbList schema labels.
     17= What it does =
    3518
    36 It does not include keyword scoring, readability analysis, AI, analytics, tracking, internal linking suggestions, marketing widgets, or upsells.
     19* **Titles and descriptions.** Templates with variables for every content type, archive and term, with an override on each post, page and term.
     20* **Robots and canonical.** Noindex where it belongs (search results, empty archives, password-protected posts, feeds), a canonical URL on every page, and a per-post choice.
     21* **Social sharing.** Open Graph and X (Twitter) tags, with an image per post, the featured image or a default image.
     22* **Structured data.** One JSON-LD graph per page: Organization, local business or Person, WebSite, WebPage, Article on posts, ProfilePage on author archives and BreadcrumbList.
     23* **Breadcrumb.** Uses the WordPress Breadcrumbs block; its structured data follows what the block shows.
     24* **Sitemap.** Uses the sitemap built into WordPress and keeps it consistent with the robots rules: only content that can be indexed is listed.
     25* **Robots.txt.** Adds your rules to the robots.txt generated by WordPress, without writing files.
     26* **Redirects.** 301, 302 and 307, plus 410 (gone) and 451 (unavailable for legal reasons); exact addresses or regular expressions. A redirect is created by itself when you change the address of a published page.
     27* **Custom code.** HTML and PHP snippets in the head, after the opening body tag, in the footer or, for PHP, everywhere, with syntax check, revisions and a safe mode.
     28* **Import.** Copies the SEO fields of posts and terms from Yoast SEO and Rank Math, and their redirects (Yoast SEO Premium and Rank Math), without overwriting what you already set in EasyRankly.
     29* **Multilingual.** Languages with their own address prefix, translations linked to each other, hreflang, a language switcher block, and per-language site title, tagline, template parts and synced patterns. Needs a block theme.
     30
     31= What it does not do =
     32
     33* No content score or real-time readability analysis.
     34* No 404 log or hit counters.
     35* No news or video sitemaps, no IndexNow or Google Indexing API.
     36* No AI features: they belong to EasyRankly Pro, a separate paid plugin.
     37* No multisite-specific features: on a network, each site has its own settings.
     38
     39= Built on native WordPress APIs =
     40
     41* **No custom tables.** Everything is stored with WordPress options, meta, post types and taxonomies.
     42* **No cron jobs.** Nothing runs in the background on your server.
     43* **No frontend scripts.** Your visitors download nothing extra.
     44
     45= Source code =
     46
     47The JavaScript of the admin screens and of the block editor panels ships compiled in the `build` folder. Its source code is included in the plugin, in the `assets/src` folder, with `package.json` and `webpack.config.js`. To build it yourself, run `npm install` and `npm run build` in the plugin folder.
    3748
    3849== Installation ==
    3950
    40 1. Upload the `easyrankly` folder to `/wp-content/plugins/`.
    41 2. Activate EasyRankly from the Plugins screen.
    42 3. Complete the short setup wizard, or configure the plugin later under Settings > EasyRankly.
     511. In your WordPress admin, go to Plugins > Add New and search for "EasyRankly", or upload the zip file there.
     522. Activate the plugin.
     533. Open EasyRankly in the admin menu: check the titles, the indexing choices and who the site represents (Schema and social).
     544. Optional: add languages in EasyRankly > Languages, redirects in EasyRankly > Redirects and snippets in EasyRankly > Custom code.
     555. Coming from Yoast SEO or Rank Math? Deactivate it, then EasyRankly > Import copies its SEO fields and redirects.
     56
     57EasyRankly needs WordPress 7.0 and PHP 8.0 or later. Use one SEO plugin at a time: deactivate other SEO plugins before activating EasyRankly.
    4358
    4459== Frequently Asked Questions ==
    4560
    46 = Can I run EasyRankly alongside another SEO plugin such as Yoast SEO or Rank Math? =
     61= Can I use EasyRankly together with another SEO plugin? =
    4762
    48 Yes. EasyRankly detects active SEO plugins and automatically steps back from overlapping output (document title, meta description, canonical, robots meta, sitemaps, and robots.txt) so you never get duplicate tags. You can force any of it back on with the `erankly_enable_head_output`, `erankly_enable_sitemaps_with_external_seo`, and `erankly_enable_robots_txt_with_external_seo` filters.
     63No. Two SEO plugins print the same tags twice: title, description, canonical and structured data. Deactivate Yoast SEO, Rank Math or any other SEO plugin before activating EasyRankly; EasyRankly > Import copies their SEO fields and redirects.
    4964
    50 = Does it support WooCommerce? =
     65= Does it work on multisite? =
    5166
    52 Yes. EasyRankly can output Product JSON-LD structured data for WooCommerce products. It is controlled by the `erankly_woocommerce_structured_data_enabled` and `erankly_render_woocommerce_product_schema` filters.
     67Yes. Each site of the network has its own settings, redirects, snippets and languages, with nothing to set up for the network. There are no network-wide features. Deleting the plugin removes its data from every site of the network.
    5368
    54 = Does EasyRankly work on WordPress Multisite? =
     69= Does EasyRankly collect personal data or send data to external servers? =
    5570
    56 Yes. There is full Multisite support with network-level global settings, plus an optional multilingual module that links posts, pages, and terms across network sites and outputs hreflang alternates in the head and XML sitemaps.
     71No. It connects to no external service, does not track visitors and stores nothing about them: what it saves is your settings and SEO fields, in your WordPress database.
    5772
    58 = Does EasyRankly collect any personal data or phone home? =
     73= How do header, footer and patterns change language? =
    5974
    60 No. EasyRankly makes no outbound requests from your server: there is no analytics, tracking, or telemetry, and no data is sent anywhere. The optional Health 404 monitor stores only anonymized request paths in your own database; no user data is recorded. The only third-party reference is the public YouTube thumbnail URL added to the optional XML video sitemap for YouTube videos you have embedded yourself — see the External services section below.
     75In block themes, create a template part or a synced pattern with the same name followed by the language prefix: "header-en" for "header", "Banner - EN" for "Banner". On the English pages it replaces the original. This is also how each language gets its own menu: the Navigation block in "header-en" shows the English menu. Unsynced patterns become a copy where they are inserted, so they are translated with the page of each language.
    6176
    62 = How do I display breadcrumbs? =
     77= Are categories and tags translated? =
    6378
    64 Call `erankly_breadcrumbs()` in your theme template. You can customise the markup with the `erankly_breadcrumb_items` and `erankly_breadcrumbs_html` filters.
     79No, each category or tag is the same in every language, with its address in each language. Its SEO title, description and social texts, and its description, are written in the default language: on the pages of the other languages they are ignored and the title and description templates of that language are used.
    6580
    66 = Can I import my settings from Yoast SEO or Rank Math? =
     81= Are classic themes supported? =
    6782
    68 Yes. Open Settings > EasyRankly > Import/Export. It can import all plugin data from a JSON file and includes dedicated Yoast SEO and Rank Math importers for per-content meta.
     83SEO, sitemap, redirects and custom code work with every theme. Two features need a block theme. The breadcrumb uses the WordPress Breadcrumbs block, and its structured data (BreadcrumbList) is printed only when the block is part of the template, which classic themes render after the page head. Multilingual needs a block theme too: with a classic theme, contents, addresses, site title and tagline change with the language, but menus, widgets and texts set in the Customizer show the same text in every language.
    6984
    70 == Developer API ==
     85= What does the "Everywhere" position of Custom code do? =
    7186
    72 Available filters include:
     87It runs a PHP snippet as soon as the plugins are loaded, on every request, admin included, like the functions.php of a theme: use it to add or remove hooks. It never runs on the Custom code screen when you open it, so a broken snippet can always be fixed there. What the snippet prints at that moment is dropped. If a snippet locks you out, add `define( 'EASYRANKLY_SAFE_MODE', true );` to wp-config.php: no snippet runs until you remove it.
    7388
    74 * `erankly_breadcrumb_items`
    75 * `erankly_breadcrumbs_html`
    76 * `erankly_canonical`
    77 * `erankly_description`
    78 * `erankly_enable_head_output`
    79 * `erankly_enable_robots_txt_with_external_seo`
    80 * `erankly_enable_sitemaps_with_external_seo`
    81 * `erankly_faq_items`
    82 * `erankly_head_credit_name`
    83 * `erankly_health_404_sample_rate`
    84 * `erankly_hreflang_alternates`
    85 * `erankly_image_sitemap_url`
    86 * `erankly_include_user_sitemap`
    87 * `erankly_local_business_types`
    88 * `erankly_localized_url`
    89 * `erankly_ml_default_notice`
    90 * `erankly_ml_language_native_name`
    91 * `erankly_navigable_hreflang_alternates`
    92 * `erankly_news_sitemap_post_types`
    93 * `erankly_news_sitemap_publication_language`
    94 * `erankly_news_sitemap_publication_name`
    95 * `erankly_news_sitemap_url`
    96 * `erankly_og_description`
    97 * `erankly_og_image`
    98 * `erankly_og_title`
    99 * `erankly_opengraph_tags`
    100 * `erankly_organization_schema_details`
    101 * `erankly_post_breadcrumb_name`
    102 * `erankly_post_types`
    103 * `erankly_redirect_hit_sample_rate`
    104 * `erankly_render_woocommerce_product_schema`
    105 * `erankly_robots`
    106 * `erankly_robots_txt_lines`
    107 * `erankly_schema`
    108 * `erankly_schema_article`
    109 * `erankly_schema_blogposting`
    110 * `erankly_schema_breadcrumb_list`
    111 * `erankly_schema_faq`
    112 * `erankly_schema_local_business`
    113 * `erankly_schema_localbusiness`
    114 * `erankly_schema_organization`
    115 * `erankly_schema_person`
    116 * `erankly_schema_service`
    117 * `erankly_schema_webpage`
    118 * `erankly_schema_website`
    119 * `erankly_seo_checklist_docs_url`
    120 * `erankly_settings_tabs`
    121 * `erankly_sitemap_images`
    122 * `erankly_sitemap_post_types`
    123 * `erankly_special_pages`
    124 * `erankly_taxonomies`
    125 * `erankly_title`
    126 * `erankly_twitter_card_type`
    127 * `erankly_twitter_description`
    128 * `erankly_twitter_image`
    129 * `erankly_twitter_site`
    130 * `erankly_twitter_title`
    131 * `erankly_video_sitemap_url`
    132 * `erankly_woocommerce_structured_data_enabled`
     89= Who can use PHP snippets? =
    13390
    134 Available actions include:
     91Only users who can edit plugin files (the `edit_plugins` capability, which on a standard site means administrators). PHP snippets are switched off on sites that define `DISALLOW_FILE_EDIT` or `DISALLOW_FILE_MODS` in wp-config.php: existing ones stop running and new ones cannot be saved. HTML snippets need the `unfiltered_html` capability, which administrators have on a single site and only super admins have on a multisite network.
    13592
    136 * `erankly_redirects_caches_flushed`
    137 * `erankly_render_settings_tab_{$slug}`
     93= What happens when a PHP snippet breaks? =
    13894
    139 Use `erankly_breadcrumbs()` to render breadcrumbs.
     95The code is checked for syntax errors before it is saved, and a snippet that does not parse can be saved only as inactive. If an active snippet throws an error, the error is shown to administrators and the snippet stays on, so visitors cannot switch it off; a fatal error, which breaks the page, switches it off. If a snippet locks you out anyway, add `define( 'EASYRANKLY_SAFE_MODE', true );` to wp-config.php: no snippet runs until you remove it.
    14096
    141 When the Multisite multilingual module is enabled, the `[erankly_language_switcher]` and `[erankly_translation_notice]` shortcodes are available.
     97= How are password-protected posts handled? =
     98
     99They are noindex and left out of the sitemap. Their automatic description is empty, so their text never appears in the page source; a description you write for the post yourself is still used.
     100
     101= Are empty archives indexed? =
     102
     103No. A category, tag, author or post type archive with nothing in it is noindex until it has content. A category or tag with its own description stays indexable, since the description is content.
     104
     105= Are feeds and attachment pages indexed? =
     106
     107Feeds are never indexed: they send an `X-Robots-Tag: noindex, follow` header, so search engines still follow their links. Attachment pages, which show only the uploaded file, are off by default on sites created with WordPress 6.4 or later. On older sites, turn them off in EasyRankly > Indexing: their address then leads to the file. This is the WordPress setting, so it stays as you set it if you remove EasyRankly.
     108
     109= Does it work with WooCommerce and ACF? =
     110
     111Yes, without conflicts. The shop page gives the product archive its SEO title, description and canonical, cart, checkout and account pages stay out of the sitemap, and WooCommerce keeps printing its own Product structured data. Products, and any content edited with the classic editor, get the SEO and Language fields in boxes under the editor. Not supported yet: multilingual stores. With ACF, title and description templates can use a custom field with {{cf:field_name}}; values saved inside ACF blocks are not read.
    142112
    143113== External services ==
    144114
    145 EasyRankly does not call any external service from your server. It performs no analytics, tracking, or telemetry, and sends no data to any remote endpoint.
    146 
    147 The only third-party reference appears in the optional XML video sitemap. When the video sitemap is enabled and a post embeds a YouTube video, EasyRankly adds that video's public thumbnail URL (https://img.youtube.com/vi/&lt;video-id&gt;/0.jpg) to the sitemap so search engines can show a preview. The video ID is taken from the YouTube video you embedded yourself; EasyRankly does not contact YouTube — the URL is only written into your sitemap and is fetched by search-engine crawlers, not by your site. No personal data is involved.
    148 
    149 This thumbnail service is provided by YouTube (Google LLC). Terms of Service: https://www.youtube.com/t/terms — Privacy Policy: https://policies.google.com/privacy
     115EasyRankly does not connect to any external service.
    150116
    151117== Changelog ==
    152118
    153 = 1.0.0 =
    154 Release date: June 14, 2026
    155 
    156 * First public release.
     119= 2.0.0 =
     120* Complete rewrite on native WordPress APIs: titles and descriptions, robots, canonical, social tags, structured data, breadcrumb, sitemap, robots.txt, redirects, custom code and multilingual.
     121* Requires WordPress 7.0 and PHP 8.0.
     122* Import of the SEO fields of posts and terms from Yoast SEO and Rank Math, and of their redirects (Yoast SEO Premium and Rank Math) (EasyRankly > Import).
     123* Settings, SEO fields and redirects of version 1.0.0 are not imported.
    157124
    158125== Upgrade Notice ==
    159126
    160 = 1.0.0 =
    161 First public release of EasyRankly.
     127= 2.0.0 =
     128Complete rewrite. Settings, SEO fields and redirects of 1.0.0 are not imported: set them again after updating. Requires WordPress 7.0 and PHP 8.0.
  • easyrankly/trunk/uninstall.php

    r3588731 r3759017  
    11<?php
    22/**
    3  * Plugin uninstall cleanup.
     3 * Removes everything EasyRankly created on the site, or on every site of the network on multisite.
     4 *
     5 * Every option, meta key, post type and taxonomy listed in docs/data-model.md
     6 * must be deleted here, and tests/UninstallTest.php must prove it.
    47 *
    58 * @package EasyRankly
    69 */
    710
    8 if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    9         exit;
     11defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
     12
     13// Everything EasyRankly stores lives in the tables of a single site, so the cleanup runs once per site.
     14$easyrankly_cleanup = static function (): void {
     15        // Redirects (src/Redirects/Redirects.php) and snippets (src/CustomCode/CustomCode.php): posts with
     16        // their meta and revisions first, then the caches built from them. Each post is tried once, so
     17        // one that cannot be deleted does not stop the others: in ID order, the ones left behind are always
     18        // the first ones, and the offset skips them.
     19        $easyrankly_failed = 0;
     20        do {
     21                $easyrankly_ids = get_posts(
     22                        array(
     23                                'post_type'      => array( 'erankly_redirect', 'erankly_snippet' ),
     24                                'post_status'    => array_keys( get_post_stati() ),
     25                                'orderby'        => 'ID',
     26                                'order'          => 'ASC',
     27                                'offset'         => $easyrankly_failed,
     28                                'posts_per_page' => 100,
     29                                'fields'         => 'ids',
     30                                'no_found_rows'  => true,
     31                        )
     32                );
     33                foreach ( $easyrankly_ids as $easyrankly_id ) {
     34                        if ( ! wp_delete_post( (int) $easyrankly_id, true ) ) {
     35                                ++$easyrankly_failed;
     36                        }
     37                }
     38        } while ( array() !== $easyrankly_ids );
     39
     40        // Languages and translation groups (src/Multilingual/): the plugin is not loaded here, so the
     41        // taxonomies are registered (if missing) just to delete their terms, which removes the relationships too.
     42        foreach ( array( 'erankly_language', 'erankly_translation' ) as $easyrankly_taxonomy ) {
     43                if ( ! taxonomy_exists( $easyrankly_taxonomy ) ) {
     44                        register_taxonomy( $easyrankly_taxonomy, array() );
     45                }
     46                $easyrankly_failed = 0;
     47                do {
     48                        $easyrankly_terms = get_terms(
     49                                array(
     50                                        'taxonomy'   => $easyrankly_taxonomy,
     51                                        'hide_empty' => false,
     52                                        'orderby'    => 'term_id',
     53                                        'order'      => 'ASC',
     54                                        'offset'     => $easyrankly_failed,
     55                                        'fields'     => 'ids',
     56                                        'number'     => 100,
     57                                )
     58                        );
     59                        $easyrankly_terms = is_array( $easyrankly_terms ) ? $easyrankly_terms : array();
     60                        foreach ( $easyrankly_terms as $easyrankly_term ) {
     61                                if ( true !== wp_delete_term( (int) $easyrankly_term, $easyrankly_taxonomy ) ) {
     62                                        ++$easyrankly_failed;
     63                                }
     64                        }
     65                } while ( array() !== $easyrankly_terms );
     66        }
     67
     68        // Notices about redirects not created after an address change (src/Redirects/SlugChanges.php): one
     69        // transient per user, only for users whose role can change addresses. Others, if any, expire in a day
     70        // and the core removes them with the other expired transients.
     71        $easyrankly_offset = 0;
     72        do {
     73                $easyrankly_users = get_users(
     74                        array(
     75                                'capability__in' => array( 'edit_pages', 'edit_posts', 'manage_categories' ),
     76                                'fields'         => 'ID',
     77                                'number'         => 500,
     78                                'offset'         => $easyrankly_offset,
     79                                'orderby'        => 'ID',
     80                                'count_total'    => false,
     81                        )
     82                );
     83                foreach ( $easyrankly_users as $easyrankly_user ) {
     84                        delete_transient( 'easyrankly_redirect_notice_' . (int) $easyrankly_user );
     85                }
     86                $easyrankly_offset += 500;
     87        } while ( array() !== $easyrankly_users );
     88
     89        delete_option( 'easyrankly_settings' );
     90        delete_option( 'easyrankly_redirects_forced' );
     91        delete_option( 'easyrankly_redirects_regex' );
     92        delete_option( 'easyrankly_snippets' );
     93        delete_option( 'easyrankly_import' );
     94
     95        // SEO meta of posts and terms (src/Meta/Meta.php).
     96        foreach (
     97                array(
     98                        '_easyrankly_title',
     99                        '_easyrankly_description',
     100                        '_easyrankly_canonical',
     101                        '_easyrankly_noindex',
     102                        '_easyrankly_nofollow',
     103                        '_easyrankly_og_title',
     104                        '_easyrankly_og_description',
     105                        '_easyrankly_og_image',
     106                ) as $easyrankly_key
     107        ) {
     108                delete_metadata( 'post', 0, $easyrankly_key, '', true );
     109                delete_metadata( 'term', 0, $easyrankly_key, '', true );
     110        }
     111};
     112
     113// On multisite the plugin can be active on any site of any network, and WordPress runs uninstall.php once, on the
     114// site where it is deleted: every site is cleaned, in batches so large networks do not load all sites at once.
     115if ( is_multisite() ) {
     116        $easyrankly_offset = 0;
     117        do {
     118                $easyrankly_sites = get_sites(
     119                        array(
     120                                'fields'                 => 'ids',
     121                                'number'                 => 100,
     122                                'offset'                 => $easyrankly_offset,
     123                                'orderby'                => 'id',
     124                                'update_site_meta_cache' => false,
     125                        )
     126                );
     127                foreach ( $easyrankly_sites as $easyrankly_site ) {
     128                        switch_to_blog( (int) $easyrankly_site );
     129                        $easyrankly_cleanup();
     130                        restore_current_blog();
     131                }
     132                $easyrankly_offset += 100;
     133        } while ( array() !== $easyrankly_sites );
     134} else {
     135        $easyrankly_cleanup();
    10136}
    11 
    12 global $wpdb;
    13 
    14 /**
    15  * Removes per-site options, the redirect table, post meta and transients for one site.
    16  *
    17  * Global settings (erankly_settings, erankly_version) are handled separately
    18  * because on Multisite they are stored as network options and must be deleted once.
    19  *
    20  * @return void
    21  */
    22 function erankly_uninstall_site(): void {
    23         global $wpdb;
    24 
    25         wp_clear_scheduled_hook( 'erankly_health_prune_404_cron' );
    26 
    27         delete_option( 'erankly_redirects_db_version' );
    28         delete_option( 'erankly_redirects_runtime_rules' );
    29         delete_option( 'erankly_flush_rewrite_rules' );
    30         delete_option( 'erankly_sitemap_cache_version' );
    31         delete_option( 'erankly_health_404_candidates' );
    32         delete_option( 'erankly_health_404_frequent' );
    33         delete_option( 'erankly_health_thin_content' );
    34 
    35         $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . 'erankly_redirects' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Uninstall cleanup removes the plugin-owned redirects table.
    36 
    37         $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Uninstall cleanup removes plugin-owned post meta.
    38                 $wpdb->prepare(
    39                         "DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    40                         $wpdb->esc_like( '_erankly_' ) . '%'
    41                 )
    42         );
    43         $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Uninstall cleanup removes plugin-owned term meta.
    44                 $wpdb->prepare(
    45                         "DELETE FROM {$wpdb->termmeta} WHERE meta_key LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    46                         $wpdb->esc_like( '_erankly_' ) . '%'
    47                 )
    48         );
    49         $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Uninstall cleanup removes plugin-owned sitemap transients.
    50                 $wpdb->prepare(
    51                         "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    52                         $wpdb->esc_like( '_transient_erankly_sitemap_' ) . '%',
    53                         $wpdb->esc_like( '_transient_timeout_erankly_sitemap_' ) . '%'
    54                 )
    55         );
    56 }
    57 
    58 if ( is_multisite() ) {
    59         // Global settings are stored as network options — delete once for the entire network.
    60         delete_site_option( 'erankly_settings' );
    61         delete_site_option( 'erankly_version' );
    62         delete_site_option( 'erankly_setup_wizard_status' );
    63 
    64         // Multilingual module data: network options and the network-wide relations table.
    65         delete_site_option( 'erankly_ml_sites' );
    66         delete_site_option( 'erankly_ml_db_version' );
    67         $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->base_prefix . 'erankly_ml_relations' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Uninstall cleanup removes the plugin-owned multilingual relations table.
    68 
    69         // Per-site data must be cleaned up on every site.
    70         $erankly_site_ids = get_sites(
    71                 array(
    72                         'fields' => 'ids',
    73                 )
    74         );
    75 
    76         foreach ( $erankly_site_ids as $erankly_site_id ) {
    77                 switch_to_blog( (int) $erankly_site_id );
    78                 erankly_uninstall_site();
    79                 restore_current_blog();
    80         }
    81 } else {
    82         delete_option( 'erankly_settings' );
    83         delete_option( 'erankly_version' );
    84         delete_option( 'erankly_setup_wizard_status' );
    85         erankly_uninstall_site();
    86 }
Note: See TracChangeset for help on using the changeset viewer.