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 +307 -20 2.1.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 }
@@ -85,9 +90,9 @@
85 90 ],
86 91 'key_features' => [
87 92 'title' => 'Key Features',
88 93 'required' => true,
89 - '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.',
90 95 'max_length' => 300
91 96 ],
92 97 'architecture' => [
93 98 'title' => 'Architecture & Components',
@@ -198,8 +203,20 @@
198 203 */
199 204 private const DELIVERY_MODES = ['auto', 'static', 'dynamic'];
200 205
201 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 + /**
202 219 * Business type templates for content generation
203 220 *
204 221 * @since 1.0.0
205 222 * @var array
@@ -291,8 +308,13 @@
291 308 'validation' => [],
292 309 'file_info' => []
293 310 ];
294 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 +
295 317 // Get current settings
296 318 $settings = $this->get_settings('site');
297 319
298 320 // Merge saved settings underneath the provided input so that empty or
@@ -536,8 +558,18 @@
536 558 if ('static' === $mode || 'dynamic' === $mode) {
537 559 return $mode;
538 560 }
539 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 +
540 572 // $is_apache also covers LiteSpeed, which reads .htaccess the same way.
541 573 if (empty($GLOBALS['is_apache'])) {
542 574 return 'dynamic';
543 575 }
@@ -745,8 +777,28 @@
745 777 return is_string($content) ? $content : '';
746 778 }
747 779
748 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 + /**
749 801 * Ask the common page/CDN cache layers to drop their copy of /llms.txt.
750 802 *
751 803 * A cached response outlives a republish, so without this a mode switch or
752 804 * a content change keeps serving the old document (and, on the static path,
@@ -1169,9 +1221,9 @@
1169 1221 $status = [
1170 1222 'file_exists' => file_exists($llms_file),
1171 1223 // Whether /llms.txt is actually being served, either mode. Prefer
1172 1224 // this over file_exists, which is only meaningful in static mode.
1173 - 'published' => file_exists($llms_file) || '' !== trim($stored),
1225 + 'published' => $this->is_published(),
1174 1226 'delivery_mode' => $mode,
1175 1227 'file_path' => 'dynamic' === $mode ? '' : $llms_file,
1176 1228 'file_url' => home_url('/llms.txt'),
1177 1229 'writable' => $this->is_directory_writable(dirname($llms_file)),
@@ -1207,12 +1259,11 @@
1207 1259 }
1208 1260
1209 1261 if (null !== $content) {
1210 1262 // Get content preview (first 200 characters) with size safety
1211 - $status['content_preview'] = substr($content, 0, 200);
1212 - if (strlen($content) > 200) {
1213 - $status['content_preview'] .= '...';
1214 - }
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);
1215 1266 }
1216 1267
1217 1268 // Cache the result for 5 minutes to improve performance
1218 1269 set_transient($cache_key, $status, 5 * MINUTE_IN_SECONDS);
@@ -1548,11 +1599,20 @@
1548 1599 }
1549 1600
1550 1601 // Check key features quality
1551 1602 if (!empty($user_input['key_features'])) {
1552 - $features = explode("\n", $user_input['key_features']);
1553 - $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']));
1554 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 +
1555 1615 if ($feature_count < 3) {
1556 1616 $validation['warnings'][] = 'Consider adding more key features (3-8 recommended) for comprehensive AI understanding';
1557 1617 $validation['score'] -= 10;
1558 1618 } elseif ($feature_count > 10) {
@@ -2013,9 +2073,9 @@
2013 2073 ],
2014 2074 'key_features' => [
2015 2075 'type' => 'string',
2016 2076 'title' => 'Key Features',
2017 - '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.',
2018 2078 'default' => '',
2019 2079 'maxLength' => 500
2020 2080 ],
2021 2081 'target_audience' => [
@@ -2123,8 +2183,243 @@
2123 2183 return "> " . $description . "\n\n";
2124 2184 }
2125 2185
2126 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 + /**
2127 2422 * Build additional details section
2128 2423 *
2129 2424 * @since 1.0.0
2130 2425 *
@@ -2142,18 +2437,10 @@
2142 2437 }
2143 2438
2144 2439 if (!empty($key_features)) {
2145 2440 $content .= "**Key Features:**\n";
2146 - // The UI field is a multi-line textarea and validation counts by
2147 - // newline, so split on newlines (and still tolerate commas) rather
2148 - // than commas only — otherwise newline-separated input collapses
2149 - // into one broken bullet.
2150 - $features = preg_split('/[\r\n,]+/', $key_features);
2151 - foreach ($features as $feature) {
2152 - $feature = trim($feature);
2153 - if (!empty($feature)) {
2154 - $content .= "- " . $feature . "\n";
2155 - }
2441 + foreach (self::split_key_features($key_features) as $feature) {
2442 + $content .= "- " . $feature . "\n";
2156 2443 }
2157 2444 $content .= "\n";
2158 2445 }
2159 2446
@@ -2187,9 +2474,9 @@
2187 2474 $content .= "- [Technical Stack]({$website_url}): Built with {$stack}\n";
2188 2475 }
2189 2476
2190 2477 if (!empty($user_input['development_approach'])) {
2191 - $approach_summary = wp_trim_words($user_input['development_approach'], 10);
2478 + $approach_summary = \ThinkRank\Core\Seo_Text::trim_words($user_input['development_approach'], 10);
2192 2479 $content .= "- [Development Guidelines]({$website_url}): {$approach_summary}\n";
2193 2480 }
2194 2481
2195 2482 // Add robots.txt reference