| 1 |
<?php |
| 2 |
/** |
| 3 |
* MxChat Content Generator |
| 4 |
* |
| 5 |
* Handles AI-powered blog post and landing page generation |
| 6 |
* with image generation, SEO metadata, and inline editing. |
| 7 |
* |
| 8 |
* @package MxChat |
| 9 |
* @since 3.1.0 |
| 10 |
*/ |
| 11 |
|
| 12 |
if (!defined('ABSPATH')) { |
| 13 |
exit; |
| 14 |
} |
| 15 |
|
| 16 |
class MxChat_Content_Generator { |
| 17 |
|
| 18 |
private $options; |
| 19 |
|
| 20 |
public function __construct() { |
| 21 |
$this->options = get_option('mxchat_options', array()); |
| 22 |
|
| 23 |
// AJAX hooks (admin only, but wp_ajax_ prefix ensures that) |
| 24 |
add_action('wp_ajax_mxchat_generate_content', array($this, 'handle_generate_content')); |
| 25 |
add_action('wp_ajax_mxchat_content_edit', array($this, 'handle_content_edit')); |
| 26 |
add_action('wp_ajax_mxchat_content_progress', array($this, 'handle_content_progress')); |
| 27 |
add_action('wp_ajax_mxchat_save_content_setting', array($this, 'handle_save_content_setting')); |
| 28 |
add_action('wp_ajax_mxchat_content_history', array($this, 'handle_content_history')); |
| 29 |
add_action('wp_ajax_mxchat_load_post_for_edit', array($this, 'handle_load_post_for_edit')); |
| 30 |
add_action('wp_ajax_mxchat_delete_content', array($this, 'handle_delete_content')); |
| 31 |
add_action('wp_ajax_mxchat_update_post_status', array($this, 'handle_update_post_status')); |
| 32 |
add_action('wp_ajax_mxchat_seo_analyze', array($this, 'handle_seo_analyze')); |
| 33 |
add_action('wp_ajax_mxchat_seo_analyze_batch', array($this, 'handle_seo_analyze_batch')); |
| 34 |
add_action('wp_ajax_mxchat_seo_suggest', array($this, 'handle_seo_suggest')); |
| 35 |
add_action('wp_ajax_mxchat_seo_list_posts', array($this, 'handle_seo_list_posts')); |
| 36 |
|
| 37 |
// Background generation via loopback (nopriv because loopback doesn't carry cookies — auth via secret token) |
| 38 |
add_action('wp_ajax_nopriv_mxchat_generate_content_background', array($this, 'handle_generate_content_background')); |
| 39 |
add_action('wp_ajax_mxchat_generate_content_background', array($this, 'handle_generate_content_background')); |
| 40 |
|
| 41 |
// Frontend CSS injection — outputs generated styles in <head> |
| 42 |
add_action('wp_head', array($this, 'inject_generated_css')); |
| 43 |
|
| 44 |
// Hide admin bar inside content generator preview iframe (WordPress-native approach) |
| 45 |
add_filter('show_admin_bar', array($this, 'hide_admin_bar_in_preview')); |
| 46 |
|
| 47 |
// Disable wpautop and wptexturize for generated content. |
| 48 |
// wpautop inserts rogue <p> tags between block elements (section, div, etc.) |
| 49 |
// which breaks flex/grid layouts. wptexturize converts quotes inside CSS |
| 50 |
// values and data attributes into curly quotes, breaking functionality. |
| 51 |
add_filter('the_content', array($this, 'protect_generated_content'), 1); |
| 52 |
add_filter('the_content', array($this, 'restore_content_filters'), 999); |
| 53 |
} |
| 54 |
|
| 55 |
// ─── Content Filter Protection ────────────────────────────────── |
| 56 |
|
| 57 |
/** |
| 58 |
* Disable wpautop and wptexturize for MxChat-generated posts. |
| 59 |
* |
| 60 |
* WordPress applies these filters to the_content by default: |
| 61 |
* - wpautop: wraps text in <p> tags, breaking flex/grid layouts |
| 62 |
* - wptexturize: converts quotes to curly quotes, breaking attributes |
| 63 |
* |
| 64 |
* This runs at priority 1 (earliest) to remove the filters before |
| 65 |
* they execute, then restore_content_filters() at priority 999 |
| 66 |
* re-adds them for subsequent posts (e.g. in archive/loop contexts). |
| 67 |
*/ |
| 68 |
public function protect_generated_content($content) { |
| 69 |
$post_id = get_the_ID(); |
| 70 |
if (!$post_id) { |
| 71 |
return $content; |
| 72 |
} |
| 73 |
|
| 74 |
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') { |
| 75 |
return $content; |
| 76 |
} |
| 77 |
|
| 78 |
// Remove wpautop and wptexturize for this post |
| 79 |
remove_filter('the_content', 'wpautop'); |
| 80 |
remove_filter('the_content', 'wptexturize'); |
| 81 |
|
| 82 |
return $content; |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Re-add wpautop and wptexturize after our generated content has rendered. |
| 87 |
* This ensures other posts in the same page load (archives, widgets) |
| 88 |
* still get normal WordPress formatting. |
| 89 |
*/ |
| 90 |
public function restore_content_filters($content) { |
| 91 |
if (!has_filter('the_content', 'wpautop')) { |
| 92 |
add_filter('the_content', 'wpautop'); |
| 93 |
} |
| 94 |
if (!has_filter('the_content', 'wptexturize')) { |
| 95 |
add_filter('the_content', 'wptexturize'); |
| 96 |
} |
| 97 |
|
| 98 |
return $content; |
| 99 |
} |
| 100 |
|
| 101 |
// ─── Frontend CSS Injection ────────────────────────────────────── |
| 102 |
|
| 103 |
/** |
| 104 |
* Inject generated CSS into <head> on the frontend. |
| 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 |
*/ |
| 110 |
public function inject_generated_css() { |
| 111 |
if (!is_singular()) { |
| 112 |
return; |
| 113 |
} |
| 114 |
|
| 115 |
$post_id = get_the_ID(); |
| 116 |
if (!$post_id) { |
| 117 |
return; |
| 118 |
} |
| 119 |
|
| 120 |
// Only inject on MxChat-generated posts |
| 121 |
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') { |
| 122 |
return; |
| 123 |
} |
| 124 |
|
| 125 |
$ai_css = get_post_meta($post_id, '_mxchat_content_css', true); |
| 126 |
$fullwidth = get_post_meta($post_id, '_mxchat_fullwidth', true) === '1'; |
| 127 |
$hide_title = get_post_meta($post_id, '_mxchat_hide_title', true) === '1'; |
| 128 |
|
| 129 |
// Output the combined CSS |
| 130 |
echo $this->get_generated_css($fullwidth, $ai_css); |
| 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. |
| 135 |
if ($hide_title) { |
| 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"; |
| 171 |
} |
| 172 |
} |
| 173 |
|
| 174 |
/** |
| 175 |
* Hide the WordPress admin bar when the page is loaded inside the |
| 176 |
* content generator preview iframe (?mxchat_preview=1). |
| 177 |
* |
| 178 |
* Uses the native show_admin_bar filter so WordPress never renders |
| 179 |
* the bar at all — no CSS hacks, no flash of the bar disappearing. |
| 180 |
* |
| 181 |
* @param bool $show Whether to show the admin bar. |
| 182 |
* @return bool |
| 183 |
*/ |
| 184 |
public function hide_admin_bar_in_preview($show) { |
| 185 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, no data processing |
| 186 |
if (isset($_GET['mxchat_preview'])) { |
| 187 |
return false; |
| 188 |
} |
| 189 |
return $show; |
| 190 |
} |
| 191 |
|
| 192 |
// ─── HTML Sanitization ──────────────────────────────────────────── |
| 193 |
|
| 194 |
/** |
| 195 |
* Sanitize AI-generated HTML allowing all safe CSS properties. |
| 196 |
* WordPress wp_kses_post() strips most inline styles (display, flex, |
| 197 |
* background, gradient, border-radius, box-shadow, etc.) which breaks |
| 198 |
* modern page layouts. This method uses a permissive allowlist for |
| 199 |
* admin-generated content only. |
| 200 |
*/ |
| 201 |
private function sanitize_generated_html($html) { |
| 202 |
$allowed = wp_kses_allowed_html('post'); |
| 203 |
|
| 204 |
// Tags that need full style support |
| 205 |
$styled_tags = array( |
| 206 |
'div', 'section', 'article', 'header', 'footer', 'main', 'nav', 'aside', |
| 207 |
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'a', 'figure', |
| 208 |
'figcaption', 'img', 'ul', 'ol', 'li', 'blockquote', 'table', 'thead', |
| 209 |
'tbody', 'tr', 'th', 'td', 'button', 'strong', 'em', 'br', 'hr', |
| 210 |
'video', 'source', 'iframe', 'svg', 'path', 'circle', 'rect', 'line', |
| 211 |
'polyline', 'polygon', 'g', 'defs', 'use', 'symbol', 'text', |
| 212 |
); |
| 213 |
|
| 214 |
foreach ($styled_tags as $tag) { |
| 215 |
if (!isset($allowed[$tag])) { |
| 216 |
$allowed[$tag] = array(); |
| 217 |
} |
| 218 |
$allowed[$tag]['style'] = true; |
| 219 |
$allowed[$tag]['class'] = true; |
| 220 |
$allowed[$tag]['id'] = true; |
| 221 |
} |
| 222 |
|
| 223 |
// iframe attributes for video embeds (YouTube, Vimeo, etc.) |
| 224 |
$allowed['iframe']['src'] = true; |
| 225 |
$allowed['iframe']['width'] = true; |
| 226 |
$allowed['iframe']['height'] = true; |
| 227 |
$allowed['iframe']['frameborder'] = true; |
| 228 |
$allowed['iframe']['allow'] = true; |
| 229 |
$allowed['iframe']['allowfullscreen'] = true; |
| 230 |
$allowed['iframe']['title'] = true; |
| 231 |
$allowed['iframe']['loading'] = true; |
| 232 |
|
| 233 |
// Ensure a/img have their needed attributes |
| 234 |
$allowed['a']['href'] = true; |
| 235 |
$allowed['a']['target'] = true; |
| 236 |
$allowed['a']['rel'] = true; |
| 237 |
$allowed['img']['src'] = true; |
| 238 |
$allowed['img']['alt'] = true; |
| 239 |
$allowed['img']['width'] = true; |
| 240 |
$allowed['img']['height'] = true; |
| 241 |
$allowed['img']['loading'] = true; |
| 242 |
|
| 243 |
// SVG attributes for inline icons |
| 244 |
foreach (array('svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g') as $svg_tag) { |
| 245 |
$allowed[$svg_tag]['xmlns'] = true; |
| 246 |
$allowed[$svg_tag]['viewbox'] = true; |
| 247 |
$allowed[$svg_tag]['fill'] = true; |
| 248 |
$allowed[$svg_tag]['stroke'] = true; |
| 249 |
$allowed[$svg_tag]['stroke-width'] = true; |
| 250 |
$allowed[$svg_tag]['stroke-linecap'] = true; |
| 251 |
$allowed[$svg_tag]['stroke-linejoin'] = true; |
| 252 |
$allowed[$svg_tag]['d'] = true; |
| 253 |
$allowed[$svg_tag]['cx'] = true; |
| 254 |
$allowed[$svg_tag]['cy'] = true; |
| 255 |
$allowed[$svg_tag]['r'] = true; |
| 256 |
$allowed[$svg_tag]['x'] = true; |
| 257 |
$allowed[$svg_tag]['y'] = true; |
| 258 |
$allowed[$svg_tag]['width'] = true; |
| 259 |
$allowed[$svg_tag]['height'] = true; |
| 260 |
$allowed[$svg_tag]['points'] = true; |
| 261 |
$allowed[$svg_tag]['x1'] = true; |
| 262 |
$allowed[$svg_tag]['y1'] = true; |
| 263 |
$allowed[$svg_tag]['x2'] = true; |
| 264 |
$allowed[$svg_tag]['y2'] = true; |
| 265 |
$allowed[$svg_tag]['transform'] = true; |
| 266 |
} |
| 267 |
|
| 268 |
// Allow all safe CSS properties via the safecss filter |
| 269 |
add_filter('safe_style_css', array($this, 'allow_all_safe_css')); |
| 270 |
$clean = wp_kses($html, $allowed); |
| 271 |
remove_filter('safe_style_css', array($this, 'allow_all_safe_css')); |
| 272 |
|
| 273 |
return $clean; |
| 274 |
} |
| 275 |
|
| 276 |
/** |
| 277 |
* Expand the list of allowed CSS properties for generated content. |
| 278 |
*/ |
| 279 |
public function allow_all_safe_css($styles) { |
| 280 |
$extra = array( |
| 281 |
'display', 'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', |
| 282 |
'flex-basis', 'justify-content', 'align-items', 'align-self', 'gap', 'order', |
| 283 |
'grid', 'grid-template-columns', 'grid-template-rows', 'grid-column', 'grid-row', |
| 284 |
'grid-gap', 'grid-area', |
| 285 |
'position', 'top', 'right', 'bottom', 'left', 'z-index', |
| 286 |
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height', |
| 287 |
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', |
| 288 |
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', |
| 289 |
'background', 'background-color', 'background-image', 'background-size', |
| 290 |
'background-position', 'background-repeat', 'background-attachment', |
| 291 |
'border', 'border-top', 'border-right', 'border-bottom', 'border-left', |
| 292 |
'border-radius', 'border-color', 'border-style', 'border-width', |
| 293 |
'box-shadow', 'text-shadow', |
| 294 |
'color', 'font-size', 'font-weight', 'font-style', 'font-family', |
| 295 |
'line-height', 'letter-spacing', 'text-align', 'text-decoration', 'text-transform', |
| 296 |
'vertical-align', 'white-space', 'word-break', 'overflow', 'overflow-x', 'overflow-y', |
| 297 |
'opacity', 'visibility', 'cursor', |
| 298 |
'transition', 'transform', 'animation', |
| 299 |
'object-fit', 'object-position', |
| 300 |
'list-style', 'list-style-type', |
| 301 |
'aspect-ratio', |
| 302 |
); |
| 303 |
return array_unique(array_merge($styles, $extra)); |
| 304 |
} |
| 305 |
|
| 306 |
// ─── Generation Pipeline ─────────────────────────────────────────── |
| 307 |
|
| 308 |
/** |
| 309 |
* Main content generation handler |
| 310 |
*/ |
| 311 |
public function handle_generate_content() { |
| 312 |
check_ajax_referer('mxchat_content_nonce', 'nonce'); |
| 313 |
|
| 314 |
if (!current_user_can('manage_options')) { |
| 315 |
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat'))); |
| 316 |
} |
| 317 |
|
| 318 |
$prompt = sanitize_textarea_field($_POST['prompt'] ?? ''); |
| 319 |
$content_type = sanitize_text_field($_POST['content_type'] ?? 'post'); |
| 320 |
$post_status = sanitize_text_field($_POST['post_status'] ?? 'draft'); |
| 321 |
$schedule_date = sanitize_text_field($_POST['schedule_date'] ?? ''); |
| 322 |
$layout = sanitize_text_field($_POST['layout'] ?? 'fullwidth'); |
| 323 |
$title_display = sanitize_text_field($_POST['title_display'] ?? 'hide'); |
| 324 |
|
| 325 |
if (empty($prompt)) { |
| 326 |
wp_send_json_error(array('message' => __('Please enter a prompt.', 'mxchat'))); |
| 327 |
} |
| 328 |
|
| 329 |
// Validate inputs |
| 330 |
if (!in_array($content_type, array('post', 'page'), true)) { |
| 331 |
$content_type = 'post'; |
| 332 |
} |
| 333 |
if (!in_array($post_status, array('draft', 'publish', 'future'), true)) { |
| 334 |
$post_status = 'draft'; |
| 335 |
} |
| 336 |
|
| 337 |
// Generate a unique progress key and secret for the background worker |
| 338 |
$progress_key = 'mxchat_content_progress_' . get_current_user_id() . '_' . time(); |
| 339 |
$secret = wp_generate_password(32, false); |
| 340 |
|
| 341 |
$this->update_progress($progress_key, 'starting', __('Starting generation...', 'mxchat'), 5); |
| 342 |
|
| 343 |
// Store generation parameters for the background worker |
| 344 |
$params = array( |
| 345 |
'secret' => $secret, |
| 346 |
'user_id' => get_current_user_id(), |
| 347 |
'prompt' => $prompt, |
| 348 |
'content_type' => $content_type, |
| 349 |
'post_status' => $post_status, |
| 350 |
'schedule_date' => $schedule_date, |
| 351 |
'layout' => $layout, |
| 352 |
'title_display' => $title_display, |
| 353 |
); |
| 354 |
set_transient($progress_key . '_params', $params, 600); |
| 355 |
|
| 356 |
// Send JSON response to the browser immediately, then continue |
| 357 |
// generation in the same PHP process. This avoids loopback requests |
| 358 |
// which fail behind Cloudflare, CDNs, and on shared hosting. |
| 359 |
// |
| 360 |
// Strategy (works on all hosting environments): |
| 361 |
// 1. litespeed_finish_request() — LiteSpeed servers (HostGator, etc.) |
| 362 |
// 2. fastcgi_finish_request() — Nginx + PHP-FPM (most VPS/cloud hosts) |
| 363 |
// 3. Connection: close + output buffer flush — Apache mod_php, CGI, any other SAPI |
| 364 |
// |
| 365 |
// All three approaches send the response to the client and allow PHP |
| 366 |
// to continue executing in the background. |
| 367 |
|
| 368 |
ignore_user_abort(true); |
| 369 |
if (function_exists('set_time_limit')) { |
| 370 |
set_time_limit(300); |
| 371 |
} |
| 372 |
|
| 373 |
$response_json = wp_json_encode(array('success' => true, 'data' => array('progress_key' => $progress_key))); |
| 374 |
|
| 375 |
if (function_exists('litespeed_finish_request')) { |
| 376 |
header('Content-Type: application/json; charset=utf-8'); |
| 377 |
echo $response_json; |
| 378 |
litespeed_finish_request(); |
| 379 |
} elseif (function_exists('fastcgi_finish_request')) { |
| 380 |
header('Content-Type: application/json; charset=utf-8'); |
| 381 |
echo $response_json; |
| 382 |
fastcgi_finish_request(); |
| 383 |
} else { |
| 384 |
// Universal fallback: close the connection via headers + output buffer flush. |
| 385 |
// Works on Apache mod_php, CGI, and any SAPI that doesn't have a finish function. |
| 386 |
header('Content-Type: application/json; charset=utf-8'); |
| 387 |
header('Connection: close'); |
| 388 |
header('Content-Encoding: none'); |
| 389 |
|
| 390 |
// Clear any existing output buffers |
| 391 |
while (ob_get_level() > 0) { |
| 392 |
ob_end_clean(); |
| 393 |
} |
| 394 |
|
| 395 |
ob_start(); |
| 396 |
echo $response_json; |
| 397 |
$size = ob_get_length(); |
| 398 |
header('Content-Length: ' . $size); |
| 399 |
ob_end_flush(); |
| 400 |
flush(); |
| 401 |
} |
| 402 |
|
| 403 |
// Client has received the response and is now polling for progress. |
| 404 |
// Run generation inline in this same PHP process. |
| 405 |
$this->run_background_generation($progress_key, $params); |
| 406 |
die(); |
| 407 |
} |
| 408 |
|
| 409 |
/** |
| 410 |
* Background content generation handler. |
| 411 |
* Called via non-blocking loopback from handle_generate_content(). |
| 412 |
* Authenticated via secret token stored in transient (no nonce/cookie needed). |
| 413 |
*/ |
| 414 |
public function handle_generate_content_background() { |
| 415 |
$progress_key = sanitize_text_field($_POST['progress_key'] ?? ''); |
| 416 |
$secret = sanitize_text_field($_POST['secret'] ?? ''); |
| 417 |
|
| 418 |
if (empty($progress_key) || empty($secret)) { |
| 419 |
die(); |
| 420 |
} |
| 421 |
|
| 422 |
// Validate the secret token |
| 423 |
$params = get_transient($progress_key . '_params'); |
| 424 |
if (!$params || !isset($params['secret']) || $params['secret'] !== $secret) { |
| 425 |
die(); |
| 426 |
} |
| 427 |
|
| 428 |
// One-time use — delete params transient |
| 429 |
delete_transient($progress_key . '_params'); |
| 430 |
|
| 431 |
// Set up execution environment |
| 432 |
if (function_exists('set_time_limit')) { |
| 433 |
set_time_limit(300); |
| 434 |
} |
| 435 |
ignore_user_abort(true); |
| 436 |
|
| 437 |
// Restore the original user context |
| 438 |
wp_set_current_user($params['user_id']); |
| 439 |
|
| 440 |
$this->run_background_generation($progress_key, $params); |
| 441 |
die(); |
| 442 |
} |
| 443 |
|
| 444 |
/** |
| 445 |
* Core generation logic — used by both loopback and inline execution paths. |
| 446 |
*/ |
| 447 |
private function run_background_generation($progress_key, $params) { |
| 448 |
$prompt = $params['prompt']; |
| 449 |
$content_type = $params['content_type']; |
| 450 |
$post_status = $params['post_status']; |
| 451 |
$schedule_date = $params['schedule_date']; |
| 452 |
$layout = $params['layout']; |
| 453 |
$title_display = $params['title_display']; |
| 454 |
|
| 455 |
$this->update_progress($progress_key, 'planning', __('Planning content structure...', 'mxchat'), 10); |
| 456 |
|
| 457 |
// Step 1: Plan the content |
| 458 |
$plan = $this->plan_content($prompt, $content_type); |
| 459 |
if (is_wp_error($plan)) { |
| 460 |
$this->update_progress($progress_key, 'error', $plan->get_error_message(), 0); |
| 461 |
return; |
| 462 |
} |
| 463 |
|
| 464 |
$this->update_progress($progress_key, 'images', __('Generating images...', 'mxchat'), 30); |
| 465 |
|
| 466 |
// Step 2: Generate images |
| 467 |
$image_urls = $this->generate_content_images($plan, $progress_key); |
| 468 |
|
| 469 |
$this->update_progress($progress_key, 'writing', __('Writing full content...', 'mxchat'), 60); |
| 470 |
|
| 471 |
// Step 3: Generate full HTML content |
| 472 |
$html_content = $this->generate_html_content($plan, $image_urls, $content_type, $prompt); |
| 473 |
if (is_wp_error($html_content)) { |
| 474 |
$this->update_progress($progress_key, 'error', $html_content->get_error_message(), 0); |
| 475 |
return; |
| 476 |
} |
| 477 |
|
| 478 |
$this->update_progress($progress_key, 'creating', __('Creating WordPress post...', 'mxchat'), 85); |
| 479 |
|
| 480 |
// Step 4: Create the WordPress post/page |
| 481 |
// Extract AI CSS before sanitization — stored in post meta, injected via wp_head |
| 482 |
$ai_css = $this->extract_css($html_content); |
| 483 |
$html_without_style = preg_replace('/<style[^>]*>.*?<\/style>/is', '', $html_content); |
| 484 |
$sanitized_html = $this->sanitize_generated_html($html_without_style); |
| 485 |
|
| 486 |
$post_args = array( |
| 487 |
'post_title' => sanitize_text_field($plan['title']), |
| 488 |
'post_content' => $sanitized_html, |
| 489 |
'post_status' => $post_status, |
| 490 |
'post_type' => $content_type === 'page' ? 'page' : 'post', |
| 491 |
'meta_input' => array( |
| 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, |
| 497 |
), |
| 498 |
); |
| 499 |
|
| 500 |
// Handle scheduled posts |
| 501 |
if ($post_status === 'future' && !empty($schedule_date)) { |
| 502 |
$post_args['post_date'] = $schedule_date; |
| 503 |
$post_args['post_date_gmt'] = get_gmt_from_date($schedule_date); |
| 504 |
} |
| 505 |
|
| 506 |
$post_id = wp_insert_post($post_args, true); |
| 507 |
|
| 508 |
if (is_wp_error($post_id)) { |
| 509 |
$this->update_progress($progress_key, 'error', $post_id->get_error_message(), 0); |
| 510 |
return; |
| 511 |
} |
| 512 |
|
| 513 |
// Set featured image if we have one |
| 514 |
if (!empty($image_urls) && !empty($image_urls[0]['attachment_id'])) { |
| 515 |
set_post_thumbnail($post_id, $image_urls[0]['attachment_id']); |
| 516 |
} |
| 517 |
|
| 518 |
// Associate all generated images with this post and store IDs for reliable tracking |
| 519 |
$image_ids = array(); |
| 520 |
foreach ($image_urls as $img) { |
| 521 |
if (!empty($img['attachment_id'])) { |
| 522 |
wp_update_post(array( |
| 523 |
'ID' => $img['attachment_id'], |
| 524 |
'post_parent' => $post_id, |
| 525 |
)); |
| 526 |
$image_ids[] = $img['attachment_id']; |
| 527 |
} |
| 528 |
} |
| 529 |
if (!empty($image_ids)) { |
| 530 |
update_post_meta($post_id, '_mxchat_image_ids', $image_ids); |
| 531 |
} |
| 532 |
|
| 533 |
// Apply fullwidth/title settings via theme-specific post meta |
| 534 |
$this->apply_layout_settings($post_id, $layout, $title_display); |
| 535 |
|
| 536 |
// Step 5: Fill SEO metadata |
| 537 |
$this->fill_seo_metadata($post_id, $plan); |
| 538 |
|
| 539 |
// Build final result |
| 540 |
$preview_url = add_query_arg(array('preview' => 'true'), get_permalink($post_id)); |
| 541 |
$edit_url = admin_url('post.php?post=' . $post_id . '&action=edit'); |
| 542 |
$permalink = get_permalink($post_id); |
| 543 |
|
| 544 |
$images_for_response = array(); |
| 545 |
foreach ($image_urls as $img) { |
| 546 |
if (!empty($img['url']) && !empty($img['attachment_id'])) { |
| 547 |
$thumb_url = wp_get_attachment_image_url($img['attachment_id'], 'medium'); |
| 548 |
$images_for_response[] = array( |
| 549 |
'url' => $img['url'], |
| 550 |
'thumbnail' => $thumb_url ?: $img['url'], |
| 551 |
'attachment_id' => $img['attachment_id'], |
| 552 |
); |
| 553 |
} |
| 554 |
} |
| 555 |
|
| 556 |
$result = array( |
| 557 |
'post_id' => $post_id, |
| 558 |
'preview_url' => $preview_url, |
| 559 |
'edit_url' => $edit_url, |
| 560 |
'permalink' => $permalink, |
| 561 |
'title' => $plan['title'], |
| 562 |
'status' => $post_status, |
| 563 |
'images' => $images_for_response, |
| 564 |
'meta' => array( |
| 565 |
'description' => $plan['meta_description'] ?? '', |
| 566 |
'keyword' => !empty($plan['keywords']) ? $plan['keywords'][0] : '', |
| 567 |
'excerpt' => '', |
| 568 |
), |
| 569 |
); |
| 570 |
|
| 571 |
// Store the result in the progress transient so polling can retrieve it |
| 572 |
$this->update_progress($progress_key, 'done', __('Content generated successfully!', 'mxchat'), 100, $result); |
| 573 |
} |
| 574 |
|
| 575 |
/** |
| 576 |
* Handle content edit via mini-chat |
| 577 |
*/ |
| 578 |
public function handle_content_edit() { |
| 579 |
check_ajax_referer('mxchat_content_nonce', 'nonce'); |
| 580 |
|
| 581 |
if (!current_user_can('manage_options')) { |
| 582 |
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat'))); |
| 583 |
} |
| 584 |
|
| 585 |
// Extend PHP execution time for long AI calls |
| 586 |
if (function_exists('set_time_limit')) { |
| 587 |
set_time_limit(300); |
| 588 |
} |
| 589 |
|
| 590 |
$post_id = intval($_POST['post_id'] ?? 0); |
| 591 |
$edit_instruction = sanitize_textarea_field($_POST['edit_instruction'] ?? ''); |
| 592 |
|
| 593 |
if (!$post_id || empty($edit_instruction)) { |
| 594 |
wp_send_json_error(array('message' => __('Missing post ID or edit instruction.', 'mxchat'))); |
| 595 |
} |
| 596 |
|
| 597 |
$post = get_post($post_id); |
| 598 |
if (!$post) { |
| 599 |
wp_send_json_error(array('message' => __('Post not found.', 'mxchat'))); |
| 600 |
} |
| 601 |
|
| 602 |
$current_content = $post->post_content; |
| 603 |
$current_title = $post->post_title; |
| 604 |
$current_css = get_post_meta($post_id, '_mxchat_content_css', true); |
| 605 |
|
| 606 |
// Build the edit prompt — send both CSS and HTML so AI can edit either |
| 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>"; |
| 608 |
|
| 609 |
// Reconstruct the full content (CSS + HTML) for the AI to edit |
| 610 |
$full_content_for_ai = ''; |
| 611 |
if (!empty($current_css)) { |
| 612 |
$full_content_for_ai = "<style>\n" . $current_css . "\n</style>\n"; |
| 613 |
} |
| 614 |
$full_content_for_ai .= $current_content; |
| 615 |
|
| 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; |
| 617 |
|
| 618 |
$messages = array( |
| 619 |
array('role' => 'user', 'content' => $user_message), |
| 620 |
); |
| 621 |
|
| 622 |
// Allow add-ons to handle the edit via tool calling (str_replace, etc.) |
| 623 |
// Filter returns null to fall through to the default full-rewrite approach. |
| 624 |
$result = apply_filters('mxchat_content_tool_edit', null, $full_content_for_ai, $edit_instruction, $current_title); |
| 625 |
|
| 626 |
if ($result === null) { |
| 627 |
// Default: AI rewrites entire page |
| 628 |
$result = $this->call_content_model($system_prompt, $messages, 16384); |
| 629 |
} |
| 630 |
|
| 631 |
if (is_wp_error($result)) { |
| 632 |
wp_send_json_error(array('message' => $result->get_error_message())); |
| 633 |
} |
| 634 |
|
| 635 |
// Clean up code fences if present |
| 636 |
$result = trim($result); |
| 637 |
$result = preg_replace('/^```(?:html)?\s*/i', '', $result); |
| 638 |
$result = preg_replace('/\s*```\s*$/', '', $result); |
| 639 |
|
| 640 |
// Strip HTML comments |
| 641 |
$result = preg_replace('/<!--.*?-->/s', '', $result); |
| 642 |
|
| 643 |
// Extract title if it was changed (look for first h1) |
| 644 |
$new_title = $current_title; |
| 645 |
if (preg_match('/<h1[^>]*>(.*?)<\/h1>/is', $result, $title_match)) { |
| 646 |
$new_title = wp_strip_all_tags($title_match[1]); |
| 647 |
} |
| 648 |
|
| 649 |
// Extract CSS → meta, sanitize HTML → post_content |
| 650 |
$ai_css = $this->extract_css($result); |
| 651 |
$html_without_style = preg_replace('/<style[^>]*>.*?<\/style>/is', '', $result); |
| 652 |
$sanitized_html = $this->sanitize_generated_html($html_without_style); |
| 653 |
|
| 654 |
// Clear invalid page template meta that causes "Invalid page template" errors |
| 655 |
$current_template = get_post_meta($post_id, '_wp_page_template', true); |
| 656 |
if (!empty($current_template) && $current_template !== 'default') { |
| 657 |
$theme_templates = wp_get_theme()->get_page_templates(get_post($post_id)); |
| 658 |
if (!isset($theme_templates[$current_template])) { |
| 659 |
delete_post_meta($post_id, '_wp_page_template'); |
| 660 |
} |
| 661 |
} |
| 662 |
|
| 663 |
// Update the post content (HTML only) |
| 664 |
$update_result = wp_update_post(array( |
| 665 |
'ID' => $post_id, |
| 666 |
'post_title' => sanitize_text_field($new_title), |
| 667 |
'post_content' => $sanitized_html, |
| 668 |
), true); |
| 669 |
|
| 670 |
// Update CSS in meta (injected via wp_head) |
| 671 |
update_post_meta($post_id, '_mxchat_content_css', $ai_css); |
| 672 |
|
| 673 |
if (is_wp_error($update_result)) { |
| 674 |
wp_send_json_error(array('message' => $update_result->get_error_message())); |
| 675 |
} |
| 676 |
|
| 677 |
$preview_url = add_query_arg(array('preview' => 'true'), get_permalink($post_id)); |
| 678 |
|
| 679 |
// Discover images — prefer stored IDs, fall back to post_parent query |
| 680 |
$images = $this->discover_post_images($post_id); |
| 681 |
|
| 682 |
wp_send_json_success(array( |
| 683 |
'post_id' => $post_id, |
| 684 |
'preview_url' => $preview_url, |
| 685 |
'title' => $new_title, |
| 686 |
'message' => __('Content updated successfully.', 'mxchat'), |
| 687 |
'images' => $images, |
| 688 |
'meta' => array( |
| 689 |
'description' => $this->get_meta_description($post_id), |
| 690 |
'keyword' => $this->get_focus_keyword($post_id), |
| 691 |
'excerpt' => get_post($post_id)->post_excerpt, |
| 692 |
), |
| 693 |
)); |
| 694 |
} |
| 695 |
|
| 696 |
/** |
| 697 |
* Return current progress status for polling |
| 698 |
*/ |
| 699 |
public function handle_content_progress() { |
| 700 |
// Prevent proxy/CDN from caching progress responses |
| 701 |
nocache_headers(); |
| 702 |
|
| 703 |
check_ajax_referer('mxchat_content_nonce', 'nonce'); |
| 704 |
|
| 705 |
$progress_key = sanitize_text_field($_POST['progress_key'] ?? ''); |
| 706 |
if (empty($progress_key)) { |
| 707 |
wp_send_json_error(array('message' => __('Invalid progress key.', 'mxchat'))); |
| 708 |
} |
| 709 |
|
| 710 |
// Direct DB read — bypasses all caching layers for guaranteed freshness |
| 711 |
$progress = $this->get_progress($progress_key); |
| 712 |
if (!$progress) { |
| 713 |
wp_send_json_success(array( |
| 714 |
'step' => 'waiting', |
| 715 |
'message' => __('Waiting...', 'mxchat'), |
| 716 |
'percent' => 0, |
| 717 |
)); |
| 718 |
return; |
| 719 |
} |
| 720 |
|
| 721 |
// Clean up completed/errored progress rows after reading |
| 722 |
if (in_array($progress['step'], array('done', 'error'), true)) { |
| 723 |
$this->delete_progress($progress_key); |
| 724 |
} |
| 725 |
|
| 726 |
wp_send_json_success($progress); |
| 727 |
} |
| 728 |
|
| 729 |
// ─── Content Planning ────────────────────────────────────────────── |
| 730 |
|
| 731 |
/** |
| 732 |
* Step 1: Ask AI to plan the content structure |
| 733 |
*/ |
| 734 |
private function plan_content($prompt, $content_type) { |
| 735 |
$type_label = ($content_type === 'page') ? 'landing page' : 'blog post'; |
| 736 |
|
| 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 []."; |
| 738 |
|
| 739 |
$messages = array( |
| 740 |
array('role' => 'user', 'content' => "Create a {$type_label} about: {$prompt}"), |
| 741 |
); |
| 742 |
|
| 743 |
$result = $this->call_content_model($system_prompt, $messages); |
| 744 |
|
| 745 |
if (is_wp_error($result)) { |
| 746 |
return $result; |
| 747 |
} |
| 748 |
|
| 749 |
// Clean the response - remove markdown code fences if present |
| 750 |
$result = trim($result); |
| 751 |
$result = preg_replace('/^```(?:json)?\s*/i', '', $result); |
| 752 |
$result = preg_replace('/\s*```\s*$/', '', $result); |
| 753 |
|
| 754 |
$plan = json_decode($result, true); |
| 755 |
|
| 756 |
if (json_last_error() !== JSON_ERROR_NONE) { |
| 757 |
return new WP_Error('json_parse_error', __('Failed to parse content plan. The AI response was not valid JSON.', 'mxchat')); |
| 758 |
} |
| 759 |
|
| 760 |
// Validate required fields |
| 761 |
if (empty($plan['title']) || empty($plan['sections'])) { |
| 762 |
return new WP_Error('invalid_plan', __('Content plan is missing required fields (title or sections).', 'mxchat')); |
| 763 |
} |
| 764 |
|
| 765 |
return $plan; |
| 766 |
} |
| 767 |
|
| 768 |
// ─── Image Generation ────────────────────────────────────────────── |
| 769 |
|
| 770 |
/** |
| 771 |
* Step 2: Generate images for sections that need them. |
| 772 |
* Uses cURL multi-handle to fire all image API requests in parallel, |
| 773 |
* then saves results to the media library sequentially. |
| 774 |
*/ |
| 775 |
private function generate_content_images($plan, $progress_key) { |
| 776 |
$options = get_option('mxchat_options', array()); |
| 777 |
$enable_images = ($options['content_enable_images'] ?? 'on') === 'on'; |
| 778 |
|
| 779 |
if (!$enable_images) { |
| 780 |
return array(); |
| 781 |
} |
| 782 |
|
| 783 |
$image_sections = array(); |
| 784 |
foreach ($plan['sections'] as $index => $section) { |
| 785 |
if (!empty($section['needs_image']) && !empty($section['image_prompt'])) { |
| 786 |
$image_sections[] = array( |
| 787 |
'index' => $index, |
| 788 |
'prompt' => $section['image_prompt'], |
| 789 |
'heading' => $section['heading'] ?? 'Section', |
| 790 |
); |
| 791 |
} |
| 792 |
} |
| 793 |
|
| 794 |
if (empty($image_sections)) { |
| 795 |
return array(); |
| 796 |
} |
| 797 |
|
| 798 |
$total = count($image_sections); |
| 799 |
|
| 800 |
// Fallback to sequential if cURL multi is unavailable |
| 801 |
if (!function_exists('curl_multi_init')) { |
| 802 |
return $this->generate_content_images_sequential($image_sections, $total, $progress_key); |
| 803 |
} |
| 804 |
|
| 805 |
$this->update_progress( |
| 806 |
$progress_key, |
| 807 |
'images', |
| 808 |
sprintf(__('Generating %d images...', 'mxchat'), $total), |
| 809 |
30 |
| 810 |
); |
| 811 |
|
| 812 |
// Build cURL requests for all images |
| 813 |
$image_model = $options['content_image_model'] ?? 'gpt-image-1.5'; |
| 814 |
$curl_configs = array(); |
| 815 |
|
| 816 |
foreach ($image_sections as $img) { |
| 817 |
$config = $this->build_image_request($img['prompt'], $image_model, $options); |
| 818 |
if (!is_wp_error($config)) { |
| 819 |
$curl_configs[] = array( |
| 820 |
'section_index' => $img['index'], |
| 821 |
'prompt' => $img['prompt'], |
| 822 |
'config' => $config, |
| 823 |
); |
| 824 |
} |
| 825 |
} |
| 826 |
|
| 827 |
if (empty($curl_configs)) { |
| 828 |
return array(); |
| 829 |
} |
| 830 |
|
| 831 |
// Fire all requests in parallel |
| 832 |
$multi = curl_multi_init(); |
| 833 |
$handles = array(); |
| 834 |
|
| 835 |
foreach ($curl_configs as $i => $item) { |
| 836 |
$ch = curl_init(); |
| 837 |
curl_setopt($ch, CURLOPT_URL, $item['config']['url']); |
| 838 |
curl_setopt($ch, CURLOPT_POST, true); |
| 839 |
curl_setopt($ch, CURLOPT_POSTFIELDS, $item['config']['body']); |
| 840 |
curl_setopt($ch, CURLOPT_HTTPHEADER, $item['config']['headers']); |
| 841 |
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); |
| 842 |
curl_setopt($ch, CURLOPT_TIMEOUT, 120); |
| 843 |
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); |
| 844 |
|
| 845 |
curl_multi_add_handle($multi, $ch); |
| 846 |
$handles[$i] = array( |
| 847 |
'handle' => $ch, |
| 848 |
'section_index' => $item['section_index'], |
| 849 |
'prompt' => $item['prompt'], |
| 850 |
); |
| 851 |
} |
| 852 |
|
| 853 |
// Execute all requests concurrently |
| 854 |
$running = null; |
| 855 |
do { |
| 856 |
$status = curl_multi_exec($multi, $running); |
| 857 |
if ($running > 0) { |
| 858 |
curl_multi_select($multi, 1.0); |
| 859 |
} |
| 860 |
} while ($running > 0 && $status === CURLM_OK); |
| 861 |
|
| 862 |
// Collect responses |
| 863 |
$raw_responses = array(); |
| 864 |
foreach ($handles as $i => $h) { |
| 865 |
$body = curl_multi_getcontent($h['handle']); |
| 866 |
$http_code = curl_getinfo($h['handle'], CURLINFO_HTTP_CODE); |
| 867 |
|
| 868 |
curl_multi_remove_handle($multi, $h['handle']); |
| 869 |
curl_close($h['handle']); |
| 870 |
|
| 871 |
if ($http_code === 200 && !empty($body)) { |
| 872 |
$raw_responses[] = array( |
| 873 |
'section_index' => $h['section_index'], |
| 874 |
'prompt' => $h['prompt'], |
| 875 |
'body' => $body, |
| 876 |
); |
| 877 |
} |
| 878 |
} |
| 879 |
curl_multi_close($multi); |
| 880 |
|
| 881 |
$this->update_progress( |
| 882 |
$progress_key, |
| 883 |
'images', |
| 884 |
__('Saving images to media library...', 'mxchat'), |
| 885 |
50 |
| 886 |
); |
| 887 |
|
| 888 |
// Process responses and save to media library (sequential — fast) |
| 889 |
$image_urls = array(); |
| 890 |
foreach ($raw_responses as $resp) { |
| 891 |
$result = $this->process_image_response($resp['body'], $image_model, $options); |
| 892 |
if (!is_wp_error($result)) { |
| 893 |
// Store the original prompt for later regeneration by add-ons |
| 894 |
update_post_meta($result['attachment_id'], '_mxchat_image_prompt', $resp['prompt']); |
| 895 |
$image_urls[] = array( |
| 896 |
'section_index' => $resp['section_index'], |
| 897 |
'url' => $result['url'], |
| 898 |
'attachment_id' => $result['attachment_id'], |
| 899 |
); |
| 900 |
} |
| 901 |
} |
| 902 |
|
| 903 |
return $image_urls; |
| 904 |
} |
| 905 |
|
| 906 |
/** |
| 907 |
* Sequential fallback when cURL multi is not available. |
| 908 |
*/ |
| 909 |
private function generate_content_images_sequential($image_sections, $total, $progress_key) { |
| 910 |
$image_urls = array(); |
| 911 |
|
| 912 |
foreach ($image_sections as $i => $img) { |
| 913 |
$step_num = $i + 1; |
| 914 |
$this->update_progress( |
| 915 |
$progress_key, |
| 916 |
'images', |
| 917 |
sprintf(__('Generating image %d of %d...', 'mxchat'), $step_num, $total), |
| 918 |
30 + (int)(($step_num / $total) * 25) |
| 919 |
); |
| 920 |
|
| 921 |
$image_result = $this->generate_single_image($img['prompt']); |
| 922 |
|
| 923 |
if (is_wp_error($image_result)) { |
| 924 |
continue; |
| 925 |
} |
| 926 |
|
| 927 |
// Store the original prompt for later regeneration by add-ons |
| 928 |
update_post_meta($image_result['attachment_id'], '_mxchat_image_prompt', $img['prompt']); |
| 929 |
|
| 930 |
$image_urls[] = array( |
| 931 |
'section_index' => $img['index'], |
| 932 |
'url' => $image_result['url'], |
| 933 |
'attachment_id' => $image_result['attachment_id'], |
| 934 |
); |
| 935 |
} |
| 936 |
|
| 937 |
return $image_urls; |
| 938 |
} |
| 939 |
|
| 940 |
/** |
| 941 |
* Generate a single image using the configured image model |
| 942 |
*/ |
| 943 |
private function generate_single_image($prompt) { |
| 944 |
$options = get_option('mxchat_options', array()); |
| 945 |
$image_model = $options['content_image_model'] ?? 'gpt-image-1.5'; |
| 946 |
|
| 947 |
if (strpos($image_model, 'gpt-image') === 0) { |
| 948 |
return $this->generate_openai_image($prompt, $options); |
| 949 |
} elseif (strpos($image_model, 'grok') === 0) { |
| 950 |
return $this->generate_xai_image($prompt, $options); |
| 951 |
} elseif (strpos($image_model, 'gemini') === 0) { |
| 952 |
return $this->generate_gemini_image($prompt, $image_model, $options); |
| 953 |
} |
| 954 |
|
| 955 |
return new WP_Error('unknown_model', __('Unknown image model configured.', 'mxchat')); |
| 956 |
} |
| 957 |
|
| 958 |
/** |
| 959 |
* Build a cURL-ready request config for a single image generation. |
| 960 |
* Used by the parallel image pipeline. |
| 961 |
* |
| 962 |
* @param string $prompt Image generation prompt. |
| 963 |
* @param string $image_model The configured image model ID. |
| 964 |
* @param array $options Plugin options (contains API keys). |
| 965 |
* @return array|WP_Error Array with 'url', 'headers', 'body' keys, or WP_Error. |
| 966 |
*/ |
| 967 |
private function build_image_request($prompt, $image_model, $options) { |
| 968 |
if (strpos($image_model, 'gpt-image') === 0) { |
| 969 |
$api_key = $options['api_key'] ?? ''; |
| 970 |
if (empty($api_key)) { |
| 971 |
return new WP_Error('no_api_key', __('OpenAI API key not configured.', 'mxchat')); |
| 972 |
} |
| 973 |
return array( |
| 974 |
'url' => 'https://api.openai.com/v1/images/generations', |
| 975 |
'headers' => array( |
| 976 |
'Authorization: Bearer ' . $api_key, |
| 977 |
'Content-Type: application/json', |
| 978 |
), |
| 979 |
'body' => wp_json_encode(array( |
| 980 |
'model' => 'gpt-image-1.5', |
| 981 |
'prompt' => $prompt, |
| 982 |
'n' => 1, |
| 983 |
'size' => '1536x1024', |
| 984 |
'quality' => 'medium', |
| 985 |
'output_format' => 'png', |
| 986 |
)), |
| 987 |
); |
| 988 |
} |
| 989 |
|
| 990 |
if (strpos($image_model, 'grok') === 0) { |
| 991 |
$api_key = $options['xai_api_key'] ?? ''; |
| 992 |
if (empty($api_key)) { |
| 993 |
return new WP_Error('no_api_key', __('xAI API key not configured.', 'mxchat')); |
| 994 |
} |
| 995 |
return array( |
| 996 |
'url' => 'https://api.x.ai/v1/images/generations', |
| 997 |
'headers' => array( |
| 998 |
'Authorization: Bearer ' . $api_key, |
| 999 |
'Content-Type: application/json', |
| 1000 |
), |
| 1001 |
'body' => wp_json_encode(array( |
| 1002 |
'model' => $image_model, |
| 1003 |
'prompt' => $prompt, |
| 1004 |
'n' => 1, |
| 1005 |
'response_format' => 'url', |
| 1006 |
)), |
| 1007 |
); |
| 1008 |
} |
| 1009 |
|
| 1010 |
if (strpos($image_model, 'gemini') === 0) { |
| 1011 |
$api_key = $options['gemini_api_key'] ?? ''; |
| 1012 |
if (empty($api_key)) { |
| 1013 |
return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat')); |
| 1014 |
} |
| 1015 |
$api_version = (strpos($image_model, 'preview') !== false) ? 'v1beta' : 'v1beta'; |
| 1016 |
return array( |
| 1017 |
'url' => "https://generativelanguage.googleapis.com/{$api_version}/models/{$image_model}:generateContent?key={$api_key}", |
| 1018 |
'headers' => array( |
| 1019 |
'Content-Type: application/json', |
| 1020 |
), |
| 1021 |
'body' => wp_json_encode(array( |
| 1022 |
'contents' => array( |
| 1023 |
array( |
| 1024 |
'parts' => array( |
| 1025 |
array('text' => $prompt), |
| 1026 |
), |
| 1027 |
), |
| 1028 |
), |
| 1029 |
'generationConfig' => array( |
| 1030 |
'responseModalities' => array('TEXT', 'IMAGE'), |
| 1031 |
'imageConfig' => array('aspectRatio' => '16:9'), |
| 1032 |
), |
| 1033 |
)), |
| 1034 |
); |
| 1035 |
} |
| 1036 |
|
| 1037 |
return new WP_Error('unknown_model', __('Unknown image model.', 'mxchat')); |
| 1038 |
} |
| 1039 |
|
| 1040 |
/** |
| 1041 |
* Process a raw API response body from an image generation call. |
| 1042 |
* Decodes the response, extracts image data, and saves to media library. |
| 1043 |
* |
| 1044 |
* @param string $response_body Raw JSON response body. |
| 1045 |
* @param string $image_model The image model used (determines response format). |
| 1046 |
* @param array $options Plugin options. |
| 1047 |
* @return array|WP_Error Array with 'url' and 'attachment_id', or WP_Error. |
| 1048 |
*/ |
| 1049 |
private function process_image_response($response_body, $image_model, $options = array()) { |
| 1050 |
$decoded = json_decode($response_body, true); |
| 1051 |
if (json_last_error() !== JSON_ERROR_NONE || empty($decoded)) { |
| 1052 |
return new WP_Error('json_error', __('Invalid image API response.', 'mxchat')); |
| 1053 |
} |
| 1054 |
|
| 1055 |
// OpenAI GPT Image — b64_json or url |
| 1056 |
if (strpos($image_model, 'gpt-image') === 0) { |
| 1057 |
$b64_data = $decoded['data'][0]['b64_json'] ?? ''; |
| 1058 |
if (!empty($b64_data)) { |
| 1059 |
return $this->save_image_to_media_library($b64_data, 'image/png'); |
| 1060 |
} |
| 1061 |
$url = $decoded['data'][0]['url'] ?? ''; |
| 1062 |
if (!empty($url)) { |
| 1063 |
return $this->save_image_url_to_media_library($url); |
| 1064 |
} |
| 1065 |
return new WP_Error('no_image_data', __('No image data in OpenAI response.', 'mxchat')); |
| 1066 |
} |
| 1067 |
|
| 1068 |
// xAI Grok — url |
| 1069 |
if (strpos($image_model, 'grok') === 0) { |
| 1070 |
$url = $decoded['data'][0]['url'] ?? ''; |
| 1071 |
if (!empty($url)) { |
| 1072 |
return $this->save_image_url_to_media_library($url); |
| 1073 |
} |
| 1074 |
return new WP_Error('no_image_data', __('No image data in xAI response.', 'mxchat')); |
| 1075 |
} |
| 1076 |
|
| 1077 |
// Gemini — inlineData base64 |
| 1078 |
if (strpos($image_model, 'gemini') === 0) { |
| 1079 |
if (isset($decoded['candidates'][0]['content']['parts'])) { |
| 1080 |
foreach ($decoded['candidates'][0]['content']['parts'] as $part) { |
| 1081 |
$inline_data = $part['inlineData'] ?? $part['inline_data'] ?? null; |
| 1082 |
if ($inline_data && !empty($inline_data['data'])) { |
| 1083 |
$mime_type = $inline_data['mimeType'] ?? $inline_data['mime_type'] ?? 'image/png'; |
| 1084 |
return $this->save_image_to_media_library($inline_data['data'], $mime_type); |
| 1085 |
} |
| 1086 |
} |
| 1087 |
} |
| 1088 |
return new WP_Error('no_image_data', __('No image data in Gemini response.', 'mxchat')); |
| 1089 |
} |
| 1090 |
|
| 1091 |
return new WP_Error('unknown_model', __('Unknown image model.', 'mxchat')); |
| 1092 |
} |
| 1093 |
|
| 1094 |
/** |
| 1095 |
* Generate image via OpenAI GPT Image 1.5 |
| 1096 |
*/ |
| 1097 |
private function generate_openai_image($prompt, $options) { |
| 1098 |
$api_key = $options['api_key'] ?? ''; |
| 1099 |
if (empty($api_key)) { |
| 1100 |
return new WP_Error('no_api_key', __('OpenAI API key not configured.', 'mxchat')); |
| 1101 |
} |
| 1102 |
|
| 1103 |
$body = array( |
| 1104 |
'model' => 'gpt-image-1.5', |
| 1105 |
'prompt' => $prompt, |
| 1106 |
'n' => 1, |
| 1107 |
'size' => '1536x1024', |
| 1108 |
'quality' => 'medium', |
| 1109 |
'output_format' => 'png', |
| 1110 |
); |
| 1111 |
|
| 1112 |
$response = wp_remote_post('https://api.openai.com/v1/images/generations', array( |
| 1113 |
'headers' => array( |
| 1114 |
'Authorization' => 'Bearer ' . $api_key, |
| 1115 |
'Content-Type' => 'application/json', |
| 1116 |
), |
| 1117 |
'body' => wp_json_encode($body), |
| 1118 |
'timeout' => 120, |
| 1119 |
)); |
| 1120 |
|
| 1121 |
if (is_wp_error($response)) { |
| 1122 |
return $response; |
| 1123 |
} |
| 1124 |
|
| 1125 |
$status_code = wp_remote_retrieve_response_code($response); |
| 1126 |
$response_body = json_decode(wp_remote_retrieve_body($response), true); |
| 1127 |
|
| 1128 |
if ($status_code !== 200) { |
| 1129 |
$error_msg = $response_body['error']['message'] ?? __('OpenAI image generation failed.', 'mxchat'); |
| 1130 |
return new WP_Error('openai_image_error', $error_msg); |
| 1131 |
} |
| 1132 |
|
| 1133 |
// GPT Image returns b64_json by default |
| 1134 |
$b64_data = $response_body['data'][0]['b64_json'] ?? ''; |
| 1135 |
if (!empty($b64_data)) { |
| 1136 |
return $this->save_image_to_media_library($b64_data, 'image/png'); |
| 1137 |
} |
| 1138 |
|
| 1139 |
// Fallback: URL-based response |
| 1140 |
$image_url = $response_body['data'][0]['url'] ?? ''; |
| 1141 |
if (!empty($image_url)) { |
| 1142 |
return $this->save_image_url_to_media_library($image_url); |
| 1143 |
} |
| 1144 |
|
| 1145 |
return new WP_Error('no_image_data', __('No image data in OpenAI response.', 'mxchat')); |
| 1146 |
} |
| 1147 |
|
| 1148 |
/** |
| 1149 |
* Generate image via xAI Grok Imagine |
| 1150 |
*/ |
| 1151 |
private function generate_xai_image($prompt, $options) { |
| 1152 |
$api_key = $options['xai_api_key'] ?? ''; |
| 1153 |
if (empty($api_key)) { |
| 1154 |
return new WP_Error('no_api_key', __('xAI API key not configured.', 'mxchat')); |
| 1155 |
} |
| 1156 |
|
| 1157 |
$image_model = $options['content_image_model'] ?? 'grok-imagine-image'; |
| 1158 |
|
| 1159 |
$body = array( |
| 1160 |
'model' => $image_model, |
| 1161 |
'prompt' => $prompt, |
| 1162 |
'n' => 1, |
| 1163 |
'response_format' => 'url', |
| 1164 |
); |
| 1165 |
|
| 1166 |
$response = wp_remote_post('https://api.x.ai/v1/images/generations', array( |
| 1167 |
'headers' => array( |
| 1168 |
'Authorization' => 'Bearer ' . $api_key, |
| 1169 |
'Content-Type' => 'application/json', |
| 1170 |
), |
| 1171 |
'body' => wp_json_encode($body), |
| 1172 |
'timeout' => 120, |
| 1173 |
)); |
| 1174 |
|
| 1175 |
if (is_wp_error($response)) { |
| 1176 |
return $response; |
| 1177 |
} |
| 1178 |
|
| 1179 |
$status_code = wp_remote_retrieve_response_code($response); |
| 1180 |
$response_body = json_decode(wp_remote_retrieve_body($response), true); |
| 1181 |
|
| 1182 |
if ($status_code !== 200) { |
| 1183 |
$error_msg = $response_body['error']['message'] ?? __('xAI image generation failed.', 'mxchat'); |
| 1184 |
return new WP_Error('xai_image_error', $error_msg); |
| 1185 |
} |
| 1186 |
|
| 1187 |
$image_url = $response_body['data'][0]['url'] ?? ''; |
| 1188 |
if (!empty($image_url)) { |
| 1189 |
return $this->save_image_url_to_media_library($image_url); |
| 1190 |
} |
| 1191 |
|
| 1192 |
return new WP_Error('no_image_data', __('No image data in xAI response.', 'mxchat')); |
| 1193 |
} |
| 1194 |
|
| 1195 |
/** |
| 1196 |
* Generate image via Gemini (Nano Banana) |
| 1197 |
*/ |
| 1198 |
private function generate_gemini_image($prompt, $model, $options) { |
| 1199 |
$api_key = $options['gemini_api_key'] ?? ''; |
| 1200 |
if (empty($api_key)) { |
| 1201 |
return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat')); |
| 1202 |
} |
| 1203 |
|
| 1204 |
$body = array( |
| 1205 |
'contents' => array( |
| 1206 |
array( |
| 1207 |
'parts' => array( |
| 1208 |
array('text' => $prompt), |
| 1209 |
), |
| 1210 |
), |
| 1211 |
), |
| 1212 |
'generationConfig' => array( |
| 1213 |
'responseModalities' => array('TEXT', 'IMAGE'), |
| 1214 |
'imageConfig' => array( |
| 1215 |
'aspectRatio' => '16:9', |
| 1216 |
), |
| 1217 |
), |
| 1218 |
); |
| 1219 |
|
| 1220 |
$url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $model . ':generateContent?key=' . $api_key; |
| 1221 |
|
| 1222 |
$response = wp_remote_post($url, array( |
| 1223 |
'headers' => array( |
| 1224 |
'Content-Type' => 'application/json', |
| 1225 |
), |
| 1226 |
'body' => wp_json_encode($body), |
| 1227 |
'timeout' => 120, |
| 1228 |
)); |
| 1229 |
|
| 1230 |
if (is_wp_error($response)) { |
| 1231 |
return $response; |
| 1232 |
} |
| 1233 |
|
| 1234 |
$status_code = wp_remote_retrieve_response_code($response); |
| 1235 |
$response_body = json_decode(wp_remote_retrieve_body($response), true); |
| 1236 |
|
| 1237 |
if ($status_code !== 200) { |
| 1238 |
$error_msg = $response_body['error']['message'] ?? __('Gemini image generation failed.', 'mxchat'); |
| 1239 |
return new WP_Error('gemini_image_error', $error_msg); |
| 1240 |
} |
| 1241 |
|
| 1242 |
// Extract image from Gemini response |
| 1243 |
if (isset($response_body['candidates'][0]['content']['parts'])) { |
| 1244 |
foreach ($response_body['candidates'][0]['content']['parts'] as $part) { |
| 1245 |
$inline_data = $part['inlineData'] ?? $part['inline_data'] ?? null; |
| 1246 |
if ($inline_data && !empty($inline_data['data'])) { |
| 1247 |
$mime_type = $inline_data['mimeType'] ?? $inline_data['mime_type'] ?? 'image/png'; |
| 1248 |
return $this->save_image_to_media_library($inline_data['data'], $mime_type); |
| 1249 |
} |
| 1250 |
} |
| 1251 |
} |
| 1252 |
|
| 1253 |
return new WP_Error('no_image_data', __('No image data in Gemini response.', 'mxchat')); |
| 1254 |
} |
| 1255 |
|
| 1256 |
// ─── Media Library Helpers ────────────────────────────────────────── |
| 1257 |
|
| 1258 |
/** |
| 1259 |
* Save base64 image data to WordPress media library |
| 1260 |
*/ |
| 1261 |
private function save_image_to_media_library($base64_data, $mime_type = 'image/png') { |
| 1262 |
require_once(ABSPATH . 'wp-admin/includes/media.php'); |
| 1263 |
require_once(ABSPATH . 'wp-admin/includes/file.php'); |
| 1264 |
require_once(ABSPATH . 'wp-admin/includes/image.php'); |
| 1265 |
|
| 1266 |
$image_content = base64_decode($base64_data); |
| 1267 |
if ($image_content === false) { |
| 1268 |
return new WP_Error('decode_failed', __('Failed to decode image data.', 'mxchat')); |
| 1269 |
} |
| 1270 |
|
| 1271 |
$extension = 'png'; |
| 1272 |
if (strpos($mime_type, 'jpeg') !== false || strpos($mime_type, 'jpg') !== false) { |
| 1273 |
$extension = 'jpg'; |
| 1274 |
} elseif (strpos($mime_type, 'webp') !== false) { |
| 1275 |
$extension = 'webp'; |
| 1276 |
} |
| 1277 |
|
| 1278 |
$filename = 'mxchat-content-' . time() . '-' . wp_generate_password(6, false) . '.' . $extension; |
| 1279 |
$temp_file = wp_tempnam($filename); |
| 1280 |
|
| 1281 |
if (!$temp_file) { |
| 1282 |
return new WP_Error('temp_file_failed', __('Could not create temporary file.', 'mxchat')); |
| 1283 |
} |
| 1284 |
|
| 1285 |
$bytes = file_put_contents($temp_file, $image_content); |
| 1286 |
if ($bytes === false) { |
| 1287 |
@unlink($temp_file); |
| 1288 |
return new WP_Error('write_failed', __('Could not write image file.', 'mxchat')); |
| 1289 |
} |
| 1290 |
|
| 1291 |
$file_array = array( |
| 1292 |
'name' => $filename, |
| 1293 |
'tmp_name' => $temp_file, |
| 1294 |
'type' => $mime_type, |
| 1295 |
); |
| 1296 |
|
| 1297 |
$attachment_id = media_handle_sideload($file_array, 0); |
| 1298 |
|
| 1299 |
if (is_wp_error($attachment_id)) { |
| 1300 |
@unlink($temp_file); |
| 1301 |
return $attachment_id; |
| 1302 |
} |
| 1303 |
|
| 1304 |
return array( |
| 1305 |
'url' => wp_get_attachment_url($attachment_id), |
| 1306 |
'attachment_id' => $attachment_id, |
| 1307 |
); |
| 1308 |
} |
| 1309 |
|
| 1310 |
/** |
| 1311 |
* Download an image from URL and save to WordPress media library |
| 1312 |
*/ |
| 1313 |
private function save_image_url_to_media_library($image_url) { |
| 1314 |
require_once(ABSPATH . 'wp-admin/includes/media.php'); |
| 1315 |
require_once(ABSPATH . 'wp-admin/includes/file.php'); |
| 1316 |
require_once(ABSPATH . 'wp-admin/includes/image.php'); |
| 1317 |
|
| 1318 |
$tmp = download_url($image_url, 120); |
| 1319 |
if (is_wp_error($tmp)) { |
| 1320 |
return $tmp; |
| 1321 |
} |
| 1322 |
|
| 1323 |
$filename = 'mxchat-content-' . time() . '-' . wp_generate_password(6, false) . '.jpg'; |
| 1324 |
|
| 1325 |
$file_array = array( |
| 1326 |
'name' => $filename, |
| 1327 |
'tmp_name' => $tmp, |
| 1328 |
); |
| 1329 |
|
| 1330 |
$attachment_id = media_handle_sideload($file_array, 0); |
| 1331 |
|
| 1332 |
if (is_wp_error($attachment_id)) { |
| 1333 |
@unlink($tmp); |
| 1334 |
return $attachment_id; |
| 1335 |
} |
| 1336 |
|
| 1337 |
return array( |
| 1338 |
'url' => wp_get_attachment_url($attachment_id), |
| 1339 |
'attachment_id' => $attachment_id, |
| 1340 |
); |
| 1341 |
} |
| 1342 |
|
| 1343 |
// ─── HTML Content Generation ──────────────────────────────────────── |
| 1344 |
|
| 1345 |
/** |
| 1346 |
* Step 3: Generate the full HTML content |
| 1347 |
*/ |
| 1348 |
private function generate_html_content($plan, $image_urls, $content_type, $original_prompt = '') { |
| 1349 |
$type_label = ($content_type === 'page') ? 'landing page' : 'blog post'; |
| 1350 |
$has_images = !empty($image_urls); |
| 1351 |
|
| 1352 |
// Build image URL map by section index |
| 1353 |
$image_map = array(); |
| 1354 |
foreach ($image_urls as $img) { |
| 1355 |
$image_map[$img['section_index']] = $img['url']; |
| 1356 |
} |
| 1357 |
|
| 1358 |
// Include image URLs in the plan for the AI |
| 1359 |
$plan_with_images = $plan; |
| 1360 |
foreach ($plan_with_images['sections'] as $index => &$section) { |
| 1361 |
if (isset($image_map[$index])) { |
| 1362 |
$section['image_url'] = $image_map[$index]; |
| 1363 |
} |
| 1364 |
} |
| 1365 |
unset($section); |
| 1366 |
|
| 1367 |
// Strip image fields from plan when no images, so the AI doesn't |
| 1368 |
// render image prompts as visible placeholder text |
| 1369 |
if (!$has_images) { |
| 1370 |
foreach ($plan_with_images['sections'] as &$section) { |
| 1371 |
unset($section['needs_image'], $section['image_prompt']); |
| 1372 |
} |
| 1373 |
unset($section); |
| 1374 |
} |
| 1375 |
|
| 1376 |
$plan_json = wp_json_encode($plan_with_images, JSON_PRETTY_PRINT); |
| 1377 |
|
| 1378 |
// Build explicit image URL reference so the AI can't miss them |
| 1379 |
$image_reference = ''; |
| 1380 |
if ($has_images && !empty($image_map)) { |
| 1381 |
$image_reference = "\n\n=== IMAGE URL REFERENCE (use these EXACT URLs) ===\n"; |
| 1382 |
foreach ($image_map as $section_idx => $url) { |
| 1383 |
$section_heading = $plan['sections'][$section_idx]['heading'] ?? "Section {$section_idx}"; |
| 1384 |
$image_reference .= "Section \"{$section_heading}\": {$url}\n"; |
| 1385 |
} |
| 1386 |
$image_reference .= "=== END IMAGE URLS — Do NOT use any other image URLs ===\n"; |
| 1387 |
} |
| 1388 |
|
| 1389 |
if ($content_type === 'page') { |
| 1390 |
$system_prompt = $this->get_landing_page_prompt($has_images); |
| 1391 |
} else { |
| 1392 |
$system_prompt = $this->get_blog_post_prompt($has_images); |
| 1393 |
} |
| 1394 |
|
| 1395 |
// Allow add-ons to modify the system prompt (e.g. internal linking instructions) |
| 1396 |
$system_prompt = apply_filters('mxchat_content_system_prompt', $system_prompt, $plan, $content_type); |
| 1397 |
|
| 1398 |
// Include the original user prompt so the AI can see any specific URLs, links, or details the user mentioned |
| 1399 |
$original_context = ''; |
| 1400 |
if (!empty($original_prompt)) { |
| 1401 |
$original_context = "\n\n=== ORIGINAL USER REQUEST ===\n{$original_prompt}\n=== END ORIGINAL REQUEST ===\nIMPORTANT: If the user specified any URLs or links above, you MUST use those exact URLs in the corresponding buttons/links in the HTML. Do NOT replace user-specified URLs with href=\"#\".\n"; |
| 1402 |
} |
| 1403 |
|
| 1404 |
$user_message = "Generate the full HTML content for this {$type_label}. Here is the content plan:\n\n{$plan_json}{$image_reference}{$original_context}"; |
| 1405 |
|
| 1406 |
// Allow add-ons to append data to the user message (e.g. internal links list) |
| 1407 |
$user_message = apply_filters('mxchat_content_user_message', $user_message, $plan, $content_type); |
| 1408 |
|
| 1409 |
$messages = array( |
| 1410 |
array('role' => 'user', 'content' => $user_message), |
| 1411 |
); |
| 1412 |
|
| 1413 |
$result = $this->call_content_model($system_prompt, $messages, 16384); |
| 1414 |
|
| 1415 |
if (is_wp_error($result)) { |
| 1416 |
return $result; |
| 1417 |
} |
| 1418 |
|
| 1419 |
// Clean up - remove markdown code fences if present |
| 1420 |
$result = trim($result); |
| 1421 |
$result = preg_replace('/^```(?:html)?\s*/i', '', $result); |
| 1422 |
$result = preg_replace('/\s*```\s*$/', '', $result); |
| 1423 |
|
| 1424 |
// Strip HTML comments — WordPress wpautop() wraps them in <p> tags |
| 1425 |
// causing visible white blocks like <p><!-- PRICING SECTION --></p> |
| 1426 |
$result = preg_replace('/<!--.*?-->/s', '', $result); |
| 1427 |
|
| 1428 |
// Post-process: replace any hallucinated image URLs with our real ones |
| 1429 |
if ($has_images) { |
| 1430 |
$result = $this->replace_hallucinated_images($result, $image_urls); |
| 1431 |
} |
| 1432 |
|
| 1433 |
// Allow add-ons to post-process generated HTML |
| 1434 |
$result = apply_filters('mxchat_content_generated_html', $result, $plan, $content_type); |
| 1435 |
|
| 1436 |
return $result; |
| 1437 |
} |
| 1438 |
|
| 1439 |
/** |
| 1440 |
* Replace any image URL that isn't one of our provided real URLs. |
| 1441 |
* AI models hallucinate fake URLs from random domains — instead of |
| 1442 |
* blocklisting domains, we allowlist only the URLs we actually provided. |
| 1443 |
*/ |
| 1444 |
private function replace_hallucinated_images($html, $image_urls) { |
| 1445 |
if (empty($image_urls)) { |
| 1446 |
return $html; |
| 1447 |
} |
| 1448 |
|
| 1449 |
// Build set of our real URLs |
| 1450 |
$real_urls = array(); |
| 1451 |
foreach ($image_urls as $img) { |
| 1452 |
if (!empty($img['url'])) { |
| 1453 |
$real_urls[] = $img['url']; |
| 1454 |
} |
| 1455 |
} |
| 1456 |
|
| 1457 |
if (empty($real_urls)) { |
| 1458 |
return $html; |
| 1459 |
} |
| 1460 |
|
| 1461 |
$index = 0; |
| 1462 |
|
| 1463 |
$html = preg_replace_callback('/<img([^>]+)src=["\']([^"\']+)["\']([^>]*)>/i', function($matches) use ($real_urls, &$index) { |
| 1464 |
$src = $matches[2]; |
| 1465 |
|
| 1466 |
// If this src is one of our real URLs, keep it |
| 1467 |
if (in_array($src, $real_urls, true)) { |
| 1468 |
return $matches[0]; |
| 1469 |
} |
| 1470 |
|
| 1471 |
// Otherwise replace with the next real URL |
| 1472 |
$replacement_url = $real_urls[$index % count($real_urls)]; |
| 1473 |
$index++; |
| 1474 |
return '<img' . $matches[1] . 'src="' . esc_url($replacement_url) . '"' . $matches[3] . '>'; |
| 1475 |
}, $html); |
| 1476 |
|
| 1477 |
return $html; |
| 1478 |
} |
| 1479 |
|
| 1480 |
/** |
| 1481 |
* System prompt optimized for landing page generation (CSS-first approach). |
| 1482 |
* |
| 1483 |
* The AI outputs a <style> block with ALL CSS using mxg- prefixed classes, |
| 1484 |
* followed by clean semantic HTML referencing those classes. No inline styles. |
| 1485 |
*/ |
| 1486 |
private function get_landing_page_prompt($has_images = false) { |
| 1487 |
$image_rules = $has_images |
| 1488 |
? 'IMAGES: |
| 1489 |
- You will receive image URLs in the content plan JSON under "image_url" for each section |
| 1490 |
- You MUST use ONLY those exact image URLs — copy them character for character into your <img> tags |
| 1491 |
- NEVER invent, guess, or fabricate ANY image URLs |
| 1492 |
- If a section has no image_url, do NOT add an image for that section |
| 1493 |
- Format: <img class="mxg-img" src="EXACT_URL_FROM_PLAN" alt="descriptive alt text">' |
| 1494 |
: 'IMAGES: |
| 1495 |
- Do NOT include any <img> tags at all |
| 1496 |
- Do NOT add any images, placeholders, or image URLs |
| 1497 |
- Design all sections using only text, colors, gradients, and layout'; |
| 1498 |
|
| 1499 |
return <<<PROMPT |
| 1500 |
You are an expert web designer and copywriter. Generate a visually stunning, modern landing page. |
| 1501 |
|
| 1502 |
YOUR OUTPUT FORMAT — you MUST follow this exactly: |
| 1503 |
1. First, output a single <style> block containing ALL CSS for the page |
| 1504 |
2. Then, output clean semantic HTML that references those CSS classes |
| 1505 |
3. Return ONLY the <style> block followed by the HTML — no markdown, no code fences, no commentary |
| 1506 |
|
| 1507 |
CSS RULES: |
| 1508 |
- ALL class names MUST start with "mxg-" prefix (e.g. mxg-hero, mxg-card, mxg-btn-primary) |
| 1509 |
- Do NOT use any inline styles on HTML elements — put ALL styling in the <style> block |
| 1510 |
- Do NOT set font-family on anything (inherit from the WordPress theme) |
| 1511 |
- Include responsive @media queries inside your <style> block: |
| 1512 |
- @media (max-width: 768px) — tablet breakpoint (stack columns, reduce font sizes, adjust padding) |
| 1513 |
- @media (max-width: 480px) — mobile breakpoint (further reduce sizes) |
| 1514 |
- Use these required class names (we add responsive overrides for them): |
| 1515 |
- mxg-container — inner content wrapper (max-width: 1200px; margin: 0 auto) |
| 1516 |
- mxg-row — flex row for side-by-side layouts |
| 1517 |
- mxg-col — each column in an mxg-row |
| 1518 |
- mxg-grid — flex-wrap grid for cards |
| 1519 |
- mxg-card — each card in an mxg-grid |
| 1520 |
- mxg-hero-heading — the main h1 in the hero section |
| 1521 |
- mxg-section-heading — h2 section headings |
| 1522 |
- You may create additional mxg- classes as needed (e.g. mxg-hero, mxg-cta-section, mxg-btn-primary, mxg-subtitle, mxg-features) |
| 1523 |
|
| 1524 |
DESIGN SYSTEM: |
| 1525 |
- Wrap everything in <div class="mxg-wrapper"> |
| 1526 |
- Each <section> should be full-width with its own background color/gradient |
| 1527 |
- Inside each section: <div class="mxg-container"> |
| 1528 |
- For side-by-side layouts: <div class="mxg-row"> with <div class="mxg-col"> children |
| 1529 |
- For card grids: <div class="mxg-grid"> with <div class="mxg-card"> children |
| 1530 |
|
| 1531 |
HERO SECTION: |
| 1532 |
- Full-width dark or gradient background |
| 1533 |
- Two-column layout (mxg-row): text column + visual/feature column |
| 1534 |
- Large h1 with class="mxg-hero-heading" (3rem+ on desktop) |
| 1535 |
- Subheading paragraph |
| 1536 |
- CTA buttons (mxg-btn-primary, mxg-btn-secondary) |
| 1537 |
- Generous vertical padding (80px+) |
| 1538 |
|
| 1539 |
CONTENT SECTIONS: |
| 1540 |
- Alternate light (#ffffff) and subtle gray (#f8fafc) backgrounds |
| 1541 |
- Two-column layouts alternating content left/right |
| 1542 |
- Card grids for features/benefits |
| 1543 |
- Each section gets its own mxg- class for unique styling |
| 1544 |
|
| 1545 |
TYPOGRAPHY: |
| 1546 |
- Hero heading: 3rem desktop, scales down in your media queries |
| 1547 |
- Section headings: 2.25rem desktop |
| 1548 |
- Body text: 1.1rem, line-height 1.7 |
| 1549 |
- Light text on dark backgrounds (#e2e8f0), dark text on light backgrounds (#334155) |
| 1550 |
|
| 1551 |
CTA SECTION (final): |
| 1552 |
- Bold background color or gradient |
| 1553 |
- Centered text with large heading |
| 1554 |
- Prominent CTA button |
| 1555 |
|
| 1556 |
{$image_rules} |
| 1557 |
|
| 1558 |
CRITICAL RULES: |
| 1559 |
- ALL styling goes in the <style> block — ZERO inline styles |
| 1560 |
- ALL class names use the mxg- prefix — no unprefixed classes |
| 1561 |
- The <style> block MUST include @media responsive queries |
| 1562 |
- No shortcodes, no WordPress-specific markup, no page builder code |
| 1563 |
- Use semantic HTML: section, h1-h3, p, a, div, img, ul, li, strong, em |
| 1564 |
- Make the copy compelling, specific, and conversion-focused |
| 1565 |
- If the user provided specific URLs/links in their request or in the plan's "links" array, you MUST use those exact URLs in the corresponding buttons and anchor tags |
| 1566 |
- Only use href="#" as a fallback for links where no URL was specified by the user |
| 1567 |
- Do NOT generate a <header>, <footer>, or <nav> — this goes INSIDE an existing WordPress page |
| 1568 |
- Do NOT include HTML comments |
| 1569 |
PROMPT; |
| 1570 |
} |
| 1571 |
|
| 1572 |
/** |
| 1573 |
* System prompt optimized for blog post generation (CSS-first approach). |
| 1574 |
* |
| 1575 |
* The AI outputs a <style> block with ALL CSS using mxg- prefixed classes, |
| 1576 |
* followed by clean semantic HTML referencing those classes. No inline styles. |
| 1577 |
*/ |
| 1578 |
private function get_blog_post_prompt($has_images = false) { |
| 1579 |
$image_rules = $has_images |
| 1580 |
? 'IMAGES: |
| 1581 |
- You will receive image URLs in the content plan JSON under "image_url" for each section |
| 1582 |
- You MUST use ONLY those exact image URLs — copy them character for character into your <img> tags |
| 1583 |
- NEVER invent, guess, or fabricate ANY image URLs |
| 1584 |
- If a section has no image_url, do NOT add an image for that section |
| 1585 |
- Format: <img class="mxg-img" src="EXACT_URL_FROM_PLAN" alt="descriptive alt text"> |
| 1586 |
- Place naturally within the content flow between sections' |
| 1587 |
: 'IMAGES: |
| 1588 |
- Do NOT include any <img> tags at all |
| 1589 |
- Do NOT add any images, placeholders, or image URLs |
| 1590 |
- Rely on text formatting, blockquotes, and takeaway boxes for visual interest'; |
| 1591 |
|
| 1592 |
return <<<PROMPT |
| 1593 |
You are an expert content writer and web designer. Generate a beautifully formatted, long-form blog post. |
| 1594 |
|
| 1595 |
YOUR OUTPUT FORMAT — you MUST follow this exactly: |
| 1596 |
1. First, output a single <style> block containing ALL CSS for the post |
| 1597 |
2. Then, output clean semantic HTML that references those CSS classes |
| 1598 |
3. Return ONLY the <style> block followed by the HTML — no markdown, no code fences, no commentary |
| 1599 |
|
| 1600 |
CSS RULES: |
| 1601 |
- ALL class names MUST start with "mxg-" prefix (e.g. mxg-article, mxg-meta, mxg-blockquote, mxg-takeaway) |
| 1602 |
- Do NOT use any inline styles on HTML elements — put ALL styling in the <style> block |
| 1603 |
- Do NOT set font-family on anything (inherit from the WordPress theme) |
| 1604 |
- Include responsive @media queries inside your <style> block: |
| 1605 |
- @media (max-width: 768px) — tablet breakpoint |
| 1606 |
- @media (max-width: 480px) — mobile breakpoint |
| 1607 |
- Use these required class names (we add responsive overrides for them): |
| 1608 |
- mxg-container — article wrapper (max-width: 800px; margin: 0 auto) |
| 1609 |
- mxg-hero-heading — the main h1 |
| 1610 |
- mxg-section-heading — h2 section headings |
| 1611 |
- You may create additional mxg- classes as needed (e.g. mxg-meta, mxg-blockquote, mxg-takeaway, mxg-highlight, mxg-img) |
| 1612 |
|
| 1613 |
LAYOUT: |
| 1614 |
- Wrap in <article class="mxg-container"> |
| 1615 |
- Clean, readable blog layout — single column, generous whitespace |
| 1616 |
- max-width: 800px, centered, with comfortable padding |
| 1617 |
|
| 1618 |
HEADER: |
| 1619 |
- <h1 class="mxg-hero-heading"> — large, bold title (2.5rem desktop) |
| 1620 |
- Meta line below: <p class="mxg-meta"> — publish date, estimated read time, subtle color |
| 1621 |
|
| 1622 |
BODY CONTENT: |
| 1623 |
- Write 1500-3000 words of genuinely useful, well-researched content |
| 1624 |
- <h2 class="mxg-section-heading"> for main sections (1.75rem desktop) |
| 1625 |
- H3 subheadings with their own mxg- class |
| 1626 |
- Well-spaced paragraphs (1.1rem, line-height 1.8) |
| 1627 |
- Varied structures: paragraphs, bullet lists, numbered lists, blockquotes, key takeaway boxes |
| 1628 |
|
| 1629 |
SPECIAL ELEMENTS: |
| 1630 |
- Blockquotes: <blockquote class="mxg-blockquote"> with left accent border, subtle background |
| 1631 |
- Key takeaway boxes: <div class="mxg-takeaway"> with gradient background, border, rounded corners, bold heading inside |
| 1632 |
- Highlighted stats: <span class="mxg-highlight"> with accent color and bold weight |
| 1633 |
|
| 1634 |
{$image_rules} |
| 1635 |
|
| 1636 |
CONCLUSION: |
| 1637 |
- Clear summary section with H2 heading |
| 1638 |
- Wrap up key points |
| 1639 |
- End with a subtle CTA or next-steps suggestion |
| 1640 |
|
| 1641 |
CRITICAL RULES: |
| 1642 |
- ALL styling goes in the <style> block — ZERO inline styles |
| 1643 |
- ALL class names use the mxg- prefix — no unprefixed classes |
| 1644 |
- The <style> block MUST include @media responsive queries |
| 1645 |
- No shortcodes, no WordPress-specific markup |
| 1646 |
- Write substantive, expert-level content — not generic filler |
| 1647 |
- Use semantic HTML: article, h1-h3, p, ul, ol, li, blockquote, img, strong, em, a, div, span |
| 1648 |
- If the user provided specific URLs/links in their request or in the plan's "links" array, you MUST use those exact URLs in the corresponding anchor tags |
| 1649 |
- Only use href="#" as a fallback for links where no URL was specified by the user (internal links to real posts will be provided separately if available) |
| 1650 |
- Do NOT generate a <header>, <footer>, or <nav> — this goes INSIDE an existing WordPress page |
| 1651 |
- Do NOT include HTML comments |
| 1652 |
PROMPT; |
| 1653 |
} |
| 1654 |
|
| 1655 |
// ─── CSS Extraction ────────────────────────────────────────────── |
| 1656 |
|
| 1657 |
/** |
| 1658 |
* Extract CSS content from <style> tags in AI-generated HTML. |
| 1659 |
* WordPress wp_kses strips <style> tags during sanitization, |
| 1660 |
* so we pull the CSS out first and re-inject it after sanitizing. |
| 1661 |
* |
| 1662 |
* @param string $html Raw AI-generated HTML that may contain <style> blocks. |
| 1663 |
* @return string The extracted CSS rules (without <style> tags), or empty string. |
| 1664 |
*/ |
| 1665 |
private function extract_css($html) { |
| 1666 |
$css = ''; |
| 1667 |
if (preg_match_all('/<style[^>]*>(.*?)<\/style>/is', $html, $matches)) { |
| 1668 |
foreach ($matches[1] as $block) { |
| 1669 |
$css .= trim($block) . "\n"; |
| 1670 |
} |
| 1671 |
} |
| 1672 |
return trim($css); |
| 1673 |
} |
| 1674 |
|
| 1675 |
// ─── Layout / Fullwidth Settings ────────────────────────────────── |
| 1676 |
|
| 1677 |
/** |
| 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. |
| 1688 |
*/ |
| 1689 |
private function get_generated_css($fullwidth = true, $ai_css = '') { |
| 1690 |
$css = '<style>' . "\n"; |
| 1691 |
|
| 1692 |
// Layer 1: Theme & page builder padding reset — only when fullwidth is selected |
| 1693 |
if ($fullwidth) { |
| 1694 |
$css .= '/* MxChat — Fullwidth Theme Reset */ |
| 1695 |
/* Generic WordPress themes */ |
| 1696 |
.entry-content-wrap, |
| 1697 |
.entry-content, |
| 1698 |
.post-inner .entry-content, |
| 1699 |
.container.site-content, |
| 1700 |
article .entry-content, |
| 1701 |
.content-area .site-main, |
| 1702 |
.single-content .entry-content, |
| 1703 |
.type-post .entry-content, |
| 1704 |
.type-page .entry-content, |
| 1705 |
.page .entry-content, |
| 1706 |
.single .entry-content { |
| 1707 |
padding-left: 0 !important; |
| 1708 |
padding-right: 0 !important; |
| 1709 |
max-width: 100% !important; |
| 1710 |
width: 100% !important; |
| 1711 |
} |
| 1712 |
/* Astra */ |
| 1713 |
.ast-container .entry-content, |
| 1714 |
.site-content .ast-container, |
| 1715 |
.ast-separate-container .ast-article-single, |
| 1716 |
.ast-separate-container .ast-article-post, |
| 1717 |
.ast-separate-container .ast-article-page { |
| 1718 |
padding: 0 !important; |
| 1719 |
margin: 0 auto !important; |
| 1720 |
max-width: 100% !important; |
| 1721 |
width: 100% !important; |
| 1722 |
background: transparent !important; |
| 1723 |
} |
| 1724 |
.ast-separate-container .entry-content { |
| 1725 |
margin: 0 !important; |
| 1726 |
} |
| 1727 |
/* GeneratePress */ |
| 1728 |
.inside-article .entry-content, |
| 1729 |
.generate-columns-container, |
| 1730 |
.inside-article { |
| 1731 |
padding-left: 0 !important; |
| 1732 |
padding-right: 0 !important; |
| 1733 |
max-width: 100% !important; |
| 1734 |
width: 100% !important; |
| 1735 |
} |
| 1736 |
/* Kadence */ |
| 1737 |
.kb-row-layout-wrap, |
| 1738 |
.entry-content-wrap, |
| 1739 |
.content-container.site-container { |
| 1740 |
padding-left: 0 !important; |
| 1741 |
padding-right: 0 !important; |
| 1742 |
max-width: 100% !important; |
| 1743 |
width: 100% !important; |
| 1744 |
} |
| 1745 |
.content-style-unboxed .entry:not(.loop-entry), |
| 1746 |
.content-style-boxed .entry:not(.loop-entry) { |
| 1747 |
box-shadow: none !important; |
| 1748 |
border-radius: 0 !important; |
| 1749 |
margin: 0 !important; |
| 1750 |
padding: 0 !important; |
| 1751 |
} |
| 1752 |
/* OceanWP */ |
| 1753 |
.ocean-content .entry, |
| 1754 |
#content-wrap .container { |
| 1755 |
padding-left: 0 !important; |
| 1756 |
padding-right: 0 !important; |
| 1757 |
max-width: 100% !important; |
| 1758 |
width: 100% !important; |
| 1759 |
} |
| 1760 |
/* Neve */ |
| 1761 |
.nv-single-post-wrap .entry-content, |
| 1762 |
.nv-content-wrap .entry-content { |
| 1763 |
padding-left: 0 !important; |
| 1764 |
padding-right: 0 !important; |
| 1765 |
max-width: 100% !important; |
| 1766 |
width: 100% !important; |
| 1767 |
} |
| 1768 |
/* Hello Elementor / Elementor default theme */ |
| 1769 |
.site-main .elementor-section-wrap, |
| 1770 |
.elementor-page .page-content .entry-content, |
| 1771 |
.elementor-default .entry-content { |
| 1772 |
padding-left: 0 !important; |
| 1773 |
padding-right: 0 !important; |
| 1774 |
max-width: 100% !important; |
| 1775 |
width: 100% !important; |
| 1776 |
} |
| 1777 |
/* Bricks Builder */ |
| 1778 |
.brxe-post-content .entry-content, |
| 1779 |
.bricks-layout-wrapper .entry-content, |
| 1780 |
.brxe-container .entry-content { |
| 1781 |
padding-left: 0 !important; |
| 1782 |
padding-right: 0 !important; |
| 1783 |
max-width: 100% !important; |
| 1784 |
width: 100% !important; |
| 1785 |
} |
| 1786 |
/* Divi */ |
| 1787 |
.et_pb_post .entry-content, |
| 1788 |
#main-content .container .entry-content, |
| 1789 |
.et_full_width_page .entry-content { |
| 1790 |
padding-left: 0 !important; |
| 1791 |
padding-right: 0 !important; |
| 1792 |
max-width: 100% !important; |
| 1793 |
width: 100% !important; |
| 1794 |
} |
| 1795 |
/* Beaver Builder */ |
| 1796 |
.fl-post-content .entry-content, |
| 1797 |
.fl-content-full .entry-content { |
| 1798 |
padding-left: 0 !important; |
| 1799 |
padding-right: 0 !important; |
| 1800 |
max-width: 100% !important; |
| 1801 |
width: 100% !important; |
| 1802 |
} |
| 1803 |
/* Blocksy */ |
| 1804 |
.entry-content[data-source], |
| 1805 |
.site-main > article > .entry-content { |
| 1806 |
padding-left: 0 !important; |
| 1807 |
padding-right: 0 !important; |
| 1808 |
max-width: 100% !important; |
| 1809 |
width: 100% !important; |
| 1810 |
} |
| 1811 |
/* Spectra / starter templates */ |
| 1812 |
.uagb-body-wrapper .entry-content, |
| 1813 |
.starter-template-content .entry-content { |
| 1814 |
padding-left: 0 !important; |
| 1815 |
padding-right: 0 !important; |
| 1816 |
max-width: 100% !important; |
| 1817 |
width: 100% !important; |
| 1818 |
} |
| 1819 |
'; |
| 1820 |
} |
| 1821 |
|
| 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 */ |
| 1827 |
.mxg-wrapper { box-sizing: border-box; } |
| 1828 |
.mxg-wrapper *, .mxg-wrapper *::before, .mxg-wrapper *::after { box-sizing: inherit; } |
| 1829 |
.mxg-wrapper img { max-width: 100%; height: auto; } |
| 1830 |
.mxg-wrapper section { clear: both; } |
| 1831 |
.mxg-row { display: flex; flex-wrap: wrap; } |
| 1832 |
.mxg-col { min-width: 0; } |
| 1833 |
.mxg-grid { display: flex; flex-wrap: wrap; } |
| 1834 |
.mxg-container { box-sizing: border-box; width: 100%; } |
| 1835 |
|
| 1836 |
@media (max-width: 768px) { |
| 1837 |
.mxg-row { flex-direction: column !important; gap: 24px !important; } |
| 1838 |
.mxg-col { flex: 1 1 100% !important; width: 100% !important; max-width: 100% !important; } |
| 1839 |
.mxg-hero-heading { font-size: 2.2rem !important; } |
| 1840 |
.mxg-section-heading { font-size: 1.6rem !important; } |
| 1841 |
.mxg-container { padding-left: 20px !important; padding-right: 20px !important; } |
| 1842 |
.mxg-card { flex: 1 1 100% !important; } |
| 1843 |
.mxg-grid { gap: 16px !important; } |
| 1844 |
} |
| 1845 |
@media (max-width: 480px) { |
| 1846 |
.mxg-hero-heading { font-size: 1.75rem !important; } |
| 1847 |
.mxg-section-heading { font-size: 1.35rem !important; } |
| 1848 |
.mxg-container { padding-left: 16px !important; padding-right: 16px !important; } |
| 1849 |
} |
| 1850 |
'; |
| 1851 |
|
| 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; |
| 1860 |
} |
| 1861 |
|
| 1862 |
/** |
| 1863 |
* Apply layout settings via theme-specific and builder-specific post meta. |
| 1864 |
* |
| 1865 |
* Supports: Astra, GeneratePress, Kadence, OceanWP, Neve, Blocksy, |
| 1866 |
* Elementor, Bricks Builder, Divi, Beaver Builder, and generic WordPress. |
| 1867 |
* |
| 1868 |
* Page builders that are installed but NOT used to edit this post will |
| 1869 |
* still respect standard WordPress post_content — this method sets the |
| 1870 |
* right meta so the theme renders it fullwidth without sidebar. |
| 1871 |
*/ |
| 1872 |
private function apply_layout_settings($post_id, $layout, $title_display) { |
| 1873 |
$is_fullwidth = ($layout === 'fullwidth'); |
| 1874 |
$hide_title = ($title_display === 'hide'); |
| 1875 |
|
| 1876 |
// ── Astra Theme ── |
| 1877 |
if (defined('ASTRA_THEME_VERSION') || get_template() === 'astra') { |
| 1878 |
if ($is_fullwidth) { |
| 1879 |
update_post_meta($post_id, 'site-content-layout', 'page-builder'); |
| 1880 |
update_post_meta($post_id, 'site-sidebar-layout', 'no-sidebar'); |
| 1881 |
} |
| 1882 |
if ($hide_title) { |
| 1883 |
update_post_meta($post_id, 'site-post-title', 'disabled'); |
| 1884 |
} |
| 1885 |
} |
| 1886 |
|
| 1887 |
// ── GeneratePress Theme ── |
| 1888 |
if (defined('GENERATE_VERSION') || get_template() === 'generatepress') { |
| 1889 |
if ($is_fullwidth) { |
| 1890 |
update_post_meta($post_id, '_generate-sidebar-layout-meta', 'no-sidebar'); |
| 1891 |
update_post_meta($post_id, '_generate-full-width-content', 'true'); |
| 1892 |
} |
| 1893 |
if ($hide_title) { |
| 1894 |
update_post_meta($post_id, '_generate-disable-title', 'true'); |
| 1895 |
} |
| 1896 |
} |
| 1897 |
|
| 1898 |
// ── Kadence Theme ── |
| 1899 |
if (class_exists('Kadence\\Theme') || get_template() === 'kadence') { |
| 1900 |
if ($is_fullwidth) { |
| 1901 |
update_post_meta($post_id, '_kad_post_layout', 'fullwidth'); |
| 1902 |
update_post_meta($post_id, '_kad_post_content_style', 'unboxed'); |
| 1903 |
} |
| 1904 |
if ($hide_title) { |
| 1905 |
update_post_meta($post_id, '_kad_post_title', 'hide'); |
| 1906 |
} |
| 1907 |
} |
| 1908 |
|
| 1909 |
// ── OceanWP Theme ── |
| 1910 |
if (class_exists('Ocean_Extra') || get_template() === 'oceanwp') { |
| 1911 |
if ($is_fullwidth) { |
| 1912 |
update_post_meta($post_id, 'oceanwp_post_layout', 'full-width'); |
| 1913 |
update_post_meta($post_id, 'ocean_content_layout', 'full-width'); |
| 1914 |
} |
| 1915 |
if ($hide_title) { |
| 1916 |
update_post_meta($post_id, 'oceanwp_disable_title', 'on'); |
| 1917 |
} |
| 1918 |
} |
| 1919 |
|
| 1920 |
// ── Neve Theme ── |
| 1921 |
if (get_template() === 'neve') { |
| 1922 |
if ($is_fullwidth) { |
| 1923 |
update_post_meta($post_id, 'neve_meta_sidebar', 'full-width'); |
| 1924 |
update_post_meta($post_id, 'neve_meta_container', 'full-width'); |
| 1925 |
} |
| 1926 |
if ($hide_title) { |
| 1927 |
update_post_meta($post_id, 'neve_meta_disable_title', 'on'); |
| 1928 |
} |
| 1929 |
} |
| 1930 |
|
| 1931 |
// ── Blocksy Theme ── |
| 1932 |
if (get_template() === 'blocksy') { |
| 1933 |
if ($is_fullwidth) { |
| 1934 |
update_post_meta($post_id, 'page_structure_type', 'type-4'); |
| 1935 |
} |
| 1936 |
if ($hide_title) { |
| 1937 |
update_post_meta($post_id, 'disable_header', 'yes'); |
| 1938 |
} |
| 1939 |
} |
| 1940 |
|
| 1941 |
// ── Elementor Canvas/Full Width ── |
| 1942 |
// When Elementor is installed, use its Canvas template for the cleanest |
| 1943 |
// fullwidth output (no header/footer/sidebar chrome from the theme). |
| 1944 |
// The post still uses standard post_content — Elementor only takes over |
| 1945 |
// rendering when _elementor_edit_mode is set (which we don't set). |
| 1946 |
if (defined('ELEMENTOR_VERSION') && $is_fullwidth) { |
| 1947 |
$post_type = get_post_type($post_id); |
| 1948 |
$templates = wp_get_theme()->get_page_templates(get_post($post_id), $post_type); |
| 1949 |
|
| 1950 |
// Prefer Elementor Canvas (no theme chrome at all) |
| 1951 |
if (isset($templates['elementor_canvas'])) { |
| 1952 |
update_post_meta($post_id, '_wp_page_template', 'elementor_canvas'); |
| 1953 |
} elseif (isset($templates['elementor_header_footer'])) { |
| 1954 |
update_post_meta($post_id, '_wp_page_template', 'elementor_header_footer'); |
| 1955 |
} |
| 1956 |
} |
| 1957 |
|
| 1958 |
// ── Divi Theme / Divi Builder ── |
| 1959 |
if (defined('ET_BUILDER_VERSION') || get_template() === 'Divi') { |
| 1960 |
if ($is_fullwidth) { |
| 1961 |
update_post_meta($post_id, '_et_pb_page_layout', 'et_full_width_page'); |
| 1962 |
update_post_meta($post_id, '_et_pb_side_nav', 'off'); |
| 1963 |
} |
| 1964 |
if ($hide_title) { |
| 1965 |
update_post_meta($post_id, '_et_pb_show_title', 'off'); |
| 1966 |
} |
| 1967 |
} |
| 1968 |
|
| 1969 |
// ── Beaver Builder ── |
| 1970 |
if (class_exists('FLBuilder') || class_exists('FLBuilderLoader')) { |
| 1971 |
if ($is_fullwidth) { |
| 1972 |
// Beaver Themer uses this meta for sidebar control |
| 1973 |
update_post_meta($post_id, '_fl_builder_sidebar', 'no_sidebar'); |
| 1974 |
} |
| 1975 |
} |
| 1976 |
|
| 1977 |
// ── Bricks Builder ── |
| 1978 |
// Bricks uses its own rendering when _bricks_editor_mode is set. |
| 1979 |
// For standard WP content, it falls through to the theme's template. |
| 1980 |
// No special meta needed — our CSS resets and wp_head injection handle it. |
| 1981 |
|
| 1982 |
// ── Generic full-width page template ── |
| 1983 |
// Only set _wp_page_template if the theme actually has a matching template file |
| 1984 |
// AND we haven't already set one above (e.g. Elementor Canvas). |
| 1985 |
// Setting a non-existent template causes "Invalid page template" errors on wp_update_post(). |
| 1986 |
// Our CSS injection via wp_head already handles fullwidth layout for all themes. |
| 1987 |
if ($is_fullwidth) { |
| 1988 |
$current_template = get_post_meta($post_id, '_wp_page_template', true); |
| 1989 |
if (empty($current_template) || $current_template === 'default') { |
| 1990 |
$theme_templates = wp_get_theme()->get_page_templates(get_post($post_id)); |
| 1991 |
foreach ($theme_templates as $file => $label) { |
| 1992 |
if (stripos($file, 'full') !== false && stripos($file, 'width') !== false) { |
| 1993 |
update_post_meta($post_id, '_wp_page_template', $file); |
| 1994 |
break; |
| 1995 |
} |
| 1996 |
} |
| 1997 |
} |
| 1998 |
} |
| 1999 |
} |
| 2000 |
|
| 2001 |
// ─── SEO Metadata ────────────────────────────────────────────────── |
| 2002 |
|
| 2003 |
/** |
| 2004 |
* Step 5: Fill SEO metadata for the generated post |
| 2005 |
*/ |
| 2006 |
private function fill_seo_metadata($post_id, $plan) { |
| 2007 |
$meta_description = $plan['meta_description'] ?? ''; |
| 2008 |
$keywords = $plan['keywords'] ?? array(); |
| 2009 |
$focus_keyword = !empty($keywords) ? $keywords[0] : ''; |
| 2010 |
$keywords_string = implode(', ', $keywords); |
| 2011 |
|
| 2012 |
$this->set_meta_description($post_id, $meta_description); |
| 2013 |
$this->set_focus_keyword($post_id, !empty($focus_keyword) ? $focus_keyword : $keywords_string); |
| 2014 |
} |
| 2015 |
|
| 2016 |
// ─── AI Model Caller ──────────────────────────────────────────────── |
| 2017 |
|
| 2018 |
/** |
| 2019 |
* Call the configured content model |
| 2020 |
*/ |
| 2021 |
private function call_content_model($system_prompt, $messages, $max_tokens = 4096) { |
| 2022 |
$options = get_option('mxchat_options', array()); |
| 2023 |
$model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.1-chat-latest'; |
| 2024 |
|
| 2025 |
// Determine provider from model name |
| 2026 |
if ($this->is_claude_model($model)) { |
| 2027 |
return $this->call_claude($model, $options['claude_api_key'] ?? '', $system_prompt, $messages, $max_tokens); |
| 2028 |
} elseif ($this->is_gemini_model($model)) { |
| 2029 |
return $this->call_gemini($model, $options['gemini_api_key'] ?? '', $system_prompt, $messages, $max_tokens); |
| 2030 |
} elseif ($this->is_xai_model($model)) { |
| 2031 |
return $this->call_openai_compatible($model, $options['xai_api_key'] ?? '', 'https://api.x.ai/v1/chat/completions', $system_prompt, $messages, $max_tokens); |
| 2032 |
} elseif ($this->is_deepseek_model($model)) { |
| 2033 |
return $this->call_openai_compatible($model, $options['deepseek_api_key'] ?? '', 'https://api.deepseek.com/chat/completions', $system_prompt, $messages, $max_tokens); |
| 2034 |
} else { |
| 2035 |
// Default: OpenAI |
| 2036 |
return $this->call_openai_compatible($model, $options['api_key'] ?? '', 'https://api.openai.com/v1/chat/completions', $system_prompt, $messages, $max_tokens); |
| 2037 |
} |
| 2038 |
} |
| 2039 |
|
| 2040 |
/** |
| 2041 |
* Call OpenAI-compatible API (OpenAI, xAI, DeepSeek) |
| 2042 |
*/ |
| 2043 |
private function call_openai_compatible($model, $api_key, $endpoint, $system_prompt, $messages, $max_tokens) { |
| 2044 |
if (empty($api_key)) { |
| 2045 |
return new WP_Error('no_api_key', __('API key not configured for the selected content model.', 'mxchat')); |
| 2046 |
} |
| 2047 |
|
| 2048 |
$formatted = array(); |
| 2049 |
$formatted[] = array('role' => 'system', 'content' => $system_prompt); |
| 2050 |
foreach ($messages as $msg) { |
| 2051 |
$formatted[] = array( |
| 2052 |
'role' => $msg['role'] ?? 'user', |
| 2053 |
'content' => $msg['content'] ?? '', |
| 2054 |
); |
| 2055 |
} |
| 2056 |
|
| 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'; |
| 2060 |
|
| 2061 |
$body = array( |
| 2062 |
'model' => $model, |
| 2063 |
'messages' => $formatted, |
| 2064 |
$token_key => $max_tokens, |
| 2065 |
'stream' => false, |
| 2066 |
); |
| 2067 |
|
| 2068 |
if (!$is_gpt5) { |
| 2069 |
$body['temperature'] = 0.7; |
| 2070 |
} |
| 2071 |
|
| 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 |
} |
| 2081 |
} |
| 2082 |
|
| 2083 |
// Scale timeout with token count — large generation calls need more time |
| 2084 |
$timeout = ($max_tokens > 8000) ? 300 : 120; |
| 2085 |
|
| 2086 |
$response = wp_remote_post($endpoint, array( |
| 2087 |
'headers' => array( |
| 2088 |
'Authorization' => 'Bearer ' . $api_key, |
| 2089 |
'Content-Type' => 'application/json', |
| 2090 |
), |
| 2091 |
'body' => wp_json_encode($body), |
| 2092 |
'timeout' => $timeout, |
| 2093 |
)); |
| 2094 |
|
| 2095 |
if (is_wp_error($response)) { |
| 2096 |
return $response; |
| 2097 |
} |
| 2098 |
|
| 2099 |
$status_code = wp_remote_retrieve_response_code($response); |
| 2100 |
$decoded = json_decode(wp_remote_retrieve_body($response), true); |
| 2101 |
|
| 2102 |
if ($status_code !== 200) { |
| 2103 |
$error_msg = $decoded['error']['message'] ?? __('API request failed with status ', 'mxchat') . $status_code; |
| 2104 |
return new WP_Error('api_error', $error_msg); |
| 2105 |
} |
| 2106 |
|
| 2107 |
if (isset($decoded['choices'][0]['message']['content'])) { |
| 2108 |
return trim($decoded['choices'][0]['message']['content']); |
| 2109 |
} |
| 2110 |
|
| 2111 |
return new WP_Error('unexpected_response', __('Unexpected API response format.', 'mxchat')); |
| 2112 |
} |
| 2113 |
|
| 2114 |
/** |
| 2115 |
* Call Claude (Anthropic) API |
| 2116 |
*/ |
| 2117 |
private function call_claude($model, $api_key, $system_prompt, $messages, $max_tokens) { |
| 2118 |
if (empty($api_key)) { |
| 2119 |
return new WP_Error('no_api_key', __('Claude API key not configured.', 'mxchat')); |
| 2120 |
} |
| 2121 |
|
| 2122 |
$formatted = array(); |
| 2123 |
foreach ($messages as $msg) { |
| 2124 |
$role = $msg['role'] ?? 'user'; |
| 2125 |
if (!in_array($role, array('user', 'assistant'), true)) { |
| 2126 |
$role = 'user'; |
| 2127 |
} |
| 2128 |
$formatted[] = array( |
| 2129 |
'role' => $role, |
| 2130 |
'content' => $msg['content'] ?? '', |
| 2131 |
); |
| 2132 |
} |
| 2133 |
|
| 2134 |
$body = array( |
| 2135 |
'model' => $model, |
| 2136 |
'max_tokens' => $max_tokens, |
| 2137 |
'temperature' => 0.7, |
| 2138 |
'messages' => $formatted, |
| 2139 |
'system' => $system_prompt, |
| 2140 |
); |
| 2141 |
|
| 2142 |
$timeout = ($max_tokens > 8000) ? 300 : 120; |
| 2143 |
|
| 2144 |
$response = wp_remote_post('https://api.anthropic.com/v1/messages', array( |
| 2145 |
'headers' => array( |
| 2146 |
'Content-Type' => 'application/json', |
| 2147 |
'x-api-key' => $api_key, |
| 2148 |
'anthropic-version' => '2023-06-01', |
| 2149 |
), |
| 2150 |
'body' => wp_json_encode($body), |
| 2151 |
'timeout' => $timeout, |
| 2152 |
)); |
| 2153 |
|
| 2154 |
if (is_wp_error($response)) { |
| 2155 |
return $response; |
| 2156 |
} |
| 2157 |
|
| 2158 |
$status_code = wp_remote_retrieve_response_code($response); |
| 2159 |
$decoded = json_decode(wp_remote_retrieve_body($response), true); |
| 2160 |
|
| 2161 |
if ($status_code !== 200) { |
| 2162 |
$error_msg = $decoded['error']['message'] ?? __('Claude API error ', 'mxchat') . $status_code; |
| 2163 |
return new WP_Error('claude_error', $error_msg); |
| 2164 |
} |
| 2165 |
|
| 2166 |
if (isset($decoded['content'][0]['text'])) { |
| 2167 |
return trim($decoded['content'][0]['text']); |
| 2168 |
} |
| 2169 |
|
| 2170 |
return new WP_Error('unexpected_response', __('Unexpected Claude response format.', 'mxchat')); |
| 2171 |
} |
| 2172 |
|
| 2173 |
|
| 2174 |
/** |
| 2175 |
* Call Gemini API |
| 2176 |
*/ |
| 2177 |
private function call_gemini($model, $api_key, $system_prompt, $messages, $max_tokens) { |
| 2178 |
if (empty($api_key)) { |
| 2179 |
return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat')); |
| 2180 |
} |
| 2181 |
|
| 2182 |
$formatted = array(); |
| 2183 |
|
| 2184 |
// System instructions as first user message |
| 2185 |
$formatted[] = array( |
| 2186 |
'role' => 'user', |
| 2187 |
'parts' => array(array('text' => "[System Instructions] " . $system_prompt)), |
| 2188 |
); |
| 2189 |
$formatted[] = array( |
| 2190 |
'role' => 'model', |
| 2191 |
'parts' => array(array('text' => "I understand and will follow these instructions.")), |
| 2192 |
); |
| 2193 |
|
| 2194 |
foreach ($messages as $msg) { |
| 2195 |
$role = ($msg['role'] ?? 'user') === 'assistant' ? 'model' : 'user'; |
| 2196 |
$formatted[] = array( |
| 2197 |
'role' => $role, |
| 2198 |
'parts' => array(array('text' => $msg['content'] ?? '')), |
| 2199 |
); |
| 2200 |
} |
| 2201 |
|
| 2202 |
$body = array( |
| 2203 |
'contents' => $formatted, |
| 2204 |
'generationConfig' => array( |
| 2205 |
'temperature' => 0.7, |
| 2206 |
'topP' => 0.95, |
| 2207 |
'topK' => 40, |
| 2208 |
'maxOutputTokens' => $max_tokens, |
| 2209 |
), |
| 2210 |
); |
| 2211 |
|
| 2212 |
$api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1'; |
| 2213 |
$url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key={$api_key}"; |
| 2214 |
|
| 2215 |
$timeout = ($max_tokens > 8000) ? 300 : 120; |
| 2216 |
|
| 2217 |
$response = wp_remote_post($url, array( |
| 2218 |
'headers' => array('Content-Type' => 'application/json'), |
| 2219 |
'body' => wp_json_encode($body), |
| 2220 |
'timeout' => $timeout, |
| 2221 |
)); |
| 2222 |
|
| 2223 |
if (is_wp_error($response)) { |
| 2224 |
return $response; |
| 2225 |
} |
| 2226 |
|
| 2227 |
$status_code = wp_remote_retrieve_response_code($response); |
| 2228 |
$decoded = json_decode(wp_remote_retrieve_body($response), true); |
| 2229 |
|
| 2230 |
if ($status_code !== 200) { |
| 2231 |
$error_msg = $decoded['error']['message'] ?? __('Gemini API error ', 'mxchat') . $status_code; |
| 2232 |
return new WP_Error('gemini_error', $error_msg); |
| 2233 |
} |
| 2234 |
|
| 2235 |
if (isset($decoded['candidates'][0]['content']['parts'][0]['text'])) { |
| 2236 |
return trim($decoded['candidates'][0]['content']['parts'][0]['text']); |
| 2237 |
} |
| 2238 |
|
| 2239 |
return new WP_Error('unexpected_response', __('Unexpected Gemini response format.', 'mxchat')); |
| 2240 |
} |
| 2241 |
|
| 2242 |
// ─── Model Detection Helpers ──────────────────────────────────────── |
| 2243 |
|
| 2244 |
private function is_claude_model($model) { |
| 2245 |
return strpos($model, 'claude') === 0; |
| 2246 |
} |
| 2247 |
|
| 2248 |
private function is_gemini_model($model) { |
| 2249 |
return strpos($model, 'gemini') === 0; |
| 2250 |
} |
| 2251 |
|
| 2252 |
private function is_xai_model($model) { |
| 2253 |
return strpos($model, 'grok') === 0; |
| 2254 |
} |
| 2255 |
|
| 2256 |
private function is_deepseek_model($model) { |
| 2257 |
return strpos($model, 'deepseek') === 0; |
| 2258 |
} |
| 2259 |
|
| 2260 |
// ─── Content Settings Save ──────────────────────────────────────── |
| 2261 |
|
| 2262 |
/** |
| 2263 |
* Dedicated handler for saving content generator settings. |
| 2264 |
* Bypasses the main settings handler entirely. |
| 2265 |
*/ |
| 2266 |
public function handle_save_content_setting() { |
| 2267 |
check_ajax_referer('mxchat_content_nonce', 'nonce'); |
| 2268 |
|
| 2269 |
if (!current_user_can('manage_options')) { |
| 2270 |
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat'))); |
| 2271 |
} |
| 2272 |
|
| 2273 |
$field = sanitize_text_field($_POST['field'] ?? ''); |
| 2274 |
$value = sanitize_text_field($_POST['value'] ?? ''); |
| 2275 |
|
| 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'); |
| 2277 |
if (!in_array($field, $allowed_fields, true)) { |
| 2278 |
wp_send_json_error(array('message' => __('Invalid field.', 'mxchat'))); |
| 2279 |
} |
| 2280 |
|
| 2281 |
// Toggle fields |
| 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)) { |
| 2283 |
$value = ($value === 'on') ? 'on' : 'off'; |
| 2284 |
} |
| 2285 |
|
| 2286 |
$options = get_option('mxchat_options', array()); |
| 2287 |
$options[$field] = $value; |
| 2288 |
update_option('mxchat_options', $options); |
| 2289 |
|
| 2290 |
wp_send_json_success(array('message' => __('Setting saved.', 'mxchat'))); |
| 2291 |
} |
| 2292 |
|
| 2293 |
// ─── Progress Tracking ───────────────────────────────────────────── |
| 2294 |
|
| 2295 |
/** |
| 2296 |
* Update generation progress. |
| 2297 |
* Uses wp_options directly (not transients) to avoid object cache |
| 2298 |
* stale-read issues across the background worker and polling processes. |
| 2299 |
*/ |
| 2300 |
private function update_progress($key, $step, $message, $percent, $result = null) { |
| 2301 |
global $wpdb; |
| 2302 |
|
| 2303 |
$data = array( |
| 2304 |
'step' => $step, |
| 2305 |
'message' => $message, |
| 2306 |
'percent' => $percent, |
| 2307 |
'updated' => time(), |
| 2308 |
); |
| 2309 |
if ($result !== null) { |
| 2310 |
$data['result'] = $result; |
| 2311 |
} |
| 2312 |
|
| 2313 |
$option_name = '_mxchat_progress_' . $key; |
| 2314 |
$serialized = maybe_serialize($data); |
| 2315 |
|
| 2316 |
// Direct DB write — bypasses object cache entirely |
| 2317 |
$exists = $wpdb->get_var($wpdb->prepare( |
| 2318 |
"SELECT COUNT(*) FROM $wpdb->options WHERE option_name = %s", |
| 2319 |
$option_name |
| 2320 |
)); |
| 2321 |
|
| 2322 |
if ($exists) { |
| 2323 |
$wpdb->update( |
| 2324 |
$wpdb->options, |
| 2325 |
array('option_value' => $serialized), |
| 2326 |
array('option_name' => $option_name) |
| 2327 |
); |
| 2328 |
} else { |
| 2329 |
$wpdb->insert( |
| 2330 |
$wpdb->options, |
| 2331 |
array( |
| 2332 |
'option_name' => $option_name, |
| 2333 |
'option_value' => $serialized, |
| 2334 |
'autoload' => 'no', |
| 2335 |
) |
| 2336 |
); |
| 2337 |
} |
| 2338 |
|
| 2339 |
// Also bust the object cache in case anything reads via get_option() |
| 2340 |
wp_cache_delete($option_name, 'options'); |
| 2341 |
} |
| 2342 |
|
| 2343 |
/** |
| 2344 |
* Read generation progress. |
| 2345 |
* Direct DB read — bypasses object cache for guaranteed freshness. |
| 2346 |
*/ |
| 2347 |
private function get_progress($key) { |
| 2348 |
global $wpdb; |
| 2349 |
|
| 2350 |
$option_name = '_mxchat_progress_' . $key; |
| 2351 |
|
| 2352 |
$value = $wpdb->get_var($wpdb->prepare( |
| 2353 |
"SELECT option_value FROM $wpdb->options WHERE option_name = %s", |
| 2354 |
$option_name |
| 2355 |
)); |
| 2356 |
|
| 2357 |
if ($value === null) { |
| 2358 |
return false; |
| 2359 |
} |
| 2360 |
|
| 2361 |
return maybe_unserialize($value); |
| 2362 |
} |
| 2363 |
|
| 2364 |
/** |
| 2365 |
* Delete generation progress (cleanup). |
| 2366 |
*/ |
| 2367 |
private function delete_progress($key) { |
| 2368 |
global $wpdb; |
| 2369 |
|
| 2370 |
$option_name = '_mxchat_progress_' . $key; |
| 2371 |
$wpdb->delete($wpdb->options, array('option_name' => $option_name)); |
| 2372 |
wp_cache_delete($option_name, 'options'); |
| 2373 |
} |
| 2374 |
|
| 2375 |
|
| 2376 |
// ─── Content History ────────────────────────────────────────────── |
| 2377 |
|
| 2378 |
/** |
| 2379 |
* Return paginated list of AI-generated posts for the History tab. |
| 2380 |
*/ |
| 2381 |
public function handle_content_history() { |
| 2382 |
check_ajax_referer('mxchat_content_nonce', 'nonce'); |
| 2383 |
|
| 2384 |
if (!current_user_can('manage_options')) { |
| 2385 |
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat'))); |
| 2386 |
} |
| 2387 |
|
| 2388 |
$page = max(1, intval($_POST['page'] ?? 1)); |
| 2389 |
$per_page = 10; |
| 2390 |
|
| 2391 |
$query = new WP_Query(array( |
| 2392 |
'post_type' => array('post', 'page'), |
| 2393 |
'post_status' => array('publish', 'draft', 'future', 'pending', 'private'), |
| 2394 |
'meta_key' => '_mxchat_generated', |
| 2395 |
'meta_value' => '1', |
| 2396 |
'posts_per_page' => $per_page, |
| 2397 |
'paged' => $page, |
| 2398 |
'orderby' => 'date', |
| 2399 |
'order' => 'DESC', |
| 2400 |
)); |
| 2401 |
|
| 2402 |
$items = array(); |
| 2403 |
foreach ($query->posts as $post) { |
| 2404 |
$thumb = get_the_post_thumbnail_url($post->ID, 'thumbnail'); |
| 2405 |
$items[] = array( |
| 2406 |
'post_id' => $post->ID, |
| 2407 |
'title' => $post->post_title, |
| 2408 |
'status' => $post->post_status, |
| 2409 |
'post_type' => $post->post_type, |
| 2410 |
'date' => get_the_date('M j, Y', $post), |
| 2411 |
'thumbnail' => $thumb ? $thumb : '', |
| 2412 |
'permalink' => get_permalink($post->ID), |
| 2413 |
); |
| 2414 |
} |
| 2415 |
|
| 2416 |
wp_send_json_success(array( |
| 2417 |
'items' => $items, |
| 2418 |
'total' => (int) $query->found_posts, |
| 2419 |
'total_pages' => (int) $query->max_num_pages, |
| 2420 |
'current_page' => $page, |
| 2421 |
)); |
| 2422 |
} |
| 2423 |
|
| 2424 |
/** |
| 2425 |
* Move an AI-generated post to the trash. |
| 2426 |
*/ |
| 2427 |
public function handle_delete_content() { |
| 2428 |
check_ajax_referer('mxchat_content_nonce', 'nonce'); |
| 2429 |
|
| 2430 |
if (!current_user_can('manage_options')) { |
| 2431 |
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat'))); |
| 2432 |
} |
| 2433 |
|
| 2434 |
$post_id = intval($_POST['post_id'] ?? 0); |
| 2435 |
if (!$post_id) { |
| 2436 |
wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat'))); |
| 2437 |
} |
| 2438 |
|
| 2439 |
// Only allow deleting MxChat-generated content |
| 2440 |
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') { |
| 2441 |
wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat'))); |
| 2442 |
} |
| 2443 |
|
| 2444 |
$result = wp_trash_post($post_id); |
| 2445 |
if (!$result) { |
| 2446 |
wp_send_json_error(array('message' => __('Failed to delete post.', 'mxchat'))); |
| 2447 |
} |
| 2448 |
|
| 2449 |
wp_send_json_success(array('post_id' => $post_id)); |
| 2450 |
} |
| 2451 |
|
| 2452 |
/** |
| 2453 |
* Update the status of an AI-generated post (draft, publish, future). |
| 2454 |
*/ |
| 2455 |
public function handle_update_post_status() { |
| 2456 |
check_ajax_referer('mxchat_content_nonce', 'nonce'); |
| 2457 |
|
| 2458 |
if (!current_user_can('manage_options')) { |
| 2459 |
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat'))); |
| 2460 |
} |
| 2461 |
|
| 2462 |
$post_id = intval($_POST['post_id'] ?? 0); |
| 2463 |
$new_status = sanitize_text_field($_POST['new_status'] ?? ''); |
| 2464 |
$schedule_date = sanitize_text_field($_POST['schedule_date'] ?? ''); |
| 2465 |
|
| 2466 |
if (!$post_id) { |
| 2467 |
wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat'))); |
| 2468 |
} |
| 2469 |
|
| 2470 |
// Only allow updating MxChat-generated content |
| 2471 |
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') { |
| 2472 |
wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat'))); |
| 2473 |
} |
| 2474 |
|
| 2475 |
if (!in_array($new_status, array('draft', 'publish', 'future'), true)) { |
| 2476 |
wp_send_json_error(array('message' => __('Invalid status.', 'mxchat'))); |
| 2477 |
} |
| 2478 |
|
| 2479 |
$post_args = array('ID' => $post_id, 'post_status' => $new_status); |
| 2480 |
|
| 2481 |
// Scheduled: require future date |
| 2482 |
if ($new_status === 'future') { |
| 2483 |
if (empty($schedule_date)) { |
| 2484 |
wp_send_json_error(array('message' => __('A schedule date is required.', 'mxchat'))); |
| 2485 |
} |
| 2486 |
$post_args['post_date'] = $schedule_date; |
| 2487 |
$post_args['post_date_gmt'] = get_gmt_from_date($schedule_date); |
| 2488 |
$post_args['edit_date'] = true; |
| 2489 |
} |
| 2490 |
|
| 2491 |
// Transitioning FROM future to draft/publish: reset post_date to now |
| 2492 |
if ($new_status !== 'future') { |
| 2493 |
$current = get_post($post_id); |
| 2494 |
if ($current && $current->post_status === 'future') { |
| 2495 |
$post_args['post_date'] = current_time('mysql'); |
| 2496 |
$post_args['post_date_gmt'] = current_time('mysql', true); |
| 2497 |
$post_args['edit_date'] = true; |
| 2498 |
} |
| 2499 |
} |
| 2500 |
|
| 2501 |
$result = wp_update_post($post_args, true); |
| 2502 |
if (is_wp_error($result)) { |
| 2503 |
wp_send_json_error(array('message' => $result->get_error_message())); |
| 2504 |
} |
| 2505 |
|
| 2506 |
// Re-read to get confirmed status (WP may auto-publish if schedule date is past) |
| 2507 |
$updated = get_post($post_id); |
| 2508 |
|
| 2509 |
wp_send_json_success(array( |
| 2510 |
'post_id' => $post_id, |
| 2511 |
'status' => $updated->post_status, |
| 2512 |
)); |
| 2513 |
} |
| 2514 |
|
| 2515 |
/** |
| 2516 |
* Load an existing AI-generated post into the editor state. |
| 2517 |
* Returns the same data shape as the generation success response. |
| 2518 |
*/ |
| 2519 |
public function handle_load_post_for_edit() { |
| 2520 |
check_ajax_referer('mxchat_content_nonce', 'nonce'); |
| 2521 |
|
| 2522 |
if (!current_user_can('manage_options')) { |
| 2523 |
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat'))); |
| 2524 |
} |
| 2525 |
|
| 2526 |
$post_id = intval($_POST['post_id'] ?? 0); |
| 2527 |
if (!$post_id) { |
| 2528 |
wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat'))); |
| 2529 |
} |
| 2530 |
|
| 2531 |
$post = get_post($post_id); |
| 2532 |
if (!$post) { |
| 2533 |
wp_send_json_error(array('message' => __('Post not found.', 'mxchat'))); |
| 2534 |
} |
| 2535 |
|
| 2536 |
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') { |
| 2537 |
wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat'))); |
| 2538 |
} |
| 2539 |
|
| 2540 |
$preview_url = add_query_arg(array('preview' => 'true'), get_permalink($post_id)); |
| 2541 |
$edit_url = admin_url('post.php?post=' . $post_id . '&action=edit'); |
| 2542 |
$permalink = get_permalink($post_id); |
| 2543 |
|
| 2544 |
// Discover images — prefer stored IDs, fall back to post_parent query |
| 2545 |
$images = $this->discover_post_images($post_id); |
| 2546 |
|
| 2547 |
wp_send_json_success(array( |
| 2548 |
'post_id' => $post_id, |
| 2549 |
'preview_url' => $preview_url, |
| 2550 |
'edit_url' => $edit_url, |
| 2551 |
'permalink' => $permalink, |
| 2552 |
'title' => $post->post_title, |
| 2553 |
'status' => $post->post_status, |
| 2554 |
'images' => $images, |
| 2555 |
'meta' => array( |
| 2556 |
'description' => $this->get_meta_description($post_id), |
| 2557 |
'keyword' => $this->get_focus_keyword($post_id), |
| 2558 |
'excerpt' => $post->post_excerpt, |
| 2559 |
), |
| 2560 |
)); |
| 2561 |
} |
| 2562 |
|
| 2563 |
/** |
| 2564 |
* Discover images associated with a generated post. |
| 2565 |
* Uses stored attachment IDs (most reliable), falls back to post_parent query. |
| 2566 |
*/ |
| 2567 |
private function discover_post_images($post_id) { |
| 2568 |
$images = array(); |
| 2569 |
$stored_ids = get_post_meta($post_id, '_mxchat_image_ids', true); |
| 2570 |
|
| 2571 |
if (!empty($stored_ids) && is_array($stored_ids)) { |
| 2572 |
// Primary: use stored attachment IDs |
| 2573 |
foreach ($stored_ids as $att_id) { |
| 2574 |
$att_url = wp_get_attachment_url($att_id); |
| 2575 |
if ($att_url) { |
| 2576 |
$thumb_url = wp_get_attachment_image_url($att_id, 'medium'); |
| 2577 |
$images[] = array( |
| 2578 |
'url' => $att_url, |
| 2579 |
'thumbnail' => $thumb_url ?: $att_url, |
| 2580 |
'attachment_id' => $att_id, |
| 2581 |
); |
| 2582 |
} |
| 2583 |
} |
| 2584 |
} else { |
| 2585 |
// Fallback: query by post_parent + meta key |
| 2586 |
$attachments = get_posts(array( |
| 2587 |
'post_type' => 'attachment', |
| 2588 |
'post_mime_type' => 'image', |
| 2589 |
'posts_per_page' => -1, |
| 2590 |
'post_parent' => $post_id, |
| 2591 |
'meta_key' => '_mxchat_image_prompt', |
| 2592 |
'meta_compare' => 'EXISTS', |
| 2593 |
'orderby' => 'date', |
| 2594 |
'order' => 'ASC', |
| 2595 |
)); |
| 2596 |
foreach ($attachments as $att) { |
| 2597 |
$att_url = wp_get_attachment_url($att->ID); |
| 2598 |
if ($att_url) { |
| 2599 |
$thumb_url = wp_get_attachment_image_url($att->ID, 'medium'); |
| 2600 |
$images[] = array( |
| 2601 |
'url' => $att_url, |
| 2602 |
'thumbnail' => $thumb_url ?: $att_url, |
| 2603 |
'attachment_id' => $att->ID, |
| 2604 |
); |
| 2605 |
} |
| 2606 |
} |
| 2607 |
} |
| 2608 |
|
| 2609 |
// Fallback: include featured image if nothing else found |
| 2610 |
$featured_id = get_post_thumbnail_id($post_id); |
| 2611 |
if (empty($images) && $featured_id) { |
| 2612 |
$feat_url = wp_get_attachment_url($featured_id); |
| 2613 |
$feat_thumb = wp_get_attachment_image_url($featured_id, 'medium'); |
| 2614 |
if ($feat_url) { |
| 2615 |
$images[] = array( |
| 2616 |
'url' => $feat_url, |
| 2617 |
'thumbnail' => $feat_thumb ?: $feat_url, |
| 2618 |
'attachment_id' => $featured_id, |
| 2619 |
); |
| 2620 |
} |
| 2621 |
} |
| 2622 |
|
| 2623 |
return $images; |
| 2624 |
} |
| 2625 |
|
| 2626 |
/** |
| 2627 |
* Get the focus keyword (first keyword from comma-separated list). |
| 2628 |
*/ |
| 2629 |
private function get_seo_plugin() { |
| 2630 |
if (class_exists('RankMath')) return 'rankmath'; |
| 2631 |
if (defined('WPSEO_VERSION')) return 'yoast'; |
| 2632 |
if (function_exists('aioseo')) return 'aioseo'; |
| 2633 |
return 'none'; |
| 2634 |
} |
| 2635 |
|
| 2636 |
private function get_meta_description($post_id) { |
| 2637 |
$plugin = $this->get_seo_plugin(); |
| 2638 |
$keys = array( |
| 2639 |
'rankmath' => 'rank_math_description', |
| 2640 |
'yoast' => '_yoast_wpseo_metadesc', |
| 2641 |
'aioseo' => '_aioseo_description', |
| 2642 |
); |
| 2643 |
if (isset($keys[$plugin])) { |
| 2644 |
$val = get_post_meta($post_id, $keys[$plugin], true); |
| 2645 |
if (!empty($val)) return $val; |
| 2646 |
} |
| 2647 |
return get_post_meta($post_id, '_mxchat_meta_description', true); |
| 2648 |
} |
| 2649 |
|
| 2650 |
private function set_meta_description($post_id, $value) { |
| 2651 |
$value = sanitize_text_field($value); |
| 2652 |
$plugin = $this->get_seo_plugin(); |
| 2653 |
$keys = array( |
| 2654 |
'rankmath' => 'rank_math_description', |
| 2655 |
'yoast' => '_yoast_wpseo_metadesc', |
| 2656 |
'aioseo' => '_aioseo_description', |
| 2657 |
); |
| 2658 |
if (isset($keys[$plugin])) { |
| 2659 |
update_post_meta($post_id, $keys[$plugin], $value); |
| 2660 |
} else { |
| 2661 |
update_post_meta($post_id, '_mxchat_meta_description', $value); |
| 2662 |
} |
| 2663 |
} |
| 2664 |
|
| 2665 |
private function get_focus_keyword($post_id) { |
| 2666 |
$plugin = $this->get_seo_plugin(); |
| 2667 |
$keys = array( |
| 2668 |
'rankmath' => 'rank_math_focus_keyword', |
| 2669 |
'yoast' => '_yoast_wpseo_focuskw', |
| 2670 |
); |
| 2671 |
if (isset($keys[$plugin])) { |
| 2672 |
$val = get_post_meta($post_id, $keys[$plugin], true); |
| 2673 |
if (!empty($val)) { |
| 2674 |
$parts = explode(',', $val); |
| 2675 |
return trim($parts[0]); |
| 2676 |
} |
| 2677 |
} |
| 2678 |
$keywords = get_post_meta($post_id, '_mxchat_keywords', true); |
| 2679 |
if (!empty($keywords)) { |
| 2680 |
$parts = explode(',', $keywords); |
| 2681 |
return trim($parts[0]); |
| 2682 |
} |
| 2683 |
return ''; |
| 2684 |
} |
| 2685 |
|
| 2686 |
private function set_focus_keyword($post_id, $value) { |
| 2687 |
$value = sanitize_text_field($value); |
| 2688 |
$plugin = $this->get_seo_plugin(); |
| 2689 |
$keys = array( |
| 2690 |
'rankmath' => 'rank_math_focus_keyword', |
| 2691 |
'yoast' => '_yoast_wpseo_focuskw', |
| 2692 |
); |
| 2693 |
if (isset($keys[$plugin])) { |
| 2694 |
update_post_meta($post_id, $keys[$plugin], $value); |
| 2695 |
} else { |
| 2696 |
update_post_meta($post_id, '_mxchat_keywords', $value); |
| 2697 |
} |
| 2698 |
} |
| 2699 |
|
| 2700 |
// ─── SEO Analysis ────────────────────────────────────────────── |
| 2701 |
|
| 2702 |
/** |
| 2703 |
* Analyze a generated post for SEO quality. |
| 2704 |
* Returns a 0-100 score with individual check results. |
| 2705 |
*/ |
| 2706 |
public function handle_seo_analyze() { |
| 2707 |
check_ajax_referer('mxchat_content_nonce', 'nonce'); |
| 2708 |
|
| 2709 |
if (!current_user_can('edit_posts')) { |
| 2710 |
wp_send_json_error('Unauthorized'); |
| 2711 |
} |
| 2712 |
|
| 2713 |
$post_id = intval($_POST['post_id'] ?? 0); |
| 2714 |
if (!$post_id || !get_post($post_id)) { |
| 2715 |
wp_send_json_error('Post not found'); |
| 2716 |
} |
| 2717 |
|
| 2718 |
$result = $this->seo_score_post($post_id); |
| 2719 |
wp_send_json_success($result); |
| 2720 |
} |
| 2721 |
|
| 2722 |
public function handle_seo_analyze_batch() { |
| 2723 |
check_ajax_referer('mxchat_content_nonce', 'nonce'); |
| 2724 |
|
| 2725 |
if (!current_user_can('edit_posts')) { |
| 2726 |
wp_send_json_error('Unauthorized'); |
| 2727 |
} |
| 2728 |
|
| 2729 |
$post_ids = array_map('intval', (array) ($_POST['post_ids'] ?? array())); |
| 2730 |
$post_ids = array_filter($post_ids); |
| 2731 |
if (empty($post_ids) || count($post_ids) > 50) { |
| 2732 |
wp_send_json_error('Invalid post IDs (1-50 allowed)'); |
| 2733 |
} |
| 2734 |
|
| 2735 |
$results = array(); |
| 2736 |
foreach ($post_ids as $pid) { |
| 2737 |
if (get_post($pid)) { |
| 2738 |
$results[$pid] = $this->seo_score_post($pid); |
| 2739 |
} |
| 2740 |
} |
| 2741 |
|
| 2742 |
wp_send_json_success(array('results' => $results)); |
| 2743 |
} |
| 2744 |
|
| 2745 |
private function seo_score_post($post_id) { |
| 2746 |
$post = get_post($post_id); |
| 2747 |
|
| 2748 |
$title = $post->post_title; |
| 2749 |
$content = $post->post_content; |
| 2750 |
$slug = $post->post_name; |
| 2751 |
$meta_desc = $this->get_meta_description($post_id); |
| 2752 |
$focus_kw = $this->get_focus_keyword($post_id); |
| 2753 |
$text = wp_strip_all_tags($content); |
| 2754 |
$word_count = str_word_count($text); |
| 2755 |
|
| 2756 |
// Parse images |
| 2757 |
preg_match_all('/<img[^>]*>/i', $content, $img_matches); |
| 2758 |
$total_images = count($img_matches[0]); |
| 2759 |
$images_with_alt = 0; |
| 2760 |
foreach ($img_matches[0] as $img) { |
| 2761 |
if (preg_match('/alt\s*=\s*["\']([^"\']+)["\']/i', $img, $alt_m) && trim($alt_m[1]) !== '') { |
| 2762 |
$images_with_alt++; |
| 2763 |
} |
| 2764 |
} |
| 2765 |
|
| 2766 |
// Parse links |
| 2767 |
preg_match_all('/<a[^>]+href\s*=\s*["\']([^"\']+)["\']/i', $content, $link_matches); |
| 2768 |
$site_url = home_url(); |
| 2769 |
$internal_links = 0; |
| 2770 |
if (!empty($link_matches[1])) { |
| 2771 |
foreach ($link_matches[1] as $href) { |
| 2772 |
if (strpos($href, $site_url) === 0 || (strpos($href, '/') === 0 && strpos($href, '//') !== 0)) { |
| 2773 |
$internal_links++; |
| 2774 |
} |
| 2775 |
} |
| 2776 |
} |
| 2777 |
|
| 2778 |
// Parse headings |
| 2779 |
preg_match_all('/<h([1-6])[^>]*>/i', $content, $h_matches); |
| 2780 |
$heading_count = count($h_matches[0]); |
| 2781 |
$has_subheadings = false; |
| 2782 |
foreach ($h_matches[1] as $lvl) { |
| 2783 |
if ($lvl >= 2) { $has_subheadings = true; break; } |
| 2784 |
} |
| 2785 |
|
| 2786 |
$checks = array(); |
| 2787 |
$score = 100; |
| 2788 |
|
| 2789 |
// 1. Title length |
| 2790 |
$tl = mb_strlen($title); |
| 2791 |
if ($tl === 0) $checks['title_length'] = array('status' => 'fail', 'label' => 'Page Title', 'detail' => 'Missing', 'penalty' => 20); |
| 2792 |
elseif ($tl < 30) $checks['title_length'] = array('status' => 'warn', 'label' => 'Page Title', 'detail' => $tl . ' chars — too short (aim for 50–60)', 'penalty' => 5); |
| 2793 |
elseif ($tl > 70) $checks['title_length'] = array('status' => 'warn', 'label' => 'Page Title', 'detail' => $tl . ' chars — may truncate in search', 'penalty' => 3); |
| 2794 |
else $checks['title_length'] = array('status' => 'pass', 'label' => 'Page Title', 'detail' => $tl . ' chars — good length', 'penalty' => 0); |
| 2795 |
|
| 2796 |
// 2. Meta description |
| 2797 |
$ml = mb_strlen($meta_desc); |
| 2798 |
if ($ml === 0) $checks['meta_desc'] = array('status' => 'fail', 'label' => 'Meta Description', 'detail' => 'Missing — engines will auto-generate one', 'penalty' => 15); |
| 2799 |
elseif ($ml < 120) $checks['meta_desc'] = array('status' => 'warn', 'label' => 'Meta Description', 'detail' => $ml . ' chars — could be longer (150–160)', 'penalty' => 3); |
| 2800 |
elseif ($ml > 160) $checks['meta_desc'] = array('status' => 'warn', 'label' => 'Meta Description', 'detail' => $ml . ' chars — may truncate (150–160)', 'penalty' => 3); |
| 2801 |
else $checks['meta_desc'] = array('status' => 'pass', 'label' => 'Meta Description', 'detail' => $ml . ' chars — good length', 'penalty' => 0); |
| 2802 |
|
| 2803 |
// 3. Focus keyword placement |
| 2804 |
if (empty($focus_kw)) { |
| 2805 |
$checks['focus_kw'] = array('status' => 'warn', 'label' => 'Focus Keyword', 'detail' => 'Not set — helps guide optimization', 'penalty' => 5); |
| 2806 |
} else { |
| 2807 |
$places = array(); |
| 2808 |
if (mb_stripos($title, $focus_kw) !== false) $places[] = 'title'; |
| 2809 |
if (mb_stripos($meta_desc, $focus_kw) !== false) $places[] = 'meta'; |
| 2810 |
if (mb_stripos($text, $focus_kw) !== false) $places[] = 'content'; |
| 2811 |
if (stripos($slug, str_replace(' ', '-', strtolower($focus_kw))) !== false) $places[] = 'slug'; |
| 2812 |
|
| 2813 |
if (count($places) >= 3) $checks['focus_kw'] = array('status' => 'pass', 'label' => 'Focus Keyword', 'detail' => 'Found in ' . implode(', ', $places), 'penalty' => 0); |
| 2814 |
elseif (count($places) >= 1) { |
| 2815 |
$miss = array_diff(array('title', 'meta', 'content', 'slug'), $places); |
| 2816 |
$checks['focus_kw'] = array('status' => 'warn', 'label' => 'Focus Keyword', 'detail' => 'In ' . implode(', ', $places) . ' — missing from ' . implode(', ', array_slice($miss, 0, 2)), 'penalty' => 5); |
| 2817 |
} else $checks['focus_kw'] = array('status' => 'fail', 'label' => 'Focus Keyword', 'detail' => '"' . esc_html($focus_kw) . '" not found in content', 'penalty' => 10); |
| 2818 |
} |
| 2819 |
|
| 2820 |
// 4. Content depth |
| 2821 |
if ($word_count < 300) $checks['content_depth'] = array('status' => 'fail', 'label' => 'Content Depth', 'detail' => $word_count . ' words — thin (aim for 800+)', 'penalty' => 12); |
| 2822 |
elseif ($word_count < 600) $checks['content_depth'] = array('status' => 'warn', 'label' => 'Content Depth', 'detail' => $word_count . ' words — light (800+ ideal)', 'penalty' => 5); |
| 2823 |
else $checks['content_depth'] = array('status' => 'pass', 'label' => 'Content Depth', 'detail' => number_format($word_count) . ' words', 'penalty' => 0); |
| 2824 |
|
| 2825 |
// 5. Heading structure |
| 2826 |
if ($heading_count === 0) $checks['headings'] = array('status' => 'fail', 'label' => 'Heading Structure', 'detail' => 'No headings — add H2s to organize content', 'penalty' => 10); |
| 2827 |
elseif (!$has_subheadings) $checks['headings'] = array('status' => 'warn', 'label' => 'Heading Structure', 'detail' => 'Missing subheadings (H2/H3)', 'penalty' => 5); |
| 2828 |
else $checks['headings'] = array('status' => 'pass', 'label' => 'Heading Structure', 'detail' => $heading_count . ' headings — well structured', 'penalty' => 0); |
| 2829 |
|
| 2830 |
// 6. Image ALT text |
| 2831 |
if ($total_images === 0) { |
| 2832 |
$checks['img_alt'] = array('status' => 'warn', 'label' => 'Image ALT Text', 'detail' => 'No images found', 'penalty' => 2); |
| 2833 |
} else { |
| 2834 |
$missing = $total_images - $images_with_alt; |
| 2835 |
$checks['img_alt'] = $missing === 0 |
| 2836 |
? array('status' => 'pass', 'label' => 'Image ALT Text', 'detail' => 'All ' . $total_images . ' images have ALT text', 'penalty' => 0) |
| 2837 |
: array('status' => 'fail', 'label' => 'Image ALT Text', 'detail' => $missing . '/' . $total_images . ' missing ALT text', 'penalty' => min($missing * 3, 12)); |
| 2838 |
} |
| 2839 |
|
| 2840 |
// 7. Internal links |
| 2841 |
if ($internal_links === 0 && $word_count >= 300) |
| 2842 |
$checks['internal_links'] = array('status' => 'fail', 'label' => 'Internal Links', 'detail' => 'None — link to related content', 'penalty' => 8); |
| 2843 |
else |
| 2844 |
$checks['internal_links'] = array('status' => 'pass', 'label' => 'Internal Links', 'detail' => $internal_links ? $internal_links . ' found' : 'Short content — optional', 'penalty' => 0); |
| 2845 |
|
| 2846 |
// 8. Slug quality |
| 2847 |
$sw = count(explode('-', $slug)); |
| 2848 |
if (strlen($slug) > 75) $checks['slug'] = array('status' => 'warn', 'label' => 'URL Slug', 'detail' => 'Too long — shorten to 3–5 words', 'penalty' => 3); |
| 2849 |
elseif ($sw > 8) $checks['slug'] = array('status' => 'warn', 'label' => 'URL Slug', 'detail' => $sw . ' words — keep to 3–5', 'penalty' => 2); |
| 2850 |
else $checks['slug'] = array('status' => 'pass', 'label' => 'URL Slug', 'detail' => '/' . esc_html($slug), 'penalty' => 0); |
| 2851 |
|
| 2852 |
// 9. Readability (Flesch-Kincaid) |
| 2853 |
$sents = max(count(preg_split('/[.!?]+/', $text, -1, PREG_SPLIT_NO_EMPTY)), 1); |
| 2854 |
$syls = $this->seo_count_syllables($text); |
| 2855 |
$fk = max(0, min(100, round(206.835 - 1.015 * ($word_count / $sents) - 84.6 * ($syls / max($word_count, 1))))); |
| 2856 |
if ($fk >= 60) $checks['readability'] = array('status' => 'pass', 'label' => 'Readability', 'detail' => 'Score ' . $fk . ' — easy to read', 'penalty' => 0); |
| 2857 |
elseif ($fk >= 40) $checks['readability'] = array('status' => 'warn', 'label' => 'Readability', 'detail' => 'Score ' . $fk . ' — somewhat complex', 'penalty' => 3); |
| 2858 |
else $checks['readability'] = array('status' => 'fail', 'label' => 'Readability', 'detail' => 'Score ' . $fk . ' — hard to read, simplify', 'penalty' => 7); |
| 2859 |
|
| 2860 |
// 10. Featured image |
| 2861 |
$checks['featured_img'] = has_post_thumbnail($post_id) |
| 2862 |
? array('status' => 'pass', 'label' => 'Featured Image', 'detail' => 'Set', 'penalty' => 0) |
| 2863 |
: array('status' => 'warn', 'label' => 'Featured Image', 'detail' => 'Missing — important for social sharing', 'penalty' => 4); |
| 2864 |
|
| 2865 |
// Calculate score |
| 2866 |
foreach ($checks as $c) { $score -= $c['penalty']; } |
| 2867 |
$score = max(0, min(100, $score)); |
| 2868 |
|
| 2869 |
$pass = $warn = $fail = 0; |
| 2870 |
foreach ($checks as $c) { |
| 2871 |
if ($c['status'] === 'pass') $pass++; |
| 2872 |
elseif ($c['status'] === 'warn') $warn++; |
| 2873 |
else $fail++; |
| 2874 |
} |
| 2875 |
|
| 2876 |
// Cache results to post meta for the SEO dashboard list view |
| 2877 |
update_post_meta($post_id, '_mxchat_seo_score', $score); |
| 2878 |
update_post_meta($post_id, '_mxchat_seo_checks', $checks); |
| 2879 |
update_post_meta($post_id, '_mxchat_seo_analyzed', time()); |
| 2880 |
|
| 2881 |
return array( |
| 2882 |
'score' => $score, |
| 2883 |
'checks' => $checks, |
| 2884 |
'summary' => array('pass' => $pass, 'warn' => $warn, 'fail' => $fail), |
| 2885 |
); |
| 2886 |
} |
| 2887 |
|
| 2888 |
/** |
| 2889 |
* AI-powered SEO suggestion for a specific field. |
| 2890 |
*/ |
| 2891 |
public function handle_seo_suggest() { |
| 2892 |
check_ajax_referer('mxchat_content_nonce', 'nonce'); |
| 2893 |
if (!current_user_can('edit_posts')) { wp_send_json_error('Unauthorized'); } |
| 2894 |
|
| 2895 |
$post_id = intval($_POST['post_id'] ?? 0); |
| 2896 |
$field = sanitize_text_field($_POST['field'] ?? ''); |
| 2897 |
if (!$post_id || !$field || !($post = get_post($post_id))) { |
| 2898 |
wp_send_json_error('Missing parameters'); |
| 2899 |
} |
| 2900 |
|
| 2901 |
$title = $post->post_title; |
| 2902 |
$content = wp_strip_all_tags($post->post_content); |
| 2903 |
$focus_kw = $this->get_focus_keyword($post_id); |
| 2904 |
|
| 2905 |
// Sample content to manage token usage |
| 2906 |
$sample = mb_strlen($content) > 1200 |
| 2907 |
? mb_substr($content, 0, 800) . "\n...\n" . mb_substr($content, -400) |
| 2908 |
: $content; |
| 2909 |
|
| 2910 |
$kw = !empty($focus_kw) ? ' Incorporate the focus keyword "' . $focus_kw . '" naturally.' : ''; |
| 2911 |
|
| 2912 |
$prompts = array( |
| 2913 |
'meta_description' => 'Write a compelling meta description for this blog post. 150-160 characters, include the main topic, entice clicks. Return ONLY the text.' . $kw . "\n\nTitle: " . $title . "\n\nContent:\n" . $sample, |
| 2914 |
'seo_title' => 'Write an SEO-optimized page title. 50-60 characters, keyword near the beginning. Return ONLY the title.' . $kw . "\n\nOriginal: " . $title . "\n\nContent:\n" . $sample, |
| 2915 |
'slug' => 'Generate an SEO-friendly URL slug. 3-5 lowercase words with hyphens, no stop words. Return ONLY the slug.' . $kw . "\n\nTitle: " . $title, |
| 2916 |
'excerpt' => 'Write a concise excerpt in 1-2 sentences, under 200 characters. Return ONLY the text.' . $kw . "\n\nTitle: " . $title . "\n\nContent:\n" . $sample, |
| 2917 |
'readability' => true, // Handled by Advanced Content Editor add-on |
| 2918 |
'internal_links' => true, // Handled by Advanced Content Editor add-on |
| 2919 |
'img_alt' => true, // Handled by Advanced Content Editor add-on |
| 2920 |
'featured_img' => true, // Handled by Advanced Content Editor add-on |
| 2921 |
); |
| 2922 |
|
| 2923 |
if (!isset($prompts[$field])) { wp_send_json_error('Invalid field'); } |
| 2924 |
|
| 2925 |
// These fields are handled by the Advanced Content Editor add-on |
| 2926 |
$addon_fields = array('readability', 'internal_links', 'img_alt', 'featured_img'); |
| 2927 |
if (in_array($field, $addon_fields, true)) { |
| 2928 |
$feature_key = 'seo_' . $field; |
| 2929 |
$has_addon = apply_filters('mxchat_content_pro_feature', false, $feature_key); |
| 2930 |
if (!$has_addon) { |
| 2931 |
wp_send_json_error('This feature requires the Advanced Content Editor add-on.'); |
| 2932 |
return; |
| 2933 |
} |
| 2934 |
// Delegate to add-on via action hook |
| 2935 |
do_action('mxchat_seo_optimize_' . $field, $post_id, $post, $focus_kw); |
| 2936 |
return; |
| 2937 |
} |
| 2938 |
|
| 2939 |
$response = $this->call_content_model( |
| 2940 |
'You are an expert SEO copywriter. Return only what is asked for. No quotes, no explanations, no prefixes.', |
| 2941 |
array(array('role' => 'user', 'content' => $prompts[$field])), |
| 2942 |
256 |
| 2943 |
); |
| 2944 |
|
| 2945 |
if (is_wp_error($response)) { wp_send_json_error($response->get_error_message()); } |
| 2946 |
|
| 2947 |
$suggestion = trim($response); |
| 2948 |
|
| 2949 |
// Save suggestion |
| 2950 |
if ($field === 'meta_description') { |
| 2951 |
$this->set_meta_description($post_id, $suggestion); |
| 2952 |
} elseif ($field === 'seo_title') { |
| 2953 |
wp_update_post(array('ID' => $post_id, 'post_title' => sanitize_text_field($suggestion))); |
| 2954 |
} elseif ($field === 'slug') { |
| 2955 |
wp_update_post(array('ID' => $post_id, 'post_name' => sanitize_title($suggestion))); |
| 2956 |
} elseif ($field === 'excerpt') { |
| 2957 |
wp_update_post(array('ID' => $post_id, 'post_excerpt' => sanitize_text_field($suggestion))); |
| 2958 |
} |
| 2959 |
|
| 2960 |
wp_send_json_success(array('field' => $field, 'suggestion' => $suggestion)); |
| 2961 |
} |
| 2962 |
|
| 2963 |
/** |
| 2964 |
* List published posts/pages with cached SEO scores for the dashboard. |
| 2965 |
*/ |
| 2966 |
public function handle_seo_list_posts() { |
| 2967 |
check_ajax_referer('mxchat_content_nonce', 'nonce'); |
| 2968 |
|
| 2969 |
$page = max(1, intval($_POST['page'] ?? 1)); |
| 2970 |
$per_page = 50; |
| 2971 |
$post_type = sanitize_text_field($_POST['post_type'] ?? 'any'); |
| 2972 |
$filter = sanitize_text_field($_POST['filter'] ?? 'all'); |
| 2973 |
$search = sanitize_text_field($_POST['search'] ?? ''); |
| 2974 |
$sort_by = sanitize_text_field($_POST['sort_by'] ?? 'date'); |
| 2975 |
$sort_order = strtoupper(sanitize_text_field($_POST['sort_order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC'; |
| 2976 |
|
| 2977 |
// Map sort_by to WP_Query orderby |
| 2978 |
$orderby = 'date'; |
| 2979 |
$sort_meta_key = ''; |
| 2980 |
switch ($sort_by) { |
| 2981 |
case 'title': |
| 2982 |
$orderby = 'title'; |
| 2983 |
break; |
| 2984 |
case 'score': |
| 2985 |
$orderby = 'meta_value_num'; |
| 2986 |
$sort_meta_key = '_mxchat_seo_score'; |
| 2987 |
break; |
| 2988 |
case 'clicks': |
| 2989 |
$orderby = 'meta_value_num'; |
| 2990 |
$sort_meta_key = '_mxchat_gsc_clicks'; |
| 2991 |
break; |
| 2992 |
case 'impressions': |
| 2993 |
$orderby = 'meta_value_num'; |
| 2994 |
$sort_meta_key = '_mxchat_gsc_impressions'; |
| 2995 |
break; |
| 2996 |
default: |
| 2997 |
$orderby = 'date'; |
| 2998 |
break; |
| 2999 |
} |
| 3000 |
|
| 3001 |
$args = array( |
| 3002 |
'post_status' => 'publish', |
| 3003 |
'posts_per_page' => -1, |
| 3004 |
'post_type' => $post_type === 'any' ? array('post', 'page') : $post_type, |
| 3005 |
'orderby' => $orderby, |
| 3006 |
'order' => $sort_order, |
| 3007 |
'fields' => 'ids', |
| 3008 |
); |
| 3009 |
|
| 3010 |
if (!empty($search)) { |
| 3011 |
$args['s'] = $search; |
| 3012 |
} |
| 3013 |
|
| 3014 |
// Meta query for score-based filters |
| 3015 |
if ($filter === 'issues') { |
| 3016 |
$args['meta_query'] = array( |
| 3017 |
array('key' => '_mxchat_seo_score', 'value' => 70, 'compare' => '<', 'type' => 'NUMERIC'), |
| 3018 |
); |
| 3019 |
} elseif ($filter === 'good') { |
| 3020 |
$args['meta_query'] = array( |
| 3021 |
array('key' => '_mxchat_seo_score', 'value' => 70, 'compare' => '>=', 'type' => 'NUMERIC'), |
| 3022 |
); |
| 3023 |
} elseif ($filter === 'unscored') { |
| 3024 |
$args['meta_query'] = array( |
| 3025 |
array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'), |
| 3026 |
); |
| 3027 |
} |
| 3028 |
|
| 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. |
| 3031 |
if ($sort_meta_key) { |
| 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 |
); |
| 3039 |
} |
| 3040 |
} |
| 3041 |
|
| 3042 |
$query = new \WP_Query($args); |
| 3043 |
$all_ids = $query->posts; |
| 3044 |
$total = count($all_ids); |
| 3045 |
$pages = max(1, ceil($total / $per_page)); |
| 3046 |
$page = min($page, $pages); |
| 3047 |
$offset = ($page - 1) * $per_page; |
| 3048 |
$page_ids = array_slice($all_ids, $offset, $per_page); |
| 3049 |
|
| 3050 |
$posts = array(); |
| 3051 |
foreach ($page_ids as $pid) { |
| 3052 |
$p = get_post($pid); |
| 3053 |
$score = get_post_meta($pid, '_mxchat_seo_score', true); |
| 3054 |
|
| 3055 |
$gsc_clicks = get_post_meta($pid, '_mxchat_gsc_clicks', true); |
| 3056 |
$gsc_impr = get_post_meta($pid, '_mxchat_gsc_impressions', true); |
| 3057 |
|
| 3058 |
$posts[] = array( |
| 3059 |
'id' => $pid, |
| 3060 |
'title' => $p->post_title, |
| 3061 |
'type' => $p->post_type, |
| 3062 |
'date' => get_the_date('M j, Y', $pid), |
| 3063 |
'edit_url' => get_edit_post_link($pid, 'raw'), |
| 3064 |
'permalink' => get_permalink($pid), |
| 3065 |
'score' => $score !== '' ? intval($score) : null, |
| 3066 |
'analyzed' => (bool) get_post_meta($pid, '_mxchat_seo_analyzed', true), |
| 3067 |
'clicks' => $gsc_clicks !== '' ? intval($gsc_clicks) : null, |
| 3068 |
'impressions' => $gsc_impr !== '' ? intval($gsc_impr) : null, |
| 3069 |
); |
| 3070 |
} |
| 3071 |
|
| 3072 |
// Count unscored for the Scan button |
| 3073 |
$unscored_q = new \WP_Query(array( |
| 3074 |
'post_status' => 'publish', |
| 3075 |
'posts_per_page' => -1, |
| 3076 |
'post_type' => array('post', 'page'), |
| 3077 |
'fields' => 'ids', |
| 3078 |
'meta_query' => array( |
| 3079 |
array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'), |
| 3080 |
), |
| 3081 |
)); |
| 3082 |
$unscored_count = count($unscored_q->posts); |
| 3083 |
|
| 3084 |
wp_send_json_success(array( |
| 3085 |
'posts' => $posts, |
| 3086 |
'page' => $page, |
| 3087 |
'pages' => $pages, |
| 3088 |
'total' => $total, |
| 3089 |
'unscored_count' => $unscored_count, |
| 3090 |
)); |
| 3091 |
} |
| 3092 |
|
| 3093 |
/** |
| 3094 |
* Count syllables in text (Flesch-Kincaid helper). |
| 3095 |
*/ |
| 3096 |
private function seo_count_syllables($text) { |
| 3097 |
$words = preg_split('/\s+/', strtolower($text), -1, PREG_SPLIT_NO_EMPTY); |
| 3098 |
$total = 0; |
| 3099 |
foreach ($words as $w) { |
| 3100 |
$w = preg_replace('/[^a-z]/', '', $w); |
| 3101 |
if (strlen($w) <= 3) { $total++; continue; } |
| 3102 |
$w = preg_replace('/(?:[^laeiouy]es|ed|[^laeiouy]e)$/', '', $w); |
| 3103 |
preg_match_all('/[aeiouy]{1,2}/', $w, $m); |
| 3104 |
$total += max(1, count($m[0])); |
| 3105 |
} |
| 3106 |
return $total; |
| 3107 |
} |
| 3108 |
} |