🔍 URL SOCIAL PREVIEW CHECKER
'HTTPS',
'rec' => 'Required',
'html' => 'Social crawlers refuse to load preview images served over http://. The URL must use https://.',
),
array(
'name' => 'HTTP Response',
'rec' => 'Required',
'html' => 'The page must return HTTP 200 for the crawler\'s User-Agent. A 403 or bot-block will prevent any preview from loading.',
),
array(
'name' => 'Response Time',
'rec' => 'Recommended',
'html' => 'Crawlers time out after ~3 seconds. Pages that take longer to respond will show no preview, check for slow plugins or uncached pages.',
),
array(
'name' => 'og:title',
'rec' => 'Required',
'html' => 'The title shown in the social card. Without og:title, platforms fall back to the page <title> tag or show nothing.',
),
array(
'name' => 'og:description',
'rec' => 'Recommended',
'html' => 'The summary text shown under the title. Recommended for all platforms; Twitter/X truncates to ~200 chars.',
),
array(
'name' => 'og:image',
'rec' => 'Required',
'html' => 'The preview image. Must be an absolute https:// URL. Recommended size: 1200×630 px, max 8 MB per Facebook, which enforces a minimum of 200×200 px. Note this checker is stricter: it fails any og:image over 300 KB, because WhatsApp silently drops those.',
),
array(
'name' => 'og:image dimensions',
'rec' => 'Recommended',
'html' => 'The checker downloads the og:image and reports its real pixel dimensions, warning below 1200×630. (Your own posts also get og:image:width/og:image:height so crawlers need not download the file.) Speeds up card rendering and avoids layout shifts.',
),
array(
'name' => 'robots.txt',
'rec' => 'Info',
'html' => 'Checks that robots.txt does not block Googlebot, Twitterbot, facebookexternalhit, or other social crawlers from accessing the page.',
),
array(
'name' => 'Crawler UA test',
'rec' => 'Info',
'html' => 'After the diagnostic finishes, click Test All Crawlers in the results to re-fetch the page with each platform\'s real crawler User-Agent and confirm none are blocked by a WAF, Cloudflare rule, or bot-protection plugin. The automatic diagnostic itself fetches with the WhatsApp UA only.',
),
)
);
?>
🖼️ DEFAULT FEATURED IMAGE
'What it does',
'rec' => 'Overview',
'html' => 'When a post has no featured image set, WordPress normally shows nothing. This plugin intercepts the post_thumbnail_html and has_post_thumbnail filters to return your chosen fallback image instead, in theme loops and archive pages (posts only, not pages). It does not change the og:image tag, social previews still need a real featured image.',
),
array(
'name' => 'Recommended size',
'rec' => 'Required',
'html' => 'Use a 1200 × 630 px image (JPEG or PNG, under 300 KB). This is the optimal size for WhatsApp, LinkedIn, Facebook, and X/Twitter cards. Smaller images may be cropped or rejected by social crawlers.',
),
array(
'name' => 'og:image fallback',
'rec' => 'Important',
'html' => 'This fallback covers theme output, not social previews: the plugin only emits og:image for posts that have a real featured image. Set a default so archive and loop templates never render an empty thumbnail, and set real featured images on the posts you care about shared.',
),
array(
'name' => 'Change vs Remove',
'rec' => 'Info',
'html' => 'Change Image, opens the WordPress Media Library to select a new fallback image. Remove, clears the fallback. Posts without a featured image will revert to showing no thumbnail.',
),
)
);
?>
No image selected
☁️ CLOUDFLARE SETUP & DIAGNOSTICS
'Bot Fight Mode fix',
'rec' => 'Critical',
'html' => 'Cloudflare\'s Bot Fight Mode blocks social crawler user agents (WhatsApp, Facebook, LinkedIn, X/Twitter). This prevents them from reading your OG tags, meaning no preview card when links are shared. The fix is a WAF Custom Rule that skips Bot Fight Mode for those specific user agents only.',
),
array(
'name' => 'WAF rule setup',
'rec' => 'Recommended',
'html' => 'In Cloudflare Dashboard → Security → WAF → Custom Rules: create a rule with User Agent contains (facebookexternalhit OR LinkedInBot OR WhatsApp OR Twitterbot) → Action: Skip → Bot Fight Mode. Place it above any block rules.',
),
array(
'name' => 'Cache purge',
'rec' => 'Optional',
'html' => 'After fixing OG tags or images, Cloudflare may serve stale cached versions to crawlers for hours. The Cache Purge tool lets you clear a specific URL or your entire zone instantly. Requires a Cloudflare API Token with Cache Purge permission and your Zone ID.',
),
array(
'name' => 'Origin Refresh URL',
'rec' => 'Recommended if you run a page cache',
'html' => 'If a page cache sits between Cloudflare and WordPress (e.g. an nginx fastcgi_cache), purging Cloudflare alone is not enough: the very next request re-fetches from that origin, gets the still-stale cached HTML, and Cloudflare re-caches that for its full edge TTL. Setting this to your origin\'s internal address (e.g. http://nginx) makes every automatic purge, generating a featured image, publishing, or updating a post, refresh the origin\'s copy of the page first, before Cloudflare is purged. Leave blank if you have no such cache in front of WordPress.',
),
array(
'name' => 'Crawler test',
'rec' => 'Info',
'html' => 'The URL Social Preview Checker (panel above) simulates each crawler\'s user agent and reports exactly what OG tags they see, including whether Cloudflare is blocking them. Use it to verify your WAF rule is working correctly.',
),
)
);
?>
1
2
(http.user_agent contains "WhatsApp") or (http.user_agent contains "facebookexternalhit") or (http.user_agent contains "Facebot") or (http.user_agent contains "LinkedInBot") or (http.user_agent contains "Twitterbot")
3
4
readonly
placeholder="">
✓
📋 POST SOCIAL PREVIEW SCAN
'Facebook',
'rec' => 'Min 200×200',
'html' => 'Checks the WordPress featured image file directly, no live HTTP fetch. Recommended 1200×630 px, max 8 MB. Optimised versions are auto-generated to your uploads folder\'s csdt-social-formats/ directory when you publish or update a post.',
),
array(
'name' => 'X / Twitter',
'rec' => 'Min 280×150',
'html' => 'summary_large_image card format. Recommended 1200×628 px, max 5 MB. Auto-generated at the correct crop on every post save with a new featured image.',
),
array(
'name' => 'WhatsApp',
'rec' => 'Max 300 KB',
'html' => 'Strict 300 KB hard limit, images over this are silently hidden with no error message. The plugin retries at 90/80/70/60 JPEG quality, up to four attempts. If it still cannot reach the target it flags the post as over-limit rather than pretending it succeeded.',
),
array(
'name' => 'LinkedIn',
'rec' => 'Min 200×110',
'html' => 'Recommended 1200×627 px, max 5 MB. Auto-generated with the correct crop. Portrait-oriented or very small images often display poorly in LinkedIn feed cards.',
),
array(
'name' => 'Instagram',
'rec' => '1080×1080 sq',
'html' => 'Square 1:1 format for direct feed post uploads. Min 320×320, recommended 1080×1080, max 8 MB.
Note: Instagram does not scrape OG tags for link previews, this format is for direct uploads only.',
),
array(
'name' => 'Auto-generate',
'rec' => 'Automatic',
'html' => 'Every time you publish or update a post with a new featured image, the plugin automatically generates correctly sized and compressed images for each enabled platform. Nothing changes if the featured image hasn\'t changed.',
),
array(
'name' => 'Fix',
'rec' => 'Manual action',
'html' => 'Manually triggers generation for a single post. Use this to regenerate after changing platform settings, or for posts that existed before auto-generation was enabled.',
),
array(
'name' => 'Fix all',
'rec' => 'Manual action',
'html' => 'Runs Fix for every post in the current scan results (up to 50). Useful for quickly fixing the posts you just scanned.',
),
array(
'name' => 'Fix All Posts on Site',
'rec' => 'Bulk action',
'html' => 'Processes every published post on the entire site in batches of 10, generating platform formats for each. Shows live progress (e.g. Fixing 45 / 320). Posts without a featured image are skipped automatically.',
),
array(
'name' => 'Refresh Stale',
'rec' => 'Targeted bulk action',
'html' => 'Scans every published post and regenerates only the ones where the featured image has changed since the last generation, either because a different attachment was selected, or because the media file was replaced in the Media Library (same attachment ID, new file). Shows a live log of how many posts were found and fixed, with a clickable link to each one. Much faster than Fix All Posts on Site when only a handful of posts need updating.',
),
array(
'name' => 'Re-check',
'rec' => 'Diagnostic',
'html' => 'Runs the full live URL diagnostic (OG tags, robots.txt, crawler UA test) on this specific post URL and scrolls to the URL checker results above.',
),
)
);
?>
🔧 GENERATE MISSING THUMBNAILS
'Why thumbnails go missing',
'rec' => 'Overview',
'html' => 'Image cleanup plugins delete "unused" files, including WordPress-generated intermediate sizes like thumbnail, medium, medium_large, and large. These are the files your theme requests when displaying featured images. When they are gone, WordPress falls back to the full-size original, which the theme then CSS-crops, causing edges to be cut off or the wrong portion to be shown.',
),
array(
'name' => 'Scan',
'rec' => 'Non-destructive',
'html' => 'Checks every image actually in use, featured images, your default fallback, the site logo and site icon, against all registered WordPress image sizes. Reports how many images are missing one or more sizes, and flags which images are actively used as featured images on posts.',
),
array(
'name' => 'Regenerate All Missing',
'rec' => 'Safe to run',
'html' => 'Runs wp_generate_attachment_metadata() for each image that has missing sizes. Only generates what is missing, it does not touch images that are already complete. Original full-size files are never modified.',
),
array(
'name' => 'In-use images',
'rec' => 'Important',
'html' => 'Images marked Featured image are set as the featured image on one or more posts. These are the most important to regenerate as they directly affect how your articles look. Consider excluding the /wp-content/uploads/ folder from your cleanup plugin, or at minimum protecting any image shown here.',
),
array(
'name' => 'Performance note',
'rec' => 'Info',
'html' => 'Processing runs in batches of 5 images per request to avoid server timeouts. A site with 200 in-use images takes roughly 40 requests, expect 30 to 90 seconds total depending on your server speed.',
),
)
);
?>
🎨 SOCIAL FORMAT SETTINGS
'Facebook 1200×630',
'rec' => 'Optimum ~400 KB',
'html' => 'Optimum: 1200×630 px at under 400 KB JPEG. Hard limit: 8 MB. Minimum: 200×200 px. Landscape 1.91:1 ratio, the plugin auto-crops to this exact frame so Facebook always shows your image, not a random one from the page.',
),
array(
'name' => 'X / Twitter 1200×628',
'rec' => 'Optimum ~400 KB',
'html' => 'Optimum: 1200×628 px at under 400 KB JPEG. Hard limit: 5 MB. Minimum for large card: 280×150 px. Slightly shorter than Facebook, a separate dedicated crop prevents the subject being letterboxed or clipped.',
),
array(
'name' => 'WhatsApp 1200×630',
'rec' => 'Optimum ~200 KB',
'html' => 'Optimum: 1200×630 px at under 200 KB JPEG. Hard limit: 300 KB, images over this are silently dropped with no error message. The plugin targets 200 KB for a safe margin, retrying at 90/80/70/60 quality for up to four attempts.',
),
array(
'name' => 'LinkedIn 1200×627',
'rec' => 'Optimum ~400 KB',
'html' => 'Optimum: 1200×627 px at under 400 KB JPEG. Hard limit: 5 MB. Minimum: 200×110 px. Landscape cards perform best, portrait images are cropped awkwardly or shown very small in the LinkedIn feed.',
),
array(
'name' => 'Instagram 1080×1080',
'rec' => 'Optimum ~400 KB',
'html' => 'Optimum: 1080×1080 px square at under 400 KB JPEG. Hard limit: 8 MB. Minimum: 320×320 px. Square 1:1 crop for direct feed uploads, Instagram does not scrape OG tags for link preview cards.',
),
array(
'name' => 'Auto-generation',
'rec' => 'How it works',
'html' => 'Every time you publish or update a post with a new featured image, the plugin automatically generates all enabled platform formats at the optimum size and quality, not just within the hard limit. Unchanged images are skipped. Originals are never modified.',
),
array(
'name' => 'Save Settings',
'rec' => 'Required once',
'html' => 'Saves which platforms are enabled. Only checked platforms are generated on post save. Changes take effect on the next publish or update.',
),
)
);
?>
🎨 FEATURED IMAGE GENERATOR
'What it does',
'rec' => 'Overview',
'html' => 'Finds your published posts that have no featured image, then uses gpt-image-2 (OpenAI) to generate a 1536×1024 landscape header image tailored to each post. The image is uploaded to your Media Library as a candidate for review; you accept or regenerate it before it becomes the featured image.',
),
array(
'name' => 'How to set up',
'rec' => 'Step-by-step',
'html' => 'If your managed CloudScale AI plan is active, both the prompt and the image are generated through CloudScale, no API key is needed and the vendor/model choices below are ignored. The rest of this applies only when using your own keys. Step 1: Pick a vendor, choose which AI writes the prompt description sent to gpt-image-2. OpenAI (ChatGPT) is recommended as it shares your billing account. Step 2: Pick a model, Automatic uses the best model for that vendor. Pick the fast option (e.g. GPT-6 Luna) to cut cost per prompt. Step 3: Enter your API key for that vendor and click Save Key. Note: gpt-image-2 always uses OpenAI for image generation. If you choose Anthropic or Google as your prompt writer, you also need to enter an OpenAI key in the OpenAI row.',
),
array(
'name' => 'Image quality',
'rec' => 'Standard recommended',
'html' => 'Standard, medium quality (1536×1024 px). Recommended for most posts. HD, high quality. More detail, higher cost. See openai.com/pricing for current rates.',
),
array(
'name' => 'Cost estimate',
'rec' => 'Info',
'html' => 'With GPT-6 Luna as prompt writer + Standard quality: approximately $0.07 to $0.10 per image (varies with prompt length). See openai.com/pricing for gpt-image-2 rates.',
),
array(
'name' => 'After generation',
'rec' => 'Info',
'html' => 'The image is uploaded as a candidate and shown in a review modal. Click Accept to set it as the featured image, social format crops are generated at that point, or Regenerate to try again. Rejected candidates are deleted.',
),
)
);
?>
';
}
// ─── AJAX: generate per-platform social formats ───────────────────────
public static function ajax_social_generate_formats(): void {
check_ajax_referer( self::THUMB_NONCE, 'nonce' );
if ( ! current_user_can( 'upload_files' ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
$post_id = absint( $_POST['post_id'] ?? 0 );
if ( ! $post_id ) {
wp_send_json_error( array( 'message' => 'No post ID.' ) );
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
if ( ! get_post_thumbnail_id( $post_id ) ) {
wp_send_json_error( array( 'message' => 'No featured image set for this post.' ) );
}
$results = self::generate_social_formats_for_post( $post_id );
if ( $results === null ) {
wp_send_json_error( array( 'message' => 'Could not generate formats, check the featured image file and platform settings.' ) );
}
// Mark as up-to-date so the save hook won't re-run for this thumbnail.
update_post_meta( $post_id, '_csdt_social_formats_thumb_id', (int) get_post_thumbnail_id( $post_id ) );
// Purge Cloudflare cache so WhatsApp/social crawlers get the fresh og:image immediately.
$post_url = get_permalink( $post_id );
$cf_purged = $post_url ? self::cf_purge_urls( array( $post_url ) ) : false;
wp_send_json_success( array_merge( $results, array( 'cf_purged' => $cf_purged ) ) );
}
// ─── AJAX: diagnose social formats for a post ────────────────────────
// Checks: (1) what's stored in meta, (2) whether image files exist on disk,
// (3) whether image URLs are reachable by each crawler UA.
public static function ajax_social_diagnose_formats(): void {
check_ajax_referer( self::THUMB_NONCE, 'nonce' );
if ( ! current_user_can( 'upload_files' ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
$post_id = absint( $_POST['post_id'] ?? 0 );
if ( ! $post_id ) {
wp_send_json_error( array( 'message' => 'No post ID.' ) );
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
$result = array();
// ── 1. Meta state ────────────────────────────────────────────────
$formats = get_post_meta( $post_id, '_csdt_social_formats', true );
$thumb_id = (int) get_post_meta( $post_id, '_csdt_social_formats_thumb_id', true );
$current_thumb = (int) get_post_thumbnail_id( $post_id );
$result['meta'] = array(
'has_new_key' => ! empty( $formats ),
'thumb_id_saved' => $thumb_id,
'thumb_id_now' => $current_thumb,
'thumb_stale' => $thumb_id !== $current_thumb,
'no_thumbnail' => ! $current_thumb,
);
// ── 2. Per-platform: file existence + URL reachability ───────────
$upload = wp_upload_dir();
$dest_dirs = array(
trailingslashit( $upload['basedir'] ) . self::SOCIAL_SUBDIR . $post_id,
// Crops made before 1.10.890 stay where they were, so URLs already shared keep working.
trailingslashit( $upload['basedir'] ) . self::SOCIAL_LEGACY_SUBDIR . $post_id,
);
$test_uas = array(
'LinkedInBot' => 'LinkedInBot/1.0 (compatible; Mozilla/5.0; Apache-HttpClient +http://www.linkedin.com)',
'Facebook' => 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)',
'Twitterbot' => 'Twitterbot/1.0',
);
$platforms_out = array();
foreach ( self::SOCIAL_PLATFORMS as $key => $p ) {
$meta_entry = $formats[ $key ] ?? null;
$entry = array(
'label' => $p['label'],
'meta_status' => 'missing',
'url' => null,
'file_exists' => false,
'ua_results' => array(),
);
if ( $meta_entry !== null ) {
if ( ! empty( $meta_entry['success'] ) ) {
$entry['meta_status'] = 'ok';
$entry['url'] = $meta_entry['url'] ?? null;
$entry['kb'] = $meta_entry['kb'] ?? null;
$entry['w'] = $meta_entry['w'] ?? null;
$entry['h'] = $meta_entry['h'] ?? null;
} else {
$entry['meta_status'] = 'failed';
$entry['error'] = $meta_entry['error'] ?? 'Unknown error';
}
}
// Check file on disk. Crop names are content-addressed
// ({$key}-{hash}.{$ext}) since 1.10.783, so the file the meta URL
// names is the one that matters: a stale URL pointing at a pruned
// file is precisely the fault worth reporting. Older posts still
// carry the unversioned name, so both shapes are searched.
$candidates = array();
if ( ! empty( $entry['url'] ) ) {
foreach ( $dest_dirs as $dest_dir ) {
$candidates[] = "{$dest_dir}/" . basename( wp_parse_url( (string) $entry['url'], PHP_URL_PATH ) ?? '' );
}
}
foreach ( $dest_dirs as $dest_dir ) {
foreach ( array( 'jpg', 'png', 'webp' ) as $ext ) {
$candidates[] = "{$dest_dir}/{$key}.{$ext}";
$versioned = glob( "{$dest_dir}/{$key}-*.{$ext}" );
if ( $versioned ) {
$candidates = array_merge( $candidates, $versioned );
}
}
}
foreach ( $candidates as $path ) {
if ( is_file( $path ) ) {
$entry['file_exists'] = true;
$entry['file_path'] = $path;
$entry['file_kb'] = round( filesize( $path ) / 1024, 1 );
break;
}
}
// Test URL reachability with crawler UAs (only if a URL is stored).
if ( ! empty( $entry['url'] ) ) {
foreach ( $test_uas as $ua_label => $ua_string ) {
$resp = wp_safe_remote_head(
$entry['url'],
array(
'user-agent' => $ua_string,
'timeout' => 8,
'redirection' => 3,
)
);
if ( is_wp_error( $resp ) ) {
$entry['ua_results'][ $ua_label ] = array(
'code' => 0,
'ok' => false,
'error' => CSDT_Error_Text::in_seconds( $resp->get_error_message() ),
);
} else {
$code = (int) wp_remote_retrieve_response_code( $resp );
$entry['ua_results'][ $ua_label ] = array(
'code' => $code,
'ok' => $code === 200,
);
}
}
}
$platforms_out[ $key ] = $entry;
}
$result['platforms'] = $platforms_out;
// ── 3. What og:image would each crawler see ──────────────────────
$post_url = get_permalink( $post_id );
$og_seen = array();
foreach ( $test_uas as $ua_label => $ua_string ) {
$resp = wp_safe_remote_get(
$post_url,
array(
'user-agent' => $ua_string,
'timeout' => 10,
'redirection' => 5,
)
);
if ( is_wp_error( $resp ) ) {
$og_seen[ $ua_label ] = array(
'ok' => false,
'error' => CSDT_Error_Text::in_seconds( $resp->get_error_message() ),
);
continue;
}
$code = (int) wp_remote_retrieve_response_code( $resp );
$body = wp_remote_retrieve_body( $resp );
preg_match( '/]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']|]+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']/', $body, $m );
$og_url = $m[1] ?? $m[2] ?? null;
$og_seen[ $ua_label ] = array(
'ok' => $code === 200,
'code' => $code,
'og_url' => $og_url,
'has_og' => ! empty( $og_url ),
);
}
$result['og_seen'] = $og_seen;
wp_send_json_success( $result );
}
// ─── AJAX: batch fix all posts ────────────────────────────────────────
public static function ajax_social_fix_all_batch(): void {
check_ajax_referer( self::THUMB_NONCE, 'nonce' );
// Site-wide bulk operation over ALL published posts (incl. other users'),
// so require admin, not the Author-level 'upload_files' the single-post
// handlers use with a per-post edit_post check.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
$offset = absint( $_POST['offset'] ?? 0 );
$batch_size = 10; // process 10 per request to avoid timeouts
$total = (int) wp_count_posts( 'post' )->publish;
$posts = get_posts(
array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $batch_size,
'offset' => $offset,
'orderby' => 'ID',
'order' => 'ASC',
'fields' => 'ids',
'suppress_filters' => false,
)
);
$batch_results = array();
foreach ( $posts as $post_id ) {
$thumb_id = get_post_thumbnail_id( $post_id );
if ( ! $thumb_id ) {
$batch_results[] = array(
'post_id' => $post_id,
'skipped' => true,
'reason' => 'no_thumbnail',
);
continue;
}
$file = get_attached_file( (int) $thumb_id );
if ( ! $file || ! file_exists( $file ) ) {
$batch_results[] = array(
'post_id' => $post_id,
'skipped' => true,
'reason' => 'file_missing',
);
continue;
}
$results = self::generate_social_formats_for_post( $post_id );
if ( $results ) {
update_post_meta( $post_id, '_csdt_social_formats_thumb_id', (int) $thumb_id );
}
$batch_results[] = array(
'post_id' => $post_id,
'skipped' => false,
'ok' => $results !== null,
);
}
$next_offset = $offset + count( $posts );
wp_send_json_success(
array(
'total' => $total,
'offset' => $offset,
'next_offset' => $next_offset,
'has_more' => $next_offset < $total,
'batch' => $batch_results,
)
);
}
public static function ajax_social_refresh_stale_batch(): void {
check_ajax_referer( self::THUMB_NONCE, 'nonce' );
// Site-wide bulk operation over ALL published posts, require admin.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
$offset = absint( $_POST['offset'] ?? 0 );
$batch_size = 10;
$total = (int) wp_count_posts( 'post' )->publish;
$posts = get_posts(
array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $batch_size,
'offset' => $offset,
'orderby' => 'ID',
'order' => 'ASC',
'fields' => 'ids',
'suppress_filters' => false,
)
);
$stale_posts = array();
foreach ( $posts as $post_id ) {
$thumb_id = (int) get_post_thumbnail_id( $post_id );
if ( ! $thumb_id ) {
continue;
}
$last_thumb = (int) get_post_meta( $post_id, '_csdt_social_formats_thumb_id', true );
$last_gen_time = (int) get_post_meta( $post_id, '_csdt_social_formats_gen_time', true );
$thumb_post = get_post( $thumb_id );
$thumb_mtime = $thumb_post ? strtotime( $thumb_post->post_modified_gmt ) : 0;
$is_stale = ( $last_thumb !== $thumb_id ) || ( $last_gen_time < $thumb_mtime );
if ( ! $is_stale ) {
continue;
}
$results = self::generate_social_formats_for_post( $post_id );
$ok = $results !== null;
if ( $ok ) {
update_post_meta( $post_id, '_csdt_social_formats_thumb_id', $thumb_id );
update_post_meta( $post_id, '_csdt_social_formats_gen_time', time() );
}
$stale_posts[] = array(
'post_id' => $post_id,
'title' => get_the_title( $post_id ),
'url' => get_permalink( $post_id ),
'reason' => $last_thumb !== $thumb_id ? 'thumb_id_changed' : 'file_replaced',
'ok' => $ok,
);
}
$next_offset = $offset + count( $posts );
wp_send_json_success(
array(
'total' => $total,
'next_offset' => $next_offset,
'has_more' => $next_offset < $total,
'checked' => count( $posts ),
'stale' => $stale_posts,
)
);
}
// ─── Always output social og:image early (priority 1) ───────────────
// UA-based serving breaks CDN caches, Cloudflare caches the first response
// and all subsequent visitors (including bots) get the same cached HTML.
// Instead, always output the best available 1200×630 social image for all
// visitors so the CDN-cached page has the correct og:image embedded.
public static function output_crawler_og_image(): void {
if ( ! is_singular( 'post' ) ) {
return;
}
// When the CloudScale SEO plugin is active with OG output enabled, it
// emits the canonical OG block and (since SEO v4.21.349) prefers this
// plugin's WhatsApp crop itself. Emitting a second og:image here left
// the page with two competing og:image blocks whose secure_url pointed
// at a different file, some WhatsApp builds pick the LAST og:image or
// prefer og:image:secure_url, and previews silently failed. One block
// only: defer to the SEO plugin.
// SEO 4.21.670 renamed its class and option (cs_seo_ to cloudscale_seo_ai_optimizer_); the
// old names are read too, for a site still running an older SEO copy.
if ( class_exists( 'CloudScale_SEO_AI_Optimizer_Plugin' ) || class_exists( 'Cs_Seo_Plugin' ) ) {
$seo_opts = get_option( 'cloudscale_seo_ai_optimizer_options', get_option( 'cs_seo_options', array() ) );
if ( is_array( $seo_opts ) && ! empty( $seo_opts['enable_og'] ) ) {
return;
}
}
$post_id = get_the_ID();
if ( ! $post_id ) {
return;
}
$formats = get_post_meta( $post_id, '_csdt_social_formats', true );
if ( empty( $formats ) ) {
return;
}
// Pick the best available format: prefer whatsapp (1200×630, ≤200 KB),
// fall back through other 1200×630 formats.
$order = array( 'whatsapp', 'facebook', 'linkedin', 'instagram', 'twitter' );
$platform = null;
foreach ( $order as $p ) {
if ( ! empty( $formats[ $p ]['url'] ) ) {
$platform = $p;
break;
}
}
if ( ! $platform ) {
return;
}
$img_url = $formats[ $platform ]['url'];
$dims = self::SOCIAL_PLATFORMS[ $platform ];
// Output before SEO plugin tags (priority 1). First og:image wins for most crawlers.
echo '' . "\n";
if ( str_starts_with( $img_url, 'https://' ) ) {
// WhatsApp's scraper relies on og:image:secure_url; it must match og:image.
echo '' . "\n";
}
echo '' . "\n";
echo '' . "\n";
echo '' . "\n";
}
// ─── AJAX: Cloudflare crawler UA test ────────────────────────────────
public static function ajax_social_cf_test(): void {
check_ajax_referer( self::THUMB_NONCE, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
$url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : home_url( '/' );
if ( ! self::is_safe_external_url( $url ) ) {
wp_send_json_error( array( 'message' => 'URL must be a publicly accessible address.' ) );
}
$post_id = url_to_postid( $url );
if ( $post_id ) {
$chk = get_post( $post_id );
if ( $chk && ( $chk->post_status === 'private' || ! empty( $chk->post_password ) ) ) {
wp_send_json_error( array( 'message' => 'This post is password-protected or private, social crawlers cannot read it. Please enter a public URL.' ) );
}
}
wp_send_json_success( self::social_test_crawlers( $url ) );
}
// ─── AJAX: Cloudflare cache purge ────────────────────────────────────
public static function ajax_cf_purge(): void {
check_ajax_referer( self::THUMB_NONCE, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
$zone_id = get_option( 'csdt_devtools_cf_zone_id', '' );
$token = CSDT_Secrets::get( 'csdt_devtools_cf_api_token' );
if ( ! $zone_id || ! $token ) {
wp_send_json_error( array( 'message' => __( 'Cloudflare Zone ID and API Token are required. Please save them above.', 'cloudscale-cyber-devtools' ) ) );
}
$purge_url = isset( $_POST['purge_url'] ) ? esc_url_raw( wp_unslash( $_POST['purge_url'] ) ) : '';
// Ensure purge_url belongs to this site, prevents purging arbitrary Cloudflare-cached URLs.
if ( $purge_url && strpos( $purge_url, home_url() ) !== 0 ) {
wp_send_json_error( array( 'message' => __( 'URL must belong to this site.', 'cloudscale-cyber-devtools' ) ) );
}
$cf_api = "https://api.cloudflare.com/client/v4/zones/{$zone_id}/purge_cache";
$body = $purge_url
? wp_json_encode( array( 'files' => array( $purge_url ) ) )
: wp_json_encode( array( 'purge_everything' => true ) );
$response = wp_remote_post(
$cf_api,
array(
'user-agent' => CSDT_DevTools::USER_AGENT,
'headers' => array(
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
),
'body' => $body,
'timeout' => 15,
)
);
$r = CSDT_Http::json( $response );
$data = $r['data'];
if ( 0 === $r['code'] ) {
CSDT_CF_Health::record_failure( __( 'cache purge', 'cloudscale-cyber-devtools' ), CSDT_CF_Health::error_text( $r ) );
wp_send_json_error( array( 'message' => $r['error'] ) );
}
if ( ! empty( $data['success'] ) ) {
CSDT_CF_Health::record_success( __( 'cache purge', 'cloudscale-cyber-devtools' ) );
wp_send_json_success(
array(
'message' => $purge_url
// translators: %s is the URL that was cache-purged
? sprintf( __( 'Cache purged for: %s', 'cloudscale-cyber-devtools' ), $purge_url )
: __( 'Entire Cloudflare cache purged successfully.', 'cloudscale-cyber-devtools' ),
)
);
} else {
// Cloudflare's own errors when it sent them; otherwise the status and what answered,
// which "Unknown error" used to hide.
$errors = isset( $data['errors'] ) ? wp_json_encode( $data['errors'] ) : CSDT_Http::describe( $r );
CSDT_CF_Health::record_failure( __( 'cache purge', 'cloudscale-cyber-devtools' ), CSDT_CF_Health::error_text( $r ) );
wp_send_json_error( array( 'message' => $errors ) );
}
}
// ─── AJAX: test the saved CF credentials ─────────────────────────────
public static function ajax_cf_check(): void {
check_ajax_referer( self::THUMB_NONCE, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
$res = CSDT_CF_Health::check_now( true );
if ( $res['ok'] ) {
wp_send_json_success( $res );
}
wp_send_json_error( $res );
}
// ─── AJAX: save CF credentials ───────────────────────────────────────
public static function ajax_cf_save(): void {
check_ajax_referer( self::THUMB_NONCE, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 );
}
$zone_id = isset( $_POST['zone_id'] ) ? sanitize_text_field( wp_unslash( $_POST['zone_id'] ) ) : '';
$token = isset( $_POST['api_token'] ) ? sanitize_text_field( wp_unslash( $_POST['api_token'] ) ) : '';
$origin_url = isset( $_POST['origin_refresh_url'] ) ? untrailingslashit( sanitize_text_field( wp_unslash( $_POST['origin_refresh_url'] ) ) ) : '';
update_option( 'csdt_devtools_cf_zone_id', $zone_id );
update_option( 'csdt_devtools_cf_origin_refresh_url', $origin_url );
if ( $token !== '' && ! CSDT_Secrets::set( 'csdt_devtools_cf_api_token', $token ) ) {
// Encrypted (CSDT_Secrets, non-autoloaded), never plaintext: with no key to
// encrypt under nothing is stored, and the admin is told rather than a silent save.
wp_send_json_error( array( 'message' => __( 'The API token was not saved: this site has no encryption key (WordPress salts or CSDT_SECRETS_KEY).', 'cloudscale-cyber-devtools' ) ) );
}
wp_send_json_success( array( 'message' => __( 'Cloudflare settings saved.', 'cloudscale-cyber-devtools' ) ) );
}
// ─── Default Featured Image ───────────────────────────────────────────
public static function ajax_save_default_image(): void {
check_ajax_referer( 'csdt_defimg', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( 'Unauthorized', 403 ); }
$id = absint( $_POST['image_id'] ?? 0 );
update_option( 'csdt_default_image_id', $id );
wp_send_json_success( array( 'id' => $id ) );
}
public static function default_image_html( string $html, int $post_id, $post_thumbnail_id, $size, $attr ): string {
if ( ! empty( $html ) ) {
return $html; }
if ( get_post_type( $post_id ) !== 'post' ) {
return $html; }
$default_id = (int) get_option( 'csdt_default_image_id', 0 );
if ( ! $default_id ) {
return $html; }
return wp_get_attachment_image( $default_id, $size, false, (array) $attr );
}
public static function default_image_has_thumbnail( bool $has, $post, $thumbnail_id ): bool {
if ( $has ) {
return $has; }
$post_obj = get_post( $post );
if ( ! $post_obj || $post_obj->post_type !== 'post' ) {
return $has; }
return (int) get_option( 'csdt_default_image_id', 0 ) > 0;
}
// ─── Hero image: show original image on single posts ─────────────────
// Previously swapped to the 1200×630 social-format crop, which cut off
// title text at the top of images. Now returns the unmodified thumbnail HTML
// so the original uploaded image is displayed at natural dimensions.
public static function hero_image_html( string $html, int $post_id, $post_thumbnail_id, $size, $attr ): string {
return $html;
}
public static function enqueue_hero_styles(): void {
if ( ! is_singular( 'post' ) ) {
return; }
wp_register_style( 'csdt-hero-styles', false, array(), CSDT_DevTools::VERSION );
wp_enqueue_style( 'csdt-hero-styles' );
wp_add_inline_style( 'csdt-hero-styles', '.single .wp-post-image{width:100%;display:block;height:auto}' );
}
// ─── Frontend admin: Generate Featured Image button ──────────────────
public static function enqueue_frontend_admin_scripts(): void {
if ( ! is_singular( 'post' ) || ! current_user_can( 'manage_options' ) ) {
return; }
// ...and the capability the bar's own handlers ask for, not just the one that gets
// you into wp-admin. The bar used to load for anyone with manage_options, while
// ajax_ai_image_pending() and ajax_ai_image_abort() check edit_post on the post. A
// user holding the first without the second, both scoped test roles, for one,
// opened the panel, checkPending() fired, and the guaranteed 403 arrived as a
// CRITICAL "Editor request failed" alert with nothing to act on. Asking here means
// the impossible request is never made.
if ( ! current_user_can( 'edit_post', get_queried_object_id() ) ) {
return; }
// Nothing to power if the client has hidden every frontend button.
if ( ! get_option( 'csdt_devtools_frontbar_copy', true )
&& ! get_option( 'csdt_devtools_frontbar_genimg', true )
&& ! get_option( 'csdt_devtools_frontbar_versions', true ) ) {
return;
}
// Shared generation overlay, same file the admin Thumbnails panel loads, so
// both paths show the same thing and cannot drift apart again.
wp_enqueue_script(
'csdt-gen-overlay',
plugins_url( 'assets/cs-gen-overlay.js', dirname( __DIR__ ) . '/cloudscale-cyber-devtools.php' ),
array(),
CSDT_DevTools::VERSION,
true
);
wp_enqueue_script(
'csdt-frontend-admin',
plugins_url( 'assets/cs-frontend-admin.js', dirname( __DIR__ ) . '/cloudscale-cyber-devtools.php' ),
array( 'csdt-gen-overlay' ),
CSDT_DevTools::VERSION,
true
);
wp_localize_script( 'csdt-frontend-admin', 'csdtFrontAdmin', self::gen_dialog_config() );
}
/**
* The generate dialog's configuration, in one place.
*
* Read by two enqueues now, the front-end toolbar and the block editor, and these values are
* the dialog's whole idea of what it may do: which vendor and model write the prompt, which
* style lists to offer, which nonce to send. A second copy is a second thing to remember when a
* setting is added, and the copy that gets forgotten is always the one being read.
*
* @since 1.10.590
* @return array
*/
private static function gen_dialog_config(): array {
return array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'csdt_devtools_thumbnails' ),
'promptVendor' => (string) get_option( 'csdt_devtools_prompt_vendor', 'openai' ),
'promptModel' => CSDT_AI_Dispatcher::sanitize_prompt_model( (string) get_option( 'csdt_devtools_prompt_model', '_auto' ) ),
'imgStyle' => CSDT_Image_Brief::canonical_style( (string) get_option( 'csdt_devtools_img_style', 'auto' ) ),
'imgQuality' => (string) get_option( 'csdt_devtools_img_quality', 'standard' ),
'styleOptions' => self::get_image_style_options(),
'articleStyleOptions' => self::get_article_style_options(),
// Which account will pay, so the overlay can say so while the image is being made. This is
// the PREDICTED path (no call has happened yet); the poll response carries what actually
// served, and the dialog corrects itself if the managed call fell back.
'aiMode' => self::image_provenance( true ),
);
}
/**
* "Generate Post Image with AI" inside the block editor's Set featured image box.
*
* The generator was reachable from the front-end toolbar (only on a published post you are
* viewing) and from the admin Thumbnails panel (a different screen). Neither is where you are
* when you notice the post has no image, which is while writing it, so the button goes into
* core's own featured-image box, via the `editor.PostFeaturedImage` filter.
*
* It loads the SAME dialog the toolbar opens, through window.csdtGenImage.open(); see the note
* at the top of assets/cs-editor-featured-image.js for why a third copy of that dialog was not
* written.
*
* @since 1.10.590
* @return void
*/
/**
* What paid for the image just generated: the managed plan, or the site's own OpenAI key.
*
* Read from CSDT_AI_Dispatcher::served(), never from CSDT_Managed_AI::should_use(), because
* generate_image() can START on the managed plan and FINISH on the site's own key, the managed
* call falls back on a cost cap, a cancellation or a network error. Asking the setting would
* report "your plan paid for this" about an image OpenAI billed the user for directly, which is
* the one thing a provenance label must never do. Same rule the scan audit rows follow.
*
* On the evening of 2026-08-13 that fallback was live for real: OpenRouter and Together were
* both timing out, the managed API answered 502, and the SEO plugin's own alert said "AI
* features keep working on your own API key, which means the provider bills you directly". A
* label that read the setting would have said "managed" throughout.
*
* @since 1.10.595
* @param bool $predict When no call has been made yet, describe the path a call WOULD take
* rather than returning 'unknown', for the label shown while generating.
* @return array{mode:string,label:string,note:string,quota:?array}
*/
public static function image_provenance( bool $predict = false ): array {
$served = class_exists( 'CSDT_AI_Dispatcher' ) ? CSDT_AI_Dispatcher::served() : array();
$mode = (string) ( $served['mode'] ?? '' );
$note = class_exists( 'CSDT_AI_Dispatcher' ) ? CSDT_AI_Dispatcher::image_fallback_reason() : '';
if ( '' === $mode || 'none' === $mode ) {
if ( ! $predict ) {
return array(
'mode' => 'unknown',
'label' => __( 'Not recorded', 'cloudscale-cyber-devtools' ),
'note' => '',
'quota' => null,
);
}
$managed = class_exists( 'CSDT_Managed_AI' ) && CSDT_Managed_AI::should_use();
return array(
'mode' => $managed ? 'managed' : 'own_key',
'label' => $managed
? __( 'Your managed CloudScale plan', 'cloudscale-cyber-devtools' )
: __( 'Your own OpenAI key', 'cloudscale-cyber-devtools' ),
'note' => '',
'quota' => $managed ? self::image_quota() : null,
);
}
return array(
'mode' => $mode,
'label' => 'managed' === $mode
? __( 'Your managed CloudScale plan', 'cloudscale-cyber-devtools' )
: __( 'Your own OpenAI key', 'cloudscale-cyber-devtools' ),
// Stated, not hidden: a user whose plan quietly stopped paying should learn it here,
// not from an OpenAI invoice.
'note' => ( 'own_key' === $mode && '' !== $note )
? __( 'Your managed plan could not be used, so your own key paid for this.', 'cloudscale-cyber-devtools' )
: '',
// Only the managed plan HAS an allowance. On the site's own key there is no ceiling
// to report, and the note above already says the provider is billing directly.
'quota' => 'managed' === $mode ? self::image_quota() : null,
);
}
/**
* How much of the managed image allowance is spent, for the line under the payer.
*
* "Paid for by: your managed plan" answers who, and not how much is left of it, which is the
* question an author is actually holding while they watch a generation run. The figures come
* from the same three keys the licence card's "Image generations" meter reads, refreshed by
* CSDT_Managed_AI::dispatch_image() out of the /v1/images response, so the overlay and the
* card cannot quote different numbers at the same person.
*
* `left` goes through CSDT_Licence::credits_left() rather than being worked out here: the
* boost balance rolls over and never expires, so it adds to whatever is left of the month,
* and that rule already has one home.
*
* @since 1.10.647
* @return array{used:int,limit:int,boost:int,left:int}|null Null when this licence has no
* image allowance at all.
*/
private static function image_quota(): ?array {
$o = get_option( CSDT_Managed_AI::OPTION, array() );
$o = is_array( $o ) ? $o : array();
$used = (int) ( $o['proxy_img_usage'] ?? 0 );
$limit = (int) ( $o['proxy_img_limit'] ?? 0 );
$boost = (int) ( $o['proxy_img_boost'] ?? 0 );
// Nothing allowed, nothing bought and nothing spent means the plan has never reported an
// image allowance. Say nothing rather than "0 of 0 used", which reads as a broken meter.
if ( $limit <= 0 && $boost <= 0 && $used <= 0 ) {
return null;
}
return array(
'used' => $used,
'limit' => $limit,
'boost' => $boost,
'left' => class_exists( 'CSDT_Licence' )
? CSDT_Licence::credits_left(
array(
'limit' => $limit,
'usage' => $used,
'boost' => $boost,
)
)
: max( 0, $limit - $used ) + $boost,
);
}
public static function enqueue_editor_assets(): void {
// Post-scoped, exactly like the toolbar: another post type has no business with a
// featured-image generator, and the handlers behind the dialog would refuse anyway.
$post = get_post();
if ( ! $post || 'post' !== $post->post_type ) {
return;
}
// The same two questions the toolbar asks. edit_post on THIS post, not manage_options
// alone: the ai_image_* handlers ask about the post, and offering a button whose every
// click 403s is precisely how the toolbar came to page the site owner with a CRITICAL
// alert about a request nothing was wrong with.
if ( ! current_user_can( 'manage_options' ) || ! current_user_can( 'edit_post', $post->ID ) ) {
return;
}
// Honour the same visibility switch as the toolbar's own generate button: an admin who has
// hidden that has said they do not want this feature offered.
if ( ! get_option( 'csdt_devtools_frontbar_genimg', true ) ) {
return;
}
wp_enqueue_script(
'csdt-gen-overlay',
plugins_url( 'assets/cs-gen-overlay.js', dirname( __DIR__ ) . '/cloudscale-cyber-devtools.php' ),
array(),
CSDT_DevTools::VERSION,
true
);
wp_enqueue_script(
'csdt-frontend-admin',
plugins_url( 'assets/cs-frontend-admin.js', dirname( __DIR__ ) . '/cloudscale-cyber-devtools.php' ),
array( 'csdt-gen-overlay' ),
CSDT_DevTools::VERSION,
true
);
wp_localize_script( 'csdt-frontend-admin', 'csdtFrontAdmin', self::gen_dialog_config() );
wp_enqueue_script(
'csdt-editor-featured-image',
plugins_url( 'assets/cs-editor-featured-image.js', dirname( __DIR__ ) . '/cloudscale-cyber-devtools.php' ),
array( 'csdt-frontend-admin', 'wp-hooks', 'wp-element', 'wp-data' ),
CSDT_DevTools::VERSION,
true
);
}
/**
* True when the frontend editor toolbar (#csdt-gen-bar) will render on this
* request: a single post, an admin, and at least one of its own buttons shown OR
* another plugin has registered content for the bar via csdt_gen_bar_items.
*
* Public so sibling plugins (e.g. the SEO CTR pill) can decide server-side whether
* to render INTO the bar (via the hook) or fall back to their own inline position,
* removing the need for any client-side DOM polling to find the bar.
*/
public static function will_render_gen_bar(): bool {
if ( ! is_singular( 'post' ) || ! current_user_can( 'manage_options' ) || ! get_the_ID() ) {
return false;
}
// Every tool on this bar acts on THIS post, so edit_post on it is the real
// requirement, see the note in enqueue_frontend_admin_scripts(). Asked here as well
// as there because the two must not disagree: gating only the enqueue would render a
// bar whose buttons have no script behind them.
if ( ! current_user_can( 'edit_post', get_the_ID() ) ) {
return false;
}
$any_button = get_option( 'csdt_devtools_frontbar_copy', true )
|| get_option( 'csdt_devtools_frontbar_genimg', true )
|| get_option( 'csdt_devtools_frontbar_versions', true );
return (bool) $any_button || has_action( 'csdt_gen_bar_items' );
}
public static function inject_gen_image_button(): void {
if ( ! self::will_render_gen_bar() ) {
return;
}
$post_id = get_the_ID();
// Per-button visibility. Each defaults to shown; a client can hide any it
// does not use from Tools → CloudScale Cyber & Devtools → Featured Images.
$show_copy = (bool) get_option( 'csdt_devtools_frontbar_copy', true );
$show_genimg = (bool) get_option( 'csdt_devtools_frontbar_genimg', true );
$show_versions = (bool) get_option( 'csdt_devtools_frontbar_versions', true );
// A panel, not a bare row: a labelled container with a scrolling track and
// prev/next arrows (cs-frontend-admin.js). The bare row clipped its last pill at
// the edge with no sign it scrolled. The class is csdt-postbar, not csdt-gen-bar,
// because cs-gen-overlay.js styles .csdt-gen-bar as its 9px progress bar and
// that rule squashed this toolbar whenever the overlay's stylesheet was on the
// page. The id stays: sibling plugins and tests find the bar by it.
echo '
';
if ( $show_copy ) {
echo '';
}
if ( $show_genimg ) {
echo '';
}
if ( $show_versions ) {
echo '';
}
// Slot for sibling plugins to render buttons INTO this bar (e.g. the SEO
// "Optimise Click Through Rate" pill) server-side, no JS docking/polling.
do_action( 'csdt_gen_bar_items', $post_id );
echo '
' . "\n";
}
// ─── Private: full URL diagnostic ────────────────────────────────────
/**
* Runs all social-preview checks against a URL and returns a structured
* result array with sections (title + result items) and summary totals.
*/
private static function social_diagnose_url( string $url ): array {
$sections = array();
$og_image = '';
$img_kb = null;
$img_w = null;
$img_h = null;
$wa_ua = self::SOCIAL_UAS['WhatsApp'];
// 1. HTTPS
$pass = str_starts_with( $url, 'https://' );
$sections[] = array(
'title' => 'HTTPS',
'results' => array(
$pass
? array(
'type' => 'pass',
'msg' => 'URL uses HTTPS',
)
: array(
'type' => 'fail',
'msg' => 'URL uses HTTP, WhatsApp requires HTTPS for link previews.',
'fix' => 'Install an SSL certificate (Let\'s Encrypt is free) and update your WordPress Address and Site Address in Settings → General to use https://. Add a redirect rule to force HTTP → HTTPS.',
),
),
);
// 2. HTTP response (WhatsApp UA)
$head = wp_safe_remote_head(
$url,
array(
'user-agent' => $wa_ua,
'redirection' => 5,
'timeout' => 12,
'sslverify' => true,
)
);
$http_ok = false;
if ( is_wp_error( $head ) ) {
$sections[] = array(
'title' => 'HTTP Response',
'results' => array(
array(
'type' => 'fail',
'msg' => 'Could not connect: ' . CSDT_Error_Text::in_seconds( $head->get_error_message() ),
),
),
);
} else {
$code = wp_remote_retrieve_response_code( $head );
$http_ok = ( $code === 200 );
$is_redirect = in_array( $code, array( 301, 302, 307, 308 ), true );
$sections[] = array(
'title' => 'HTTP Response (WhatsApp UA)',
'results' => array(
$code === 200
? array(
'type' => 'pass',
'msg' => 'HTTP 200 OK',
)
: array(
'type' => $is_redirect ? 'warn' : 'fail',
'msg' => "HTTP $code, " . ( $is_redirect ? 'Redirect (crawlers follow, but adds latency)' : 'Non-200 response; crawler may not read OG tags' ),
'fix' => $is_redirect
? 'Update og:url and your canonical tag to point directly to the final URL to avoid the redirect chain.'
: 'Check your WAF, Cloudflare firewall rules, or bot-protection plugins, they may be blocking the WhatsApp crawler User-Agent. Add facebookexternalhit, WhatsApp, Twitterbot, and LinkedInBot to your allowlist.',
),
),
);
}
// 3. Fetch HTML + measure response time
$start = microtime( true );
$resp = wp_safe_remote_get(
$url,
array(
'user-agent' => $wa_ua,
'redirection' => 5,
'timeout' => 18,
)
);
$elapsed = round( microtime( true ) - $start, 2 );
$html = is_wp_error( $resp ) ? '' : wp_remote_retrieve_body( $resp );
$sections[] = array(
'title' => 'Response Time',
'results' => array(
$elapsed < 3.0
? array(
'type' => 'pass',
'msg' => "{$elapsed}s, within 3s crawler timeout",
)
: ( $elapsed < 5.0
? array(
'type' => 'warn',
'msg' => "{$elapsed}s, approaching WhatsApp 3 to 5s timeout",
'fix' => 'Enable a page caching plugin (e.g. WP Super Cache or W3 Total Cache) and enable Cloudflare\'s HTML caching. Crawlers hit cold pages, caching ensures they get a fast response every time.',
)
: array(
'type' => 'fail',
'msg' => "{$elapsed}s, exceeds 5s; crawler will likely abort before reading OG tags",
'fix' => 'Enable full-page caching immediately. Check for slow database queries using the CS Monitor DB tab, deactivate heavy plugins, and enable Cloudflare in front of the origin server.',
) ),
),
);
// 4. OG tags
$og_results = array();
$og_fixes = array(
'og:title' => 'Add to the . Use an SEO plugin like Yoast or Rank Math, they generate this automatically from your post title.',
'og:description' => 'Add . Most SEO plugins set this from the meta description field on each post/page.',
'og:image' => 'Add with the absolute URL of your image. Use a 1200×630px JPEG/PNG under 300 KB. Set a site-wide fallback in your SEO plugin settings.',
'og:url' => 'Add using the canonical URL of this page.',
'og:type' => 'Add (or "article" for blog posts). Most SEO plugins set this automatically.',
);
foreach ( array( 'og:title', 'og:description', 'og:image', 'og:url', 'og:type' ) as $prop ) {
$val = self::social_extract_property( $html, $prop );
$og_results[] = $val
? array(
'type' => 'pass',
'msg' => "$prop: " . mb_substr( $val, 0, 80 ),
)
: array(
'type' => 'fail',
'msg' => "$prop is missing",
'fix' => $og_fixes[ $prop ] ?? '',
);
}
foreach ( array( 'twitter:card', 'twitter:image' ) as $name ) {
$val = self::social_extract_name( $html, $name );
$og_results[] = $val
? array(
'type' => 'pass',
'msg' => "$name: " . mb_substr( $val, 0, 80 ),
)
: array(
'type' => 'warn',
'msg' => "$name missing, X/Twitter may not render large card",
'fix' => $name === 'twitter:card'
? 'Add to show the full-width image card on X/Twitter. Most SEO plugins have a Twitter Card setting.'
// The example URL is built from this site, not a made-up hostname. It reads
// better (the reader can see their own domain) and it means the advice
// carries no third-party address for a scanner to read as an outbound call.
: 'Add . X/Twitter uses this over og:image if present.',
);
}
$sections[] = array(
'title' => 'Open Graph Tags',
'results' => $og_results,
);
// 5. og:image analysis
$og_image = self::social_extract_property( $html, 'og:image' );
$img_results = array();
if ( ! $og_image ) {
$img_results[] = array(
'type' => 'fail',
'msg' => 'og:image is missing, cannot analyse image.',
'fix' => 'Set a featured image on this post/page and ensure your SEO plugin is configured to use it as og:image. Add a site-wide fallback image in your SEO plugin settings.',
);
} else {
// Warn if og:image is the site-wide fallback (post has no post-specific featured image).
$seo_opts = get_option( 'cloudscale_seo_ai_optimizer_options', get_option( 'cs_seo_options', array() ) );
$default_og_url = trim( (string) ( $seo_opts['default_og_image'] ?? '' ) );
if ( $default_og_url ) {
$norm = static function ( string $u ): string {
return rtrim( strtok( $u, '?' ), '/' );
};
if ( $norm( $og_image ) === $norm( $default_og_url ) ) {
$img_results[] = array(
'type' => 'warn',
'msg' => 'og:image is the site-wide default fallback, this post has no featured image set. Every post without a featured image will show this same generic image on social media.',
'fix' => 'Set a featured image on this post to give it a unique social preview.',
);
}
}
$img_head = wp_safe_remote_head(
$og_image,
array(
'user-agent' => $wa_ua,
'timeout' => 10,
'redirection' => 3,
)
);
if ( is_wp_error( $img_head ) ) {
$img_results[] = array(
'type' => 'fail',
'msg' => 'og:image URL unreachable: ' . CSDT_Error_Text::in_seconds( $img_head->get_error_message() ),
'fix' => 'Verify the image URL is publicly accessible. Check that the file exists in your Media Library and that no security plugin or Cloudflare rule is blocking direct image access.',
);
} else {
$img_code = wp_remote_retrieve_response_code( $img_head );
$img_results[] = $img_code === 200
? array(
'type' => 'pass',
'msg' => 'og:image URL returns HTTP 200',
)
: array(
'type' => 'fail',
'msg' => "og:image URL returns HTTP $img_code, image inaccessible",
'fix' => "The image file returned HTTP $img_code. Re-upload the image to your Media Library, update the og:image URL to the new path, and confirm the file is publicly readable (check file permissions and any WAF rules blocking image requests).",
);
$ct = wp_remote_retrieve_header( $img_head, 'content-type' );
$img_results[] = str_contains( (string) $ct, 'image/' )
? array(
'type' => 'pass',
'msg' => "Content-Type: $ct",
)
: array(
'type' => 'warn',
'msg' => "Unexpected Content-Type: '$ct'",
'fix' => 'The URL is not serving an image file. Verify the og:image URL points directly to a JPEG, PNG, or WebP file (not a page or redirect). If using a CDN, check that it is not transforming the response Content-Type.',
);
}
$img_results[] = str_starts_with( $og_image, 'https://' )
? array(
'type' => 'pass',
'msg' => 'og:image uses HTTPS',
)
: array(
'type' => 'fail',
'msg' => 'og:image uses HTTP, WhatsApp requires HTTPS images',
'fix' => 'Update the og:image URL to use https://. If your site has SSL, the image URL should automatically use HTTPS, check your SEO plugin settings or the post\'s custom OG image field.',
);
$img_resp = wp_safe_remote_get(
$og_image,
array(
'user-agent' => $wa_ua,
'timeout' => 20,
'redirection' => 3,
)
);
if ( ! is_wp_error( $img_resp ) ) {
$img_body = wp_remote_retrieve_body( $img_resp );
$img_bytes = strlen( $img_body );
$img_kb = round( $img_bytes / 1024, 1 );
if ( $img_bytes > 307200 ) {
$img_results[] = array(
'type' => 'fail',
'msg' => "Image is {$img_kb} KB, exceeds WhatsApp's 300 KB silent-failure threshold. Compress to under 250 KB.",
'fix' => 'Recompress this image with squoosh.app / TinyPNG to manually compress it to under 250 KB. Then re-upload and update the og:image URL.',
);
} elseif ( $img_bytes > 204800 ) {
$img_results[] = array(
'type' => 'warn',
'msg' => "Image is {$img_kb} KB, approaching 300 KB WhatsApp limit. Consider optimising.",
'fix' => 'Compress the image to under 200 KB using TinyPNG or squoosh.app. JPEG at 80% quality typically achieves good compression without visible quality loss.',
);
} else {
$img_results[] = array(
'type' => 'pass',
'msg' => "Image is {$img_kb} KB, within the 300 KB WhatsApp limit.",
);
}
if ( function_exists( 'imagecreatefromstring' ) ) {
$res = @imagecreatefromstring( $img_body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( $res ) {
$img_w = imagesx( $res );
$img_h = imagesy( $res );
unset( $res ); // Frees the GD image; imagedestroy() has been a no-op since PHP 8.0 and is deprecated in 8.5.
if ( $img_w >= 1200 && $img_h >= 630 ) {
$img_results[] = array(
'type' => 'pass',
'msg' => "Dimensions: {$img_w}×{$img_h}px, meets 1200×630 minimum",
);
} elseif ( $img_w >= 600 ) {
$img_results[] = array(
'type' => 'warn',
'msg' => "Dimensions: {$img_w}×{$img_h}px, below recommended 1200×630",
'fix' => 'Resize or recreate the image at 1200×630px. This is the optimal size for Facebook, LinkedIn, and WhatsApp. Use Canva or your image editor to export at exactly 1200×630px.',
);
} else {
$img_results[] = array(
'type' => 'fail',
'msg' => "Dimensions: {$img_w}×{$img_h}px, too small for reliable social previews",
'fix' => 'Replace this image with one at least 1200×630px. Images smaller than 600px wide are often ignored by social crawlers entirely. Create a new featured image at 1200×630px.',
);
}
}
}
}
}
$sections[] = array(
'title' => 'og:image Analysis',
'results' => $img_results,
);
// 6. robots.txt
$base_url = preg_replace( '#(https?://[^/]+).*#', '$1', $url );
$robots_resp = wp_safe_remote_get(
"$base_url/robots.txt",
array(
'timeout' => 8,
'user-agent' => CSDT_DevTools::USER_AGENT,
)
);
$rb_results = array();
if ( is_wp_error( $robots_resp ) || wp_remote_retrieve_response_code( $robots_resp ) !== 200 ) {
$rb_results[] = array(
'type' => 'warn',
'msg' => 'robots.txt not found, ensure crawlers are not blocked elsewhere',
'fix' => 'Create a robots.txt at your domain root. In WordPress, go to Settings → Reading and ensure "Discourage search engines" is unchecked. CloudScale SEO AI auto-generates robots.txt, enable it if available.',
);
} else {
$rb_body = wp_remote_retrieve_body( $robots_resp );
foreach ( array( 'facebookexternalhit', 'WhatsApp', 'Facebot', 'LinkedInBot', 'Twitterbot' ) as $bot ) {
if ( preg_match( '/User-agent:\s*' . preg_quote( $bot, '/' ) . '.*?Disallow:\s*\//si', $rb_body ) ) {
$rb_results[] = array(
'type' => 'fail',
'msg' => "robots.txt blocks $bot, this prevents all previews from that platform",
'fix' => "Remove the Disallow rule for $bot from your robots.txt. Add \"User-agent: $bot\\nDisallow:\" (empty Disallow = allow all) to explicitly permit this crawler. Edit robots.txt via your SEO plugin or directly in the site root.",
);
} else {
$rb_results[] = array(
'type' => 'pass',
'msg' => "robots.txt does not block $bot",
);
}
}
}
$sections[] = array(
'title' => 'robots.txt',
'results' => $rb_results,
);
// 7. Cloudflare detection
$cf_results = array();
$cf_ray = wp_remote_retrieve_header( is_wp_error( $head ) ? array() : $head, 'cf-ray' );
if ( $cf_ray ) {
$cf_cache = wp_remote_retrieve_header( is_wp_error( $head ) ? array() : $head, 'cf-cache-status' );
$cf_results[] = array(
'type' => 'pass',
'msg' => "Cloudflare active: cf-ray $cf_ray" . ( $cf_cache ? " | Cache: $cf_cache" : '' ),
);
$cf_results[] = array(
'type' => 'info',
'msg' => 'If any crawler UA test failed, set up a WAF Skip rule in Cloudflare for social crawler user agents, see the Cloudflare Setup panel.',
);
} else {
$cf_results[] = array(
'type' => 'pass',
'msg' => 'No Cloudflare detected, WAF skip rule not required',
);
}
$sections[] = array(
'title' => 'Cloudflare',
'results' => $cf_results,
);
// Totals
$pass = $warn = $fail = 0;
foreach ( $sections as $s ) {
foreach ( $s['results'] as $r ) {
match ( $r['type'] ) {
'pass' => $pass++, 'warn' => $warn++, 'fail' => $fail++, default => null };
}
}
return array(
'url' => $url,
'sections' => $sections,
'totals' => array(
'pass' => $pass,
'warn' => $warn,
'fail' => $fail,
),
'og_image' => $og_image,
'img_kb' => $img_kb,
'img_w' => $img_w,
'img_h' => $img_h,
);
}
/** Runs the five social crawler UA tests against a URL, used by the CF test button. */
private static function social_test_crawlers( string $url ): array {
$results = array();
foreach ( self::SOCIAL_UAS as $label => $ua ) {
$resp = wp_safe_remote_get(
$url,
array(
'user-agent' => $ua,
'redirection' => 5,
'timeout' => 15,
)
);
if ( is_wp_error( $resp ) ) {
$results[ $label ] = array(
'type' => 'fail',
'code' => 0,
'og' => false,
'msg' => CSDT_Error_Text::in_seconds( $resp->get_error_message() ),
);
continue;
}
$code = wp_remote_retrieve_response_code( $resp );
$body = wp_remote_retrieve_body( $resp );
$has_og = (bool) preg_match( '/property=["\']og:image["\']/', $body );
$challenged = str_contains( $body, 'challenge-platform' );
if ( $code === 200 && $has_og ) {
// A crawler that can read the page can still fail to render a card, because
// the IMAGE is a second fetch it makes separately. That is the shape of the
// failure this panel is for: a preview with the title and no picture means
// the page fetch succeeded and the image fetch did not. Checking only that
// the tag exists reports a pass for exactly that case, so fetch the image
// too, with the same user agent, and let the crawler's own limits decide.
$img_url = self::social_extract_property( $body, 'og:image' );
$img_check = self::social_test_og_image( $img_url, $ua );
$page_msg = $challenged ? 'HTTP 200, og:image present (Cloudflare challenge script detected, WAF skip rule is working)' : 'HTTP 200, og:image present';
$results[ $label ] = array(
'type' => $img_check['ok'] ? 'pass' : 'fail',
'code' => $code,
'og' => true,
'image' => $img_check,
'msg' => $page_msg . '. ' . $img_check['msg'],
);
} elseif ( $code === 200 && ! $has_og ) {
$results[ $label ] = array(
'type' => 'fail',
'code' => $code,
'og' => false,
'msg' => $challenged ? 'HTTP 200 but og:image absent, Bot Fight Mode is blocking this crawler. WAF skip rule needed.' : 'HTTP 200 but og:image absent in response',
);
} else {
$results[ $label ] = array(
'type' => 'fail',
'code' => $code,
'og' => false,
'msg' => "HTTP $code, crawler is being blocked",
);
}
}
return $results;
}
/**
* Fetches the og:image with one crawler's user agent and says whether that crawler
* could actually use it.
*
* Separate from the page fetch on purpose. Social platforms fetch the page and the
* image as two independent requests, and a WAF, a bot rule or a hotlink rule can
* allow one and refuse the other. When that happens the card renders with the title
* and description and no picture, which is what a reader sees as "the preview is
* broken" while every page-level check passes.
*
* The size ceiling is WhatsApp's, which is the tightest of the platforms tested here
* and the one this plugin already targets when it crops (see the 300 KB guidance in
* the Thumbnails help). A larger image is reported rather than treated as fatal for
* the others, because it is a real cause of a missing WhatsApp card and a non-issue
* for Facebook.
*
* @since 1.10.836
* @param string $img_url The og:image URL as the page declared it.
* @param string $ua The crawler user agent to fetch it with.
* @return array{ok:bool,msg:string,code:int,bytes:int,type:string}
*/
private static function social_test_og_image( string $img_url, string $ua ): array {
$fail = static function ( string $msg, int $code = 0, int $bytes = 0, string $type = '' ): array {
return array(
'ok' => false,
'msg' => $msg,
'code' => $code,
'bytes' => $bytes,
'type' => $type,
);
};
if ( '' === $img_url ) {
return $fail( 'The og:image tag has no URL, so there is nothing for the crawler to fetch.' );
}
if ( ! self::is_safe_external_url( $img_url ) ) {
return $fail( 'The og:image URL is not a publicly reachable address, so no crawler can fetch it.' );
}
$resp = wp_safe_remote_get(
$img_url,
array(
'user-agent' => $ua,
'redirection' => 5,
'timeout' => 15,
)
);
if ( is_wp_error( $resp ) ) {
return $fail( 'The image could not be fetched: ' . CSDT_Error_Text::in_seconds( $resp->get_error_message() ) );
}
$code = (int) wp_remote_retrieve_response_code( $resp );
$type = strtolower( (string) wp_remote_retrieve_header( $resp, 'content-type' ) );
$bytes = strlen( (string) wp_remote_retrieve_body( $resp ) );
if ( 200 !== $code ) {
return $fail( "The image returned HTTP {$code} to this crawler, so the card will have no picture. The page is reachable, the image is not.", $code, $bytes, $type );
}
if ( '' !== $type && ! str_starts_with( $type, 'image/' ) ) {
// A challenge page or an error page served with a 200 is the usual cause.
return $fail( "The image URL returned '{$type}' rather than an image, so the crawler has nothing to render.", $code, $bytes, $type );
}
if ( 0 === $bytes ) {
return $fail( 'The image returned an empty body, so the crawler has nothing to render.', $code, $bytes, $type );
}
$kb = (int) round( $bytes / 1024 );
if ( $bytes > self::SOCIAL_IMAGE_MAX_BYTES ) {
return $fail(
"The image fetches (HTTP 200) but is {$kb} KB, over WhatsApp's " . (int) round( self::SOCIAL_IMAGE_MAX_BYTES / 1024 ) . ' KB ceiling, so WhatsApp will show the card without a picture.',
$code,
$bytes,
$type
);
}
return array(
'ok' => true,
'msg' => "Image fetched too (HTTP 200, {$kb} KB).",
'code' => $code,
'bytes' => $bytes,
'type' => $type,
);
}
/** Helper: extract og:meta property content. */
private static function social_extract_property( string $html, string $prop ): string {
if ( preg_match( '/property=["\']' . preg_quote( $prop, '/' ) . '["\'][^>]+content=["\']([^"\']+)["\']/', $html, $m ) ) {
return trim( $m[1] );
}
if ( preg_match( '/content=["\']([^"\']+)["\'][^>]+property=["\']' . preg_quote( $prop, '/' ) . '["\']/', $html, $m ) ) {
return trim( $m[1] );
}
return '';
}
/** Helper: extract meta name content. */
private static function social_extract_name( string $html, string $name ): string {
if ( preg_match( '/name=["\']' . preg_quote( $name, '/' ) . '["\'][^>]+content=["\']([^"\']+)["\']/', $html, $m ) ) {
return trim( $m[1] );
}
if ( preg_match( '/content=["\']([^"\']+)["\'][^>]+name=["\']' . preg_quote( $name, '/' ) . '["\']/', $html, $m ) ) {
return trim( $m[1] );
}
return '';
}
/**
* Helper: recompress an attachment to under 300 KB using WP_Image_Editor.
*
* @return array|\WP_Error
*/
private static function social_recompress_image( int $attachment_id ) {
$file_path = get_attached_file( $attachment_id );
if ( ! $file_path || ! file_exists( $file_path ) ) {
return new \WP_Error( 'not_found', __( 'Attachment file not found on disk.', 'cloudscale-cyber-devtools' ) );
}
$ext = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
if ( ! in_array( $ext, array( 'jpg', 'jpeg', 'png' ), true ) ) {
return new \WP_Error( 'unsupported', __( 'Only JPEG and PNG images can be recompressed.', 'cloudscale-cyber-devtools' ) );
}
// The recompressed image is written as a NEW file beside the original, the way
// core's own image editor saves an edit (wp_save_image() adds an "-e