PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.22
MxChat – AI Chatbot & Content Generation for WordPress v3.2.22
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
mxchat-basic / includes / class-mxchat-content-generator.php

class-mxchat-content-generator.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.22, at includes/class-mxchat-content-generator.php

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