options = get_option('mxchat_options', array()); // AJAX hooks (admin only, but wp_ajax_ prefix ensures that) add_action('wp_ajax_mxchat_generate_content', array($this, 'handle_generate_content')); add_action('wp_ajax_mxchat_content_edit', array($this, 'handle_content_edit')); add_action('wp_ajax_mxchat_content_progress', array($this, 'handle_content_progress')); add_action('wp_ajax_mxchat_save_content_setting', array($this, 'handle_save_content_setting')); add_action('wp_ajax_mxchat_content_history', array($this, 'handle_content_history')); add_action('wp_ajax_mxchat_load_post_for_edit', array($this, 'handle_load_post_for_edit')); add_action('wp_ajax_mxchat_delete_content', array($this, 'handle_delete_content')); add_action('wp_ajax_mxchat_update_post_status', array($this, 'handle_update_post_status')); add_action('wp_ajax_mxchat_seo_analyze', array($this, 'handle_seo_analyze')); add_action('wp_ajax_mxchat_seo_analyze_batch', array($this, 'handle_seo_analyze_batch')); add_action('wp_ajax_mxchat_seo_suggest', array($this, 'handle_seo_suggest')); add_action('wp_ajax_mxchat_seo_list_posts', array($this, 'handle_seo_list_posts')); // Background generation via loopback (nopriv because loopback doesn't carry cookies — auth via secret token) add_action('wp_ajax_nopriv_mxchat_generate_content_background', array($this, 'handle_generate_content_background')); add_action('wp_ajax_mxchat_generate_content_background', array($this, 'handle_generate_content_background')); // Frontend CSS injection — outputs generated styles in
add_action('wp_head', array($this, 'inject_generated_css')); // Hide admin bar inside content generator preview iframe (WordPress-native approach) add_filter('show_admin_bar', array($this, 'hide_admin_bar_in_preview')); // Disable wpautop and wptexturize for generated content. // wpautop inserts roguetags between block elements (section, div, etc.) // which breaks flex/grid layouts. wptexturize converts quotes inside CSS // values and data attributes into curly quotes, breaking functionality. add_filter('the_content', array($this, 'protect_generated_content'), 1); add_filter('the_content', array($this, 'restore_content_filters'), 999); } // ─── Content Filter Protection ────────────────────────────────── /** * Disable wpautop and wptexturize for MxChat-generated posts. * * WordPress applies these filters to the_content by default: * - wpautop: wraps text in
tags, breaking flex/grid layouts * - wptexturize: converts quotes to curly quotes, breaking attributes * * This runs at priority 1 (earliest) to remove the filters before * they execute, then restore_content_filters() at priority 999 * re-adds them for subsequent posts (e.g. in archive/loop contexts). */ public function protect_generated_content($content) { $post_id = get_the_ID(); if (!$post_id) { return $content; } if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') { return $content; } // Remove wpautop and wptexturize for this post remove_filter('the_content', 'wpautop'); remove_filter('the_content', 'wptexturize'); return $content; } /** * Re-add wpautop and wptexturize after our generated content has rendered. * This ensures other posts in the same page load (archives, widgets) * still get normal WordPress formatting. */ public function restore_content_filters($content) { if (!has_filter('the_content', 'wpautop')) { add_filter('the_content', 'wpautop'); } if (!has_filter('the_content', 'wptexturize')) { add_filter('the_content', 'wptexturize'); } return $content; } // ─── Frontend CSS Injection ────────────────────────────────────── /** * Inject generated CSS into
on the frontend. * Fires on wp_head for any singular post/page that was created * by the content generator. CSS is stored in post meta to avoid * issues with wp_kses, wpautop, and wptexturize mangling styles * stored in post_content. */ public function inject_generated_css() { if (!is_singular()) { return; } $post_id = get_the_ID(); if (!$post_id) { return; } // Only inject on MxChat-generated posts if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') { return; } $ai_css = get_post_meta($post_id, '_mxchat_content_css', true); $fullwidth = get_post_meta($post_id, '_mxchat_fullwidth', true) === '1'; $hide_title = get_post_meta($post_id, '_mxchat_hide_title', true) === '1'; // Output the combined CSS echo $this->get_generated_css($fullwidth, $ai_css); // Hide the WordPress post/page title if configured. // Covers: generic WP, Astra, GeneratePress, Kadence, OceanWP, Neve, // Hello Elementor, Bricks, Divi, Blocksy, and common starter themes. if ($hide_title) { echo '' . "\n"; } } /** * Hide the WordPress admin bar when the page is loaded inside the * content generator preview iframe (?mxchat_preview=1). * * Uses the native show_admin_bar filter so WordPress never renders * the bar at all — no CSS hacks, no flash of the bar disappearing. * * @param bool $show Whether to show the admin bar. * @return bool */ public function hide_admin_bar_in_preview($show) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, no data processing if (isset($_GET['mxchat_preview'])) { return false; } return $show; } // ─── HTML Sanitization ──────────────────────────────────────────── /** * Sanitize AI-generated HTML allowing all safe CSS properties. * WordPress wp_kses_post() strips most inline styles (display, flex, * background, gradient, border-radius, box-shadow, etc.) which breaks * modern page layouts. This method uses a permissive allowlist for * admin-generated content only. */ private function sanitize_generated_html($html) { $allowed = wp_kses_allowed_html('post'); // Tags that need full style support $styled_tags = array( 'div', 'section', 'article', 'header', 'footer', 'main', 'nav', 'aside', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'a', 'figure', 'figcaption', 'img', 'ul', 'ol', 'li', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'button', 'strong', 'em', 'br', 'hr', 'video', 'source', 'iframe', 'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use', 'symbol', 'text', ); foreach ($styled_tags as $tag) { if (!isset($allowed[$tag])) { $allowed[$tag] = array(); } $allowed[$tag]['style'] = true; $allowed[$tag]['class'] = true; $allowed[$tag]['id'] = true; } // iframe attributes for video embeds (YouTube, Vimeo, etc.) $allowed['iframe']['src'] = true; $allowed['iframe']['width'] = true; $allowed['iframe']['height'] = true; $allowed['iframe']['frameborder'] = true; $allowed['iframe']['allow'] = true; $allowed['iframe']['allowfullscreen'] = true; $allowed['iframe']['title'] = true; $allowed['iframe']['loading'] = true; // Ensure a/img have their needed attributes $allowed['a']['href'] = true; $allowed['a']['target'] = true; $allowed['a']['rel'] = true; $allowed['img']['src'] = true; $allowed['img']['alt'] = true; $allowed['img']['width'] = true; $allowed['img']['height'] = true; $allowed['img']['loading'] = true; // SVG attributes for inline icons foreach (array('svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g') as $svg_tag) { $allowed[$svg_tag]['xmlns'] = true; $allowed[$svg_tag]['viewbox'] = true; $allowed[$svg_tag]['fill'] = true; $allowed[$svg_tag]['stroke'] = true; $allowed[$svg_tag]['stroke-width'] = true; $allowed[$svg_tag]['stroke-linecap'] = true; $allowed[$svg_tag]['stroke-linejoin'] = true; $allowed[$svg_tag]['d'] = true; $allowed[$svg_tag]['cx'] = true; $allowed[$svg_tag]['cy'] = true; $allowed[$svg_tag]['r'] = true; $allowed[$svg_tag]['x'] = true; $allowed[$svg_tag]['y'] = true; $allowed[$svg_tag]['width'] = true; $allowed[$svg_tag]['height'] = true; $allowed[$svg_tag]['points'] = true; $allowed[$svg_tag]['x1'] = true; $allowed[$svg_tag]['y1'] = true; $allowed[$svg_tag]['x2'] = true; $allowed[$svg_tag]['y2'] = true; $allowed[$svg_tag]['transform'] = true; } // Allow all safe CSS properties via the safecss filter add_filter('safe_style_css', array($this, 'allow_all_safe_css')); $clean = wp_kses($html, $allowed); remove_filter('safe_style_css', array($this, 'allow_all_safe_css')); return $clean; } /** * Expand the list of allowed CSS properties for generated content. */ public function allow_all_safe_css($styles) { $extra = array( 'display', 'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis', 'justify-content', 'align-items', 'align-self', 'gap', 'order', 'grid', 'grid-template-columns', 'grid-template-rows', 'grid-column', 'grid-row', 'grid-gap', 'grid-area', 'position', 'top', 'right', 'bottom', 'left', 'z-index', 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height', 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'background', 'background-color', 'background-image', 'background-size', 'background-position', 'background-repeat', 'background-attachment', 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-radius', 'border-color', 'border-style', 'border-width', 'box-shadow', 'text-shadow', 'color', 'font-size', 'font-weight', 'font-style', 'font-family', 'line-height', 'letter-spacing', 'text-align', 'text-decoration', 'text-transform', 'vertical-align', 'white-space', 'word-break', 'overflow', 'overflow-x', 'overflow-y', 'opacity', 'visibility', 'cursor', 'transition', 'transform', 'animation', 'object-fit', 'object-position', 'list-style', 'list-style-type', 'aspect-ratio', ); return array_unique(array_merge($styles, $extra)); } // ─── Generation Pipeline ─────────────────────────────────────────── /** * Main content generation handler */ public function handle_generate_content() { check_ajax_referer('mxchat_content_nonce', 'nonce'); if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => __('Unauthorized', 'mxchat'))); } $prompt = sanitize_textarea_field($_POST['prompt'] ?? ''); $content_type = sanitize_text_field($_POST['content_type'] ?? 'post'); $post_status = sanitize_text_field($_POST['post_status'] ?? 'draft'); $schedule_date = sanitize_text_field($_POST['schedule_date'] ?? ''); $layout = sanitize_text_field($_POST['layout'] ?? 'fullwidth'); $title_display = sanitize_text_field($_POST['title_display'] ?? 'hide'); if (empty($prompt)) { wp_send_json_error(array('message' => __('Please enter a prompt.', 'mxchat'))); } // Validate inputs if (!in_array($content_type, array('post', 'page'), true)) { $content_type = 'post'; } if (!in_array($post_status, array('draft', 'publish', 'future'), true)) { $post_status = 'draft'; } // Generate a unique progress key and secret for the background worker $progress_key = 'mxchat_content_progress_' . get_current_user_id() . '_' . time(); $secret = wp_generate_password(32, false); $this->update_progress($progress_key, 'starting', __('Starting generation...', 'mxchat'), 5); // Store generation parameters for the background worker $params = array( 'secret' => $secret, 'user_id' => get_current_user_id(), 'prompt' => $prompt, 'content_type' => $content_type, 'post_status' => $post_status, 'schedule_date' => $schedule_date, 'layout' => $layout, 'title_display' => $title_display, ); set_transient($progress_key . '_params', $params, 600); // Send JSON response to the browser immediately, then continue // generation in the same PHP process. This avoids loopback requests // which fail behind Cloudflare, CDNs, and on shared hosting. // // Strategy (works on all hosting environments): // 1. litespeed_finish_request() — LiteSpeed servers (HostGator, etc.) // 2. fastcgi_finish_request() — Nginx + PHP-FPM (most VPS/cloud hosts) // 3. Connection: close + output buffer flush — Apache mod_php, CGI, any other SAPI // // All three approaches send the response to the client and allow PHP // to continue executing in the background. ignore_user_abort(true); if (function_exists('set_time_limit')) { set_time_limit(300); } $response_json = wp_json_encode(array('success' => true, 'data' => array('progress_key' => $progress_key))); if (function_exists('litespeed_finish_request')) { header('Content-Type: application/json; charset=utf-8'); echo $response_json; litespeed_finish_request(); } elseif (function_exists('fastcgi_finish_request')) { header('Content-Type: application/json; charset=utf-8'); echo $response_json; fastcgi_finish_request(); } else { // Universal fallback: close the connection via headers + output buffer flush. // Works on Apache mod_php, CGI, and any SAPI that doesn't have a finish function. header('Content-Type: application/json; charset=utf-8'); header('Connection: close'); header('Content-Encoding: none'); // Clear any existing output buffers while (ob_get_level() > 0) { ob_end_clean(); } ob_start(); echo $response_json; $size = ob_get_length(); header('Content-Length: ' . $size); ob_end_flush(); flush(); } // Client has received the response and is now polling for progress. // Run generation inline in this same PHP process. $this->run_background_generation($progress_key, $params); die(); } /** * Background content generation handler. * Called via non-blocking loopback from handle_generate_content(). * Authenticated via secret token stored in transient (no nonce/cookie needed). */ public function handle_generate_content_background() { $progress_key = sanitize_text_field($_POST['progress_key'] ?? ''); $secret = sanitize_text_field($_POST['secret'] ?? ''); if (empty($progress_key) || empty($secret)) { die(); } // Validate the secret token $params = get_transient($progress_key . '_params'); if (!$params || !isset($params['secret']) || $params['secret'] !== $secret) { die(); } // One-time use — delete params transient delete_transient($progress_key . '_params'); // Set up execution environment if (function_exists('set_time_limit')) { set_time_limit(300); } ignore_user_abort(true); // Restore the original user context wp_set_current_user($params['user_id']); $this->run_background_generation($progress_key, $params); die(); } /** * Core generation logic — used by both loopback and inline execution paths. */ private function run_background_generation($progress_key, $params) { $prompt = $params['prompt']; $content_type = $params['content_type']; $post_status = $params['post_status']; $schedule_date = $params['schedule_date']; $layout = $params['layout']; $title_display = $params['title_display']; $this->update_progress($progress_key, 'planning', __('Planning content structure...', 'mxchat'), 10); // Step 1: Plan the content $plan = $this->plan_content($prompt, $content_type); if (is_wp_error($plan)) { $this->update_progress($progress_key, 'error', $plan->get_error_message(), 0); return; } $this->update_progress($progress_key, 'images', __('Generating images...', 'mxchat'), 30); // Step 2: Generate images $image_urls = $this->generate_content_images($plan, $progress_key); $this->update_progress($progress_key, 'writing', __('Writing full content...', 'mxchat'), 60); // Step 3: Generate full HTML content $html_content = $this->generate_html_content($plan, $image_urls, $content_type, $prompt); if (is_wp_error($html_content)) { $this->update_progress($progress_key, 'error', $html_content->get_error_message(), 0); return; } $this->update_progress($progress_key, 'creating', __('Creating WordPress post...', 'mxchat'), 85); // Step 4: Create the WordPress post/page // Extract AI CSS before sanitization — stored in post meta, injected via wp_head $ai_css = $this->extract_css($html_content); $html_without_style = preg_replace('/\n"; } $full_content_for_ai .= $current_content; $user_message = "Current post title: " . $current_title . "\n\nCurrent content (style block + HTML):\n" . $full_content_for_ai . "\n\nUser edit request: " . $edit_instruction; $messages = array( array('role' => 'user', 'content' => $user_message), ); // Allow add-ons to handle the edit via tool calling (str_replace, etc.) // Filter returns null to fall through to the default full-rewrite approach. $result = apply_filters('mxchat_content_tool_edit', null, $full_content_for_ai, $edit_instruction, $current_title); if ($result === null) { // Default: AI rewrites entire page $result = $this->call_content_model($system_prompt, $messages, 16384); } if (is_wp_error($result)) { wp_send_json_error(array('message' => $result->get_error_message())); } // Clean up code fences if present $result = trim($result); $result = preg_replace('/^```(?:html)?\s*/i', '', $result); $result = preg_replace('/\s*```\s*$/', '', $result); // Strip HTML comments $result = preg_replace('//s', '', $result); // Extract title if it was changed (look for first h1) $new_title = $current_title; if (preg_match('/