| @@ -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 | } |
| @@ -1232,12 +1259,11 @@ | ||
| 1232 | 1259 | } |
| 1233 | 1260 | |
| 1234 | 1261 | if (null !== $content) { |
| 1235 | 1262 | // 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 | - } | |
| 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); | |
| 1240 | 1266 | } |
| 1241 | 1267 | |
| 1242 | 1268 | // Cache the result for 5 minutes to improve performance |
| 1243 | 1269 | set_transient($cache_key, $status, 5 * MINUTE_IN_SECONDS); |
| @@ -1573,11 +1599,20 @@ | ||
| 1573 | 1599 | } |
| 1574 | 1600 | |
| 1575 | 1601 | // Check key features quality |
| 1576 | 1602 | if (!empty($user_input['key_features'])) { |
| 1577 | - $features = explode("\n", $user_input['key_features']); | |
| 1578 | - $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'])); | |
| 1579 | 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 | + | |
| 1580 | 1615 | if ($feature_count < 3) { |
| 1581 | 1616 | $validation['warnings'][] = 'Consider adding more key features (3-8 recommended) for comprehensive AI understanding'; |
| 1582 | 1617 | $validation['score'] -= 10; |
| 1583 | 1618 | } elseif ($feature_count > 10) { |
| @@ -2038,9 +2073,9 @@ | ||
| 2038 | 2073 | ], |
| 2039 | 2074 | 'key_features' => [ |
| 2040 | 2075 | 'type' => 'string', |
| 2041 | 2076 | 'title' => 'Key Features', |
| 2042 | - '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.', | |
| 2043 | 2078 | 'default' => '', |
| 2044 | 2079 | 'maxLength' => 500 |
| 2045 | 2080 | ], |
| 2046 | 2081 | 'target_audience' => [ |
| @@ -2148,8 +2183,243 @@ | ||
| 2148 | 2183 | return "> " . $description . "\n\n"; |
| 2149 | 2184 | } |
| 2150 | 2185 | |
| 2151 | 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 | + /** | |
| 2152 | 2422 | * Build additional details section |
| 2153 | 2423 | * |
| 2154 | 2424 | * @since 1.0.0 |
| 2155 | 2425 | * |
| @@ -2167,18 +2437,10 @@ | ||
| 2167 | 2437 | } |
| 2168 | 2438 | |
| 2169 | 2439 | if (!empty($key_features)) { |
| 2170 | 2440 | $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 | - } | |
| 2441 | + foreach (self::split_key_features($key_features) as $feature) { | |
| 2442 | + $content .= "- " . $feature . "\n"; | |
| 2181 | 2443 | } |
| 2182 | 2444 | $content .= "\n"; |
| 2183 | 2445 | } |
| 2184 | 2446 | |
| @@ -2212,9 +2474,9 @@ | ||
| 2212 | 2474 | $content .= "- [Technical Stack]({$website_url}): Built with {$stack}\n"; |
| 2213 | 2475 | } |
| 2214 | 2476 | |
| 2215 | 2477 | if (!empty($user_input['development_approach'])) { |
| 2216 | - $approach_summary = wp_trim_words($user_input['development_approach'], 10); | |
| 2478 | + $approach_summary = \ThinkRank\Core\Seo_Text::trim_words($user_input['development_approach'], 10); | |
| 2217 | 2479 | $content .= "- [Development Guidelines]({$website_url}): {$approach_summary}\n"; |
| 2218 | 2480 | } |
| 2219 | 2481 | |
| 2220 | 2482 | // Add robots.txt reference |