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: 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: 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).