PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 All 52 releases
← All changes | includes/admin/class-webroot-writable-notice.php +245 -31 2.9.0 → 2.10.0 View file →
@@ -1,11 +1,11 @@
1 1 <?php
2 2 /**
3 3 * Web Root Writability Notice
4 4 *
5 - * Tells the site owner when the WordPress root cannot be written to, which is
6 - * what stops robots.txt, llms.txt and the Instant Indexing key file from being
7 - * published.
5 + * Tells the site owner when the WordPress root cannot be written to and a
6 + * feature is explicitly set to publish files there, which is the one case that
7 + * stops it from being delivered (#756).
8 8 *
9 9 * @package ThinkRank\Admin
10 10 * @since 2.9.0
11 11 */
@@ -115,25 +115,188 @@
115 115 return wp_is_writable(ABSPATH);
116 116 }
117 117
118 118 /**
119 - * Features that stop working when the root cannot be written.
119 + * Features that genuinely stop working when the root cannot be written.
120 120 *
121 + * Derived from what is actually unavailable rather than from a fixed list.
122 + * The fixed list named robots.txt, llms.txt and the Instant Indexing key
123 + * file, and on a read-only root all three still work — each has a PHP path
124 + * that answers the request:
125 + *
126 + * - robots.txt through the `robots_txt` filter. Core only runs do_robots()
127 + * when no physical file exists, so an unwritable root is precisely the
128 + * case where the filter does answer. The file is an optional extra.
129 + * - the Instant Indexing key through `maybe_serve_key_file()` on
130 + * `parse_request` when no file exists (#243 / #247).
131 + * - llms.txt through `serve_llms_txt()`, whose `auto` mode now resolves to
132 + * dynamic on an unwritable root (#756).
133 + *
134 + * That mattered because on a managed host such as Flywheel, ABSPATH is the
135 + * locked core folder (`/www/.wordpress/`) while the document root (`/www`)
136 + * is writable — so the notice fired permanently, naming three features that
137 + * were working, and told the user to ask the host to change something the
138 + * host locks by design.
139 + *
140 + * What remains genuinely broken is a feature explicitly set to write files
141 + * on a root that cannot be written. `auto` never lands there any more.
142 + *
121 143 * Kept in one place so the notice and the Site Health test cannot drift.
122 144 *
123 145 * @since 2.9.0
146 + * @since 2.10.0 Reports only what is actually unavailable (#756).
124 147 *
148 + * @return string[] Human labels of features that cannot be delivered.
149 + */
150 + private static function affected_features(): array {
151 + return self::labels_for(self::feature_states()['affected']);
152 + }
153 +
154 + /**
155 + * Features that keep working on the unwritable root, for the reassurance.
156 + *
157 + * The complement of {@see self::affected_features()}, derived rather than
158 + * written out. The sentence used to be a fixed string naming robots.txt,
159 + * llms.txt and the Instant Indexing key, so with llms.txt forced to write
160 + * files the notice said "ThinkRank cannot publish llms.txt" and, one line
161 + * later, that llms.txt was unaffected and kept working.
162 + *
163 + * A feature that is switched off is in neither list: it publishes nothing,
164 + * so it is not failing, but "ThinkRank serves it from WordPress" would not
165 + * be true of it either.
166 + *
167 + * @since 2.10.0
168 + *
169 + * @return string[] Human labels of features still being delivered.
170 + */
171 + private static function unaffected_features(): array {
172 + return self::labels_for(self::feature_states()['unaffected']);
173 + }
174 +
175 + /**
176 + * Every feature that is switched on, whatever its delivery.
177 + *
178 + * For the writable-root pass, where nothing is failing and the question is
179 + * only what the folder is used for.
180 + *
181 + * @since 2.10.0
182 + *
183 + * @return string[] Human labels.
184 + */
185 + private static function enabled_features(): array {
186 + $states = self::feature_states();
187 +
188 + return self::labels_for(array_merge($states['affected'], $states['unaffected']));
189 + }
190 +
191 + /**
192 + * Join feature labels into a readable, localised list.
193 + *
194 + * wp_sprintf_l() rather than implode(): the lists are now built at run
195 + * time, and "robots.txt, llms.txt, the Instant Indexing key" with no
196 + * conjunction read as a sentence that had been cut short.
197 + *
198 + * @since 2.10.0
199 + *
200 + * @param string[] $labels Human labels.
201 + * @return string
202 + */
203 + private static function list_text(array $labels): string {
204 + return wp_sprintf_l('%l', $labels);
205 + }
206 +
207 + /**
208 + * Human labels for a set of feature keys, in feature_labels() order.
209 + *
210 + * @since 2.10.0
211 + *
212 + * @param string[] $keys Feature keys.
125 213 * @return string[]
126 214 */
127 - private static function affected_features(): array {
215 + private static function labels_for(array $keys): array {
216 + return array_values(array_intersect_key(self::feature_labels(), array_flip($keys)));
217 + }
218 +
219 + /**
220 + * Every file-backed feature this class reports on, keyed for the lists.
221 + *
222 + * The one place the feature names are written, so the failure sentence,
223 + * the reassurance and the Site Health pass text cannot name different sets.
224 + *
225 + * @since 2.10.0
226 + *
227 + * @return array<string,string> Feature key => human label.
228 + */
229 + private static function feature_labels(): array {
128 230 return [
129 - __('robots.txt', 'thinkrank'),
130 - __('llms.txt', 'thinkrank'),
131 - __('the Instant Indexing key file', 'thinkrank'),
231 + 'robots' => __('robots.txt', 'thinkrank'),
232 + 'llms' => __('llms.txt', 'thinkrank'),
233 + 'indexnow' => __('the Instant Indexing key', 'thinkrank'),
234 + 'sitemap' => __('the XML sitemap', 'thinkrank'),
132 235 ];
133 236 }
134 237
135 238 /**
239 + * Sort every feature into affected, unaffected, or switched off.
240 + *
241 + * robots.txt and the Instant Indexing key always have a PHP path, so they
242 + * are never affected (see {@see self::affected_features()}) and have no
243 + * delivery setting that could make them so.
244 + *
245 + * @since 2.10.0
246 + *
247 + * @return array{affected: string[], unaffected: string[]} Feature keys.
248 + */
249 + private static function feature_states(): array {
250 + $states = [
251 + 'affected' => [],
252 + 'unaffected' => ['robots', 'indexnow'],
253 + ];
254 +
255 + $llms = self::delivery_state('ThinkRank\\SEO\\LLMs_Txt_Manager');
256 + if (null !== $llms) {
257 + $states[$llms][] = 'llms';
258 + }
259 +
260 + $sitemap = self::sitemap_state();
261 + if (null !== $sitemap) {
262 + $states[$sitemap][] = 'sitemap';
263 + }
264 +
265 + return $states;
266 + }
267 +
268 + /**
269 + * Is this manager's delivery explicitly set to write files?
270 + *
271 + * Only an explicit `static` counts. `auto` resolving to static means the
272 + * root IS writable, in which case none of this applies.
273 + *
274 + * @since 2.10.0
275 + * @since 2.10.0 Returns the feature's state rather than a bool, so a
276 + * switched-off feature can be left out of both lists.
277 + *
278 + * @param string $manager_class Fully-qualified manager class name.
279 + * @return string|null 'affected', 'unaffected', or null when switched off.
280 + */
281 + private static function delivery_state(string $manager_class): ?string {
282 + if (!class_exists($manager_class)) {
283 + return null;
284 + }
285 +
286 + $manager = new $manager_class();
287 + $settings = $manager->get_settings('site');
288 +
289 + if (empty($settings['enabled'])) {
290 + // A feature that is switched off publishes nothing, so it cannot be
291 + // failing to publish.
292 + return null;
293 + }
294 +
295 + return 'static' === (string) ($settings['delivery_mode'] ?? 'auto') ? 'affected' : 'unaffected';
296 + }
297 +
298 + /**
136 299 * Is the XML sitemap genuinely unharmed by the unwritable root?
137 300 *
138 301 * Only when delivery resolves to dynamic. On `auto` — the default — an
139 302 * unwritable root resolves that way by itself, so the reassurance is
@@ -141,17 +304,29 @@
141 304 * "write files", and stating it unconditionally told those users to ignore
142 305 * a notice that was in fact reporting a broken sitemap.
143 306 *
144 307 * @since 2.9.0
308 + * @since 2.10.0 Returns the state rather than a bool; see delivery_state().
145 309 *
146 - * @return bool True when the sitemap is served from PHP and needs no file.
310 + * @return string|null 'affected', 'unaffected', or null when switched off.
147 311 */
148 - private static function sitemap_is_unaffected(): bool {
312 + private static function sitemap_state(): ?string {
149 313 if (!class_exists('ThinkRank\\SEO\\Sitemap_Generator')) {
150 - return true;
314 + return null;
151 315 }
152 316
153 - return 'dynamic' === (new \ThinkRank\SEO\Sitemap_Generator(false))->resolve_delivery_mode();
317 + $sitemap = new \ThinkRank\SEO\Sitemap_Generator(false);
318 +
319 + // Same rule delivery_state() applies to llms.txt: a feature that is
320 + // switched off publishes nothing, so it cannot be failing to publish.
321 + // Without this a site with the sitemap disabled and a stale
322 + // `delivery_mode` of `static` gets the permanent notice back, which is
323 + // the bug this class was rewritten to stop (#756).
324 + if (empty($sitemap->get_settings('site')['enabled'])) {
325 + return null;
326 + }
327 +
328 + return 'dynamic' === $sitemap->resolve_delivery_mode() ? 'unaffected' : 'affected';
154 329 }
155 330
156 331 /**
157 332 * Whether the notice should render on this request.
@@ -162,8 +337,17 @@
162 337 if (self::root_is_writable()) {
163 338 return false;
164 339 }
165 340
341 + // An unwritable root is not itself a problem. Every file feature has a
342 + // PHP path, and `auto` uses it, so there is nothing to report unless a
343 + // feature is explicitly set to write files. Warning regardless is what
344 + // made this permanent on hosts that lock the core folder by design and
345 + // will not be unlocking it (#756).
346 + if (empty(self::affected_features())) {
347 + return false;
348 + }
349 +
166 350 // Only users who can act on it (or ask the host to) are shown the
167 351 // warning.
168 352 if (!current_user_can('manage_options')) {
169 353 return false;
@@ -207,24 +391,24 @@
207 391 <p class="thinkrank-notice__title"><?php esc_html_e('ThinkRank cannot write to your WordPress folder', 'thinkrank'); ?></p>
208 392 <p class="thinkrank-notice__text">
209 393 <?php
210 394 printf(
211 - /* translators: 1: absolute path to the WordPress root, 2: comma-separated list of affected features. */
212 - esc_html__('The folder %1$s is not writable by PHP, so ThinkRank cannot publish %2$s. Ask your host to make the WordPress root writable by the web server user.', 'thinkrank'),
213 - '<code>' . esc_html(untrailingslashit(ABSPATH)) . '</code>',
214 - esc_html(implode(', ', self::affected_features()))
395 + /* translators: 1: list of affected features, 2: absolute path to the WordPress root. */
396 + esc_html__('ThinkRank cannot publish %1$s. Its delivery is set to write files, and the folder %2$s is not writable by PHP. Set delivery to Automatic and ThinkRank will serve it directly. Asking your host to make the folder writable also works, though some managed hosts lock it deliberately.', 'thinkrank'),
397 + esc_html(self::list_text(self::affected_features())),
398 + '<code>' . esc_html(untrailingslashit(ABSPATH)) . '</code>'
215 399 );
216 400 ?>
217 401 </p>
218 - <?php if (self::sitemap_is_unaffected()) : ?>
219 402 <p class="thinkrank-notice__text">
220 - <?php esc_html_e('Your XML sitemap is not affected: ThinkRank serves it directly when the folder is not writable.', 'thinkrank'); ?>
403 + <?php
404 + printf(
405 + /* translators: %s: list of features that keep working. */
406 + esc_html__('Everything else is unaffected. ThinkRank serves %s from WordPress when there is no file to read, so those keep working on a read-only folder.', 'thinkrank'),
407 + esc_html(self::list_text(self::unaffected_features()))
408 + );
409 + ?>
221 410 </p>
222 - <?php else : ?>
223 - <p class="thinkrank-notice__text">
224 - <?php esc_html_e('Your XML sitemap is affected too: sitemap delivery is set to write files, and those files cannot be written. Set sitemap delivery to automatic so WordPress serves the sitemap directly, or make the folder writable.', 'thinkrank'); ?>
225 - </p>
226 - <?php endif; ?>
227 411 <p class="thinkrank-notice__actions">
228 412 <a href="<?php echo esc_url(admin_url('site-health.php')); ?>" class="button button-primary">
229 413 <?php esc_html_e('Check Site Health', 'thinkrank'); ?>
230 414 </a>
@@ -330,9 +514,13 @@
330 514 'badge' => [
331 515 'label' => __('SEO', 'thinkrank'),
332 516 'color' => 'blue',
333 517 ],
334 - 'description' => '<p>' . esc_html__('ThinkRank can write to the WordPress root, so robots.txt, llms.txt and the Instant Indexing key file can be published.', 'thinkrank') . '</p>',
518 + 'description' => '<p>' . sprintf(
519 + /* translators: %s: list of features that can publish files. */
520 + esc_html__('ThinkRank can write to the WordPress root, so %s can be published as files.', 'thinkrank'),
521 + esc_html(self::list_text(self::enabled_features()))
522 + ) . '</p>',
335 523 'actions' => '',
336 524 'test' => self::HEALTH_TEST,
337 525 ];
338 526
@@ -339,21 +527,47 @@
339 527 if (self::root_is_writable()) {
340 528 return $result;
341 529 }
342 530
531 + // The root is read-only, but that alone is not a fault: every file
532 + // feature has a PHP path and `auto` uses it. Report a pass, and say so,
533 + // rather than a permanent "recommended" on hosts that lock the folder
534 + // by design (#756).
535 + if (empty(self::affected_features())) {
536 + $result['label'] = __('ThinkRank serves its files from WordPress', 'thinkrank');
537 + $result['description'] = '<p>' . sprintf(
538 + /* translators: 1: absolute path to the WordPress root, 2: list of features served from WordPress. */
539 + 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'),
540 + '<code>' . esc_html(untrailingslashit(ABSPATH)) . '</code>',
541 + esc_html(self::list_text(self::unaffected_features()))
542 + ) . '</p>';
543 +
544 + return $result;
545 + }
546 +
343 547 $result['status'] = 'recommended';
344 548 $result['label'] = __('ThinkRank cannot publish files to your WordPress folder', 'thinkrank');
345 549
346 550 $description = '<p>' . sprintf(
347 - /* translators: 1: absolute path to the WordPress root, 2: comma-separated list of affected features. */
348 - esc_html__('The folder %1$s is not writable by PHP, so ThinkRank cannot publish %2$s.', 'thinkrank'),
349 - '<code>' . esc_html(untrailingslashit(ABSPATH)) . '</code>',
350 - esc_html(implode(', ', self::affected_features()))
551 + /* translators: 1: list of affected features, 2: absolute path to the WordPress root. */
552 + 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'),
553 + esc_html(self::list_text(self::affected_features())),
554 + '<code>' . esc_html(untrailingslashit(ABSPATH)) . '</code>'
351 555 ) . '</p>';
352 556
353 - $description .= self::sitemap_is_unaffected()
354 - ? '<p>' . esc_html__('Your XML sitemap is not affected. ThinkRank detects this and serves the sitemap directly instead of writing it to a file.', 'thinkrank') . '</p>'
355 - : '<p>' . esc_html__('Your XML sitemap is affected too. Sitemap delivery is set to write files, and those files cannot be written. Set sitemap delivery to automatic so WordPress serves the sitemap directly, or make the folder writable.', 'thinkrank') . '</p>';
557 + // The feature-level remedy comes first: it is the one the user can
558 + // actually apply. Some managed hosts lock this folder deliberately, so
559 + // "ask your host" is the fallback, not the headline (#756).
560 + $description .= '<p>' . 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') . '</p>';
561 +
562 + // Built from what is actually still working rather than written out:
563 + // the fixed sentence named llms.txt as unaffected directly under a
564 + // paragraph saying llms.txt could not be published.
565 + $description .= '<p>' . sprintf(
566 + /* translators: %s: list of features that keep working. */
567 + esc_html__('Everything else is unaffected: ThinkRank serves %s from WordPress when there is no file to read.', 'thinkrank'),
568 + esc_html(self::list_text(self::unaffected_features()))
569 + ) . '</p>';
356 570
357 571 // Named explicitly because both are the usual first guesses and neither
358 572 // has any effect here: the write fails on the root directory itself,
359 573 // and get_filesystem_method() still reports "direct" because with no