# thinkrank/2.1.0/includes/cleanup-webroot.php

ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console &amp; Local SEO, version 2.1.0. 536 lines.

- Page: https://pluginprobe.com/plugins/thinkrank/2.1.0/code/includes/cleanup-webroot.php
- Raw: https://pluginprobe.com/plugins/thinkrank/2.1.0/raw/includes/cleanup-webroot.php
- Modified: 2026-08-27T08:35:14+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/thinkrank/2.1.0/code/includes/cleanup-webroot.php#L10-L20`.

```php
<?php
/**
 * Everything ThinkRank publishes into the WordPress web root, and how to take
 * it back out again.
 *
 * ThinkRank does not serve its sitemap, robots.txt or llms.txt through rewrite
 * rules — it writes real files into `ABSPATH` and lets the web server hand them
 * out. That makes removal a filesystem problem, not just a database one, and it
 * has a consequence the database-only cleanup missed entirely (#510): a real
 * file at `/sitemap_index.xml` is served before WordPress boots, so every other
 * SEO plugin — RankMath, Squirrly, core's own `wp-sitemap.xml` — is shadowed by
 * a file belonging to a plugin that is no longer installed. The site owner sees
 * a blank sitemap (our XSL stylesheet 404s with the plugin gone) and no way to
 * connect it to us.
 *
 * The logic lives here, as plain prefixed functions rather than a class,
 * because `uninstall.php` runs with no autoloader — `WP_UNINSTALL_PLUGIN` loads
 * it without the plugin — and the deactivator and the sitemap generator all
 * need the same filename derivation. Same reasoning as
 * {@see includes/cleanup-manifest.php}, which both removal paths already share:
 * one copy, not three that drift.
 *
 * Nothing here deletes a file we cannot show is ours. Sitemap names are derived
 * from the stored settings (never a `sitemap*.xml` glob, which would eat another
 * plugin's file); robots.txt is only removed when it carries our generated
 * header; llms.txt only when we recorded publishing it.
 *
 * @package ThinkRank
 * @since 2.1.0
 */

declare(strict_types=1);

if (!defined('ABSPATH')) {
    exit;
}

/**
 * The `.htaccess` marker the llms.txt charset block is written under.
 *
 * Mirrors `Llms_Txt_Manager::HTACCESS_MARKER`, which is private and — like
 * everything else here — unreachable during uninstall.
 */
if (!defined('THINKRANK_LLMS_HTACCESS_MARKER')) {
    define('THINKRANK_LLMS_HTACCESS_MARKER', 'ThinkRank llms.txt');
}

/**
 * The first line of every robots.txt ThinkRank generates.
 *
 * Mirrors `Site_Identity_Manager::robots_txt_header()`. This is the ownership
 * test for robots.txt: a file without it predates us or belongs to someone
 * else, and must survive our removal untouched.
 */
if (!defined('THINKRANK_ROBOTS_HEADER')) {
    define('THINKRANK_ROBOTS_HEADER', '# Robots.txt generated by ThinkRank SEO');
}

if (!function_exists('thinkrank_webroot_primary_sitemap_filename')) {
    /**
     * The sitemap file the site publishes for the given settings.
     *
     * Index mode serves the index (`sitemap_index.xml`); the default single-file
     * mode serves `sitemap.xml`. A configured `sitemap_urls` entry matching the
     * current mode wins over both.
     *
     * @since 2.1.0
     *
     * @param array $settings Sitemap settings.
     * @return string Sitemap filename.
     */
    function thinkrank_webroot_primary_sitemap_filename(array $settings): string {
        $use_index = !empty($settings['use_sitemap_index']);

        foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) {
            if (empty($config['enabled']) || empty($config['url'])) {
                continue;
            }

            if ($use_index !== (($config['type'] ?? '') === 'index')) {
                continue;
            }

            $path = wp_parse_url($config['url'], PHP_URL_PATH);
            if (!empty($path)) {
                return basename($path);
            }
        }

        return $use_index ? 'sitemap_index.xml' : 'sitemap.xml';
    }
}

if (!function_exists('thinkrank_webroot_segment_filenames')) {
    /**
     * Every child-sitemap filename this site could have published.
     *
     * Formats the configured url pattern against each type ThinkRank segments
     * by — the four built-ins plus every public custom post type and public
     * custom taxonomy — ignoring whether that type is currently included. The
     * point is to recognise our own filenames, and a type that was published
     * and later disabled still left a file behind.
     *
     * During uninstall the plugin is not loaded, so post types and taxonomies
     * registered by ThinkRank itself are absent from these lists. The built-ins
     * and every other plugin's public types are still registered, which covers
     * what a sitemap actually segments by.
     *
     * @since 2.1.0
     *
     * @param array $settings Sitemap settings (read for `custom_url_pattern`).
     * @return string[] Basenames, e.g. ['sitemap-posts.xml', 'sitemap-pages.xml'].
     */
    function thinkrank_webroot_segment_filenames(array $settings): array {
        $pattern = (string) ($settings['custom_url_pattern'] ?? 'sitemap-{type}.xml');
        if (strpos($pattern, '{type}') === false) {
            return [];
        }

        $types = ['posts', 'pages', 'categories', 'tags'];

        foreach (get_post_types(['public' => true, '_builtin' => false], 'names') as $cpt) {
            $types[] = (string) $cpt;
        }

        foreach (get_taxonomies(['public' => true, '_builtin' => false], 'names') as $taxonomy) {
            $types[] = (string) $taxonomy;
        }

        $names = [];
        foreach (array_unique($types) as $type) {
            $name = basename(str_replace('{type}', $type, $pattern));
            if ($name !== '') {
                $names[] = $name;
            }
        }

        return $names;
    }
}

if (!function_exists('thinkrank_webroot_sitemap_filenames')) {
    /**
     * Every sitemap basename ThinkRank could have written to the web root.
     *
     * The current primary plus the two default names as a safety net (settings
     * can have drifted from what is on disk), every configured `sitemap_urls`
     * entry, the local-business sitemap, and every segment the url pattern can
     * produce. Pagination pages are not listed — they are matched per stem at
     * deletion time, where the numeric suffix can be checked.
     *
     * @since 2.1.0
     *
     * @param array $settings Sitemap settings.
     * @return string[] Unique, non-empty basenames.
     */
    function thinkrank_webroot_sitemap_filenames(array $settings): array {
        $names = [
            'sitemap.xml',
            'sitemap_index.xml',
            'local-sitemap.xml',
            thinkrank_webroot_primary_sitemap_filename($settings),
        ];

        foreach ((is_array($settings['sitemap_urls'] ?? null) ? $settings['sitemap_urls'] : []) as $config) {
            if (empty($config['url'])) {
                continue;
            }
            $name = basename((string) wp_parse_url($config['url'], PHP_URL_PATH));
            if ($name !== '') {
                $names[] = $name;
            }
        }

        $names = array_merge($names, thinkrank_webroot_segment_filenames($settings));

        return array_values(array_unique(array_filter($names)));
    }
}

if (!function_exists('thinkrank_webroot_delete_sitemaps')) {
    /**
     * Remove every static sitemap file ThinkRank publishes to the web root.
     *
     * Only ThinkRank's own filenames are targeted; WordPress core's
     * `wp-sitemap.xml` and any other plugin's sitemap in the web root are left
     * untouched.
     *
     * @since 2.1.0
     *
     * @param array $settings Sitemap settings to derive the names from.
     * @return array{deleted: string[], failed: string[]} Basenames removed, and
     *                                                    those that existed but
     *                                                    could not be removed.
     */
    function thinkrank_webroot_delete_sitemaps(array $settings): array {
        $deleted = [];
        $failed  = [];

        foreach (thinkrank_webroot_sitemap_filenames($settings) as $name) {
            // Remove the file itself and any paginated -N variants of its stem
            // (e.g. seo-posts.xml plus seo-posts-2.xml, seo-posts-3.xml…).
            $path = ABSPATH . $name;
            if (file_exists($path)) {
                wp_delete_file($path);
                // wp_delete_file() returns nothing, so confirm by re-checking.
                if (file_exists($path)) {
                    $failed[] = $name;
                } else {
                    $deleted[] = $name;
                }
            }

            if (!preg_match('/^(.*)\.xml$/i', $name, $m)) {
                continue;
            }

            // Pagination pages only — a numeric suffix on this exact stem.
            // Globbing '<stem>-*.xml' matched any name that merely started with
            // the stem, so the default 'sitemap.xml' entry pulled in every
            // sitemap-*.xml in the root, including another plugin's.
            $paged_pattern = '/^' . preg_quote($m[1], '/') . '-\d+\.xml$/i';

            foreach (glob(ABSPATH . $m[1] . '-*.xml') ?: [] as $paged) {
                $paged_name = basename($paged);
                if (!preg_match($paged_pattern, $paged_name)) {
                    continue;
                }

                wp_delete_file($paged);
                if (file_exists($paged)) {
                    $failed[] = $paged_name;
                } else {
                    $deleted[] = $paged_name;
                }
            }
        }

        return [
            'deleted' => array_values(array_unique($deleted)),
            'failed'  => array_values(array_unique($failed)),
        ];
    }
}

if (!function_exists('thinkrank_webroot_read_sitemap_settings')) {
    /**
     * Read the saved site sitemap settings straight from the settings table.
     *
     * The removal paths cannot go through `Sitemap_Generator::get_settings()` —
     * uninstall has no autoloader, and by deactivation time we still want the
     * values but not the hook wiring an instance brings. Reading the four
     * columns directly is enough, and an empty array is a safe answer: the
     * derivation functions fall back to ThinkRank's default filenames, which is
     * exactly the set a site that never customised anything published.
     *
     * @since 2.1.0
     *
     * @return array Sitemap settings, or an empty array when unreadable.
     */
    function thinkrank_webroot_read_sitemap_settings(): array {
        global $wpdb;

        if (!isset($wpdb) || !is_object($wpdb)) {
            return [];
        }

        $table = $wpdb->prefix . 'thinkrank_seo_settings';

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Removal path; the object cache is being torn down alongside us.
        $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
        if ($exists !== $table) {
            return [];
        }

        // The table name cannot be a placeholder, so it is interpolated — from
        // $wpdb->prefix, and only after the SHOW TABLES check above matched it
        // exactly. Every value is a placeholder.
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, verified above.
        $sql = sprintf(
            'SELECT setting_key, setting_value FROM `%s` WHERE context_type = %%s AND context_id = %%d AND setting_category = %%s AND is_active = 1',
            $table
        );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Removal path; the object cache is being torn down alongside us.
        $rows = $wpdb->get_results(
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $sql carries the three placeholders the sniff cannot see through sprintf().
            $wpdb->prepare($sql, 'site', 0, 'sitemap'),
            ARRAY_A
        );

        if (!is_array($rows)) {
            return [];
        }

        $settings = [];
        foreach ($rows as $row) {
            $settings[$row['setting_key']] = maybe_unserialize($row['setting_value']);
        }

        return $settings;
    }
}

if (!function_exists('thinkrank_webroot_delete_robots_txt')) {
    /**
     * Remove `ABSPATH/robots.txt`, but only when ThinkRank wrote it.
     *
     * A robots.txt that predates ThinkRank — or that another plugin owns — has
     * no generated header, and deleting it would destroy crawl rules we never
     * created. The header check is the whole safety story here, so an
     * unreadable file is treated as not-ours and kept.
     *
     * The stored `robots_txt_content` setting is untouched: on reinstall or
     * reactivation the file is rebuilt from it, so nothing the user typed is
     * lost by removing the artifact.
     *
     * @since 2.1.0
     *
     * @return array{deleted: string[], failed: string[]}
     */
    function thinkrank_webroot_delete_robots_txt(): array {
        $path = ABSPATH . 'robots.txt';

        if (!file_exists($path) || !is_readable($path)) {
            return ['deleted' => [], 'failed' => []];
        }

        // Only the header line is needed; a robots.txt large enough for the rest
        // to matter is still ours or still not.
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a local web-root file during removal; WP_Filesystem would need credentials on some hosts.
        $head = (string) file_get_contents($path, false, null, 0, 128);

        if (strpos($head, THINKRANK_ROBOTS_HEADER) !== 0) {
            // Someone else's file. Leave it exactly as it is.
            return ['deleted' => [], 'failed' => []];
        }

        wp_delete_file($path);

        return file_exists($path)
            ? ['deleted' => [], 'failed' => ['robots.txt']]
            : ['deleted' => ['robots.txt'], 'failed' => []];
    }
}

if (!function_exists('thinkrank_webroot_remove_htaccess_block')) {
    /**
     * Strip a `# BEGIN <marker> … # END <marker>` block from `ABSPATH/.htaccess`.
     *
     * Strips the block outright rather than calling `insert_with_markers()` with
     * an empty insertion — that leaves the BEGIN/END markers behind as litter.
     *
     * @since 2.1.0
     *
     * @param string $marker The marker name, without the BEGIN/END words.
     * @return bool True when a block was found and removed.
     */
    function thinkrank_webroot_remove_htaccess_block(string $marker): bool {
        $htaccess = ABSPATH . '.htaccess';

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- A writability probe, not a write; WP_Filesystem would need credentials on some hosts.
        if (!file_exists($htaccess) || !is_readable($htaccess) || !is_writable($htaccess)) {
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local web-root file during removal; WP_Filesystem would need credentials on some hosts.
        $contents = file_get_contents($htaccess);
        if (!is_string($contents) || strpos($contents, '# BEGIN ' . $marker) === false) {
            return false;
        }

        $quoted  = preg_quote($marker, '/');
        $cleaned = preg_replace(
            '/\R*# BEGIN ' . $quoted . '.*?# END ' . $quoted . '[ \t]*\R?/s',
            '',
            $contents
        );

        if (!is_string($cleaned)) {
            return false;
        }

        // A file left holding nothing but our (now removed) block was ours to
        // begin with — a pre-existing .htaccess would still have content.
        if (trim($cleaned) === '') {
            wp_delete_file($htaccess);
            return !file_exists($htaccess);
        }

        // Keep the file newline-terminated after the block is cut out.
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents,WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Local web-root file during removal; WP_Filesystem would need credentials on some hosts.
        return false !== file_put_contents($htaccess, rtrim($cleaned, "\r\n") . "\n");
    }
}

if (!function_exists('thinkrank_webroot_delete_llms_txt')) {
    /**
     * Remove `ABSPATH/llms.txt` and its `.htaccess` charset block.
     *
     * Ownership is recorded rather than sniffed: the file is only removed when
     * `thinkrank_llms_txt_published_at` says we published one. llms.txt has no
     * generated header to test — the document is entirely the user's prose — so
     * a site that hand-wrote its own llms.txt before installing ThinkRank keeps
     * it.
     *
     * The stored document (`thinkrank_llms_txt_content`) is left in place so a
     * reactivation can republish it. Uninstall's own option sweep removes it
     * afterwards when the user asked for their data to go.
     *
     * @since 2.1.0
     *
     * @return array{deleted: string[], failed: string[]}
     */
    function thinkrank_webroot_delete_llms_txt(): array {
        $deleted = [];
        $failed  = [];

        if (get_option('thinkrank_llms_txt_published_at', null) === null) {
            // We have no record of publishing it — so it is not ours to remove.
            return ['deleted' => $deleted, 'failed' => $failed];
        }

        $path = ABSPATH . 'llms.txt';
        if (file_exists($path)) {
            wp_delete_file($path);
            if (file_exists($path)) {
                $failed[] = 'llms.txt';
            } else {
                $deleted[] = 'llms.txt';
            }
        }

        // The charset block only exists on Apache/LiteSpeed, and only alongside
        // a static publish — but strip it whenever it is there, since it names
        // a file that no longer is.
        if (thinkrank_webroot_remove_htaccess_block(THINKRANK_LLMS_HTACCESS_MARKER)) {
            $deleted[] = '.htaccess (llms.txt charset block)';
        }

        return ['deleted' => $deleted, 'failed' => $failed];
    }
}

if (!function_exists('thinkrank_webroot_delete_indexnow_key')) {
    /**
     * Remove the IndexNow key file the activator drops in the web root.
     *
     * `Activator::setup_indexnow_key()` writes `ABSPATH/<32-hex-key>.txt`, whose
     * name is the key itself. Only the exact filename recorded in the settings
     * is removed — never a `*.txt` sweep — and only when it looks like a key we
     * generated.
     *
     * Uninstall only, deliberately. The activator recreates this file solely
     * when `api_key` is empty, so a deactivation that removed it would leave
     * IndexNow silently broken after the plugin came back — and unlike the
     * sitemap, a stray key file shadows nothing.
     *
     * @since 2.1.0
     *
     * @return array{deleted: string[], failed: string[]}
     */
    function thinkrank_webroot_delete_indexnow_key(): array {
        $settings = get_option('thinkrank_instant_indexing_settings', []);
        $key      = is_array($settings) ? (string) ($settings['api_key'] ?? '') : '';

        // The generated shape is bin2hex(random_bytes(16)). Anything else is not
        // ours to delete, and this keeps a tampered option from naming an
        // arbitrary file in the web root.
        if (!preg_match('/^[a-f0-9]{32}$/i', $key)) {
            return ['deleted' => [], 'failed' => []];
        }

        $name = $key . '.txt';
        $path = ABSPATH . $name;

        if (!file_exists($path)) {
            return ['deleted' => [], 'failed' => []];
        }

        wp_delete_file($path);

        return file_exists($path)
            ? ['deleted' => [], 'failed' => [$name]]
            : ['deleted' => [$name], 'failed' => []];
    }
}

if (!function_exists('thinkrank_webroot_cleanup')) {
    /**
     * Remove every artifact ThinkRank published into the web root.
     *
     * Called from both removal paths — deactivation and uninstall — because a
     * file that shadows the next plugin's routes does so whether ThinkRank was
     * switched off or deleted outright. Deactivation pairs this with
     * {@see \ThinkRank\Core\Activator::restore_webroot_artifacts()}, which puts
     * the files back when the plugin is switched on again.
     *
     * Ordering note for uninstall: this has to run *before* the options and
     * tables are dropped. Every ownership test below reads state that the
     * database cleanup is about to remove — the sitemap filenames come from the
     * settings table, and llms.txt ownership from an option.
     *
     * @since 2.1.0
     *
     * @param array|null $sitemap_settings Optional. Already-read sitemap
     *                                     settings; read from the database when
     *                                     omitted.
     * @return array{deleted: string[], failed: string[]} Everything removed, and
     *                                                    everything that was
     *                                                    there but would not go.
     */
    function thinkrank_webroot_cleanup(?array $sitemap_settings = null): array {
        $settings = $sitemap_settings ?? thinkrank_webroot_read_sitemap_settings();

        $results = [
            thinkrank_webroot_delete_sitemaps($settings),
            thinkrank_webroot_delete_robots_txt(),
            thinkrank_webroot_delete_llms_txt(),
            thinkrank_webroot_delete_indexnow_key(),
        ];

        $deleted = [];
        $failed  = [];
        foreach ($results as $result) {
            $deleted = array_merge($deleted, $result['deleted']);
            $failed  = array_merge($failed, $result['failed']);
        }

        return [
            'deleted' => array_values(array_unique($deleted)),
            'failed'  => array_values(array_unique($failed)),
        ];
    }
}

```
