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

@ -223,6 +223,126 @@ class Robotstxt_OG_Admin_Settings {
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_fb_app_id',
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_fb_admins',
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_fb_pages',
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_pinterest_verify',
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_pinterest_nopin',
array(
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false,
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_telegram_channel',
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_slack_app_id',
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_theme_color',
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_hex_color',
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_app_name',
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_web_app_capable',
array(
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false,
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_app_status_bar_style',
array(
'type' => 'string',
'sanitize_callback' => array( $this, 'sanitize_status_bar_style' ),
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_format_detection',
array(
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false,
)
);
// --- General Settings section ---
add_settings_section(
'robotstxt_og_main_section',
@ -294,6 +414,118 @@ class Robotstxt_OG_Admin_Settings {
'robotstxt-og-settings',
'robotstxt_og_social_section'
);
// --- Platform Integration section ---
add_settings_section(
'robotstxt_og_platform_section',
__( 'Platform Integration', 'robotstxt-og' ),
array( $this, 'render_platform_section' ),
'robotstxt-og-settings'
);
add_settings_field(
'robotstxt_og_fb_app_id',
__( 'Facebook App ID', 'robotstxt-og' ),
array( $this, 'render_fb_app_id_field' ),
'robotstxt-og-settings',
'robotstxt_og_platform_section'
);
add_settings_field(
'robotstxt_og_fb_admins',
__( 'Facebook Admins', 'robotstxt-og' ),
array( $this, 'render_fb_admins_field' ),
'robotstxt-og-settings',
'robotstxt_og_platform_section'
);
add_settings_field(
'robotstxt_og_fb_pages',
__( 'Facebook Pages', 'robotstxt-og' ),
array( $this, 'render_fb_pages_field' ),
'robotstxt-og-settings',
'robotstxt_og_platform_section'
);
add_settings_field(
'robotstxt_og_pinterest_verify',
__( 'Pinterest Domain Verification', 'robotstxt-og' ),
array( $this, 'render_pinterest_verify_field' ),
'robotstxt-og-settings',
'robotstxt_og_platform_section'
);
add_settings_field(
'robotstxt_og_pinterest_nopin',
__( 'Disable Pinterest Pinning', 'robotstxt-og' ),
array( $this, 'render_pinterest_nopin_field' ),
'robotstxt-og-settings',
'robotstxt_og_platform_section'
);
add_settings_field(
'robotstxt_og_telegram_channel',
__( 'Telegram Channel', 'robotstxt-og' ),
array( $this, 'render_telegram_channel_field' ),
'robotstxt-og-settings',
'robotstxt_og_platform_section'
);
add_settings_field(
'robotstxt_og_slack_app_id',
__( 'Slack App ID', 'robotstxt-og' ),
array( $this, 'render_slack_app_id_field' ),
'robotstxt-og-settings',
'robotstxt_og_platform_section'
);
// --- Mobile / Web App section ---
add_settings_section(
'robotstxt_og_mobile_section',
__( 'Mobile / Web App', 'robotstxt-og' ),
array( $this, 'render_mobile_section' ),
'robotstxt-og-settings'
);
add_settings_field(
'robotstxt_og_theme_color',
__( 'Theme Color', 'robotstxt-og' ),
array( $this, 'render_theme_color_field' ),
'robotstxt-og-settings',
'robotstxt_og_mobile_section'
);
add_settings_field(
'robotstxt_og_app_name',
__( 'Web App Name', 'robotstxt-og' ),
array( $this, 'render_app_name_field' ),
'robotstxt-og-settings',
'robotstxt_og_mobile_section'
);
add_settings_field(
'robotstxt_og_web_app_capable',
__( 'PWA Standalone Mode', 'robotstxt-og' ),
array( $this, 'render_web_app_capable_field' ),
'robotstxt-og-settings',
'robotstxt_og_mobile_section'
);
add_settings_field(
'robotstxt_og_app_status_bar_style',
__( 'iOS Status Bar Style', 'robotstxt-og' ),
array( $this, 'render_app_status_bar_field' ),
'robotstxt-og-settings',
'robotstxt_og_mobile_section'
);
add_settings_field(
'robotstxt_og_format_detection',
__( 'Disable Auto Phone Detection', 'robotstxt-og' ),
array( $this, 'render_format_detection_field' ),
'robotstxt-og-settings',
'robotstxt_og_mobile_section'
);
}
/**
@ -529,6 +761,262 @@ class Robotstxt_OG_Admin_Settings {
<?php
}
/**
* Render the Platform Integration section description.
*
* @since 1.2.0
*
* @return void
*/
public function render_platform_section(): void {
?>
<p>
<?php esc_html_e( 'Site-wide identity and verification tags for Facebook, Pinterest, Telegram, and Slack. Emitted in addition to the Open Graph / Twitter tags when no SEO plugin is handling them.', 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render a regular-text settings field for a given option.
*
* @since 1.2.0
*
* @param string $option_name Option key.
* @param string $placeholder Input placeholder.
* @param string $description Field description.
* @return void
*/
private function render_text_option( string $option_name, string $placeholder, string $description ): void {
$raw = get_option( $option_name, '' );
$value = is_string( $raw ) ? $raw : '';
?>
<input
type="text"
id="<?php echo esc_attr( $option_name ); ?>"
name="<?php echo esc_attr( $option_name ); ?>"
value="<?php echo esc_attr( $value ); ?>"
placeholder="<?php echo esc_attr( $placeholder ); ?>"
class="regular-text"
/>
<p class="description"><?php echo esc_html( $description ); ?></p>
<?php
}
/**
* Render Facebook App ID field.
*
* @since 1.2.0
*
* @return void
*/
public function render_fb_app_id_field(): void {
$this->render_text_option( 'robotstxt_og_fb_app_id', '123456789012345', __( 'Facebook App ID, used for the fb:app_id meta tag and Insights.', 'robotstxt-og' ) );
}
/**
* Render Facebook Admins field.
*
* @since 1.2.0
*
* @return void
*/
public function render_fb_admins_field(): void {
$this->render_text_option( 'robotstxt_og_fb_admins', '100000000000001,100000000000002', __( 'Comma-separated Facebook user IDs of page admins (fb:admins).', 'robotstxt-og' ) );
}
/**
* Render Facebook Pages field.
*
* @since 1.2.0
*
* @return void
*/
public function render_fb_pages_field(): void {
$this->render_text_option( 'robotstxt_og_fb_pages', '1234567890', __( 'Facebook Page ID or comma-separated IDs (fb:pages).', 'robotstxt-og' ) );
}
/**
* Render Pinterest domain verification field.
*
* @since 1.2.0
*
* @return void
*/
public function render_pinterest_verify_field(): void {
$this->render_text_option( 'robotstxt_og_pinterest_verify', 'abcdef0123456789abcdef', __( 'Pinterest domain verification token (p:domain_verify).', 'robotstxt-og' ) );
}
/**
* Render Pinterest "disable pinning" field.
*
* @since 1.2.0
*
* @return void
*/
public function render_pinterest_nopin_field(): void {
$value = (bool) get_option( 'robotstxt_og_pinterest_nopin', false );
?>
<label for="robotstxt_og_pinterest_nopin">
<input
type="checkbox"
id="robotstxt_og_pinterest_nopin"
name="robotstxt_og_pinterest_nopin"
value="1"
<?php checked( $value ); ?>
/>
<?php esc_html_e( 'Emit the Pinterest "nopin" meta tag to discourage pinning images from this site.', 'robotstxt-og' ); ?>
</label>
<?php
}
/**
* Render Telegram channel field.
*
* @since 1.2.0
*
* @return void
*/
public function render_telegram_channel_field(): void {
$this->render_text_option( 'robotstxt_og_telegram_channel', '@channel', __( 'Telegram channel handle (telegram:channel), including the leading @.', 'robotstxt-og' ) );
}
/**
* Render Slack App ID field.
*
* @since 1.2.0
*
* @return void
*/
public function render_slack_app_id_field(): void {
$this->render_text_option( 'robotstxt_og_slack_app_id', 'A0123456789', __( 'Slack App ID for custom unfurls (slack-app-id).', 'robotstxt-og' ) );
}
/**
* Sanitize the iOS status bar style to a safelist value.
*
* @since 1.2.0
*
* @param mixed $value Raw value.
* @return string
*/
public function sanitize_status_bar_style( $value ): string {
$value = is_string( $value ) ? $value : '';
$allowed = array( '', 'default', 'black', 'black-translucent' );
return in_array( $value, $allowed, true ) ? $value : '';
}
/**
* Render the Mobile / Web App section description.
*
* @since 1.2.0
*
* @return void
*/
public function render_mobile_section(): void {
?>
<p>
<?php esc_html_e( 'Optional mobile and Progressive Web App metadata. Tags are emitted only when configured.', 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render a checkbox settings field for a boolean option.
*
* @since 1.2.0
*
* @param string $option_name Option key.
* @param string $label_text Checkbox label.
* @return void
*/
private function render_checkbox_option( string $option_name, string $label_text ): void {
$value = (bool) get_option( $option_name, false );
?>
<label for="<?php echo esc_attr( $option_name ); ?>">
<input
type="checkbox"
id="<?php echo esc_attr( $option_name ); ?>"
name="<?php echo esc_attr( $option_name ); ?>"
value="1"
<?php checked( $value ); ?>
/>
<?php echo esc_html( $label_text ); ?>
</label>
<?php
}
/**
* Render theme-color field.
*
* @since 1.2.0
*
* @return void
*/
public function render_theme_color_field(): void {
$this->render_text_option( 'robotstxt_og_theme_color', '#ffffff', __( 'Hex color for the browser UI chrome (theme-color).', 'robotstxt-og' ) );
}
/**
* Render web app name field.
*
* @since 1.2.0
*
* @return void
*/
public function render_app_name_field(): void {
$this->render_text_option( 'robotstxt_og_app_name', 'My Site', __( 'Web app name; emitted as application-name and apple-mobile-web-app-title.', 'robotstxt-og' ) );
}
/**
* Render the PWA standalone toggle.
*
* @since 1.2.0
*
* @return void
*/
public function render_web_app_capable_field(): void {
$this->render_checkbox_option( 'robotstxt_og_web_app_capable', __( 'Emit mobile-web-app-capable and apple-mobile-web-app-capable (standalone PWA mode).', 'robotstxt-og' ) );
}
/**
* Render the iOS status bar style dropdown.
*
* @since 1.2.0
*
* @return void
*/
public function render_app_status_bar_field(): void {
$raw = get_option( 'robotstxt_og_app_status_bar_style', '' );
$value = is_string( $raw ) ? $raw : '';
$options = array(
'' => __( 'Default (omit tag)', 'robotstxt-og' ),
'default' => 'default',
'black' => 'black',
'black-translucent' => 'black-translucent',
);
?>
<select name="robotstxt_og_app_status_bar_style" id="robotstxt_og_app_status_bar_style">
<?php foreach ( $options as $val => $label ) : ?>
<option value="<?php echo esc_attr( $val ); ?>" <?php selected( $value, $val ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
<?php
}
/**
* Render the format-detection toggle.
*
* @since 1.2.0
*
* @return void
*/
public function render_format_detection_field(): void {
$this->render_checkbox_option( 'robotstxt_og_format_detection', __( 'Emit format-detection to disable automatic phone-number linking.', 'robotstxt-og' ) );
}
/**
* Render settings page.
*

View file

@ -258,7 +258,7 @@ $page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' );
<?php
if ( ! empty( $test_url_raw ) ) {
$nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_SPECIAL_CHARS );
$nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( wp_verify_nonce( $nonce, 'robotstxt_og_test_url' ) ) {

View file

@ -1,5 +1,24 @@
== Changelog ==
= 1.2.0 =
_Release date: 2026-08-10_
**Added**
* Per-term Open Graph metadata for Categories and Tags on the term edit page (`term.php`): custom OG title, OG image URL, and OG description, exposed on term archives.
* Manual `og:image:alt` override (per-post meta box and per-term), emitted as `og:image:alt` and `twitter:image:alt`.
* Per-post `og:type` override (Default / `website` / `article`); `article:*` sub-tags now emit on the resolved article type.
* Twitter/X: emit `twitter:url` (canonical) and per-post `twitter:creator` (author handle); handles normalised with a leading `@`.
* Platform integration tags (global settings): Facebook `fb:app_id`/`admins`/`pages`, Pinterest `p:domain_verify` + disable-pinning toggle, Telegram `telegram:channel`, Slack `slack-app-id`.
* Mobile / Web App tags (global settings): `theme-color`, web-app name, PWA standalone toggle, iOS status bar style, `format-detection`; Open Graph namespace added to `<html>` via `language_attributes`.
* Uninstall cleanup extended to remove the per-term OG meta and `_og_image_alt` / `_og_type` / `_twitter_creator` (opt-in only).
**Changed**
* Minimum supported WordPress lowered from 6.8 to 4.9.8 (verified by WP-Compat; bound by register_post_meta/register_term_meta at 4.9.8).
* Minimum supported PHP lowered from 8.2 to 8.0 (verified by the PHPCompatibility scan; the code uses 8.0 features only).
= 1.1.0 =
_Release date: 2026-03-28_

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

View file

@ -1,11 +1,11 @@
=== OpenGraph (by ROBOTSTXT) ===
Contributors: javiercasares, robotstxt
Tags: opengraph, open graph, twitter card, social media, seo
Requires at least: 6.8
Requires at least: 4.9.8
Tested up to: 7.0
Stable tag: 1.1.0
Requires PHP: 8.2
Version: 1.1.0
Stable tag: 1.2.0
Requires PHP: 8.0
Version: 1.2.0
License: GPL-3.0-or-later
License URI: https://www.gnu.org/licenses/gpl-3.0.txt
@ -29,10 +29,11 @@ Social media crawlers — Facebook, Twitter/X, LinkedIn, WhatsApp — do not sup
* Full Open Graph meta tag output: `og:title`, `og:type`, `og:url`, `og:description`, `og:site_name`, `og:locale`, `og:image`, `og:image:width`, `og:image:height`, `og:image:type`, `og:image:alt`
* Article-specific tags: `article:published_time`, `article:modified_time`, `article:section`, `article:tag`
* Twitter Card tags: `twitter:card`, `twitter:site`, `twitter:image`
* Twitter Card tags: `twitter:card`, `twitter:site`, `twitter:creator`, `twitter:url`, `twitter:image`, `twitter:image:alt`
* Automatic format detection — AVIF, WebP, GIF, BMP, SVG, TIFF
* HTTP HEAD-based image verification (no file downloads)
* Per-post custom OG title and description override (post editor meta box)
* Per-term custom OG title, image, and description override for Categories and Tags (term edit page)
* Global fallback image URL and homepage-specific image URL
* Cached results in postmeta for performance, with automatic invalidation on featured image change
* Negative caching for failed resolutions (1-hour transient)
@ -163,8 +164,8 @@ Yes. The plugin hooks into `wp_head` for direct tag injection and filters Yoast/
== Compatibility ==
* WordPress: 6.8 - 7.0
* PHP: 8.2 - 8.5
* WordPress: 4.9.8 - 7.0
* PHP: 8.0 - 8.5
* WP-CLI: 2.x
* MariaDB: 10.6+
@ -175,6 +176,20 @@ Yes. The plugin hooks into `wp_head` for direct tag injection and filters Yoast/
== Changelog ==
= 1.2.0 =
_Release date: 2026-08-10_
* Added: Per-term Open Graph metadata for Categories and Tags on the term edit page — custom OG title, OG image URL, and OG description, exposed on term archives.
* Added: Manual `og:image:alt` override (per-post meta box and per-term), emitted as `og:image:alt` and `twitter:image:alt`.
* Added: Per-post `og:type` override (Default / `website` / `article`); `article:*` sub-tags now emit on the resolved article type.
* Added: Twitter/X `twitter:url` (canonical) and per-post `twitter:creator` (author handle); handles normalised with a leading `@`.
* Added: Platform integration tags (global settings) — Facebook `fb:app_id`/`admins`/`pages`, Pinterest `p:domain_verify` + disable-pinning toggle, Telegram `telegram:channel`, Slack `slack-app-id`.
* Added: Mobile / Web App tags (global settings) — `theme-color`, web-app name, PWA standalone toggle, iOS status bar style, `format-detection`; Open Graph namespace added to `<html>`.
* Added: Uninstall cleanup extended to remove the per-term OG meta and `_og_image_alt` / `_og_type` / `_twitter_creator` (opt-in only).
* Changed: Minimum supported WordPress lowered from 6.8 to 4.9.8 (verified by WP-Compat).
* Changed: Minimum supported PHP lowered from 8.2 to 8.0 (verified by the PHPCompatibility scan).
= 1.1.0 =
_Release date: 2026-03-28_
@ -195,7 +210,7 @@ _Release date: 2026-02-18_
* Initial stable release.
* Full Open Graph meta tag output: `og:title`, `og:type`, `og:url`, `og:description`, `og:site_name`, `og:locale`, `og:image`, `og:image:width`, `og:image:height`, `og:image:type`, `og:image:alt`.
* Article-specific tags for singular posts: `article:published_time`, `article:modified_time`, `article:section`, `article:tag`.
* Twitter Card tags: `twitter:card`, `twitter:site`, `twitter:image`.
* Twitter Card tags: `twitter:card`, `twitter:site`, `twitter:creator`, `twitter:url`, `twitter:image`, `twitter:image:alt`.
* Automatic format detection (AVIF, WebP, GIF, BMP, SVG, TIFF) with HTTP HEAD-based JPEG/PNG fallback resolution.
* Postmeta caching with automatic invalidation on featured image change.
* Per-post OG title and description overrides via post editor meta box.

View file

@ -3,9 +3,9 @@
* Plugin Name: OpenGraph (by ROBOTSTXT)
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-og
* Description: Intelligent Open Graph image fallback for social media crawlers. Automatically detects and serves compatible image formats (JPEG/PNG) when modern formats (AVIF/WebP) are used as featured images.
* Version: 1.1.0
* Requires at least: 6.8
* Requires PHP: 8.2
* Version: 1.2.0
* Requires at least: 4.9.8
* Requires PHP: 8.0
* Author: ROBOTSTXT
* Author URI: https://www.robotstxt.es/
* License: GPL v3 or later
@ -17,7 +17,7 @@
* Contributors: javiercasares, robotstxt
*
* @package ROBOTSTXT_OG
* @version 1.1.0
* @version 1.2.0
*/
if ( ! defined( 'ABSPATH' ) ) {
@ -25,7 +25,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
// Define plugin constants.
define( 'ROBOTSTXT_OG_VERSION', '1.1.0' );
define( 'ROBOTSTXT_OG_VERSION', '1.2.0' );
define( 'ROBOTSTXT_OG_PATH', plugin_dir_path( __FILE__ ) );
define( 'ROBOTSTXT_OG_URL', plugin_dir_url( __FILE__ ) );
define( 'ROBOTSTXT_OG_BASENAME', plugin_basename( __FILE__ ) );

View file

@ -22,362 +22,364 @@ if ( ! class_exists( 'Robotstxt_Updater' ) ) {
*/
class Robotstxt_Updater {
/**
* Plugin file path.
*
* @var string
*/
private string $plugin_file_path;
/**
* Plugin file path.
*
* @var string
*/
private string $plugin_file_path;
/**
* Plugin basename (e.g., 'my-plugin/my-plugin.php').
*
* @var string
*/
private string $plugin_basename;
/**
* Plugin basename (e.g., 'my-plugin/my-plugin.php').
*
* @var string
*/
private string $plugin_basename;
/**
* Plugin slug (directory name).
*
* @var string
*/
private string $plugin_slug;
/**
* Plugin slug (directory name).
*
* @var string
*/
private string $plugin_slug;
/**
* Remote JSON URL.
*
* @var string
*/
private string $json_url;
/**
* Remote JSON URL.
*
* @var string
*/
private string $json_url;
/**
* Cache key.
*
* @var string
*/
private string $cache_key;
/**
* Cache key.
*
* @var string
*/
private string $cache_key;
/**
* Plugin headers.
*
* @var array<string, mixed>
*/
private array $plugin_data;
/**
* Plugin headers.
*
* @var array<string, mixed>
*/
private array $plugin_data;
/**
* Initialize the updater.
*
* Usage in your main plugin file:
* require_once __DIR__ . '/robotstxt-updater.php';
* Robotstxt_Updater::init( __FILE__ );
*
* @param string $plugin_file_path Absolute path to the main plugin file.
*/
public static function init( string $plugin_file_path ): void {
$instance = new self( $plugin_file_path );
$instance->register();
}
/**
* Constructor.
*
* @param string $plugin_file_path Absolute path to the main plugin file.
*/
private function __construct( string $plugin_file_path ) {
$this->plugin_file_path = $plugin_file_path;
$this->plugin_basename = plugin_basename( $plugin_file_path );
$this->plugin_slug = dirname( $this->plugin_basename );
$this->plugin_data = $this->get_plugin_data();
$this->json_url = $this->build_json_url();
$this->cache_key = 'robotstxt_updater_' . md5( $this->plugin_basename );
}
/**
* Register WordPress hooks.
*/
private function register(): void {
add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'inject_update_info' ) );
add_filter( 'plugins_api', array( $this, 'provide_plugin_details' ), 10, 3 );
add_action( 'admin_init', array( $this, 'handle_cache_clear' ) );
add_action( 'robotstxt_updater_clear_cache', array( $this, 'clear_cache' ) );
}
/**
* Get plugin headers.
*
* @return array<string, mixed> Plugin data.
*/
private function get_plugin_data(): array {
if ( ! function_exists( 'get_plugin_data' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
/**
* Initialize the updater.
*
* Usage in your main plugin file:
* require_once __DIR__ . '/robotstxt-updater.php';
* Robotstxt_Updater::init( __FILE__ );
*
* @param string $plugin_file_path Absolute path to the main plugin file.
*/
public static function init( string $plugin_file_path ): void {
$instance = new self( $plugin_file_path );
$instance->register();
}
return get_plugin_data( $this->plugin_file_path, false, false );
}
/**
* Constructor.
*
* @param string $plugin_file_path Absolute path to the main plugin file.
*/
private function __construct( string $plugin_file_path ) {
$this->plugin_file_path = $plugin_file_path;
$this->plugin_basename = plugin_basename( $plugin_file_path );
$this->plugin_slug = dirname( $this->plugin_basename );
$this->plugin_data = $this->get_plugin_data();
$this->json_url = $this->build_json_url();
$this->cache_key = 'robotstxt_updater_' . md5( $this->plugin_basename );
}
/**
* Build JSON URL from plugin headers.
*
* Tries to use "Gitea Plugin URI" header to construct the URL.
* Falls back to Plugin URI if Gitea URI is not available.
*
* @return string JSON URL.
*/
private function build_json_url(): string {
// Try Gitea Plugin URI (format: "OWNER/REPO" or full URL).
if ( ! empty( $this->plugin_data['Gitea Plugin URI'] ) && is_string( $this->plugin_data['Gitea Plugin URI'] ) ) {
$gitea_uri = $this->plugin_data['Gitea Plugin URI'];
/**
* Register WordPress hooks.
*/
private function register(): void {
add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'inject_update_info' ) );
add_filter( 'plugins_api', array( $this, 'provide_plugin_details' ), 10, 3 );
add_action( 'admin_init', array( $this, 'handle_cache_clear' ) );
add_action( 'robotstxt_updater_clear_cache', array( $this, 'clear_cache' ) );
}
// If it's already a full URL, use it.
if ( str_starts_with( $gitea_uri, 'http' ) ) {
// Extract base URL and construct JSON path.
return rtrim( $gitea_uri, '/' ) . '/raw/branch/main/update.json';
/**
* Get plugin headers.
*
* @return array<string, mixed> Plugin data.
*/
private function get_plugin_data(): array {
if ( ! function_exists( 'get_plugin_data' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
// If it's in format "OWNER/REPO", construct full URL.
if ( preg_match( '#^[^/]+/[^/]+$#', $gitea_uri ) ) {
return "https://git.robotstxt.es/{$gitea_uri}/raw/branch/main/update.json";
}
return get_plugin_data( $this->plugin_file_path, false, false );
}
// Fallback: try to extract from Plugin URI.
if ( ! empty( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] ) ) {
$plugin_uri = $this->plugin_data['PluginURI'];
if ( str_contains( $plugin_uri, 'git.robotstxt.es' ) ) {
return rtrim( $plugin_uri, '/' ) . '/raw/branch/main/update.json';
}
}
/**
* Build JSON URL from plugin headers.
*
* Tries to use "Gitea Plugin URI" header to construct the URL.
* Falls back to Plugin URI if Gitea URI is not available.
*
* @return string JSON URL.
*/
private function build_json_url(): string {
// Try Gitea Plugin URI (format: "OWNER/REPO" or full URL).
if ( ! empty( $this->plugin_data['Gitea Plugin URI'] ) && is_string( $this->plugin_data['Gitea Plugin URI'] ) ) {
$gitea_uri = $this->plugin_data['Gitea Plugin URI'];
// Last resort: construct from plugin slug.
return "https://git.robotstxt.es/ROBOTSTXT/{$this->plugin_slug}/raw/branch/main/update.json";
}
/**
* Inject update info into WP's plugin update transient.
*
* @param object|mixed $transient The update_plugins transient.
*
* @return object The modified transient.
*/
public function inject_update_info( $transient ) {
if ( ! ( $transient instanceof stdClass ) ) {
$transient = new stdClass();
}
if ( empty( $transient->checked ) || ! is_array( $transient->checked ) ) {
return $transient;
}
if ( empty( $transient->checked[ $this->plugin_basename ] ) ) {
return $transient;
}
$current_version = $transient->checked[ $this->plugin_basename ];
$remote = $this->get_remote_data();
if ( empty( $remote['version'] ) || empty( $remote['download_url'] ) ) {
return $transient;
}
if ( ! $this->is_compatible( $remote ) ) {
return $transient;
}
if ( is_string( $current_version ) && is_string( $remote['version'] ) && version_compare( $remote['version'], $current_version, '>' ) ) {
$update = (object) array(
'slug' => $remote['slug'] ?? $this->plugin_slug,
'plugin' => $this->plugin_basename,
'new_version' => $remote['version'],
'url' => $remote['homepage'] ?? $this->plugin_data['PluginURI'] ?? '',
'package' => $remote['download_url'],
'tested' => $remote['tested'] ?? '',
'requires' => $remote['requires'] ?? '',
'requires_php' => $remote['requires_php'] ?? '',
);
$transient->response[ $this->plugin_basename ] = $update;
}
return $transient;
}
/**
* Provide "View details" modal content.
*
* @param false|object|array<string, mixed> $result The result object or array.
* @param string $action The type of information being requested.
* @param object $args Plugin API arguments.
*
* @return false|object|array<string, mixed> The plugin information object or false.
*/
public function provide_plugin_details( $result, string $action, object $args ) {
if ( 'plugin_information' !== $action ) {
return $result;
}
if ( empty( $args->slug ) || $args->slug !== $this->plugin_slug ) {
return $result;
}
$remote = $this->get_remote_data();
if ( empty( $remote['version'] ) ) {
return $result;
}
return (object) array(
'name' => $remote['name'] ?? $this->plugin_data['Name'] ?? $this->plugin_slug,
'slug' => $remote['slug'] ?? $this->plugin_slug,
'version' => $remote['version'],
'author' => $remote['author'] ?? $this->plugin_data['Author'] ?? '',
'homepage' => $remote['homepage'] ?? $this->plugin_data['PluginURI'] ?? '',
'requires' => $remote['requires'] ?? '',
'tested' => $remote['tested'] ?? '',
'requires_php' => $remote['requires_php'] ?? '',
'sections' => array(
'description' => $remote['description'] ?? $this->plugin_data['Description'] ?? '',
'changelog' => $remote['changelog'] ?? '',
),
'download_link' => $remote['download_url'] ?? '',
);
}
/**
* Get remote data with caching and HMAC signature verification.
*
* @return array<string, mixed> Remote data.
*/
private function get_remote_data(): array {
$cached = get_site_transient( $this->cache_key );
// Verify HMAC signature if AUTH_SALT is defined and cache has signature.
if ( false !== $cached && defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) {
if ( is_array( $cached ) && isset( $cached['signature'], $cached['data'] ) ) {
$expected_sig = hash_hmac( 'sha256', $this->cache_key . serialize( $cached['data'] ), AUTH_SALT );
if ( is_string( $cached['signature'] ) && hash_equals( $expected_sig, $cached['signature'] ) ) {
// Signature valid, return data.
return is_array( $cached['data'] ) ? $cached['data'] : array();
// If it's already a full URL, use it.
if ( str_starts_with( $gitea_uri, 'http' ) ) {
// Extract base URL and construct JSON path.
return rtrim( $gitea_uri, '/' ) . '/raw/branch/main/update.json';
}
// Signature invalid, delete corrupted cache.
delete_site_transient( $this->cache_key );
$cached = false;
// If it's in format "OWNER/REPO", construct full URL.
if ( preg_match( '#^[^/]+/[^/]+$#', $gitea_uri ) ) {
return "https://git.robotstxt.es/{$gitea_uri}/raw/branch/main/update.json";
}
}
// Fallback: try to extract from Plugin URI.
if ( ! empty( $this->plugin_data['PluginURI'] ) && is_string( $this->plugin_data['PluginURI'] ) ) {
$plugin_uri = $this->plugin_data['PluginURI'];
if ( str_contains( $plugin_uri, 'git.robotstxt.es' ) ) {
return rtrim( $plugin_uri, '/' ) . '/raw/branch/main/update.json';
}
}
// Last resort: construct from plugin slug.
return "https://git.robotstxt.es/ROBOTSTXT/{$this->plugin_slug}/raw/branch/main/update.json";
}
// If no valid cache, fetch fresh data.
if ( false === $cached ) {
$remote = $this->fetch_json();
/**
* Inject update info into WP's plugin update transient.
*
* @param object|mixed $transient The update_plugins transient.
*
* @return object The modified transient.
*/
public function inject_update_info( $transient ) {
if ( ! ( $transient instanceof stdClass ) ) {
$transient = new stdClass();
}
// Store with HMAC signature if AUTH_SALT is available.
if ( defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) {
$payload = array(
'data' => $remote ?: array(),
'timestamp' => time(),
'signature' => hash_hmac( 'sha256', $this->cache_key . serialize( $remote ?: array() ), AUTH_SALT ),
if ( empty( $transient->checked ) || ! is_array( $transient->checked ) ) {
return $transient;
}
if ( empty( $transient->checked[ $this->plugin_basename ] ) ) {
return $transient;
}
$current_version = $transient->checked[ $this->plugin_basename ];
$remote = $this->get_remote_data();
if ( empty( $remote['version'] ) || empty( $remote['download_url'] ) ) {
return $transient;
}
if ( ! $this->is_compatible( $remote ) ) {
return $transient;
}
if ( is_string( $current_version ) && is_string( $remote['version'] ) && version_compare( $remote['version'], $current_version, '>' ) ) {
$update = (object) array(
'slug' => $remote['slug'] ?? $this->plugin_slug,
'plugin' => $this->plugin_basename,
'new_version' => $remote['version'],
'url' => $remote['homepage'] ?? $this->plugin_data['PluginURI'] ?? '',
'package' => $remote['download_url'],
'tested' => $remote['tested'] ?? '',
'requires' => $remote['requires'] ?? '',
'requires_php' => $remote['requires_php'] ?? '',
);
set_site_transient( $this->cache_key, $payload, 6 * HOUR_IN_SECONDS );
} else {
// Fallback to standard caching.
set_site_transient( $this->cache_key, $remote ?: array(), 6 * HOUR_IN_SECONDS );
$transient->response[ $this->plugin_basename ] = $update;
}
return $remote;
return $transient;
}
// Legacy cache format without signature (backward compatibility).
return is_array( $cached ) ? $cached : array();
}
/**
* Provide "View details" modal content.
*
* @param false|object|array<string, mixed> $result The result object or array.
* @param string $action The type of information being requested.
* @param object $args Plugin API arguments.
*
* @return false|object|array<string, mixed> The plugin information object or false.
*/
public function provide_plugin_details( $result, string $action, object $args ) {
if ( 'plugin_information' !== $action ) {
return $result;
}
/**
* Fetch JSON from remote URL.
*
* @return array<string, mixed> Decoded JSON data.
*/
private function fetch_json(): array {
$response = wp_remote_get(
$this->json_url,
array(
'timeout' => 10,
'headers' => array(
'Accept' => 'application/json',
if ( empty( $args->slug ) || $args->slug !== $this->plugin_slug ) {
return $result;
}
$remote = $this->get_remote_data();
if ( empty( $remote['version'] ) ) {
return $result;
}
return (object) array(
'name' => $remote['name'] ?? $this->plugin_data['Name'] ?? $this->plugin_slug,
'slug' => $remote['slug'] ?? $this->plugin_slug,
'version' => $remote['version'],
'author' => $remote['author'] ?? $this->plugin_data['Author'] ?? '',
'homepage' => $remote['homepage'] ?? $this->plugin_data['PluginURI'] ?? '',
'requires' => $remote['requires'] ?? '',
'tested' => $remote['tested'] ?? '',
'requires_php' => $remote['requires_php'] ?? '',
'sections' => array(
'description' => $remote['description'] ?? $this->plugin_data['Description'] ?? '',
'changelog' => $remote['changelog'] ?? '',
),
)
);
if ( is_wp_error( $response ) ) {
return array();
'download_link' => $remote['download_url'] ?? '',
);
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( $code < 200 || $code >= 300 ) {
return array();
}
/**
* Get remote data with caching and HMAC signature verification.
*
* @return array<string, mixed> Remote data.
*/
private function get_remote_data(): array {
$cached = get_site_transient( $this->cache_key );
$body = wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
// Verify HMAC signature if AUTH_SALT is defined and cache has signature.
if ( false !== $cached && defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) {
if ( is_array( $cached ) && isset( $cached['signature'], $cached['data'] ) ) {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- HMAC signature over a trusted local array; not deserialization of untrusted input.
$expected_sig = hash_hmac( 'sha256', $this->cache_key . serialize( $cached['data'] ), AUTH_SALT );
return is_array( $data ) ? $data : array();
}
if ( is_string( $cached['signature'] ) && hash_equals( $expected_sig, $cached['signature'] ) ) {
// Signature valid, return data.
return is_array( $cached['data'] ) ? $cached['data'] : array();
}
/**
* Check compatibility.
*
* @param array<string, mixed> $remote Remote data.
*
* @return bool True if compatible.
*/
private function is_compatible( array $remote ): bool {
if ( ! empty( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ) {
if ( version_compare( PHP_VERSION, $remote['requires_php'], '<' ) ) {
return false;
// Signature invalid, delete corrupted cache.
delete_site_transient( $this->cache_key );
$cached = false;
}
}
}
if ( ! empty( $remote['requires'] ) && is_string( $remote['requires'] ) ) {
if ( version_compare( get_bloginfo( 'version' ), $remote['requires'], '<' ) ) {
return false;
// If no valid cache, fetch fresh data.
if ( false === $cached ) {
$remote = $this->fetch_json();
// Store with HMAC signature if AUTH_SALT is available.
if ( defined( 'AUTH_SALT' ) && '' !== AUTH_SALT ) {
$payload = array(
'data' => $remote,
'timestamp' => time(),
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- HMAC signature over a trusted local array; not deserialization of untrusted input.
'signature' => hash_hmac( 'sha256', $this->cache_key . serialize( $remote ), AUTH_SALT ),
);
set_site_transient( $this->cache_key, $payload, 6 * HOUR_IN_SECONDS );
} else {
// Fallback to standard caching.
set_site_transient( $this->cache_key, $remote, 6 * HOUR_IN_SECONDS );
}
return $remote;
}
// Legacy cache format without signature (backward compatibility).
return is_array( $cached ) ? $cached : array();
}
return true;
}
/**
* Fetch JSON from remote URL.
*
* @return array<string, mixed> Decoded JSON data.
*/
private function fetch_json(): array {
$response = wp_remote_get(
$this->json_url,
array(
'timeout' => 10,
'headers' => array(
'Accept' => 'application/json',
),
)
);
/**
* Handle manual cache clear via URL parameter.
*/
public function handle_cache_clear(): void {
// Check if this is a cache clear request first.
$clear_cache = filter_input( INPUT_GET, 'robotstxt_clear_update_cache', FILTER_UNSAFE_RAW );
if ( null === $clear_cache ) {
return;
if ( is_wp_error( $response ) ) {
return array();
}
$code = (int) wp_remote_retrieve_response_code( $response );
if ( $code < 200 || $code >= 300 ) {
return array();
}
$body = wp_remote_retrieve_body( $response );
$data = json_decode( $body, true );
return is_array( $data ) ? $data : array();
}
// This is a cache clear request - now verify nonce.
$nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
/**
* Check compatibility.
*
* @param array<string, mixed> $remote Remote data.
*
* @return bool True if compatible.
*/
private function is_compatible( array $remote ): bool {
if ( ! empty( $remote['requires_php'] ) && is_string( $remote['requires_php'] ) ) {
if ( version_compare( PHP_VERSION, $remote['requires_php'], '<' ) ) {
return false;
}
}
if ( ! wp_verify_nonce( $nonce, 'robotstxt_clear_update_cache' ) ) {
wp_die( esc_html__( 'Security check failed', 'robotstxt-og' ) );
if ( ! empty( $remote['requires'] ) && is_string( $remote['requires'] ) ) {
if ( version_compare( get_bloginfo( 'version' ), $remote['requires'], '<' ) ) {
return false;
}
}
return true;
}
// Check permissions.
if ( ! current_user_can( 'update_plugins' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) );
/**
* Handle manual cache clear via URL parameter.
*/
public function handle_cache_clear(): void {
// Check if this is a cache clear request first.
$clear_cache = filter_input( INPUT_GET, 'robotstxt_clear_update_cache', FILTER_UNSAFE_RAW );
if ( null === $clear_cache ) {
return;
}
// This is a cache clear request - now verify nonce.
$nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'robotstxt_clear_update_cache' ) ) {
wp_die( esc_html__( 'Security check failed', 'robotstxt-og' ) );
}
// Check permissions.
if ( ! current_user_can( 'update_plugins' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) );
}
$this->clear_cache();
wp_safe_redirect( remove_query_arg( array( 'robotstxt_clear_update_cache', '_wpnonce' ) ) );
exit;
}
$this->clear_cache();
wp_safe_redirect( remove_query_arg( array( 'robotstxt_clear_update_cache', '_wpnonce' ) ) );
exit;
}
/**
* Clear update cache.
*/
public function clear_cache(): void {
delete_site_transient( $this->cache_key );
delete_site_transient( 'update_plugins' );
}
/**
* Clear update cache.
*/
public function clear_cache(): void {
delete_site_transient( $this->cache_key );
delete_site_transient( 'update_plugins' );
}
}
}

View file

@ -24,40 +24,56 @@ function robotstxt_og_uninstall_cleanup(): void {
return;
}
// Delete all plugin options.
delete_option( 'robotstxt_og_fallback_image' );
delete_option( 'robotstxt_og_homepage_image' );
delete_option( 'robotstxt_og_enable_facebook' );
delete_option( 'robotstxt_og_enable_twitter' );
delete_option( 'robotstxt_og_twitter_card_type' );
delete_option( 'robotstxt_og_twitter_site' );
delete_option( 'robotstxt_og_delete_data_on_uninstall' );
// All plugin options (general, social, platform integration, mobile/web-app).
$options = array(
'robotstxt_og_fallback_image',
'robotstxt_og_homepage_image',
'robotstxt_og_enable_facebook',
'robotstxt_og_enable_twitter',
'robotstxt_og_twitter_card_type',
'robotstxt_og_twitter_site',
'robotstxt_og_fb_app_id',
'robotstxt_og_fb_admins',
'robotstxt_og_fb_pages',
'robotstxt_og_pinterest_verify',
'robotstxt_og_pinterest_nopin',
'robotstxt_og_telegram_channel',
'robotstxt_og_slack_app_id',
'robotstxt_og_theme_color',
'robotstxt_og_app_name',
'robotstxt_og_web_app_capable',
'robotstxt_og_app_status_bar_style',
'robotstxt_og_format_detection',
'robotstxt_og_delete_data_on_uninstall',
);
foreach ( $options as $option_name ) {
delete_option( $option_name );
}
// Delete all cached postmeta (fallback URLs and per-post OG overrides).
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// Bulk delete during uninstall — no WP API exists to delete all postmeta rows by key at once.
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_og_image_fallback_url'
)
);
foreach ( array( '_og_image_fallback_url', '_og_title', '_og_description', '_og_image_alt', '_og_type', '_twitter_creator' ) as $post_meta_key ) {
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
$post_meta_key
)
);
}
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_og_title'
)
);
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_og_description'
)
);
// Delete all term-level OG meta (per-term overrides + cached taxonomy fallback).
foreach ( array( '_og_title', '_og_image', '_og_description', '_og_image_alt', '_og_image_fallback_url' ) as $term_meta_key ) {
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->termmeta} WHERE meta_key = %s",
$term_meta_key
)
);
}
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// Clear related transients.
@ -75,37 +91,31 @@ function robotstxt_og_uninstall_cleanup(): void {
switch_to_blog( (int) $site->blog_id );
// Delete site-specific options.
delete_option( 'robotstxt_og_fallback_image' );
delete_option( 'robotstxt_og_homepage_image' );
delete_option( 'robotstxt_og_enable_facebook' );
delete_option( 'robotstxt_og_enable_twitter' );
delete_option( 'robotstxt_og_twitter_card_type' );
delete_option( 'robotstxt_og_twitter_site' );
delete_option( 'robotstxt_og_delete_data_on_uninstall' );
foreach ( $options as $option_name ) {
delete_option( $option_name );
}
// Delete site-specific postmeta.
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// Bulk delete during uninstall — no WP API exists to delete all postmeta rows by key at once.
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_og_image_fallback_url'
)
);
foreach ( array( '_og_image_fallback_url', '_og_title', '_og_description', '_og_image_alt', '_og_type', '_twitter_creator' ) as $post_meta_key ) {
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
$post_meta_key
)
);
}
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_og_title'
)
);
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s",
'_og_description'
)
);
// Delete all term-level OG meta (per-term overrides + cached taxonomy fallback).
foreach ( array( '_og_title', '_og_image', '_og_description', '_og_image_alt', '_og_image_fallback_url' ) as $term_meta_key ) {
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->termmeta} WHERE meta_key = %s",
$term_meta_key
)
);
}
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
// Clear site-specific transients.

View file

@ -1,20 +1,20 @@
{
"name": "OpenGraph (by ROBOTSTXT)",
"slug": "robotstxt-og",
"version": "1.1.0",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-og/releases/download/1.1.0/robotstxt-og-1.1.0.zip",
"requires": "6.8",
"requires_php": "8.2",
"version": "1.2.0",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-og/releases/download/1.2.0/robotstxt-og-1.2.0.zip",
"requires": "4.9.8",
"requires_php": "8.0",
"tested": "7.0",
"last_updated": "2026-03-28",
"last_updated": "2026-08-10",
"author": "ROBOTSTXT",
"author_profile": "https://www.robotstxt.es/",
"homepage": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-og",
"description": "Intelligent Open Graph meta tags with automatic image fallback from AVIF/WebP to JPEG/PNG for social media crawlers. Outputs a complete set of og:* and twitter:* meta tags, detects incompatible image formats, and resolves JPEG/PNG alternatives via HTTP HEAD requests.",
"changelog": "<h3>1.1.0 &#8212; 2026-03-28</h3><ul><li><strong>Fixed:</strong> Fatal TypeError in handle_thumbnail_change(): the deleted_post_meta action passes an array of meta IDs as its first argument. The method signature now accepts int|array to handle both updated_post_meta and deleted_post_meta correctly.</li><li><strong>Security:</strong> Added SSRF protection to all outbound HTTP HEAD requests via is_safe_url() &#8212; private and reserved IP ranges are now blocked.</li><li><strong>Security:</strong> Replaced deprecated FILTER_SANITIZE_SPECIAL_CHARS with FILTER_SANITIZE_FULL_SPECIAL_CHARS throughout.</li><li><strong>Added:</strong> GDPR Privacy API support &#8212; custom OG title and description post meta are now included in WordPress personal data export and erase requests.</li><li><strong>Changed:</strong> Settings page and REST API now require edit_others_posts capability, allowing editors to manage OG settings and refresh fallback images.</li><li><strong>Changed:</strong> Minimum supported WordPress version raised to 6.8. Network: true header added.</li><li><strong>Added:</strong> PHPUnit test suite with Brain\\Monkey; covers SSRF logic, image format detection, and cache clearing.</li></ul><h3>1.0.0 &#8212; 2026-02-18</h3><ul><li><strong>Initial Release:</strong> First stable release of OpenGraph (by ROBOTSTXT).</li></ul>",
"changelog": "<h3>1.2.0 &#8212; 2026-08-10</h3><ul><li><strong>Added:</strong> Per-term Open Graph metadata for Categories and Tags (term edit page) &#8212; custom OG title, image, and description, exposed on term archives.</li><li><strong>Added:</strong> Manual og:image:alt override (per-post/term), emitted as og:image:alt and twitter:image:alt.</li><li><strong>Added:</strong> Per-post og:type override (Default / website / article); article:* sub-tags now emit on the resolved article type.</li><li><strong>Added:</strong> Twitter/X twitter:url (canonical) and per-post twitter:creator (author handle).</li><li><strong>Added:</strong> Platform integration tags (global settings) &#8212; Facebook fb:app_id/admins/pages, Pinterest p:domain_verify + disable-pinning toggle, Telegram telegram:channel, Slack slack-app-id.</li><li><strong>Added:</strong> Mobile / Web App tags (global settings) &#8212; theme-color, web-app name, PWA standalone toggle, iOS status bar style, format-detection; Open Graph namespace added to &lt;html&gt;.</li></ul><h3>1.1.0 &#8212; 2026-03-28</h3><ul><li><strong>Fixed:</strong> Fatal TypeError in handle_thumbnail_change(): the deleted_post_meta action passes an array of meta IDs as its first argument. The method signature now accepts int|array to handle both updated_post_meta and deleted_post_meta correctly.</li><li><strong>Security:</strong> Added SSRF protection to all outbound HTTP HEAD requests via is_safe_url() &#8212; private and reserved IP ranges are now blocked.</li><li><strong>Security:</strong> Replaced deprecated FILTER_SANITIZE_SPECIAL_CHARS with FILTER_SANITIZE_FULL_SPECIAL_CHARS throughout.</li><li><strong>Added:</strong> GDPR Privacy API support &#8212; custom OG title and description post meta are now included in WordPress personal data export and erase requests.</li><li><strong>Changed:</strong> Settings page and REST API now require edit_others_posts capability, allowing editors to manage OG settings and refresh fallback images.</li><li><strong>Changed:</strong> Minimum supported WordPress version raised to 6.8. Network: true header added.</li><li><strong>Added:</strong> PHPUnit test suite with Brain\\Monkey; covers SSRF logic, image format detection, and cache clearing.</li></ul><h3>1.0.0 &#8212; 2026-02-18</h3><ul><li><strong>Initial Release:</strong> First stable release of OpenGraph (by ROBOTSTXT).</li></ul>",
"sections": {
"description": "<p>Social media crawlers &#8212; Facebook, Twitter/X, LinkedIn, WhatsApp &#8212; do not support modern image formats such as AVIF and WebP. When a post&#8217;s featured image uses one of these formats, the platform shows a broken or missing image preview.</p><p><strong>OpenGraph (by ROBOTSTXT)</strong> outputs a complete set of Open Graph and Twitter Card meta tags and solves this automatically. It detects whether each post&#8217;s featured image is in a supported format and, when needed, resolves and caches a compatible JPEG or PNG alternative.</p><ul><li>Full og:* and twitter:* meta tag output</li><li>Automatic AVIF/WebP/GIF/BMP/SVG/TIFF detection with JPEG/PNG fallback</li><li>Per-post OG title and description overrides via post editor meta box</li><li>Global fallback image URL and homepage-specific image URL</li><li>Yoast SEO and RankMath integration (no duplicate tags)</li><li>Postmeta caching with automatic invalidation</li><li>Admin panel with Settings, Tools, and Diagnostics tabs</li><li>WP-CLI commands and REST API endpoints</li><li>Multisite compatible</li></ul>",
"changelog": "<h3>1.1.0 &#8212; 2026-03-28</h3><ul><li><strong>Fixed:</strong> Fatal TypeError in handle_thumbnail_change(): the deleted_post_meta action passes an array of meta IDs as its first argument. The method signature now accepts int|array to handle both updated_post_meta and deleted_post_meta correctly.</li><li><strong>Security:</strong> Added SSRF protection to all outbound HTTP HEAD requests via is_safe_url() &#8212; private and reserved IP ranges are now blocked.</li><li><strong>Security:</strong> Replaced deprecated FILTER_SANITIZE_SPECIAL_CHARS with FILTER_SANITIZE_FULL_SPECIAL_CHARS throughout.</li><li><strong>Added:</strong> GDPR Privacy API support &#8212; custom OG title and description post meta are now included in WordPress personal data export and erase requests.</li><li><strong>Changed:</strong> Settings page and REST API now require edit_others_posts capability, allowing editors to manage OG settings and refresh fallback images.</li><li><strong>Changed:</strong> Minimum supported WordPress version raised to 6.8. Network: true header added.</li><li><strong>Added:</strong> PHPUnit test suite with Brain\\Monkey; covers SSRF logic, image format detection, and cache clearing.</li></ul><h3>1.0.0 &#8212; 2026-02-18</h3><ul><li><strong>Initial Release:</strong> First stable release of OpenGraph (by ROBOTSTXT).</li></ul>"
"changelog": "<h3>1.2.0 &#8212; 2026-08-10</h3><ul><li><strong>Added:</strong> Per-term Open Graph metadata for Categories and Tags (term edit page) &#8212; custom OG title, image, and description, exposed on term archives.</li><li><strong>Added:</strong> Manual og:image:alt override (per-post/term), emitted as og:image:alt and twitter:image:alt.</li><li><strong>Added:</strong> Per-post og:type override (Default / website / article); article:* sub-tags now emit on the resolved article type.</li><li><strong>Added:</strong> Twitter/X twitter:url (canonical) and per-post twitter:creator (author handle).</li><li><strong>Added:</strong> Platform integration tags (global settings) &#8212; Facebook fb:app_id/admins/pages, Pinterest p:domain_verify + disable-pinning toggle, Telegram telegram:channel, Slack slack-app-id.</li><li><strong>Added:</strong> Mobile / Web App tags (global settings) &#8212; theme-color, web-app name, PWA standalone toggle, iOS status bar style, format-detection; Open Graph namespace added to &lt;html&gt;.</li></ul><h3>1.1.0 &#8212; 2026-03-28</h3><ul><li><strong>Fixed:</strong> Fatal TypeError in handle_thumbnail_change(): the deleted_post_meta action passes an array of meta IDs as its first argument. The method signature now accepts int|array to handle both updated_post_meta and deleted_post_meta correctly.</li><li><strong>Security:</strong> Added SSRF protection to all outbound HTTP HEAD requests via is_safe_url() &#8212; private and reserved IP ranges are now blocked.</li><li><strong>Security:</strong> Replaced deprecated FILTER_SANITIZE_SPECIAL_CHARS with FILTER_SANITIZE_FULL_SPECIAL_CHARS throughout.</li><li><strong>Added:</strong> GDPR Privacy API support &#8212; custom OG title and description post meta are now included in WordPress personal data export and erase requests.</li><li><strong>Changed:</strong> Settings page and REST API now require edit_others_posts capability, allowing editors to manage OG settings and refresh fallback images.</li><li><strong>Changed:</strong> Minimum supported WordPress version raised to 6.8. Network: true header added.</li><li><strong>Added:</strong> PHPUnit test suite with Brain\\Monkey; covers SSRF logic, image format detection, and cache clearing.</li></ul><h3>1.0.0 &#8212; 2026-02-18</h3><ul><li><strong>Initial Release:</strong> First stable release of OpenGraph (by ROBOTSTXT).</li></ul>"
},
"banners": {
"low": "",