This commit is contained in:
Javier Casares 2026-08-10 14:03:36 +00:00
commit 3bb9ed33e1
12 changed files with 1762 additions and 409 deletions

View file

@ -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;
}
}

View file

@ -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 );
}
}
}

View file

@ -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 )
);
}
}
}
}

View 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 : '';
}
}