PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.12.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 All 53 releases
← All changes | includes/seo/class-llms-txt-manager.php +709 -38 2.0.0 → 2.9.0 View file →
@@ -14,8 +14,13 @@
14 14 declare(strict_types=1);
15 15
16 16 namespace ThinkRank\SEO;
17 17
18 +// Prevent direct access
19 +if (!defined('ABSPATH')) {
20 + exit;
21 +}
22 +
18 23 // Ensure dependencies are loaded
19 24 if (!class_exists('ThinkRank\\SEO\\Abstract_SEO_Manager')) {
20 25 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-abstract-seo-manager.php';
21 26 }
@@ -51,8 +56,27 @@
51 56 */
52 57 private bool $last_unpublish_failed = false;
53 58
54 59 /**
60 + * Message from the last save that switched delivery mode but could not move
61 + * the published document, or an empty string when the switch was clean.
62 + *
63 + * @since 2.1.0
64 + * @var string
65 + */
66 + private string $last_delivery_warning = '';
67 +
68 + /**
69 + * Whether the last save's delivery-mode switch failed outright, as opposed
70 + * to succeeding with a warning. Both set {@see delivery_switch_warning()},
71 + * and only one of them means /llms.txt is still on the old path.
72 + *
73 + * @since 2.1.0
74 + * @var bool
75 + */
76 + private bool $last_delivery_switch_failed = false;
77 +
78 + /**
55 79 * LLMs.txt content sections configuration
56 80 *
57 81 * @since 1.0.0
58 82 * @var array
@@ -119,8 +143,68 @@
119 143 */
120 144 private const HTACCESS_MARKER = 'ThinkRank llms.txt';
121 145
122 146 /**
147 + * Option holding the published llms.txt document.
148 + *
149 + * The published content lives here regardless of delivery mode, so the
150 + * dynamic route has an authoritative source that does not depend on a
151 + * physical file, and switching modes never loses the published document.
152 + *
153 + * @since 2.1.0
154 + * @var string
155 + */
156 + private const CONTENT_OPTION = 'thinkrank_llms_txt_content';
157 +
158 + /**
159 + * Option holding the Unix timestamp of the last publish.
160 + *
161 + * @since 2.1.0
162 + * @var string
163 + */
164 + private const PUBLISHED_AT_OPTION = 'thinkrank_llms_txt_published_at';
165 +
166 + /**
167 + * Option recording what the site's public URL really answers /llms.txt with.
168 + *
169 + * Shaped as ['home' => string, 'result' => 'charset'|'no_charset'|'unknown',
170 + * 'checked_at' => int] and keyed on the home URL, so a clone or a migration
171 + * re-checks instead of inheriting the verdict of the host it came from.
172 + *
173 + * @since 2.1.0
174 + * @var string
175 + */
176 + private const DELIVERY_PROBE_OPTION = 'thinkrank_llms_delivery_probe';
177 +
178 + /**
179 + * How long an inconclusive delivery check is left alone before retrying.
180 + *
181 + * A conclusive verdict stands until the document is published again; only
182 + * the "could not tell" answer — a blocked loopback, an HTTP-auth'd staging
183 + * site — is worth asking about a second time, and not often.
184 + *
185 + * @since 2.1.0
186 + * @var int
187 + */
188 + private const DELIVERY_PROBE_RETRY = DAY_IN_SECONDS;
189 +
190 + /**
191 + * Shown when the server answers the published file without a charset.
192 + *
193 + * @since 2.1.0
194 + * @var string
195 + */
196 + private const STATIC_CHARSET_WARNING = 'This server answers the published llms.txt without a character set, so accented characters and curly quotes arrive mis-decoded. Set Delivery Method to "Served by WordPress" to publish it as UTF-8.';
197 +
198 + /**
199 + * Delivery modes accepted by the `delivery_mode` setting.
200 + *
201 + * @since 2.1.0
202 + * @var string[]
203 + */
204 + private const DELIVERY_MODES = ['auto', 'static', 'dynamic'];
205 +
206 + /**
123 207 * Business type templates for content generation
124 208 *
125 209 * @since 1.0.0
126 210 * @var array
@@ -279,10 +363,18 @@
279 363 * @return bool
280 364 */
281 365 public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
282 366 $this->last_unpublish_failed = false;
367 + $this->last_delivery_warning = '';
368 + $this->last_delivery_switch_failed = false;
369 + $previous_mode = $this->resolve_delivery_mode();
370 +
283 371 $result = parent::save_settings($context_type, $context_id, $settings);
284 372
373 + // The cached status carries the resolved delivery mode, so it goes stale
374 + // the moment settings change — even when nothing needs republishing.
375 + delete_transient('thinkrank_llms_file_status');
376 +
285 377 // When a save explicitly disables the feature, delete the published file.
286 378 if ($result && array_key_exists('enabled', $settings) && empty($settings['enabled'])) {
287 379 if (!$this->delete_llms_txt_file()) {
288 380 // The settings were persisted, but the physical file could not be
@@ -289,14 +381,124 @@
289 381 // removed, so /llms.txt may still be served. Record it so callers
290 382 // report a partial failure instead of an unqualified success.
291 383 $this->last_unpublish_failed = true;
292 384 }
385 +
386 + return $result;
293 387 }
294 388
389 + // The published document has to sit where the active mode serves it from,
390 + // or the site keeps answering on the old path: a leftover physical file
391 + // shadows the dynamic route on every stack, and a database-only document
392 + // is invisible to a stack now expecting a file. Reconciled on any save,
393 + // not just an explicit mode change, so a site whose auto-detection now
394 + // resolves differently — an nginx install upgrading into this fix with a
395 + // static file already on disk — heals the next time settings are saved.
396 + if ($result && $this->delivery_needs_reconcile($previous_mode)) {
397 + $this->republish_for_delivery_mode();
398 + }
399 +
295 400 return $result;
296 401 }
297 402
298 403 /**
404 + * Whether the published document is out of step with the active mode.
405 + *
406 + * @since 2.1.0
407 + *
408 + * @param string $previous_mode Mode in force before the save.
409 + * @return bool
410 + */
411 + private function delivery_needs_reconcile(string $previous_mode): bool {
412 + $mode = $this->resolve_delivery_mode();
413 + $file_exists = file_exists(ABSPATH . 'llms.txt');
414 +
415 + if ('dynamic' === $mode) {
416 + // A physical file would be served instead of the PHP route.
417 + return $file_exists;
418 + }
419 +
420 + // Static: a stored document with no file behind it is unreachable on a
421 + // stack that expects one. A mode flip also forces the charset block to
422 + // be (re)written for a file that predates it.
423 + return (!$file_exists && '' !== trim($this->get_published_content()))
424 + || $mode !== $previous_mode;
425 + }
426 +
427 + /**
428 + * Re-publish the current document under the active delivery mode.
429 + *
430 + * A no-op when nothing is published yet — this only moves an existing
431 + * document, it never publishes on the user's behalf.
432 + *
433 + * @since 2.1.0
434 + *
435 + * @return void
436 + */
437 + private function republish_for_delivery_mode(): void {
438 + $content = $this->get_published_content();
439 +
440 + if ('' === trim($content)) {
441 + // Published before the stored copy existed: recover it from the file.
442 + $llms_file = ABSPATH . 'llms.txt';
443 + if (file_exists($llms_file)) {
444 + $read_result = $this->safe_file_read($llms_file);
445 + if ($read_result['success']) {
446 + $content = $read_result['content'];
447 + }
448 + }
449 + }
450 +
451 + if ('' === trim($content)) {
452 + return;
453 + }
454 +
455 + $write = $this->write_llms_txt_to_file($content);
456 +
457 + // The switch itself failed (an unwritable root on the way to static, a
458 + // stuck file on the way to dynamic). The settings are saved, so report
459 + // it rather than letting the mode read as applied when it is not.
460 + if (empty($write['success'])) {
461 + $this->last_delivery_warning = isset($write['message']) && '' !== (string) $write['message']
462 + ? (string) $write['message']
463 + : 'The delivery method was saved, but the published llms.txt could not be moved to it.';
464 + $this->last_delivery_switch_failed = true;
465 + return;
466 + }
467 +
468 + // The switch worked, but static delivery on this server cannot carry the
469 + // charset the document needs. Only an explicitly chosen `static` gets
470 + // this far — `auto` moves itself to WordPress delivery instead.
471 + if (!empty($write['delivery_warning'])) {
472 + $this->last_delivery_warning = (string) $write['delivery_warning'];
473 + }
474 + }
475 +
476 + /**
477 + * Message from the last save whose delivery-mode switch could not be
478 + * applied to the already-published document, or '' when there was none.
479 + *
480 + * @since 2.1.0
481 + *
482 + * @return string
483 + */
484 + public function delivery_switch_warning(): string {
485 + return $this->last_delivery_warning;
486 + }
487 +
488 + /**
489 + * Whether the last save's warning was a failed switch rather than a
490 + * successful one the server cannot serve correctly.
491 + *
492 + * @since 2.1.0
493 + *
494 + * @return bool
495 + */
496 + public function delivery_switch_failed(): bool {
497 + return $this->last_delivery_switch_failed;
498 + }
499 +
500 + /**
299 501 * Whether the last save_settings() disabled the feature but could not remove
300 502 * the published llms.txt file (which may therefore still be served).
301 503 *
302 504 * @return bool
@@ -305,15 +507,360 @@
305 507 return $this->last_unpublish_failed;
306 508 }
307 509
308 510 /**
309 - * Remove the published llms.txt file and bust the status transient.
511 + * Resolve the effective delivery mode for /llms.txt.
310 512 *
311 - * @return bool True if the file is absent or was removed.
513 + * `static` publishes a physical ABSPATH/llms.txt and lets the web server
514 + * answer it; `dynamic` keeps the document in the database and lets the PHP
515 + * route in {@see serve_llms_txt()} answer it. `auto` picks static only on
516 + * Apache/LiteSpeed, the stacks that read the .htaccess charset block — on
517 + * nginx a physical file is served with a bare `Content-Type: text/plain`
518 + * that neither fix path can reach, which renders UTF-8 as mojibake (#419).
519 + *
520 + * $is_apache is not trusted on its own: WordPress reads it from
521 + * $_SERVER['SERVER_SOFTWARE'], which describes the server that runs PHP
522 + * rather than the one answering the public request. A reverse proxy hides
523 + * the difference — an nginx edge in front of an Apache backend reports
524 + * Apache, so `auto` chose the file that nginx then served with no charset,
525 + * which is the very defect the setting was added to avoid (#493). A
526 + * publish-time self-request settles what the detection cannot see, and its
527 + * verdict is what this consults; an explicit setting still wins outright.
528 + *
529 + * @since 2.1.0
530 + *
531 + * @param string|null $mode Optional. Raw setting value; read from the saved
532 + * settings when null.
533 + * @return string Either 'static' or 'dynamic'.
312 534 */
535 + public function resolve_delivery_mode(?string $mode = null): string {
536 + if (null === $mode) {
537 + $settings = $this->get_settings('site');
538 + $mode = (string) ($settings['delivery_mode'] ?? 'auto');
539 + }
540 +
541 + if ('static' === $mode || 'dynamic' === $mode) {
542 + return $mode;
543 + }
544 +
545 + // $is_apache also covers LiteSpeed, which reads .htaccess the same way.
546 + if (empty($GLOBALS['is_apache'])) {
547 + return 'dynamic';
548 + }
549 +
550 + // Detection says this stack reads the .htaccess charset block. Believe
551 + // it unless a self-request has caught the public URL answering without
552 + // a charset, which is what a reverse-proxied stack does (#493).
553 + return $this->static_delivery_drops_charset() ? 'dynamic' : 'static';
554 + }
555 +
556 + /**
557 + * Whether the recorded check caught the public URL dropping the charset.
558 + *
559 + * @since 2.1.0
560 + *
561 + * @return bool
562 + */
563 + private function static_delivery_drops_charset(): bool {
564 + return 'no_charset' === ($this->delivery_probe()['result'] ?? '');
565 + }
566 +
567 + /**
568 + * The delivery check recorded for this site, or [] when there is none.
569 + *
570 + * @since 2.1.0
571 + *
572 + * @return array
573 + */
574 + private function delivery_probe(): array {
575 + $probe = get_option(self::DELIVERY_PROBE_OPTION, []);
576 +
577 + if (!is_array($probe) || !isset($probe['result'])) {
578 + return [];
579 + }
580 +
581 + return ($probe['home'] ?? '') === home_url() ? $probe : [];
582 + }
583 +
584 + /**
585 + * Ask the site's own public URL what it answers /llms.txt with.
586 + *
587 + * @since 2.1.0
588 + *
589 + * @return string 'charset', 'no_charset', or 'unknown' when the response
590 + * could not be read and nothing should be concluded from it.
591 + */
592 + private function probe_static_delivery(): string {
593 + if (!function_exists('wp_remote_get')) {
594 + return 'unknown';
595 + }
596 +
597 + // The cache-buster stops a page cache from answering with a copy stored
598 + // before the file was written; a server ignores the query string when it
599 + // serves a physical file, so the response still shows the real headers.
600 + $url = add_query_arg(
601 + 'thinkrank-delivery-check',
602 + (string) time(),
603 + home_url('/llms.txt')
604 + );
605 +
606 + $response = wp_remote_get($url, [
607 + 'timeout' => 5,
608 + 'redirection' => 2,
609 + // A request to our own home URL, from which a single response header
610 + // is read. Staging and local installs routinely run on certificates
611 + // this host does not trust, and failing there would leave the very
612 + // sites most likely to be misconfigured unchecked.
613 + 'sslverify' => false,
614 + 'headers' => ['Cache-Control' => 'no-cache'],
615 + ]);
616 +
617 + if (is_wp_error($response) || 200 !== (int) wp_remote_retrieve_response_code($response)) {
618 + return 'unknown';
619 + }
620 +
621 + $content_type = wp_remote_retrieve_header($response, 'content-type');
622 +
623 + // A header sent more than once comes back as an array.
624 + if (is_array($content_type)) {
625 + $content_type = implode(' ', $content_type);
626 + }
627 +
628 + $content_type = trim((string) $content_type);
629 +
630 + // No Content-Type at all is the same problem: the browser is left to
631 + // guess the encoding.
632 + if ('' === $content_type) {
633 + return 'no_charset';
634 + }
635 +
636 + return false !== stripos($content_type, 'charset=') ? 'charset' : 'no_charset';
637 + }
638 +
639 + /**
640 + * Persist the outcome of a delivery check.
641 + *
642 + * @since 2.1.0
643 + *
644 + * @param string $verdict One of 'charset', 'no_charset', 'unknown'.
645 + * @return void
646 + */
647 + private function record_delivery_probe(string $verdict): void {
648 + update_option(self::DELIVERY_PROBE_OPTION, [
649 + 'home' => home_url(),
650 + 'result' => $verdict,
651 + 'checked_at' => time(),
652 + ], false);
653 + }
654 +
655 + /**
656 + * Confirm the published file is really served with a charset, and act on it.
657 + *
658 + * Static delivery leans on an .htaccess directive, so it is only ever as
659 + * good as the guess that the server reads .htaccess. This checks the guess
660 + * against the response the public URL actually returns: a site left on
661 + * `auto` is moved to WordPress delivery when the charset is missing — the
662 + * file has to go with it, or it would shadow the PHP route that carries the
663 + * charset — while a site that asked for `static` keeps its file and gets a
664 + * warning, because an explicit choice is not overruled.
665 + *
666 + * @since 2.1.0
667 + *
668 + * @param array $result Publish result to annotate.
669 + * @return array The annotated result.
670 + */
671 + private function verify_static_delivery(array $result): array {
672 + $verdict = $this->probe_static_delivery();
673 +
674 + $this->record_delivery_probe($verdict);
675 +
676 + if ('no_charset' !== $verdict) {
677 + return $result;
678 + }
679 +
680 + $settings = $this->get_settings('site');
681 + $explicit = 'static' === (string) ($settings['delivery_mode'] ?? 'auto');
682 +
683 + // Auto: resolve_delivery_mode() answers 'dynamic' from here on, so the
684 + // file it would otherwise leave behind has to be removed. The document
685 + // is already stored, so nothing is lost by deleting it.
686 + if (!$explicit && $this->delete_static_file()) {
687 + delete_transient('thinkrank_llms_file_status');
688 + $this->purge_llms_txt_caches();
689 +
690 + $result['delivery_mode'] = 'dynamic';
691 + $result['charset_pinned'] = true;
692 + $result['message'] = 'LLMs.txt published. This server answers a static file without a character set, so WordPress serves it as UTF-8 instead.';
693 + $result['permissions']['file_exists'] = false;
694 + $result['permissions']['file_writable'] = null;
695 +
696 + return $result;
697 + }
698 +
699 + $result['charset_pinned'] = false;
700 + $result['delivery_warning'] = self::STATIC_CHARSET_WARNING;
701 + $result['message'] = trim((string) $result['message'] . ' ' . self::STATIC_CHARSET_WARNING);
702 +
703 + return $result;
704 + }
705 +
706 + /**
707 + * Whether the delivery check may run on this request.
708 + *
709 + * It makes an HTTP request of its own, so it never runs on a front-end
710 + * page view — only where an administrator, the REST API, WP-CLI or cron is
711 + * already waiting on a status read.
712 + *
713 + * @since 2.1.0
714 + *
715 + * @return bool
716 + */
717 + private function delivery_probe_is_due(): bool {
718 + $interactive = is_admin()
719 + || (defined('REST_REQUEST') && REST_REQUEST)
720 + || (defined('WP_CLI') && WP_CLI)
721 + || (function_exists('wp_doing_cron') && wp_doing_cron());
722 +
723 + if (!$interactive) {
724 + return false;
725 + }
726 +
727 + $probe = $this->delivery_probe();
728 +
729 + if ([] === $probe) {
730 + return true;
731 + }
732 +
733 + if ('unknown' !== $probe['result']) {
734 + return false;
735 + }
736 +
737 + return (time() - (int) ($probe['checked_at'] ?? 0)) > self::DELIVERY_PROBE_RETRY;
738 + }
739 +
740 + /**
741 + * The published llms.txt document, or an empty string when unpublished.
742 + *
743 + * @since 2.1.0
744 + *
745 + * @return string
746 + */
747 + public function get_published_content(): string {
748 + $content = get_option(self::CONTENT_OPTION, '');
749 +
750 + return is_string($content) ? $content : '';
751 + }
752 +
753 + /**
754 + * Whether /llms.txt is currently being served, in either delivery mode.
755 + *
756 + * `static` publishes a file at ABSPATH; `dynamic` keeps the document in
757 + * an option and answers from serve_llms_txt(). Callers that only need
758 + * this yes/no must use it in preference to get_llms_txt_status(), which
759 + * resolves the delivery mode, may fire a loopback delivery probe, asks
760 + * the filesystem API whether ABSPATH is writable, reads the document and
761 + * writes a transient — far too much work for a boolean, and not
762 + * something a dashboard summary should be triggering.
763 + *
764 + * @since 2.2.1
765 + *
766 + * @return bool
767 + */
768 + public function is_published(): bool {
769 + return file_exists(ABSPATH . 'llms.txt')
770 + || '' !== trim($this->get_published_content());
771 + }
772 +
773 + /**
774 + * Ask the common page/CDN cache layers to drop their copy of /llms.txt.
775 + *
776 + * A cached response outlives a republish, so without this a mode switch or
777 + * a content change keeps serving the old document (and, on the static path,
778 + * the old headers). Every call is guarded — a site running none of these
779 + * simply gets the action hook, which integrations can use.
780 + *
781 + * @since 2.1.0
782 + *
783 + * @return void
784 + */
785 + private function purge_llms_txt_caches(): void {
786 + $url = home_url('/llms.txt');
787 +
788 + /**
789 + * Fires after the published llms.txt changes, so cache layers ThinkRank
790 + * does not know about can drop their copy.
791 + *
792 + * @since 2.1.0
793 + *
794 + * @param string $url Public URL of the llms.txt document.
795 + */
796 + do_action('thinkrank_llms_txt_updated', $url);
797 +
798 + // LiteSpeed Cache and Nginx Helper both listen on their own actions.
799 + // These are third-party hook names we fire, not ours to prefix.
800 + do_action('litespeed_purge_url', $url); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
801 + do_action('rt_nginx_helper_purge_all'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
802 +
803 + if (function_exists('rocket_clean_files')) {
804 + rocket_clean_files([$url]);
805 + }
806 + if (function_exists('w3tc_flush_url')) {
807 + w3tc_flush_url($url);
808 + }
809 + if (function_exists('wpsc_delete_url_cache')) {
810 + wpsc_delete_url_cache($url);
811 + }
812 + }
813 +
814 + /**
815 + * Unpublish llms.txt: drop the stored document and any physical file.
816 + *
817 + * Both delivery modes are cleared, not just the active one, so a site that
818 + * published under one mode and switched to the other is left with nothing
819 + * still being served.
820 + *
821 + * @return bool True once nothing is left to serve.
822 + */
313 823 public function delete_llms_txt_file(): bool {
824 + delete_option(self::CONTENT_OPTION);
825 + delete_option(self::PUBLISHED_AT_OPTION);
826 +
827 + return $this->unpublish_static_file();
828 + }
829 +
830 + /**
831 + * Stop serving llms.txt, but keep the document.
832 + *
833 + * Deactivation needs this half: the physical file must go — it shadows the
834 + * next plugin's routes and advertises a plugin that is switched off — but
835 + * the user's prose has to survive so reactivation can republish it.
836 + * {@see \ThinkRank\Core\Activator::restore_webroot_artifacts()} does that.
837 + *
838 + * Deactivation previously called {@see delete_llms_txt_file()}, which drops
839 + * the stored document too, so a deactivate/reactivate round-trip silently
840 + * lost whatever the user had written.
841 + *
842 + * @since 2.1.0
843 + *
844 + * @return bool True once nothing is left on disk.
845 + */
846 + public function unpublish_static_file(): bool {
314 847 delete_transient('thinkrank_llms_file_status');
315 848
849 + $removed = $this->delete_static_file();
850 + $this->purge_llms_txt_caches();
851 +
852 + return $removed;
853 + }
854 +
855 + /**
856 + * Remove the physical ABSPATH/llms.txt and its .htaccess charset block.
857 + *
858 + * @since 2.1.0
859 + *
860 + * @return bool True if the file is absent or was removed.
861 + */
862 + private function delete_static_file(): bool {
316 863 $llms_file = ABSPATH . 'llms.txt';
317 864 if (!file_exists($llms_file)) {
318 865 $this->remove_htaccess_charset();
319 866 return true;
@@ -445,9 +992,12 @@
445 992
446 993 $content = '';
447 994 $llms_file = ABSPATH . 'llms.txt';
448 995
449 - if (file_exists($llms_file)) {
996 + // In static mode a physical file is what the server would normally hand
997 + // back, so prefer its exact bytes; in dynamic mode there is no file and
998 + // the stored document is the authoritative copy.
999 + if ('static' === $this->resolve_delivery_mode() && file_exists($llms_file)) {
450 1000 $read_result = $this->safe_file_read($llms_file);
451 1001 if ($read_result['success']) {
452 1002 $content = $read_result['content'];
453 1003 }
@@ -453,8 +1003,12 @@
453 1003 }
454 1004 }
455 1005
456 1006 if ('' === trim($content)) {
1007 + $content = $this->get_published_content();
1008 + }
1009 +
1010 + if ('' === trim($content)) {
457 1011 $generated = $this->generate_llms_txt([]);
458 1012 $content = (string) ($generated['content'] ?? '');
459 1013 }
460 1014
@@ -496,10 +1050,39 @@
496 1050 $result['message'] = 'LLMs.txt is disabled. Enable it before publishing.';
497 1051 return $result;
498 1052 }
499 1053
1054 + $mode = $this->resolve_delivery_mode();
1055 + $result['delivery_mode'] = $mode;
1056 +
500 1057 $llms_file = ABSPATH . 'llms.txt';
1058 + $result['file_path'] = $llms_file;
501 1059
1060 + // Dynamic delivery: the document lives in the database and /llms.txt is
1061 + // answered by serve_llms_txt(), which sets `charset=utf-8` itself. A
1062 + // physical file would shadow that route on every stack, so any leftover
1063 + // from a previous static publish has to go.
1064 + if ('dynamic' === $mode) {
1065 + if (!$this->delete_static_file()) {
1066 + $result['message'] = 'A physical llms.txt is still present and could not be removed. It would be served instead of the dynamic route.';
1067 + return $result;
1068 + }
1069 +
1070 + $this->store_published_content($content);
1071 +
1072 + $result['success'] = true;
1073 + $result['message'] = 'LLMs.txt published. It is served by WordPress as UTF-8 text.';
1074 + $result['bytes_written'] = strlen($content);
1075 + $result['charset_pinned'] = true;
1076 + $result['permissions'] = [
1077 + 'directory_writable' => $this->is_directory_writable(ABSPATH),
1078 + 'file_exists' => false,
1079 + 'file_writable' => null,
1080 + ];
1081 +
1082 + return $result;
1083 + }
1084 +
502 1085 // Security: Validate file path to prevent path traversal attacks
503 1086 $real_llms_file = realpath(dirname($llms_file)) . DIRECTORY_SEPARATOR . basename($llms_file);
504 1087 $allowed_dir = realpath(ABSPATH);
505 1088
@@ -507,10 +1090,8 @@
507 1090 $result['message'] = 'Invalid file path detected for security reasons.';
508 1091 return $result;
509 1092 }
510 1093
511 - $result['file_path'] = $llms_file;
512 -
513 1094 // Check directory permissions
514 1095 $result['permissions'] = [
515 1096 'directory_writable' => $this->is_directory_writable(ABSPATH),
516 1097 'file_exists' => file_exists($llms_file),
@@ -528,36 +1109,57 @@
528 1109 $result['message'] = 'Existing llms.txt file is not writable. Please check file permissions.';
529 1110 return $result;
530 1111 }
531 1112
532 - // Write new content using WP_Filesystem
533 - if (!$this->init_filesystem()) {
534 - $result['message'] = 'Could not initialize WordPress filesystem.';
535 - return $result;
536 - }
1113 + // Write new content using WP_Filesystem
1114 + if (!$this->init_filesystem()) {
1115 + $result['message'] = 'Could not initialize WordPress filesystem.';
1116 + return $result;
1117 + }
537 1118
538 - $write_success = $this->filesystem->put_contents($llms_file, $content, FS_CHMOD_FILE);
1119 + if (!$this->filesystem->put_contents($llms_file, $content, FS_CHMOD_FILE)) {
1120 + $result['message'] = 'Failed to write llms.txt file.';
1121 + return $result;
1122 + }
539 1123
540 - if ($write_success) {
541 - $result['success'] = true;
542 - $result['message'] = 'LLMs.txt file written successfully.';
543 - $result['bytes_written'] = strlen($content);
1124 + $result['success'] = true;
1125 + $result['message'] = 'LLMs.txt file written successfully.';
1126 + $result['bytes_written'] = strlen($content);
544 1127
545 - // Pin the served charset to UTF-8. Best effort: a site without a
546 - // writable .htaccess (or not on Apache/LiteSpeed) still gets a
547 - // correctly written file, so this must never fail the publish.
548 - $result['charset_pinned'] = $this->sync_htaccess_charset();
1128 + // Pin the served charset to UTF-8. Best effort: a site without a
1129 + // writable .htaccess (or not on Apache/LiteSpeed) still gets a
1130 + // correctly written file, so this must never fail the publish.
1131 + $result['charset_pinned'] = $this->sync_htaccess_charset();
549 1132
550 - // Invalidate file status cache since file has changed
551 - delete_transient('thinkrank_llms_file_status');
552 - } else {
553 - $result['message'] = 'Failed to write llms.txt file.';
554 - }
1133 + // Keep the stored copy in step with the file so a later switch to
1134 + // dynamic delivery serves the same document.
1135 + $this->store_published_content($content);
555 1136
556 - return $result;
1137 + // Detection said this server reads the .htaccess block. Check what the
1138 + // public URL really answers with before leaving the file in place — on a
1139 + // reverse-proxied stack the detection describes the wrong server (#493).
1140 + return $this->verify_static_delivery($result);
557 1141 }
558 1142
559 1143 /**
1144 + * Persist the published document and bust the caches that mirror it.
1145 + *
1146 + * @since 2.1.0
1147 + *
1148 + * @param string $content Published llms.txt content.
1149 + * @return void
1150 + */
1151 + private function store_published_content(string $content): void {
1152 + update_option(self::CONTENT_OPTION, $content, false);
1153 + update_option(self::PUBLISHED_AT_OPTION, time(), false);
1154 +
1155 + // Invalidate file status cache since the published document has changed
1156 + delete_transient('thinkrank_llms_file_status');
1157 +
1158 + $this->purge_llms_txt_caches();
1159 + }
1160 +
1161 + /**
560 1162 * Get LLMs.txt file status and information
561 1163 *
562 1164 * @since 1.0.0
563 1165 *
@@ -574,36 +1176,69 @@
574 1176 }
575 1177 }
576 1178
577 1179 $llms_file = ABSPATH . 'llms.txt';
1180 + $mode = $this->resolve_delivery_mode();
578 1181
1182 + // A site that published before this check existed — or whose server has
1183 + // changed under it — has never had its delivery confirmed. Do it here so
1184 + // an already-broken install heals without waiting for a republish; the
1185 + // recorded verdict and the status cache keep it to a couple of requests
1186 + // a day at most.
1187 + if ('static' === $mode && file_exists($llms_file) && $this->delivery_probe_is_due()) {
1188 + $this->verify_static_delivery(['message' => '']);
1189 + $mode = $this->resolve_delivery_mode();
1190 + }
1191 +
1192 + $stored = $this->get_published_content();
1193 +
579 1194 $status = [
580 1195 'file_exists' => file_exists($llms_file),
581 - 'file_path' => $llms_file,
1196 + // Whether /llms.txt is actually being served, either mode. Prefer
1197 + // this over file_exists, which is only meaningful in static mode.
1198 + 'published' => $this->is_published(),
1199 + 'delivery_mode' => $mode,
1200 + 'file_path' => 'dynamic' === $mode ? '' : $llms_file,
582 1201 'file_url' => home_url('/llms.txt'),
583 1202 'writable' => $this->is_directory_writable(dirname($llms_file)),
1203 + // Non-empty only when the site is on static delivery that the server
1204 + // is known to answer without a charset — i.e. an explicit `static`
1205 + // the plugin will not overrule, which is the user's to fix.
1206 + 'delivery_warning' => 'static' === $mode && $this->static_delivery_drops_charset()
1207 + ? self::STATIC_CHARSET_WARNING
1208 + : '',
584 1209 'last_modified' => null,
585 1210 'file_size' => null,
586 1211 'content_preview' => ''
587 1212 ];
588 1213
1214 + $content = null;
1215 +
589 1216 if ($status['file_exists']) {
590 1217 $status['last_modified'] = filemtime($llms_file);
591 1218 $status['file_size'] = filesize($llms_file);
592 1219
593 - // Get content preview (first 200 characters) with size safety
594 1220 $read_result = $this->safe_file_read($llms_file);
595 1221 if ($read_result['success']) {
596 - $status['content_preview'] = substr($read_result['content'], 0, 200);
597 - if (strlen($read_result['content']) > 200) {
598 - $status['content_preview'] .= '...';
599 - }
1222 + $content = $read_result['content'];
600 1223 } else {
601 1224 $status['content_preview'] = 'Error: ' . $read_result['error'];
602 1225 $status['read_error'] = $read_result['error'];
603 1226 }
1227 + } elseif ('' !== trim($stored)) {
1228 + $published_at = (int) get_option(self::PUBLISHED_AT_OPTION, 0);
1229 + $status['last_modified'] = $published_at > 0 ? $published_at : null;
1230 + $status['file_size'] = strlen($stored);
1231 + $content = $stored;
604 1232 }
605 1233
1234 + if (null !== $content) {
1235 + // Get content preview (first 200 characters) with size safety
1236 + // substr()/strlen() count BYTES, so this cut a multibyte character
1237 + // in half and shipped an invalid UTF-8 sequence in the preview (#687).
1238 + $status['content_preview'] = \ThinkRank\Core\Seo_Text::trim_to_length($content, 200);
1239 + }
1240 +
606 1241 // Cache the result for 5 minutes to improve performance
607 1242 set_transient($cache_key, $status, 5 * MINUTE_IN_SECONDS);
608 1243
609 1244 return $status;
@@ -748,13 +1383,15 @@
748 1383 * Override parent sanitize_settings to preserve line breaks in link fields
749 1384 *
750 1385 * @since 1.0.0
751 1386 *
752 - * @param array $settings Settings to sanitize
1387 + * @param array $settings Settings to sanitize
1388 + * @param string $context_type Context the save is for.
753 1389 * @return array Sanitized settings
754 1390 */
755 - protected function sanitize_settings(array $settings): array {
1391 + protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
756 1392 $sanitized = [];
1393 + $known = $this->get_known_setting_keys($context_type);
757 1394
758 1395 // Fields that should preserve line breaks
759 1396 $preserve_linebreaks = [
760 1397 'documentation_links',
@@ -771,8 +1408,27 @@
771 1408
772 1409 foreach ($settings as $key => $value) {
773 1410 $sanitized_key = sanitize_key($key);
774 1411
1412 + // Never store the REST envelope back as settings (see
1413 + // Abstract_Seo_Manager::RESERVED_ENVELOPE_KEYS).
1414 + if (in_array($sanitized_key, self::RESERVED_ENVELOPE_KEYS, true)) {
1415 + continue;
1416 + }
1417 +
1418 + // And nothing this manager does not declare (#452).
1419 + if (!$this->is_known_setting_key($sanitized_key, $known)) {
1420 + continue;
1421 + }
1422 +
1423 + // Constrain the delivery mode to the known enum so an unexpected
1424 + // value falls back to auto-detection rather than being stored.
1425 + if ('delivery_mode' === $sanitized_key) {
1426 + $mode = is_string($value) ? sanitize_key($value) : '';
1427 + $sanitized[$sanitized_key] = in_array($mode, self::DELIVERY_MODES, true) ? $mode : 'auto';
1428 + continue;
1429 + }
1430 +
775 1431 if (is_string($value)) {
776 1432 if (in_array($key, $preserve_linebreaks, true)) {
777 1433 // Use our custom sanitization that preserves line breaks
778 1434 if (in_array($key, ['documentation_links', 'technical_links', 'optional_links', 'custom_sections'], true)) {
@@ -1236,12 +1892,16 @@
1236 1892 $validation['score'] -= 5;
1237 1893 }
1238 1894 }
1239 1895
1240 - // Check file permissions if enabled
1241 - if (!empty($settings['enabled'])) {
1896 + // Check file permissions if enabled. Only the static delivery mode needs
1897 + // a writable root — dynamic delivery keeps the document in the database.
1898 + $mode = $this->resolve_delivery_mode(
1899 + isset($settings['delivery_mode']) ? (string) $settings['delivery_mode'] : null
1900 + );
1901 + if (!empty($settings['enabled']) && 'static' === $mode) {
1242 1902 if (!$this->is_directory_writable(ABSPATH)) {
1243 - $validation['warnings'][] = 'WordPress root directory is not writable, llms.txt cannot be automatically managed';
1903 + $validation['warnings'][] = 'WordPress root directory is not writable, llms.txt cannot be automatically managed. Switch delivery to "Served by WordPress" to publish without writing a file.';
1244 1904 $validation['score'] -= 10;
1245 1905 }
1246 1906 }
1247 1907
@@ -1276,9 +1936,10 @@
1276 1936
1277 1937 // Get file status
1278 1938 $output['file_status'] = $this->get_llms_txt_status();
1279 1939
1280 - // If file exists, get current content safely
1940 + // If a file is published, get its current content safely; otherwise fall
1941 + // back to the stored document that dynamic delivery serves.
1281 1942 if ($output['file_status']['file_exists']) {
1282 1943 $llms_file = ABSPATH . 'llms.txt';
1283 1944 $read_result = $this->safe_file_read($llms_file);
1284 1945 if ($read_result['success']) {
@@ -1286,8 +1947,10 @@
1286 1947 } else {
1287 1948 $output['llms_txt_content'] = '';
1288 1949 $output['file_read_error'] = $read_result['error'];
1289 1950 }
1951 + } else {
1952 + $output['llms_txt_content'] = $this->get_published_content();
1290 1953 }
1291 1954
1292 1955 // Add metadata
1293 1956 $output['metadata'] = [
@@ -1319,8 +1982,9 @@
1319 1982 'development_approach' => '',
1320 1983 'setup_instructions' => '',
1321 1984 'ai_context_custom' => '',
1322 1985 'auto_generate' => false,
1986 + 'delivery_mode' => 'auto',
1323 1987 'last_generated' => null,
1324 1988 // Structured sections for llms.txt spec compliance
1325 1989 'documentation_links' => '',
1326 1990 'technical_links' => '',
@@ -1425,8 +2089,15 @@
1425 2089 'title' => 'Auto-generate',
1426 2090 'description' => 'Automatically regenerate llms.txt when settings change',
1427 2091 'default' => false
1428 2092 ],
2093 + 'delivery_mode' => [
2094 + 'type' => 'string',
2095 + 'title' => 'Delivery Method',
2096 + 'description' => 'How /llms.txt is served: "static" writes a physical file the web server answers, "dynamic" keeps the document in WordPress and serves it from PHP as UTF-8, "auto" picks static on Apache/LiteSpeed and dynamic elsewhere.',
2097 + 'enum' => self::DELIVERY_MODES,
2098 + 'default' => 'auto'
2099 + ],
1429 2100 'last_generated' => [
1430 2101 'type' => 'string',
1431 2102 'title' => 'Last Generated',
1432 2103 'description' => 'Timestamp of last generation',
@@ -1540,9 +2211,9 @@
1540 2211 $content .= "- [Technical Stack]({$website_url}): Built with {$stack}\n";
1541 2212 }
1542 2213
1543 2214 if (!empty($user_input['development_approach'])) {
1544 - $approach_summary = wp_trim_words($user_input['development_approach'], 10);
2215 + $approach_summary = \ThinkRank\Core\Seo_Text::trim_words($user_input['development_approach'], 10);
1545 2216 $content .= "- [Development Guidelines]({$website_url}): {$approach_summary}\n";
1546 2217 }
1547 2218
1548 2219 // Add robots.txt reference