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.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 All 55 releases
← All changes | includes/seo/class-llms-txt-manager.php +1246 -310 1.0.0 → 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 }
@@ -42,8 +47,36 @@
42 47 */
43 48 private $filesystem = null;
44 49
45 50 /**
51 + * Whether the most recent save_settings() persisted a disable but failed to
52 + * remove the published llms.txt file (so it may still be served). Callers
53 + * check this via {@see unpublish_failed()} to surface a partial failure.
54 + *
55 + * @var bool
56 + */
57 + private bool $last_unpublish_failed = false;
58 +
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 + /**
46 79 * LLMs.txt content sections configuration
47 80 *
48 81 * @since 1.0.0
49 82 * @var array
@@ -57,9 +90,9 @@
57 90 ],
58 91 'key_features' => [
59 92 'title' => 'Key Features',
60 93 'required' => true,
61 - '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.',
62 95 'max_length' => 300
63 96 ],
64 97 'architecture' => [
65 98 'title' => 'Architecture & Components',
@@ -95,8 +128,95 @@
95 128 */
96 129 private const MAX_FILE_SIZE = 1048576; // 1MB in bytes
97 130
98 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 + /**
99 219 * Business type templates for content generation
100 220 *
101 221 * @since 1.0.0
102 222 * @var array
@@ -179,34 +299,8 @@
179 299 }
180 300
181 301 return $this->filesystem->is_writable($file);
182 302 }
183 -
184 - /**
185 - * Set file permissions using WP_Filesystem
186 - *
187 - * @since 1.0.0
188 - * @param string $file File path
189 - * @param int $mode File permissions mode
190 - * @return bool True if successful, false otherwise
191 - */
192 - private function set_file_permissions(string $file, int $mode): bool {
193 - if (!$this->init_filesystem()) {
194 - return false;
195 - }
196 -
197 - return $this->filesystem->chmod($file, $mode);
198 - }
199 -
200 - /**
201 - * Generate LLMs.txt content based on user input
202 - *
203 - * @since 1.0.0
204 - *
205 - * @param array $user_input User-provided website information
206 - * @param array $options Generation options
207 - * @return array Generated LLMs.txt data with content and metadata
208 - */
209 303 public function generate_llms_txt(array $user_input, array $options = []): array {
210 304 $llms_data = [
211 305 'content' => '',
212 306 'sections' => [],
@@ -214,11 +308,33 @@
214 308 'validation' => [],
215 309 'file_info' => []
216 310 ];
217 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 +
218 317 // Get current settings
219 318 $settings = $this->get_settings('site');
220 -
319 +
320 + // Merge saved settings underneath the provided input so that empty or
321 + // partial $user_input falls back to the persisted configuration.
322 + // Explicitly provided (non-empty) values win; blank ones are filled from
323 + // saved settings. This lets callers generate from saved settings by
324 + // passing an empty payload (e.g. the generate-llms-txt MCP ability),
325 + // matching the documented behavior.
326 + $provided = array_filter(
327 + $user_input,
328 + static function ($value) {
329 + if (is_string($value)) {
330 + return '' !== trim($value);
331 + }
332 + return null !== $value && [] !== $value;
333 + }
334 + );
335 + $user_input = array_merge($settings, $provided);
336 +
221 337 // Check file status
222 338 $llms_file = ABSPATH . 'llms.txt';
223 339 $llms_data['file_info'] = [
224 340 'file_exists' => file_exists($llms_file),
@@ -254,8 +370,691 @@
254 370 return $llms_data;
255 371 }
256 372
257 373 /**
374 + * Persist settings, unpublishing the physical file when the feature is
375 + * disabled so a disable actually stops serving /llms.txt.
376 + *
377 + * @param string $context_type Context type.
378 + * @param int|null $context_id Context ID.
379 + * @param array $settings Settings to save.
380 + * @return bool
381 + */
382 + public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
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 +
388 + $result = parent::save_settings($context_type, $context_id, $settings);
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 +
394 + // When a save explicitly disables the feature, delete the published file.
395 + if ($result && array_key_exists('enabled', $settings) && empty($settings['enabled'])) {
396 + if (!$this->delete_llms_txt_file()) {
397 + // The settings were persisted, but the physical file could not be
398 + // removed, so /llms.txt may still be served. Record it so callers
399 + // report a partial failure instead of an unqualified success.
400 + $this->last_unpublish_failed = true;
401 + }
402 +
403 + return $result;
404 + }
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 +
417 + return $result;
418 + }
419 +
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 + /**
518 + * Whether the last save_settings() disabled the feature but could not remove
519 + * the published llms.txt file (which may therefore still be served).
520 + *
521 + * @return bool
522 + */
523 + public function unpublish_failed(): bool {
524 + return $this->last_unpublish_failed;
525 + }
526 +
527 + /**
528 + * Resolve the effective delivery mode for /llms.txt.
529 + *
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'.
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 + */
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 {
874 + delete_transient('thinkrank_llms_file_status');
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 {
890 + $llms_file = ABSPATH . 'llms.txt';
891 + if (!file_exists($llms_file)) {
892 + $this->remove_htaccess_charset();
893 + return true;
894 + }
895 + if (!$this->init_filesystem()) {
896 + return false;
897 + }
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;
906 + }
907 +
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 + /**
258 1057 * Write LLMs.txt content to filesystem
259 1058 *
260 1059 * @since 1.0.0
261 1060 *
@@ -269,10 +1068,48 @@
269 1068 'file_path' => '',
270 1069 'permissions' => []
271 1070 ];
272 1071
1072 + // Refuse to publish when the feature is disabled. The React UI hides the
1073 + // publish button, but the REST endpoint and the MCP publish ability call
1074 + // this directly, so enforce the toggle here at the single write choke point.
1075 + $settings = $this->get_settings('site');
1076 + if (empty($settings['enabled'])) {
1077 + $result['message'] = 'LLMs.txt is disabled. Enable it before publishing.';
1078 + return $result;
1079 + }
1080 +
1081 + $mode = $this->resolve_delivery_mode();
1082 + $result['delivery_mode'] = $mode;
1083 +
273 1084 $llms_file = ABSPATH . 'llms.txt';
1085 + $result['file_path'] = $llms_file;
274 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 +
275 1112 // Security: Validate file path to prevent path traversal attacks
276 1113 $real_llms_file = realpath(dirname($llms_file)) . DIRECTORY_SEPARATOR . basename($llms_file);
277 1114 $allowed_dir = realpath(ABSPATH);
278 1115
@@ -280,10 +1117,8 @@
280 1117 $result['message'] = 'Invalid file path detected for security reasons.';
281 1118 return $result;
282 1119 }
283 1120
284 - $result['file_path'] = $llms_file;
285 -
286 1121 // Check directory permissions
287 1122 $result['permissions'] = [
288 1123 'directory_writable' => $this->is_directory_writable(ABSPATH),
289 1124 'file_exists' => file_exists($llms_file),
@@ -301,34 +1136,57 @@
301 1136 $result['message'] = 'Existing llms.txt file is not writable. Please check file permissions.';
302 1137 return $result;
303 1138 }
304 1139
305 - try {
306 - // Write new content (directly replace existing file)
307 - $bytes_written = file_put_contents($llms_file, $content, LOCK_EX);
1140 + // Write new content using WP_Filesystem
1141 + if (!$this->init_filesystem()) {
1142 + $result['message'] = 'Could not initialize WordPress filesystem.';
1143 + return $result;
1144 + }
308 1145
309 - if ($bytes_written !== false) {
310 - $result['success'] = true;
311 - $result['message'] = 'LLMs.txt file written successfully.';
312 - $result['bytes_written'] = $bytes_written;
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 + }
313 1150
314 - // Set appropriate file permissions (644)
315 - $this->set_file_permissions($llms_file, 0644);
1151 + $result['success'] = true;
1152 + $result['message'] = 'LLMs.txt file written successfully.';
1153 + $result['bytes_written'] = strlen($content);
316 1154
317 - // Invalidate file status cache since file has changed
318 - delete_transient('thinkrank_llms_file_status');
319 - } else {
320 - $result['message'] = 'Failed to write llms.txt file.';
321 - }
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();
322 1159
323 - } catch (\Exception $e) {
324 - $result['message'] = 'Error writing llms.txt file: ' . $e->getMessage();
325 - }
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);
326 1163
327 - 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);
328 1168 }
329 1169
330 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 + /**
331 1189 * Get LLMs.txt file status and information
332 1190 *
333 1191 * @since 1.0.0
334 1192 *
@@ -345,36 +1203,69 @@
345 1203 }
346 1204 }
347 1205
348 1206 $llms_file = ABSPATH . 'llms.txt';
1207 + $mode = $this->resolve_delivery_mode();
349 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 +
350 1221 $status = [
351 1222 'file_exists' => file_exists($llms_file),
352 - '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,
353 1228 'file_url' => home_url('/llms.txt'),
354 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 + : '',
355 1236 'last_modified' => null,
356 1237 'file_size' => null,
357 1238 'content_preview' => ''
358 1239 ];
359 1240
1241 + $content = null;
1242 +
360 1243 if ($status['file_exists']) {
361 1244 $status['last_modified'] = filemtime($llms_file);
362 1245 $status['file_size'] = filesize($llms_file);
363 1246
364 - // Get content preview (first 200 characters) with size safety
365 1247 $read_result = $this->safe_file_read($llms_file);
366 1248 if ($read_result['success']) {
367 - $status['content_preview'] = substr($read_result['content'], 0, 200);
368 - if (strlen($read_result['content']) > 200) {
369 - $status['content_preview'] .= '...';
370 - }
1249 + $content = $read_result['content'];
371 1250 } else {
372 1251 $status['content_preview'] = 'Error: ' . $read_result['error'];
373 1252 $status['read_error'] = $read_result['error'];
374 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;
375 1259 }
376 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 +
377 1268 // Cache the result for 5 minutes to improve performance
378 1269 set_transient($cache_key, $status, 5 * MINUTE_IN_SECONDS);
379 1270
380 1271 return $status;
@@ -519,13 +1410,15 @@
519 1410 * Override parent sanitize_settings to preserve line breaks in link fields
520 1411 *
521 1412 * @since 1.0.0
522 1413 *
523 - * @param array $settings Settings to sanitize
1414 + * @param array $settings Settings to sanitize
1415 + * @param string $context_type Context the save is for.
524 1416 * @return array Sanitized settings
525 1417 */
526 - protected function sanitize_settings(array $settings): array {
1418 + protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
527 1419 $sanitized = [];
1420 + $known = $this->get_known_setting_keys($context_type);
528 1421
529 1422 // Fields that should preserve line breaks
530 1423 $preserve_linebreaks = [
531 1424 'documentation_links',
@@ -542,8 +1435,27 @@
542 1435
543 1436 foreach ($settings as $key => $value) {
544 1437 $sanitized_key = sanitize_key($key);
545 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 +
546 1458 if (is_string($value)) {
547 1459 if (in_array($key, $preserve_linebreaks, true)) {
548 1460 // Use our custom sanitization that preserves line breaks
549 1461 if (in_array($key, ['documentation_links', 'technical_links', 'optional_links', 'custom_sections'], true)) {
@@ -574,15 +1486,15 @@
574 1486 * Recursively sanitize array values (preserving line breaks where needed)
575 1487 *
576 1488 * @since 1.0.0
577 1489 *
578 - * @param array $array Array to sanitize
1490 + * @param array $input Array to sanitize
579 1491 * @return array Sanitized array
580 1492 */
581 - private function sanitize_array_recursive(array $array): array {
1493 + private function sanitize_array_recursive(array $input): array {
582 1494 $sanitized = [];
583 1495
584 - foreach ($array as $key => $value) {
1496 + foreach ($input as $key => $value) {
585 1497 $sanitized_key = sanitize_key($key);
586 1498
587 1499 if (is_string($value)) {
588 1500 $sanitized[$sanitized_key] = sanitize_textarea_field($value);
@@ -687,11 +1599,20 @@
687 1599 }
688 1600
689 1601 // Check key features quality
690 1602 if (!empty($user_input['key_features'])) {
691 - $features = explode("\n", $user_input['key_features']);
692 - $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']));
693 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 +
694 1615 if ($feature_count < 3) {
695 1616 $validation['warnings'][] = 'Consider adding more key features (3-8 recommended) for comprehensive AI understanding';
696 1617 $validation['score'] -= 10;
697 1618 } elseif ($feature_count > 10) {
@@ -829,9 +1750,22 @@
829 1750 'content' => $this->sanitize_llms_content($user_input['custom_sections'])
830 1751 ];
831 1752 }
832 1753
833 - 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);
834 1768 }
835 1769
836 1770 /**
837 1771 * Sanitize LLMs.txt content while preserving line breaks
@@ -850,8 +1784,19 @@
850 1784 'code' => [],
851 1785 'pre' => []
852 1786 ]);
853 1787
1788 + // wp_kses only guards HTML href attributes, not markdown link syntax
1789 + // [text](url). Neutralize dangerous schemes (javascript:/data:/vbscript:)
1790 + // in markdown link targets so they don't survive into the published file
1791 + // for downstream consumers that render it as markdown/HTML.
1792 + $content = preg_replace_callback('/\]\(([^)]*)\)/', static function ($m) {
1793 + if (preg_match('#^\s*(?:javascript|data|vbscript):#i', $m[1])) {
1794 + return '](#)';
1795 + }
1796 + return $m[0];
1797 + }, $content);
1798 +
854 1799 // Normalize line endings and preserve line breaks
855 1800 $content = str_replace(["\r\n", "\r"], "\n", $content);
856 1801
857 1802 // Remove excessive whitespace but preserve intentional line breaks
@@ -896,9 +1841,14 @@
896 1841 );
897 1842 return $result;
898 1843 }
899 1844
900 - $content = file_get_contents($file_path);
1845 + if (!$this->init_filesystem()) {
1846 + $result['error'] = 'Could not initialize WordPress filesystem';
1847 + return $result;
1848 + }
1849 +
1850 + $content = $this->filesystem->get_contents($file_path);
901 1851 if (false === $content) {
902 1852 $result['error'] = 'Failed to read file content';
903 1853 return $result;
904 1854 }
@@ -906,152 +1856,12 @@
906 1856 $result['success'] = true;
907 1857 $result['content'] = $content;
908 1858 return $result;
909 1859 }
910 -
911 - /**
912 - * Build project overview content
913 - *
914 - * @since 1.0.0
915 - *
916 - * @param array $user_input User input data
917 - * @param array $settings Current settings
918 - * @return string Project overview content
919 - */
920 - private function build_project_overview(array $user_input, array $settings): string {
921 - $site_name = $user_input['site_name'] ?? $settings['site_name'] ?? get_bloginfo('name');
922 - $website_url = home_url();
923 - $description = sanitize_textarea_field($user_input['website_description'] ?? '');
924 - $business_type = $user_input['business_type'] ?? 'website';
925 - $target_audience = sanitize_text_field($user_input['target_audience'] ?? '');
926 -
927 - $overview = "{$site_name} is a {$this->business_types[$business_type]} located at {$website_url}.\n\n";
928 - $overview .= "{$description}\n\n";
929 -
930 - if (!empty($target_audience)) {
931 - $overview .= "Target Audience: {$target_audience}\n";
932 - }
933 -
934 - return $overview;
935 - }
936 -
937 - /**
938 - * Build key features content
939 - *
940 - * @since 1.0.0
941 - *
942 - * @param array $user_input User input data
943 - * @return string Key features content
944 - */
945 - private function build_key_features(array $user_input): string {
946 - $features = sanitize_textarea_field($user_input['key_features'] ?? '');
947 -
948 - // If features are provided as a list, format them properly
949 - if (strpos($features, "\n") !== false || strpos($features, ',') !== false) {
950 - $feature_list = preg_split('/[,\n]+/', $features);
951 - $formatted_features = '';
952 - foreach ($feature_list as $feature) {
953 - $feature = trim($feature);
954 - if (!empty($feature)) {
955 - $formatted_features .= "- " . $feature . "\n";
956 - }
957 - }
958 - return $formatted_features;
959 - }
960 -
961 - return $features;
962 - }
963 -
964 - /**
965 - * Build architecture section content
966 - *
967 - * @since 1.0.0
968 - *
969 - * @param array $user_input User input data
970 - * @return string Architecture content
971 - */
972 - private function build_architecture_section(array $user_input): string {
973 - $content = '';
974 -
975 - if (!empty($user_input['technical_stack'])) {
976 - $content .= "**Technical Stack:**\n";
977 - $stack = sanitize_textarea_field($user_input['technical_stack']);
978 - $content .= $stack . "\n\n";
979 - }
980 -
981 - if (!empty($user_input['development_approach'])) {
982 - $content .= "**Development Approach:**\n";
983 - $approach = sanitize_textarea_field($user_input['development_approach']);
984 - $content .= $approach . "\n";
985 - }
986 -
987 - return $content;
988 - }
989 -
990 - /**
991 - * Build development guidelines content
992 - *
993 - * @since 1.0.0
994 - *
995 - * @param array $user_input User input data
996 - * @return string Development guidelines content
997 - */
998 - private function build_development_guidelines(array $user_input): string {
999 - $guidelines = sanitize_textarea_field($user_input['development_approach'] ?? '');
1000 -
1001 - // Add some standard guidelines if not provided
1002 - if (empty($guidelines)) {
1003 - $guidelines = "- Follow WordPress coding standards\n";
1004 - $guidelines .= "- Use semantic HTML and accessible design\n";
1005 - $guidelines .= "- Optimize for performance and SEO\n";
1006 - }
1007 -
1008 - return $guidelines;
1009 - }
1010 -
1011 - /**
1012 - * Build AI context content
1013 - *
1014 - * @since 1.0.0
1015 - *
1016 - * @param array $user_input User input data
1017 - * @param array $settings Current settings
1018 - * @return string AI context content
1019 - */
1020 - private function build_ai_context(array $user_input, array $settings): string {
1021 - $context = "This website is built with WordPress and uses the ThinkRank SEO plugin for optimization.\n\n";
1022 -
1023 - $business_type = $user_input['business_type'] ?? 'website';
1024 - $context .= "When providing assistance:\n";
1025 - $context .= "- Consider this is a {$this->business_types[$business_type]}\n";
1026 -
1027 - if (!empty($user_input['target_audience'])) {
1028 - $target_audience = sanitize_text_field($user_input['target_audience']);
1029 - $context .= "- Target audience: {$target_audience}\n";
1030 - }
1031 -
1032 - $context .= "- Focus on SEO best practices and user experience\n";
1033 - $context .= "- Maintain WordPress coding standards\n";
1034 -
1035 - if (!empty($user_input['ai_context_custom'])) {
1036 - $context .= "\nAdditional Context:\n";
1037 - $context .= sanitize_textarea_field($user_input['ai_context_custom']);
1038 - }
1039 -
1040 - return $context;
1041 - }
1042 -
1043 - /**
1044 - * Build final LLMs.txt content from sections
1045 - *
1046 - * @since 1.0.0
1047 - *
1048 - * @param array $sections Content sections
1049 - * @param string $site_name Site name to use in the header
1050 - * @return string Complete LLMs.txt content
1051 - */
1052 1860 private function build_llms_txt_content(array $sections, string $site_name = ''): string {
1053 - $site_name = $site_name ?: get_bloginfo('name');
1861 + // Sanitize inside the manager rather than trusting callers — the MCP
1862 + // abilities pass site_name through unsanitized.
1863 + $site_name = sanitize_text_field($site_name ?: get_bloginfo('name'));
1054 1864 $content = "# {$site_name}\n\n";
1055 1865
1056 1866 foreach ($sections as $section_key => $section_data) {
1057 1867 // Only add H2 header if title is not empty
@@ -1118,12 +1928,16 @@
1118 1928 $validation['score'] -= 5;
1119 1929 }
1120 1930 }
1121 1931
1122 - // Check file permissions if enabled
1123 - 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) {
1124 1938 if (!$this->is_directory_writable(ABSPATH)) {
1125 - $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.';
1126 1940 $validation['score'] -= 10;
1127 1941 }
1128 1942 }
1129 1943
@@ -1158,9 +1972,10 @@
1158 1972
1159 1973 // Get file status
1160 1974 $output['file_status'] = $this->get_llms_txt_status();
1161 1975
1162 - // 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.
1163 1978 if ($output['file_status']['file_exists']) {
1164 1979 $llms_file = ABSPATH . 'llms.txt';
1165 1980 $read_result = $this->safe_file_read($llms_file);
1166 1981 if ($read_result['success']) {
@@ -1168,8 +1983,10 @@
1168 1983 } else {
1169 1984 $output['llms_txt_content'] = '';
1170 1985 $output['file_read_error'] = $read_result['error'];
1171 1986 }
1987 + } else {
1988 + $output['llms_txt_content'] = $this->get_published_content();
1172 1989 }
1173 1990
1174 1991 // Add metadata
1175 1992 $output['metadata'] = [
@@ -1201,8 +2018,9 @@
1201 2018 'development_approach' => '',
1202 2019 'setup_instructions' => '',
1203 2020 'ai_context_custom' => '',
1204 2021 'auto_generate' => false,
2022 + 'delivery_mode' => 'auto',
1205 2023 'last_generated' => null,
1206 2024 // Structured sections for llms.txt spec compliance
1207 2025 'documentation_links' => '',
1208 2026 'technical_links' => '',
@@ -1255,9 +2073,9 @@
1255 2073 ],
1256 2074 'key_features' => [
1257 2075 'type' => 'string',
1258 2076 'title' => 'Key Features',
1259 - '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.',
1260 2078 'default' => '',
1261 2079 'maxLength' => 500
1262 2080 ],
1263 2081 'target_audience' => [
@@ -1307,8 +2125,15 @@
1307 2125 'title' => 'Auto-generate',
1308 2126 'description' => 'Automatically regenerate llms.txt when settings change',
1309 2127 'default' => false
1310 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 + ],
1311 2136 'last_generated' => [
1312 2137 'type' => 'string',
1313 2138 'title' => 'Last Generated',
1314 2139 'description' => 'Timestamp of last generation',
@@ -1344,173 +2169,284 @@
1344 2169 'maxLength' => 3000
1345 2170 ]
1346 2171 ];
1347 2172 }
2173 + private function build_summary_blockquote(array $user_input, array $settings): string {
2174 + $description = sanitize_textarea_field($user_input['website_description'] ?? '');
1348 2175
2176 + if (empty($description)) {
2177 + $site_name = sanitize_text_field($user_input['site_name'] ?? $settings['site_name'] ?? get_bloginfo('name'));
2178 + $business_type = $user_input['business_type'] ?? 'website';
2179 + $description = "{$site_name} is a {$this->business_types[$business_type]} providing valuable resources and information.";
2180 + }
2181 +
2182 + // Format as blockquote (required by spec)
2183 + return "> " . $description . "\n\n";
2184 + }
2185 +
1349 2186 /**
1350 - * Calculate validation score based on validation results
2187 + * Split the Key Features field into individual features.
1351 2188 *
1352 - * @since 1.0.0
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.
1353 2195 *
1354 - * @param array $validation Validation results
1355 - * @return int Calculated score (0-100)
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.
1356 2210 */
1357 - private function calculate_validation_score(array $validation): int {
1358 - $score = 100;
2211 + public static function split_key_features(string $key_features): array {
2212 + $features = preg_split('/[\r\n]+/', $key_features);
1359 2213
1360 - // Deduct points for errors (major issues)
1361 - $score -= count($validation['errors']) * 25;
2214 + if (!is_array($features)) {
2215 + return [];
2216 + }
1362 2217
1363 - // Deduct points for warnings (moderate issues)
1364 - $score -= count($validation['warnings']) * 10;
2218 + $features = array_map('trim', $features);
1365 2219
1366 - // Deduct points for suggestions (minor issues)
1367 - $score -= count($validation['suggestions']) * 5;
1368 -
1369 - // Ensure score doesn't go below 0
1370 - return max(0, $score);
2220 + return array_values(array_filter($features, static fn(string $f): bool => '' !== $f));
1371 2221 }
1372 2222
1373 2223 /**
1374 - * Build summary blockquote (required by llms.txt spec)
2224 + * Whether a value looks like the old comma-separated list.
1375 2225 *
1376 - * @since 1.0.0
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.
1377 2230 *
1378 - * @param array $user_input User input data
1379 - * @param array $settings Current settings
1380 - * @return string Blockquote summary content
2231 + * @since 2.10.0
2232 + *
2233 + * @param string $key_features Raw field value.
2234 + * @return bool
1381 2235 */
1382 - private function build_summary_blockquote(array $user_input, array $settings): string {
1383 - $description = sanitize_textarea_field($user_input['website_description'] ?? '');
2236 + public static function looks_like_comma_list(string $key_features): bool {
2237 + $trimmed = trim($key_features);
1384 2238
1385 - if (empty($description)) {
1386 - $site_name = $user_input['site_name'] ?? $settings['site_name'] ?? get_bloginfo('name');
1387 - $business_type = $user_input['business_type'] ?? 'website';
1388 - $description = "{$site_name} is a {$this->business_types[$business_type]} providing valuable resources and information.";
2239 + if ('' === $trimmed || false !== strpbrk($trimmed, "\r\n")) {
2240 + return false;
1389 2241 }
1390 2242
1391 - // Format as blockquote (required by spec)
1392 - return "> " . $description . "\n\n";
2243 + return false !== strpos($trimmed, ',');
1393 2244 }
1394 2245
1395 2246 /**
1396 - * Build additional details section
2247 + * Turn a single-line comma list into one feature per line.
1397 2248 *
1398 - * @since 1.0.0
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.
1399 2251 *
1400 - * @param array $user_input User input data
1401 - * @param array $settings Current settings
1402 - * @return string Additional details content
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.
1403 2274 */
1404 - private function build_additional_details(array $user_input, array $settings): string {
1405 - $content = '';
1406 - $target_audience = sanitize_text_field($user_input['target_audience'] ?? '');
1407 - $key_features = sanitize_textarea_field($user_input['key_features'] ?? '');
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 + }
1408 2279
1409 - if (!empty($target_audience)) {
1410 - $content .= "**Target Audience:** {$target_audience}\n\n";
2280 + $segments = preg_split('/\s*,(?!\d)\s*/', trim($key_features));
2281 +
2282 + if (!is_array($segments)) {
2283 + return null;
1411 2284 }
1412 2285
1413 - if (!empty($key_features)) {
1414 - $content .= "**Key Features:**\n";
1415 - $features = explode(',', $key_features);
1416 - foreach ($features as $feature) {
1417 - $feature = trim($feature);
1418 - if (!empty($feature)) {
1419 - $content .= "- " . $feature . "\n";
1420 - }
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;
1421 2298 }
1422 - $content .= "\n";
1423 2299 }
1424 2300
1425 - return $content;
2301 + if (preg_match('/\s(?:and|or|&)\s/i', (string) end($segments))) {
2302 + return null;
2303 + }
2304 +
2305 + return implode("\n", $segments);
1426 2306 }
1427 2307
1428 2308 /**
1429 - * Build documentation links section (H2 with file lists)
2309 + * Coerce an AI reply for Key Features into the one-per-line field value.
1430 2310 *
1431 - * @since 1.0.0
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.
1432 2316 *
1433 - * @param array $user_input User input data
1434 - * @param array $settings Current settings
1435 - * @return string Documentation links content
2317 + * @since 2.10.0
2318 + *
2319 + * @param mixed $value Decoded `key_features` from the reply.
2320 + * @return string Sanitised, newline-separated features.
1436 2321 */
1437 - private function build_documentation_links(array $user_input, array $settings): string {
1438 - $content = '';
1439 - $website_url = home_url();
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 + }
1440 2337
1441 - // Add common WordPress documentation links
1442 - $content .= "- [WordPress Documentation]({$website_url}/wp-admin/): Admin dashboard and content management\n";
1443 - $content .= "- [Site Pages]({$website_url}/sitemap.xml): Complete sitemap of all pages\n";
2338 + $value = (string) $value;
2339 + $lines = self::comma_list_to_lines($value);
1444 2340
1445 - // Add custom documentation if setup instructions provided
1446 - if (!empty($user_input['setup_instructions'])) {
1447 - $content .= "- [Setup Guide]({$website_url}): " . wp_trim_words($user_input['setup_instructions'], 10) . "\n";
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;
1448 2372 }
1449 2373
1450 - return $content;
2374 + update_option(self::KEY_FEATURES_MIGRATION_OPTION, self::KEY_FEATURES_MIGRATION_VERSION, true);
2375 +
2376 + (new static())->migrate_stored_key_features();
1451 2377 }
1452 2378
1453 2379 /**
1454 - * Build technical details links section
2380 + * Rewrite the stored Key Features as lines when it is a legacy comma list.
1455 2381 *
1456 - * @since 1.0.0
2382 + * @since 2.10.0
1457 2383 *
1458 - * @param array $user_input User input data
1459 - * @param array $settings Current settings
1460 - * @return string Technical links content
2384 + * @return bool True when a value was converted and saved.
1461 2385 */
1462 - private function build_technical_links(array $user_input, array $settings): string {
1463 - $content = '';
1464 - $website_url = home_url();
2386 + public function migrate_stored_key_features(): bool {
2387 + $stored = $this->get_stored_settings('site');
1465 2388
1466 - if (!empty($user_input['technical_stack'])) {
1467 - $content .= "- [Technical Stack Information]({$website_url}): Built with " . sanitize_text_field($user_input['technical_stack']) . "\n";
2389 + if (!isset($stored['key_features']) || !is_string($stored['key_features'])) {
2390 + return false;
1468 2391 }
1469 2392
1470 - if (!empty($user_input['development_approach'])) {
1471 - $content .= "- [Development Guidelines]({$website_url}): " . wp_trim_words($user_input['development_approach'], 15) . "\n";
2393 + $lines = self::comma_list_to_lines($stored['key_features']);
2394 +
2395 + if (null === $lines) {
2396 + return false;
1472 2397 }
1473 2398
1474 - // Add robots.txt reference
1475 - $content .= "- [Robots.txt]({$website_url}/robots.txt): Site crawling guidelines\n";
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 + }
1476 2408
1477 - return $content;
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);
1478 2419 }
1479 2420
1480 2421 /**
1481 - * Build optional links section (for secondary information)
2422 + * Build additional details section
1482 2423 *
1483 2424 * @since 1.0.0
1484 2425 *
1485 2426 * @param array $user_input User input data
1486 2427 * @param array $settings Current settings
1487 - * @return string Optional links content
2428 + * @return string Additional details content
1488 2429 */
1489 - private function build_optional_links(array $user_input, array $settings): string {
2430 + private function build_additional_details(array $user_input, array $settings): string {
1490 2431 $content = '';
1491 - $website_url = home_url();
2432 + $target_audience = sanitize_text_field($user_input['target_audience'] ?? '');
2433 + $key_features = sanitize_textarea_field($user_input['key_features'] ?? '');
1492 2434
1493 - // Add optional/secondary resources
1494 - $content .= "- [WordPress Codex](https://codex.wordpress.org/): Official WordPress documentation\n";
1495 - $content .= "- [Plugin Directory](https://wordpress.org/plugins/): WordPress plugin repository\n";
2435 + if (!empty($target_audience)) {
2436 + $content .= "**Target Audience:** {$target_audience}\n\n";
2437 + }
1496 2438
1497 - // Add theme information if available
1498 - $theme = wp_get_theme();
1499 - if ($theme->exists()) {
1500 - $content .= "- [Theme Information]({$website_url}): Using " . $theme->get('Name') . " theme\n";
2439 + if (!empty($key_features)) {
2440 + $content .= "**Key Features:**\n";
2441 + foreach (self::split_key_features($key_features) as $feature) {
2442 + $content .= "- " . $feature . "\n";
2443 + }
2444 + $content .= "\n";
1501 2445 }
1502 2446
1503 2447 return $content;
1504 2448 }
1505 -
1506 - /**
1507 - * Get default documentation links when user hasn't provided any
1508 - *
1509 - * @since 1.0.0
1510 - *
1511 - * @return string Default documentation links
1512 - */
1513 2449 private function get_default_documentation_links(): string {
1514 2450 $website_url = home_url();
1515 2451 $content = '';
1516 2452
@@ -1538,9 +2474,9 @@
1538 2474 $content .= "- [Technical Stack]({$website_url}): Built with {$stack}\n";
1539 2475 }
1540 2476
1541 2477 if (!empty($user_input['development_approach'])) {
1542 - $approach_summary = wp_trim_words($user_input['development_approach'], 10);
2478 + $approach_summary = \ThinkRank\Core\Seo_Text::trim_words($user_input['development_approach'], 10);
1543 2479 $content .= "- [Development Guidelines]({$website_url}): {$approach_summary}\n";
1544 2480 }
1545 2481
1546 2482 // Add robots.txt reference