robotstxt-og/includes/class-robotstxt-og-meta-box.php
2026-08-10 14:03:36 +00:00

383 lines
11 KiB
PHP

<?php
/**
* Meta Box Class
*
* Provides per-post Open Graph and social media customization fields
* in the WordPress post editor.
*
* @package ROBOTSTXT_OG
* @since 1.2.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Class Robotstxt_OG_Meta_Box
*
* Registers and handles the Open Graph meta box in the post editor,
* and registers the underlying post meta for REST API access.
*
* @since 1.2.0
*/
class Robotstxt_OG_Meta_Box {
/**
* Allowed og:type override values (the auto-derived default is stored as empty).
*
* @since 1.2.0
* @var string[]
*/
public const ALLOWED_OG_TYPES = array( 'website', 'article' );
/**
* Sanitize an og:type override value to a safe value or empty string.
*
* @since 1.2.0
*
* @param mixed $value Raw value.
* @return string
*/
public static function sanitize_og_type( $value ): string {
$value = is_string( $value ) ? $value : '';
return in_array( $value, self::ALLOWED_OG_TYPES, true ) ? $value : '';
}
/**
* Initialize hooks.
*
* @since 1.2.0
*
* @return void
*/
public function init(): void {
// Register post meta for REST API access (block editor).
add_action( 'init', array( $this, 'register_post_meta' ) );
// Meta box UI is admin-only.
if ( is_admin() ) {
add_action( 'add_meta_boxes', array( $this, 'register_meta_box' ) );
add_action( 'save_post', array( $this, 'save_meta_box' ), 10, 2 );
}
}
/**
* Register custom post meta for all public post types.
*
* Exposes _og_title and _og_description via the REST API so the
* block editor can read and write them via useEntityProp.
*
* @since 1.2.0
*
* @return void
*/
public function register_post_meta(): void {
$post_types = get_post_types( array( 'public' => true ), 'names' );
foreach ( $post_types as $post_type ) {
register_post_meta(
$post_type,
'_og_title',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => static function () {
return current_user_can( 'edit_posts' );
},
'show_in_rest' => true,
)
);
register_post_meta(
$post_type,
'_og_description',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => 'sanitize_textarea_field',
'auth_callback' => static function () {
return current_user_can( 'edit_posts' );
},
'show_in_rest' => true,
)
);
register_post_meta(
$post_type,
'_og_image_alt',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => static function () {
return current_user_can( 'edit_posts' );
},
'show_in_rest' => true,
)
);
register_post_meta(
$post_type,
'_og_type',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => array( __CLASS__, 'sanitize_og_type' ),
'auth_callback' => static function () {
return current_user_can( 'edit_posts' );
},
'show_in_rest' => true,
)
);
register_post_meta(
$post_type,
'_twitter_creator',
array(
'type' => 'string',
'single' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => static function () {
return current_user_can( 'edit_posts' );
},
'show_in_rest' => true,
)
);
}
}
/**
* Register the meta box for all public post types.
*
* @since 1.2.0
*
* @return void
*/
public function register_meta_box(): void {
$post_types = get_post_types( array( 'public' => true ), 'names' );
foreach ( $post_types as $post_type ) {
add_meta_box(
'robotstxt-og-meta-box',
__( 'Open Graph / Social Media', 'robotstxt-og' ),
array( $this, 'render_meta_box' ),
$post_type,
'normal',
'default'
);
}
}
/**
* Render the meta box HTML.
*
* @since 1.2.0
*
* @param WP_Post $post The current post object.
* @return void
*/
public function render_meta_box( WP_Post $post ): void {
wp_nonce_field( 'robotstxt_og_meta_box', 'robotstxt_og_meta_box_nonce' );
$raw_title = get_post_meta( $post->ID, '_og_title', true );
$og_title = is_string( $raw_title ) ? $raw_title : '';
$raw_desc = get_post_meta( $post->ID, '_og_description', true );
$og_description = is_string( $raw_desc ) ? $raw_desc : '';
$raw_alt = get_post_meta( $post->ID, '_og_image_alt', true );
$og_image_alt = is_string( $raw_alt ) ? $raw_alt : '';
$raw_type = get_post_meta( $post->ID, '_og_type', true );
$og_type = self::sanitize_og_type( $raw_type );
$raw_creator = get_post_meta( $post->ID, '_twitter_creator', true );
$twitter_creator = is_string( $raw_creator ) ? $raw_creator : '';
?>
<table class="form-table" role="presentation">
<tbody>
<tr>
<th scope="row">
<label for="robotstxt_og_type">
<?php esc_html_e( 'Open Graph Type', 'robotstxt-og' ); ?>
</label>
</th>
<td>
<select id="robotstxt_og_type" name="robotstxt_og_type">
<option value="" <?php selected( $og_type, '' ); ?>>
<?php esc_html_e( 'Default (auto: article for posts, website otherwise)', 'robotstxt-og' ); ?>
</option>
<option value="website" <?php selected( $og_type, 'website' ); ?>>
<?php esc_html_e( 'website', 'robotstxt-og' ); ?>
</option>
<option value="article" <?php selected( $og_type, 'article' ); ?>>
<?php esc_html_e( 'article', 'robotstxt-og' ); ?>
</option>
</select>
<p class="description">
<?php esc_html_e( 'Overrides og:type for this post. Selecting "article" also emits the article:* meta tags (published/modified time, section, tags).', 'robotstxt-og' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="robotstxt_og_twitter_creator">
<?php esc_html_e( 'Twitter Author Handle', 'robotstxt-og' ); ?>
</label>
</th>
<td>
<input
type="text"
id="robotstxt_og_twitter_creator"
name="robotstxt_og_twitter_creator"
value="<?php echo esc_attr( $twitter_creator ); ?>"
class="large-text"
placeholder="@username"
/>
<p class="description">
<?php esc_html_e( 'Twitter/X handle of the post author, emitted as twitter:creator. Leave blank to omit.', 'robotstxt-og' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="robotstxt_og_title">
<?php esc_html_e( 'Custom Title', 'robotstxt-og' ); ?>
</label>
</th>
<td>
<input
type="text"
id="robotstxt_og_title"
name="robotstxt_og_title"
value="<?php echo esc_attr( $og_title ); ?>"
class="large-text"
/>
<p class="description">
<?php esc_html_e( 'Overrides the default og:title for this post. Leave blank to use the post title automatically.', 'robotstxt-og' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="robotstxt_og_description">
<?php esc_html_e( 'Custom Description', 'robotstxt-og' ); ?>
</label>
</th>
<td>
<textarea
id="robotstxt_og_description"
name="robotstxt_og_description"
class="large-text"
rows="3"
><?php echo esc_textarea( $og_description ); ?></textarea>
<p class="description">
<?php esc_html_e( 'Overrides the default og:description for this post. Leave blank to use the excerpt automatically.', 'robotstxt-og' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row">
<label for="robotstxt_og_image_alt">
<?php esc_html_e( 'Custom OG Image Alt', 'robotstxt-og' ); ?>
</label>
</th>
<td>
<input
type="text"
id="robotstxt_og_image_alt"
name="robotstxt_og_image_alt"
value="<?php echo esc_attr( $og_image_alt ); ?>"
class="large-text"
/>
<p class="description">
<?php esc_html_e( 'Alt text for the og:image of this post. Leave blank to use the featured image alt text from the media library.', 'robotstxt-og' ); ?>
</p>
</td>
</tr>
</tbody>
</table>
<?php
}
/**
* Save meta box values on post save.
*
* @since 1.2.0
*
* @param int $post_id The post ID.
* @param WP_Post $post The post object.
* @return void
*/
public function save_meta_box( int $post_id, WP_Post $post ): void {
// Skip autosaves and revisions.
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
return;
}
// Verify nonce.
$nonce_raw = filter_input( INPUT_POST, 'robotstxt_og_meta_box_nonce', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'robotstxt_og_meta_box' ) ) {
return;
}
// Check permissions.
$post_type_obj = get_post_type_object( $post->post_type );
if ( ! $post_type_obj || ! current_user_can( $post_type_obj->cap->edit_post, $post_id ) ) {
return;
}
// Save og:title.
$og_title_raw = filter_input( INPUT_POST, 'robotstxt_og_title', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$og_title = $og_title_raw ? sanitize_text_field( wp_unslash( $og_title_raw ) ) : '';
if ( empty( $og_title ) ) {
delete_post_meta( $post_id, '_og_title' );
} else {
update_post_meta( $post_id, '_og_title', $og_title );
}
// Save og:description.
$og_desc_raw = filter_input( INPUT_POST, 'robotstxt_og_description', FILTER_UNSAFE_RAW );
$og_desc = $og_desc_raw ? sanitize_textarea_field( wp_unslash( $og_desc_raw ) ) : '';
if ( empty( $og_desc ) ) {
delete_post_meta( $post_id, '_og_description' );
} else {
update_post_meta( $post_id, '_og_description', $og_desc );
}
// Save og:image:alt.
$og_alt_raw = filter_input( INPUT_POST, 'robotstxt_og_image_alt', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$og_alt = $og_alt_raw ? sanitize_text_field( wp_unslash( $og_alt_raw ) ) : '';
if ( empty( $og_alt ) ) {
delete_post_meta( $post_id, '_og_image_alt' );
} else {
update_post_meta( $post_id, '_og_image_alt', $og_alt );
}
// Save og:type override (whitelisted).
$og_type_raw = filter_input( INPUT_POST, 'robotstxt_og_type', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$og_type = self::sanitize_og_type( $og_type_raw ? wp_unslash( $og_type_raw ) : '' );
if ( '' === $og_type ) {
delete_post_meta( $post_id, '_og_type' );
} else {
update_post_meta( $post_id, '_og_type', $og_type );
}
// Save twitter:creator handle.
$creator_raw = filter_input( INPUT_POST, 'robotstxt_og_twitter_creator', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$creator = $creator_raw ? sanitize_text_field( wp_unslash( $creator_raw ) ) : '';
if ( '' === $creator ) {
delete_post_meta( $post_id, '_twitter_creator' );
} else {
update_post_meta( $post_id, '_twitter_creator', $creator );
}
}
}