PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
← All changes | includes/class-mxchat-content-generator.php +195 -37 3.2.5 → 3.2.25 View file →
@@ -731,10 +731,21 @@
731 731 */
732 732 private function plan_content($prompt, $content_type) {
733 733 $type_label = ($content_type === 'page') ? 'landing page' : 'blog post';
734 734
735 - $system_prompt = "You are a professional content strategist. The user wants to create a {$type_label}. Analyze their request and create a detailed content plan.\n\nYou MUST respond with ONLY valid JSON (no markdown, no code fences, no commentary). Use this exact structure:\n\n{\"title\": \"SEO-optimized title\", \"slug\": \"url-friendly-slug\", \"meta_description\": \"155 character meta description for SEO\", \"keywords\": [\"keyword1\", \"keyword2\", \"keyword3\", \"keyword4\", \"keyword5\"], \"links\": [{\"label\": \"Button or link text\", \"url\": \"https://example.com/page\"}], \"sections\": [{\"type\": \"hero\", \"heading\": \"Main heading\", \"subheading\": \"Supporting text\", \"needs_image\": true, \"image_prompt\": \"Detailed prompt for hero image\"}, {\"type\": \"content\", \"heading\": \"Section heading\", \"key_points\": [\"point 1\", \"point 2\"], \"needs_image\": true, \"image_prompt\": \"Detailed prompt for section image\"}, {\"type\": \"content\", \"heading\": \"Another section\", \"key_points\": [\"point 1\", \"point 2\"], \"needs_image\": false, \"image_prompt\": \"\"}, {\"type\": \"cta\", \"heading\": \"Call to action heading\", \"subheading\": \"CTA supporting text\", \"needs_image\": false, \"image_prompt\": \"\"}]}\n\nGuidelines:\n- Create 5-9 sections for blog posts, 7-11 for landing pages — add more if the user's request warrants extensive coverage\n- For landing pages, draw from sections like: hero, problem/solution, features, benefits, how-it-works, use cases, testimonials/social proof, pricing or comparison, FAQ, and final CTA — pick whichever fit the request\n- Each content section should include 3-5 substantive key_points (full descriptive sentences, not one-word labels) so the HTML generator has enough material to write meaningful copy\n- Include 2-4 sections that need images\n- Image prompts should be detailed, descriptive, and suitable for AI image generation\n- Image prompts should describe photorealistic or illustrative images relevant to the content\n- Keywords should be relevant long-tail SEO keywords\n- Title should be compelling and SEO-friendly\n- IMPORTANT: If the user specifies any URLs or links (for buttons, CTAs, navigation, etc.), you MUST capture every one of them in the \"links\" array with its label and exact URL. If no URLs are mentioned, use an empty array [].";
735 + // "Images per article" control (1–5, default 3). Drives how many sections the
736 + // planner marks needs_image:true. When image generation is off, force zero.
737 + $plan_options = get_option('mxchat_options', array());
738 + $images_enabled = ($plan_options['content_enable_images'] ?? 'on') === 'on';
739 + $image_count = max(1, min(5, (int) ($plan_options['content_image_count'] ?? 3)));
740 + if ($images_enabled) {
741 + $image_instruction = "- Select EXACTLY {$image_count} section(s) to illustrate: set \"needs_image\": true with a detailed \"image_prompt\" on exactly those {$image_count} section(s) and \"needs_image\": false with an empty \"image_prompt\" on every other section. Spread the illustrated sections across the article (favor the hero and the most visual content sections)";
742 + } else {
743 + $image_instruction = "- Set \"needs_image\": false and \"image_prompt\": \"\" on ALL sections (image generation is turned off for this site)";
744 + }
736 745
746 + $system_prompt = "You are a professional content strategist. The user wants to create a {$type_label}. Analyze their request and create a detailed content plan.\n\nYou MUST respond with ONLY valid JSON (no markdown, no code fences, no commentary). Use this exact structure:\n\n{\"title\": \"SEO-optimized title\", \"slug\": \"url-friendly-slug\", \"meta_description\": \"155 character meta description for SEO\", \"keywords\": [\"keyword1\", \"keyword2\", \"keyword3\", \"keyword4\", \"keyword5\"], \"links\": [{\"label\": \"Button or link text\", \"url\": \"https://example.com/page\"}], \"sections\": [{\"type\": \"hero\", \"heading\": \"Main heading\", \"subheading\": \"Supporting text\", \"needs_image\": true, \"image_prompt\": \"Detailed prompt for hero image\"}, {\"type\": \"content\", \"heading\": \"Section heading\", \"key_points\": [\"point 1\", \"point 2\"], \"needs_image\": true, \"image_prompt\": \"Detailed prompt for section image\"}, {\"type\": \"content\", \"heading\": \"Another section\", \"key_points\": [\"point 1\", \"point 2\"], \"needs_image\": false, \"image_prompt\": \"\"}, {\"type\": \"cta\", \"heading\": \"Call to action heading\", \"subheading\": \"CTA supporting text\", \"needs_image\": false, \"image_prompt\": \"\"}]}\n\nGuidelines:\n- Create 5-9 sections for blog posts, 7-11 for landing pages — add more if the user's request warrants extensive coverage\n- For landing pages, draw from sections like: hero, problem/solution, features, benefits, how-it-works, use cases, testimonials/social proof, pricing or comparison, FAQ, and final CTA — pick whichever fit the request\n- Each content section should include 3-5 substantive key_points (full descriptive sentences, not one-word labels) so the HTML generator has enough material to write meaningful copy\n{$image_instruction}\n- Image prompts should be detailed, descriptive, and suitable for AI image generation\n- Image prompts should describe photorealistic or illustrative images relevant to the content\n- Keywords should be relevant long-tail SEO keywords\n- Title should be compelling and SEO-friendly\n- IMPORTANT: If the user specifies any URLs or links (for buttons, CTAs, navigation, etc.), you MUST capture every one of them in the \"links\" array with its label and exact URL. If no URLs are mentioned, use an empty array [].";
747 +
737 748 $messages = array(
738 749 array('role' => 'user', 'content' => "Create a {$type_label} about: {$prompt}"),
739 750 );
740 751
@@ -788,8 +799,15 @@
788 799 );
789 800 }
790 801 }
791 802
803 + // Safety cap: never generate more than the user-configured "Images per article"
804 + // count (1–5, default 3), even if the planner over-marked needs_image sections.
805 + $image_count = max(1, min(5, (int) ($options['content_image_count'] ?? 3)));
806 + if (count($image_sections) > $image_count) {
807 + $image_sections = array_slice($image_sections, 0, $image_count);
808 + }
809 +
792 810 if (empty($image_sections)) {
793 811 return array();
794 812 }
795 813
@@ -978,9 +996,9 @@
978 996 'model' => 'gpt-image-1.5',
979 997 'prompt' => $prompt,
980 998 'n' => 1,
981 999 'size' => '1536x1024',
982 - 'quality' => 'medium',
1000 + 'quality' => $options['content_image_quality'] ?? 'auto',
983 1001 'output_format' => 'png',
984 1002 )),
985 1003 );
986 1004 }
@@ -1102,9 +1120,9 @@
1102 1120 'model' => 'gpt-image-1.5',
1103 1121 'prompt' => $prompt,
1104 1122 'n' => 1,
1105 1123 'size' => '1536x1024',
1106 - 'quality' => 'medium',
1124 + 'quality' => $options['content_image_quality'] ?? 'auto',
1107 1125 'output_format' => 'png',
1108 1126 );
1109 1127
1110 1128 $response = wp_remote_post('https://api.openai.com/v1/images/generations', array(
@@ -2224,11 +2242,25 @@
2224 2242
2225 2243 /**
2226 2244 * Call the configured content model
2227 2245 */
2246 + /**
2247 + * Provider slugs (MxChat_Model_Catalog keys) that call_content_model() can
2248 + * dispatch to. MUST mirror the if/elseif chain below — the Content Model
2249 + * picker derives its provider set from this list (plan ccb751), so a
2250 + * provider added to the dispatch without updating this is never offered,
2251 + * and one added here without dispatch support would break generation.
2252 + * OpenRouter and the custom OpenAI-compatible provider are absent on
2253 + * purpose: their pseudo-model ids would fall through to the OpenAI default
2254 + * branch with the wrong key and endpoint.
2255 + */
2256 + public static function supported_content_providers() {
2257 + return array('openai', 'claude', 'gemini', 'xai', 'deepseek');
2258 + }
2259 +
2228 2260 private function call_content_model($system_prompt, $messages, $max_tokens = 4096) {
2229 2261 $options = get_option('mxchat_options', array());
2230 - $model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.1-chat-latest';
2262 + $model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.6-sol';
2231 2263
2232 2264 // Determine provider from model name
2233 2265 if ($this->is_claude_model($model)) {
2234 2266 return $this->call_claude($model, $options['claude_api_key'] ?? '', $system_prompt, $messages, $max_tokens);
@@ -2246,8 +2278,24 @@
2246 2278
2247 2279 /**
2248 2280 * Call OpenAI-compatible API (OpenAI, xAI, DeepSeek)
2249 2281 */
2282 + /**
2283 + * plan-mxchat-20260714-dcb71c: frozen pre-dcb71c 'content' reasoning ladder,
2284 + * used only when the core model catalog is unavailable. Byte-identical to the
2285 + * old inline if/elseif at the call site.
2286 + */
2287 + private function mxchat_cg_reasoning_effort_fallback($model) {
2288 + if (strpos($model, 'gpt-5') !== 0 && strpos($model, 'gpt-6') !== 0) return null; // gpt-6 too (plan 45c22d)
2289 + if ($model === 'gpt-5.2' || $model === 'gpt-5.1-chat-latest') return null;
2290 + if ($model === 'gpt-5.1-2025-11-13') return 'low';
2291 + if ($model === 'gpt-5.5') return 'low';
2292 + if ($model === 'gpt-5.4') return 'low';
2293 + if (in_array($model, array('gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna'), true)) return 'low'; // plan 45c22d
2294 + if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
2295 + return 'minimal';
2296 + }
2297 +
2250 2298 private function call_openai_compatible($model, $api_key, $endpoint, $system_prompt, $messages, $max_tokens) {
2251 2299 if (empty($api_key)) {
2252 2300 return new WP_Error('no_api_key', __('API key not configured for the selected content model.', 'mxchat'));
2253 2301 }
@@ -2260,11 +2308,17 @@
2260 2308 'content' => $msg['content'] ?? '',
2261 2309 );
2262 2310 }
2263 2311
2264 - // GPT-5.x models require max_completion_tokens and only support temperature=1
2265 - $is_gpt5 = strpos($model, 'gpt-5') === 0;
2266 - $token_key = $is_gpt5 ? 'max_completion_tokens' : 'max_tokens';
2312 + // GPT-5.x models require max_completion_tokens and only support temperature=1.
2313 + // Token key, temperature gate, and reasoning_effort are sourced from the
2314 + // core model catalog (plan-dcb71c); frozen fallbacks preserve exact
2315 + // pre-dcb71c behavior if the catalog class is unavailable.
2316 + $is_gpt5 = strpos($model, 'gpt-5') === 0 || strpos($model, 'gpt-6') === 0; // gpt-6 too (plan 45c22d)
2317 + $catalog = class_exists('MxChat_Model_Catalog');
2318 + $token_key = ($catalog && method_exists('MxChat_Model_Catalog', 'openai_token_param'))
2319 + ? MxChat_Model_Catalog::openai_token_param($model)
2320 + : ($is_gpt5 ? 'max_completion_tokens' : 'max_tokens');
2267 2321
2268 2322 $body = array(
2269 2323 'model' => $model,
2270 2324 'messages' => $formatted,
@@ -2271,25 +2325,30 @@
2271 2325 $token_key => $max_tokens,
2272 2326 'stream' => false,
2273 2327 );
2274 2328
2275 - if (!$is_gpt5) {
2329 + $temp_ok = ($catalog && method_exists('MxChat_Model_Catalog', 'supports_temperature'))
2330 + ? MxChat_Model_Catalog::supports_temperature($model)
2331 + : !$is_gpt5;
2332 + if ($temp_ok) {
2276 2333 $body['temperature'] = 0.7;
2277 2334 }
2278 2335
2279 - // Add reasoning_effort only for GPT-5 models that support it
2280 - // gpt-5.2 and gpt-5.1-chat-latest don't support reasoning_effort parameter
2281 - if ($is_gpt5 && $model !== 'gpt-5.2' && $model !== 'gpt-5.1-chat-latest') {
2282 - // GPT-5.1 uses 'low' instead of 'minimal'
2283 - if ($model === 'gpt-5.1-2025-11-13') {
2284 - $body['reasoning_effort'] = 'low';
2285 - } elseif ($model === 'gpt-5.4') {
2286 - $body['reasoning_effort'] = 'low';
2287 - } else {
2288 - $body['reasoning_effort'] = 'minimal';
2289 - }
2336 + $effort = ($catalog && method_exists('MxChat_Model_Catalog', 'reasoning_effort_for'))
2337 + ? MxChat_Model_Catalog::reasoning_effort_for($model, 'content')
2338 + : $this->mxchat_cg_reasoning_effort_fallback($model);
2339 + if ($effort !== null) {
2340 + $body['reasoning_effort'] = $effort;
2290 2341 }
2291 2342
2343 + // DeepSeek V4 defaults to thinking mode ON (temperature ignored and
2344 + // reasoning eats the token budget); generation wants the legacy
2345 + // deepseek-chat semantics = non-thinking. Endpoint-gated because this
2346 + // helper is shared with OpenAI and xAI.
2347 + if (strpos($endpoint, 'api.deepseek.com') !== false) {
2348 + $body['thinking'] = array('type' => 'disabled');
2349 + }
2350 +
2292 2351 // Scale timeout with token count — large generation calls need more time
2293 2352 $timeout = ($max_tokens > 8000) ? 300 : 120;
2294 2353
2295 2354 $response = wp_remote_post($endpoint, array(
@@ -2307,8 +2366,38 @@
2307 2366
2308 2367 $status_code = wp_remote_retrieve_response_code($response);
2309 2368 $decoded = json_decode(wp_remote_retrieve_body($response), true);
2310 2369
2370 + // plan-25b972 self-heal: supported reasoning_effort VALUES are per-model;
2371 + // a 400 rejecting the value we sent (stale catalog entry / provider
2372 + // drift) is deterministic — strip the param and retry ONCE.
2373 + if ($status_code === 400
2374 + && isset($body['reasoning_effort'])
2375 + && isset($decoded['error']['message'])
2376 + && is_string($decoded['error']['message'])
2377 + && preg_match('/Unsupported value:.*reasoning_effort/i', $decoded['error']['message'])) {
2378 + if (defined('WP_DEBUG') && WP_DEBUG) {
2379 + error_log(sprintf(
2380 + '[MxChat] content-generator: model %s rejected reasoning_effort \'%s\' — retrying once without the param (plan-25b972).',
2381 + $model, $body['reasoning_effort']
2382 + ));
2383 + }
2384 + unset($body['reasoning_effort']);
2385 + $response = wp_remote_post($endpoint, array(
2386 + 'headers' => array(
2387 + 'Authorization' => 'Bearer ' . $api_key,
2388 + 'Content-Type' => 'application/json',
2389 + ),
2390 + 'body' => wp_json_encode($body),
2391 + 'timeout' => $timeout,
2392 + ));
2393 + if (is_wp_error($response)) {
2394 + return $response;
2395 + }
2396 + $status_code = wp_remote_retrieve_response_code($response);
2397 + $decoded = json_decode(wp_remote_retrieve_body($response), true);
2398 + }
2399 +
2311 2400 if ($status_code !== 200) {
2312 2401 $error_msg = $decoded['error']['message'] ?? __('API request failed with status ', 'mxchat') . $status_code;
2313 2402 return new WP_Error('api_error', $error_msg);
2314 2403 }
@@ -2323,8 +2412,11 @@
2323 2412 /**
2324 2413 * Call Claude (Anthropic) API
2325 2414 */
2326 2415 private function call_claude($model, $api_key, $system_prompt, $messages, $max_tokens) {
2416 + // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
2417 + // Read-time rescue: a saved retired id is remapped to the catalog's replacement before the API call.
2418 + $model = MxChat_Model_Catalog::resolve_retired($model); // plan 8c4eb4: the catalog's one map
2327 2419 if (empty($api_key)) {
2328 2420 return new WP_Error('no_api_key', __('Claude API key not configured.', 'mxchat'));
2329 2421 }
2330 2422
@@ -2344,11 +2436,25 @@
2344 2436 'model' => $model,
2345 2437 'max_tokens' => $max_tokens,
2346 2438 'temperature' => 0.7,
2347 2439 'messages' => $formatted,
2348 - 'system' => $system_prompt,
2440 + // Prompt-cache breakpoint (plan 1ff43b): generation pipelines reuse
2441 + // the same system prompt across consecutive requests within the
2442 + // 5-minute cache window. Silently no-ops below the model minimum.
2443 + 'system' => trim((string) $system_prompt) === '' ? $system_prompt : array(
2444 + array('type' => 'text', 'text' => $system_prompt, 'cache_control' => array('type' => 'ephemeral')),
2445 + ),
2349 2446 );
2350 2447
2448 + // Anthropic removed temperature on Opus 4.7+ flagships (400 if sent) —
2449 + // sourced from the core model catalog (plan-dcb71c); frozen list fallback.
2450 + $omit_temp = (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'supports_temperature'))
2451 + ? !MxChat_Model_Catalog::supports_temperature($model)
2452 + : in_array($model, array('claude-opus-5', 'claude-opus-4-7', 'claude-opus-4-8', 'claude-fable-5', 'claude-sonnet-5'), true);
2453 + if ($omit_temp) {
2454 + unset($body['temperature']);
2455 + }
2456 +
2351 2457 $timeout = ($max_tokens > 8000) ? 300 : 120;
2352 2458
2353 2459 $response = wp_remote_post('https://api.anthropic.com/v1/messages', array(
2354 2460 'headers' => array(
@@ -2371,10 +2477,16 @@
2371 2477 $error_msg = $decoded['error']['message'] ?? __('Claude API error ', 'mxchat') . $status_code;
2372 2478 return new WP_Error('claude_error', $error_msg);
2373 2479 }
2374 2480
2375 - if (isset($decoded['content'][0]['text'])) {
2376 - return trim($decoded['content'][0]['text']);
2481 + // claude-fable-5 prepends a thinking block to content — take the
2482 + // first TEXT block, not content[0].
2483 + if (isset($decoded['content']) && is_array($decoded['content'])) {
2484 + foreach ($decoded['content'] as $block) {
2485 + if (isset($block['type'], $block['text']) && $block['type'] === 'text') {
2486 + return trim($block['text']);
2487 + }
2488 + }
2377 2489 }
2378 2490
2379 2491 return new WP_Error('unexpected_response', __('Unexpected Claude response format.', 'mxchat'));
2380 2492 }
@@ -2383,8 +2495,11 @@
2383 2495 /**
2384 2496 * Call Gemini API
2385 2497 */
2386 2498 private function call_gemini($model, $api_key, $system_prompt, $messages, $max_tokens) {
2499 + if ($model === 'gemini-3-pro-preview') {
2500 + $model = 'gemini-3.1-pro-preview';
2501 + }
2387 2502 if (empty($api_key)) {
2388 2503 return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat'));
2389 2504 }
2390 2505
@@ -2440,12 +2555,29 @@
2440 2555 $error_msg = $decoded['error']['message'] ?? __('Gemini API error ', 'mxchat') . $status_code;
2441 2556 return new WP_Error('gemini_error', $error_msg);
2442 2557 }
2443 2558
2444 - if (isset($decoded['candidates'][0]['content']['parts'][0]['text'])) {
2445 - return trim($decoded['candidates'][0]['content']['parts'][0]['text']);
2559 + // plan 781afd: join every text part (skip thought parts), not only parts[0].
2560 + $text = '';
2561 + if (isset($decoded['candidates'][0]['content']['parts']) && is_array($decoded['candidates'][0]['content']['parts'])) {
2562 + foreach ($decoded['candidates'][0]['content']['parts'] as $part) {
2563 + if (is_array($part) && isset($part['text']) && is_string($part['text']) && empty($part['thought'])) {
2564 + $text .= $part['text'];
2565 + }
2566 + }
2446 2567 }
2568 + $text = trim($text);
2569 + if ($text !== '') {
2570 + return $text;
2571 + }
2447 2572
2573 + $no_text_reason = isset($decoded['candidates'][0]['finishReason']) && is_string($decoded['candidates'][0]['finishReason'])
2574 + ? $decoded['candidates'][0]['finishReason']
2575 + : (isset($decoded['promptFeedback']['blockReason']) && is_string($decoded['promptFeedback']['blockReason']) ? $decoded['promptFeedback']['blockReason'] : '');
2576 + if ($no_text_reason !== '') {
2577 + /* translators: %s: Gemini's finish or block reason, e.g. SAFETY */
2578 + return new WP_Error('gemini_no_text', sprintf(__('Gemini returned no text (%s).', 'mxchat'), $no_text_reason));
2579 + }
2448 2580 return new WP_Error('unexpected_response', __('Unexpected Gemini response format.', 'mxchat'));
2449 2581 }
2450 2582
2451 2583 // ─── Model Detection Helpers ────────────────────────────────────────
@@ -2481,9 +2613,9 @@
2481 2613
2482 2614 $field = sanitize_text_field($_POST['field'] ?? '');
2483 2615 $value = sanitize_text_field($_POST['value'] ?? '');
2484 2616
2485 - $allowed_fields = array('content_model', 'content_image_model', 'content_enable_images', 'content_use_placeholders', 'content_internal_linking', 'content_tool_use', 'seo_optimize_meta_desc', 'seo_optimize_seo_title', 'seo_optimize_slug', 'seo_optimize_readability', 'seo_optimize_internal_links', 'seo_optimize_img_alt', 'seo_optimize_featured_img');
2617 + $allowed_fields = array('content_model', 'content_image_model', 'content_image_quality', 'content_image_count', 'content_enable_images', 'content_use_placeholders', 'content_internal_linking', 'content_tool_use', 'seo_optimize_meta_desc', 'seo_optimize_seo_title', 'seo_optimize_slug', 'seo_optimize_readability', 'seo_optimize_internal_links', 'seo_optimize_img_alt', 'seo_optimize_featured_img');
2486 2618 if (!in_array($field, $allowed_fields, true)) {
2487 2619 wp_send_json_error(array('message' => __('Invalid field.', 'mxchat')));
2488 2620 }
2489 2621
@@ -2491,8 +2623,18 @@
2491 2623 if (in_array($field, array('content_enable_images', 'content_use_placeholders', 'content_internal_linking', 'content_tool_use', 'seo_optimize_meta_desc', 'seo_optimize_seo_title', 'seo_optimize_slug', 'seo_optimize_readability', 'seo_optimize_internal_links', 'seo_optimize_img_alt', 'seo_optimize_featured_img'), true)) {
2492 2624 $value = ($value === 'on') ? 'on' : 'off';
2493 2625 }
2494 2626
2627 + // Enum field: image quality
2628 + if ($field === 'content_image_quality') {
2629 + $value = in_array($value, array('auto', 'low', 'medium', 'high'), true) ? $value : 'auto';
2630 + }
2631 +
2632 + // Integer field: images per article (clamp 1–5)
2633 + if ($field === 'content_image_count') {
2634 + $value = (string) max(1, min(5, (int) $value));
2635 + }
2636 +
2495 2637 $options = get_option('mxchat_options', array());
2496 2638 $options[$field] = $value;
2497 2639 update_option('mxchat_options', $options);
2498 2640
@@ -3234,23 +3376,39 @@
3234 3376 array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'),
3235 3377 );
3236 3378 }
3237 3379
3238 - // When sorting by a meta field, ensure meta_key is set for ordering.
3239 - // For 'all' filter, include posts without the meta key via OR clause.
3380 + // When sorting by a meta field, posts WITHOUT the key must stay in the
3381 + // list — GSC meta is no longer zero-filled for no-data posts (plan
3382 + // 282c80), and score meta never existed for unanalyzed posts. A single
3383 + // OR EXISTS/NOT-EXISTS meta_query cannot order this reliably (under an
3384 + // OR relation WP_Meta_Query leaves the orderby clause's join alias
3385 + // unconstrained by meta_key, so ORDER BY reads arbitrary meta rows).
3386 + // Instead: two id-only queries — posts WITH the key sorted by its
3387 + // value, then posts WITHOUT it appended (newest first). Missing-data
3388 + // rows deliberately sort last in BOTH directions.
3240 3389 if ($sort_meta_key) {
3241 - $args['meta_key'] = $sort_meta_key;
3242 - if ($filter === 'all') {
3243 - $args['meta_query'] = array(
3244 - 'relation' => 'OR',
3245 - array('key' => $sort_meta_key, 'compare' => 'EXISTS'),
3246 - array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS'),
3247 - );
3390 + $with_args = $args;
3391 + $with_args['meta_key'] = $sort_meta_key;
3392 + $with_args['orderby'] = 'meta_value_num';
3393 + $q_with = new \WP_Query($with_args);
3394 +
3395 + $without_args = $args;
3396 + $missing_clause = array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS');
3397 + if (!empty($without_args['meta_query'])) {
3398 + $without_args['meta_query'] = array('relation' => 'AND', $without_args['meta_query'], $missing_clause);
3399 + } else {
3400 + $without_args['meta_query'] = array($missing_clause);
3248 3401 }
3402 + $without_args['orderby'] = 'date';
3403 + $without_args['order'] = 'DESC';
3404 + $q_without = new \WP_Query($without_args);
3405 +
3406 + $all_ids = array_merge($q_with->posts, $q_without->posts);
3407 + } else {
3408 + $query = new \WP_Query($args);
3409 + $all_ids = $query->posts;
3249 3410 }
3250 -
3251 - $query = new \WP_Query($args);
3252 - $all_ids = $query->posts;
3253 3411 $total = count($all_ids);
3254 3412 $pages = max(1, ceil($total / $per_page));
3255 3413 $page = min($page, $pages);
3256 3414 $offset = ($page - 1) * $per_page;