| 1 |
<?php |
| 2 |
// If this file is called directly, abort. |
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* SEO meta columns for the posts list table. |
| 9 |
* |
| 10 |
* Adds four opt-in columns (SEO Title, SEO Meta Desc, Noindex, Nofollow) to the |
| 11 |
* Posts / Pages / public-CPT list tables so editors can audit SEO meta at a glance |
| 12 |
* instead of opening each post individually. |
| 13 |
* |
| 14 |
* Values are read from post meta only — never from the OTTO API. OTTO persists its |
| 15 |
* suggestions to `_metasync_otto_title` / `_metasync_otto_description` whenever it |
| 16 |
* syncs a page (see otto/metasync-otto-seo-functions.php), so the list screen needs |
| 17 |
* no network calls. |
| 18 |
* |
| 19 |
* @since 3.0.0 |
| 20 |
* @package Metasync |
| 21 |
* @subpackage Metasync/admin |
| 22 |
*/ |
| 23 |
class Metasync_SEO_Columns |
| 24 |
{ |
| 25 |
/** @var self|null */ |
| 26 |
private static $instance = null; |
| 27 |
|
| 28 |
/** Column IDs. */ |
| 29 |
const COL_TITLE = 'metasync_seo_title'; |
| 30 |
const COL_DESC = 'metasync_seo_desc'; |
| 31 |
const COL_NOINDEX = 'metasync_noindex'; |
| 32 |
const COL_NOFOLLOW = 'metasync_nofollow'; |
| 33 |
|
| 34 |
/** Recommended maximum lengths, used for the character counters. */ |
| 35 |
const TITLE_MAX_LEN = 60; |
| 36 |
const DESC_MAX_LEN = 160; |
| 37 |
|
| 38 |
/** |
| 39 |
* Get the singleton instance. |
| 40 |
* |
| 41 |
* @return self |
| 42 |
*/ |
| 43 |
public static function get_instance() |
| 44 |
{ |
| 45 |
if (self::$instance === null) { |
| 46 |
self::$instance = new self(); |
| 47 |
} |
| 48 |
return self::$instance; |
| 49 |
} |
| 50 |
|
| 51 |
private function __construct() |
| 52 |
{ |
| 53 |
} |
| 54 |
|
| 55 |
/** |
| 56 |
* All column IDs this class owns. |
| 57 |
* |
| 58 |
* @return array |
| 59 |
*/ |
| 60 |
public static function get_column_ids() |
| 61 |
{ |
| 62 |
return array(self::COL_TITLE, self::COL_DESC, self::COL_NOINDEX, self::COL_NOFOLLOW); |
| 63 |
} |
| 64 |
|
| 65 |
/** |
| 66 |
* A companion plugin that already renders these exact columns. |
| 67 |
* |
| 68 |
* WebJIVE's "LNR SEO Columns" was written to fill this gap before the feature |
| 69 |
* existed, and it uses byte-identical column IDs. With both active WordPress |
| 70 |
* shows one column but runs both render callbacks, so every cell would print |
| 71 |
* two stacked values. |
| 72 |
*/ |
| 73 |
const COMPANION_PLUGIN_CLASS = 'WebJIVE_Metasync_SEO_Columns'; |
| 74 |
|
| 75 |
/** |
| 76 |
* Whether a companion plugin owns these columns already. |
| 77 |
* |
| 78 |
* When it does we stand down entirely rather than double-render, and surface a |
| 79 |
* notice telling the site owner they can retire it. Nothing breaks either way — |
| 80 |
* this only avoids duplicated cell content. |
| 81 |
* |
| 82 |
* @return bool |
| 83 |
*/ |
| 84 |
public static function companion_plugin_active() |
| 85 |
{ |
| 86 |
return class_exists(self::COMPANION_PLUGIN_CLASS); |
| 87 |
} |
| 88 |
|
| 89 |
/** |
| 90 |
* Whether the columns should appear for a given post type. |
| 91 |
* |
| 92 |
* Reuses the SEO Health post-type list so the two features never drift: posts, |
| 93 |
* pages, WooCommerce products, plus every public + publicly_queryable CPT, minus |
| 94 |
* builder-internal types (Elementor library, WP templates, Oxygen, Divi layouts). |
| 95 |
* |
| 96 |
* @param string $post_type Post type slug. |
| 97 |
* @return bool |
| 98 |
*/ |
| 99 |
public static function is_supported_post_type($post_type) |
| 100 |
{ |
| 101 |
if (empty($post_type) || !class_exists('Metasync_SEO_Health')) { |
| 102 |
return false; |
| 103 |
} |
| 104 |
if (self::companion_plugin_active()) { |
| 105 |
return false; |
| 106 |
} |
| 107 |
return in_array($post_type, Metasync_SEO_Health::get_supported_post_types(), true); |
| 108 |
} |
| 109 |
|
| 110 |
/** |
| 111 |
* Tell the site owner they no longer need the companion plugin. |
| 112 |
* |
| 113 |
* @return void |
| 114 |
*/ |
| 115 |
public function companion_plugin_notice() |
| 116 |
{ |
| 117 |
if (!self::companion_plugin_active() || !current_user_can('activate_plugins')) { |
| 118 |
return; |
| 119 |
} |
| 120 |
|
| 121 |
$screen = function_exists('get_current_screen') ? get_current_screen() : null; |
| 122 |
if (!$screen || empty($screen->base) || $screen->base !== 'edit') { |
| 123 |
return; |
| 124 |
} |
| 125 |
|
| 126 |
printf( |
| 127 |
'<div class="notice notice-info is-dismissible"><p>%s</p></div>', |
| 128 |
esc_html__( |
| 129 |
'SEO columns are now built into this plugin. The separate "LNR SEO Columns" plugin is still active and is providing them instead — you can deactivate it to use the built-in version.', |
| 130 |
'metasync' |
| 131 |
) |
| 132 |
); |
| 133 |
} |
| 134 |
|
| 135 |
/** |
| 136 |
* Label prefix for the column headings. |
| 137 |
* |
| 138 |
* Honours white labelling so a reseller sees their own brand in the column |
| 139 |
* headers rather than "SEO". |
| 140 |
* |
| 141 |
* @return string Company name when white labelled, otherwise an empty string. |
| 142 |
*/ |
| 143 |
private function get_label_prefix() |
| 144 |
{ |
| 145 |
if (!class_exists('Metasync')) { |
| 146 |
return ''; |
| 147 |
} |
| 148 |
$company = Metasync::get_whitelabel_company_name(); |
| 149 |
return !empty($company) ? $company . ' ' : ''; |
| 150 |
} |
| 151 |
|
| 152 |
/** |
| 153 |
* Add the columns to the list table. |
| 154 |
* |
| 155 |
* Inserted after the Title column so they read left-to-right with the post name. |
| 156 |
* |
| 157 |
* @param array $columns Existing columns. |
| 158 |
* @param string $post_type Post type (only passed on the `manage_posts_columns` filter). |
| 159 |
* @return array |
| 160 |
*/ |
| 161 |
public function add_columns($columns, $post_type = '') |
| 162 |
{ |
| 163 |
if (empty($post_type)) { |
| 164 |
$screen = function_exists('get_current_screen') ? get_current_screen() : null; |
| 165 |
$post_type = ($screen && !empty($screen->post_type)) ? $screen->post_type : 'page'; |
| 166 |
} |
| 167 |
|
| 168 |
if (!self::is_supported_post_type($post_type)) { |
| 169 |
return $columns; |
| 170 |
} |
| 171 |
|
| 172 |
$prefix = $this->get_label_prefix(); |
| 173 |
|
| 174 |
$new_columns = array(); |
| 175 |
foreach ($columns as $key => $value) { |
| 176 |
$new_columns[$key] = $value; |
| 177 |
if ($key === 'title') { |
| 178 |
$new_columns[self::COL_TITLE] = esc_html($prefix . __('SEO Title', 'metasync')); |
| 179 |
$new_columns[self::COL_DESC] = esc_html($prefix . __('SEO Meta Desc', 'metasync')); |
| 180 |
$new_columns[self::COL_NOINDEX] = esc_html($prefix . __('Noindex', 'metasync')); |
| 181 |
$new_columns[self::COL_NOFOLLOW] = esc_html($prefix . __('Nofollow', 'metasync')); |
| 182 |
} |
| 183 |
} |
| 184 |
|
| 185 |
// Defensive: if the table has no `title` column, still expose ours. |
| 186 |
foreach (self::get_column_ids() as $id) { |
| 187 |
if (!isset($new_columns[$id])) { |
| 188 |
$new_columns[$id] = esc_html($prefix . $id); |
| 189 |
} |
| 190 |
} |
| 191 |
|
| 192 |
return $new_columns; |
| 193 |
} |
| 194 |
|
| 195 |
/** |
| 196 |
* Hide the columns by default for a user who has never set column preferences. |
| 197 |
* |
| 198 |
* Core only calls this filter when `manage{$screen->id}columnshidden` is absent |
| 199 |
* for the user (wp-admin/includes/screen.php, get_hidden_columns()). Anyone who |
| 200 |
* has already touched Screen Options on this screen skips it entirely, which is |
| 201 |
* what hidden_columns() below exists to cover. |
| 202 |
* |
| 203 |
* @param array $hidden Column IDs hidden by default. |
| 204 |
* @param WP_Screen|null $screen Current screen. |
| 205 |
* @return array |
| 206 |
*/ |
| 207 |
public function default_hidden_columns($hidden, $screen) |
| 208 |
{ |
| 209 |
if (!$this->screen_wants_columns($screen)) { |
| 210 |
return $hidden; |
| 211 |
} |
| 212 |
return array_values(array_unique(array_merge($hidden, self::get_column_ids()))); |
| 213 |
} |
| 214 |
|
| 215 |
/** |
| 216 |
* Hide the columns once for users whose saved preferences predate this feature. |
| 217 |
* |
| 218 |
* Core runs this filter unconditionally and passes `$use_defaults`, so it is the |
| 219 |
* only place a new column can be hidden for an existing user. Without it, anyone |
| 220 |
* who had ever adjusted Screen Options got all four columns switched on the |
| 221 |
* moment they upgraded — the opposite of the opt-in this feature promises. |
| 222 |
* |
| 223 |
* A filtered value is not saved by core, so returning the merged list alone would |
| 224 |
* hide the columns for a single request and then let them reappear for good. We |
| 225 |
* therefore write it to the same user-meta key core reads and Screen Options |
| 226 |
* saves to (`wp-admin/includes/ajax-actions.php`, wp_ajax_hidden_columns()). |
| 227 |
* |
| 228 |
* The marker is per screen, because posts and pages keep separate preferences. |
| 229 |
* Once set we never touch that user's choice again, so a later deliberate opt-in |
| 230 |
* survives. |
| 231 |
* |
| 232 |
* @param array $hidden Hidden column IDs. |
| 233 |
* @param WP_Screen|null $screen Current screen. |
| 234 |
* @param bool $use_defaults Whether core fell back to the defaults. |
| 235 |
* @return array |
| 236 |
*/ |
| 237 |
public function hidden_columns($hidden, $screen, $use_defaults = false) |
| 238 |
{ |
| 239 |
if (!$this->screen_wants_columns($screen)) { |
| 240 |
return $hidden; |
| 241 |
} |
| 242 |
|
| 243 |
$user_id = get_current_user_id(); |
| 244 |
if (!$user_id) { |
| 245 |
return $hidden; |
| 246 |
} |
| 247 |
|
| 248 |
$flag = 'metasync_seo_columns_init_' . $screen->id; |
| 249 |
if (!empty(get_user_meta($user_id, $flag, true))) { |
| 250 |
return $hidden; |
| 251 |
} |
| 252 |
|
| 253 |
// No stored preference: default_hidden_columns() already hid our columns this |
| 254 |
// request. Record it so the user's first deliberate opt-in is not re-hidden. |
| 255 |
if ($use_defaults) { |
| 256 |
update_user_meta($user_id, $flag, '1'); |
| 257 |
return $hidden; |
| 258 |
} |
| 259 |
|
| 260 |
// Stored preference predating this feature — persist the merge, then mark. |
| 261 |
$hidden = array_values(array_unique(array_merge($hidden, self::get_column_ids()))); |
| 262 |
$pref_key = 'manage' . $screen->id . 'columnshidden'; |
| 263 |
update_user_meta($user_id, $pref_key, $hidden); |
| 264 |
|
| 265 |
// Only seal the migration once the preference has demonstrably stored. If the |
| 266 |
// write failed — a transient DB error, or an update_user_metadata filter |
| 267 |
// rejecting this key — marking anyway would leave the columns visible from the |
| 268 |
// next request onward with no possibility of a retry. |
| 269 |
// |
| 270 |
// Read back rather than trust the return value: update_user_meta() also |
| 271 |
// returns false when the stored value was already identical. |
| 272 |
$stored = get_user_meta($user_id, $pref_key, true); |
| 273 |
if (is_array($stored) && !array_diff(self::get_column_ids(), $stored)) { |
| 274 |
update_user_meta($user_id, $flag, '1'); |
| 275 |
} |
| 276 |
|
| 277 |
return $hidden; |
| 278 |
} |
| 279 |
|
| 280 |
/** |
| 281 |
* Whether the current screen is a list table that carries our columns. |
| 282 |
* |
| 283 |
* Mirrors add_columns(): a screen we never add columns to must not get migration |
| 284 |
* state either, or a post type that later becomes supported would find a stale |
| 285 |
* marker and show all four columns immediately. |
| 286 |
* |
| 287 |
* @param WP_Screen|null $screen Current screen. |
| 288 |
* @return bool |
| 289 |
*/ |
| 290 |
private function screen_wants_columns($screen) |
| 291 |
{ |
| 292 |
if ($screen === null || empty($screen->base) || $screen->base !== 'edit') { |
| 293 |
return false; |
| 294 |
} |
| 295 |
if (empty($screen->post_type)) { |
| 296 |
return false; |
| 297 |
} |
| 298 |
return self::is_supported_post_type($screen->post_type); |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* Render a cell. |
| 303 |
* |
| 304 |
* Reads the whole meta array once per row. WP_Query primes the post meta cache for |
| 305 |
* the list table (`update_post_meta_cache` defaults to true), so this is an object |
| 306 |
* cache read rather than a query, and costs the same regardless of how many of our |
| 307 |
* columns are enabled. |
| 308 |
* |
| 309 |
* @param string $column_name Column being rendered. |
| 310 |
* @param int $post_id Post ID. |
| 311 |
* @return void |
| 312 |
*/ |
| 313 |
public function render_column($column_name, $post_id) |
| 314 |
{ |
| 315 |
if (!in_array($column_name, self::get_column_ids(), true)) { |
| 316 |
return; |
| 317 |
} |
| 318 |
if (self::companion_plugin_active()) { |
| 319 |
return; |
| 320 |
} |
| 321 |
|
| 322 |
$all_meta = get_post_meta($post_id); |
| 323 |
if (!is_array($all_meta)) { |
| 324 |
$all_meta = array(); |
| 325 |
} |
| 326 |
|
| 327 |
switch ($column_name) { |
| 328 |
case self::COL_TITLE: |
| 329 |
echo $this->render_text_cell($post_id, 'title'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper |
| 330 |
break; |
| 331 |
case self::COL_DESC: |
| 332 |
echo $this->render_text_cell($post_id, 'description'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper |
| 333 |
break; |
| 334 |
case self::COL_NOINDEX: |
| 335 |
echo $this->render_robots_cell($all_meta, 'noindex'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper |
| 336 |
break; |
| 337 |
case self::COL_NOFOLLOW: |
| 338 |
echo $this->render_robots_cell($all_meta, 'nofollow'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in helper |
| 339 |
break; |
| 340 |
} |
| 341 |
} |
| 342 |
|
| 343 |
/** |
| 344 |
* Resolve the value the front end will actually render for this post. |
| 345 |
* |
| 346 |
* The order below mirrors what the rendering code does, verified across every |
| 347 |
* path that can set the title or description on a singular request: |
| 348 |
* |
| 349 |
* 1. `_metasync_seo_*` — the sidebar / Classic meta box value. Wins everywhere: |
| 350 |
* the sidebar filter runs at `pre_get_document_title` priority 100, after |
| 351 |
* OTTO's at 99 (admin/class-metasync-seo-sidebar.php:95 vs |
| 352 |
* otto/metasync-otto-seo-functions.php:3070), and OTTO's own HTML rewrite |
| 353 |
* re-forces it at the end (otto/Otto_html_class.php:1708). |
| 354 |
* 2. `_metasync_otto_*` — OTTO's persisted suggestion. |
| 355 |
* 3. `meta_description` — legacy key the original emitter still prints |
| 356 |
* (public/class-metasync-seo-output.php:271). Description only. |
| 357 |
* 4. A third-party SEO plugin's own key, but ONLY when that plugin is active. |
| 358 |
* A leftover `_yoast_wpseo_title` from a deactivated Yoast renders nothing, |
| 359 |
* so surfacing it would be inventing a value. |
| 360 |
* |
| 361 |
* `_metasync_metatitle` / `_metasync_metadesc` are deliberately absent. They are |
| 362 |
* mirrors written for third-party plugins and the MCP tools; no singular render |
| 363 |
* path reads them (confirmed by two independent traces). Ranking them above OTTO |
| 364 |
* — as Metasync_SEO_Health still does — makes the column disagree with the page. |
| 365 |
* |
| 366 |
* Reads post meta only, with one exception: the OTTO disable test also consults |
| 367 |
* the manual URL exclusion list, which costs a URL resolution per row plus a |
| 368 |
* single cached read of the exclusion set for the whole request. On the Pages |
| 369 |
* table that resolution is not free — get_permalink() walks ancestors through |
| 370 |
* get_page_uri() for a nested page, which WP_Query has not primed, and it fires |
| 371 |
* the post_link/page_link filters that translation and permalink plugins hook. |
| 372 |
* Everything else here is free: WP_Query has already primed the post meta cache |
| 373 |
* for this list table. |
| 374 |
* |
| 375 |
* @param int $post_id Post ID. |
| 376 |
* @param string $field 'title' or 'description'. |
| 377 |
* @return array{value:string,source:string} Source is '' for a user-owned value. |
| 378 |
*/ |
| 379 |
private function resolve_rendered_value($post_id, $field) |
| 380 |
{ |
| 381 |
$is_title = ($field === 'title'); |
| 382 |
|
| 383 |
// MetaSync's own tiers come from Metasync_Seo_Precedence, the one place |
| 384 |
// the order is defined. |
| 385 |
// |
| 386 |
// OTTO stands down entirely for a post with the per-post disable flag set |
| 387 |
// (otto/metasync-otto-seo-functions.php:2582 -> the title filter returns the |
| 388 |
// incoming title untouched). Its persisted suggestion is then dead data, so |
| 389 |
// reporting it would name a value the page does not use. |
| 390 |
// |
| 391 |
// The persisted-OTTO tier is left out here, which is what this column has |
| 392 |
// always done. SEO Health does include it, so the two surfaces can disagree |
| 393 |
// about a post whose only value is `_metasync_metatitle`; carried forward |
| 394 |
// unchanged rather than resolved as a side effect of consolidating. |
| 395 |
$chain = Metasync_Seo_Precedence::chain( |
| 396 |
$is_title |
| 397 |
? Metasync_Seo_Precedence::FIELD_TITLE |
| 398 |
: Metasync_Seo_Precedence::FIELD_DESCRIPTION, |
| 399 |
Metasync_Seo_Precedence::TYPE_POST, |
| 400 |
array( |
| 401 |
'include_otto' => !$this->otto_disabled_for_post($post_id), |
| 402 |
'include_persisted_otto' => false, |
| 403 |
) |
| 404 |
); |
| 405 |
|
| 406 |
$third_party = $this->get_active_plugin_meta_keys($is_title); |
| 407 |
|
| 408 |
// The legacy `meta_description` tag is only emitted when no active SEO plugin |
| 409 |
// takes over: should_output_legacy_description() returns false for Yoast and |
| 410 |
// Rank Math, which auto-generate their own. So an active plugin outranks the |
| 411 |
// legacy key; with no plugin active the legacy key is what prints. |
| 412 |
if (!$is_title) { |
| 413 |
if (!empty($third_party)) { |
| 414 |
$chain += $third_party; |
| 415 |
$chain['meta_description'] = 'Synced'; |
| 416 |
} else { |
| 417 |
$chain['meta_description'] = 'Synced'; |
| 418 |
} |
| 419 |
} else { |
| 420 |
$chain += $third_party; |
| 421 |
} |
| 422 |
|
| 423 |
foreach ($chain as $meta_key => $source) { |
| 424 |
$value = get_post_meta($post_id, $meta_key, true); |
| 425 |
if (!empty($value) && is_string($value)) { |
| 426 |
// `source` is no longer rendered — the columns show the value only. It is |
| 427 |
// still returned so precedence can be asserted directly in tests. |
| 428 |
return array('value' => $value, 'source' => $source); |
| 429 |
} |
| 430 |
} |
| 431 |
|
| 432 |
return array('value' => '', 'source' => ''); |
| 433 |
} |
| 434 |
|
| 435 |
/** |
| 436 |
* Whether OTTO output is switched off for this specific post. |
| 437 |
* |
| 438 |
* Delegates to Metasync_Otto_Frontend_Toolbar::is_otto_disabled(), which reports |
| 439 |
* the effective status: the `_metasync_otto_disabled` meta flag OR a manual entry |
| 440 |
* on the Compatibility page's "Excluded URLs" list. Both stop OTTO rendering, |
| 441 |
* so both have to stop the column naming OTTO's value as the rendered one. |
| 442 |
* |
| 443 |
* The fallback repeats the meta test verbatim for the case where the toolbar class |
| 444 |
* is not loaded. It treats only '1' and 'true' as disabled: a looser truthiness |
| 445 |
* test would read the string 'false' as disabled and wrongly hide OTTO's value |
| 446 |
* from the column while the front end still serves it. |
| 447 |
* |
| 448 |
* @param int $post_id Post ID. |
| 449 |
* @return bool |
| 450 |
*/ |
| 451 |
private function otto_disabled_for_post($post_id) |
| 452 |
{ |
| 453 |
// Quick Edit saves over admin-ajax as `inline-save`, and wp_ajax_inline_save() |
| 454 |
// redraws this row. metasync.php skips otto/otto_pixel.php for any admin-ajax |
| 455 |
// action that is not ours, so the exclusion checker is absent in exactly the |
| 456 |
// request that re-renders the column — and the row would show OTTO's value for |
| 457 |
// an excluded post until the next full page load. Load it here, where the |
| 458 |
// column is actually being rendered, rather than widening the load for every |
| 459 |
// third-party ajax request: this runs only inside a column render callback, |
| 460 |
// so a Quick Edit save pays for it and the frequent ajax actions the guard in |
| 461 |
// metasync.php exists for (heartbeat, autosave) never reach this line. |
| 462 |
// |
| 463 |
// Gated on wp_doing_ajax() rather than the DOING_AJAX constant that |
| 464 |
// metasync_is_non_metasync_admin_ajax() reads. The two can in principle |
| 465 |
// disagree — wp_doing_ajax() is filterable — but it is the sanctioned API |
| 466 |
// and the one PHPStan's WordPress ruleset requires, and a plugin filtering |
| 467 |
// it to false mid-Quick-Edit would only cost this column its freshness. |
| 468 |
if (!function_exists('metasync_is_otto_url_manually_excluded') && wp_doing_ajax()) { |
| 469 |
$pixel_file = plugin_dir_path(dirname(__FILE__)) . 'otto/otto_pixel.php'; |
| 470 |
if (file_exists($pixel_file)) { |
| 471 |
require_once $pixel_file; |
| 472 |
} |
| 473 |
} |
| 474 |
|
| 475 |
if (class_exists('Metasync_Otto_Frontend_Toolbar')) { |
| 476 |
return (bool) Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id); |
| 477 |
} |
| 478 |
|
| 479 |
$disabled = get_post_meta($post_id, '_metasync_otto_disabled', true); |
| 480 |
return $disabled === '1' || $disabled === 'true'; |
| 481 |
} |
| 482 |
|
| 483 |
/** |
| 484 |
* Whether a value is a third-party template rather than final text. |
| 485 |
* |
| 486 |
* Yoast and Rank Math store patterns such as `%%title%% %%sep%% %%sitename%%` |
| 487 |
* and expand them at render time. Displaying the raw tokens is honest, but |
| 488 |
* measuring them against the 60/160 limits is not — the rendered length is |
| 489 |
* entirely different. Callers suppress the counter for these. |
| 490 |
* |
| 491 |
* @param string $value Stored value. |
| 492 |
* @return bool |
| 493 |
*/ |
| 494 |
private function is_template_value($value) |
| 495 |
{ |
| 496 |
return (bool) preg_match('/%%[a-z0-9_-]+%%/i', $value); |
| 497 |
} |
| 498 |
|
| 499 |
/** |
| 500 |
* Third-party meta keys worth reading, restricted to plugins that are active. |
| 501 |
* |
| 502 |
* The plugin paths mirror Metasync_SEO_Conflict_Handler's own detection list. |
| 503 |
* They are checked directly rather than through that class: its constructor |
| 504 |
* registers filters and needs a full WordPress request context, which is far |
| 505 |
* more than a list-table column should pull in to ask "is Yoast switched on?". |
| 506 |
* |
| 507 |
* @param bool $is_title Whether to return title keys (else description keys). |
| 508 |
* @return array meta_key => source label. |
| 509 |
*/ |
| 510 |
private function get_active_plugin_meta_keys($is_title) |
| 511 |
{ |
| 512 |
if (!function_exists('is_plugin_active')) { |
| 513 |
if (!defined('ABSPATH') || !file_exists(ABSPATH . 'wp-admin/includes/plugin.php')) { |
| 514 |
return array(); |
| 515 |
} |
| 516 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 517 |
} |
| 518 |
|
| 519 |
$keys = array(); |
| 520 |
|
| 521 |
if (is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) { |
| 522 |
$keys[$is_title ? '_yoast_wpseo_title' : '_yoast_wpseo_metadesc'] = 'Yoast'; |
| 523 |
} |
| 524 |
|
| 525 |
if (is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rankmath/rank-math.php')) { |
| 526 |
$keys[$is_title ? 'rank_math_title' : 'rank_math_description'] = 'Rank Math'; |
| 527 |
} |
| 528 |
|
| 529 |
if ( |
| 530 |
is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') |
| 531 |
|| is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php') |
| 532 |
) { |
| 533 |
$keys[$is_title ? '_aioseo_title' : '_aioseo_description'] = 'AIOSEO'; |
| 534 |
} |
| 535 |
|
| 536 |
return $keys; |
| 537 |
} |
| 538 |
|
| 539 |
/** |
| 540 |
* Render the SEO Title or SEO Meta Desc cell. |
| 541 |
* |
| 542 |
* Empty states deliberately differ between the two fields: |
| 543 |
* |
| 544 |
* - Title is never really empty. When no override exists the sidebar filter hands |
| 545 |
* WordPress' own generated title straight back (class-metasync-seo-sidebar.php), |
| 546 |
* so the page still has a working <title>. Showing a bare dash would imply the |
| 547 |
* page is broken when it is not — we show the post title, muted, instead. |
| 548 |
* - Description genuinely can be empty: WordPress does not auto-generate meta |
| 549 |
* descriptions. That IS an actionable gap, so it gets a "Not set" warning. |
| 550 |
* |
| 551 |
* @param int $post_id Post ID. |
| 552 |
* @param string $field 'title' or 'description'. |
| 553 |
* @return string HTML. |
| 554 |
*/ |
| 555 |
private function render_text_cell($post_id, $field) |
| 556 |
{ |
| 557 |
$resolved = $this->resolve_rendered_value($post_id, $field); |
| 558 |
|
| 559 |
$value = $resolved['value']; |
| 560 |
|
| 561 |
if ($value === '') { |
| 562 |
if ($field === 'title') { |
| 563 |
// No override: WordPress generates the title from the post title, so the |
| 564 |
// page still has a working one. Shown muted, with the explanation in the |
| 565 |
// hover title rather than an inline label. |
| 566 |
return sprintf( |
| 567 |
'<span class="metasync-seo-col metasync-seo-col--auto" title="%s">%s</span>', |
| 568 |
esc_attr__('No SEO title set. WordPress generates the page title from the post title.', 'metasync'), |
| 569 |
esc_html(get_the_title($post_id)) |
| 570 |
); |
| 571 |
} |
| 572 |
|
| 573 |
return sprintf( |
| 574 |
'<span class="metasync-seo-col metasync-seo-col--missing">%s</span>', |
| 575 |
esc_html__('Not set', 'metasync') |
| 576 |
); |
| 577 |
} |
| 578 |
|
| 579 |
$max = ($field === 'title') ? self::TITLE_MAX_LEN : self::DESC_MAX_LEN; |
| 580 |
|
| 581 |
// A stored template is shown verbatim but not measured: the tokens expand at |
| 582 |
// render time, so a raw count would report a length the page never has. |
| 583 |
$meta_line = $this->is_template_value($value) |
| 584 |
? __('template — expands on render', 'metasync') |
| 585 |
: $this->char_count($value, $max); |
| 586 |
|
| 587 |
return sprintf( |
| 588 |
'<span class="metasync-seo-col">%s</span><br /><span class="metasync-seo-col__meta">%s</span>', |
| 589 |
esc_html($value), |
| 590 |
esc_html($meta_line) |
| 591 |
); |
| 592 |
} |
| 593 |
|
| 594 |
/** |
| 595 |
* Character counter, e.g. "153/160". |
| 596 |
* |
| 597 |
* @param string $value Value to measure. |
| 598 |
* @param int $max Recommended maximum. |
| 599 |
* @return string |
| 600 |
*/ |
| 601 |
private function char_count($value, $max) |
| 602 |
{ |
| 603 |
$len = function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); |
| 604 |
return $len . '/' . $max; |
| 605 |
} |
| 606 |
|
| 607 |
/** |
| 608 |
* Render the Noindex / Nofollow cell. |
| 609 |
* |
| 610 |
* @param array $all_meta Result of get_post_meta($post_id). |
| 611 |
* @param string $directive 'noindex' or 'nofollow'. |
| 612 |
* @return string HTML. |
| 613 |
*/ |
| 614 |
private function render_robots_cell($all_meta, $directive) |
| 615 |
{ |
| 616 |
$flags = $this->resolve_robots_flags($all_meta); |
| 617 |
|
| 618 |
if (empty($flags[$directive])) { |
| 619 |
return '<span class="metasync-seo-col__meta">—</span>'; |
| 620 |
} |
| 621 |
|
| 622 |
return sprintf( |
| 623 |
'<span class="metasync-seo-col metasync-seo-col--flag">%s</span>', |
| 624 |
esc_html__('Yes', 'metasync') |
| 625 |
); |
| 626 |
} |
| 627 |
|
| 628 |
/** |
| 629 |
* Resolve the effective noindex / nofollow state for a post. |
| 630 |
* |
| 631 |
* Mirrors the priority order documented on |
| 632 |
* Metasync_Seo_Output::resolve_robots_value() (public/class-metasync-seo-output.php), |
| 633 |
* reduced to the two booleans this screen displays: |
| 634 |
* |
| 635 |
* 1. _metasync_robots_index (noindex, separate key) |
| 636 |
* 2. _metasync_robots_advanced (JSON) |
| 637 |
* 3. meta_robots (string, set via REST) |
| 638 |
* 4. metasync_common_robots (array, admin checkboxes) |
| 639 |
* |
| 640 |
* There is deliberately no global-default step: the frontend resolver's global |
| 641 |
* branch only contributes max-* directives, never noindex/nofollow. |
| 642 |
* |
| 643 |
* Returns all-false when an active third-party SEO plugin owns the robots tag. |
| 644 |
* hook_metasync_metatags() only emits our robots on such a site when |
| 645 |
* Metasync_SEO_Conflict_Handler::metasync_has_robots() passes, and that method |
| 646 |
* inspects `meta_robots` and `metasync_common_robots` only — it does not know |
| 647 |
* about `_metasync_robots_index` or `_metasync_robots_advanced`. A post carrying |
| 648 |
* just those keys gets the plugin's directives on the page, so claiming ours are |
| 649 |
* in force would be wrong. Showing nothing under-reports rather than misleads. |
| 650 |
* |
| 651 |
* @param array $all_meta Result of get_post_meta($post_id). |
| 652 |
* @return array{noindex:bool,nofollow:bool} |
| 653 |
*/ |
| 654 |
private function resolve_robots_flags($all_meta) |
| 655 |
{ |
| 656 |
if (!$this->metasync_robots_reach_the_page($all_meta)) { |
| 657 |
return array('noindex' => false, 'nofollow' => false); |
| 658 |
} |
| 659 |
$out = array('noindex' => false, 'nofollow' => false); |
| 660 |
|
| 661 |
// 1. noindex lives in its own key regardless of the other storage formats. |
| 662 |
if (($all_meta['_metasync_robots_index'][0] ?? '') === 'noindex') { |
| 663 |
$out['noindex'] = true; |
| 664 |
} |
| 665 |
|
| 666 |
// 2. Advanced JSON is authoritative for the advanced directives when present. |
| 667 |
$advanced_raw = $all_meta['_metasync_robots_advanced'][0] ?? ''; |
| 668 |
if (!empty($advanced_raw)) { |
| 669 |
$advanced = json_decode($advanced_raw, true); |
| 670 |
if (is_array($advanced) && !empty($advanced)) { |
| 671 |
$out['nofollow'] = !empty($advanced['nofollow']); |
| 672 |
return $out; |
| 673 |
} |
| 674 |
} |
| 675 |
|
| 676 |
// 3. meta_robots string (REST API). |
| 677 |
$meta_robots = $all_meta['meta_robots'][0] ?? ''; |
| 678 |
if (!empty($meta_robots)) { |
| 679 |
// Tokenised, not substring-matched. `none` is the standard shorthand for |
| 680 |
// "noindex, nofollow" and would otherwise be missed entirely, while a |
| 681 |
// substring test also accepts malformed values such as "noindexing". |
| 682 |
$tokens = array_map('trim', explode(',', strtolower((string) $meta_robots))); |
| 683 |
|
| 684 |
if (in_array('none', $tokens, true)) { |
| 685 |
$out['noindex'] = true; |
| 686 |
$out['nofollow'] = true; |
| 687 |
return $out; |
| 688 |
} |
| 689 |
if (in_array('noindex', $tokens, true)) { |
| 690 |
$out['noindex'] = true; |
| 691 |
} |
| 692 |
if (in_array('nofollow', $tokens, true)) { |
| 693 |
$out['nofollow'] = true; |
| 694 |
} |
| 695 |
return $out; |
| 696 |
} |
| 697 |
|
| 698 |
// 4. Admin checkbox array. |
| 699 |
$raw = $all_meta['metasync_common_robots'][0] ?? ''; |
| 700 |
if (!empty($raw)) { |
| 701 |
$common = maybe_unserialize($raw); |
| 702 |
if (is_array($common) && !empty(array_filter($common))) { |
| 703 |
if (!empty($common['noindex'])) { |
| 704 |
$out['noindex'] = true; |
| 705 |
} |
| 706 |
if (!empty($common['nofollow'])) { |
| 707 |
$out['nofollow'] = true; |
| 708 |
} |
| 709 |
return $out; |
| 710 |
} |
| 711 |
} |
| 712 |
|
| 713 |
// No global fallback. The frontend resolver's global step |
| 714 |
// (Metasync_Seo_Output::resolve_robots_value(), step 4) reads |
| 715 |
// `advance_robots_meta` and only ever contributes max-snippet / |
| 716 |
// max-video-preview / max-image-preview — it never applies a global noindex |
| 717 |
// or nofollow. The global `common_robots_meta` option only pre-checks the |
| 718 |
// meta box UI (Metasync_Post_Meta_Settings::common_robots_meta_box_display()), |
| 719 |
// it does not emit a directive until the post itself is saved. |
| 720 |
// |
| 721 |
// Inheriting it here would tell an editor a page is noindexed when the page |
| 722 |
// actually renders as indexable, which is worse than showing nothing. |
| 723 |
return $out; |
| 724 |
} |
| 725 |
|
| 726 |
/** |
| 727 |
* Whether our robots directives actually reach the page for this post. |
| 728 |
* |
| 729 |
* With no third-party SEO plugin active they always do. With one active, the |
| 730 |
* emitter defers unless the conflict handler recognises a MetaSync robots value, |
| 731 |
* and it only looks at `meta_robots` and `metasync_common_robots`. |
| 732 |
* |
| 733 |
* @param array $all_meta Result of get_post_meta($post_id). |
| 734 |
* @return bool |
| 735 |
*/ |
| 736 |
private function metasync_robots_reach_the_page($all_meta) |
| 737 |
{ |
| 738 |
if (empty($this->get_active_plugin_meta_keys(true))) { |
| 739 |
return true; |
| 740 |
} |
| 741 |
|
| 742 |
if (!empty($all_meta['meta_robots'][0] ?? '')) { |
| 743 |
return true; |
| 744 |
} |
| 745 |
|
| 746 |
$common = maybe_unserialize($all_meta['metasync_common_robots'][0] ?? ''); |
| 747 |
return is_array($common) && !empty(array_filter($common)); |
| 748 |
} |
| 749 |
|
| 750 |
/** |
| 751 |
* Column styles, printed only on list-table screens that show our columns. |
| 752 |
* |
| 753 |
* @return void |
| 754 |
*/ |
| 755 |
public function print_styles() |
| 756 |
{ |
| 757 |
$screen = function_exists('get_current_screen') ? get_current_screen() : null; |
| 758 |
if (!$screen || empty($screen->base) || $screen->base !== 'edit') { |
| 759 |
return; |
| 760 |
} |
| 761 |
if (!self::is_supported_post_type($screen->post_type)) { |
| 762 |
return; |
| 763 |
} |
| 764 |
?> |
| 765 |
<style> |
| 766 |
.metasync-seo-col { display: inline-block; max-width: 320px; } |
| 767 |
.metasync-seo-col--auto { color: #646970; font-style: italic; } |
| 768 |
.metasync-seo-col--missing { color: #b32d2e; font-weight: 600; } |
| 769 |
.metasync-seo-col__meta { color: #646970; font-size: 11px; } |
| 770 |
</style> |
| 771 |
<?php |
| 772 |
} |
| 773 |
} |
| 774 |
|