Feature key => human label. */ private static function feature_labels(): array { return [ 'robots' => __('robots.txt', 'thinkrank'), 'llms' => __('llms.txt', 'thinkrank'), 'indexnow' => __('the Instant Indexing key', 'thinkrank'), 'sitemap' => __('the XML sitemap', 'thinkrank'), ]; } /** * Sort every feature into affected, unaffected, or switched off. * * robots.txt and the Instant Indexing key always have a PHP path, so they * are never affected (see {@see self::affected_features()}) and have no * delivery setting that could make them so. * * @since 2.10.0 * * @return array{affected: string[], unaffected: string[]} Feature keys. */ private static function feature_states(): array { $states = [ 'affected' => [], 'unaffected' => ['robots', 'indexnow'], ]; $llms = self::delivery_state('ThinkRank\\SEO\\LLMs_Txt_Manager'); if (null !== $llms) { $states[$llms][] = 'llms'; } $sitemap = self::sitemap_state(); if (null !== $sitemap) { $states[$sitemap][] = 'sitemap'; } return $states; } /** * Is this manager's delivery explicitly set to write files? * * Only an explicit `static` counts. `auto` resolving to static means the * root IS writable, in which case none of this applies. * * @since 2.10.0 * @since 2.10.0 Returns the feature's state rather than a bool, so a * switched-off feature can be left out of both lists. * * @param string $manager_class Fully-qualified manager class name. * @return string|null 'affected', 'unaffected', or null when switched off. */ private static function delivery_state(string $manager_class): ?string { if (!class_exists($manager_class)) { return null; } $manager = new $manager_class(); $settings = $manager->get_settings('site'); if (empty($settings['enabled'])) { // A feature that is switched off publishes nothing, so it cannot be // failing to publish. return null; } return 'static' === (string) ($settings['delivery_mode'] ?? 'auto') ? 'affected' : 'unaffected'; } /** * Is the XML sitemap genuinely unharmed by the unwritable root? * * Only when delivery resolves to dynamic. On `auto` — the default — an * unwritable root resolves that way by itself, so the reassurance is * normally true. It stops being true the moment someone explicitly picks * "write files", and stating it unconditionally told those users to ignore * a notice that was in fact reporting a broken sitemap. * * @since 2.9.0 * @since 2.10.0 Returns the state rather than a bool; see delivery_state(). * * @return string|null 'affected', 'unaffected', or null when switched off. */ private static function sitemap_state(): ?string { if (!class_exists('ThinkRank\\SEO\\Sitemap_Generator')) { return null; } $sitemap = new \ThinkRank\SEO\Sitemap_Generator(false); // Same rule delivery_state() applies to llms.txt: a feature that is // switched off publishes nothing, so it cannot be failing to publish. // Without this a site with the sitemap disabled and a stale // `delivery_mode` of `static` gets the permanent notice back, which is // the bug this class was rewritten to stop (#756). if (empty($sitemap->get_settings('site')['enabled'])) { return null; } return 'dynamic' === $sitemap->resolve_delivery_mode() ? 'unaffected' : 'affected'; } /** * Whether the notice should render on this request. * * @return bool */ private function should_display(): bool { if (self::root_is_writable()) { return false; } // An unwritable root is not itself a problem. Every file feature has a // PHP path, and `auto` uses it, so there is nothing to report unless a // feature is explicitly set to write files. Warning regardless is what // made this permanent on hosts that lock the core folder by design and // will not be unlocking it (#756). if (empty(self::affected_features())) { return false; } // Only users who can act on it (or ask the host to) are shown the // warning. if (!current_user_can('manage_options')) { return false; } return !get_option(self::OPT_DISMISSED); } /** * Load the shared notice stylesheet when the notice will render. * * @return void */ public function enqueue_assets(): void { if (!$this->should_display()) { return; } wp_enqueue_style( 'thinkrank-admin-notices', THINKRANK_PLUGIN_URL . 'static/css/admin-notices.css', [], THINKRANK_VERSION ); } /** * Render the notice. * * @return void */ public function render(): void { if (!$this->should_display()) { return; } ?>

' . esc_html(untrailingslashit(ABSPATH)) . '' ); ?>

__('ThinkRank can publish files to your WordPress folder', 'thinkrank'), 'test' => [$this, 'run_health_test'], ]; return $tests; } /** * Site Health test body. * * @since 2.9.0 * * @return array Site Health result array. */ public function run_health_test(): array { $result = [ 'label' => __('ThinkRank can publish files to your WordPress folder', 'thinkrank'), 'status' => 'good', 'badge' => [ 'label' => __('SEO', 'thinkrank'), 'color' => 'blue', ], 'description' => '

' . sprintf( /* translators: %s: list of features that can publish files. */ esc_html__('ThinkRank can write to the WordPress root, so %s can be published as files.', 'thinkrank'), esc_html(self::list_text(self::enabled_features())) ) . '

', 'actions' => '', 'test' => self::HEALTH_TEST, ]; if (self::root_is_writable()) { return $result; } // The root is read-only, but that alone is not a fault: every file // feature has a PHP path and `auto` uses it. Report a pass, and say so, // rather than a permanent "recommended" on hosts that lock the folder // by design (#756). if (empty(self::affected_features())) { $result['label'] = __('ThinkRank serves its files from WordPress', 'thinkrank'); $result['description'] = '

' . sprintf( /* translators: 1: absolute path to the WordPress root, 2: list of features served from WordPress. */ esc_html__('The folder %1$s is not writable by PHP, which is normal on managed hosts that keep the WordPress core folder read-only. Nothing is affected: ThinkRank serves %2$s directly from WordPress when it cannot write them to disk.', 'thinkrank'), '' . esc_html(untrailingslashit(ABSPATH)) . '', esc_html(self::list_text(self::unaffected_features())) ) . '

'; return $result; } $result['status'] = 'recommended'; $result['label'] = __('ThinkRank cannot publish files to your WordPress folder', 'thinkrank'); $description = '

' . sprintf( /* translators: 1: list of affected features, 2: absolute path to the WordPress root. */ esc_html__('ThinkRank cannot publish %1$s. Its delivery is set to write files, and the folder %2$s is not writable by PHP.', 'thinkrank'), esc_html(self::list_text(self::affected_features())), '' . esc_html(untrailingslashit(ABSPATH)) . '' ) . '

'; // The feature-level remedy comes first: it is the one the user can // actually apply. Some managed hosts lock this folder deliberately, so // "ask your host" is the fallback, not the headline (#756). $description .= '

' . esc_html__('Set delivery to Automatic and ThinkRank will serve it directly from WordPress, with no file to write. Making the folder writable also works, though some managed hosts keep it read-only by design.', 'thinkrank') . '

'; // Built from what is actually still working rather than written out: // the fixed sentence named llms.txt as unaffected directly under a // paragraph saying llms.txt could not be published. $description .= '

' . sprintf( /* translators: %s: list of features that keep working. */ esc_html__('Everything else is unaffected: ThinkRank serves %s from WordPress when there is no file to read.', 'thinkrank'), esc_html(self::list_text(self::unaffected_features())) ) . '

'; // Named explicitly because both are the usual first guesses and neither // has any effect here: the write fails on the root directory itself, // and get_filesystem_method() still reports "direct" because with no // context argument it tests wp-content, not the root. $description .= '

' . esc_html__('Adding FS_METHOD or FTP credentials to wp-config.php will not resolve this. Ask your host to make the WordPress root writable by the web server user.', 'thinkrank') . '

'; if (get_option(self::OPT_ACTIVATION_STATE) === 'writable') { $description .= '

' . esc_html__('This folder was writable when ThinkRank was activated, so something on the hosting side changed since then.', 'thinkrank') . '

'; } $result['description'] = $description; return $result; } /** * Clear the dismissal once the root becomes writable again. * * Called from the Site Health test and the notice path is cheap, so this * runs wherever the condition is evaluated rather than on a schedule. * * @since 2.9.0 * * @return void */ public function reset_dismissal(): void { if (self::root_is_writable()) { delete_option(self::OPT_DISMISSED); } } }