| @@ -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 |