# thinkrank/2.5.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.5.0. 726 lines.

- Page: https://pluginprobe.com/plugins/thinkrank/2.5.0/code/includes/cleanup-webroot.php
- Raw: https://pluginprobe.com/plugins/thinkrank/2.5.0/raw/includes/cleanup-webroot.php
- Modified: 2026-08-31T10:09:34+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.5.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. Sitemaps must carry our
 * prolog marker (names are derived from the stored settings too — never a
 * `sitemap*.xml` glob — but a name is not proof, since ours are the canonical
 * ones another plugin also writes, #515); robots.txt is only removed when it
 * carries our generated header; llms.txt only when we recorded publishing it.
 *
 * The sitemap test is not just this file's rule, because removal is not just
 * this file's job: `Sitemap_Generator` deletes on every regeneration too —
 * orphaned segments, stale pagination pages, the local sitemap after the
 * business identity is cleared — and those fire on a post save, not on a
 * once-off deactivation. It routes all three through
 * {@see thinkrank_webroot_sitemap_is_ours()} for exactly that reason; gating
 * only the deactivation path left #515 reachable through the more common door.
 *
 * @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');
}

/**
 * The XML comment every sitemap ThinkRank writes carries in its prolog.
 *
 * This is the ownership test for sitemaps, and it is deliberately independent
 * of any setting: `enable_styling` can be off, `include_images` can be off, the
 * file can be an index or a segment or the local-business sitemap, and the
 * marker is still there. {@see Sitemap_Generator::xml_prolog()} writes it.
 *
 * Sitemaps need the test more than the other artifacts do, because our names
 * are the canonical ones — `sitemap.xml`, `sitemap_index.xml`,
 * `sitemap-posts.xml` — and RankMath, Squirrly or the site owner may well have
 * a real file of their own at exactly those paths (#515). Deleting by name
 * alone destroyed it.
 */
if (!defined('THINKRANK_SITEMAP_MARKER')) {
    define('THINKRANK_SITEMAP_MARKER', '<!-- Generated by ThinkRank SEO -->');
}

/**
 * The pre-2.1.1 sitemap marker: the href of our own XSL stylesheet.
 *
 * Sitemaps written before THINKRANK_SITEMAP_MARKER existed carry no comment,
 * but the ones written with styling enabled do reference our stylesheet, and
 * no other plugin has a reason to point at a path inside our plugin directory.
 * Recognising it keeps those files removable.
 *
 * The path is the stock install directory. A site that renamed the plugin folder
 * will not match its own pre-2.1.1 styled sitemaps, so those fall through to the
 * fallback below like any other unmarked file. Failing to recognise our own file
 * only leaves it behind; widening this to a bare `/static/xsl/` would start
 * matching other plugins' files, which is the failure that matters (#515).
 */
if (!defined('THINKRANK_SITEMAP_LEGACY_MARKER')) {
    define('THINKRANK_SITEMAP_LEGACY_MARKER', '/plugins/thinkrank/static/xsl/');
}

/**
 * The option recording that no unmarked sitemap of ours can be on disk.
 *
 * Set in two places, both meaning the same thing:
 *
 *   - the first time {@see Sitemap_Generator::save_sitemap_to_file()} succeeds
 *     on 2.1.1+, since everything this version writes carries the marker; and
 *   - at activation on a brand-new install
 *     ({@see Activator::retire_sitemap_legacy_fallback()}), which cannot have a
 *     pre-2.1.1 file of ours to recover in the first place.
 *
 * It bounds the legacy fallback below. Without the second case, "has not
 * written a marked sitemap yet" conflates a legacy install awaiting recovery
 * with a fresh install that simply has not generated — and on the latter the
 * fallback could only ever delete another plugin's file (#515). That is not a
 * momentary window: `regenerate_sitemap_from_settings()` returns early while
 * the master `enabled` flag is off, so a site with sitemaps disabled never
 * records a write of its own.
 */
if (!defined('THINKRANK_SITEMAP_MARKED_WRITE_OPTION')) {
    define('THINKRANK_SITEMAP_MARKED_WRITE_OPTION', 'thinkrank_sitemap_marked_write');
}

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 the current settings say ThinkRank publishes.
     *
     * The primary sitemap for the configured mode, 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.
     *
     * The two default names are no longer added unconditionally: with
     * `use_sitemap_index` off the site cannot have just written a
     * `sitemap_index.xml`, and claiming it anyway is how we came to delete
     * RankMath's (#515). They moved to
     * {@see thinkrank_webroot_sitemap_safety_net_filenames()}, which still
     * catches files a settings change left behind, but only ever deletes one
     * the contents identify as ours.
     *
     * @since 2.1.0
     * @since 2.1.1 Only names the current settings derive; the unconditional
     *              defaults moved to the safety net.
     *
     * @param array $settings Sitemap settings.
     * @return string[] Unique, non-empty basenames.
     */
    function thinkrank_webroot_sitemap_filenames(array $settings): array {
        $names = [
            '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_sitemap_safety_net_filenames')) {
    /**
     * Names ThinkRank may have written earlier, but the current settings would
     * not produce.
     *
     * Settings drift: a site that ran in index mode and later switched to a
     * single sitemap still has a `sitemap_index.xml` on disk, and a site that
     * renamed its sitemap through `sitemap_urls` still has the default name it
     * published under before. #510 is precisely about such a leftover shadowing
     * the next plugin's route, so the names still have to be considered — they
     * just cannot be deleted on the strength of the name, because they are also
     * the canonical names every other SEO plugin uses.
     * {@see thinkrank_webroot_delete_sitemaps()} requires a positive content
     * match for everything on this list.
     *
     * @since 2.1.1
     *
     * @return string[] Basenames.
     */
    function thinkrank_webroot_sitemap_safety_net_filenames(): array {
        return ['sitemap.xml', 'sitemap_index.xml'];
    }
}

if (!function_exists('thinkrank_webroot_sitemap_is_ours')) {
    /**
     * Can this web-root sitemap be shown to be one ThinkRank wrote?
     *
     * The same stance robots.txt and llms.txt already take: a file we cannot
     * prove is ours is left alone. Deleting a competitor's sitemap from the
     * canonical path is the mirror image of the bug #510 reported, and just as
     * damaging — it is silent, irreversible, and hits on plain deactivation
     * (#515).
     *
     * Proof is a marker in the file's prolog: the current comment, or the XSL
     * href older versions wrote when styling was enabled.
     *
     * The legacy fallback covers the one blind spot those markers leave — a
     * pre-2.1.1 file written with `enable_styling` off carries neither. It is
     * kept as narrow as it can be, and it expires: the name must be one the
     * *current* settings derive (not a safety-net name), the settings must
     * actually say styling is off, and this install must not yet have written a
     * marked sitemap of its own. Once it has, an unmarked file at one of our
     * names is by definition somebody else's. Unreadable settings, styling on,
     * or a marked write already recorded all mean no fallback — an unmarked file
     * then survives, which is the safe direction to fail in.
     *
     * A fresh install records that marker at activation without writing
     * anything, so the fallback never opens on a site that has no pre-2.1.1
     * sitemap of ours to recover.
     *
     * Callers that delete on a routine schedule pass `$name_derived = false` and
     * opt out of the fallback entirely; only the once-off removal paths
     * (deactivate, uninstall) ask for it.
     *
     * @since 2.1.1
     *
     * @param string $path         Absolute path to an existing file.
     * @param array  $settings     Sitemap settings.
     * @param bool   $name_derived Whether the name came from the current
     *                             settings rather than the safety net.
     * @return bool True when the file may be deleted.
     */
    function thinkrank_webroot_sitemap_is_ours(string $path, array $settings, bool $name_derived): bool {
        if (!is_readable($path)) {
            // Cannot look inside, so cannot show it is ours.
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local filesystem read of a fixed path; WP_Filesystem is unavailable during uninstall.
        $head = (string) file_get_contents($path, false, null, 0, 1024);

        if (strpos($head, THINKRANK_SITEMAP_MARKER) !== false
            || strpos($head, THINKRANK_SITEMAP_LEGACY_MARKER) !== false) {
            return true;
        }

        if (!$name_derived
            || !array_key_exists('enable_styling', $settings)
            || !empty($settings['enable_styling'])) {
            return false;
        }

        // The fallback only makes sense while this install has never written a
        // marked sitemap. After that, unmarked means not ours.
        return get_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION) !== '1';
    }
}

if (!function_exists('thinkrank_webroot_delete_sitemaps')) {
    /**
     * Remove every static sitemap file ThinkRank publishes to the web root.
     *
     * A name match is necessary but not sufficient. Our sitemap names are the
     * canonical ones — `sitemap.xml`, `sitemap_index.xml`, `sitemap-posts.xml`
     * — so another plugin's file sits at exactly those paths on a great many
     * sites, and deleting by name alone destroyed it on plain deactivation
     * (#515). Every candidate is checked against
     * {@see thinkrank_webroot_sitemap_is_ours()} first; anything that cannot be
     * shown to be ours is left where it is.
     *
     * @since 2.1.0
     * @since 2.1.1 Each candidate must pass a content ownership test.
     *
     * @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  = [];

        // name => whether the current settings derive it. Safety-net names are
        // added second and never upgrade a derived one.
        $targets = [];
        foreach (thinkrank_webroot_sitemap_filenames($settings) as $name) {
            $targets[$name] = true;
        }
        foreach (thinkrank_webroot_sitemap_safety_net_filenames() as $name) {
            if (!isset($targets[$name])) {
                $targets[$name] = false;
            }
        }

        foreach ($targets as $name => $name_derived) {
            $name = (string) $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) && thinkrank_webroot_sitemap_is_ours($path, $settings, $name_derived)) {
                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;
                }

                // A page of our sitemap is no more ours by name than its first
                // page is; another plugin paginates the same stem the same way.
                if (!thinkrank_webroot_sitemap_is_ours($paged, $settings, $name_derived)) {
                    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)),
        ];
    }
}

```
