PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk All 53 releases
← All changes | includes/seo/class-llms-txt-manager.php +287 -35 2.3.0 → 2.12.0 View file →
@@ -90,9 +90,9 @@
90 90 ],
91 91 'key_features' => [
92 92 'title' => 'Key Features',
93 93 'required' => true,
94 - '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.',
95 95 'max_length' => 300
96 96 ],
97 97 'architecture' => [
98 98 'title' => 'Architecture & Components',
@@ -203,8 +203,20 @@
203 203 */
204 204 private const DELIVERY_MODES = ['auto', 'static', 'dynamic'];
205 205
206 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 + /**
207 219 * Business type templates for content generation
208 220 *
209 221 * @since 1.0.0
210 222 * @var array
@@ -296,8 +308,13 @@
296 308 'validation' => [],
297 309 'file_info' => []
298 310 ];
299 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 +
300 317 // Get current settings
301 318 $settings = $this->get_settings('site');
302 319
303 320 // Merge saved settings underneath the provided input so that empty or
@@ -541,8 +558,18 @@
541 558 if ('static' === $mode || 'dynamic' === $mode) {
542 559 return $mode;
543 560 }
544 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 +
545 572 // $is_apache also covers LiteSpeed, which reads .htaccess the same way.
546 573 if (empty($GLOBALS['is_apache'])) {
547 574 return 'dynamic';
548 575 }
@@ -774,11 +801,14 @@
774 801 * Ask the common page/CDN cache layers to drop their copy of /llms.txt.
775 802 *
776 803 * A cached response outlives a republish, so without this a mode switch or
777 804 * a content change keeps serving the old document (and, on the static path,
778 - * the old headers). Every call is guarded — a site running none of these
779 - * simply gets the action hook, which integrations can use.
805 + * the old headers).
780 806 *
807 + * The layer-by-layer work moved to Cache_Purger in 2.10.1, once alt-text
808 + * writes needed the same thing (#763). The llms.txt-specific hook stays
809 + * here because it names this document, which a generic URL purge cannot.
810 + *
781 811 * @since 2.1.0
782 812 *
783 813 * @return void
784 814 */
@@ -794,22 +824,9 @@
794 824 * @param string $url Public URL of the llms.txt document.
795 825 */
796 826 do_action('thinkrank_llms_txt_updated', $url);
797 827
798 - // LiteSpeed Cache and Nginx Helper both listen on their own actions.
799 - // These are third-party hook names we fire, not ours to prefix.
800 - do_action('litespeed_purge_url', $url); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
801 - do_action('rt_nginx_helper_purge_all'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
802 -
803 - if (function_exists('rocket_clean_files')) {
804 - rocket_clean_files([$url]);
805 - }
806 - if (function_exists('w3tc_flush_url')) {
807 - w3tc_flush_url($url);
808 - }
809 - if (function_exists('wpsc_delete_url_cache')) {
810 - wpsc_delete_url_cache($url);
811 - }
828 + Cache_Purger::purge_urls([$url]);
812 829 }
813 830
814 831 /**
815 832 * Unpublish llms.txt: drop the stored document and any physical file.
@@ -1232,12 +1249,11 @@
1232 1249 }
1233 1250
1234 1251 if (null !== $content) {
1235 1252 // Get content preview (first 200 characters) with size safety
1236 - $status['content_preview'] = substr($content, 0, 200);
1237 - if (strlen($content) > 200) {
1238 - $status['content_preview'] .= '...';
1239 - }
1253 + // substr()/strlen() count BYTES, so this cut a multibyte character
1254 + // in half and shipped an invalid UTF-8 sequence in the preview (#687).
1255 + $status['content_preview'] = \ThinkRank\Core\Seo_Text::trim_to_length($content, 200);
1240 1256 }
1241 1257
1242 1258 // Cache the result for 5 minutes to improve performance
1243 1259 set_transient($cache_key, $status, 5 * MINUTE_IN_SECONDS);
@@ -1573,11 +1589,20 @@
1573 1589 }
1574 1590
1575 1591 // Check key features quality
1576 1592 if (!empty($user_input['key_features'])) {
1577 - $features = explode("\n", $user_input['key_features']);
1578 - $feature_count = count(array_filter($features, 'trim'));
1593 + // The same splitter the generated file uses, so the count reported
1594 + // here and the bullets written out can never disagree (#765).
1595 + $feature_count = count(self::split_key_features((string) $user_input['key_features']));
1579 1596
1597 + // A single line containing commas is ambiguous: it is either a
1598 + // legacy comma-separated list or one feature with a comma in it.
1599 + // Rather than guess and risk publishing "and Etsy" as a feature,
1600 + // say so and let the author decide.
1601 + if (self::looks_like_comma_list((string) $user_input['key_features'])) {
1602 + $validation['suggestions'][] = 'Put each key feature on its own line. Commas are treated as part of a feature, not as separators.';
1603 + }
1604 +
1580 1605 if ($feature_count < 3) {
1581 1606 $validation['warnings'][] = 'Consider adding more key features (3-8 recommended) for comprehensive AI understanding';
1582 1607 $validation['score'] -= 10;
1583 1608 } elseif ($feature_count > 10) {
@@ -2038,9 +2063,9 @@
2038 2063 ],
2039 2064 'key_features' => [
2040 2065 'type' => 'string',
2041 2066 'title' => 'Key Features',
2042 - 'description' => 'Main features and functionality of your website',
2067 + '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.',
2043 2068 'default' => '',
2044 2069 'maxLength' => 500
2045 2070 ],
2046 2071 'target_audience' => [
@@ -2148,8 +2173,243 @@
2148 2173 return "> " . $description . "\n\n";
2149 2174 }
2150 2175
2151 2176 /**
2177 + * Split the Key Features field into individual features.
2178 + *
2179 + * One feature per line. Commas used to be delimiters too, which meant a
2180 + * single feature that happened to contain one — "Collect reviews from
2181 + * Trustpilot, Google, and Etsy" — was published as three bullets, one of
2182 + * them reading "and Etsy" (#765). Validation counted by newline only, so
2183 + * it reported one feature while the file showed three and never flagged
2184 + * the split.
2185 + *
2186 + * Commas are not a fallback delimiter even when the value has no newlines.
2187 + * A comma inside a feature is ordinary prose and far more likely than a
2188 + * deliberate comma-separated list, and guessing wrong publishes mangled
2189 + * text to the file AI crawlers read. A single-line value with commas is
2190 + * kept whole and validate_content_quality() suggests splitting it, which
2191 + * tells the user what to do instead of quietly deciding for them.
2192 + *
2193 + * The one splitter both generation and validation use, so the file and the
2194 + * feature count can no longer disagree.
2195 + *
2196 + * @since 2.10.0
2197 + *
2198 + * @param string $key_features Raw field value.
2199 + * @return string[] Trimmed features, empties removed.
2200 + */
2201 + public static function split_key_features(string $key_features): array {
2202 + $features = preg_split('/[\r\n]+/', $key_features);
2203 +
2204 + if (!is_array($features)) {
2205 + return [];
2206 + }
2207 +
2208 + $features = array_map('trim', $features);
2209 +
2210 + return array_values(array_filter($features, static fn(string $f): bool => '' !== $f));
2211 + }
2212 +
2213 + /**
2214 + * Whether a value looks like the old comma-separated list.
2215 + *
2216 + * One line, and a comma in it. That is either a legacy list saved before
2217 + * newlines became the delimiter, or a single feature containing a comma —
2218 + * indistinguishable from the outside, which is exactly why this prompts
2219 + * rather than splits.
2220 + *
2221 + * @since 2.10.0
2222 + *
2223 + * @param string $key_features Raw field value.
2224 + * @return bool
2225 + */
2226 + public static function looks_like_comma_list(string $key_features): bool {
2227 + $trimmed = trim($key_features);
2228 +
2229 + if ('' === $trimmed || false !== strpbrk($trimmed, "\r\n")) {
2230 + return false;
2231 + }
2232 +
2233 + return false !== strpos($trimmed, ',');
2234 + }
2235 +
2236 + /**
2237 + * Turn a single-line comma list into one feature per line.
2238 + *
2239 + * Returns null when the value is not something to convert: it already has
2240 + * line breaks, has no comma, or reads as one feature containing a series.
2241 + *
2242 + * Only for text that was written under a comma rule: values saved before
2243 + * newlines became the only delimiter (see
2244 + * {@see self::maybe_migrate_legacy_key_features()}), and AI replies that
2245 + * ignored the one-per-line instruction. Typed input never goes through
2246 + * this; split_key_features() still keeps a comma inside a feature (#765).
2247 + *
2248 + * A series is the one shape the old rule demonstrably mangled: "Collect
2249 + * reviews from Trustpilot, Google, and Etsy" became three bullets, the
2250 + * last reading "and Etsy". So a value is left whole when a segment after
2251 + * the first opens with a conjunction (the Oxford form), or when the final
2252 + * segment carries one ("..., Google and Etsy", the form the field's own
2253 + * placeholder uses). A plain list that happens to end "X and Y" is left
2254 + * whole too; validation still suggests splitting it, and one intact bullet
2255 + * is the safer wrong answer than a sentence cut into fragments.
2256 + *
2257 + * A comma between digits ("1,000 templates") is a thousands separator,
2258 + * not a delimiter.
2259 + *
2260 + * @since 2.10.0
2261 + *
2262 + * @param string $key_features Raw value.
2263 + * @return string|null Newline-separated features, or null to leave as is.
2264 + */
2265 + public static function comma_list_to_lines(string $key_features): ?string {
2266 + if (!self::looks_like_comma_list($key_features)) {
2267 + return null;
2268 + }
2269 +
2270 + $segments = preg_split('/\s*,(?!\d)\s*/', trim($key_features));
2271 +
2272 + if (!is_array($segments)) {
2273 + return null;
2274 + }
2275 +
2276 + $segments = array_values(array_filter(
2277 + array_map('trim', $segments),
2278 + static fn(string $s): bool => '' !== $s
2279 + ));
2280 +
2281 + if (count($segments) < 2) {
2282 + return null;
2283 + }
2284 +
2285 + foreach (array_slice($segments, 1) as $segment) {
2286 + if (preg_match('/^(?:(?:and|or|nor|plus)\b|&)/i', $segment)) {
2287 + return null;
2288 + }
2289 + }
2290 +
2291 + if (preg_match('/\s(?:and|or|&)\s/i', (string) end($segments))) {
2292 + return null;
2293 + }
2294 +
2295 + return implode("\n", $segments);
2296 + }
2297 +
2298 + /**
2299 + * Coerce an AI reply for Key Features into the one-per-line field value.
2300 + *
2301 + * The prompt asks for one feature per line, but models still answer with a
2302 + * JSON array or a comma-separated line. An array went through
2303 + * sanitize_textarea_field() as '' and the field silently kept its old
2304 + * value; a comma line was published as a single bullet now that commas are
2305 + * not delimiters. Both are normalised to lines here, before sanitising.
2306 + *
2307 + * @since 2.10.0
2308 + *
2309 + * @param mixed $value Decoded `key_features` from the reply.
2310 + * @return string Sanitised, newline-separated features.
2311 + */
2312 + public static function normalize_ai_key_features($value): string {
2313 + if (is_array($value)) {
2314 + $features = [];
2315 + foreach ($value as $item) {
2316 + if (is_scalar($item)) {
2317 + $item = trim((string) $item);
2318 + if ('' !== $item) {
2319 + $features[] = $item;
2320 + }
2321 + }
2322 + }
2323 + $value = implode("\n", $features);
2324 + } elseif (!is_scalar($value)) {
2325 + return '';
2326 + }
2327 +
2328 + $value = (string) $value;
2329 + $lines = self::comma_list_to_lines($value);
2330 +
2331 + return sanitize_textarea_field(null === $lines ? $value : $lines);
2332 + }
2333 +
2334 + /**
2335 + * Convert a Key Features value saved under the old comma rule, once.
2336 + *
2337 + * Up to 2.9.0 a comma separated features, so a site that saved
2338 + * "SEO audits, Schema markup, XML sitemaps" published three bullets. After
2339 + * #765 made newlines the only delimiter the same stored value regenerates
2340 + * as one bullet holding the whole line, a silent change to the file AI
2341 + * crawlers read. Rewriting the stored value as lines keeps that site's
2342 + * output what it was, in the form the field now documents.
2343 + *
2344 + * A migration rather than a runtime fallback on purpose: a fallback would
2345 + * keep treating commas as delimiters for every single-line value forever,
2346 + * which is the #765 bug. Here only values that were saved while commas
2347 + * really were delimiters are touched, exactly once; anything typed after
2348 + * this has run follows the new rule. comma_list_to_lines() still leaves a
2349 + * series such as the #765 value whole.
2350 + *
2351 + * Version-gated like Settings::retire_seeded_ai_provider(), and the marker
2352 + * is written first so a site that fails the write does not retry on every
2353 + * admin request. The activator records it on a fresh install.
2354 + *
2355 + * @since 2.10.0
2356 + *
2357 + * @return void
2358 + */
2359 + public static function maybe_migrate_legacy_key_features(): void {
2360 + if (get_option(self::KEY_FEATURES_MIGRATION_OPTION) === self::KEY_FEATURES_MIGRATION_VERSION) {
2361 + return;
2362 + }
2363 +
2364 + update_option(self::KEY_FEATURES_MIGRATION_OPTION, self::KEY_FEATURES_MIGRATION_VERSION, true);
2365 +
2366 + (new static())->migrate_stored_key_features();
2367 + }
2368 +
2369 + /**
2370 + * Rewrite the stored Key Features as lines when it is a legacy comma list.
2371 + *
2372 + * @since 2.10.0
2373 + *
2374 + * @return bool True when a value was converted and saved.
2375 + */
2376 + public function migrate_stored_key_features(): bool {
2377 + $stored = $this->get_stored_settings('site');
2378 +
2379 + if (!isset($stored['key_features']) || !is_string($stored['key_features'])) {
2380 + return false;
2381 + }
2382 +
2383 + $lines = self::comma_list_to_lines($stored['key_features']);
2384 +
2385 + if (null === $lines) {
2386 + return false;
2387 + }
2388 +
2389 + // validate_settings() rejects a payload without `enabled`, so carry the
2390 + // stored flag along. Written through the base save, not this class's,
2391 + // which would also reconcile the delivery mode: a stored-value rewrite
2392 + // must not republish anything.
2393 + return $this->write_migrated_key_features([
2394 + 'enabled' => $stored['enabled'] ?? true,
2395 + 'key_features' => $lines,
2396 + ]);
2397 + }
2398 +
2399 + /**
2400 + * Persist the converted value. Separate so tests can observe the write.
2401 + *
2402 + * @since 2.10.0
2403 + *
2404 + * @param array $settings `enabled` and `key_features`.
2405 + * @return bool
2406 + */
2407 + protected function write_migrated_key_features(array $settings): bool {
2408 + return parent::save_settings('site', null, $settings);
2409 + }
2410 +
2411 + /**
2152 2412 * Build additional details section
2153 2413 *
2154 2414 * @since 1.0.0
2155 2415 *
@@ -2167,18 +2427,10 @@
2167 2427 }
2168 2428
2169 2429 if (!empty($key_features)) {
2170 2430 $content .= "**Key Features:**\n";
2171 - // The UI field is a multi-line textarea and validation counts by
2172 - // newline, so split on newlines (and still tolerate commas) rather
2173 - // than commas only — otherwise newline-separated input collapses
2174 - // into one broken bullet.
2175 - $features = preg_split('/[\r\n,]+/', $key_features);
2176 - foreach ($features as $feature) {
2177 - $feature = trim($feature);
2178 - if (!empty($feature)) {
2179 - $content .= "- " . $feature . "\n";
2180 - }
2431 + foreach (self::split_key_features($key_features) as $feature) {
2432 + $content .= "- " . $feature . "\n";
2181 2433 }
2182 2434 $content .= "\n";
2183 2435 }
2184 2436
@@ -2212,9 +2464,9 @@
2212 2464 $content .= "- [Technical Stack]({$website_url}): Built with {$stack}\n";
2213 2465 }
2214 2466
2215 2467 if (!empty($user_input['development_approach'])) {
2216 - $approach_summary = wp_trim_words($user_input['development_approach'], 10);
2468 + $approach_summary = \ThinkRank\Core\Seo_Text::trim_words($user_input['development_approach'], 10);
2217 2469 $content .= "- [Development Guidelines]({$website_url}): {$approach_summary}\n";
2218 2470 }
2219 2471
2220 2472 // Add robots.txt reference