PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 All 52 releases
← All changes | includes/seo/class-llms-txt-manager.php +614 -27 2.0.1 → 2.10.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 }
@@ -60,8 +65,18 @@
60 65 */
61 66 private string $last_delivery_warning = '';
62 67
63 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 + /**
64 79 * LLMs.txt content sections configuration
65 80 *
66 81 * @since 1.0.0
67 82 * @var array
@@ -75,9 +90,9 @@
75 90 ],
76 91 'key_features' => [
77 92 'title' => 'Key Features',
78 93 'required' => true,
79 - '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.',
80 95 'max_length' => 300
81 96 ],
82 97 'architecture' => [
83 98 'title' => 'Architecture & Components',
@@ -148,8 +163,40 @@
148 163 */
149 164 private const PUBLISHED_AT_OPTION = 'thinkrank_llms_txt_published_at';
150 165
151 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 + /**
152 199 * Delivery modes accepted by the `delivery_mode` setting.
153 200 *
154 201 * @since 2.1.0
155 202 * @var string[]
@@ -156,8 +203,20 @@
156 203 */
157 204 private const DELIVERY_MODES = ['auto', 'static', 'dynamic'];
158 205
159 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 + /**
160 219 * Business type templates for content generation
161 220 *
162 221 * @since 1.0.0
163 222 * @var array
@@ -249,8 +308,13 @@
249 308 'validation' => [],
250 309 'file_info' => []
251 310 ];
252 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 +
253 317 // Get current settings
254 318 $settings = $this->get_settings('site');
255 319
256 320 // Merge saved settings underneath the provided input so that empty or
@@ -317,8 +381,9 @@
317 381 */
318 382 public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
319 383 $this->last_unpublish_failed = false;
320 384 $this->last_delivery_warning = '';
385 + $this->last_delivery_switch_failed = false;
321 386 $previous_mode = $this->resolve_delivery_mode();
322 387
323 388 $result = parent::save_settings($context_type, $context_id, $settings);
324 389
@@ -412,9 +477,18 @@
412 477 if (empty($write['success'])) {
413 478 $this->last_delivery_warning = isset($write['message']) && '' !== (string) $write['message']
414 479 ? (string) $write['message']
415 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;
416 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 + }
417 491 }
418 492
419 493 /**
420 494 * Message from the last save whose delivery-mode switch could not be
@@ -428,8 +502,20 @@
428 502 return $this->last_delivery_warning;
429 503 }
430 504
431 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 + /**
432 518 * Whether the last save_settings() disabled the feature but could not remove
433 519 * the published llms.txt file (which may therefore still be served).
434 520 *
435 521 * @return bool
@@ -447,11 +533,16 @@
447 533 * Apache/LiteSpeed, the stacks that read the .htaccess charset block — on
448 534 * nginx a physical file is served with a bare `Content-Type: text/plain`
449 535 * that neither fix path can reach, which renders UTF-8 as mojibake (#419).
450 536 *
451 - * Layered hosts (e.g. an nginx front end reporting as something else) can
452 - * defeat the detection, which is why the setting also accepts an explicit
453 - * override rather than relying on $is_apache alone.
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.
454 545 *
455 546 * @since 2.1.0
456 547 *
457 548 * @param string|null $mode Optional. Raw setting value; read from the saved
@@ -467,13 +558,214 @@
467 558 if ('static' === $mode || 'dynamic' === $mode) {
468 559 return $mode;
469 560 }
470 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 +
471 572 // $is_apache also covers LiteSpeed, which reads .htaccess the same way.
472 - return !empty($GLOBALS['is_apache']) ? 'static' : 'dynamic';
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';
473 581 }
474 582
475 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 + /**
476 768 * The published llms.txt document, or an empty string when unpublished.
477 769 *
478 770 * @since 2.1.0
479 771 *
@@ -485,8 +777,28 @@
485 777 return is_string($content) ? $content : '';
486 778 }
487 779
488 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 + /**
489 801 * Ask the common page/CDN cache layers to drop their copy of /llms.txt.
490 802 *
491 803 * A cached response outlives a republish, so without this a mode switch or
492 804 * a content change keeps serving the old document (and, on the static path,
@@ -535,13 +847,33 @@
535 847 *
536 848 * @return bool True once nothing is left to serve.
537 849 */
538 850 public function delete_llms_txt_file(): bool {
539 - delete_transient('thinkrank_llms_file_status');
540 -
541 851 delete_option(self::CONTENT_OPTION);
542 852 delete_option(self::PUBLISHED_AT_OPTION);
543 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 {
874 + delete_transient('thinkrank_llms_file_status');
875 +
544 876 $removed = $this->delete_static_file();
545 877 $this->purge_llms_txt_caches();
546 878
547 879 return $removed;
@@ -828,9 +1160,12 @@
828 1160 // Keep the stored copy in step with the file so a later switch to
829 1161 // dynamic delivery serves the same document.
830 1162 $this->store_published_content($content);
831 1163
832 - return $result;
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);
833 1168 }
834 1169
835 1170 /**
836 1171 * Persist the published document and bust the caches that mirror it.
@@ -869,8 +1204,19 @@
869 1204 }
870 1205
871 1206 $llms_file = ABSPATH . 'llms.txt';
872 1207 $mode = $this->resolve_delivery_mode();
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 +
873 1219 $stored = $this->get_published_content();
874 1220
875 1221 $status = [
876 1222 'file_exists' => file_exists($llms_file),
@@ -875,13 +1221,19 @@
875 1221 $status = [
876 1222 'file_exists' => file_exists($llms_file),
877 1223 // Whether /llms.txt is actually being served, either mode. Prefer
878 1224 // this over file_exists, which is only meaningful in static mode.
879 - 'published' => file_exists($llms_file) || '' !== trim($stored),
1225 + 'published' => $this->is_published(),
880 1226 'delivery_mode' => $mode,
881 1227 'file_path' => 'dynamic' === $mode ? '' : $llms_file,
882 1228 'file_url' => home_url('/llms.txt'),
883 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 + : '',
884 1236 'last_modified' => null,
885 1237 'file_size' => null,
886 1238 'content_preview' => ''
887 1239 ];
@@ -907,12 +1259,11 @@
907 1259 }
908 1260
909 1261 if (null !== $content) {
910 1262 // Get content preview (first 200 characters) with size safety
911 - $status['content_preview'] = substr($content, 0, 200);
912 - if (strlen($content) > 200) {
913 - $status['content_preview'] .= '...';
914 - }
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);
915 1266 }
916 1267
917 1268 // Cache the result for 5 minutes to improve performance
918 1269 set_transient($cache_key, $status, 5 * MINUTE_IN_SECONDS);
@@ -1248,11 +1599,20 @@
1248 1599 }
1249 1600
1250 1601 // Check key features quality
1251 1602 if (!empty($user_input['key_features'])) {
1252 - $features = explode("\n", $user_input['key_features']);
1253 - $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']));
1254 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 +
1255 1615 if ($feature_count < 3) {
1256 1616 $validation['warnings'][] = 'Consider adding more key features (3-8 recommended) for comprehensive AI understanding';
1257 1617 $validation['score'] -= 10;
1258 1618 } elseif ($feature_count > 10) {
@@ -1713,9 +2073,9 @@
1713 2073 ],
1714 2074 'key_features' => [
1715 2075 'type' => 'string',
1716 2076 'title' => 'Key Features',
1717 - '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.',
1718 2078 'default' => '',
1719 2079 'maxLength' => 500
1720 2080 ],
1721 2081 'target_audience' => [
@@ -1823,8 +2183,243 @@
1823 2183 return "> " . $description . "\n\n";
1824 2184 }
1825 2185
1826 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 + /**
1827 2422 * Build additional details section
1828 2423 *
1829 2424 * @since 1.0.0
1830 2425 *
@@ -1842,18 +2437,10 @@
1842 2437 }
1843 2438
1844 2439 if (!empty($key_features)) {
1845 2440 $content .= "**Key Features:**\n";
1846 - // The UI field is a multi-line textarea and validation counts by
1847 - // newline, so split on newlines (and still tolerate commas) rather
1848 - // than commas only — otherwise newline-separated input collapses
1849 - // into one broken bullet.
1850 - $features = preg_split('/[\r\n,]+/', $key_features);
1851 - foreach ($features as $feature) {
1852 - $feature = trim($feature);
1853 - if (!empty($feature)) {
1854 - $content .= "- " . $feature . "\n";
1855 - }
2441 + foreach (self::split_key_features($key_features) as $feature) {
2442 + $content .= "- " . $feature . "\n";
1856 2443 }
1857 2444 $content .= "\n";
1858 2445 }
1859 2446
@@ -1887,9 +2474,9 @@
1887 2474 $content .= "- [Technical Stack]({$website_url}): Built with {$stack}\n";
1888 2475 }
1889 2476
1890 2477 if (!empty($user_input['development_approach'])) {
1891 - $approach_summary = wp_trim_words($user_input['development_approach'], 10);
2478 + $approach_summary = \ThinkRank\Core\Seo_Text::trim_words($user_input['development_approach'], 10);
1892 2479 $content .= "- [Development Guidelines]({$website_url}): {$approach_summary}\n";
1893 2480 }
1894 2481
1895 2482 // Add robots.txt reference