| 1 |
<?php |
| 2 |
// If this file is called directly, abort. |
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* SEO Health List Table |
| 9 |
* |
| 10 |
* Displays all posts/pages with their SEO completeness indicators. |
| 11 |
* Uses CSS classes (not inline styles) for dark-theme compatibility. |
| 12 |
* |
| 13 |
* @since 3.0.0 |
| 14 |
* @package Metasync |
| 15 |
* @subpackage Metasync/admin |
| 16 |
*/ |
| 17 |
class Metasync_SEO_Health_List_Table extends WP_List_Table |
| 18 |
{ |
| 19 |
/** Nonce action for bulk actions. */ |
| 20 |
const NONCE_ACTION = 'metasync_seo_health_bulk'; |
| 21 |
|
| 22 |
public function __construct() |
| 23 |
{ |
| 24 |
parent::__construct(array( |
| 25 |
'singular' => 'seo-health-item', |
| 26 |
'plural' => 'seo-health-items', |
| 27 |
'ajax' => false, |
| 28 |
)); |
| 29 |
} |
| 30 |
|
| 31 |
public function get_columns() |
| 32 |
{ |
| 33 |
return array( |
| 34 |
'cb' => '<input type="checkbox" />', |
| 35 |
'title' => _x('Title', 'Column label', 'metasync'), |
| 36 |
'post_type' => _x('Post Type', 'Column label', 'metasync'), |
| 37 |
'status' => _x('Status', 'Column label', 'metasync'), |
| 38 |
'seo_title' => _x('SEO Title', 'Column label', 'metasync'), |
| 39 |
'meta_description' => _x('Meta Description', 'Column label', 'metasync'), |
| 40 |
'schema' => _x('Schema', 'Column label', 'metasync'), |
| 41 |
'og_image' => _x('OG Image', 'Column label', 'metasync'), |
| 42 |
'alt_text_coverage' => _x('Alt Text', 'Column label', 'metasync'), |
| 43 |
'last_modified' => _x('Last Modified', 'Column label', 'metasync'), |
| 44 |
); |
| 45 |
} |
| 46 |
|
| 47 |
protected function get_sortable_columns() |
| 48 |
{ |
| 49 |
return array( |
| 50 |
'title' => array('title', false), |
| 51 |
'post_type' => array('post_type', false), |
| 52 |
'status' => array('post_status', false), |
| 53 |
'last_modified' => array('post_modified', false), |
| 54 |
); |
| 55 |
} |
| 56 |
|
| 57 |
protected function column_cb($item) |
| 58 |
{ |
| 59 |
return sprintf( |
| 60 |
'<input type="checkbox" name="post_ids[]" value="%s" />', |
| 61 |
absint($item->ID) |
| 62 |
); |
| 63 |
} |
| 64 |
|
| 65 |
protected function column_default($item, $column_name) |
| 66 |
{ |
| 67 |
switch ($column_name) { |
| 68 |
case 'title': |
| 69 |
return sprintf( |
| 70 |
'<a href="%s"><strong>%s</strong></a>', |
| 71 |
esc_url(get_edit_post_link($item->ID)), |
| 72 |
esc_html($item->post_title) |
| 73 |
); |
| 74 |
|
| 75 |
case 'post_type': |
| 76 |
$pt_obj = get_post_type_object($item->post_type); |
| 77 |
return esc_html($pt_obj ? $pt_obj->labels->singular_name : $item->post_type); |
| 78 |
|
| 79 |
case 'status': |
| 80 |
return esc_html(ucfirst($item->post_status)); |
| 81 |
|
| 82 |
case 'seo_title': |
| 83 |
return $this->render_seo_meta_column($item->ID, 'title', 30, 60); |
| 84 |
|
| 85 |
case 'meta_description': |
| 86 |
return $this->render_seo_meta_column($item->ID, 'description', 120, 160); |
| 87 |
|
| 88 |
case 'schema': |
| 89 |
return Metasync_SEO_Health::has_schema($item->ID) |
| 90 |
? '<span class="seo-status-set optimal">✓ Yes</span>' |
| 91 |
: '<span class="seo-status-none">— None</span>'; |
| 92 |
|
| 93 |
case 'og_image': |
| 94 |
return Metasync_SEO_Health::has_og_image($item->ID) |
| 95 |
? '<span class="seo-status-set optimal">✓ Set</span>' |
| 96 |
: '<span class="seo-status-missing">⚠ Missing</span>'; |
| 97 |
|
| 98 |
case 'alt_text_coverage': |
| 99 |
return $this->render_alt_text_column($item); |
| 100 |
|
| 101 |
case 'last_modified': |
| 102 |
return esc_html(get_the_modified_date('Y-m-d', $item)); |
| 103 |
|
| 104 |
default: |
| 105 |
return ''; |
| 106 |
} |
| 107 |
} |
| 108 |
|
| 109 |
/** |
| 110 |
* Render an SEO meta column (title or description) with fallback and length indicator. |
| 111 |
* |
| 112 |
* The resolver still reports which tier supplied the value, but the column |
| 113 |
* deliberately ignores it: the table answers whether the field is set, not |
| 114 |
* which internal source filled it. Naming one source means naming them all |
| 115 |
* — OTTO, OG, Yoast, Rank Math, AIOSEO — and a row of competing labels |
| 116 |
* reads as noise to a customer who only owns one of them. |
| 117 |
*/ |
| 118 |
private function render_seo_meta_column($post_id, $field, $min_optimal, $max_optimal) |
| 119 |
{ |
| 120 |
$result = Metasync_SEO_Health::get_seo_meta_with_fallback($post_id, $field); |
| 121 |
|
| 122 |
if (empty($result['value'])) { |
| 123 |
return '<span class="seo-status-missing">⚠ Missing</span>'; |
| 124 |
} |
| 125 |
|
| 126 |
$length = mb_strlen($result['value']); |
| 127 |
$quality = ($length >= $min_optimal && $length <= $max_optimal) ? 'optimal' : 'suboptimal'; |
| 128 |
|
| 129 |
return sprintf( |
| 130 |
'<span class="seo-status-set %s">✓ %d chars</span>', |
| 131 |
$quality, |
| 132 |
$length |
| 133 |
); |
| 134 |
} |
| 135 |
|
| 136 |
/** |
| 137 |
* Render alt text coverage column. |
| 138 |
*/ |
| 139 |
private function render_alt_text_column($item) |
| 140 |
{ |
| 141 |
$alt = Metasync_SEO_Health::calculate_alt_text_coverage($item->post_content); |
| 142 |
|
| 143 |
if ($alt['total'] === 0) { |
| 144 |
return '<span class="seo-status-none">No images</span>'; |
| 145 |
} |
| 146 |
|
| 147 |
$quality = 'seo-status-missing'; |
| 148 |
if ($alt['percentage'] === 100) { |
| 149 |
$quality = 'seo-status-set optimal'; |
| 150 |
} elseif ($alt['percentage'] >= 50) { |
| 151 |
$quality = 'seo-status-set suboptimal'; |
| 152 |
} |
| 153 |
|
| 154 |
return sprintf( |
| 155 |
'<span class="%s">%d%% (%d/%d)</span>', |
| 156 |
$quality, |
| 157 |
$alt['percentage'], |
| 158 |
$alt['with_alt'], |
| 159 |
$alt['total'] |
| 160 |
); |
| 161 |
} |
| 162 |
|
| 163 |
protected function get_bulk_actions() |
| 164 |
{ |
| 165 |
return array( |
| 166 |
'push_to_platform' => _x('Push to platform for optimization', 'List table bulk action', 'metasync'), |
| 167 |
/* translators: %s: effective plugin name (whitelabel-aware). */ |
| 168 |
'clear_metasync_cache' => sprintf(_x('Clear %s cache for selected', 'List table bulk action', 'metasync'), Metasync::get_effective_plugin_name()), |
| 169 |
); |
| 170 |
} |
| 171 |
|
| 172 |
protected function process_bulk_action() |
| 173 |
{ |
| 174 |
if (!$this->current_action()) { |
| 175 |
return; |
| 176 |
} |
| 177 |
|
| 178 |
check_admin_referer(self::NONCE_ACTION, '_wpnonce_bulk'); |
| 179 |
|
| 180 |
$post_ids = isset($_REQUEST['post_ids']) && is_array($_REQUEST['post_ids']) |
| 181 |
? array_map('absint', $_REQUEST['post_ids']) |
| 182 |
: array(); |
| 183 |
|
| 184 |
if (empty($post_ids)) { |
| 185 |
return; |
| 186 |
} |
| 187 |
|
| 188 |
if ('clear_metasync_cache' === $this->current_action()) { |
| 189 |
if (class_exists('Metasync_Cache_Purge')) { |
| 190 |
foreach ($post_ids as $post_id) { |
| 191 |
$url = get_permalink($post_id); |
| 192 |
if ($url) { |
| 193 |
Metasync_Cache_Purge::purge_single_url($url); |
| 194 |
} |
| 195 |
} |
| 196 |
} |
| 197 |
} |
| 198 |
|
| 199 |
if ('push_to_platform' === $this->current_action()) { |
| 200 |
// TODO: Implement Search Atlas API integration to queue posts for optimization. |
| 201 |
// The action hook is in place — needs a listener once the backend API endpoint exists. |
| 202 |
do_action('metasync_push_posts_to_platform', $post_ids); |
| 203 |
} |
| 204 |
} |
| 205 |
|
| 206 |
/** |
| 207 |
* Output the nonce field for bulk actions. |
| 208 |
* Called automatically by WP_List_Table::display(). |
| 209 |
*/ |
| 210 |
protected function extra_tablenav($which) |
| 211 |
{ |
| 212 |
if ($which === 'top') { |
| 213 |
wp_nonce_field(self::NONCE_ACTION, '_wpnonce_bulk'); |
| 214 |
?> |
| 215 |
<div class="alignleft actions"> |
| 216 |
<?php $this->render_filter_dropdown('post_type_filter', $this->get_post_type_options(), __('All Post Types', 'metasync')); ?> |
| 217 |
<?php $this->render_filter_dropdown('status_filter', $this->get_status_options(), __('All Statuses', 'metasync')); ?> |
| 218 |
<?php $this->render_filter_dropdown('missing_filter', $this->get_missing_options(), __('All Items', 'metasync')); ?> |
| 219 |
<?php submit_button(__('Filter', 'metasync'), '', 'filter_action', false); ?> |
| 220 |
</div> |
| 221 |
<?php |
| 222 |
} |
| 223 |
} |
| 224 |
|
| 225 |
/** |
| 226 |
* Render a single filter dropdown. |
| 227 |
*/ |
| 228 |
private function render_filter_dropdown($name, $options, $default_label) |
| 229 |
{ |
| 230 |
$current = isset($_GET[$name]) ? sanitize_text_field(wp_unslash($_GET[$name])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed filter and sort values for the list query |
| 231 |
?> |
| 232 |
<select name="<?php echo esc_attr($name); ?>"> |
| 233 |
<option value=""><?php echo esc_html($default_label); ?></option> |
| 234 |
<?php foreach ($options as $value => $label): ?> |
| 235 |
<option value="<?php echo esc_attr($value); ?>" <?php selected($current, $value); ?>> |
| 236 |
<?php echo esc_html($label); ?> |
| 237 |
</option> |
| 238 |
<?php endforeach; ?> |
| 239 |
</select> |
| 240 |
<?php |
| 241 |
} |
| 242 |
|
| 243 |
private function get_post_type_options() |
| 244 |
{ |
| 245 |
$options = array(); |
| 246 |
foreach (Metasync_SEO_Health::get_supported_post_types() as $pt) { |
| 247 |
$pt_obj = get_post_type_object($pt); |
| 248 |
if ($pt_obj) { |
| 249 |
$options[$pt] = $pt_obj->labels->singular_name; |
| 250 |
} |
| 251 |
} |
| 252 |
return $options; |
| 253 |
} |
| 254 |
|
| 255 |
private function get_status_options() |
| 256 |
{ |
| 257 |
return array( |
| 258 |
'publish' => __('Published', 'metasync'), |
| 259 |
'draft' => __('Draft', 'metasync'), |
| 260 |
); |
| 261 |
} |
| 262 |
|
| 263 |
private function get_missing_options() |
| 264 |
{ |
| 265 |
return array( |
| 266 |
'missing_title' => __('Missing SEO Title', 'metasync'), |
| 267 |
'missing_description' => __('Missing Description', 'metasync'), |
| 268 |
'missing_schema' => __('Missing Schema', 'metasync'), |
| 269 |
'missing_og_image' => __('Missing OG Image', 'metasync'), |
| 270 |
'missing_alt_text' => __('Missing Alt Text', 'metasync'), |
| 271 |
); |
| 272 |
} |
| 273 |
|
| 274 |
public function prepare_items() |
| 275 |
{ |
| 276 |
$per_page = 20; |
| 277 |
|
| 278 |
$this->_column_headers = array( |
| 279 |
$this->get_columns(), |
| 280 |
array(), |
| 281 |
$this->get_sortable_columns(), |
| 282 |
); |
| 283 |
|
| 284 |
$this->process_bulk_action(); |
| 285 |
|
| 286 |
$current_page = $this->get_pagenum(); |
| 287 |
$missing_filter = isset($_GET['missing_filter']) ? sanitize_text_field(wp_unslash($_GET['missing_filter'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed filter and sort values for the list query |
| 288 |
|
| 289 |
$args = array( |
| 290 |
'post_status' => array('publish', 'draft'), |
| 291 |
'orderby' => 'date', |
| 292 |
'order' => 'DESC', |
| 293 |
); |
| 294 |
|
| 295 |
// Post type filter — validate against allowed types |
| 296 |
$post_type_filter = isset($_GET['post_type_filter']) ? sanitize_text_field(wp_unslash($_GET['post_type_filter'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed filter and sort values for the list query |
| 297 |
$supported_types = Metasync_SEO_Health::get_supported_post_types(); |
| 298 |
$args['post_type'] = (!empty($post_type_filter) && in_array($post_type_filter, $supported_types, true)) |
| 299 |
? $post_type_filter |
| 300 |
: $supported_types; |
| 301 |
|
| 302 |
// Status filter — validate against allowed statuses |
| 303 |
$status_filter = isset($_GET['status_filter']) ? sanitize_text_field(wp_unslash($_GET['status_filter'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed filter and sort values for the list query |
| 304 |
if (!empty($status_filter) && in_array($status_filter, array('publish', 'draft'), true)) { |
| 305 |
$args['post_status'] = $status_filter; |
| 306 |
} |
| 307 |
|
| 308 |
// Search |
| 309 |
$search = isset($_GET['s']) ? sanitize_text_field(wp_unslash($_GET['s'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed filter and sort values for the list query |
| 310 |
if (!empty($search)) { |
| 311 |
$args['s'] = $search; |
| 312 |
} |
| 313 |
|
| 314 |
// Sorting — validate orderby against allowed columns |
| 315 |
$allowed_orderby = array('title', 'post_type', 'post_status', 'post_modified', 'date'); |
| 316 |
$orderby = isset($_GET['orderby']) ? sanitize_text_field(wp_unslash($_GET['orderby'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed filter and sort values for the list query |
| 317 |
$order = isset($_GET['order']) ? sanitize_text_field(wp_unslash($_GET['order'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only, allow-listed filter and sort values for the list query |
| 318 |
if (!empty($orderby) && in_array($orderby, $allowed_orderby, true)) { |
| 319 |
$args['orderby'] = $orderby; |
| 320 |
$args['order'] = (!empty($order) && in_array(strtoupper($order), array('ASC', 'DESC'), true)) |
| 321 |
? strtoupper($order) |
| 322 |
: 'ASC'; |
| 323 |
} |
| 324 |
|
| 325 |
// Always exclude custom/LPS pages — their SEO is self-managed inside their |
| 326 |
// own HTML bundle, so they would otherwise show as false "not set". |
| 327 |
$args['metasync_exclude_custom_pages'] = true; |
| 328 |
add_filter('posts_where', 'metasync_filter_exclude_custom_pages_where', 10, 2); |
| 329 |
|
| 330 |
// Missing filters (title, description, schema, og_image) use correlated |
| 331 |
// anti-existence conditions instead of per-key postmeta joins. |
| 332 |
// This avoids multiplying rows on large stores, especially when found_posts is calculated. |
| 333 |
$query = null; |
| 334 |
if ($this->uses_optimized_missing_meta_filter($missing_filter)) { |
| 335 |
$args['posts_per_page'] = $per_page; |
| 336 |
$args['paged'] = $current_page; |
| 337 |
$args['metasync_seo_health_missing_filter'] = $missing_filter; |
| 338 |
|
| 339 |
add_filter('posts_where', array($this, 'filter_missing_meta_where'), 10, 2); |
| 340 |
try { |
| 341 |
$query = new WP_Query($args); |
| 342 |
} finally { |
| 343 |
remove_filter('posts_where', array($this, 'filter_missing_meta_where'), 10); |
| 344 |
remove_filter('posts_where', 'metasync_filter_exclude_custom_pages_where', 10); |
| 345 |
} |
| 346 |
|
| 347 |
$this->items = $query->posts; |
| 348 |
$total_items = $query->found_posts; |
| 349 |
} |
| 350 |
|
| 351 |
if (!($query instanceof WP_Query)) { |
| 352 |
try { |
| 353 |
if ($missing_filter === 'missing_alt_text') { |
| 354 |
// Alt text requires post_content inspection and post-query filtering. |
| 355 |
$args['posts_per_page'] = -1; |
| 356 |
$query = new WP_Query($args); |
| 357 |
$all_filtered = $this->filter_missing_alt_text($query->posts); |
| 358 |
$total_items = count($all_filtered); |
| 359 |
$this->items = array_slice($all_filtered, ($current_page - 1) * $per_page, $per_page); |
| 360 |
} else { |
| 361 |
// No missing filter (or unrecognized filter) - standard paginated query. |
| 362 |
$args['posts_per_page'] = $per_page; |
| 363 |
$args['paged'] = $current_page; |
| 364 |
|
| 365 |
$query = new WP_Query($args); |
| 366 |
$this->items = $query->posts; |
| 367 |
$total_items = $query->found_posts; |
| 368 |
} |
| 369 |
} finally { |
| 370 |
remove_filter('posts_where', 'metasync_filter_exclude_custom_pages_where', 10); |
| 371 |
} |
| 372 |
} |
| 373 |
|
| 374 |
$this->set_pagination_args(array( |
| 375 |
'total_items' => $total_items, |
| 376 |
'per_page' => $per_page, |
| 377 |
'total_pages' => ceil($total_items / $per_page), |
| 378 |
)); |
| 379 |
} |
| 380 |
|
| 381 |
/** |
| 382 |
* Whether a missing filter can use the single-query anti-existence path. |
| 383 |
* |
| 384 |
* @param string $filter The missing_filter value. |
| 385 |
* @return bool |
| 386 |
*/ |
| 387 |
private function uses_optimized_missing_meta_filter($filter) |
| 388 |
{ |
| 389 |
return in_array($filter, array('missing_title', 'missing_description', 'missing_schema', 'missing_og_image'), true); |
| 390 |
} |
| 391 |
|
| 392 |
/** |
| 393 |
* Add the prepared anti-existence predicate for missing filters. |
| 394 |
* |
| 395 |
* The key maps come from the shared precedence resolver, so the |
| 396 |
* optimized query checks exactly the same active-plugin and fallback keys as |
| 397 |
* the row renderer. Empty values remain missing because the resolver treats |
| 398 |
* only a non-empty value as set. |
| 399 |
* |
| 400 |
* @param string $where Existing WHERE clause. |
| 401 |
* @param WP_Query $query Current query. |
| 402 |
* @return string |
| 403 |
*/ |
| 404 |
public function filter_missing_meta_where($where, $query) |
| 405 |
{ |
| 406 |
$filter = $query->get('metasync_seo_health_missing_filter'); |
| 407 |
if (!$this->uses_optimized_missing_meta_filter($filter)) { |
| 408 |
return $where; |
| 409 |
} |
| 410 |
|
| 411 |
return $where . self::build_missing_meta_filter_where($filter); |
| 412 |
} |
| 413 |
|
| 414 |
/** |
| 415 |
* Build correlated NOT EXISTS condition for a missing filter. |
| 416 |
* |
| 417 |
* @param string $filter The missing_filter value. |
| 418 |
* @return string Prepared SQL, or an empty string for unsupported filters. |
| 419 |
*/ |
| 420 |
private static function build_missing_meta_filter_where($filter) |
| 421 |
{ |
| 422 |
global $wpdb; |
| 423 |
|
| 424 |
if ($filter === 'missing_schema') { |
| 425 |
return $wpdb->prepare( |
| 426 |
" AND NOT EXISTS (\n" |
| 427 |
. "\tSELECT 1\n" |
| 428 |
. "\tFROM {$wpdb->postmeta} AS metasync_seo_health_meta\n" |
| 429 |
. "\tWHERE metasync_seo_health_meta.post_id = {$wpdb->posts}.ID\n" |
| 430 |
. "\t\tAND metasync_seo_health_meta.meta_key = %s\n" |
| 431 |
. "\t\tAND metasync_seo_health_meta.meta_value NOT IN ('', '[]')\n" |
| 432 |
. ")", |
| 433 |
'metasync_schema_markup' |
| 434 |
); |
| 435 |
} |
| 436 |
|
| 437 |
if ($filter === 'missing_og_image') { |
| 438 |
return $wpdb->prepare( |
| 439 |
" AND NOT EXISTS (\n" |
| 440 |
. "\tSELECT 1\n" |
| 441 |
. "\tFROM {$wpdb->postmeta} AS metasync_seo_health_og\n" |
| 442 |
. "\tWHERE metasync_seo_health_og.post_id = {$wpdb->posts}.ID\n" |
| 443 |
. "\t\tAND metasync_seo_health_og.meta_key = %s\n" |
| 444 |
. "\t\tAND metasync_seo_health_og.meta_value <> ''\n" |
| 445 |
. ")\n" |
| 446 |
. "AND NOT EXISTS (\n" |
| 447 |
. "\tSELECT 1\n" |
| 448 |
. "\tFROM {$wpdb->postmeta} AS metasync_seo_health_thumb\n" |
| 449 |
. "\tWHERE metasync_seo_health_thumb.post_id = {$wpdb->posts}.ID\n" |
| 450 |
. "\t\tAND metasync_seo_health_thumb.meta_key = %s\n" |
| 451 |
. "\t\tAND metasync_seo_health_thumb.meta_value <> ''\n" |
| 452 |
. ")", |
| 453 |
'_metasync_og_image', |
| 454 |
'_thumbnail_id' |
| 455 |
); |
| 456 |
} |
| 457 |
|
| 458 |
$keys = self::get_missing_meta_keys($filter); |
| 459 |
if (empty($keys)) { |
| 460 |
return ''; |
| 461 |
} |
| 462 |
|
| 463 |
return $wpdb->prepare( |
| 464 |
" AND NOT EXISTS (\n" |
| 465 |
. "\tSELECT 1\n" |
| 466 |
. "\tFROM {$wpdb->postmeta} AS metasync_seo_health_meta\n" |
| 467 |
. "\tWHERE metasync_seo_health_meta.post_id = {$wpdb->posts}.ID\n" |
| 468 |
. "\t\tAND metasync_seo_health_meta.meta_key IN (" . implode(', ', array_fill(0, count($keys), '%s')) . ")\n" |
| 469 |
. "\t\tAND metasync_seo_health_meta.meta_value <> ''\n" |
| 470 |
. ")", |
| 471 |
$keys |
| 472 |
); |
| 473 |
} |
| 474 |
|
| 475 |
/** |
| 476 |
* Return the precedence keys used by a missing title/description filter. |
| 477 |
* |
| 478 |
* @param string $filter The missing_filter value. |
| 479 |
* @return string[] |
| 480 |
*/ |
| 481 |
private static function get_missing_meta_keys($filter) |
| 482 |
{ |
| 483 |
if ($filter === 'missing_title') { |
| 484 |
return array_keys(Metasync_SEO_Health::title_meta_keys()); |
| 485 |
} |
| 486 |
|
| 487 |
if ($filter === 'missing_description') { |
| 488 |
return array_keys(Metasync_SEO_Health::desc_meta_keys()); |
| 489 |
} |
| 490 |
|
| 491 |
return array(); |
| 492 |
} |
| 493 |
|
| 494 |
/** |
| 495 |
* Filter posts that have images missing alt text. |
| 496 |
* |
| 497 |
* @param array $posts Array of WP_Post objects. |
| 498 |
* @return array Filtered posts with missing alt text. |
| 499 |
*/ |
| 500 |
private function filter_missing_alt_text($posts) |
| 501 |
{ |
| 502 |
$filtered = array(); |
| 503 |
foreach ($posts as $post) { |
| 504 |
if (preg_match_all('/<img[^>]*>/i', $post->post_content, $matches)) { |
| 505 |
foreach ($matches[0] as $img_tag) { |
| 506 |
if (!preg_match('/alt\s*=\s*["\']([^"\']+)["\']/i', $img_tag)) { |
| 507 |
$filtered[] = $post; |
| 508 |
break; |
| 509 |
} |
| 510 |
} |
| 511 |
} |
| 512 |
} |
| 513 |
return $filtered; |
| 514 |
} |
| 515 |
} |
| 516 |
|