PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.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/seo/class-llms-txt-manager.php +1168 -52 1.27.0 → 2.11.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
@@ -66,9 +90,9 @@
66 90 ],
67 91 'key_features' => [
68 92 'title' => 'Key Features',
69 93 'required' => true,
70 - 'description' => 'Main features and functionality of the website',
94 + 'description' => 'Main features and functionality of the website, one feature per line. Commas are part of a feature, not separators.',
71 95 'max_length' => 300
72 96 ],
73 97 'architecture' => [
74 98 'title' => 'Architecture & Components',
@@ -104,8 +128,95 @@
104 128 */
105 129 private const MAX_FILE_SIZE = 1048576; // 1MB in bytes
106 130
107 131 /**
132 + * Marker used for ThinkRank's block in the site's .htaccess.
133 + *
134 + * The published llms.txt is a physical file, so the web server — not PHP —
135 + * serves it and decides the response headers. Apache/LiteSpeed answer .txt
136 + * with a bare `Content-Type: text/plain` (no charset), which makes browsers
137 + * fall back to their legacy single-byte default and render UTF-8 content as
138 + * mojibake ("Aktivitäten" → "Aktivitäten"); `X-Content-Type-Options:
139 + * nosniff` removes even the sniffing fallback. This block pins the charset
140 + * for that one file. See {@see serve_llms_txt()} for the PHP-served path.
141 + *
142 + * @var string
143 + */
144 + private const HTACCESS_MARKER = 'ThinkRank llms.txt';
145 +
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 + /**
207 + * One-time marker for {@see LLMs_Txt_Manager::maybe_migrate_legacy_key_features()}.
208 + *
209 + * Public so the activator can record it on a fresh install, which has no
210 + * value saved under the old comma rule and must never be migrated.
211 + *
212 + * @since 2.10.0
213 + * @var string
214 + */
215 + public const KEY_FEATURES_MIGRATION_OPTION = 'thinkrank_llms_key_features_migration';
216 + public const KEY_FEATURES_MIGRATION_VERSION = '1';
217 +
218 + /**
108 219 * Business type templates for content generation
109 220 *
110 221 * @since 1.0.0
111 222 * @var array
@@ -197,8 +308,13 @@
197 308 'validation' => [],
198 309 'file_info' => []
199 310 ];
200 311
312 + // Generating from saved settings (the MCP ability passes an empty
313 + // payload) can happen before any admin request has run the upgrade,
314 + // so make sure a legacy comma list has been converted first.
315 + self::maybe_migrate_legacy_key_features();
316 +
201 317 // Get current settings
202 318 $settings = $this->get_settings('site');
203 319
204 320 // Merge saved settings underneath the provided input so that empty or
@@ -264,10 +380,18 @@
264 380 * @return bool
265 381 */
266 382 public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
267 383 $this->last_unpublish_failed = false;
384 + $this->last_delivery_warning = '';
385 + $this->last_delivery_switch_failed = false;
386 + $previous_mode = $this->resolve_delivery_mode();
387 +
268 388 $result = parent::save_settings($context_type, $context_id, $settings);
269 389
390 + // The cached status carries the resolved delivery mode, so it goes stale
391 + // the moment settings change — even when nothing needs republishing.
392 + delete_transient('thinkrank_llms_file_status');
393 +
270 394 // When a save explicitly disables the feature, delete the published file.
271 395 if ($result && array_key_exists('enabled', $settings) && empty($settings['enabled'])) {
272 396 if (!$this->delete_llms_txt_file()) {
273 397 // The settings were persisted, but the physical file could not be
@@ -274,14 +398,124 @@
274 398 // removed, so /llms.txt may still be served. Record it so callers
275 399 // report a partial failure instead of an unqualified success.
276 400 $this->last_unpublish_failed = true;
277 401 }
402 +
403 + return $result;
278 404 }
279 405
406 + // The published document has to sit where the active mode serves it from,
407 + // or the site keeps answering on the old path: a leftover physical file
408 + // shadows the dynamic route on every stack, and a database-only document
409 + // is invisible to a stack now expecting a file. Reconciled on any save,
410 + // not just an explicit mode change, so a site whose auto-detection now
411 + // resolves differently — an nginx install upgrading into this fix with a
412 + // static file already on disk — heals the next time settings are saved.
413 + if ($result && $this->delivery_needs_reconcile($previous_mode)) {
414 + $this->republish_for_delivery_mode();
415 + }
416 +
280 417 return $result;
281 418 }
282 419
283 420 /**
421 + * Whether the published document is out of step with the active mode.
422 + *
423 + * @since 2.1.0
424 + *
425 + * @param string $previous_mode Mode in force before the save.
426 + * @return bool
427 + */
428 + private function delivery_needs_reconcile(string $previous_mode): bool {
429 + $mode = $this->resolve_delivery_mode();
430 + $file_exists = file_exists(ABSPATH . 'llms.txt');
431 +
432 + if ('dynamic' === $mode) {
433 + // A physical file would be served instead of the PHP route.
434 + return $file_exists;
435 + }
436 +
437 + // Static: a stored document with no file behind it is unreachable on a
438 + // stack that expects one. A mode flip also forces the charset block to
439 + // be (re)written for a file that predates it.
440 + return (!$file_exists && '' !== trim($this->get_published_content()))
441 + || $mode !== $previous_mode;
442 + }
443 +
444 + /**
445 + * Re-publish the current document under the active delivery mode.
446 + *
447 + * A no-op when nothing is published yet — this only moves an existing
448 + * document, it never publishes on the user's behalf.
449 + *
450 + * @since 2.1.0
451 + *
452 + * @return void
453 + */
454 + private function republish_for_delivery_mode(): void {
455 + $content = $this->get_published_content();
456 +
457 + if ('' === trim($content)) {
458 + // Published before the stored copy existed: recover it from the file.
459 + $llms_file = ABSPATH . 'llms.txt';
460 + if (file_exists($llms_file)) {
461 + $read_result = $this->safe_file_read($llms_file);
462 + if ($read_result['success']) {
463 + $content = $read_result['content'];
464 + }
465 + }
466 + }
467 +
468 + if ('' === trim($content)) {
469 + return;
470 + }
471 +
472 + $write = $this->write_llms_txt_to_file($content);
473 +
474 + // The switch itself failed (an unwritable root on the way to static, a
475 + // stuck file on the way to dynamic). The settings are saved, so report
476 + // it rather than letting the mode read as applied when it is not.
477 + if (empty($write['success'])) {
478 + $this->last_delivery_warning = isset($write['message']) && '' !== (string) $write['message']
479 + ? (string) $write['message']
480 + : 'The delivery method was saved, but the published llms.txt could not be moved to it.';
481 + $this->last_delivery_switch_failed = true;
482 + return;
483 + }
484 +
485 + // The switch worked, but static delivery on this server cannot carry the
486 + // charset the document needs. Only an explicitly chosen `static` gets
487 + // this far — `auto` moves itself to WordPress delivery instead.
488 + if (!empty($write['delivery_warning'])) {
489 + $this->last_delivery_warning = (string) $write['delivery_warning'];
490 + }
491 + }
492 +
493 + /**
494 + * Message from the last save whose delivery-mode switch could not be
495 + * applied to the already-published document, or '' when there was none.
496 + *
497 + * @since 2.1.0
498 + *
499 + * @return string
500 + */
501 + public function delivery_switch_warning(): string {
502 + return $this->last_delivery_warning;
503 + }
504 +
505 + /**
506 + * Whether the last save's warning was a failed switch rather than a
507 + * successful one the server cannot serve correctly.
508 + *
509 + * @since 2.1.0
510 + *
511 + * @return bool
512 + */
513 + public function delivery_switch_failed(): bool {
514 + return $this->last_delivery_switch_failed;
515 + }
516 +
517 + /**
284 518 * Whether the last save_settings() disabled the feature but could not remove
285 519 * the published llms.txt file (which may therefore still be served).
286 520 *
287 521 * @return bool
@@ -290,26 +524,537 @@
290 524 return $this->last_unpublish_failed;
291 525 }
292 526
293 527 /**
294 - * Remove the published llms.txt file and bust the status transient.
528 + * Resolve the effective delivery mode for /llms.txt.
295 529 *
296 - * @return bool True if the file is absent or was removed.
530 + * `static` publishes a physical ABSPATH/llms.txt and lets the web server
531 + * answer it; `dynamic` keeps the document in the database and lets the PHP
532 + * route in {@see serve_llms_txt()} answer it. `auto` picks static only on
533 + * Apache/LiteSpeed, the stacks that read the .htaccess charset block — on
534 + * nginx a physical file is served with a bare `Content-Type: text/plain`
535 + * that neither fix path can reach, which renders UTF-8 as mojibake (#419).
536 + *
537 + * $is_apache is not trusted on its own: WordPress reads it from
538 + * $_SERVER['SERVER_SOFTWARE'], which describes the server that runs PHP
539 + * rather than the one answering the public request. A reverse proxy hides
540 + * the difference — an nginx edge in front of an Apache backend reports
541 + * Apache, so `auto` chose the file that nginx then served with no charset,
542 + * which is the very defect the setting was added to avoid (#493). A
543 + * publish-time self-request settles what the detection cannot see, and its
544 + * verdict is what this consults; an explicit setting still wins outright.
545 + *
546 + * @since 2.1.0
547 + *
548 + * @param string|null $mode Optional. Raw setting value; read from the saved
549 + * settings when null.
550 + * @return string Either 'static' or 'dynamic'.
297 551 */
552 + public function resolve_delivery_mode(?string $mode = null): string {
553 + if (null === $mode) {
554 + $settings = $this->get_settings('site');
555 + $mode = (string) ($settings['delivery_mode'] ?? 'auto');
556 + }
557 +
558 + if ('static' === $mode || 'dynamic' === $mode) {
559 + return $mode;
560 + }
561 +
562 + // A root PHP cannot write to has no static path at all: publishing
563 + // would simply fail and /llms.txt would 404. The sitemap's `auto`
564 + // already resolves this way (#754); llms.txt did not, so on an
565 + // Apache/LiteSpeed host with a read-only root — a managed stack such as
566 + // Flywheel, where ABSPATH is the locked core folder — `auto` chose
567 + // static and then could not deliver it (#756).
568 + if (!wp_is_writable(ABSPATH)) {
569 + return 'dynamic';
570 + }
571 +
572 + // $is_apache also covers LiteSpeed, which reads .htaccess the same way.
573 + if (empty($GLOBALS['is_apache'])) {
574 + return 'dynamic';
575 + }
576 +
577 + // Detection says this stack reads the .htaccess charset block. Believe
578 + // it unless a self-request has caught the public URL answering without
579 + // a charset, which is what a reverse-proxied stack does (#493).
580 + return $this->static_delivery_drops_charset() ? 'dynamic' : 'static';
581 + }
582 +
583 + /**
584 + * Whether the recorded check caught the public URL dropping the charset.
585 + *
586 + * @since 2.1.0
587 + *
588 + * @return bool
589 + */
590 + private function static_delivery_drops_charset(): bool {
591 + return 'no_charset' === ($this->delivery_probe()['result'] ?? '');
592 + }
593 +
594 + /**
595 + * The delivery check recorded for this site, or [] when there is none.
596 + *
597 + * @since 2.1.0
598 + *
599 + * @return array
600 + */
601 + private function delivery_probe(): array {
602 + $probe = get_option(self::DELIVERY_PROBE_OPTION, []);
603 +
604 + if (!is_array($probe) || !isset($probe['result'])) {
605 + return [];
606 + }
607 +
608 + return ($probe['home'] ?? '') === home_url() ? $probe : [];
609 + }
610 +
611 + /**
612 + * Ask the site's own public URL what it answers /llms.txt with.
613 + *
614 + * @since 2.1.0
615 + *
616 + * @return string 'charset', 'no_charset', or 'unknown' when the response
617 + * could not be read and nothing should be concluded from it.
618 + */
619 + private function probe_static_delivery(): string {
620 + if (!function_exists('wp_remote_get')) {
621 + return 'unknown';
622 + }
623 +
624 + // The cache-buster stops a page cache from answering with a copy stored
625 + // before the file was written; a server ignores the query string when it
626 + // serves a physical file, so the response still shows the real headers.
627 + $url = add_query_arg(
628 + 'thinkrank-delivery-check',
629 + (string) time(),
630 + home_url('/llms.txt')
631 + );
632 +
633 + $response = wp_remote_get($url, [
634 + 'timeout' => 5,
635 + 'redirection' => 2,
636 + // A request to our own home URL, from which a single response header
637 + // is read. Staging and local installs routinely run on certificates
638 + // this host does not trust, and failing there would leave the very
639 + // sites most likely to be misconfigured unchecked.
640 + 'sslverify' => false,
641 + 'headers' => ['Cache-Control' => 'no-cache'],
642 + ]);
643 +
644 + if (is_wp_error($response) || 200 !== (int) wp_remote_retrieve_response_code($response)) {
645 + return 'unknown';
646 + }
647 +
648 + $content_type = wp_remote_retrieve_header($response, 'content-type');
649 +
650 + // A header sent more than once comes back as an array.
651 + if (is_array($content_type)) {
652 + $content_type = implode(' ', $content_type);
653 + }
654 +
655 + $content_type = trim((string) $content_type);
656 +
657 + // No Content-Type at all is the same problem: the browser is left to
658 + // guess the encoding.
659 + if ('' === $content_type) {
660 + return 'no_charset';
661 + }
662 +
663 + return false !== stripos($content_type, 'charset=') ? 'charset' : 'no_charset';
664 + }
665 +
666 + /**
667 + * Persist the outcome of a delivery check.
668 + *
669 + * @since 2.1.0
670 + *
671 + * @param string $verdict One of 'charset', 'no_charset', 'unknown'.
672 + * @return void
673 + */
674 + private function record_delivery_probe(string $verdict): void {
675 + update_option(self::DELIVERY_PROBE_OPTION, [
676 + 'home' => home_url(),
677 + 'result' => $verdict,
678 + 'checked_at' => time(),
679 + ], false);
680 + }
681 +
682 + /**
683 + * Confirm the published file is really served with a charset, and act on it.
684 + *
685 + * Static delivery leans on an .htaccess directive, so it is only ever as
686 + * good as the guess that the server reads .htaccess. This checks the guess
687 + * against the response the public URL actually returns: a site left on
688 + * `auto` is moved to WordPress delivery when the charset is missing — the
689 + * file has to go with it, or it would shadow the PHP route that carries the
690 + * charset — while a site that asked for `static` keeps its file and gets a
691 + * warning, because an explicit choice is not overruled.
692 + *
693 + * @since 2.1.0
694 + *
695 + * @param array $result Publish result to annotate.
696 + * @return array The annotated result.
697 + */
698 + private function verify_static_delivery(array $result): array {
699 + $verdict = $this->probe_static_delivery();
700 +
701 + $this->record_delivery_probe($verdict);
702 +
703 + if ('no_charset' !== $verdict) {
704 + return $result;
705 + }
706 +
707 + $settings = $this->get_settings('site');
708 + $explicit = 'static' === (string) ($settings['delivery_mode'] ?? 'auto');
709 +
710 + // Auto: resolve_delivery_mode() answers 'dynamic' from here on, so the
711 + // file it would otherwise leave behind has to be removed. The document
712 + // is already stored, so nothing is lost by deleting it.
713 + if (!$explicit && $this->delete_static_file()) {
714 + delete_transient('thinkrank_llms_file_status');
715 + $this->purge_llms_txt_caches();
716 +
717 + $result['delivery_mode'] = 'dynamic';
718 + $result['charset_pinned'] = true;
719 + $result['message'] = 'LLMs.txt published. This server answers a static file without a character set, so WordPress serves it as UTF-8 instead.';
720 + $result['permissions']['file_exists'] = false;
721 + $result['permissions']['file_writable'] = null;
722 +
723 + return $result;
724 + }
725 +
726 + $result['charset_pinned'] = false;
727 + $result['delivery_warning'] = self::STATIC_CHARSET_WARNING;
728 + $result['message'] = trim((string) $result['message'] . ' ' . self::STATIC_CHARSET_WARNING);
729 +
730 + return $result;
731 + }
732 +
733 + /**
734 + * Whether the delivery check may run on this request.
735 + *
736 + * It makes an HTTP request of its own, so it never runs on a front-end
737 + * page view — only where an administrator, the REST API, WP-CLI or cron is
738 + * already waiting on a status read.
739 + *
740 + * @since 2.1.0
741 + *
742 + * @return bool
743 + */
744 + private function delivery_probe_is_due(): bool {
745 + $interactive = is_admin()
746 + || (defined('REST_REQUEST') && REST_REQUEST)
747 + || (defined('WP_CLI') && WP_CLI)
748 + || (function_exists('wp_doing_cron') && wp_doing_cron());
749 +
750 + if (!$interactive) {
751 + return false;
752 + }
753 +
754 + $probe = $this->delivery_probe();
755 +
756 + if ([] === $probe) {
757 + return true;
758 + }
759 +
760 + if ('unknown' !== $probe['result']) {
761 + return false;
762 + }
763 +
764 + return (time() - (int) ($probe['checked_at'] ?? 0)) > self::DELIVERY_PROBE_RETRY;
765 + }
766 +
767 + /**
768 + * The published llms.txt document, or an empty string when unpublished.
769 + *
770 + * @since 2.1.0
771 + *
772 + * @return string
773 + */
774 + public function get_published_content(): string {
775 + $content = get_option(self::CONTENT_OPTION, '');
776 +
777 + return is_string($content) ? $content : '';
778 + }
779 +
780 + /**
781 + * Whether /llms.txt is currently being served, in either delivery mode.
782 + *
783 + * `static` publishes a file at ABSPATH; `dynamic` keeps the document in
784 + * an option and answers from serve_llms_txt(). Callers that only need
785 + * this yes/no must use it in preference to get_llms_txt_status(), which
786 + * resolves the delivery mode, may fire a loopback delivery probe, asks
787 + * the filesystem API whether ABSPATH is writable, reads the document and
788 + * writes a transient — far too much work for a boolean, and not
789 + * something a dashboard summary should be triggering.
790 + *
791 + * @since 2.2.1
792 + *
793 + * @return bool
794 + */
795 + public function is_published(): bool {
796 + return file_exists(ABSPATH . 'llms.txt')
797 + || '' !== trim($this->get_published_content());
798 + }
799 +
800 + /**
801 + * Ask the common page/CDN cache layers to drop their copy of /llms.txt.
802 + *
803 + * A cached response outlives a republish, so without this a mode switch or
804 + * a content change keeps serving the old document (and, on the static path,
805 + * the old headers). Every call is guarded — a site running none of these
806 + * simply gets the action hook, which integrations can use.
807 + *
808 + * @since 2.1.0
809 + *
810 + * @return void
811 + */
812 + private function purge_llms_txt_caches(): void {
813 + $url = home_url('/llms.txt');
814 +
815 + /**
816 + * Fires after the published llms.txt changes, so cache layers ThinkRank
817 + * does not know about can drop their copy.
818 + *
819 + * @since 2.1.0
820 + *
821 + * @param string $url Public URL of the llms.txt document.
822 + */
823 + do_action('thinkrank_llms_txt_updated', $url);
824 +
825 + // LiteSpeed Cache and Nginx Helper both listen on their own actions.
826 + // These are third-party hook names we fire, not ours to prefix.
827 + do_action('litespeed_purge_url', $url); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
828 + do_action('rt_nginx_helper_purge_all'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
829 +
830 + if (function_exists('rocket_clean_files')) {
831 + rocket_clean_files([$url]);
832 + }
833 + if (function_exists('w3tc_flush_url')) {
834 + w3tc_flush_url($url);
835 + }
836 + if (function_exists('wpsc_delete_url_cache')) {
837 + wpsc_delete_url_cache($url);
838 + }
839 + }
840 +
841 + /**
842 + * Unpublish llms.txt: drop the stored document and any physical file.
843 + *
844 + * Both delivery modes are cleared, not just the active one, so a site that
845 + * published under one mode and switched to the other is left with nothing
846 + * still being served.
847 + *
848 + * @return bool True once nothing is left to serve.
849 + */
298 850 public function delete_llms_txt_file(): bool {
851 + delete_option(self::CONTENT_OPTION);
852 + delete_option(self::PUBLISHED_AT_OPTION);
853 +
854 + return $this->unpublish_static_file();
855 + }
856 +
857 + /**
858 + * Stop serving llms.txt, but keep the document.
859 + *
860 + * Deactivation needs this half: the physical file must go — it shadows the
861 + * next plugin's routes and advertises a plugin that is switched off — but
862 + * the user's prose has to survive so reactivation can republish it.
863 + * {@see \ThinkRank\Core\Activator::restore_webroot_artifacts()} does that.
864 + *
865 + * Deactivation previously called {@see delete_llms_txt_file()}, which drops
866 + * the stored document too, so a deactivate/reactivate round-trip silently
867 + * lost whatever the user had written.
868 + *
869 + * @since 2.1.0
870 + *
871 + * @return bool True once nothing is left on disk.
872 + */
873 + public function unpublish_static_file(): bool {
299 874 delete_transient('thinkrank_llms_file_status');
300 875
876 + $removed = $this->delete_static_file();
877 + $this->purge_llms_txt_caches();
878 +
879 + return $removed;
880 + }
881 +
882 + /**
883 + * Remove the physical ABSPATH/llms.txt and its .htaccess charset block.
884 + *
885 + * @since 2.1.0
886 + *
887 + * @return bool True if the file is absent or was removed.
888 + */
889 + private function delete_static_file(): bool {
301 890 $llms_file = ABSPATH . 'llms.txt';
302 891 if (!file_exists($llms_file)) {
892 + $this->remove_htaccess_charset();
303 893 return true;
304 894 }
305 895 if (!$this->init_filesystem()) {
306 896 return false;
307 897 }
308 - return (bool) $this->filesystem->delete($llms_file);
898 +
899 + $deleted = (bool) $this->filesystem->delete($llms_file);
900 + if ($deleted) {
901 + // Leave no orphaned rule behind once the file is gone.
902 + $this->remove_htaccess_charset();
903 + }
904 +
905 + return $deleted;
309 906 }
310 907
311 908 /**
909 + * Pin the served charset of the physical llms.txt to UTF-8 via .htaccess.
910 + *
911 + * Scoped to the single file with <Files>, and wrapped in <IfModule> so a
912 + * server without mod_mime ignores it instead of returning a 500. Nginx does
913 + * not read .htaccess — there the PHP route in {@see serve_llms_txt()} is
914 + * what carries the charset, provided no physical file shadows it.
915 + *
916 + * @since 1.32.0
917 + *
918 + * @return bool True when the block is in place.
919 + */
920 + private function sync_htaccess_charset(): bool {
921 + // $is_apache also covers LiteSpeed, which reads .htaccess the same way.
922 + if (empty($GLOBALS['is_apache'])) {
923 + return false;
924 + }
925 +
926 + $htaccess = ABSPATH . '.htaccess';
927 +
928 + if (file_exists($htaccess)) {
929 + if (!$this->is_file_writable($htaccess)) {
930 + return false;
931 + }
932 + } elseif (!$this->is_directory_writable(ABSPATH)) {
933 + return false;
934 + }
935 +
936 + if (!function_exists('insert_with_markers')) {
937 + require_once ABSPATH . 'wp-admin/includes/misc.php';
938 + }
939 +
940 + return (bool) insert_with_markers($htaccess, self::HTACCESS_MARKER, [
941 + '<IfModule mod_mime.c>',
942 + '<Files "llms.txt">',
943 + "ForceType 'text/plain; charset=UTF-8'",
944 + '</Files>',
945 + '</IfModule>',
946 + ]);
947 + }
948 +
949 + /**
950 + * Remove ThinkRank's charset block from .htaccess.
951 + *
952 + * Strips the block outright rather than calling insert_with_markers() with
953 + * an empty insertion — that leaves the BEGIN/END markers behind as litter.
954 + *
955 + * @since 1.32.0
956 + *
957 + * @return void
958 + */
959 + private function remove_htaccess_charset(): void {
960 + $htaccess = ABSPATH . '.htaccess';
961 +
962 + if (!file_exists($htaccess) || !$this->is_file_writable($htaccess)) {
963 + return;
964 + }
965 +
966 + if (!$this->init_filesystem()) {
967 + return;
968 + }
969 +
970 + $contents = $this->filesystem->get_contents($htaccess);
971 + if (!is_string($contents) || false === strpos($contents, '# BEGIN ' . self::HTACCESS_MARKER)) {
972 + return;
973 + }
974 +
975 + $marker = preg_quote(self::HTACCESS_MARKER, '/');
976 + $cleaned = preg_replace(
977 + '/\R*# BEGIN ' . $marker . '.*?# END ' . $marker . '[ \t]*\R?/s',
978 + '',
979 + $contents
980 + );
981 +
982 + if (!is_string($cleaned)) {
983 + return;
984 + }
985 +
986 + // A file left holding nothing but our (now removed) block was ours to
987 + // begin with — a pre-existing .htaccess would still have content.
988 + if ('' === trim($cleaned)) {
989 + $this->filesystem->delete($htaccess);
990 + return;
991 + }
992 +
993 + // Keep the file newline-terminated after the block is cut out.
994 + $this->filesystem->put_contents($htaccess, rtrim($cleaned, "\r\n") . "\n", FS_CHMOD_FILE);
995 + }
996 +
997 + /**
998 + * Serve /llms.txt from PHP with an explicit UTF-8 charset.
999 + *
1000 + * Only reached when the request actually gets to WordPress — i.e. when no
1001 + * physical llms.txt shadows the route, or on a stack that routes every
1002 + * request through index.php. Prefers the published file's exact bytes and
1003 + * falls back to regenerating from the saved settings, so the response is
1004 + * the same document either way, just with headers PHP controls.
1005 + *
1006 + * Called by \ThinkRank\Frontend\SEO_Manager on template_redirect.
1007 + *
1008 + * @since 1.32.0
1009 + *
1010 + * @return void
1011 + */
1012 + public function serve_llms_txt(): void {
1013 + $settings = $this->get_settings('site');
1014 +
1015 + // Never resurrect the file for a site that turned the feature off.
1016 + if (empty($settings['enabled'])) {
1017 + return;
1018 + }
1019 +
1020 + $content = '';
1021 + $llms_file = ABSPATH . 'llms.txt';
1022 +
1023 + // In static mode a physical file is what the server would normally hand
1024 + // back, so prefer its exact bytes; in dynamic mode there is no file and
1025 + // the stored document is the authoritative copy.
1026 + if ('static' === $this->resolve_delivery_mode() && file_exists($llms_file)) {
1027 + $read_result = $this->safe_file_read($llms_file);
1028 + if ($read_result['success']) {
1029 + $content = $read_result['content'];
1030 + }
1031 + }
1032 +
1033 + if ('' === trim($content)) {
1034 + $content = $this->get_published_content();
1035 + }
1036 +
1037 + if ('' === trim($content)) {
1038 + $generated = $this->generate_llms_txt([]);
1039 + $content = (string) ($generated['content'] ?? '');
1040 + }
1041 +
1042 + // Nothing configured yet: leave the 404 alone rather than serving a stub.
1043 + if ('' === trim($content)) {
1044 + return;
1045 + }
1046 +
1047 + status_header(200);
1048 + header('Content-Type: text/plain; charset=utf-8');
1049 +
1050 + // Plain-text file body — already sanitized on save by
1051 + // sanitize_llms_content(); escaping it here would corrupt the markdown.
1052 + echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1053 + exit;
1054 + }
1055 +
1056 + /**
312 1057 * Write LLMs.txt content to filesystem
313 1058 *
314 1059 * @since 1.0.0
315 1060 *
@@ -332,10 +1077,39 @@
332 1077 $result['message'] = 'LLMs.txt is disabled. Enable it before publishing.';
333 1078 return $result;
334 1079 }
335 1080
1081 + $mode = $this->resolve_delivery_mode();
1082 + $result['delivery_mode'] = $mode;
1083 +
336 1084 $llms_file = ABSPATH . 'llms.txt';
1085 + $result['file_path'] = $llms_file;
337 1086
1087 + // Dynamic delivery: the document lives in the database and /llms.txt is
1088 + // answered by serve_llms_txt(), which sets `charset=utf-8` itself. A
1089 + // physical file would shadow that route on every stack, so any leftover
1090 + // from a previous static publish has to go.
1091 + if ('dynamic' === $mode) {
1092 + if (!$this->delete_static_file()) {
1093 + $result['message'] = 'A physical llms.txt is still present and could not be removed. It would be served instead of the dynamic route.';
1094 + return $result;
1095 + }
1096 +
1097 + $this->store_published_content($content);
1098 +
1099 + $result['success'] = true;
1100 + $result['message'] = 'LLMs.txt published. It is served by WordPress as UTF-8 text.';
1101 + $result['bytes_written'] = strlen($content);
1102 + $result['charset_pinned'] = true;
1103 + $result['permissions'] = [
1104 + 'directory_writable' => $this->is_directory_writable(ABSPATH),
1105 + 'file_exists' => false,
1106 + 'file_writable' => null,
1107 + ];
1108 +
1109 + return $result;
1110 + }
1111 +
338 1112 // Security: Validate file path to prevent path traversal attacks
339 1113 $real_llms_file = realpath(dirname($llms_file)) . DIRECTORY_SEPARATOR . basename($llms_file);
340 1114 $allowed_dir = realpath(ABSPATH);
341 1115
@@ -343,10 +1117,8 @@
343 1117 $result['message'] = 'Invalid file path detected for security reasons.';
344 1118 return $result;
345 1119 }
346 1120
347 - $result['file_path'] = $llms_file;
348 -
349 1121 // Check directory permissions
350 1122 $result['permissions'] = [
351 1123 'directory_writable' => $this->is_directory_writable(ABSPATH),
352 1124 'file_exists' => file_exists($llms_file),
@@ -364,31 +1136,57 @@
364 1136 $result['message'] = 'Existing llms.txt file is not writable. Please check file permissions.';
365 1137 return $result;
366 1138 }
367 1139
368 - // Write new content using WP_Filesystem
369 - if (!$this->init_filesystem()) {
370 - $result['message'] = 'Could not initialize WordPress filesystem.';
371 - return $result;
372 - }
1140 + // Write new content using WP_Filesystem
1141 + if (!$this->init_filesystem()) {
1142 + $result['message'] = 'Could not initialize WordPress filesystem.';
1143 + return $result;
1144 + }
373 1145
374 - $write_success = $this->filesystem->put_contents($llms_file, $content, FS_CHMOD_FILE);
1146 + if (!$this->filesystem->put_contents($llms_file, $content, FS_CHMOD_FILE)) {
1147 + $result['message'] = 'Failed to write llms.txt file.';
1148 + return $result;
1149 + }
375 1150
376 - if ($write_success) {
377 - $result['success'] = true;
378 - $result['message'] = 'LLMs.txt file written successfully.';
379 - $result['bytes_written'] = strlen($content);
1151 + $result['success'] = true;
1152 + $result['message'] = 'LLMs.txt file written successfully.';
1153 + $result['bytes_written'] = strlen($content);
380 1154
381 - // Invalidate file status cache since file has changed
382 - delete_transient('thinkrank_llms_file_status');
383 - } else {
384 - $result['message'] = 'Failed to write llms.txt file.';
385 - }
1155 + // Pin the served charset to UTF-8. Best effort: a site without a
1156 + // writable .htaccess (or not on Apache/LiteSpeed) still gets a
1157 + // correctly written file, so this must never fail the publish.
1158 + $result['charset_pinned'] = $this->sync_htaccess_charset();
386 1159
387 - return $result;
1160 + // Keep the stored copy in step with the file so a later switch to
1161 + // dynamic delivery serves the same document.
1162 + $this->store_published_content($content);
1163 +
1164 + // Detection said this server reads the .htaccess block. Check what the
1165 + // public URL really answers with before leaving the file in place — on a
1166 + // reverse-proxied stack the detection describes the wrong server (#493).
1167 + return $this->verify_static_delivery($result);
388 1168 }
389 1169
390 1170 /**
1171 + * Persist the published document and bust the caches that mirror it.
1172 + *
1173 + * @since 2.1.0
1174 + *
1175 + * @param string $content Published llms.txt content.
1176 + * @return void
1177 + */
1178 + private function store_published_content(string $content): void {
1179 + update_option(self::CONTENT_OPTION, $content, false);
1180 + update_option(self::PUBLISHED_AT_OPTION, time(), false);
1181 +
1182 + // Invalidate file status cache since the published document has changed
1183 + delete_transient('thinkrank_llms_file_status');
1184 +
1185 + $this->purge_llms_txt_caches();
1186 + }
1187 +
1188 + /**
391 1189 * Get LLMs.txt file status and information
392 1190 *
393 1191 * @since 1.0.0
394 1192 *
@@ -405,36 +1203,69 @@
405 1203 }
406 1204 }
407 1205
408 1206 $llms_file = ABSPATH . 'llms.txt';
1207 + $mode = $this->resolve_delivery_mode();
409 1208
1209 + // A site that published before this check existed — or whose server has
1210 + // changed under it — has never had its delivery confirmed. Do it here so
1211 + // an already-broken install heals without waiting for a republish; the
1212 + // recorded verdict and the status cache keep it to a couple of requests
1213 + // a day at most.
1214 + if ('static' === $mode && file_exists($llms_file) && $this->delivery_probe_is_due()) {
1215 + $this->verify_static_delivery(['message' => '']);
1216 + $mode = $this->resolve_delivery_mode();
1217 + }
1218 +
1219 + $stored = $this->get_published_content();
1220 +
410 1221 $status = [
411 1222 'file_exists' => file_exists($llms_file),
412 - 'file_path' => $llms_file,
1223 + // Whether /llms.txt is actually being served, either mode. Prefer
1224 + // this over file_exists, which is only meaningful in static mode.
1225 + 'published' => $this->is_published(),
1226 + 'delivery_mode' => $mode,
1227 + 'file_path' => 'dynamic' === $mode ? '' : $llms_file,
413 1228 'file_url' => home_url('/llms.txt'),
414 1229 'writable' => $this->is_directory_writable(dirname($llms_file)),
1230 + // Non-empty only when the site is on static delivery that the server
1231 + // is known to answer without a charset — i.e. an explicit `static`
1232 + // the plugin will not overrule, which is the user's to fix.
1233 + 'delivery_warning' => 'static' === $mode && $this->static_delivery_drops_charset()
1234 + ? self::STATIC_CHARSET_WARNING
1235 + : '',
415 1236 'last_modified' => null,
416 1237 'file_size' => null,
417 1238 'content_preview' => ''
418 1239 ];
419 1240
1241 + $content = null;
1242 +
420 1243 if ($status['file_exists']) {
421 1244 $status['last_modified'] = filemtime($llms_file);
422 1245 $status['file_size'] = filesize($llms_file);
423 1246
424 - // Get content preview (first 200 characters) with size safety
425 1247 $read_result = $this->safe_file_read($llms_file);
426 1248 if ($read_result['success']) {
427 - $status['content_preview'] = substr($read_result['content'], 0, 200);
428 - if (strlen($read_result['content']) > 200) {
429 - $status['content_preview'] .= '...';
430 - }
1249 + $content = $read_result['content'];
431 1250 } else {
432 1251 $status['content_preview'] = 'Error: ' . $read_result['error'];
433 1252 $status['read_error'] = $read_result['error'];
434 1253 }
1254 + } elseif ('' !== trim($stored)) {
1255 + $published_at = (int) get_option(self::PUBLISHED_AT_OPTION, 0);
1256 + $status['last_modified'] = $published_at > 0 ? $published_at : null;
1257 + $status['file_size'] = strlen($stored);
1258 + $content = $stored;
435 1259 }
436 1260
1261 + if (null !== $content) {
1262 + // Get content preview (first 200 characters) with size safety
1263 + // substr()/strlen() count BYTES, so this cut a multibyte character
1264 + // in half and shipped an invalid UTF-8 sequence in the preview (#687).
1265 + $status['content_preview'] = \ThinkRank\Core\Seo_Text::trim_to_length($content, 200);
1266 + }
1267 +
437 1268 // Cache the result for 5 minutes to improve performance
438 1269 set_transient($cache_key, $status, 5 * MINUTE_IN_SECONDS);
439 1270
440 1271 return $status;
@@ -579,13 +1410,15 @@
579 1410 * Override parent sanitize_settings to preserve line breaks in link fields
580 1411 *
581 1412 * @since 1.0.0
582 1413 *
583 - * @param array $settings Settings to sanitize
1414 + * @param array $settings Settings to sanitize
1415 + * @param string $context_type Context the save is for.
584 1416 * @return array Sanitized settings
585 1417 */
586 - protected function sanitize_settings(array $settings): array {
1418 + protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
587 1419 $sanitized = [];
1420 + $known = $this->get_known_setting_keys($context_type);
588 1421
589 1422 // Fields that should preserve line breaks
590 1423 $preserve_linebreaks = [
591 1424 'documentation_links',
@@ -602,8 +1435,27 @@
602 1435
603 1436 foreach ($settings as $key => $value) {
604 1437 $sanitized_key = sanitize_key($key);
605 1438
1439 + // Never store the REST envelope back as settings (see
1440 + // Abstract_Seo_Manager::RESERVED_ENVELOPE_KEYS).
1441 + if (in_array($sanitized_key, self::RESERVED_ENVELOPE_KEYS, true)) {
1442 + continue;
1443 + }
1444 +
1445 + // And nothing this manager does not declare (#452).
1446 + if (!$this->is_known_setting_key($sanitized_key, $known)) {
1447 + continue;
1448 + }
1449 +
1450 + // Constrain the delivery mode to the known enum so an unexpected
1451 + // value falls back to auto-detection rather than being stored.
1452 + if ('delivery_mode' === $sanitized_key) {
1453 + $mode = is_string($value) ? sanitize_key($value) : '';
1454 + $sanitized[$sanitized_key] = in_array($mode, self::DELIVERY_MODES, true) ? $mode : 'auto';
1455 + continue;
1456 + }
1457 +
606 1458 if (is_string($value)) {
607 1459 if (in_array($key, $preserve_linebreaks, true)) {
608 1460 // Use our custom sanitization that preserves line breaks
609 1461 if (in_array($key, ['documentation_links', 'technical_links', 'optional_links', 'custom_sections'], true)) {
@@ -634,15 +1486,15 @@
634 1486 * Recursively sanitize array values (preserving line breaks where needed)
635 1487 *
636 1488 * @since 1.0.0
637 1489 *
638 - * @param array $array Array to sanitize
1490 + * @param array $input Array to sanitize
639 1491 * @return array Sanitized array
640 1492 */
641 - private function sanitize_array_recursive(array $array): array {
1493 + private function sanitize_array_recursive(array $input): array {
642 1494 $sanitized = [];
643 1495
644 - foreach ($array as $key => $value) {
1496 + foreach ($input as $key => $value) {
645 1497 $sanitized_key = sanitize_key($key);
646 1498
647 1499 if (is_string($value)) {
648 1500 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
@@ -747,11 +1599,20 @@
747 1599 }
748 1600
749 1601 // Check key features quality
750 1602 if (!empty($user_input['key_features'])) {
751 - $features = explode("\n", $user_input['key_features']);
752 - $feature_count = count(array_filter($features, 'trim'));
1603 + // The same splitter the generated file uses, so the count reported
1604 + // here and the bullets written out can never disagree (#765).
1605 + $feature_count = count(self::split_key_features((string) $user_input['key_features']));
753 1606
1607 + // A single line containing commas is ambiguous: it is either a
1608 + // legacy comma-separated list or one feature with a comma in it.
1609 + // Rather than guess and risk publishing "and Etsy" as a feature,
1610 + // say so and let the author decide.
1611 + if (self::looks_like_comma_list((string) $user_input['key_features'])) {
1612 + $validation['suggestions'][] = 'Put each key feature on its own line. Commas are treated as part of a feature, not as separators.';
1613 + }
1614 +
754 1615 if ($feature_count < 3) {
755 1616 $validation['warnings'][] = 'Consider adding more key features (3-8 recommended) for comprehensive AI understanding';
756 1617 $validation['score'] -= 10;
757 1618 } elseif ($feature_count > 10) {
@@ -889,9 +1750,22 @@
889 1750 'content' => $this->sanitize_llms_content($user_input['custom_sections'])
890 1751 ];
891 1752 }
892 1753
893 - return $sections;
1754 + /**
1755 + * Filter the llms.txt content sections before assembly.
1756 + *
1757 + * Each entry is ['title' => string, 'content' => string]; an empty
1758 + * title emits the content without an H2. Pro appends a "Markdown for
1759 + * AI" section here when that feature is enabled. Section content is
1760 + * the callback's responsibility to sanitize.
1761 + *
1762 + * @since 1.32.0
1763 + *
1764 + * @param array $sections Sections keyed by slug.
1765 + * @param array $user_input Validated user input for the generator.
1766 + */
1767 + return apply_filters('thinkrank_llms_txt_sections', $sections, $user_input);
894 1768 }
895 1769
896 1770 /**
897 1771 * Sanitize LLMs.txt content while preserving line breaks
@@ -1054,12 +1928,16 @@
1054 1928 $validation['score'] -= 5;
1055 1929 }
1056 1930 }
1057 1931
1058 - // Check file permissions if enabled
1059 - if (!empty($settings['enabled'])) {
1932 + // Check file permissions if enabled. Only the static delivery mode needs
1933 + // a writable root — dynamic delivery keeps the document in the database.
1934 + $mode = $this->resolve_delivery_mode(
1935 + isset($settings['delivery_mode']) ? (string) $settings['delivery_mode'] : null
1936 + );
1937 + if (!empty($settings['enabled']) && 'static' === $mode) {
1060 1938 if (!$this->is_directory_writable(ABSPATH)) {
1061 - $validation['warnings'][] = 'WordPress root directory is not writable, llms.txt cannot be automatically managed';
1939 + $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.';
1062 1940 $validation['score'] -= 10;
1063 1941 }
1064 1942 }
1065 1943
@@ -1094,9 +1972,10 @@
1094 1972
1095 1973 // Get file status
1096 1974 $output['file_status'] = $this->get_llms_txt_status();
1097 1975
1098 - // If file exists, get current content safely
1976 + // If a file is published, get its current content safely; otherwise fall
1977 + // back to the stored document that dynamic delivery serves.
1099 1978 if ($output['file_status']['file_exists']) {
1100 1979 $llms_file = ABSPATH . 'llms.txt';
1101 1980 $read_result = $this->safe_file_read($llms_file);
1102 1981 if ($read_result['success']) {
@@ -1104,8 +1983,10 @@
1104 1983 } else {
1105 1984 $output['llms_txt_content'] = '';
1106 1985 $output['file_read_error'] = $read_result['error'];
1107 1986 }
1987 + } else {
1988 + $output['llms_txt_content'] = $this->get_published_content();
1108 1989 }
1109 1990
1110 1991 // Add metadata
1111 1992 $output['metadata'] = [
@@ -1137,8 +2018,9 @@
1137 2018 'development_approach' => '',
1138 2019 'setup_instructions' => '',
1139 2020 'ai_context_custom' => '',
1140 2021 'auto_generate' => false,
2022 + 'delivery_mode' => 'auto',
1141 2023 'last_generated' => null,
1142 2024 // Structured sections for llms.txt spec compliance
1143 2025 'documentation_links' => '',
1144 2026 'technical_links' => '',
@@ -1191,9 +2073,9 @@
1191 2073 ],
1192 2074 'key_features' => [
1193 2075 'type' => 'string',
1194 2076 'title' => 'Key Features',
1195 - 'description' => 'Main features and functionality of your website',
2077 + 'description' => 'Main features and functionality of your website. One feature per line: a comma is treated as part of a feature, not as a separator.',
1196 2078 'default' => '',
1197 2079 'maxLength' => 500
1198 2080 ],
1199 2081 'target_audience' => [
@@ -1243,8 +2125,15 @@
1243 2125 'title' => 'Auto-generate',
1244 2126 'description' => 'Automatically regenerate llms.txt when settings change',
1245 2127 'default' => false
1246 2128 ],
2129 + 'delivery_mode' => [
2130 + 'type' => 'string',
2131 + 'title' => 'Delivery Method',
2132 + '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.',
2133 + 'enum' => self::DELIVERY_MODES,
2134 + 'default' => 'auto'
2135 + ],
1247 2136 'last_generated' => [
1248 2137 'type' => 'string',
1249 2138 'title' => 'Last Generated',
1250 2139 'description' => 'Timestamp of last generation',
@@ -1294,8 +2183,243 @@
1294 2183 return "> " . $description . "\n\n";
1295 2184 }
1296 2185
1297 2186 /**
2187 + * Split the Key Features field into individual features.
2188 + *
2189 + * One feature per line. Commas used to be delimiters too, which meant a
2190 + * single feature that happened to contain one — "Collect reviews from
2191 + * Trustpilot, Google, and Etsy" — was published as three bullets, one of
2192 + * them reading "and Etsy" (#765). Validation counted by newline only, so
2193 + * it reported one feature while the file showed three and never flagged
2194 + * the split.
2195 + *
2196 + * Commas are not a fallback delimiter even when the value has no newlines.
2197 + * A comma inside a feature is ordinary prose and far more likely than a
2198 + * deliberate comma-separated list, and guessing wrong publishes mangled
2199 + * text to the file AI crawlers read. A single-line value with commas is
2200 + * kept whole and validate_content_quality() suggests splitting it, which
2201 + * tells the user what to do instead of quietly deciding for them.
2202 + *
2203 + * The one splitter both generation and validation use, so the file and the
2204 + * feature count can no longer disagree.
2205 + *
2206 + * @since 2.10.0
2207 + *
2208 + * @param string $key_features Raw field value.
2209 + * @return string[] Trimmed features, empties removed.
2210 + */
2211 + public static function split_key_features(string $key_features): array {
2212 + $features = preg_split('/[\r\n]+/', $key_features);
2213 +
2214 + if (!is_array($features)) {
2215 + return [];
2216 + }
2217 +
2218 + $features = array_map('trim', $features);
2219 +
2220 + return array_values(array_filter($features, static fn(string $f): bool => '' !== $f));
2221 + }
2222 +
2223 + /**
2224 + * Whether a value looks like the old comma-separated list.
2225 + *
2226 + * One line, and a comma in it. That is either a legacy list saved before
2227 + * newlines became the delimiter, or a single feature containing a comma —
2228 + * indistinguishable from the outside, which is exactly why this prompts
2229 + * rather than splits.
2230 + *
2231 + * @since 2.10.0
2232 + *
2233 + * @param string $key_features Raw field value.
2234 + * @return bool
2235 + */
2236 + public static function looks_like_comma_list(string $key_features): bool {
2237 + $trimmed = trim($key_features);
2238 +
2239 + if ('' === $trimmed || false !== strpbrk($trimmed, "\r\n")) {
2240 + return false;
2241 + }
2242 +
2243 + return false !== strpos($trimmed, ',');
2244 + }
2245 +
2246 + /**
2247 + * Turn a single-line comma list into one feature per line.
2248 + *
2249 + * Returns null when the value is not something to convert: it already has
2250 + * line breaks, has no comma, or reads as one feature containing a series.
2251 + *
2252 + * Only for text that was written under a comma rule: values saved before
2253 + * newlines became the only delimiter (see
2254 + * {@see self::maybe_migrate_legacy_key_features()}), and AI replies that
2255 + * ignored the one-per-line instruction. Typed input never goes through
2256 + * this; split_key_features() still keeps a comma inside a feature (#765).
2257 + *
2258 + * A series is the one shape the old rule demonstrably mangled: "Collect
2259 + * reviews from Trustpilot, Google, and Etsy" became three bullets, the
2260 + * last reading "and Etsy". So a value is left whole when a segment after
2261 + * the first opens with a conjunction (the Oxford form), or when the final
2262 + * segment carries one ("..., Google and Etsy", the form the field's own
2263 + * placeholder uses). A plain list that happens to end "X and Y" is left
2264 + * whole too; validation still suggests splitting it, and one intact bullet
2265 + * is the safer wrong answer than a sentence cut into fragments.
2266 + *
2267 + * A comma between digits ("1,000 templates") is a thousands separator,
2268 + * not a delimiter.
2269 + *
2270 + * @since 2.10.0
2271 + *
2272 + * @param string $key_features Raw value.
2273 + * @return string|null Newline-separated features, or null to leave as is.
2274 + */
2275 + public static function comma_list_to_lines(string $key_features): ?string {
2276 + if (!self::looks_like_comma_list($key_features)) {
2277 + return null;
2278 + }
2279 +
2280 + $segments = preg_split('/\s*,(?!\d)\s*/', trim($key_features));
2281 +
2282 + if (!is_array($segments)) {
2283 + return null;
2284 + }
2285 +
2286 + $segments = array_values(array_filter(
2287 + array_map('trim', $segments),
2288 + static fn(string $s): bool => '' !== $s
2289 + ));
2290 +
2291 + if (count($segments) < 2) {
2292 + return null;
2293 + }
2294 +
2295 + foreach (array_slice($segments, 1) as $segment) {
2296 + if (preg_match('/^(?:(?:and|or|nor|plus)\b|&)/i', $segment)) {
2297 + return null;
2298 + }
2299 + }
2300 +
2301 + if (preg_match('/\s(?:and|or|&)\s/i', (string) end($segments))) {
2302 + return null;
2303 + }
2304 +
2305 + return implode("\n", $segments);
2306 + }
2307 +
2308 + /**
2309 + * Coerce an AI reply for Key Features into the one-per-line field value.
2310 + *
2311 + * The prompt asks for one feature per line, but models still answer with a
2312 + * JSON array or a comma-separated line. An array went through
2313 + * sanitize_textarea_field() as '' and the field silently kept its old
2314 + * value; a comma line was published as a single bullet now that commas are
2315 + * not delimiters. Both are normalised to lines here, before sanitising.
2316 + *
2317 + * @since 2.10.0
2318 + *
2319 + * @param mixed $value Decoded `key_features` from the reply.
2320 + * @return string Sanitised, newline-separated features.
2321 + */
2322 + public static function normalize_ai_key_features($value): string {
2323 + if (is_array($value)) {
2324 + $features = [];
2325 + foreach ($value as $item) {
2326 + if (is_scalar($item)) {
2327 + $item = trim((string) $item);
2328 + if ('' !== $item) {
2329 + $features[] = $item;
2330 + }
2331 + }
2332 + }
2333 + $value = implode("\n", $features);
2334 + } elseif (!is_scalar($value)) {
2335 + return '';
2336 + }
2337 +
2338 + $value = (string) $value;
2339 + $lines = self::comma_list_to_lines($value);
2340 +
2341 + return sanitize_textarea_field(null === $lines ? $value : $lines);
2342 + }
2343 +
2344 + /**
2345 + * Convert a Key Features value saved under the old comma rule, once.
2346 + *
2347 + * Up to 2.9.0 a comma separated features, so a site that saved
2348 + * "SEO audits, Schema markup, XML sitemaps" published three bullets. After
2349 + * #765 made newlines the only delimiter the same stored value regenerates
2350 + * as one bullet holding the whole line, a silent change to the file AI
2351 + * crawlers read. Rewriting the stored value as lines keeps that site's
2352 + * output what it was, in the form the field now documents.
2353 + *
2354 + * A migration rather than a runtime fallback on purpose: a fallback would
2355 + * keep treating commas as delimiters for every single-line value forever,
2356 + * which is the #765 bug. Here only values that were saved while commas
2357 + * really were delimiters are touched, exactly once; anything typed after
2358 + * this has run follows the new rule. comma_list_to_lines() still leaves a
2359 + * series such as the #765 value whole.
2360 + *
2361 + * Version-gated like Settings::retire_seeded_ai_provider(), and the marker
2362 + * is written first so a site that fails the write does not retry on every
2363 + * admin request. The activator records it on a fresh install.
2364 + *
2365 + * @since 2.10.0
2366 + *
2367 + * @return void
2368 + */
2369 + public static function maybe_migrate_legacy_key_features(): void {
2370 + if (get_option(self::KEY_FEATURES_MIGRATION_OPTION) === self::KEY_FEATURES_MIGRATION_VERSION) {
2371 + return;
2372 + }
2373 +
2374 + update_option(self::KEY_FEATURES_MIGRATION_OPTION, self::KEY_FEATURES_MIGRATION_VERSION, true);
2375 +
2376 + (new static())->migrate_stored_key_features();
2377 + }
2378 +
2379 + /**
2380 + * Rewrite the stored Key Features as lines when it is a legacy comma list.
2381 + *
2382 + * @since 2.10.0
2383 + *
2384 + * @return bool True when a value was converted and saved.
2385 + */
2386 + public function migrate_stored_key_features(): bool {
2387 + $stored = $this->get_stored_settings('site');
2388 +
2389 + if (!isset($stored['key_features']) || !is_string($stored['key_features'])) {
2390 + return false;
2391 + }
2392 +
2393 + $lines = self::comma_list_to_lines($stored['key_features']);
2394 +
2395 + if (null === $lines) {
2396 + return false;
2397 + }
2398 +
2399 + // validate_settings() rejects a payload without `enabled`, so carry the
2400 + // stored flag along. Written through the base save, not this class's,
2401 + // which would also reconcile the delivery mode: a stored-value rewrite
2402 + // must not republish anything.
2403 + return $this->write_migrated_key_features([
2404 + 'enabled' => $stored['enabled'] ?? true,
2405 + 'key_features' => $lines,
2406 + ]);
2407 + }
2408 +
2409 + /**
2410 + * Persist the converted value. Separate so tests can observe the write.
2411 + *
2412 + * @since 2.10.0
2413 + *
2414 + * @param array $settings `enabled` and `key_features`.
2415 + * @return bool
2416 + */
2417 + protected function write_migrated_key_features(array $settings): bool {
2418 + return parent::save_settings('site', null, $settings);
2419 + }
2420 +
2421 + /**
1298 2422 * Build additional details section
1299 2423 *
1300 2424 * @since 1.0.0
1301 2425 *
@@ -1313,18 +2437,10 @@
1313 2437 }
1314 2438
1315 2439 if (!empty($key_features)) {
1316 2440 $content .= "**Key Features:**\n";
1317 - // The UI field is a multi-line textarea and validation counts by
1318 - // newline, so split on newlines (and still tolerate commas) rather
1319 - // than commas only — otherwise newline-separated input collapses
1320 - // into one broken bullet.
1321 - $features = preg_split('/[\r\n,]+/', $key_features);
1322 - foreach ($features as $feature) {
1323 - $feature = trim($feature);
1324 - if (!empty($feature)) {
1325 - $content .= "- " . $feature . "\n";
1326 - }
2441 + foreach (self::split_key_features($key_features) as $feature) {
2442 + $content .= "- " . $feature . "\n";
1327 2443 }
1328 2444 $content .= "\n";
1329 2445 }
1330 2446
@@ -1358,9 +2474,9 @@
1358 2474 $content .= "- [Technical Stack]({$website_url}): Built with {$stack}\n";
1359 2475 }
1360 2476
1361 2477 if (!empty($user_input['development_approach'])) {
1362 - $approach_summary = wp_trim_words($user_input['development_approach'], 10);
2478 + $approach_summary = \ThinkRank\Core\Seo_Text::trim_words($user_input['development_approach'], 10);
1363 2479 $content .= "- [Development Guidelines]({$website_url}): {$approach_summary}\n";
1364 2480 }
1365 2481
1366 2482 // Add robots.txt reference