v1.2.0
This commit is contained in:
parent
ddaaff71ad
commit
3bb9ed33e1
12 changed files with 1762 additions and 409 deletions
|
|
@ -69,6 +69,14 @@ class Robotstxt_OG_Image_Fallback {
|
|||
*/
|
||||
private Robotstxt_OG_Meta_Box $meta_box;
|
||||
|
||||
/**
|
||||
* Term meta instance.
|
||||
*
|
||||
* @since 1.2.0
|
||||
* @var Robotstxt_OG_Term_Meta
|
||||
*/
|
||||
private Robotstxt_OG_Term_Meta $term_meta;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
|
|
@ -103,6 +111,9 @@ class Robotstxt_OG_Image_Fallback {
|
|||
* @return void
|
||||
*/
|
||||
public function init(): void {
|
||||
// Load bundled translations (required for non-wordpress.org distribution).
|
||||
load_plugin_textdomain( 'robotstxt-og', false, basename( ROBOTSTXT_OG_PATH ) . '/languages' );
|
||||
|
||||
// Load dependencies.
|
||||
$this->load_dependencies();
|
||||
|
||||
|
|
@ -121,6 +132,10 @@ class Robotstxt_OG_Image_Fallback {
|
|||
$this->meta_box = new Robotstxt_OG_Meta_Box();
|
||||
$this->meta_box->init();
|
||||
|
||||
// Initialize term meta (category/tag OG fields on term.php).
|
||||
$this->term_meta = new Robotstxt_OG_Term_Meta();
|
||||
$this->term_meta->init();
|
||||
|
||||
// Initialize admin settings if in admin context.
|
||||
if ( is_admin() ) {
|
||||
$this->admin_settings = new Robotstxt_OG_Admin_Settings( $this->resolver );
|
||||
|
|
@ -157,6 +172,7 @@ class Robotstxt_OG_Image_Fallback {
|
|||
require_once ROBOTSTXT_OG_PATH . 'includes/class-robotstxt-og-tags.php';
|
||||
require_once ROBOTSTXT_OG_PATH . 'includes/class-robotstxt-og-rest-api.php';
|
||||
require_once ROBOTSTXT_OG_PATH . 'includes/class-robotstxt-og-meta-box.php';
|
||||
require_once ROBOTSTXT_OG_PATH . 'includes/class-robotstxt-og-term-meta.php';
|
||||
|
||||
// Load admin class if in admin context.
|
||||
if ( is_admin() ) {
|
||||
|
|
@ -297,8 +313,10 @@ class Robotstxt_OG_Image_Fallback {
|
|||
foreach ( $posts as $post_id ) {
|
||||
$og_title = get_post_meta( $post_id, '_og_title', true );
|
||||
$og_desc = get_post_meta( $post_id, '_og_description', true );
|
||||
$creator = get_post_meta( $post_id, '_twitter_creator', true );
|
||||
$og_alt = get_post_meta( $post_id, '_og_image_alt', true );
|
||||
|
||||
if ( empty( $og_title ) && empty( $og_desc ) ) {
|
||||
if ( empty( $og_title ) && empty( $og_desc ) && empty( $creator ) && empty( $og_alt ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -318,6 +336,20 @@ class Robotstxt_OG_Image_Fallback {
|
|||
);
|
||||
}
|
||||
|
||||
if ( ! empty( $creator ) && is_string( $creator ) ) {
|
||||
$item_data[] = array(
|
||||
'name' => __( 'Twitter Author Handle', 'robotstxt-og' ),
|
||||
'value' => $creator,
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! empty( $og_alt ) && is_string( $og_alt ) ) {
|
||||
$item_data[] = array(
|
||||
'name' => __( 'Custom OG Image Alt', 'robotstxt-og' ),
|
||||
'value' => $og_alt,
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! empty( $item_data ) ) {
|
||||
$data[] = array(
|
||||
'group_id' => 'robotstxt-og-post-meta',
|
||||
|
|
@ -374,10 +406,15 @@ class Robotstxt_OG_Image_Fallback {
|
|||
$items_removed = 0;
|
||||
|
||||
foreach ( $posts as $post_id ) {
|
||||
$deleted_title = delete_post_meta( $post_id, '_og_title' );
|
||||
$deleted_desc = delete_post_meta( $post_id, '_og_description' );
|
||||
$removed = false;
|
||||
|
||||
if ( $deleted_title || $deleted_desc ) {
|
||||
foreach ( array( '_og_title', '_og_description', '_twitter_creator', '_og_image_alt' ) as $meta_key ) {
|
||||
if ( delete_post_meta( $post_id, $meta_key ) ) {
|
||||
$removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $removed ) {
|
||||
++$items_removed;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,29 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||
*/
|
||||
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.
|
||||
*
|
||||
|
|
@ -82,6 +105,48 @@ class Robotstxt_OG_Meta_Box {
|
|||
'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,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,14 +183,62 @@ class Robotstxt_OG_Meta_Box {
|
|||
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_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">
|
||||
|
|
@ -163,6 +276,25 @@ class Robotstxt_OG_Meta_Box {
|
|||
</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
|
||||
|
|
@ -217,5 +349,35 @@ class Robotstxt_OG_Meta_Box {
|
|||
} 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ class Robotstxt_OG_Tags {
|
|||
if ( $this->inject_directly ) {
|
||||
// No SEO plugin detected, inject all tags directly.
|
||||
add_action( 'wp_head', array( $this, 'inject_og_tags' ), 5 );
|
||||
add_action( 'wp_head', array( $this, 'output_platform_tags' ), 6 );
|
||||
add_action( 'wp_head', array( $this, 'output_web_app_tags' ), 7 );
|
||||
add_filter( 'language_attributes', array( $this, 'add_og_namespace_prefix' ) );
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +115,7 @@ class Robotstxt_OG_Tags {
|
|||
}
|
||||
|
||||
$image_url = $this->get_contextual_image();
|
||||
$image_alt = '' !== $image_url ? $this->get_contextual_image_alt( $image_url ) : '';
|
||||
|
||||
if ( $enable_facebook ) {
|
||||
// Output all basic OG tags: title, type, url, description, site_name, locale.
|
||||
|
|
@ -119,7 +123,7 @@ class Robotstxt_OG_Tags {
|
|||
|
||||
// Output image tags only when a compatible image is available.
|
||||
if ( ! empty( $image_url ) ) {
|
||||
$this->output_og_image_tags( $image_url );
|
||||
$this->output_og_image_tags( $image_url, $image_alt );
|
||||
}
|
||||
|
||||
// Output article-specific tags (only for singular blog posts).
|
||||
|
|
@ -127,7 +131,99 @@ class Robotstxt_OG_Tags {
|
|||
}
|
||||
|
||||
if ( $enable_twitter ) {
|
||||
$this->output_twitter_card_tags( $image_url );
|
||||
$this->output_twitter_card_tags( $image_url, $image_alt );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output platform-specific meta tags (Facebook, Pinterest, Telegram, Slack).
|
||||
*
|
||||
* Emits only the tags whose setting is non-empty. Runs independently of the
|
||||
* enable_facebook/enable_twitter toggles, but only when this plugin is the
|
||||
* OG handler (no SEO plugin detected).
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function output_platform_tags(): void {
|
||||
$this->emit_option_tag( 'property', 'fb:app_id', 'robotstxt_og_fb_app_id' );
|
||||
$this->emit_option_tag( 'property', 'fb:admins', 'robotstxt_og_fb_admins' );
|
||||
$this->emit_option_tag( 'property', 'fb:pages', 'robotstxt_og_fb_pages' );
|
||||
$this->emit_option_tag( 'name', 'p:domain_verify', 'robotstxt_og_pinterest_verify' );
|
||||
$this->emit_option_tag( 'name', 'telegram:channel', 'robotstxt_og_telegram_channel' );
|
||||
$this->emit_option_tag( 'name', 'slack-app-id', 'robotstxt_og_slack_app_id' );
|
||||
|
||||
if ( (bool) get_option( 'robotstxt_og_pinterest_nopin', false ) ) {
|
||||
printf( '<meta name="pinterest" content="nopin" />' . "\n" );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output mobile / web-app meta tags.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function output_web_app_tags(): void {
|
||||
$this->emit_option_tag( 'name', 'theme-color', 'robotstxt_og_theme_color' );
|
||||
$this->emit_option_tag( 'name', 'apple-mobile-web-app-status-bar-style', 'robotstxt_og_app_status_bar_style' );
|
||||
|
||||
// Web app name -> two tags.
|
||||
$raw_name = get_option( 'robotstxt_og_app_name', '' );
|
||||
$name = is_string( $raw_name ) ? trim( $raw_name ) : '';
|
||||
|
||||
if ( '' !== $name ) {
|
||||
printf( '<meta name="application-name" content="%s" />' . "\n", esc_attr( $name ) );
|
||||
printf( '<meta name="apple-mobile-web-app-title" content="%s" />' . "\n", esc_attr( $name ) );
|
||||
}
|
||||
|
||||
if ( (bool) get_option( 'robotstxt_og_web_app_capable', false ) ) {
|
||||
printf( '<meta name="mobile-web-app-capable" content="yes" />' . "\n" );
|
||||
printf( '<meta name="apple-mobile-web-app-capable" content="yes" />' . "\n" );
|
||||
}
|
||||
|
||||
if ( (bool) get_option( 'robotstxt_og_format_detection', false ) ) {
|
||||
printf( '<meta name="format-detection" content="telephone=no" />' . "\n" );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the Open Graph namespace prefix to the <html> tag attributes.
|
||||
*
|
||||
* Hooked to `language_attributes`. Skipped if the OG namespace is already
|
||||
* present (e.g. added by the theme).
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param string $output Existing attribute string.
|
||||
* @return string
|
||||
*/
|
||||
public function add_og_namespace_prefix( string $output ): string {
|
||||
if ( false !== stripos( $output, 'og:' ) ) {
|
||||
return $output;
|
||||
}
|
||||
|
||||
return $output . ' prefix="og: http://ogp.me/ns#"';
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a single meta tag driven by a string option, when non-empty.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param string $attr Attribute kind: 'property' or 'name'.
|
||||
* @param string $tag Tag name (e.g. 'fb:app_id').
|
||||
* @param string $option Option key.
|
||||
* @return void
|
||||
*/
|
||||
private function emit_option_tag( string $attr, string $tag, string $option ): void {
|
||||
$raw = get_option( $option, '' );
|
||||
$value = is_string( $raw ) ? trim( $raw ) : '';
|
||||
|
||||
if ( '' !== $value ) {
|
||||
printf( '<meta %s="%s" content="%s" />' . "\n", esc_attr( $attr ), esc_attr( $tag ), esc_attr( $value ) );
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -213,6 +309,70 @@ class Robotstxt_OG_Tags {
|
|||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the og:image:alt text for the current context.
|
||||
*
|
||||
* A per-post / per-term manual override (`_og_image_alt`) wins; otherwise the
|
||||
* alt text is read from the media library attachment.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param string $image_url Resolved image URL.
|
||||
* @return string Alt text, or empty string.
|
||||
*/
|
||||
private function get_contextual_image_alt( string $image_url ): string {
|
||||
$manual = $this->get_manual_image_alt();
|
||||
|
||||
if ( '' !== $manual ) {
|
||||
return $manual;
|
||||
}
|
||||
|
||||
$image_id = attachment_url_to_postid( $image_url );
|
||||
|
||||
if ( $image_id ) {
|
||||
$raw_alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
|
||||
|
||||
return is_string( $raw_alt ) ? $raw_alt : '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the per-context manual og:image:alt override.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return string Override alt text, or empty string.
|
||||
*/
|
||||
private function get_manual_image_alt(): string {
|
||||
if ( is_singular() ) {
|
||||
$post_id = get_queried_object_id();
|
||||
|
||||
if ( $post_id ) {
|
||||
$raw = get_post_meta( $post_id, '_og_image_alt', true );
|
||||
|
||||
if ( is_string( $raw ) && '' !== $raw ) {
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( is_tax() || is_category() || is_tag() ) {
|
||||
$term = get_queried_object();
|
||||
|
||||
if ( $term instanceof WP_Term ) {
|
||||
$raw = get_term_meta( $term->term_id, '_og_image_alt', true );
|
||||
|
||||
if ( is_string( $raw ) && '' !== $raw ) {
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the og:title for the current context.
|
||||
*
|
||||
|
|
@ -243,6 +403,12 @@ class Robotstxt_OG_Tags {
|
|||
$term = get_queried_object();
|
||||
|
||||
if ( $term instanceof WP_Term ) {
|
||||
$custom_title = get_term_meta( $term->term_id, '_og_title', true );
|
||||
|
||||
if ( is_string( $custom_title ) && '' !== $custom_title ) {
|
||||
return $custom_title;
|
||||
}
|
||||
|
||||
return (string) $term->name;
|
||||
}
|
||||
}
|
||||
|
|
@ -287,8 +453,16 @@ class Robotstxt_OG_Tags {
|
|||
if ( is_tax() || is_category() || is_tag() ) {
|
||||
$term = get_queried_object();
|
||||
|
||||
if ( $term instanceof WP_Term && ! empty( $term->description ) ) {
|
||||
return wp_strip_all_tags( $term->description );
|
||||
if ( $term instanceof WP_Term ) {
|
||||
$custom_desc = get_term_meta( $term->term_id, '_og_description', true );
|
||||
|
||||
if ( is_string( $custom_desc ) && '' !== $custom_desc ) {
|
||||
return wp_strip_all_tags( $custom_desc );
|
||||
}
|
||||
|
||||
if ( ! empty( $term->description ) ) {
|
||||
return wp_strip_all_tags( $term->description );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -337,6 +511,20 @@ class Robotstxt_OG_Tags {
|
|||
* @return string 'article' or 'website'.
|
||||
*/
|
||||
private function get_og_type(): string {
|
||||
// Per-post override wins.
|
||||
if ( is_singular() ) {
|
||||
$post_id = get_queried_object_id();
|
||||
|
||||
if ( $post_id ) {
|
||||
$custom = get_post_meta( $post_id, '_og_type', true );
|
||||
|
||||
if ( is_string( $custom ) && '' !== $custom ) {
|
||||
return $custom;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: "article" for blog posts, "website" otherwise.
|
||||
if ( is_singular( 'post' ) ) {
|
||||
return 'article';
|
||||
}
|
||||
|
|
@ -410,10 +598,11 @@ class Robotstxt_OG_Tags {
|
|||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $image_url Image URL.
|
||||
* @param string $image_url Image URL.
|
||||
* @param string $manual_alt Manual alt override (preferred over media library).
|
||||
* @return void
|
||||
*/
|
||||
private function output_og_image_tags( string $image_url ): void {
|
||||
private function output_og_image_tags( string $image_url, string $manual_alt = '' ): void {
|
||||
// Basic OG image tag.
|
||||
printf(
|
||||
'<meta property="og:image" content="%s" />' . "\n",
|
||||
|
|
@ -428,7 +617,7 @@ class Robotstxt_OG_Tags {
|
|||
);
|
||||
}
|
||||
|
||||
// Try to get dimensions, MIME type, and alt text from the media library.
|
||||
// Try to get dimensions and MIME type from the media library.
|
||||
$image_id = attachment_url_to_postid( $image_url );
|
||||
|
||||
if ( $image_id ) {
|
||||
|
|
@ -456,17 +645,21 @@ class Robotstxt_OG_Tags {
|
|||
esc_attr( $mime_type )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Alt text from the media library (set when uploading/editing the image).
|
||||
// Alt text: a manual override wins over the media library value.
|
||||
$alt_text = '' !== $manual_alt ? $manual_alt : '';
|
||||
|
||||
if ( '' === $alt_text && $image_id ) {
|
||||
$raw_alt = get_post_meta( $image_id, '_wp_attachment_image_alt', true );
|
||||
$alt_text = is_string( $raw_alt ) ? $raw_alt : '';
|
||||
}
|
||||
|
||||
if ( ! empty( $alt_text ) ) {
|
||||
printf(
|
||||
'<meta property="og:image:alt" content="%s" />' . "\n",
|
||||
esc_attr( $alt_text )
|
||||
);
|
||||
}
|
||||
if ( '' !== $alt_text ) {
|
||||
printf(
|
||||
'<meta property="og:image:alt" content="%s" />' . "\n",
|
||||
esc_attr( $alt_text )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -481,7 +674,7 @@ class Robotstxt_OG_Tags {
|
|||
* @return void
|
||||
*/
|
||||
private function output_article_tags(): void {
|
||||
if ( ! is_singular( 'post' ) ) {
|
||||
if ( 'article' !== $this->get_og_type() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -532,6 +725,51 @@ class Robotstxt_OG_Tags {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a Twitter/X handle: trim and ensure a leading "@".
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param string $handle Raw handle.
|
||||
* @return string Normalised handle, or empty string.
|
||||
*/
|
||||
private function format_twitter_handle( string $handle ): string {
|
||||
$handle = trim( $handle );
|
||||
|
||||
if ( '' === $handle ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ( '@' !== substr( $handle, 0, 1 ) ) {
|
||||
$handle = '@' . $handle;
|
||||
}
|
||||
|
||||
return $handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the per-post twitter:creator handle.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return string Handle (without "@"), or empty string.
|
||||
*/
|
||||
private function get_twitter_creator(): string {
|
||||
if ( ! is_singular() ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$post_id = get_queried_object_id();
|
||||
|
||||
if ( ! $post_id ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$raw = get_post_meta( $post_id, '_twitter_creator', true );
|
||||
|
||||
return is_string( $raw ) ? trim( $raw ) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Output Twitter Card meta tags.
|
||||
*
|
||||
|
|
@ -542,9 +780,10 @@ class Robotstxt_OG_Tags {
|
|||
* @since 1.1.0
|
||||
*
|
||||
* @param string $image_url Image URL, or empty string if no image available.
|
||||
* @param string $image_alt Alt text for the image, or empty string.
|
||||
* @return void
|
||||
*/
|
||||
private function output_twitter_card_tags( string $image_url ): void {
|
||||
private function output_twitter_card_tags( string $image_url, string $image_alt = '' ): void {
|
||||
$card_option = get_option( 'robotstxt_og_twitter_card_type', 'summary_large_image' );
|
||||
$card_type = is_string( $card_option ) ? $card_option : 'summary_large_image';
|
||||
|
||||
|
|
@ -558,28 +797,50 @@ class Robotstxt_OG_Tags {
|
|||
esc_attr( $card_type )
|
||||
);
|
||||
|
||||
// Canonical URL.
|
||||
$url = $this->get_og_url();
|
||||
|
||||
if ( '' !== $url ) {
|
||||
printf(
|
||||
'<meta name="twitter:url" content="%s" />' . "\n",
|
||||
esc_url( $url )
|
||||
);
|
||||
}
|
||||
|
||||
// Site handle (e.g. @example).
|
||||
$twitter_option = get_option( 'robotstxt_og_twitter_site', '' );
|
||||
$twitter_site = sanitize_text_field( is_string( $twitter_option ) ? $twitter_option : '' );
|
||||
|
||||
if ( ! empty( $twitter_site ) ) {
|
||||
// Ensure the handle includes the @ prefix.
|
||||
if ( '@' !== substr( $twitter_site, 0, 1 ) ) {
|
||||
$twitter_site = '@' . $twitter_site;
|
||||
}
|
||||
$twitter_site = $this->format_twitter_handle( is_string( $twitter_option ) ? $twitter_option : '' );
|
||||
|
||||
if ( '' !== $twitter_site ) {
|
||||
printf(
|
||||
'<meta name="twitter:site" content="%s" />' . "\n",
|
||||
esc_attr( $twitter_site )
|
||||
);
|
||||
}
|
||||
|
||||
// Author handle (twitter:creator) — per-post override.
|
||||
$creator = $this->format_twitter_handle( $this->get_twitter_creator() );
|
||||
|
||||
if ( '' !== $creator ) {
|
||||
printf(
|
||||
'<meta name="twitter:creator" content="%s" />' . "\n",
|
||||
esc_attr( $creator )
|
||||
);
|
||||
}
|
||||
|
||||
// Image (only when available; Twitter falls back to og:image otherwise).
|
||||
if ( ! empty( $image_url ) ) {
|
||||
printf(
|
||||
'<meta name="twitter:image" content="%s" />' . "\n",
|
||||
esc_url( $image_url )
|
||||
);
|
||||
|
||||
if ( ! empty( $image_alt ) ) {
|
||||
printf(
|
||||
'<meta name="twitter:image:alt" content="%s" />' . "\n",
|
||||
esc_attr( $image_alt )
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
359
includes/class-robotstxt-og-term-meta.php
Normal file
359
includes/class-robotstxt-og-term-meta.php
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
<?php
|
||||
/**
|
||||
* Term Meta Class
|
||||
*
|
||||
* Provides per-term Open Graph fields (title, image, description) on the
|
||||
* category and tag edit pages (term.php), and exposes the stored image to the
|
||||
* taxonomy archive image resolution via the `robotstxt_og_taxonomy_image`
|
||||
* filter.
|
||||
*
|
||||
* @package ROBOTSTXT_OG
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_OG_Term_Meta
|
||||
*
|
||||
* @since 1.2.0
|
||||
*/
|
||||
class Robotstxt_OG_Term_Meta {
|
||||
|
||||
/**
|
||||
* Term meta key for the custom OG title.
|
||||
*
|
||||
* @since 1.2.0
|
||||
* @var string
|
||||
*/
|
||||
public const META_TITLE = '_og_title';
|
||||
|
||||
/**
|
||||
* Term meta key for the custom OG image URL.
|
||||
*
|
||||
* @since 1.2.0
|
||||
* @var string
|
||||
*/
|
||||
public const META_IMAGE = '_og_image';
|
||||
|
||||
/**
|
||||
* Term meta key for the custom OG description.
|
||||
*
|
||||
* @since 1.2.0
|
||||
* @var string
|
||||
*/
|
||||
public const META_DESCRIPTION = '_og_description';
|
||||
|
||||
/**
|
||||
* Term meta key for the custom OG image alt text.
|
||||
*
|
||||
* @since 1.2.0
|
||||
* @var string
|
||||
*/
|
||||
public const META_IMAGE_ALT = '_og_image_alt';
|
||||
|
||||
/**
|
||||
* Nonce action for the term edit form.
|
||||
*
|
||||
* @since 1.2.0
|
||||
* @var string
|
||||
*/
|
||||
public const NONCE_ACTION = 'robotstxt_og_term_meta';
|
||||
|
||||
/**
|
||||
* Nonce field name for the term edit form.
|
||||
*
|
||||
* @since 1.2.0
|
||||
* @var string
|
||||
*/
|
||||
public const NONCE_NAME = 'robotstxt_og_term_meta_nonce';
|
||||
|
||||
/**
|
||||
* Initialize hooks.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function init(): void {
|
||||
add_action( 'init', array( $this, 'register_term_meta' ) );
|
||||
add_filter( 'robotstxt_og_taxonomy_image', array( $this, 'filter_taxonomy_image' ), 10, 2 );
|
||||
|
||||
if ( ! is_admin() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $this->get_supported_taxonomies() as $taxonomy ) {
|
||||
add_action( "{$taxonomy}_edit_form_fields", array( $this, 'render_term_fields' ) );
|
||||
add_action(
|
||||
"edited_{$taxonomy}",
|
||||
function ( $term_id ) use ( $taxonomy ): void {
|
||||
$this->save_term_meta( (int) $term_id, $taxonomy );
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of taxonomies the OG fields are exposed on.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return string[] Taxonomy slugs.
|
||||
*/
|
||||
protected function get_supported_taxonomies(): array {
|
||||
$taxonomies = array( 'category', 'post_tag' );
|
||||
|
||||
/**
|
||||
* Filter the taxonomies that expose per-term Open Graph fields.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param string[] $taxonomies Taxonomy slugs.
|
||||
*/
|
||||
return apply_filters( 'robotstxt_og_term_meta_taxonomies', $taxonomies );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register term meta for REST API / block editor access.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_term_meta(): void {
|
||||
$fields = array(
|
||||
self::META_TITLE => 'sanitize_text_field',
|
||||
self::META_IMAGE => 'sanitize_url',
|
||||
self::META_IMAGE_ALT => 'sanitize_text_field',
|
||||
self::META_DESCRIPTION => 'sanitize_textarea_field',
|
||||
);
|
||||
|
||||
foreach ( $this->get_supported_taxonomies() as $taxonomy ) {
|
||||
$cap = $this->get_edit_cap( $taxonomy );
|
||||
|
||||
foreach ( $fields as $key => $sanitize ) {
|
||||
register_term_meta(
|
||||
$taxonomy,
|
||||
$key,
|
||||
array(
|
||||
'type' => 'string',
|
||||
'single' => true,
|
||||
'sanitize_callback' => $sanitize,
|
||||
'auth_callback' => static function () use ( $cap ) {
|
||||
return current_user_can( $cap );
|
||||
},
|
||||
'show_in_rest' => true,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the edit capability for a taxonomy.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param string $taxonomy Taxonomy slug.
|
||||
* @return string Capability name.
|
||||
*/
|
||||
protected function get_edit_cap( string $taxonomy ): string {
|
||||
$tax_obj = get_taxonomy( $taxonomy );
|
||||
|
||||
if ( $tax_obj && isset( $tax_obj->cap->edit_terms ) && '' !== $tax_obj->cap->edit_terms ) {
|
||||
return $tax_obj->cap->edit_terms;
|
||||
}
|
||||
|
||||
return 'manage_categories';
|
||||
}
|
||||
|
||||
/**
|
||||
* Supply the stored term image URL during taxonomy archive resolution.
|
||||
*
|
||||
* Hooked to `robotstxt_og_taxonomy_image`. The resolver still validates the
|
||||
* URL and resolves a compatible format, so a raw stored value is fine.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param string $url Default URL (empty string).
|
||||
* @param int $term_id Term ID.
|
||||
* @return string
|
||||
*/
|
||||
public function filter_taxonomy_image( string $url, $term_id ): string {
|
||||
$term_id = (int) $term_id;
|
||||
|
||||
if ( $term_id <= 0 ) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$stored = get_term_meta( $term_id, self::META_IMAGE, true );
|
||||
$stored = is_string( $stored ) ? $stored : '';
|
||||
|
||||
return '' !== $stored ? $stored : $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the OG fields on the term edit page.
|
||||
*
|
||||
* Fires inside the existing term-edit form-table, so rows are emitted
|
||||
* directly (no wrapping table).
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param WP_Term $term The term being edited.
|
||||
* @return void
|
||||
*/
|
||||
public function render_term_fields( WP_Term $term ): void {
|
||||
wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME );
|
||||
|
||||
$title = $this->get_meta_string( (int) $term->term_id, self::META_TITLE );
|
||||
$image = $this->get_meta_string( (int) $term->term_id, self::META_IMAGE );
|
||||
$alt = $this->get_meta_string( (int) $term->term_id, self::META_IMAGE_ALT );
|
||||
$desc = $this->get_meta_string( (int) $term->term_id, self::META_DESCRIPTION );
|
||||
?>
|
||||
<tr class="form-field robotstxt-og-term-row">
|
||||
<th scope="row">
|
||||
<label for="robotstxt_og_term_title"><?php esc_html_e( 'Custom OG Title', 'robotstxt-og' ); ?></label>
|
||||
</th>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
id="robotstxt_og_term_title"
|
||||
name="robotstxt_og_term_title"
|
||||
value="<?php echo esc_attr( $title ); ?>"
|
||||
class="large-text"
|
||||
/>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Overrides og:title for this term archive. Leave blank to use the term name.', 'robotstxt-og' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="form-field robotstxt-og-term-row">
|
||||
<th scope="row">
|
||||
<label for="robotstxt_og_term_image"><?php esc_html_e( 'OG Image URL', 'robotstxt-og' ); ?></label>
|
||||
</th>
|
||||
<td>
|
||||
<input
|
||||
type="url"
|
||||
id="robotstxt_og_term_image"
|
||||
name="robotstxt_og_term_image"
|
||||
value="<?php echo esc_attr( $image ); ?>"
|
||||
class="large-text"
|
||||
/>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Absolute HTTPS URL for og:image on this term archive. JPEG or PNG recommended.', 'robotstxt-og' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="form-field robotstxt-og-term-row">
|
||||
<th scope="row">
|
||||
<label for="robotstxt_og_term_image_alt"><?php esc_html_e( 'OG Image Alt', 'robotstxt-og' ); ?></label>
|
||||
</th>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
id="robotstxt_og_term_image_alt"
|
||||
name="robotstxt_og_term_image_alt"
|
||||
value="<?php echo esc_attr( $alt ); ?>"
|
||||
class="large-text"
|
||||
/>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Alt text for the term archive og:image. Leave blank to use the media library alt text.', 'robotstxt-og' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="form-field robotstxt-og-term-row">
|
||||
<th scope="row">
|
||||
<label for="robotstxt_og_term_description"><?php esc_html_e( 'Custom OG Description', 'robotstxt-og' ); ?></label>
|
||||
</th>
|
||||
<td>
|
||||
<textarea
|
||||
id="robotstxt_og_term_description"
|
||||
name="robotstxt_og_term_description"
|
||||
class="large-text"
|
||||
rows="3"
|
||||
><?php echo esc_textarea( $desc ); ?></textarea>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Overrides og:description for this term archive. Leave blank to use the term description.', 'robotstxt-og' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the term OG fields.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param int $term_id Term ID.
|
||||
* @param string $taxonomy Taxonomy slug.
|
||||
* @return void
|
||||
*/
|
||||
public function save_term_meta( int $term_id, string $taxonomy ): void {
|
||||
$nonce_raw = filter_input( INPUT_POST, self::NONCE_NAME, FILTER_SANITIZE_FULL_SPECIAL_CHARS );
|
||||
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
|
||||
|
||||
if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! current_user_can( $this->get_edit_cap( $taxonomy ) ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$title_raw = filter_input( INPUT_POST, 'robotstxt_og_term_title', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
|
||||
$title = $title_raw ? sanitize_text_field( wp_unslash( $title_raw ) ) : '';
|
||||
|
||||
$image_raw = filter_input( INPUT_POST, 'robotstxt_og_term_image', FILTER_UNSAFE_RAW );
|
||||
$image = $image_raw ? sanitize_url( wp_unslash( $image_raw ) ) : '';
|
||||
|
||||
$alt_raw = filter_input( INPUT_POST, 'robotstxt_og_term_image_alt', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
|
||||
$alt = $alt_raw ? sanitize_text_field( wp_unslash( $alt_raw ) ) : '';
|
||||
|
||||
$desc_raw = filter_input( INPUT_POST, 'robotstxt_og_term_description', FILTER_UNSAFE_RAW );
|
||||
$desc = $desc_raw ? sanitize_textarea_field( wp_unslash( $desc_raw ) ) : '';
|
||||
|
||||
$this->store_meta( $term_id, self::META_TITLE, $title );
|
||||
$this->store_meta( $term_id, self::META_IMAGE, $image );
|
||||
$this->store_meta( $term_id, self::META_IMAGE_ALT, $alt );
|
||||
$this->store_meta( $term_id, self::META_DESCRIPTION, $desc );
|
||||
}
|
||||
|
||||
/**
|
||||
* Store (or delete) a single term meta value.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param int $term_id Term ID.
|
||||
* @param string $key Meta key.
|
||||
* @param string $value Sanitized value.
|
||||
* @return void
|
||||
*/
|
||||
protected function store_meta( int $term_id, string $key, string $value ): void {
|
||||
if ( '' === trim( $value ) ) {
|
||||
delete_term_meta( $term_id, $key );
|
||||
return;
|
||||
}
|
||||
|
||||
update_term_meta( $term_id, $key, $value );
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a term meta value as a string.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param int $term_id Term ID.
|
||||
* @param string $key Meta key.
|
||||
* @return string
|
||||
*/
|
||||
private function get_meta_string( int $term_id, string $key ): string {
|
||||
$raw = get_term_meta( $term_id, $key, true );
|
||||
|
||||
return is_string( $raw ) ? $raw : '';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue