| 1 |
<?php |
| 2 |
/** |
| 3 |
* Classic-editor save handler for the three Gutenberg-only SEO fields. |
| 4 |
* |
| 5 |
* Breadcrumb Title Override, Language Alternates (hreflang), and Primary |
| 6 |
* Category are persisted client-side in the block editor via the SEO sidebar's |
| 7 |
* `editPost` -> core REST meta. They have no `save_post` handler, so the |
| 8 |
* classic post form — which posts a flat form body, not a REST payload — has |
| 9 |
* no way to write them. |
| 10 |
* |
| 11 |
* This class adds that `save_post` path. It is deliberately separate from |
| 12 |
* Metasync_Seo_Suite: the Suite is presentation-only (it re-renders existing |
| 13 |
* fields with identical name/nonce attributes and lets the pre-existing |
| 14 |
* handlers persist them; it contains no save logic at all), and that invariant |
| 15 |
* is enforced by tests/unit/SeoSuiteParityTest.php. The Suite renders the |
| 16 |
* breadcrumb-title field and the Languages tab and emits this handler's nonce; |
| 17 |
* the primary-category select is injected by the accompanying JS under the |
| 18 |
* core Categories checklist, where the equivalent Gutenberg control lives. |
| 19 |
* |
| 20 |
* Guard parity with the Suite: |
| 21 |
* - bails on autosave / revision (the same guard the other handlers use) |
| 22 |
* - bails on block-editor requests (the `meta-box-loader=1` re-post and the |
| 23 |
* `is_block_editor()` screen), mirroring |
| 24 |
* Metasync_Seo_Suite::is_block_editor_request(). Without this a classic |
| 25 |
* save would clobber values the Gutenberg sidebar owns. |
| 26 |
* - bails on LPS / custom-HTML managed pages (no nonces, no writes), matching |
| 27 |
* the Suite's managed-page lockout so this handler can never overwrite |
| 28 |
* WebStudio-managed SEO. |
| 29 |
* - verifies a single nonce, emitted wherever the fields render — by the |
| 30 |
* Suite for the breadcrumb field and the Languages tab, and beside the |
| 31 |
* primary-category select for that field alone (the same per-section |
| 32 |
* conditional-nonce pattern the Suite already uses). The nonce is absent |
| 33 |
* on Quick Edit, bulk edit, REST saves, and every other path that never |
| 34 |
* renders these fields — so the handler is inert there. |
| 35 |
* |
| 36 |
* Sanitization matches the REST `register_post_meta` registrations in |
| 37 |
* class-metasync-seo-sidebar.php: |
| 38 |
* - `_metasync_breadcrumb_title` -> sanitize_text_field (string) |
| 39 |
* - `_metasync_primary_category` -> absint (int term ID; 0 deletes) |
| 40 |
* - `_metasync_hreflang` -> the canonical JSON round-trip, |
| 41 |
* Metasync_SEO_Sidebar::sanitize_hreflang_meta(), so classic-saved rows go |
| 42 |
* through the same normalisation (en_US -> en+US, case folding, |
| 43 |
* x-default region strip) as REST-saved rows. |
| 44 |
* |
| 45 |
* Writing via update_post_meta is intentional: the Yoast / Rank Math |
| 46 |
* breadcrumb cross-sync in Metasync_Breadcrumbs fires on |
| 47 |
* `added_post_meta` / `updated_post_meta`, so the breadcrumb title mirrors |
| 48 |
* into the third-party plugins for free. The existing |
| 49 |
* Metasync_SEO_Sidebar::cleanup_primary_category_meta() handler runs at |
| 50 |
* `save_post` priority 10; WordPress assigns taxonomy terms inside |
| 51 |
* wp_insert_post before `save_post` fires, so by the time that cleanup reads |
| 52 |
* the post's terms this priority-9 write is already consistent with them. |
| 53 |
* |
| 54 |
* @package Metasync |
| 55 |
*/ |
| 56 |
|
| 57 |
// If this file is called directly, abort. |
| 58 |
if (!defined('ABSPATH')) { |
| 59 |
exit; |
| 60 |
} |
| 61 |
|
| 62 |
class Metasync_Classic_Seo_Fields |
| 63 |
{ |
| 64 |
/** Nonce action and field name — emitted by the Suite only when the fields render. */ |
| 65 |
const NONCE_ACTION = 'metasync_classic_seo_fields_nonce'; |
| 66 |
const NONCE_FIELD = 'metasync_classic_seo_fields_nonce'; |
| 67 |
|
| 68 |
/** Meta keys (mirrors the REST registrations in class-metasync-seo-sidebar.php). */ |
| 69 |
const META_BREADCRUMB_TITLE = '_metasync_breadcrumb_title'; |
| 70 |
const META_PRIMARY_CATEGORY = '_metasync_primary_category'; |
| 71 |
const META_HREFLANG = '_metasync_hreflang'; |
| 72 |
|
| 73 |
/** Form key the Suite's Languages tab posts its repeatable rows under. */ |
| 74 |
const HREFLANG_ROWS_FIELD = 'metasync_hreflang_rows'; |
| 75 |
|
| 76 |
/** Form key the Languages tab posts to say "rendered, even with zero rows". */ |
| 77 |
const HREFLANG_PRESENT_FIELD = 'metasync_hreflang_present'; |
| 78 |
|
| 79 |
public function __construct() |
| 80 |
{ |
| 81 |
// Priority 9 so this runs before the sidebar's |
| 82 |
// cleanup_primary_category_meta handler (priority 10), which reads the |
| 83 |
// value written here and only acts if the term is no longer attached. |
| 84 |
add_action('save_post', array($this, 'save'), 9, 1); |
| 85 |
add_action('admin_enqueue_scripts', array($this, 'enqueue_assets')); |
| 86 |
add_action('admin_footer-post.php', array($this, 'inject_primary_category')); |
| 87 |
add_action('admin_footer-post-new.php', array($this, 'inject_primary_category')); |
| 88 |
} |
| 89 |
|
| 90 |
/** |
| 91 |
* Enqueue the primary-category JS on classic post-edit screens. |
| 92 |
* |
| 93 |
* The script builds the select under the core Categories checklist and |
| 94 |
* keeps it in sync with ticking, unticking, and the "Add New Category" |
| 95 |
* AJAX path. Only loads where that control can render (see |
| 96 |
* primary_category_should_render()): classic editor — the block editor's |
| 97 |
* sidebar owns the field there — post types with the core `category` |
| 98 |
* taxonomy, and sites where no other SEO plugin provides its own |
| 99 |
* primary-category control. |
| 100 |
* |
| 101 |
* @param string $hook Current admin page hook. |
| 102 |
*/ |
| 103 |
public function enqueue_assets($hook) |
| 104 |
{ |
| 105 |
if (!in_array($hook, array('post.php', 'post-new.php'), true)) { |
| 106 |
return; |
| 107 |
} |
| 108 |
|
| 109 |
if (!$this->primary_category_should_render()) { |
| 110 |
return; |
| 111 |
} |
| 112 |
|
| 113 |
$post_id = get_the_ID(); |
| 114 |
$stored = $post_id ? (int) get_post_meta($post_id, self::META_PRIMARY_CATEGORY, true) : 0; |
| 115 |
|
| 116 |
wp_enqueue_script( |
| 117 |
'metasync-classic-seo-fields', |
| 118 |
plugin_dir_url(__FILE__) . 'js/metasync-classic-seo-fields.js', |
| 119 |
array(), |
| 120 |
defined('METASYNC_VERSION') ? METASYNC_VERSION : '1.0.0', |
| 121 |
true |
| 122 |
); |
| 123 |
wp_localize_script('metasync-classic-seo-fields', 'metasyncClassicSeoFields', array( |
| 124 |
'selectName' => self::META_PRIMARY_CATEGORY, |
| 125 |
'storedPrimary' => $stored, |
| 126 |
'i18n' => array( |
| 127 |
'label' => __('Primary Category', 'metasync'), |
| 128 |
'help' => __('Used for breadcrumbs when the post has multiple categories. Options come from the categories checked above.', 'metasync'), |
| 129 |
'none' => __('(none)', 'metasync'), |
| 130 |
), |
| 131 |
)); |
| 132 |
} |
| 133 |
|
| 134 |
/** |
| 135 |
* True when another SEO plugin provides its own primary-category control. |
| 136 |
* |
| 137 |
* Mirrors the $other_seo_primary detection localized for the Gutenberg |
| 138 |
* sidebar, so the classic select defers exactly where the sidebar's does. |
| 139 |
* |
| 140 |
* @return bool |
| 141 |
*/ |
| 142 |
private function other_seo_primary_active() |
| 143 |
{ |
| 144 |
return defined('WPSEO_VERSION') |
| 145 |
|| defined('RANK_MATH_VERSION') |
| 146 |
|| defined('AIOSEO_VERSION') |
| 147 |
|| class_exists('AIOSEO\\Plugin\\AIOSEO'); |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* Echo the primary-category select container for the classic editor. |
| 152 |
* |
| 153 |
* Rendered hidden in the admin footer and relocated by the JS under the |
| 154 |
* core Categories checklist (#categorydiv), where the equivalent |
| 155 |
* Gutenberg control lives — beside the box the user is already |
| 156 |
* interacting with, not inside the SEO Suite. The styling is hardcoded |
| 157 |
* light: this lives inside a core WordPress metabox, so it must not |
| 158 |
* follow the Suite's Dark/Light toggle. |
| 159 |
* |
| 160 |
* Gated identically to the enqueue: block editor, post types without the |
| 161 |
* core `category` taxonomy, managed pages, and sites where another SEO |
| 162 |
* plugin owns the primary-category control all get nothing. The save |
| 163 |
* handler treats the field's absence as "leave the stored value |
| 164 |
* untouched". The nonce is emitted here too — the Suite prints its own |
| 165 |
* for its fields, but on a site that opts out of the Suite (or registers |
| 166 |
* none of its boxes) this field still needs one or it renders as dead UI. |
| 167 |
*/ |
| 168 |
public function inject_primary_category() |
| 169 |
{ |
| 170 |
if (!$this->primary_category_should_render()) { |
| 171 |
return; |
| 172 |
} |
| 173 |
|
| 174 |
$post_id = get_the_ID(); |
| 175 |
$stored = $post_id ? (int) get_post_meta($post_id, self::META_PRIMARY_CATEGORY, true) : 0; |
| 176 |
?> |
| 177 |
<div id="metasync-primary-category-wrap" data-stored="<?php echo esc_attr((string) $stored); ?>" style="display:none"> |
| 178 |
<?php wp_nonce_field(self::NONCE_ACTION, self::NONCE_FIELD); ?> |
| 179 |
<label for="metasync-primary-category-select" style="display:block;font-weight:600;margin:0 0 4px"><?php esc_html_e('Primary Category', 'metasync'); ?></label> |
| 180 |
<select id="metasync-primary-category-select" name="<?php echo esc_attr(self::META_PRIMARY_CATEGORY); ?>" style="width:100%;max-width:300px;padding:4px 6px"> |
| 181 |
<option value="0"><?php esc_html_e('(none)', 'metasync'); ?></option> |
| 182 |
</select> |
| 183 |
<p style="margin:4px 0 0;color:#666;font-size:12px"><?php esc_html_e('Used for breadcrumbs when the post has multiple categories. Options come from the categories checked above.', 'metasync'); ?></p> |
| 184 |
</div> |
| 185 |
<?php |
| 186 |
} |
| 187 |
|
| 188 |
/** |
| 189 |
* True when the primary-category select can render on this screen. |
| 190 |
* |
| 191 |
* @return bool |
| 192 |
*/ |
| 193 |
private function primary_category_should_render() |
| 194 |
{ |
| 195 |
$screen = function_exists('get_current_screen') ? get_current_screen() : null; |
| 196 |
if ($screen && $screen->is_block_editor()) { |
| 197 |
return false; |
| 198 |
} |
| 199 |
|
| 200 |
// get_current_post_type() is wp-admin-only and unloaded in the |
| 201 |
// contexts save_post can reach; the global post is set on both |
| 202 |
// post.php and post-new.php, so resolve from it like the sidebar's |
| 203 |
// enqueue does. |
| 204 |
$post_id = get_the_ID(); |
| 205 |
$post_type = $post_id ? get_post_type($post_id) : ''; |
| 206 |
if (!$post_type) { |
| 207 |
return false; |
| 208 |
} |
| 209 |
$taxonomies = get_object_taxonomies($post_type, 'names'); |
| 210 |
if (!in_array('category', $taxonomies, true)) { |
| 211 |
return false; |
| 212 |
} |
| 213 |
|
| 214 |
// Managed pages: the Suite locks its whole box read-only for these; |
| 215 |
// this field must not look editable there either. |
| 216 |
if ($this->is_managed_page($post_id)) { |
| 217 |
return false; |
| 218 |
} |
| 219 |
|
| 220 |
return !$this->other_seo_primary_active(); |
| 221 |
} |
| 222 |
|
| 223 |
/** |
| 224 |
* Persist the three classic-editor SEO fields. |
| 225 |
* |
| 226 |
* @param int $post_id Post ID being saved. |
| 227 |
*/ |
| 228 |
public function save($post_id) |
| 229 |
{ |
| 230 |
// Autosave and revision saves must not touch these fields. |
| 231 |
if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) { |
| 232 |
return; |
| 233 |
} |
| 234 |
|
| 235 |
// The nonce is emitted wherever the fields render — by the Suite for |
| 236 |
// the breadcrumb field and the Languages tab, and beside the primary |
| 237 |
// category select for that field alone. It is absent on Quick Edit, |
| 238 |
// bulk edit, REST, autosave and every other path — so its absence |
| 239 |
// means "do nothing", exactly like the other classic save handlers. |
| 240 |
if (!isset($_POST[self::NONCE_FIELD]) || !wp_verify_nonce($_POST[self::NONCE_FIELD], self::NONCE_ACTION)) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the nonce IS the verification |
| 241 |
return; |
| 242 |
} |
| 243 |
|
| 244 |
// The block editor owns these fields there; bail on both the main |
| 245 |
// block-editor load and the `meta-box-loader=1` re-post. |
| 246 |
if ($this->is_block_editor_request()) { |
| 247 |
return; |
| 248 |
} |
| 249 |
|
| 250 |
// LPS / custom-HTML managed pages: no nonces, no writes. |
| 251 |
if ($this->is_managed_page($post_id)) { |
| 252 |
return; |
| 253 |
} |
| 254 |
|
| 255 |
if (!current_user_can('edit_post', $post_id)) { |
| 256 |
return; |
| 257 |
} |
| 258 |
|
| 259 |
$this->save_breadcrumb_title($post_id); |
| 260 |
$this->save_primary_category($post_id); |
| 261 |
$this->save_hreflang($post_id); |
| 262 |
} |
| 263 |
|
| 264 |
/** |
| 265 |
* True for any request originating from the block editor. |
| 266 |
* |
| 267 |
* Mirrors Metasync_Seo_Suite::is_block_editor_request(): the block editor |
| 268 |
* renders its meta-box area during the main page load (is_block_editor() |
| 269 |
* is true there) and re-posts that same form to post.php?meta-box-loader=1 |
| 270 |
* when saving. Both are treated as block-editor context so this handler |
| 271 |
* never engages in either — a classic save must not clobber values the |
| 272 |
* Gutenberg sidebar owns. get_current_screen() does not exist outside |
| 273 |
* wp-admin, and save_post also fires on REST saves, hence the guard. |
| 274 |
* |
| 275 |
* @return bool |
| 276 |
*/ |
| 277 |
private function is_block_editor_request() |
| 278 |
{ |
| 279 |
if (!empty($_REQUEST['meta-box-loader'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request flag for editor context |
| 280 |
return true; |
| 281 |
} |
| 282 |
if (function_exists('get_current_screen')) { |
| 283 |
$screen = get_current_screen(); |
| 284 |
if ($screen && $screen->is_block_editor()) { |
| 285 |
return true; |
| 286 |
} |
| 287 |
} |
| 288 |
return false; |
| 289 |
} |
| 290 |
|
| 291 |
/** |
| 292 |
* True when the page's front-end output bypasses WordPress SEO (LPS / |
| 293 |
* custom-HTML). Reuses the shared helper so this stays in lockstep with |
| 294 |
* the Suite's managed-page lockout. |
| 295 |
* |
| 296 |
* @param int $post_id Post ID. |
| 297 |
* @return bool |
| 298 |
*/ |
| 299 |
private function is_managed_page($post_id) |
| 300 |
{ |
| 301 |
if (function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id)) { |
| 302 |
return true; |
| 303 |
} |
| 304 |
// Fallbacks if the helper isn't loaded on this request — mirrors |
| 305 |
// Metasync_Seo_Suite::is_managed_page(). |
| 306 |
if ($post_id > 0) { |
| 307 |
if (get_post_meta($post_id, '_metasync_lps_import', true)) { |
| 308 |
return true; |
| 309 |
} |
| 310 |
if (get_post_meta($post_id, '_metasync_is_custom_html_page', true) === '1' |
| 311 |
&& get_post_meta($post_id, '_metasync_raw_html_enabled', true) === '1') { |
| 312 |
return true; |
| 313 |
} |
| 314 |
} |
| 315 |
return false; |
| 316 |
} |
| 317 |
|
| 318 |
/** |
| 319 |
* True when the Language Alternates editor switch is on. |
| 320 |
* |
| 321 |
* The REST registration of the hreflang meta is gated on the same flag, so |
| 322 |
* the classic write path refuses exactly where the block editor's would. |
| 323 |
* The defined() guard matches the registration's upgrade-window defence |
| 324 |
* against a stale opcache copy of the flags class. |
| 325 |
* |
| 326 |
* @return bool |
| 327 |
*/ |
| 328 |
private function language_alternates_enabled() |
| 329 |
{ |
| 330 |
// @phpstan-ignore-next-line function.alreadyNarrowedType |
| 331 |
return defined('Metasync_Feature_Flags::LANGUAGE_ALTERNATES') |
| 332 |
&& Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::LANGUAGE_ALTERNATES); |
| 333 |
} |
| 334 |
|
| 335 |
/** |
| 336 |
* True when the breadcrumb title override is meaningful on this site. |
| 337 |
* |
| 338 |
* @return bool |
| 339 |
*/ |
| 340 |
private function breadcrumb_title_meaningful() |
| 341 |
{ |
| 342 |
return !class_exists('Metasync_Breadcrumbs') || Metasync_Breadcrumbs::is_breadcrumb_title_meaningful(); |
| 343 |
} |
| 344 |
|
| 345 |
/** |
| 346 |
* Breadcrumb Title Override. sanitize_text_field matches the REST |
| 347 |
* registration; an empty string is a deliberate clear. Writing via |
| 348 |
* update_post_meta fires the Yoast/Rank Math cross-sync for free. |
| 349 |
* |
| 350 |
* @param int $post_id Post ID. |
| 351 |
*/ |
| 352 |
private function save_breadcrumb_title($post_id) |
| 353 |
{ |
| 354 |
if (!isset($_POST[self::META_BREADCRUMB_TITLE])) { |
| 355 |
return; |
| 356 |
} |
| 357 |
|
| 358 |
// Breadcrumbs globally disabled: the field did not render, so leave |
| 359 |
// the stored value untouched rather than honouring a crafted input. |
| 360 |
if (!$this->breadcrumb_title_meaningful()) { |
| 361 |
return; |
| 362 |
} |
| 363 |
|
| 364 |
$value = sanitize_text_field(wp_unslash($_POST[self::META_BREADCRUMB_TITLE])); |
| 365 |
if ($value === '') { |
| 366 |
delete_post_meta($post_id, self::META_BREADCRUMB_TITLE); |
| 367 |
return; |
| 368 |
} |
| 369 |
// update_post_meta() unslashes again, so re-slash to keep literal |
| 370 |
// backslashes and quotes in the stored value. |
| 371 |
update_post_meta($post_id, self::META_BREADCRUMB_TITLE, wp_slash($value)); |
| 372 |
} |
| 373 |
|
| 374 |
/** |
| 375 |
* Primary Category. absint matches the REST registration; 0 means "no |
| 376 |
* primary category" and deletes rather than persisting a zero. The select |
| 377 |
* is absent from the form on post types without categories (and wherever |
| 378 |
* another SEO plugin owns the control), in which case the stored value is |
| 379 |
* left untouched. |
| 380 |
* |
| 381 |
* @param int $post_id Post ID. |
| 382 |
*/ |
| 383 |
private function save_primary_category($post_id) |
| 384 |
{ |
| 385 |
if (!isset($_POST[self::META_PRIMARY_CATEGORY])) { |
| 386 |
return; |
| 387 |
} |
| 388 |
|
| 389 |
$value = absint(wp_unslash($_POST[self::META_PRIMARY_CATEGORY])); |
| 390 |
if ($value > 0) { |
| 391 |
update_post_meta($post_id, self::META_PRIMARY_CATEGORY, $value); |
| 392 |
} else { |
| 393 |
delete_post_meta($post_id, self::META_PRIMARY_CATEGORY); |
| 394 |
} |
| 395 |
} |
| 396 |
|
| 397 |
/** |
| 398 |
* Language Alternates (hreflang). |
| 399 |
* |
| 400 |
* The classic form posts individual |
| 401 |
* `metasync_hreflang_rows[N][lang|region|url]` inputs (it cannot post a |
| 402 |
* JSON string), so assemble them here and run the result through the |
| 403 |
* canonical REST sanitize callback for storage-format parity. |
| 404 |
* |
| 405 |
* The Languages tab also posts a `metasync_hreflang_present` marker. With |
| 406 |
* every row removed the row inputs are gone from the form entirely, so |
| 407 |
* without that marker the handler could not tell "user cleared all rows" |
| 408 |
* (clear the stored value) from "the tab never rendered" (leave the |
| 409 |
* stored value — the Gutenberg sidebar or a site with the feature |
| 410 |
* disabled owns it). |
| 411 |
* |
| 412 |
* @param int $post_id Post ID. |
| 413 |
*/ |
| 414 |
private function save_hreflang($post_id) |
| 415 |
{ |
| 416 |
$rows_in = null; |
| 417 |
if (isset($_POST[self::HREFLANG_ROWS_FIELD])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above |
| 418 |
$rows_in = wp_unslash($_POST[self::HREFLANG_ROWS_FIELD]); |
| 419 |
if (!is_array($rows_in)) { |
| 420 |
$rows_in = array(); |
| 421 |
} |
| 422 |
} elseif (isset($_POST[self::HREFLANG_PRESENT_FIELD])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above |
| 423 |
// Tab rendered, all rows removed: a deliberate clear. |
| 424 |
$rows_in = array(); |
| 425 |
} else { |
| 426 |
// The tab did not render on this save; the stored value belongs |
| 427 |
// to whoever did render it. |
| 428 |
return; |
| 429 |
} |
| 430 |
|
| 431 |
// Flag off: the tab did not render, so a crafted submission must not |
| 432 |
// write what the REST path would refuse. |
| 433 |
if (!$this->language_alternates_enabled()) { |
| 434 |
return; |
| 435 |
} |
| 436 |
|
| 437 |
$rows = array(); |
| 438 |
foreach ($rows_in as $row) { |
| 439 |
if (!is_array($row)) { |
| 440 |
continue; |
| 441 |
} |
| 442 |
$rows[] = array( |
| 443 |
'lang' => isset($row['lang']) && is_string($row['lang']) ? sanitize_text_field($row['lang']) : '', |
| 444 |
'region' => isset($row['region']) && is_string($row['region']) ? sanitize_text_field($row['region']) : '', |
| 445 |
'url' => isset($row['url']) && is_string($row['url']) ? sanitize_text_field($row['url']) : '', |
| 446 |
); |
| 447 |
} |
| 448 |
|
| 449 |
$encoded = wp_json_encode($rows); |
| 450 |
if (!is_string($encoded)) { |
| 451 |
// Encoding failure: never persist a corrupt value; clear instead. |
| 452 |
delete_post_meta($post_id, self::META_HREFLANG); |
| 453 |
return; |
| 454 |
} |
| 455 |
|
| 456 |
$clean = $this->sanitize_hreflang_json($encoded); |
| 457 |
if ($clean === '' || $clean === '[]') { |
| 458 |
delete_post_meta($post_id, self::META_HREFLANG); |
| 459 |
return; |
| 460 |
} |
| 461 |
// update_post_meta() unslashes again; re-slash for byte-exact storage. |
| 462 |
update_post_meta($post_id, self::META_HREFLANG, wp_slash($clean)); |
| 463 |
} |
| 464 |
|
| 465 |
/** |
| 466 |
* Canonical hreflang JSON sanitisation. |
| 467 |
* |
| 468 |
* Delegates to the REST registration's sanitize_callback so classic-saved |
| 469 |
* rows are normalised identically to REST-saved rows (en_US folded to |
| 470 |
* en+US, casing folded, x-default region stripped, blank rows dropped). |
| 471 |
* |
| 472 |
* @param string $json JSON array of raw rows. |
| 473 |
* @return string Normalised JSON array string. |
| 474 |
*/ |
| 475 |
private function sanitize_hreflang_json($json) |
| 476 |
{ |
| 477 |
if (class_exists('Metasync_SEO_Sidebar')) { |
| 478 |
return (string) Metasync_SEO_Sidebar::sanitize_hreflang_meta($json); |
| 479 |
} |
| 480 |
|
| 481 |
// Autoload miss (non-standard install): degrade to the same blank-row |
| 482 |
// dropping the canonical callback performs, without the full |
| 483 |
// normalisation. |
| 484 |
$decoded = json_decode($json, true); |
| 485 |
if (!is_array($decoded)) { |
| 486 |
return '[]'; |
| 487 |
} |
| 488 |
$clean = array(); |
| 489 |
foreach ($decoded as $entry) { |
| 490 |
if (!is_array($entry)) { |
| 491 |
continue; |
| 492 |
} |
| 493 |
$lang = isset($entry['lang']) ? (string) $entry['lang'] : ''; |
| 494 |
$url = isset($entry['url']) ? (string) $entry['url'] : ''; |
| 495 |
if ($lang === '' && $url === '') { |
| 496 |
continue; |
| 497 |
} |
| 498 |
$clean[] = array( |
| 499 |
'lang' => $lang, |
| 500 |
'region' => isset($entry['region']) ? (string) $entry['region'] : '', |
| 501 |
'url' => $url, |
| 502 |
); |
| 503 |
} |
| 504 |
return (string) wp_json_encode($clean); |
| 505 |
} |
| 506 |
} |
| 507 |
|