diff --git a/admin/class-robotstxt-og-admin-settings.php b/admin/class-robotstxt-og-admin-settings.php index 540a298..9376ccd 100644 --- a/admin/class-robotstxt-og-admin-settings.php +++ b/admin/class-robotstxt-og-admin-settings.php @@ -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 { +

+ +

+ + +

+ 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 ); + ?> + + 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 { + ?> +

+ +

+ + + 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', + ); + ?> + + render_checkbox_option( 'robotstxt_og_format_detection', __( 'Emit format-detection to disable automatic phone-number linking.', 'robotstxt-og' ) ); + } + /** * Render settings page. * diff --git a/admin/views/settings-page.php b/admin/views/settings-page.php index 3a3d54d..7a88271 100644 --- a/admin/views/settings-page.php +++ b/admin/views/settings-page.php @@ -258,7 +258,7 @@ $page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' ); ` 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_ diff --git a/includes/class-robotstxt-og-image-fallback.php b/includes/class-robotstxt-og-image-fallback.php index f9825ff..abae10c 100644 --- a/includes/class-robotstxt-og-image-fallback.php +++ b/includes/class-robotstxt-og-image-fallback.php @@ -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; } } diff --git a/includes/class-robotstxt-og-meta-box.php b/includes/class-robotstxt-og-meta-box.php index 7f24ce1..a16a74a 100644 --- a/includes/class-robotstxt-og-meta-box.php +++ b/includes/class-robotstxt-og-meta-box.php @@ -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 : ''; ?> + + + + + + + + + + + + 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( '' . "\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( '' . "\n", esc_attr( $name ) ); + printf( '' . "\n", esc_attr( $name ) ); + } + + if ( (bool) get_option( 'robotstxt_og_web_app_capable', false ) ) { + printf( '' . "\n" ); + printf( '' . "\n" ); + } + + if ( (bool) get_option( 'robotstxt_og_format_detection', false ) ) { + printf( '' . "\n" ); + } + } + + /** + * Add the Open Graph namespace prefix to the 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( '' . "\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( '' . "\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( - '' . "\n", - esc_attr( $alt_text ) - ); - } + if ( '' !== $alt_text ) { + printf( + '' . "\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( + '' . "\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( '' . "\n", esc_attr( $twitter_site ) ); } + // Author handle (twitter:creator) — per-post override. + $creator = $this->format_twitter_handle( $this->get_twitter_creator() ); + + if ( '' !== $creator ) { + printf( + '' . "\n", + esc_attr( $creator ) + ); + } + // Image (only when available; Twitter falls back to og:image otherwise). if ( ! empty( $image_url ) ) { printf( '' . "\n", esc_url( $image_url ) ); + + if ( ! empty( $image_alt ) ) { + printf( + '' . "\n", + esc_attr( $image_alt ) + ); + } } } } diff --git a/includes/class-robotstxt-og-term-meta.php b/includes/class-robotstxt-og-term-meta.php new file mode 100644 index 0000000..5898085 --- /dev/null +++ b/includes/class-robotstxt-og-term-meta.php @@ -0,0 +1,359 @@ +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 ); + ?> + + + + + + +

+ +

+ + + + + + + + +

+ +

+ + + + + + + + +

+ +

+ + + + + + + + +

+ +

+ + + 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 : ''; + } +} diff --git a/readme.txt b/readme.txt index 438f77b..9bb41c8 100644 --- a/readme.txt +++ b/readme.txt @@ -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 ``. +* 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. diff --git a/robotstxt-og.php b/robotstxt-og.php index 5116aac..4dc3a6f 100644 --- a/robotstxt-og.php +++ b/robotstxt-og.php @@ -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__ ) ); diff --git a/robotstxt-updater.php b/robotstxt-updater.php index 612c64c..df58331 100644 --- a/robotstxt-updater.php +++ b/robotstxt-updater.php @@ -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 - */ - private array $plugin_data; + /** + * Plugin headers. + * + * @var array + */ + 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 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 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 $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 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 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 $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 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 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 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 $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 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 $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' ); + } } } diff --git a/uninstall.php b/uninstall.php index 52bd9fd..25746ff 100644 --- a/uninstall.php +++ b/uninstall.php @@ -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. diff --git a/update.json b/update.json index e220c75..e1b5a48 100644 --- a/update.json +++ b/update.json @@ -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": "

1.1.0 — 2026-03-28

  • Fixed: 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.
  • Security: Added SSRF protection to all outbound HTTP HEAD requests via is_safe_url() — private and reserved IP ranges are now blocked.
  • Security: Replaced deprecated FILTER_SANITIZE_SPECIAL_CHARS with FILTER_SANITIZE_FULL_SPECIAL_CHARS throughout.
  • Added: GDPR Privacy API support — custom OG title and description post meta are now included in WordPress personal data export and erase requests.
  • Changed: Settings page and REST API now require edit_others_posts capability, allowing editors to manage OG settings and refresh fallback images.
  • Changed: Minimum supported WordPress version raised to 6.8. Network: true header added.
  • Added: PHPUnit test suite with Brain\\Monkey; covers SSRF logic, image format detection, and cache clearing.

1.0.0 — 2026-02-18

  • Initial Release: First stable release of OpenGraph (by ROBOTSTXT).
", + "changelog": "

1.2.0 — 2026-08-10

  • Added: Per-term Open Graph metadata for Categories and Tags (term edit page) — custom OG title, image, and description, exposed on term archives.
  • Added: Manual og:image:alt override (per-post/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).
  • 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>.

1.1.0 — 2026-03-28

  • Fixed: 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.
  • Security: Added SSRF protection to all outbound HTTP HEAD requests via is_safe_url() — private and reserved IP ranges are now blocked.
  • Security: Replaced deprecated FILTER_SANITIZE_SPECIAL_CHARS with FILTER_SANITIZE_FULL_SPECIAL_CHARS throughout.
  • Added: GDPR Privacy API support — custom OG title and description post meta are now included in WordPress personal data export and erase requests.
  • Changed: Settings page and REST API now require edit_others_posts capability, allowing editors to manage OG settings and refresh fallback images.
  • Changed: Minimum supported WordPress version raised to 6.8. Network: true header added.
  • Added: PHPUnit test suite with Brain\\Monkey; covers SSRF logic, image format detection, and cache clearing.

1.0.0 — 2026-02-18

  • Initial Release: First stable release of OpenGraph (by ROBOTSTXT).
", "sections": { "description": "

Social media crawlers — Facebook, Twitter/X, LinkedIn, WhatsApp — do not support modern image formats such as AVIF and WebP. When a post’s featured image uses one of these formats, the platform shows a broken or missing image preview.

OpenGraph (by ROBOTSTXT) outputs a complete set of Open Graph and Twitter Card meta tags and solves this automatically. It detects whether each post’s featured image is in a supported format and, when needed, resolves and caches a compatible JPEG or PNG alternative.

  • Full og:* and twitter:* meta tag output
  • Automatic AVIF/WebP/GIF/BMP/SVG/TIFF detection with JPEG/PNG fallback
  • Per-post OG title and description overrides via post editor meta box
  • Global fallback image URL and homepage-specific image URL
  • Yoast SEO and RankMath integration (no duplicate tags)
  • Postmeta caching with automatic invalidation
  • Admin panel with Settings, Tools, and Diagnostics tabs
  • WP-CLI commands and REST API endpoints
  • Multisite compatible
", - "changelog": "

1.1.0 — 2026-03-28

  • Fixed: 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.
  • Security: Added SSRF protection to all outbound HTTP HEAD requests via is_safe_url() — private and reserved IP ranges are now blocked.
  • Security: Replaced deprecated FILTER_SANITIZE_SPECIAL_CHARS with FILTER_SANITIZE_FULL_SPECIAL_CHARS throughout.
  • Added: GDPR Privacy API support — custom OG title and description post meta are now included in WordPress personal data export and erase requests.
  • Changed: Settings page and REST API now require edit_others_posts capability, allowing editors to manage OG settings and refresh fallback images.
  • Changed: Minimum supported WordPress version raised to 6.8. Network: true header added.
  • Added: PHPUnit test suite with Brain\\Monkey; covers SSRF logic, image format detection, and cache clearing.

1.0.0 — 2026-02-18

  • Initial Release: First stable release of OpenGraph (by ROBOTSTXT).
" + "changelog": "

1.2.0 — 2026-08-10

  • Added: Per-term Open Graph metadata for Categories and Tags (term edit page) — custom OG title, image, and description, exposed on term archives.
  • Added: Manual og:image:alt override (per-post/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).
  • 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>.

1.1.0 — 2026-03-28

  • Fixed: 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.
  • Security: Added SSRF protection to all outbound HTTP HEAD requests via is_safe_url() — private and reserved IP ranges are now blocked.
  • Security: Replaced deprecated FILTER_SANITIZE_SPECIAL_CHARS with FILTER_SANITIZE_FULL_SPECIAL_CHARS throughout.
  • Added: GDPR Privacy API support — custom OG title and description post meta are now included in WordPress personal data export and erase requests.
  • Changed: Settings page and REST API now require edit_others_posts capability, allowing editors to manage OG settings and refresh fallback images.
  • Changed: Minimum supported WordPress version raised to 6.8. Network: true header added.
  • Added: PHPUnit test suite with Brain\\Monkey; covers SSRF logic, image format detection, and cache clearing.

1.0.0 — 2026-02-18

  • Initial Release: First stable release of OpenGraph (by ROBOTSTXT).
" }, "banners": { "low": "",