| @@ -32,10 +32,8 @@ | ||
| 32 | 32 | add_action('wp_ajax_mxchat_seo_analyze', array($this, 'handle_seo_analyze')); |
| 33 | 33 | add_action('wp_ajax_mxchat_seo_analyze_batch', array($this, 'handle_seo_analyze_batch')); |
| 34 | 34 | add_action('wp_ajax_mxchat_seo_suggest', array($this, 'handle_seo_suggest')); |
| 35 | 35 | add_action('wp_ajax_mxchat_seo_list_posts', array($this, 'handle_seo_list_posts')); |
| 36 | - add_action('wp_ajax_mxchat_get_default_prompt', array($this, 'handle_get_default_prompt')); | |
| 37 | - add_action('wp_ajax_mxchat_save_custom_prompt', array($this, 'handle_save_custom_prompt')); | |
| 38 | 36 | |
| 39 | 37 | // Background generation via loopback (nopriv because loopback doesn't carry cookies — auth via secret token) |
| 40 | 38 | add_action('wp_ajax_nopriv_mxchat_generate_content_background', array($this, 'handle_generate_content_background')); |
| 41 | 39 | add_action('wp_ajax_mxchat_generate_content_background', array($this, 'handle_generate_content_background')); |
| @@ -103,10 +101,12 @@ | ||
| 103 | 101 | // ─── Frontend CSS Injection ────────────────────────────────────── |
| 104 | 102 | |
| 105 | 103 | /** |
| 106 | 104 | * Inject generated CSS into <head> on the frontend. |
| 107 | - * Legacy fallback for posts created before CSS was embedded in post_content. | |
| 108 | - * New posts (with mxchat-css CSS comment marker) skip this entirely. | |
| 105 | + * Fires on wp_head for any singular post/page that was created | |
| 106 | + * by the content generator. CSS is stored in post meta to avoid | |
| 107 | + * issues with wp_kses, wpautop, and wptexturize mangling styles | |
| 108 | + * stored in post_content. | |
| 109 | 109 | */ |
| 110 | 110 | public function inject_generated_css() { |
| 111 | 111 | if (!is_singular()) { |
| 112 | 112 | return; |
| @@ -121,23 +121,54 @@ | ||
| 121 | 121 | if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') { |
| 122 | 122 | return; |
| 123 | 123 | } |
| 124 | 124 | |
| 125 | - // New-format posts have CSS embedded in post_content — skip injection | |
| 126 | - $post = get_post($post_id); | |
| 127 | - if ($post && strpos($post->post_content, '/* mxchat-css */') !== false) { | |
| 128 | - return; | |
| 129 | - } | |
| 130 | - | |
| 131 | - // Legacy fallback: inject via wp_head as before | |
| 132 | 125 | $ai_css = get_post_meta($post_id, '_mxchat_content_css', true); |
| 133 | 126 | $fullwidth = get_post_meta($post_id, '_mxchat_fullwidth', true) === '1'; |
| 134 | 127 | $hide_title = get_post_meta($post_id, '_mxchat_hide_title', true) === '1'; |
| 135 | 128 | |
| 129 | + // Output the combined CSS | |
| 136 | 130 | echo $this->get_generated_css($fullwidth, $ai_css); |
| 137 | 131 | |
| 132 | + // Hide the WordPress post/page title if configured. | |
| 133 | + // Covers: generic WP, Astra, GeneratePress, Kadence, OceanWP, Neve, | |
| 134 | + // Hello Elementor, Bricks, Divi, Blocksy, and common starter themes. | |
| 138 | 135 | if ($hide_title) { |
| 139 | - echo '<style>' . $this->get_title_hide_css() . '</style>' . "\n"; | |
| 136 | + echo '<style> | |
| 137 | +.entry-title, | |
| 138 | +.page-title, | |
| 139 | +.post-title, | |
| 140 | +.wp-block-post-title, | |
| 141 | +/* Astra */ | |
| 142 | +.ast-title-with-post-meta-wrapper, | |
| 143 | +.ast-the-title, | |
| 144 | +/* GeneratePress */ | |
| 145 | +.generate-page-header .page-hero, | |
| 146 | +.entry-header .entry-title, | |
| 147 | +/* Kadence */ | |
| 148 | +.entry-hero .entry-title, | |
| 149 | +.kadence-page-title, | |
| 150 | +.wp-site-blocks .entry-title, | |
| 151 | +/* OceanWP */ | |
| 152 | +.ocean-single-post-header, | |
| 153 | +.page-header, | |
| 154 | +/* Neve */ | |
| 155 | +.nv-page-title-wrap, | |
| 156 | +.nv-post-title, | |
| 157 | +/* Hello Elementor */ | |
| 158 | +.elementor-page-title, | |
| 159 | +/* Divi */ | |
| 160 | +.et_pb_title_container .entry-title, | |
| 161 | +/* Bricks */ | |
| 162 | +.brxe-post-title, | |
| 163 | +/* Blocksy */ | |
| 164 | +[data-hero] .page-title, | |
| 165 | +.hero-section .page-title, | |
| 166 | +/* Generic header containers */ | |
| 167 | +.entry-header { | |
| 168 | + display: none !important; | |
| 169 | +} | |
| 170 | +</style>' . "\n"; | |
| 140 | 171 | } |
| 141 | 172 | } |
| 142 | 173 | |
| 143 | 174 | /** |
| @@ -289,10 +320,8 @@ | ||
| 289 | 320 | $post_status = sanitize_text_field($_POST['post_status'] ?? 'draft'); |
| 290 | 321 | $schedule_date = sanitize_text_field($_POST['schedule_date'] ?? ''); |
| 291 | 322 | $layout = sanitize_text_field($_POST['layout'] ?? 'fullwidth'); |
| 292 | 323 | $title_display = sanitize_text_field($_POST['title_display'] ?? 'hide'); |
| 293 | - $template_mode = sanitize_text_field($_POST['template_mode'] ?? 'off'); | |
| 294 | - $custom_system_prompt = wp_unslash($_POST['custom_system_prompt'] ?? ''); | |
| 295 | 324 | |
| 296 | 325 | if (empty($prompt)) { |
| 297 | 326 | wp_send_json_error(array('message' => __('Please enter a prompt.', 'mxchat'))); |
| 298 | 327 | } |
| @@ -320,10 +349,8 @@ | ||
| 320 | 349 | 'post_status' => $post_status, |
| 321 | 350 | 'schedule_date' => $schedule_date, |
| 322 | 351 | 'layout' => $layout, |
| 323 | 352 | 'title_display' => $title_display, |
| 324 | - 'template_mode' => $template_mode, | |
| 325 | - 'custom_system_prompt' => $custom_system_prompt, | |
| 326 | 353 | ); |
| 327 | 354 | set_transient($progress_key . '_params', $params, 600); |
| 328 | 355 | |
| 329 | 356 | // Send JSON response to the browser immediately, then continue |
| @@ -423,10 +450,8 @@ | ||
| 423 | 450 | $post_status = $params['post_status']; |
| 424 | 451 | $schedule_date = $params['schedule_date']; |
| 425 | 452 | $layout = $params['layout']; |
| 426 | 453 | $title_display = $params['title_display']; |
| 427 | - $template_mode = $params['template_mode'] ?? 'off'; | |
| 428 | - $custom_system_prompt = $params['custom_system_prompt'] ?? ''; | |
| 429 | 454 | |
| 430 | 455 | $this->update_progress($progress_key, 'planning', __('Planning content structure...', 'mxchat'), 10); |
| 431 | 456 | |
| 432 | 457 | // Step 1: Plan the content |
| @@ -443,9 +468,9 @@ | ||
| 443 | 468 | |
| 444 | 469 | $this->update_progress($progress_key, 'writing', __('Writing full content...', 'mxchat'), 60); |
| 445 | 470 | |
| 446 | 471 | // Step 3: Generate full HTML content |
| 447 | - $html_content = $this->generate_html_content($plan, $image_urls, $content_type, $prompt, $custom_system_prompt); | |
| 472 | + $html_content = $this->generate_html_content($plan, $image_urls, $content_type, $prompt); | |
| 448 | 473 | if (is_wp_error($html_content)) { |
| 449 | 474 | $this->update_progress($progress_key, 'error', $html_content->get_error_message(), 0); |
| 450 | 475 | return; |
| 451 | 476 | } |
| @@ -452,31 +477,24 @@ | ||
| 452 | 477 | |
| 453 | 478 | $this->update_progress($progress_key, 'creating', __('Creating WordPress post...', 'mxchat'), 85); |
| 454 | 479 | |
| 455 | 480 | // Step 4: Create the WordPress post/page |
| 456 | - // Extract AI CSS — saved to post meta for edit workflow, and embedded in post_content for portability | |
| 481 | + // Extract AI CSS before sanitization — stored in post meta, injected via wp_head | |
| 457 | 482 | $ai_css = $this->extract_css($html_content); |
| 458 | 483 | $html_without_style = preg_replace('/<style[^>]*>.*?<\/style>/is', '', $html_content); |
| 459 | 484 | $sanitized_html = $this->sanitize_generated_html($html_without_style); |
| 460 | 485 | |
| 461 | - // Build self-contained <style> block (all layers) and prepend to HTML | |
| 462 | - $is_fullwidth = ($layout === 'fullwidth'); | |
| 463 | - $is_hide_title = ($title_display === 'hide'); | |
| 464 | - $embedded_css = $this->build_embedded_css($ai_css, $is_fullwidth, $is_hide_title); | |
| 465 | - $full_content = $embedded_css . $sanitized_html; | |
| 466 | - | |
| 467 | 486 | $post_args = array( |
| 468 | 487 | 'post_title' => sanitize_text_field($plan['title']), |
| 469 | - 'post_content' => $full_content, | |
| 488 | + 'post_content' => $sanitized_html, | |
| 470 | 489 | 'post_status' => $post_status, |
| 471 | 490 | 'post_type' => $content_type === 'page' ? 'page' : 'post', |
| 472 | 491 | 'meta_input' => array( |
| 473 | - '_mxchat_generated' => '1', | |
| 474 | - '_mxchat_prompt' => $prompt, | |
| 475 | - '_mxchat_fullwidth' => ($layout === 'fullwidth') ? '1' : '0', | |
| 476 | - '_mxchat_hide_title' => ($title_display === 'hide') ? '1' : '0', | |
| 477 | - '_mxchat_content_css' => $ai_css, | |
| 478 | - '_mxchat_template_mode' => ($template_mode === 'on') ? '1' : '0', | |
| 492 | + '_mxchat_generated' => '1', | |
| 493 | + '_mxchat_prompt' => $prompt, | |
| 494 | + '_mxchat_fullwidth' => ($layout === 'fullwidth') ? '1' : '0', | |
| 495 | + '_mxchat_hide_title' => ($title_display === 'hide') ? '1' : '0', | |
| 496 | + '_mxchat_content_css' => $ai_css, | |
| 479 | 497 | ), |
| 480 | 498 | ); |
| 481 | 499 | |
| 482 | 500 | // Handle scheduled posts |
| @@ -511,14 +529,8 @@ | ||
| 511 | 529 | if (!empty($image_ids)) { |
| 512 | 530 | update_post_meta($post_id, '_mxchat_image_ids', $image_ids); |
| 513 | 531 | } |
| 514 | 532 | |
| 515 | - // Sync the inline `<img alt="...">` text from the generated body HTML | |
| 516 | - // into each attachment's _wp_attachment_image_alt postmeta so RankMath, | |
| 517 | - // schema markup, and the media library all see the alt text — not just | |
| 518 | - // the inline body HTML. (plan-mxchat-20260510-2be2da) | |
| 519 | - $this->sync_image_alts_from_content($full_content, $image_urls); | |
| 520 | - | |
| 521 | 533 | // Apply fullwidth/title settings via theme-specific post meta |
| 522 | 534 | $this->apply_layout_settings($post_id, $layout, $title_display); |
| 523 | 535 | |
| 524 | 536 | // Step 5: Fill SEO metadata |
| @@ -593,18 +605,14 @@ | ||
| 593 | 605 | |
| 594 | 606 | // Build the edit prompt — send both CSS and HTML so AI can edit either |
| 595 | 607 | $system_prompt = "You are a content editor. You have an existing page that uses a CSS-first approach: a <style> block with mxg- prefixed classes followed by clean HTML.\n\nApply ONLY the requested change and return the complete updated output. Do not add commentary — return ONLY the updated <style> block + HTML.\n\nIMPORTANT RULES:\n- Keep the same overall structure\n- Only change what the user specifically asks for\n- Return the complete output (not just the changed part)\n- If the user asks to change the title, update the <h1> in the HTML\n- Maintain the <style> block — update CSS rules if the edit requires style changes\n- ALL class names must use the mxg- prefix\n- Do NOT add inline styles — all styling stays in the <style> block\n- Do NOT include HTML comments\n- Do NOT generate a <header>, <footer>, or <nav>"; |
| 596 | 608 | |
| 597 | - // Strip embedded boilerplate CSS from post_content (Layer 1/2 + marker) | |
| 598 | - // so the AI only sees the clean AI CSS + HTML | |
| 599 | - $clean_content = preg_replace('/<style>\/\* mxchat-css \*\/.*?<\/style>\s*/is', '', $current_content); | |
| 600 | - | |
| 601 | - // Reconstruct: prepend only the AI CSS (from meta) + clean HTML | |
| 609 | + // Reconstruct the full content (CSS + HTML) for the AI to edit | |
| 602 | 610 | $full_content_for_ai = ''; |
| 603 | 611 | if (!empty($current_css)) { |
| 604 | 612 | $full_content_for_ai = "<style>\n" . $current_css . "\n</style>\n"; |
| 605 | 613 | } |
| 606 | - $full_content_for_ai .= $clean_content; | |
| 614 | + $full_content_for_ai .= $current_content; | |
| 607 | 615 | |
| 608 | 616 | $user_message = "Current post title: " . $current_title . "\n\nCurrent content (style block + HTML):\n" . $full_content_for_ai . "\n\nUser edit request: " . $edit_instruction; |
| 609 | 617 | |
| 610 | 618 | $messages = array( |
| @@ -637,19 +645,13 @@ | ||
| 637 | 645 | if (preg_match('/<h1[^>]*>(.*?)<\/h1>/is', $result, $title_match)) { |
| 638 | 646 | $new_title = wp_strip_all_tags($title_match[1]); |
| 639 | 647 | } |
| 640 | 648 | |
| 641 | - // Extract CSS → meta (for edit workflow), embed full CSS in post_content (for portability) | |
| 649 | + // Extract CSS → meta, sanitize HTML → post_content | |
| 642 | 650 | $ai_css = $this->extract_css($result); |
| 643 | 651 | $html_without_style = preg_replace('/<style[^>]*>.*?<\/style>/is', '', $result); |
| 644 | 652 | $sanitized_html = $this->sanitize_generated_html($html_without_style); |
| 645 | 653 | |
| 646 | - // Re-embed all CSS layers into post_content | |
| 647 | - $fullwidth = get_post_meta($post_id, '_mxchat_fullwidth', true) === '1'; | |
| 648 | - $hide_title = get_post_meta($post_id, '_mxchat_hide_title', true) === '1'; | |
| 649 | - $embedded_css = $this->build_embedded_css($ai_css, $fullwidth, $hide_title); | |
| 650 | - $full_content = $embedded_css . $sanitized_html; | |
| 651 | - | |
| 652 | 654 | // Clear invalid page template meta that causes "Invalid page template" errors |
| 653 | 655 | $current_template = get_post_meta($post_id, '_wp_page_template', true); |
| 654 | 656 | if (!empty($current_template) && $current_template !== 'default') { |
| 655 | 657 | $theme_templates = wp_get_theme()->get_page_templates(get_post($post_id)); |
| @@ -657,16 +659,16 @@ | ||
| 657 | 659 | delete_post_meta($post_id, '_wp_page_template'); |
| 658 | 660 | } |
| 659 | 661 | } |
| 660 | 662 | |
| 661 | - // Update the post content (CSS + HTML) and meta | |
| 663 | + // Update the post content (HTML only) | |
| 662 | 664 | $update_result = wp_update_post(array( |
| 663 | 665 | 'ID' => $post_id, |
| 664 | 666 | 'post_title' => sanitize_text_field($new_title), |
| 665 | - 'post_content' => $full_content, | |
| 667 | + 'post_content' => $sanitized_html, | |
| 666 | 668 | ), true); |
| 667 | 669 | |
| 668 | - // Keep CSS in meta for edit workflow reconstruction | |
| 670 | + // Update CSS in meta (injected via wp_head) | |
| 669 | 671 | update_post_meta($post_id, '_mxchat_content_css', $ai_css); |
| 670 | 672 | |
| 671 | 673 | if (is_wp_error($update_result)) { |
| 672 | 674 | wp_send_json_error(array('message' => $update_result->get_error_message())); |
| @@ -731,21 +733,10 @@ | ||
| 731 | 733 | */ |
| 732 | 734 | private function plan_content($prompt, $content_type) { |
| 733 | 735 | $type_label = ($content_type === 'page') ? 'landing page' : 'blog post'; |
| 734 | 736 | |
| 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 | - } | |
| 737 | + $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-8 sections for blog posts, 4-6 for landing pages\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 []."; | |
| 745 | 738 | |
| 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 | - | |
| 748 | 739 | $messages = array( |
| 749 | 740 | array('role' => 'user', 'content' => "Create a {$type_label} about: {$prompt}"), |
| 750 | 741 | ); |
| 751 | 742 | |
| @@ -799,15 +790,8 @@ | ||
| 799 | 790 | ); |
| 800 | 791 | } |
| 801 | 792 | } |
| 802 | 793 | |
| 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 | - | |
| 810 | 794 | if (empty($image_sections)) { |
| 811 | 795 | return array(); |
| 812 | 796 | } |
| 813 | 797 | |
| @@ -996,9 +980,9 @@ | ||
| 996 | 980 | 'model' => 'gpt-image-1.5', |
| 997 | 981 | 'prompt' => $prompt, |
| 998 | 982 | 'n' => 1, |
| 999 | 983 | 'size' => '1536x1024', |
| 1000 | - 'quality' => $options['content_image_quality'] ?? 'auto', | |
| 984 | + 'quality' => 'medium', | |
| 1001 | 985 | 'output_format' => 'png', |
| 1002 | 986 | )), |
| 1003 | 987 | ); |
| 1004 | 988 | } |
| @@ -1120,9 +1104,9 @@ | ||
| 1120 | 1104 | 'model' => 'gpt-image-1.5', |
| 1121 | 1105 | 'prompt' => $prompt, |
| 1122 | 1106 | 'n' => 1, |
| 1123 | 1107 | 'size' => '1536x1024', |
| 1124 | - 'quality' => $options['content_image_quality'] ?? 'auto', | |
| 1108 | + 'quality' => 'medium', | |
| 1125 | 1109 | 'output_format' => 'png', |
| 1126 | 1110 | ); |
| 1127 | 1111 | |
| 1128 | 1112 | $response = wp_remote_post('https://api.openai.com/v1/images/generations', array( |
| @@ -1360,9 +1344,9 @@ | ||
| 1360 | 1344 | |
| 1361 | 1345 | /** |
| 1362 | 1346 | * Step 3: Generate the full HTML content |
| 1363 | 1347 | */ |
| 1364 | - private function generate_html_content($plan, $image_urls, $content_type, $original_prompt = '', $custom_system_prompt = '') { | |
| 1348 | + private function generate_html_content($plan, $image_urls, $content_type, $original_prompt = '') { | |
| 1365 | 1349 | $type_label = ($content_type === 'page') ? 'landing page' : 'blog post'; |
| 1366 | 1350 | $has_images = !empty($image_urls); |
| 1367 | 1351 | |
| 1368 | 1352 | // Build image URL map by section index |
| @@ -1401,17 +1385,9 @@ | ||
| 1401 | 1385 | } |
| 1402 | 1386 | $image_reference .= "=== END IMAGE URLS — Do NOT use any other image URLs ===\n"; |
| 1403 | 1387 | } |
| 1404 | 1388 | |
| 1405 | - // Check for custom prompt: passed directly, or saved in DB | |
| 1406 | - if (empty($custom_system_prompt)) { | |
| 1407 | - $option_key = 'mxchat_custom_prompt_' . ($content_type === 'page' ? 'page' : 'post'); | |
| 1408 | - $custom_system_prompt = get_option($option_key, ''); | |
| 1409 | - } | |
| 1410 | - | |
| 1411 | - if (!empty($custom_system_prompt)) { | |
| 1412 | - $system_prompt = $custom_system_prompt; | |
| 1413 | - } elseif ($content_type === 'page') { | |
| 1389 | + if ($content_type === 'page') { | |
| 1414 | 1390 | $system_prompt = $this->get_landing_page_prompt($has_images); |
| 1415 | 1391 | } else { |
| 1416 | 1392 | $system_prompt = $this->get_blog_post_prompt($has_images); |
| 1417 | 1393 | } |
| @@ -1416,9 +1392,9 @@ | ||
| 1416 | 1392 | $system_prompt = $this->get_blog_post_prompt($has_images); |
| 1417 | 1393 | } |
| 1418 | 1394 | |
| 1419 | 1395 | // Allow add-ons to modify the system prompt (e.g. internal linking instructions) |
| 1420 | - $system_prompt = apply_filters('mxchat_content_system_prompt', $system_prompt, $plan, $content_type, $template_mode); | |
| 1396 | + $system_prompt = apply_filters('mxchat_content_system_prompt', $system_prompt, $plan, $content_type); | |
| 1421 | 1397 | |
| 1422 | 1398 | // Include the original user prompt so the AI can see any specific URLs, links, or details the user mentioned |
| 1423 | 1399 | $original_context = ''; |
| 1424 | 1400 | if (!empty($original_prompt)) { |
| @@ -1501,58 +1477,8 @@ | ||
| 1501 | 1477 | return $html; |
| 1502 | 1478 | } |
| 1503 | 1479 | |
| 1504 | 1480 | /** |
| 1505 | - * AJAX handler: return the default system prompt for the given content type. | |
| 1506 | - */ | |
| 1507 | - public function handle_get_default_prompt() { | |
| 1508 | - check_ajax_referer('mxchat_content_nonce', 'nonce'); | |
| 1509 | - | |
| 1510 | - if (!current_user_can('manage_options')) { | |
| 1511 | - wp_send_json_error(array('message' => __('Unauthorized', 'mxchat'))); | |
| 1512 | - } | |
| 1513 | - | |
| 1514 | - $content_type = sanitize_text_field($_POST['content_type'] ?? 'post'); | |
| 1515 | - | |
| 1516 | - if ($content_type === 'page') { | |
| 1517 | - $default = $this->get_landing_page_prompt(true); | |
| 1518 | - } else { | |
| 1519 | - $default = $this->get_blog_post_prompt(true); | |
| 1520 | - } | |
| 1521 | - | |
| 1522 | - $option_key = 'mxchat_custom_prompt_' . ($content_type === 'page' ? 'page' : 'post'); | |
| 1523 | - $saved = get_option($option_key, ''); | |
| 1524 | - | |
| 1525 | - wp_send_json_success(array( | |
| 1526 | - 'default_prompt' => $default, | |
| 1527 | - 'saved_prompt' => $saved, | |
| 1528 | - )); | |
| 1529 | - } | |
| 1530 | - | |
| 1531 | - /** | |
| 1532 | - * AJAX handler: save or reset a custom system prompt for a content type. | |
| 1533 | - */ | |
| 1534 | - public function handle_save_custom_prompt() { | |
| 1535 | - check_ajax_referer('mxchat_content_nonce', 'nonce'); | |
| 1536 | - | |
| 1537 | - if (!current_user_can('manage_options')) { | |
| 1538 | - wp_send_json_error(array('message' => __('Unauthorized', 'mxchat'))); | |
| 1539 | - } | |
| 1540 | - | |
| 1541 | - $content_type = sanitize_text_field($_POST['content_type'] ?? 'post'); | |
| 1542 | - $custom_prompt = wp_unslash($_POST['custom_prompt'] ?? ''); | |
| 1543 | - $option_key = 'mxchat_custom_prompt_' . ($content_type === 'page' ? 'page' : 'post'); | |
| 1544 | - | |
| 1545 | - if (empty($custom_prompt)) { | |
| 1546 | - delete_option($option_key); | |
| 1547 | - } else { | |
| 1548 | - update_option($option_key, $custom_prompt, false); | |
| 1549 | - } | |
| 1550 | - | |
| 1551 | - wp_send_json_success(); | |
| 1552 | - } | |
| 1553 | - | |
| 1554 | - /** | |
| 1555 | 1481 | * System prompt optimized for landing page generation (CSS-first approach). |
| 1556 | 1482 | * |
| 1557 | 1483 | * The AI outputs a <style> block with ALL CSS using mxg- prefixed classes, |
| 1558 | 1484 | * followed by clean semantic HTML referencing those classes. No inline styles. |
| @@ -1614,10 +1540,8 @@ | ||
| 1614 | 1540 | - Alternate light (#ffffff) and subtle gray (#f8fafc) backgrounds |
| 1615 | 1541 | - Two-column layouts alternating content left/right |
| 1616 | 1542 | - Card grids for features/benefits |
| 1617 | 1543 | - Each section gets its own mxg- class for unique styling |
| 1618 | -- Write substantive copy in every section — expand each plan key_point into a full sentence or short paragraph (roughly 80-180 words per content section). Do NOT condense the plan into one-line bullets | |
| 1619 | -- Vary the structure across sections: paragraphs, feature card grids, numbered steps, stat callouts, testimonial quotes, comparison tables, or FAQ blocks where they fit the content | |
| 1620 | 1544 | |
| 1621 | 1545 | TYPOGRAPHY: |
| 1622 | 1546 | - Hero heading: 3rem desktop, scales down in your media queries |
| 1623 | 1547 | - Section headings: 2.25rem desktop |
| @@ -1747,65 +1671,28 @@ | ||
| 1747 | 1671 | } |
| 1748 | 1672 | return trim($css); |
| 1749 | 1673 | } |
| 1750 | 1674 | |
| 1751 | - /** | |
| 1752 | - * Build a self-contained <style> block to embed in post_content. | |
| 1753 | - * Combines all three CSS layers so the page renders correctly | |
| 1754 | - * even if the plugin is deactivated. | |
| 1755 | - */ | |
| 1756 | - private function build_embedded_css($ai_css, $fullwidth = false, $hide_title = false) { | |
| 1757 | - $css = "<style>/* mxchat-css */\n"; | |
| 1758 | - | |
| 1759 | - // Layer 1: Fullwidth theme resets | |
| 1760 | - if ($fullwidth) { | |
| 1761 | - $css .= $this->get_fullwidth_reset_css(); | |
| 1762 | - } | |
| 1763 | - | |
| 1764 | - // Layer 2: mxg- isolation + responsive overrides | |
| 1765 | - $css .= $this->get_isolation_css(); | |
| 1766 | - | |
| 1767 | - // Layer 3: AI-generated CSS | |
| 1768 | - // Collapse blank lines to prevent wpautop from injecting <p> tags | |
| 1769 | - // inside the style block when the_content filter runs. | |
| 1770 | - if (!empty($ai_css)) { | |
| 1771 | - $clean_css = preg_replace('/\n\s*\n/', "\n", $ai_css); | |
| 1772 | - $css .= "\n/* MxChat — AI Generated Styles */\n" . $clean_css . "\n"; | |
| 1773 | - } | |
| 1774 | - | |
| 1775 | - // Title hiding | |
| 1776 | - if ($hide_title) { | |
| 1777 | - $css .= $this->get_title_hide_css(); | |
| 1778 | - } | |
| 1779 | - | |
| 1780 | - $css .= "</style>\n"; | |
| 1781 | - return $css; | |
| 1782 | - } | |
| 1783 | - | |
| 1784 | 1675 | // ─── Layout / Fullwidth Settings ────────────────────────────────── |
| 1785 | 1676 | |
| 1786 | 1677 | /** |
| 1787 | - * Build a single <style> block for legacy posts (CSS not yet embedded in post_content). | |
| 1788 | - * Used by inject_generated_css() as a backwards-compatibility fallback. | |
| 1678 | + * Build a single <style> block for generated content. | |
| 1679 | + * | |
| 1680 | + * Merges three layers: | |
| 1681 | + * 1. Theme padding reset (when fullwidth is enabled) | |
| 1682 | + * 2. Our mxg- responsive overrides (always) | |
| 1683 | + * 3. AI-generated CSS extracted from the content (page-specific design) | |
| 1684 | + * | |
| 1685 | + * @param bool $fullwidth Whether to include theme padding reset rules. | |
| 1686 | + * @param string $ai_css Raw CSS extracted from the AI output (no <style> tags). | |
| 1687 | + * @return string Complete <style>...</style> block ready to prepend to post content. | |
| 1789 | 1688 | */ |
| 1790 | 1689 | private function get_generated_css($fullwidth = true, $ai_css = '') { |
| 1791 | 1690 | $css = '<style>' . "\n"; |
| 1691 | + | |
| 1692 | + // Layer 1: Theme & page builder padding reset — only when fullwidth is selected | |
| 1792 | 1693 | if ($fullwidth) { |
| 1793 | - $css .= $this->get_fullwidth_reset_css(); | |
| 1794 | - } | |
| 1795 | - $css .= $this->get_isolation_css(); | |
| 1796 | - if (!empty($ai_css)) { | |
| 1797 | - $css .= "\n/* MxChat — AI Generated Styles */\n" . $ai_css . "\n"; | |
| 1798 | - } | |
| 1799 | - $css .= '</style>'; | |
| 1800 | - return $css; | |
| 1801 | - } | |
| 1802 | - | |
| 1803 | - /** | |
| 1804 | - * Layer 1: Theme & page builder fullwidth padding resets. | |
| 1805 | - */ | |
| 1806 | - private function get_fullwidth_reset_css() { | |
| 1807 | - return '/* MxChat — Fullwidth Theme Reset */ | |
| 1694 | + $css .= '/* MxChat — Fullwidth Theme Reset */ | |
| 1808 | 1695 | /* Generic WordPress themes */ |
| 1809 | 1696 | .entry-content-wrap, |
| 1810 | 1697 | .entry-content, |
| 1811 | 1698 | .post-inner .entry-content, |
| @@ -1816,27 +1703,13 @@ | ||
| 1816 | 1703 | .type-post .entry-content, |
| 1817 | 1704 | .type-page .entry-content, |
| 1818 | 1705 | .page .entry-content, |
| 1819 | 1706 | .single .entry-content { |
| 1820 | - padding: 0 !important; | |
| 1707 | + padding-left: 0 !important; | |
| 1708 | + padding-right: 0 !important; | |
| 1821 | 1709 | max-width: 100% !important; |
| 1822 | 1710 | width: 100% !important; |
| 1823 | 1711 | } |
| 1824 | -/* Outer wrappers that themes use to add spacing */ | |
| 1825 | -.site-main > article, | |
| 1826 | -.content-area, | |
| 1827 | -.site-content, | |
| 1828 | -#content, | |
| 1829 | -#primary, | |
| 1830 | -.hentry, | |
| 1831 | -.post, | |
| 1832 | -.page .post, | |
| 1833 | -.single .post { | |
| 1834 | - padding: 0 !important; | |
| 1835 | - margin-left: 0 !important; | |
| 1836 | - margin-right: 0 !important; | |
| 1837 | - max-width: 100% !important; | |
| 1838 | -} | |
| 1839 | 1712 | /* Astra */ |
| 1840 | 1713 | .ast-container .entry-content, |
| 1841 | 1714 | .site-content .ast-container, |
| 1842 | 1715 | .ast-separate-container .ast-article-single, |
| @@ -1854,9 +1727,10 @@ | ||
| 1854 | 1727 | /* GeneratePress */ |
| 1855 | 1728 | .inside-article .entry-content, |
| 1856 | 1729 | .generate-columns-container, |
| 1857 | 1730 | .inside-article { |
| 1858 | - padding: 0 !important; | |
| 1731 | + padding-left: 0 !important; | |
| 1732 | + padding-right: 0 !important; | |
| 1859 | 1733 | max-width: 100% !important; |
| 1860 | 1734 | width: 100% !important; |
| 1861 | 1735 | } |
| 1862 | 1736 | /* Kadence */ |
| @@ -1862,9 +1736,10 @@ | ||
| 1862 | 1736 | /* Kadence */ |
| 1863 | 1737 | .kb-row-layout-wrap, |
| 1864 | 1738 | .entry-content-wrap, |
| 1865 | 1739 | .content-container.site-container { |
| 1866 | - padding: 0 !important; | |
| 1740 | + padding-left: 0 !important; | |
| 1741 | + padding-right: 0 !important; | |
| 1867 | 1742 | max-width: 100% !important; |
| 1868 | 1743 | width: 100% !important; |
| 1869 | 1744 | } |
| 1870 | 1745 | .content-style-unboxed .entry:not(.loop-entry), |
| @@ -1876,9 +1751,10 @@ | ||
| 1876 | 1751 | } |
| 1877 | 1752 | /* OceanWP */ |
| 1878 | 1753 | .ocean-content .entry, |
| 1879 | 1754 | #content-wrap .container { |
| 1880 | - padding: 0 !important; | |
| 1755 | + padding-left: 0 !important; | |
| 1756 | + padding-right: 0 !important; | |
| 1881 | 1757 | max-width: 100% !important; |
| 1882 | 1758 | width: 100% !important; |
| 1883 | 1759 | } |
| 1884 | 1760 | /* Neve */ |
| @@ -1883,9 +1759,10 @@ | ||
| 1883 | 1759 | } |
| 1884 | 1760 | /* Neve */ |
| 1885 | 1761 | .nv-single-post-wrap .entry-content, |
| 1886 | 1762 | .nv-content-wrap .entry-content { |
| 1887 | - padding: 0 !important; | |
| 1763 | + padding-left: 0 !important; | |
| 1764 | + padding-right: 0 !important; | |
| 1888 | 1765 | max-width: 100% !important; |
| 1889 | 1766 | width: 100% !important; |
| 1890 | 1767 | } |
| 1891 | 1768 | /* Hello Elementor / Elementor default theme */ |
| @@ -1891,9 +1768,10 @@ | ||
| 1891 | 1768 | /* Hello Elementor / Elementor default theme */ |
| 1892 | 1769 | .site-main .elementor-section-wrap, |
| 1893 | 1770 | .elementor-page .page-content .entry-content, |
| 1894 | 1771 | .elementor-default .entry-content { |
| 1895 | - padding: 0 !important; | |
| 1772 | + padding-left: 0 !important; | |
| 1773 | + padding-right: 0 !important; | |
| 1896 | 1774 | max-width: 100% !important; |
| 1897 | 1775 | width: 100% !important; |
| 1898 | 1776 | } |
| 1899 | 1777 | /* Bricks Builder */ |
| @@ -1899,9 +1777,10 @@ | ||
| 1899 | 1777 | /* Bricks Builder */ |
| 1900 | 1778 | .brxe-post-content .entry-content, |
| 1901 | 1779 | .bricks-layout-wrapper .entry-content, |
| 1902 | 1780 | .brxe-container .entry-content { |
| 1903 | - padding: 0 !important; | |
| 1781 | + padding-left: 0 !important; | |
| 1782 | + padding-right: 0 !important; | |
| 1904 | 1783 | max-width: 100% !important; |
| 1905 | 1784 | width: 100% !important; |
| 1906 | 1785 | } |
| 1907 | 1786 | /* Divi */ |
| @@ -1907,9 +1786,10 @@ | ||
| 1907 | 1786 | /* Divi */ |
| 1908 | 1787 | .et_pb_post .entry-content, |
| 1909 | 1788 | #main-content .container .entry-content, |
| 1910 | 1789 | .et_full_width_page .entry-content { |
| 1911 | - padding: 0 !important; | |
| 1790 | + padding-left: 0 !important; | |
| 1791 | + padding-right: 0 !important; | |
| 1912 | 1792 | max-width: 100% !important; |
| 1913 | 1793 | width: 100% !important; |
| 1914 | 1794 | } |
| 1915 | 1795 | /* Beaver Builder */ |
| @@ -1914,9 +1794,10 @@ | ||
| 1914 | 1794 | } |
| 1915 | 1795 | /* Beaver Builder */ |
| 1916 | 1796 | .fl-post-content .entry-content, |
| 1917 | 1797 | .fl-content-full .entry-content { |
| 1918 | - padding: 0 !important; | |
| 1798 | + padding-left: 0 !important; | |
| 1799 | + padding-right: 0 !important; | |
| 1919 | 1800 | max-width: 100% !important; |
| 1920 | 1801 | width: 100% !important; |
| 1921 | 1802 | } |
| 1922 | 1803 | /* Blocksy */ |
| @@ -1921,9 +1802,10 @@ | ||
| 1921 | 1802 | } |
| 1922 | 1803 | /* Blocksy */ |
| 1923 | 1804 | .entry-content[data-source], |
| 1924 | 1805 | .site-main > article > .entry-content { |
| 1925 | - padding: 0 !important; | |
| 1806 | + padding-left: 0 !important; | |
| 1807 | + padding-right: 0 !important; | |
| 1926 | 1808 | max-width: 100% !important; |
| 1927 | 1809 | width: 100% !important; |
| 1928 | 1810 | } |
| 1929 | 1811 | /* Spectra / starter templates */ |
| @@ -1928,37 +1810,21 @@ | ||
| 1928 | 1810 | } |
| 1929 | 1811 | /* Spectra / starter templates */ |
| 1930 | 1812 | .uagb-body-wrapper .entry-content, |
| 1931 | 1813 | .starter-template-content .entry-content { |
| 1932 | - padding: 0 !important; | |
| 1933 | - max-width: 100% !important; | |
| 1934 | - width: 100% !important; | |
| 1935 | -} | |
| 1936 | -/* WordPress Block Themes (Twenty Twenty-Two → Twenty Twenty-Five) | |
| 1937 | - These use theme.json layout constraints rather than classic .entry-content, | |
| 1938 | - so the selectors above do not apply — neutralize them here. */ | |
| 1939 | -.wp-site-blocks, | |
| 1940 | -.wp-block-post-content, | |
| 1941 | -.wp-block-group.has-global-padding, | |
| 1942 | -.has-global-padding { | |
| 1943 | 1814 | padding-left: 0 !important; |
| 1944 | 1815 | padding-right: 0 !important; |
| 1945 | 1816 | max-width: 100% !important; |
| 1817 | + width: 100% !important; | |
| 1946 | 1818 | } |
| 1947 | -.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)), | |
| 1948 | -.wp-block-post-content > :where(:not(.alignleft):not(.alignright):not(.alignfull)) { | |
| 1949 | - max-width: 100% !important; | |
| 1950 | - margin-left: 0 !important; | |
| 1951 | - margin-right: 0 !important; | |
| 1952 | -} | |
| 1953 | 1819 | '; |
| 1954 | - } | |
| 1820 | + } | |
| 1955 | 1821 | |
| 1956 | - /** | |
| 1957 | - * Layer 2: CSS isolation + responsive overrides for mxg- classes. | |
| 1958 | - */ | |
| 1959 | - private function get_isolation_css() { | |
| 1960 | - return '/* MxChat — CSS Isolation & Responsive Overrides */ | |
| 1822 | + // Layer 2: CSS isolation + responsive overrides — always included. | |
| 1823 | + // Page builders (Elementor, Bricks, Divi, Beaver) inject global CSS | |
| 1824 | + // that can override display, margin, padding, and box-sizing on generic | |
| 1825 | + // elements. The mxg-wrapper scope ensures our layout rules take priority. | |
| 1826 | + $css .= '/* MxChat — CSS Isolation & Responsive Overrides */ | |
| 1961 | 1827 | .mxg-wrapper { box-sizing: border-box; } |
| 1962 | 1828 | .mxg-wrapper *, .mxg-wrapper *::before, .mxg-wrapper *::after { box-sizing: inherit; } |
| 1963 | 1829 | .mxg-wrapper img { max-width: 100%; height: auto; } |
| 1964 | 1830 | .mxg-wrapper section { clear: both; } |
| @@ -1981,39 +1847,17 @@ | ||
| 1981 | 1847 | .mxg-section-heading { font-size: 1.35rem !important; } |
| 1982 | 1848 | .mxg-container { padding-left: 16px !important; padding-right: 16px !important; } |
| 1983 | 1849 | } |
| 1984 | 1850 | '; |
| 1985 | - } | |
| 1986 | 1851 | |
| 1987 | - /** | |
| 1988 | - * Title-hiding CSS for WordPress themes. | |
| 1989 | - */ | |
| 1990 | - private function get_title_hide_css() { | |
| 1991 | - return '/* MxChat — Hide Title */ | |
| 1992 | -.entry-title, | |
| 1993 | -.page-title, | |
| 1994 | -.post-title, | |
| 1995 | -.wp-block-post-title, | |
| 1996 | -.ast-title-with-post-meta-wrapper, | |
| 1997 | -.ast-the-title, | |
| 1998 | -.generate-page-header .page-hero, | |
| 1999 | -.entry-header .entry-title, | |
| 2000 | -.entry-hero .entry-title, | |
| 2001 | -.kadence-page-title, | |
| 2002 | -.wp-site-blocks .entry-title, | |
| 2003 | -.ocean-single-post-header, | |
| 2004 | -.page-header, | |
| 2005 | -.nv-page-title-wrap, | |
| 2006 | -.nv-post-title, | |
| 2007 | -.elementor-page-title, | |
| 2008 | -.et_pb_title_container .entry-title, | |
| 2009 | -.brxe-post-title, | |
| 2010 | -[data-hero] .page-title, | |
| 2011 | -.hero-section .page-title, | |
| 2012 | -.entry-header { | |
| 2013 | - display: none !important; | |
| 2014 | -} | |
| 2015 | -'; | |
| 1852 | + // Layer 3: AI-generated CSS (page-specific design) | |
| 1853 | + if (!empty($ai_css)) { | |
| 1854 | + $css .= "\n/* MxChat — AI Generated Styles */\n"; | |
| 1855 | + $css .= $ai_css . "\n"; | |
| 1856 | + } | |
| 1857 | + | |
| 1858 | + $css .= '</style>'; | |
| 1859 | + return $css; | |
| 2016 | 1860 | } |
| 2017 | 1861 | |
| 2018 | 1862 | /** |
| 2019 | 1863 | * Apply layout settings via theme-specific and builder-specific post meta. |
| @@ -2166,101 +2010,18 @@ | ||
| 2166 | 2010 | $keywords_string = implode(', ', $keywords); |
| 2167 | 2011 | |
| 2168 | 2012 | $this->set_meta_description($post_id, $meta_description); |
| 2169 | 2013 | $this->set_focus_keyword($post_id, !empty($focus_keyword) ? $focus_keyword : $keywords_string); |
| 2170 | - $this->set_seo_title($post_id, $plan['title'] ?? ''); | |
| 2171 | 2014 | } |
| 2172 | 2015 | |
| 2173 | - /** | |
| 2174 | - * Write the SEO title to the active SEO plugin's title postmeta so | |
| 2175 | - * RankMath / Yoast / AIOSEO don't fall back to the bare post_title. | |
| 2176 | - * (plan-mxchat-20260510-b89332) | |
| 2177 | - * | |
| 2178 | - * v1: use the generated post_title as the SEO title. A future plan may | |
| 2179 | - * add a separately-generated 50-58 char SEO-optimized title. | |
| 2180 | - */ | |
| 2181 | - private function set_seo_title($post_id, $title) { | |
| 2182 | - $title = sanitize_text_field($title); | |
| 2183 | - if (empty($title)) { | |
| 2184 | - return; | |
| 2185 | - } | |
| 2186 | - $plugin = $this->get_seo_plugin(); | |
| 2187 | - $keys = array( | |
| 2188 | - 'rankmath' => 'rank_math_title', | |
| 2189 | - 'yoast' => '_yoast_wpseo_title', | |
| 2190 | - 'aioseo' => '_aioseo_title', | |
| 2191 | - ); | |
| 2192 | - if (isset($keys[$plugin])) { | |
| 2193 | - update_post_meta($post_id, $keys[$plugin], $title); | |
| 2194 | - } | |
| 2195 | - } | |
| 2196 | - | |
| 2197 | - /** | |
| 2198 | - * Walk the rendered body HTML and copy the inline `<img alt="...">` text | |
| 2199 | - * for each generated image into its attachment's _wp_attachment_image_alt | |
| 2200 | - * postmeta. Matches inline images to attachments by URL. | |
| 2201 | - * (plan-mxchat-20260510-2be2da) | |
| 2202 | - */ | |
| 2203 | - private function sync_image_alts_from_content($content, $image_urls) { | |
| 2204 | - if (empty($content) || empty($image_urls)) { | |
| 2205 | - return; | |
| 2206 | - } | |
| 2207 | - | |
| 2208 | - // Build a url => attachment_id lookup for fast matching. | |
| 2209 | - $by_url = array(); | |
| 2210 | - foreach ($image_urls as $img) { | |
| 2211 | - if (!empty($img['url']) && !empty($img['attachment_id'])) { | |
| 2212 | - $by_url[$img['url']] = (int) $img['attachment_id']; | |
| 2213 | - } | |
| 2214 | - } | |
| 2215 | - if (empty($by_url)) { | |
| 2216 | - return; | |
| 2217 | - } | |
| 2218 | - | |
| 2219 | - if (!preg_match_all('/<img\b[^>]*>/i', $content, $matches)) { | |
| 2220 | - return; | |
| 2221 | - } | |
| 2222 | - | |
| 2223 | - foreach ($matches[0] as $img_tag) { | |
| 2224 | - if (!preg_match('/\bsrc\s*=\s*"([^"]+)"/i', $img_tag, $src_match)) { | |
| 2225 | - continue; | |
| 2226 | - } | |
| 2227 | - $src = html_entity_decode($src_match[1], ENT_QUOTES); | |
| 2228 | - if (!isset($by_url[$src])) { | |
| 2229 | - continue; | |
| 2230 | - } | |
| 2231 | - if (!preg_match('/\balt\s*=\s*"([^"]*)"/i', $img_tag, $alt_match)) { | |
| 2232 | - continue; | |
| 2233 | - } | |
| 2234 | - $alt = sanitize_text_field(html_entity_decode($alt_match[1], ENT_QUOTES)); | |
| 2235 | - if ($alt !== '') { | |
| 2236 | - update_post_meta($by_url[$src], '_wp_attachment_image_alt', $alt); | |
| 2237 | - } | |
| 2238 | - } | |
| 2239 | - } | |
| 2240 | - | |
| 2241 | 2016 | // ─── AI Model Caller ──────────────────────────────────────────────── |
| 2242 | 2017 | |
| 2243 | 2018 | /** |
| 2244 | 2019 | * Call the configured content model |
| 2245 | 2020 | */ |
| 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 | - | |
| 2260 | 2021 | private function call_content_model($system_prompt, $messages, $max_tokens = 4096) { |
| 2261 | 2022 | $options = get_option('mxchat_options', array()); |
| 2262 | - $model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.6-sol'; | |
| 2023 | + $model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.1-chat-latest'; | |
| 2263 | 2024 | |
| 2264 | 2025 | // Determine provider from model name |
| 2265 | 2026 | if ($this->is_claude_model($model)) { |
| 2266 | 2027 | return $this->call_claude($model, $options['claude_api_key'] ?? '', $system_prompt, $messages, $max_tokens); |
| @@ -2278,23 +2039,8 @@ | ||
| 2278 | 2039 | |
| 2279 | 2040 | /** |
| 2280 | 2041 | * Call OpenAI-compatible API (OpenAI, xAI, DeepSeek) |
| 2281 | 2042 | */ |
| 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) return null; | |
| 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-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low'; | |
| 2294 | - return 'minimal'; | |
| 2295 | - } | |
| 2296 | - | |
| 2297 | 2043 | private function call_openai_compatible($model, $api_key, $endpoint, $system_prompt, $messages, $max_tokens) { |
| 2298 | 2044 | if (empty($api_key)) { |
| 2299 | 2045 | return new WP_Error('no_api_key', __('API key not configured for the selected content model.', 'mxchat')); |
| 2300 | 2046 | } |
| @@ -2307,17 +2053,11 @@ | ||
| 2307 | 2053 | 'content' => $msg['content'] ?? '', |
| 2308 | 2054 | ); |
| 2309 | 2055 | } |
| 2310 | 2056 | |
| 2311 | - // GPT-5.x models require max_completion_tokens and only support temperature=1. | |
| 2312 | - // Token key, temperature gate, and reasoning_effort are sourced from the | |
| 2313 | - // core model catalog (plan-dcb71c); frozen fallbacks preserve exact | |
| 2314 | - // pre-dcb71c behavior if the catalog class is unavailable. | |
| 2315 | - $is_gpt5 = strpos($model, 'gpt-5') === 0; | |
| 2316 | - $catalog = class_exists('MxChat_Model_Catalog'); | |
| 2317 | - $token_key = ($catalog && method_exists('MxChat_Model_Catalog', 'openai_token_param')) | |
| 2318 | - ? MxChat_Model_Catalog::openai_token_param($model) | |
| 2319 | - : ($is_gpt5 ? 'max_completion_tokens' : 'max_tokens'); | |
| 2057 | + // GPT-5.x models require max_completion_tokens and only support temperature=1 | |
| 2058 | + $is_gpt5 = strpos($model, 'gpt-5') === 0; | |
| 2059 | + $token_key = $is_gpt5 ? 'max_completion_tokens' : 'max_tokens'; | |
| 2320 | 2060 | |
| 2321 | 2061 | $body = array( |
| 2322 | 2062 | 'model' => $model, |
| 2323 | 2063 | 'messages' => $formatted, |
| @@ -2324,30 +2064,23 @@ | ||
| 2324 | 2064 | $token_key => $max_tokens, |
| 2325 | 2065 | 'stream' => false, |
| 2326 | 2066 | ); |
| 2327 | 2067 | |
| 2328 | - $temp_ok = ($catalog && method_exists('MxChat_Model_Catalog', 'supports_temperature')) | |
| 2329 | - ? MxChat_Model_Catalog::supports_temperature($model) | |
| 2330 | - : !$is_gpt5; | |
| 2331 | - if ($temp_ok) { | |
| 2068 | + if (!$is_gpt5) { | |
| 2332 | 2069 | $body['temperature'] = 0.7; |
| 2333 | 2070 | } |
| 2334 | 2071 | |
| 2335 | - $effort = ($catalog && method_exists('MxChat_Model_Catalog', 'reasoning_effort_for')) | |
| 2336 | - ? MxChat_Model_Catalog::reasoning_effort_for($model, 'content') | |
| 2337 | - : $this->mxchat_cg_reasoning_effort_fallback($model); | |
| 2338 | - if ($effort !== null) { | |
| 2339 | - $body['reasoning_effort'] = $effort; | |
| 2072 | + // Add reasoning_effort only for GPT-5 models that support it | |
| 2073 | + // gpt-5.2 and gpt-5.1-chat-latest don't support reasoning_effort parameter | |
| 2074 | + if ($is_gpt5 && $model !== 'gpt-5.2' && $model !== 'gpt-5.1-chat-latest') { | |
| 2075 | + // GPT-5.1 uses 'low' instead of 'minimal' | |
| 2076 | + if ($model === 'gpt-5.1-2025-11-13') { | |
| 2077 | + $body['reasoning_effort'] = 'low'; | |
| 2078 | + } else { | |
| 2079 | + $body['reasoning_effort'] = 'minimal'; // For other GPT-5 models | |
| 2080 | + } | |
| 2340 | 2081 | } |
| 2341 | 2082 | |
| 2342 | - // DeepSeek V4 defaults to thinking mode ON (temperature ignored and | |
| 2343 | - // reasoning eats the token budget); generation wants the legacy | |
| 2344 | - // deepseek-chat semantics = non-thinking. Endpoint-gated because this | |
| 2345 | - // helper is shared with OpenAI and xAI. | |
| 2346 | - if (strpos($endpoint, 'api.deepseek.com') !== false) { | |
| 2347 | - $body['thinking'] = array('type' => 'disabled'); | |
| 2348 | - } | |
| 2349 | - | |
| 2350 | 2083 | // Scale timeout with token count — large generation calls need more time |
| 2351 | 2084 | $timeout = ($max_tokens > 8000) ? 300 : 120; |
| 2352 | 2085 | |
| 2353 | 2086 | $response = wp_remote_post($endpoint, array( |
| @@ -2365,38 +2098,8 @@ | ||
| 2365 | 2098 | |
| 2366 | 2099 | $status_code = wp_remote_retrieve_response_code($response); |
| 2367 | 2100 | $decoded = json_decode(wp_remote_retrieve_body($response), true); |
| 2368 | 2101 | |
| 2369 | - // plan-25b972 self-heal: supported reasoning_effort VALUES are per-model; | |
| 2370 | - // a 400 rejecting the value we sent (stale catalog entry / provider | |
| 2371 | - // drift) is deterministic — strip the param and retry ONCE. | |
| 2372 | - if ($status_code === 400 | |
| 2373 | - && isset($body['reasoning_effort']) | |
| 2374 | - && isset($decoded['error']['message']) | |
| 2375 | - && is_string($decoded['error']['message']) | |
| 2376 | - && preg_match('/Unsupported value:.*reasoning_effort/i', $decoded['error']['message'])) { | |
| 2377 | - if (defined('WP_DEBUG') && WP_DEBUG) { | |
| 2378 | - error_log(sprintf( | |
| 2379 | - '[MxChat] content-generator: model %s rejected reasoning_effort \'%s\' — retrying once without the param (plan-25b972).', | |
| 2380 | - $model, $body['reasoning_effort'] | |
| 2381 | - )); | |
| 2382 | - } | |
| 2383 | - unset($body['reasoning_effort']); | |
| 2384 | - $response = wp_remote_post($endpoint, array( | |
| 2385 | - 'headers' => array( | |
| 2386 | - 'Authorization' => 'Bearer ' . $api_key, | |
| 2387 | - 'Content-Type' => 'application/json', | |
| 2388 | - ), | |
| 2389 | - 'body' => wp_json_encode($body), | |
| 2390 | - 'timeout' => $timeout, | |
| 2391 | - )); | |
| 2392 | - if (is_wp_error($response)) { | |
| 2393 | - return $response; | |
| 2394 | - } | |
| 2395 | - $status_code = wp_remote_retrieve_response_code($response); | |
| 2396 | - $decoded = json_decode(wp_remote_retrieve_body($response), true); | |
| 2397 | - } | |
| 2398 | - | |
| 2399 | 2102 | if ($status_code !== 200) { |
| 2400 | 2103 | $error_msg = $decoded['error']['message'] ?? __('API request failed with status ', 'mxchat') . $status_code; |
| 2401 | 2104 | return new WP_Error('api_error', $error_msg); |
| 2402 | 2105 | } |
| @@ -2411,12 +2114,8 @@ | ||
| 2411 | 2114 | /** |
| 2412 | 2115 | * Call Claude (Anthropic) API |
| 2413 | 2116 | */ |
| 2414 | 2117 | private function call_claude($model, $api_key, $system_prompt, $messages, $max_tokens) { |
| 2415 | - // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15. | |
| 2416 | - // Read-time rescue: remap a saved dead ID to the current equivalent before the API call. | |
| 2417 | - if ($model === 'claude-opus-4-20250514') { $model = 'claude-opus-4-8'; } | |
| 2418 | - elseif ($model === 'claude-sonnet-4-20250514') { $model = 'claude-sonnet-4-6'; } | |
| 2419 | 2118 | if (empty($api_key)) { |
| 2420 | 2119 | return new WP_Error('no_api_key', __('Claude API key not configured.', 'mxchat')); |
| 2421 | 2120 | } |
| 2422 | 2121 | |
| @@ -2436,25 +2135,11 @@ | ||
| 2436 | 2135 | 'model' => $model, |
| 2437 | 2136 | 'max_tokens' => $max_tokens, |
| 2438 | 2137 | 'temperature' => 0.7, |
| 2439 | 2138 | 'messages' => $formatted, |
| 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 | - ), | |
| 2139 | + 'system' => $system_prompt, | |
| 2446 | 2140 | ); |
| 2447 | 2141 | |
| 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 | - | |
| 2457 | 2142 | $timeout = ($max_tokens > 8000) ? 300 : 120; |
| 2458 | 2143 | |
| 2459 | 2144 | $response = wp_remote_post('https://api.anthropic.com/v1/messages', array( |
| 2460 | 2145 | 'headers' => array( |
| @@ -2477,16 +2162,10 @@ | ||
| 2477 | 2162 | $error_msg = $decoded['error']['message'] ?? __('Claude API error ', 'mxchat') . $status_code; |
| 2478 | 2163 | return new WP_Error('claude_error', $error_msg); |
| 2479 | 2164 | } |
| 2480 | 2165 | |
| 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 | - } | |
| 2166 | + if (isset($decoded['content'][0]['text'])) { | |
| 2167 | + return trim($decoded['content'][0]['text']); | |
| 2489 | 2168 | } |
| 2490 | 2169 | |
| 2491 | 2170 | return new WP_Error('unexpected_response', __('Unexpected Claude response format.', 'mxchat')); |
| 2492 | 2171 | } |
| @@ -2495,11 +2174,8 @@ | ||
| 2495 | 2174 | /** |
| 2496 | 2175 | * Call Gemini API |
| 2497 | 2176 | */ |
| 2498 | 2177 | 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 | - } | |
| 2502 | 2178 | if (empty($api_key)) { |
| 2503 | 2179 | return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat')); |
| 2504 | 2180 | } |
| 2505 | 2181 | |
| @@ -2596,9 +2272,9 @@ | ||
| 2596 | 2272 | |
| 2597 | 2273 | $field = sanitize_text_field($_POST['field'] ?? ''); |
| 2598 | 2274 | $value = sanitize_text_field($_POST['value'] ?? ''); |
| 2599 | 2275 | |
| 2600 | - $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'); | |
| 2276 | + $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'); | |
| 2601 | 2277 | if (!in_array($field, $allowed_fields, true)) { |
| 2602 | 2278 | wp_send_json_error(array('message' => __('Invalid field.', 'mxchat'))); |
| 2603 | 2279 | } |
| 2604 | 2280 | |
| @@ -2606,18 +2282,8 @@ | ||
| 2606 | 2282 | 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)) { |
| 2607 | 2283 | $value = ($value === 'on') ? 'on' : 'off'; |
| 2608 | 2284 | } |
| 2609 | 2285 | |
| 2610 | - // Enum field: image quality | |
| 2611 | - if ($field === 'content_image_quality') { | |
| 2612 | - $value = in_array($value, array('auto', 'low', 'medium', 'high'), true) ? $value : 'auto'; | |
| 2613 | - } | |
| 2614 | - | |
| 2615 | - // Integer field: images per article (clamp 1–5) | |
| 2616 | - if ($field === 'content_image_count') { | |
| 2617 | - $value = (string) max(1, min(5, (int) $value)); | |
| 2618 | - } | |
| 2619 | - | |
| 2620 | 2286 | $options = get_option('mxchat_options', array()); |
| 2621 | 2287 | $options[$field] = $value; |
| 2622 | 2288 | update_option('mxchat_options', $options); |
| 2623 | 2289 | |
| @@ -3334,9 +3000,9 @@ | ||
| 3334 | 3000 | |
| 3335 | 3001 | $args = array( |
| 3336 | 3002 | 'post_status' => 'publish', |
| 3337 | 3003 | 'posts_per_page' => -1, |
| 3338 | - 'post_type' => $post_type === 'any' ? array_values(array_diff(get_post_types(array('public' => true), 'names'), array('attachment'))) : $post_type, | |
| 3004 | + 'post_type' => $post_type === 'any' ? array('post', 'page') : $post_type, | |
| 3339 | 3005 | 'orderby' => $orderby, |
| 3340 | 3006 | 'order' => $sort_order, |
| 3341 | 3007 | 'fields' => 'ids', |
| 3342 | 3008 | ); |
| @@ -3359,39 +3025,23 @@ | ||
| 3359 | 3025 | array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'), |
| 3360 | 3026 | ); |
| 3361 | 3027 | } |
| 3362 | 3028 | |
| 3363 | - // When sorting by a meta field, posts WITHOUT the key must stay in the | |
| 3364 | - // list — GSC meta is no longer zero-filled for no-data posts (plan | |
| 3365 | - // 282c80), and score meta never existed for unanalyzed posts. A single | |
| 3366 | - // OR EXISTS/NOT-EXISTS meta_query cannot order this reliably (under an | |
| 3367 | - // OR relation WP_Meta_Query leaves the orderby clause's join alias | |
| 3368 | - // unconstrained by meta_key, so ORDER BY reads arbitrary meta rows). | |
| 3369 | - // Instead: two id-only queries — posts WITH the key sorted by its | |
| 3370 | - // value, then posts WITHOUT it appended (newest first). Missing-data | |
| 3371 | - // rows deliberately sort last in BOTH directions. | |
| 3029 | + // When sorting by a meta field, ensure meta_key is set for ordering. | |
| 3030 | + // For 'all' filter, include posts without the meta key via OR clause. | |
| 3372 | 3031 | if ($sort_meta_key) { |
| 3373 | - $with_args = $args; | |
| 3374 | - $with_args['meta_key'] = $sort_meta_key; | |
| 3375 | - $with_args['orderby'] = 'meta_value_num'; | |
| 3376 | - $q_with = new \WP_Query($with_args); | |
| 3377 | - | |
| 3378 | - $without_args = $args; | |
| 3379 | - $missing_clause = array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS'); | |
| 3380 | - if (!empty($without_args['meta_query'])) { | |
| 3381 | - $without_args['meta_query'] = array('relation' => 'AND', $without_args['meta_query'], $missing_clause); | |
| 3382 | - } else { | |
| 3383 | - $without_args['meta_query'] = array($missing_clause); | |
| 3032 | + $args['meta_key'] = $sort_meta_key; | |
| 3033 | + if ($filter === 'all') { | |
| 3034 | + $args['meta_query'] = array( | |
| 3035 | + 'relation' => 'OR', | |
| 3036 | + array('key' => $sort_meta_key, 'compare' => 'EXISTS'), | |
| 3037 | + array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS'), | |
| 3038 | + ); | |
| 3384 | 3039 | } |
| 3385 | - $without_args['orderby'] = 'date'; | |
| 3386 | - $without_args['order'] = 'DESC'; | |
| 3387 | - $q_without = new \WP_Query($without_args); | |
| 3040 | + } | |
| 3388 | 3041 | |
| 3389 | - $all_ids = array_merge($q_with->posts, $q_without->posts); | |
| 3390 | - } else { | |
| 3391 | - $query = new \WP_Query($args); | |
| 3392 | - $all_ids = $query->posts; | |
| 3393 | - } | |
| 3042 | + $query = new \WP_Query($args); | |
| 3043 | + $all_ids = $query->posts; | |
| 3394 | 3044 | $total = count($all_ids); |
| 3395 | 3045 | $pages = max(1, ceil($total / $per_page)); |
| 3396 | 3046 | $page = min($page, $pages); |
| 3397 | 3047 | $offset = ($page - 1) * $per_page; |