v1.0.0
This commit is contained in:
parent
ac2c7a8014
commit
f7702f2872
25 changed files with 6453 additions and 0 deletions
337
includes/class-robotstxt-og-cli.php
Normal file
337
includes/class-robotstxt-og-cli.php
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
<?php
|
||||
/**
|
||||
* WP-CLI Commands Class
|
||||
*
|
||||
* Provides WP-CLI commands for managing OG image fallbacks.
|
||||
*
|
||||
* @package ROBOTSTXT_OG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_OG_CLI
|
||||
*
|
||||
* WP-CLI commands for OpenGraph (by ROBOTSTXT) plugin.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* # Resolve fallback images for all posts
|
||||
* $ wp og-fallback resolve --all
|
||||
* Success: Resolved 45 posts. Failed: 2.
|
||||
*
|
||||
* # Resolve a single post
|
||||
* $ wp og-fallback resolve 42
|
||||
* Success: Post 42 resolved to https://example.com/image.jpg
|
||||
*
|
||||
* # Clear all cached fallback URLs
|
||||
* $ wp og-fallback clear-cache --all
|
||||
* Success: Cleared 45 cached fallback URLs.
|
||||
*
|
||||
* # Preview without making changes
|
||||
* $ wp og-fallback resolve --all --dry-run
|
||||
* Found 47 posts with featured images. (dry-run, no changes made)
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Robotstxt_OG_CLI extends WP_CLI_Command {
|
||||
|
||||
/**
|
||||
* Image resolver instance.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var Robotstxt_OG_Image_Resolver
|
||||
*/
|
||||
private Robotstxt_OG_Image_Resolver $resolver;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param Robotstxt_OG_Image_Resolver $resolver Image resolver instance.
|
||||
*/
|
||||
public function __construct( Robotstxt_OG_Image_Resolver $resolver ) {
|
||||
$this->resolver = $resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve fallback images for posts.
|
||||
*
|
||||
* Clears cached fallback URL and re-resolves it for the specified post(s).
|
||||
* Use --all to process all posts with featured images.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [<post_id>]
|
||||
* : The ID of a single post to resolve.
|
||||
*
|
||||
* [--all]
|
||||
* : Resolve fallback images for all posts with featured images.
|
||||
*
|
||||
* [--dry-run]
|
||||
* : Preview what would be done without making changes.
|
||||
*
|
||||
* [--post-type=<type>]
|
||||
* : Limit resolution to a specific post type. Default: any.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp og-fallback resolve --all
|
||||
* wp og-fallback resolve --all --dry-run
|
||||
* wp og-fallback resolve 42
|
||||
* wp og-fallback resolve --all --post-type=post
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param array $args Positional arguments.
|
||||
* @param array $assoc_args Associative arguments.
|
||||
* @return void
|
||||
*/
|
||||
public function resolve( array $args, array $assoc_args ): void {
|
||||
$all = isset( $assoc_args['all'] );
|
||||
$dry_run = isset( $assoc_args['dry-run'] );
|
||||
$post_type = isset( $assoc_args['post-type'] ) ? $assoc_args['post-type'] : 'any';
|
||||
|
||||
if ( $all ) {
|
||||
$this->resolve_all( $dry_run, $post_type );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! empty( $args[0] ) ) {
|
||||
$post_id = absint( $args[0] );
|
||||
$this->resolve_single( $post_id, $dry_run );
|
||||
return;
|
||||
}
|
||||
|
||||
WP_CLI::error( 'Please specify a post ID or use --all flag.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached fallback URLs.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [<post_id>]
|
||||
* : The ID of a single post to clear cache for.
|
||||
*
|
||||
* [--all]
|
||||
* : Clear all cached fallback URLs.
|
||||
*
|
||||
* [--dry-run]
|
||||
* : Preview what would be done without making changes.
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp og-fallback clear-cache --all
|
||||
* wp og-fallback clear-cache 42
|
||||
* wp og-fallback clear-cache --all --dry-run
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param array $args Positional arguments.
|
||||
* @param array $assoc_args Associative arguments.
|
||||
* @return void
|
||||
*/
|
||||
public function clear_cache( array $args, array $assoc_args ): void {
|
||||
$all = isset( $assoc_args['all'] );
|
||||
$dry_run = isset( $assoc_args['dry-run'] );
|
||||
|
||||
if ( $all ) {
|
||||
$this->clear_all_caches( $dry_run );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! empty( $args[0] ) ) {
|
||||
$post_id = absint( $args[0] );
|
||||
$this->clear_single_cache( $post_id, $dry_run );
|
||||
return;
|
||||
}
|
||||
|
||||
WP_CLI::error( 'Please specify a post ID or use --all flag.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve fallback images for all posts.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param bool $dry_run Whether to run without making changes.
|
||||
* @param string $post_type Post type to limit to.
|
||||
* @return void
|
||||
*/
|
||||
private function resolve_all( bool $dry_run, string $post_type ): void {
|
||||
$posts = get_posts(
|
||||
array(
|
||||
'post_type' => $post_type,
|
||||
'post_status' => 'any',
|
||||
'posts_per_page' => -1,
|
||||
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
array(
|
||||
'key' => '_thumbnail_id',
|
||||
'compare' => 'EXISTS',
|
||||
),
|
||||
),
|
||||
'fields' => 'ids',
|
||||
)
|
||||
);
|
||||
|
||||
$count = count( $posts );
|
||||
|
||||
if ( $dry_run ) {
|
||||
WP_CLI::line(
|
||||
/* translators: %d: number of posts found */
|
||||
sprintf( __( 'Found %d posts with featured images. (dry-run, no changes made)', 'robotstxt-og' ), $count )
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 0 === $count ) {
|
||||
WP_CLI::warning( __( 'No posts with featured images found.', 'robotstxt-og' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
$progress = WP_CLI\Utils\make_progress_bar(
|
||||
/* translators: %d: number of posts to process */
|
||||
sprintf( __( 'Resolving %d posts', 'robotstxt-og' ), $count ),
|
||||
$count
|
||||
);
|
||||
|
||||
foreach ( $posts as $post_id ) {
|
||||
$this->resolver->clear_cache( $post_id );
|
||||
$url = $this->resolver->resolve_image( $post_id );
|
||||
|
||||
if ( ! empty( $url ) ) {
|
||||
++$success;
|
||||
} else {
|
||||
++$failed;
|
||||
}
|
||||
|
||||
$progress->tick();
|
||||
}
|
||||
|
||||
$progress->finish();
|
||||
|
||||
WP_CLI::success(
|
||||
/* translators: 1: successful count, 2: failed count */
|
||||
sprintf( __( 'Resolved %1$d posts. Failed: %2$d.', 'robotstxt-og' ), $success, $failed )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve fallback image for a single post.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param bool $dry_run Whether to run without making changes.
|
||||
* @return void
|
||||
*/
|
||||
private function resolve_single( int $post_id, bool $dry_run ): void {
|
||||
$post = get_post( $post_id );
|
||||
|
||||
if ( ! $post ) {
|
||||
/* translators: %d: post ID */
|
||||
WP_CLI::error( sprintf( __( 'Post %d not found.', 'robotstxt-og' ), $post_id ) );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $dry_run ) {
|
||||
$thumb_id = get_post_thumbnail_id( $post_id );
|
||||
$thumb = $thumb_id ? wp_get_attachment_url( $thumb_id ) : '';
|
||||
/* translators: 1: post ID, 2: image URL */
|
||||
WP_CLI::line( sprintf( __( 'Post %1$d has featured image: %2$s (dry-run, no changes made)', 'robotstxt-og' ), $post_id, ! empty( $thumb ) ? $thumb : 'none' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resolver->clear_cache( $post_id );
|
||||
$url = $this->resolver->resolve_image( $post_id );
|
||||
|
||||
if ( ! empty( $url ) ) {
|
||||
/* translators: 1: post ID, 2: resolved URL */
|
||||
WP_CLI::success( sprintf( __( 'Post %1$d resolved to %2$s', 'robotstxt-og' ), $post_id, $url ) );
|
||||
} else {
|
||||
/* translators: %d: post ID */
|
||||
WP_CLI::warning( sprintf( __( 'Post %d: no compatible image found.', 'robotstxt-og' ), $post_id ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached fallback URLs.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param bool $dry_run Whether to run without making changes.
|
||||
* @return void
|
||||
*/
|
||||
private function clear_all_caches( bool $dry_run ): void {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
// WP-CLI single-invocation context; no persistent cache layer is appropriate here.
|
||||
$count = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s",
|
||||
'_og_image_fallback_url'
|
||||
)
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
|
||||
if ( $dry_run ) {
|
||||
/* translators: %d: number of cache entries found */
|
||||
WP_CLI::line( sprintf( __( 'Found %d cached fallback URLs. (dry-run, no changes made)', 'robotstxt-og' ), $count ) );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 0 === $count ) {
|
||||
WP_CLI::warning( __( 'No cached fallback URLs found.', 'robotstxt-og' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
// Bulk delete of all cached postmeta rows — no WP API equivalent for this operation.
|
||||
$deleted = $wpdb->delete(
|
||||
$wpdb->postmeta,
|
||||
array( 'meta_key' => '_og_image_fallback_url' ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
|
||||
array( '%s' )
|
||||
);
|
||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
|
||||
/* translators: %d: number of cleared entries */
|
||||
WP_CLI::success( sprintf( __( 'Cleared %d cached fallback URLs.', 'robotstxt-og' ), (int) $deleted ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached fallback URL for a single post.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param bool $dry_run Whether to run without making changes.
|
||||
* @return void
|
||||
*/
|
||||
private function clear_single_cache( int $post_id, bool $dry_run ): void {
|
||||
$cached = get_post_meta( $post_id, '_og_image_fallback_url', true );
|
||||
|
||||
if ( $dry_run ) {
|
||||
/* translators: 1: post ID, 2: cached URL or 'none' */
|
||||
WP_CLI::line( sprintf( __( 'Post %1$d cached URL: %2$s (dry-run, no changes made)', 'robotstxt-og' ), $post_id, ! empty( $cached ) ? $cached : 'none' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( empty( $cached ) ) {
|
||||
/* translators: %d: post ID */
|
||||
WP_CLI::warning( sprintf( __( 'Post %d has no cached fallback URL.', 'robotstxt-og' ), $post_id ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resolver->clear_cache( $post_id );
|
||||
/* translators: %d: post ID */
|
||||
WP_CLI::success( sprintf( __( 'Cleared cached fallback URL for post %d.', 'robotstxt-og' ), $post_id ) );
|
||||
}
|
||||
}
|
||||
247
includes/class-robotstxt-og-image-fallback.php
Normal file
247
includes/class-robotstxt-og-image-fallback.php
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
<?php
|
||||
/**
|
||||
* Main Plugin Class
|
||||
*
|
||||
* Orchestrates plugin initialization and component integration.
|
||||
*
|
||||
* @package ROBOTSTXT_OG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_OG_Image_Fallback
|
||||
*
|
||||
* Main plugin class using singleton pattern.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Robotstxt_OG_Image_Fallback {
|
||||
|
||||
/**
|
||||
* Single instance of the class.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var Robotstxt_OG_Image_Fallback|null
|
||||
*/
|
||||
private static ?Robotstxt_OG_Image_Fallback $instance = null;
|
||||
|
||||
/**
|
||||
* Image resolver instance.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var Robotstxt_OG_Image_Resolver
|
||||
*/
|
||||
private Robotstxt_OG_Image_Resolver $resolver;
|
||||
|
||||
/**
|
||||
* OG tags generator instance.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var Robotstxt_OG_Tags
|
||||
*/
|
||||
private Robotstxt_OG_Tags $tags;
|
||||
|
||||
/**
|
||||
* Admin settings instance.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var Robotstxt_OG_Admin_Settings|null
|
||||
*/
|
||||
private ?Robotstxt_OG_Admin_Settings $admin_settings = null;
|
||||
|
||||
/**
|
||||
* REST API instance.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var Robotstxt_OG_REST_API
|
||||
*/
|
||||
private Robotstxt_OG_REST_API $rest_api;
|
||||
|
||||
/**
|
||||
* Meta box instance.
|
||||
*
|
||||
* @since 1.2.0
|
||||
* @var Robotstxt_OG_Meta_Box
|
||||
*/
|
||||
private Robotstxt_OG_Meta_Box $meta_box;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return Robotstxt_OG_Image_Fallback
|
||||
*/
|
||||
public static function get_instance(): Robotstxt_OG_Image_Fallback {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* Private to enforce singleton pattern.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
private function __construct() {
|
||||
// Constructor is private.
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the plugin.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function init(): void {
|
||||
// Load dependencies.
|
||||
$this->load_dependencies();
|
||||
|
||||
// Initialize components.
|
||||
$this->resolver = new Robotstxt_OG_Image_Resolver();
|
||||
$this->tags = new Robotstxt_OG_Tags( $this->resolver );
|
||||
|
||||
// Initialize OG tags generator.
|
||||
$this->tags->init();
|
||||
|
||||
// Initialize REST API.
|
||||
$this->rest_api = new Robotstxt_OG_REST_API( $this->resolver );
|
||||
$this->rest_api->init();
|
||||
|
||||
// Initialize meta box (registers post meta for REST + editor UI).
|
||||
$this->meta_box = new Robotstxt_OG_Meta_Box();
|
||||
$this->meta_box->init();
|
||||
|
||||
// Initialize admin settings if in admin context.
|
||||
if ( is_admin() ) {
|
||||
$this->admin_settings = new Robotstxt_OG_Admin_Settings( $this->resolver );
|
||||
$this->admin_settings->init();
|
||||
}
|
||||
|
||||
// Register WP-CLI commands.
|
||||
if ( defined( 'WP_CLI' ) && WP_CLI ) {
|
||||
WP_CLI::add_command( 'og-fallback', new Robotstxt_OG_CLI( $this->resolver ) );
|
||||
}
|
||||
|
||||
// Auto-regenerate cache when featured image changes.
|
||||
add_action( 'updated_post_meta', array( $this, 'handle_thumbnail_change' ), 10, 4 );
|
||||
add_action( 'deleted_post_meta', array( $this, 'handle_thumbnail_change' ), 10, 4 );
|
||||
|
||||
// Register activation and deactivation hooks.
|
||||
register_activation_hook( ROBOTSTXT_OG_PATH . 'robotstxt-og.php', array( $this, 'activate' ) );
|
||||
register_deactivation_hook( ROBOTSTXT_OG_PATH . 'robotstxt-og.php', array( $this, 'deactivate' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plugin dependencies.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function load_dependencies(): void {
|
||||
require_once ROBOTSTXT_OG_PATH . 'includes/class-robotstxt-og-image-resolver.php';
|
||||
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';
|
||||
|
||||
// Load admin class if in admin context.
|
||||
if ( is_admin() ) {
|
||||
require_once ROBOTSTXT_OG_PATH . 'admin/class-robotstxt-og-admin-settings.php';
|
||||
}
|
||||
|
||||
// Load WP-CLI class if running in CLI context.
|
||||
if ( defined( 'WP_CLI' ) && WP_CLI ) {
|
||||
require_once ROBOTSTXT_OG_PATH . 'includes/class-robotstxt-og-cli.php';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activation hook callback.
|
||||
*
|
||||
* Runs when the plugin is activated.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function activate(): void {
|
||||
// Set default options if they don't exist.
|
||||
if ( false === get_option( 'robotstxt_og_fallback_image' ) ) {
|
||||
add_option( 'robotstxt_og_fallback_image', '' );
|
||||
}
|
||||
|
||||
if ( false === get_option( 'robotstxt_og_delete_data_on_uninstall' ) ) {
|
||||
add_option( 'robotstxt_og_delete_data_on_uninstall', false );
|
||||
}
|
||||
|
||||
// Flush rewrite rules (if needed in future).
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivation hook callback.
|
||||
*
|
||||
* Runs when the plugin is deactivated.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function deactivate(): void {
|
||||
// Flush rewrite rules (if needed in future).
|
||||
flush_rewrite_rules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear fallback cache when a post's featured image is changed or removed.
|
||||
*
|
||||
* Hooked to `updated_post_meta` and `deleted_post_meta` for `_thumbnail_id`.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $meta_id ID of the meta data entry.
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $meta_key Meta key being updated.
|
||||
* @param mixed $meta_value New meta value (unused).
|
||||
* @return void
|
||||
*/
|
||||
public function handle_thumbnail_change( int $meta_id, int $post_id, string $meta_key, $meta_value = null ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
|
||||
if ( '_thumbnail_id' !== $meta_key ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resolver->clear_cache( $post_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image resolver instance.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return Robotstxt_OG_Image_Resolver
|
||||
*/
|
||||
public function get_resolver(): Robotstxt_OG_Image_Resolver {
|
||||
return $this->resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OG tags generator instance.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return Robotstxt_OG_Tags
|
||||
*/
|
||||
public function get_tags(): Robotstxt_OG_Tags {
|
||||
return $this->tags;
|
||||
}
|
||||
}
|
||||
583
includes/class-robotstxt-og-image-resolver.php
Normal file
583
includes/class-robotstxt-og-image-resolver.php
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
<?php
|
||||
/**
|
||||
* Image Resolver Class
|
||||
*
|
||||
* Handles detection and resolution of compatible fallback images for Open Graph tags.
|
||||
*
|
||||
* @package ROBOTSTXT_OG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_OG_Image_Resolver
|
||||
*
|
||||
* Resolves compatible image formats for social media crawlers.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Robotstxt_OG_Image_Resolver {
|
||||
|
||||
/**
|
||||
* Transient prefix for negative cache entries.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
const NEGATIVE_CACHE_PREFIX = 'robotstxt_og_miss_';
|
||||
|
||||
/**
|
||||
* How long to cache negative results (URL not found), in seconds.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var int
|
||||
*/
|
||||
const NEGATIVE_CACHE_TTL = HOUR_IN_SECONDS;
|
||||
|
||||
/**
|
||||
* Get fallback image URL for a post.
|
||||
*
|
||||
* Checks cache first, then resolves if needed.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return string Image URL or empty string if not found.
|
||||
*/
|
||||
public function get_fallback_image( int $post_id ): string {
|
||||
// Check postmeta cache first.
|
||||
$cached_url = get_post_meta( $post_id, '_og_image_fallback_url', true );
|
||||
|
||||
if ( ! empty( $cached_url ) && $this->is_valid_url( $cached_url ) ) {
|
||||
$this->log(
|
||||
'cache_hit',
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'url' => $cached_url,
|
||||
)
|
||||
);
|
||||
return esc_url_raw( $cached_url );
|
||||
}
|
||||
|
||||
$this->log( 'cache_miss', array( 'post_id' => $post_id ) );
|
||||
|
||||
// No valid cache, resolve image.
|
||||
return $this->resolve_image( $post_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve fallback image for a post.
|
||||
*
|
||||
* Detects featured image format and finds compatible alternative if needed.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return string Resolved image URL or empty string.
|
||||
*/
|
||||
public function resolve_image( int $post_id ): string {
|
||||
// Get featured image ID.
|
||||
$image_id = get_post_thumbnail_id( $post_id );
|
||||
|
||||
if ( empty( $image_id ) ) {
|
||||
// No featured image, check for global fallback.
|
||||
$this->log( 'no_featured_image', array( 'post_id' => $post_id ) );
|
||||
return $this->get_global_fallback();
|
||||
}
|
||||
|
||||
// Get image URL.
|
||||
$image_url = wp_get_attachment_url( $image_id );
|
||||
|
||||
if ( empty( $image_url ) ) {
|
||||
$this->log(
|
||||
'no_attachment_url',
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'image_id' => $image_id,
|
||||
)
|
||||
);
|
||||
return $this->get_global_fallback();
|
||||
}
|
||||
|
||||
// Detect file extension.
|
||||
$path_info = pathinfo( wp_parse_url( $image_url, PHP_URL_PATH ) );
|
||||
$extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : '';
|
||||
|
||||
// If already a compatible format, save and return.
|
||||
if ( in_array( $extension, array( 'jpg', 'jpeg', 'png' ), true ) ) {
|
||||
$this->log(
|
||||
'compatible_format',
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'url' => $image_url,
|
||||
)
|
||||
);
|
||||
$this->save_cache( $post_id, $image_url );
|
||||
return esc_url_raw( $image_url );
|
||||
}
|
||||
|
||||
// Modern format detected (webp, avif, etc.), find alternative.
|
||||
if ( in_array( $extension, array( 'webp', 'avif', 'gif', 'bmp', 'svg', 'tiff', 'tif' ), true ) ) {
|
||||
$this->log(
|
||||
'modern_format_detected',
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'extension' => $extension,
|
||||
'url' => $image_url,
|
||||
)
|
||||
);
|
||||
|
||||
$alternative = $this->find_compatible_alternative( $image_url );
|
||||
|
||||
if ( ! empty( $alternative ) ) {
|
||||
$this->log(
|
||||
'alternative_found',
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'original' => $image_url,
|
||||
'alternative' => $alternative,
|
||||
)
|
||||
);
|
||||
$this->save_cache( $post_id, $alternative );
|
||||
return esc_url_raw( $alternative );
|
||||
}
|
||||
|
||||
$this->log(
|
||||
'no_alternative_found',
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'url' => $image_url,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// No alternative found, return original (don't cache to allow retry on next request).
|
||||
return esc_url_raw( $image_url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Find compatible image alternative by checking .jpg and .png versions.
|
||||
*
|
||||
* Uses HTTP HEAD requests to verify file existence.
|
||||
* Respects the `robotstxt_og_external_image_enabled` filter for external images.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $image_url Original image URL.
|
||||
* @return string Compatible image URL or empty string if not found.
|
||||
*/
|
||||
public function find_compatible_alternative( string $image_url ): string {
|
||||
// Check if external images are allowed.
|
||||
if ( $this->is_external_url( $image_url ) ) {
|
||||
/**
|
||||
* Filter whether to attempt resolution for external images.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param bool $enabled Whether external image resolution is enabled.
|
||||
* @param string $image_url The external image URL being resolved.
|
||||
*/
|
||||
$enabled = apply_filters( 'robotstxt_og_external_image_enabled', true, $image_url );
|
||||
|
||||
if ( ! $enabled ) {
|
||||
$this->log( 'external_image_skipped', array( 'url' => $image_url ) );
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Strip query string for URL manipulation.
|
||||
$clean_url = strtok( $image_url, '?' );
|
||||
|
||||
// Parse URL and get base path without extension.
|
||||
$path_info = pathinfo( wp_parse_url( $clean_url, PHP_URL_PATH ) );
|
||||
$filename = $path_info['filename'] ?? '';
|
||||
$dir = $path_info['dirname'] ?? '';
|
||||
|
||||
if ( empty( $filename ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Reconstruct base URL.
|
||||
$parsed = wp_parse_url( $clean_url );
|
||||
$base_url = ( $parsed['scheme'] ?? 'https' ) . '://' . ( $parsed['host'] ?? '' );
|
||||
|
||||
if ( ! empty( $parsed['port'] ) ) {
|
||||
$base_url .= ':' . $parsed['port'];
|
||||
}
|
||||
|
||||
$base_url .= trailingslashit( $dir ) . $filename;
|
||||
|
||||
// Try .jpg first, then .png.
|
||||
$alternatives = array( '.jpg', '.png', '.jpeg' );
|
||||
|
||||
foreach ( $alternatives as $ext ) {
|
||||
$test_url = $base_url . $ext;
|
||||
|
||||
if ( $this->url_exists( $test_url ) ) {
|
||||
return $test_url;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL exists using HTTP HEAD request.
|
||||
*
|
||||
* Uses negative caching to avoid repeated requests for non-existent URLs.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $url URL to check.
|
||||
* @return bool True if URL returns 200-299 status code.
|
||||
*/
|
||||
private function url_exists( string $url ): bool {
|
||||
// Check negative cache to avoid repeated failed requests.
|
||||
$cache_key = self::NEGATIVE_CACHE_PREFIX . md5( $url );
|
||||
|
||||
if ( false !== get_transient( $cache_key ) ) {
|
||||
$this->log( 'negative_cache_hit', array( 'url' => $url ) );
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the timeout in seconds for external image HEAD requests.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $timeout The timeout in seconds.
|
||||
* @param string $url The URL being checked.
|
||||
*/
|
||||
$timeout = (int) apply_filters( 'robotstxt_og_external_image_timeout', 5, $url );
|
||||
|
||||
// Use WordPress HTTP API.
|
||||
$response = wp_remote_head(
|
||||
$url,
|
||||
array(
|
||||
'timeout' => max( 1, $timeout ),
|
||||
'redirection' => 5,
|
||||
'user-agent' => 'WordPress/' . get_bloginfo( 'version' ) . '; ' . get_bloginfo( 'url' ),
|
||||
)
|
||||
);
|
||||
|
||||
// Handle WP_Error (network failure, timeout, etc.).
|
||||
if ( is_wp_error( $response ) ) {
|
||||
$this->log(
|
||||
'head_request_error',
|
||||
array(
|
||||
'url' => $url,
|
||||
'error' => $response->get_error_message(),
|
||||
)
|
||||
);
|
||||
set_transient( $cache_key, '1', self::NEGATIVE_CACHE_TTL );
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check response code.
|
||||
$code = (int) wp_remote_retrieve_response_code( $response );
|
||||
$exists = $code >= 200 && $code < 300;
|
||||
|
||||
if ( ! $exists ) {
|
||||
$this->log(
|
||||
'head_request_failed',
|
||||
array(
|
||||
'url' => $url,
|
||||
'code' => $code,
|
||||
)
|
||||
);
|
||||
set_transient( $cache_key, '1', self::NEGATIVE_CACHE_TTL );
|
||||
} else {
|
||||
$this->log(
|
||||
'head_request_success',
|
||||
array(
|
||||
'url' => $url,
|
||||
'code' => $code,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return $exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is external (hosted on a different domain).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $url URL to check.
|
||||
* @return bool True if the URL is external.
|
||||
*/
|
||||
private function is_external_url( string $url ): bool {
|
||||
$site_host = wp_parse_url( get_bloginfo( 'url' ), PHP_URL_HOST );
|
||||
$image_host = wp_parse_url( $url, PHP_URL_HOST );
|
||||
|
||||
if ( empty( $site_host ) || empty( $image_host ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtolower( $site_host ) !== strtolower( $image_host );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global fallback image URL from settings.
|
||||
*
|
||||
* If the stored image is in an incompatible format (AVIF, WebP, etc.),
|
||||
* attempts to resolve a JPEG/PNG alternative. Returns empty string if
|
||||
* no compatible image is found.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return string Compatible image URL or empty string.
|
||||
*/
|
||||
private function get_global_fallback(): string {
|
||||
$fallback_url = (string) get_option( 'robotstxt_og_fallback_image', '' );
|
||||
|
||||
if ( empty( $fallback_url ) || ! $this->is_valid_url( $fallback_url ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return esc_url_raw( $fallback_url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured homepage image URL.
|
||||
*
|
||||
* If the stored image is in an incompatible format (AVIF, WebP, etc.),
|
||||
* attempts to resolve a JPEG/PNG alternative before falling back to
|
||||
* the global fallback image.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*
|
||||
* @return string Compatible image URL or empty string.
|
||||
*/
|
||||
public function get_homepage_image(): string {
|
||||
$image_url = (string) get_option( 'robotstxt_og_homepage_image', '' );
|
||||
|
||||
if ( ! empty( $image_url ) && $this->is_valid_url( $image_url ) ) {
|
||||
return esc_url_raw( $image_url );
|
||||
}
|
||||
|
||||
// Fall back to global fallback image.
|
||||
return $this->get_global_fallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an image URL is in a format compatible with social media crawlers.
|
||||
*
|
||||
* Returns the URL unchanged for JPEG/PNG. For incompatible formats
|
||||
* (AVIF, WebP, GIF, SVG, BMP, TIFF), attempts to find a JPEG/PNG
|
||||
* alternative via find_compatible_alternative(). Returns empty string
|
||||
* if no compatible version can be found.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param string $image_url Image URL to check.
|
||||
* @return string Compatible image URL or empty string.
|
||||
*/
|
||||
public function ensure_compatible_format( string $image_url ): string {
|
||||
$path_info = pathinfo( wp_parse_url( $image_url, PHP_URL_PATH ) );
|
||||
$extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : '';
|
||||
|
||||
// Already a compatible format.
|
||||
if ( in_array( $extension, array( 'jpg', 'jpeg', 'png' ), true ) ) {
|
||||
return esc_url_raw( $image_url );
|
||||
}
|
||||
|
||||
// Incompatible modern format — try to find a JPEG/PNG alternative.
|
||||
if ( in_array( $extension, array( 'webp', 'avif', 'gif', 'bmp', 'svg', 'tiff', 'tif' ), true ) ) {
|
||||
$this->log(
|
||||
'fallback_image_incompatible_format',
|
||||
array(
|
||||
'url' => $image_url,
|
||||
'extension' => $extension,
|
||||
)
|
||||
);
|
||||
|
||||
$alternative = $this->find_compatible_alternative( $image_url );
|
||||
|
||||
if ( ! empty( $alternative ) ) {
|
||||
$this->log(
|
||||
'fallback_image_alternative_found',
|
||||
array(
|
||||
'original' => $image_url,
|
||||
'alternative' => $alternative,
|
||||
)
|
||||
);
|
||||
return esc_url_raw( $alternative );
|
||||
}
|
||||
|
||||
$this->log( 'fallback_image_no_alternative', array( 'url' => $image_url ) );
|
||||
|
||||
// No compatible alternative found — discard this image.
|
||||
return '';
|
||||
}
|
||||
|
||||
// Unknown extension — return as-is and let the caller decide.
|
||||
return esc_url_raw( $image_url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Save resolved URL to postmeta cache.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $url Resolved image URL.
|
||||
* @return void
|
||||
*/
|
||||
private function save_cache( int $post_id, string $url ): void {
|
||||
update_post_meta( $post_id, '_og_image_fallback_url', esc_url_raw( $url ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached fallback URL for a post.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return void
|
||||
*/
|
||||
public function clear_cache( int $post_id ): void {
|
||||
delete_post_meta( $post_id, '_og_image_fallback_url' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback image URL for a taxonomy term.
|
||||
*
|
||||
* Checks term meta cache first, then looks for a term image set by
|
||||
* supported plugins (e.g., Yoast SEO term images, custom term meta).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $term_id Term ID.
|
||||
* @return string Image URL or empty string if not found.
|
||||
*/
|
||||
public function get_taxonomy_fallback_image( int $term_id ): string {
|
||||
// Check term meta cache first.
|
||||
$cached_url = get_term_meta( $term_id, '_og_image_fallback_url', true );
|
||||
|
||||
if ( ! empty( $cached_url ) && $this->is_valid_url( $cached_url ) ) {
|
||||
$this->log(
|
||||
'taxonomy_cache_hit',
|
||||
array(
|
||||
'term_id' => $term_id,
|
||||
'url' => $cached_url,
|
||||
)
|
||||
);
|
||||
return esc_url_raw( $cached_url );
|
||||
}
|
||||
|
||||
$this->log( 'taxonomy_cache_miss', array( 'term_id' => $term_id ) );
|
||||
|
||||
return $this->resolve_taxonomy_image( $term_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve fallback image for a taxonomy term.
|
||||
*
|
||||
* Checks for term images set by Yoast SEO or other plugins via a filter.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $term_id Term ID.
|
||||
* @return string Resolved image URL or empty string.
|
||||
*/
|
||||
public function resolve_taxonomy_image( int $term_id ): string {
|
||||
/**
|
||||
* Filter the image URL for a taxonomy term.
|
||||
*
|
||||
* Use this filter to integrate with plugins that assign images to
|
||||
* taxonomy terms (e.g., custom term meta, theme functions).
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $image_url The image URL. Empty string if none.
|
||||
* @param int $term_id The term ID being resolved.
|
||||
*/
|
||||
$image_url = (string) apply_filters( 'robotstxt_og_taxonomy_image', '', $term_id );
|
||||
|
||||
if ( empty( $image_url ) || ! $this->is_valid_url( $image_url ) ) {
|
||||
return $this->get_global_fallback();
|
||||
}
|
||||
|
||||
// Detect file extension and resolve if needed.
|
||||
$path_info = pathinfo( wp_parse_url( $image_url, PHP_URL_PATH ) );
|
||||
$extension = isset( $path_info['extension'] ) ? strtolower( $path_info['extension'] ) : '';
|
||||
|
||||
if ( in_array( $extension, array( 'jpg', 'jpeg', 'png' ), true ) ) {
|
||||
update_term_meta( $term_id, '_og_image_fallback_url', esc_url_raw( $image_url ) );
|
||||
return esc_url_raw( $image_url );
|
||||
}
|
||||
|
||||
if ( in_array( $extension, array( 'webp', 'avif', 'gif', 'bmp', 'svg', 'tiff', 'tif' ), true ) ) {
|
||||
$alternative = $this->find_compatible_alternative( $image_url );
|
||||
|
||||
if ( ! empty( $alternative ) ) {
|
||||
update_term_meta( $term_id, '_og_image_fallback_url', esc_url_raw( $alternative ) );
|
||||
return esc_url_raw( $alternative );
|
||||
}
|
||||
}
|
||||
|
||||
return esc_url_raw( $image_url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached fallback URL for a taxonomy term.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $term_id Term ID.
|
||||
* @return void
|
||||
*/
|
||||
public function clear_taxonomy_cache( int $term_id ): void {
|
||||
delete_term_meta( $term_id, '_og_image_fallback_url' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a string is a valid URL.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $url URL to validate.
|
||||
* @return bool True if valid URL.
|
||||
*/
|
||||
private function is_valid_url( string $url ): bool {
|
||||
return false !== filter_var( $url, FILTER_VALIDATE_URL );
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a resolution event if logging is enabled.
|
||||
*
|
||||
* Uses the `robotstxt_og_enable_logging` filter to toggle logging.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $event Event name (e.g., 'cache_hit', 'head_request_error').
|
||||
* @param array $context Additional context data.
|
||||
* @return void
|
||||
*/
|
||||
private function log( string $event, array $context = array() ): void {
|
||||
/**
|
||||
* Filter whether to enable debug logging for image resolution.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param bool $enabled Whether logging is enabled. Default false.
|
||||
*/
|
||||
if ( ! apply_filters( 'robotstxt_og_enable_logging', false ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
'[robotstxt-og] %s | %s',
|
||||
$event,
|
||||
wp_json_encode( $context )
|
||||
);
|
||||
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log( $message );
|
||||
}
|
||||
}
|
||||
219
includes/class-robotstxt-og-meta-box.php
Normal file
219
includes/class-robotstxt-og-meta-box.php
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<?php
|
||||
/**
|
||||
* Meta Box Class
|
||||
*
|
||||
* Provides per-post Open Graph and social media customization fields
|
||||
* in the WordPress post editor.
|
||||
*
|
||||
* @package ROBOTSTXT_OG
|
||||
* @since 1.2.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_OG_Meta_Box
|
||||
*
|
||||
* Registers and handles the Open Graph meta box in the post editor,
|
||||
* and registers the underlying post meta for REST API access.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*/
|
||||
class Robotstxt_OG_Meta_Box {
|
||||
|
||||
/**
|
||||
* Initialize hooks.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function init(): void {
|
||||
// Register post meta for REST API access (block editor).
|
||||
add_action( 'init', array( $this, 'register_post_meta' ) );
|
||||
|
||||
// Meta box UI is admin-only.
|
||||
if ( is_admin() ) {
|
||||
add_action( 'add_meta_boxes', array( $this, 'register_meta_box' ) );
|
||||
add_action( 'save_post', array( $this, 'save_meta_box' ), 10, 2 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register custom post meta for all public post types.
|
||||
*
|
||||
* Exposes _og_title and _og_description via the REST API so the
|
||||
* block editor can read and write them via useEntityProp.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_post_meta(): void {
|
||||
$post_types = get_post_types( array( 'public' => true ), 'names' );
|
||||
|
||||
foreach ( $post_types as $post_type ) {
|
||||
register_post_meta(
|
||||
$post_type,
|
||||
'_og_title',
|
||||
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_description',
|
||||
array(
|
||||
'type' => 'string',
|
||||
'single' => true,
|
||||
'sanitize_callback' => 'sanitize_textarea_field',
|
||||
'auth_callback' => static function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
'show_in_rest' => true,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the meta box for all public post types.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_meta_box(): void {
|
||||
$post_types = get_post_types( array( 'public' => true ), 'names' );
|
||||
|
||||
foreach ( $post_types as $post_type ) {
|
||||
add_meta_box(
|
||||
'robotstxt-og-meta-box',
|
||||
__( 'Open Graph / Social Media', 'robotstxt-og' ),
|
||||
array( $this, 'render_meta_box' ),
|
||||
$post_type,
|
||||
'normal',
|
||||
'default'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the meta box HTML.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param WP_Post $post The current post object.
|
||||
* @return void
|
||||
*/
|
||||
public function render_meta_box( WP_Post $post ): void {
|
||||
wp_nonce_field( 'robotstxt_og_meta_box', 'robotstxt_og_meta_box_nonce' );
|
||||
|
||||
$og_title = (string) get_post_meta( $post->ID, '_og_title', true );
|
||||
$og_description = (string) get_post_meta( $post->ID, '_og_description', true );
|
||||
|
||||
?>
|
||||
<table class="form-table" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="robotstxt_og_title">
|
||||
<?php esc_html_e( 'Custom Title', 'robotstxt-og' ); ?>
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
id="robotstxt_og_title"
|
||||
name="robotstxt_og_title"
|
||||
value="<?php echo esc_attr( $og_title ); ?>"
|
||||
class="large-text"
|
||||
/>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Overrides the default og:title for this post. Leave blank to use the post title automatically.', 'robotstxt-og' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<label for="robotstxt_og_description">
|
||||
<?php esc_html_e( 'Custom Description', 'robotstxt-og' ); ?>
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<textarea
|
||||
id="robotstxt_og_description"
|
||||
name="robotstxt_og_description"
|
||||
class="large-text"
|
||||
rows="3"
|
||||
><?php echo esc_textarea( $og_description ); ?></textarea>
|
||||
<p class="description">
|
||||
<?php esc_html_e( 'Overrides the default og:description for this post. Leave blank to use the excerpt automatically.', 'robotstxt-og' ); ?>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Save meta box values on post save.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @param int $post_id The post ID.
|
||||
* @param WP_Post $post The post object.
|
||||
* @return void
|
||||
*/
|
||||
public function save_meta_box( int $post_id, WP_Post $post ): void {
|
||||
// Skip autosaves and revisions.
|
||||
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify nonce.
|
||||
$nonce_raw = filter_input( INPUT_POST, 'robotstxt_og_meta_box_nonce', FILTER_SANITIZE_SPECIAL_CHARS );
|
||||
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
|
||||
|
||||
if ( ! wp_verify_nonce( $nonce, 'robotstxt_og_meta_box' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check permissions.
|
||||
$post_type_obj = get_post_type_object( $post->post_type );
|
||||
|
||||
if ( ! $post_type_obj || ! current_user_can( $post_type_obj->cap->edit_post, $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save og:title.
|
||||
$og_title_raw = filter_input( INPUT_POST, 'robotstxt_og_title', FILTER_SANITIZE_SPECIAL_CHARS );
|
||||
$og_title = $og_title_raw ? sanitize_text_field( wp_unslash( $og_title_raw ) ) : '';
|
||||
|
||||
if ( empty( $og_title ) ) {
|
||||
delete_post_meta( $post_id, '_og_title' );
|
||||
} else {
|
||||
update_post_meta( $post_id, '_og_title', $og_title );
|
||||
}
|
||||
|
||||
// Save og:description.
|
||||
$og_desc_raw = filter_input( INPUT_POST, 'robotstxt_og_description', FILTER_UNSAFE_RAW );
|
||||
$og_desc = $og_desc_raw ? sanitize_textarea_field( wp_unslash( $og_desc_raw ) ) : '';
|
||||
|
||||
if ( empty( $og_desc ) ) {
|
||||
delete_post_meta( $post_id, '_og_description' );
|
||||
} else {
|
||||
update_post_meta( $post_id, '_og_description', $og_desc );
|
||||
}
|
||||
}
|
||||
}
|
||||
209
includes/class-robotstxt-og-rest-api.php
Normal file
209
includes/class-robotstxt-og-rest-api.php
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
<?php
|
||||
/**
|
||||
* REST API Class
|
||||
*
|
||||
* Registers and handles REST API endpoints for OpenGraph image fallback.
|
||||
*
|
||||
* @package ROBOTSTXT_OG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_OG_REST_API
|
||||
*
|
||||
* Provides REST API endpoints for the OpenGraph plugin.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Robotstxt_OG_REST_API {
|
||||
|
||||
/**
|
||||
* REST API namespace.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var string
|
||||
*/
|
||||
const NAMESPACE = 'robotstxt-og/v1';
|
||||
|
||||
/**
|
||||
* Image resolver instance.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var Robotstxt_OG_Image_Resolver
|
||||
*/
|
||||
private Robotstxt_OG_Image_Resolver $resolver;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param Robotstxt_OG_Image_Resolver $resolver Image resolver instance.
|
||||
*/
|
||||
public function __construct( Robotstxt_OG_Image_Resolver $resolver ) {
|
||||
$this->resolver = $resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize REST API hooks.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function init(): void {
|
||||
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register REST API routes.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register_routes(): void {
|
||||
// Resolve endpoint: force re-resolve a post's fallback image.
|
||||
register_rest_route(
|
||||
self::NAMESPACE,
|
||||
'/resolve/(?P<post_id>[\d]+)',
|
||||
array(
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'handle_resolve' ),
|
||||
'permission_callback' => array( $this, 'check_permission' ),
|
||||
'args' => array(
|
||||
'post_id' => array(
|
||||
'required' => true,
|
||||
'validate_callback' => function ( $value ) {
|
||||
return is_numeric( $value ) && (int) $value > 0;
|
||||
},
|
||||
'sanitize_callback' => 'absint',
|
||||
'description' => __( 'The post ID to resolve the fallback image for.', 'robotstxt-og' ),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Status endpoint: get current cached status for a post.
|
||||
register_rest_route(
|
||||
self::NAMESPACE,
|
||||
'/status/(?P<post_id>[\d]+)',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'handle_status' ),
|
||||
'permission_callback' => array( $this, 'check_permission' ),
|
||||
'args' => array(
|
||||
'post_id' => array(
|
||||
'required' => true,
|
||||
'validate_callback' => function ( $value ) {
|
||||
return is_numeric( $value ) && (int) $value > 0;
|
||||
},
|
||||
'sanitize_callback' => 'absint',
|
||||
'description' => __( 'The post ID to get the fallback image status for.', 'robotstxt-og' ),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the resolve endpoint.
|
||||
*
|
||||
* Forces re-resolution of the fallback image for the specified post.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param WP_REST_Request $request REST request object.
|
||||
* @return WP_REST_Response|WP_Error Response object.
|
||||
*/
|
||||
public function handle_resolve( WP_REST_Request $request ) {
|
||||
$post_id = (int) $request->get_param( 'post_id' );
|
||||
|
||||
// Verify post exists.
|
||||
$post = get_post( $post_id );
|
||||
|
||||
if ( ! $post ) {
|
||||
return new WP_Error(
|
||||
'post_not_found',
|
||||
__( 'Post not found.', 'robotstxt-og' ),
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
// Clear existing cache and resolve.
|
||||
$this->resolver->clear_cache( $post_id );
|
||||
$resolved_url = $this->resolver->resolve_image( $post_id );
|
||||
|
||||
$data = array(
|
||||
'post_id' => $post_id,
|
||||
'resolved_url' => $resolved_url,
|
||||
'cached' => ! empty( get_post_meta( $post_id, '_og_image_fallback_url', true ) ),
|
||||
'success' => ! empty( $resolved_url ),
|
||||
);
|
||||
|
||||
return new WP_REST_Response( $data, 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the status endpoint.
|
||||
*
|
||||
* Returns the current cached fallback image status for a post.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param WP_REST_Request $request REST request object.
|
||||
* @return WP_REST_Response|WP_Error Response object.
|
||||
*/
|
||||
public function handle_status( WP_REST_Request $request ) {
|
||||
$post_id = (int) $request->get_param( 'post_id' );
|
||||
|
||||
// Verify post exists.
|
||||
$post = get_post( $post_id );
|
||||
|
||||
if ( ! $post ) {
|
||||
return new WP_Error(
|
||||
'post_not_found',
|
||||
__( 'Post not found.', 'robotstxt-og' ),
|
||||
array( 'status' => 404 )
|
||||
);
|
||||
}
|
||||
|
||||
$cached_url = get_post_meta( $post_id, '_og_image_fallback_url', true );
|
||||
$thumbnail_id = get_post_thumbnail_id( $post_id );
|
||||
$original_url = $thumbnail_id ? wp_get_attachment_url( $thumbnail_id ) : '';
|
||||
|
||||
$data = array(
|
||||
'post_id' => $post_id,
|
||||
'cached_url' => $cached_url,
|
||||
'original_url' => $original_url,
|
||||
'has_cache' => ! empty( $cached_url ),
|
||||
);
|
||||
|
||||
return new WP_REST_Response( $data, 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check REST API permission.
|
||||
*
|
||||
* Requires manage_options capability.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return bool|WP_Error True if authorized, WP_Error otherwise.
|
||||
*/
|
||||
public function check_permission() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return new WP_Error(
|
||||
'rest_forbidden',
|
||||
__( 'You do not have permission to access this endpoint.', 'robotstxt-og' ),
|
||||
array( 'status' => 403 )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
579
includes/class-robotstxt-og-tags.php
Normal file
579
includes/class-robotstxt-og-tags.php
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
<?php
|
||||
/**
|
||||
* OG Tags Generator Class
|
||||
*
|
||||
* Handles Open Graph and Twitter Card meta tag generation and injection.
|
||||
*
|
||||
* @package ROBOTSTXT_OG
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Robotstxt_OG_Tags
|
||||
*
|
||||
* Generates and injects a complete set of Open Graph and Twitter Card meta tags
|
||||
* for social media crawlers. When no SEO plugin is detected, outputs all standard
|
||||
* OG tags (title, type, URL, description, site name, locale, image, article-specific).
|
||||
* When Yoast SEO or RankMath is active, acts as an image corrector only.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
class Robotstxt_OG_Tags {
|
||||
|
||||
/**
|
||||
* Image resolver instance.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var Robotstxt_OG_Image_Resolver
|
||||
*/
|
||||
private Robotstxt_OG_Image_Resolver $resolver;
|
||||
|
||||
/**
|
||||
* Whether to inject tags directly (vs using SEO plugin filters).
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @var bool
|
||||
*/
|
||||
private bool $inject_directly = true;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param Robotstxt_OG_Image_Resolver $resolver Image resolver instance.
|
||||
*/
|
||||
public function __construct( Robotstxt_OG_Image_Resolver $resolver ) {
|
||||
$this->resolver = $resolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize hooks.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function init(): void {
|
||||
// Detect SEO plugins and use appropriate integration method.
|
||||
$this->detect_seo_plugins();
|
||||
|
||||
if ( $this->inject_directly ) {
|
||||
// No SEO plugin detected, inject all tags directly.
|
||||
add_action( 'wp_head', array( $this, 'inject_og_tags' ), 5 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect active SEO plugins and set up filters.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function detect_seo_plugins(): void {
|
||||
// Check for Yoast SEO.
|
||||
if ( defined( 'WPSEO_VERSION' ) ) {
|
||||
$this->inject_directly = false;
|
||||
add_filter( 'wpseo_opengraph_image', array( $this, 'filter_yoast_image' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for RankMath.
|
||||
if ( class_exists( 'RankMath' ) ) {
|
||||
$this->inject_directly = false;
|
||||
add_filter( 'rank_math/opengraph/facebook/og_image', array( $this, 'filter_rankmath_image' ) );
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject all Open Graph and Twitter Card meta tags directly.
|
||||
*
|
||||
* Outputs a complete set of OG tags (og:title, og:type, og:url, og:description,
|
||||
* og:site_name, og:locale, og:image and related), article-specific tags when
|
||||
* applicable, and Twitter Card tags. Respects the enable/disable settings for
|
||||
* each tag group.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function inject_og_tags(): void {
|
||||
$enable_facebook = (bool) get_option( 'robotstxt_og_enable_facebook', true );
|
||||
$enable_twitter = (bool) get_option( 'robotstxt_og_enable_twitter', true );
|
||||
|
||||
if ( ! $enable_facebook && ! $enable_twitter ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$image_url = $this->get_contextual_image();
|
||||
|
||||
if ( $enable_facebook ) {
|
||||
// Output all basic OG tags: title, type, url, description, site_name, locale.
|
||||
$this->output_og_basic_tags();
|
||||
|
||||
// Output image tags only when a compatible image is available.
|
||||
if ( ! empty( $image_url ) ) {
|
||||
$this->output_og_image_tags( $image_url );
|
||||
}
|
||||
|
||||
// Output article-specific tags (only for singular blog posts).
|
||||
$this->output_article_tags();
|
||||
}
|
||||
|
||||
if ( $enable_twitter ) {
|
||||
$this->output_twitter_card_tags( $image_url );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter Yoast SEO's Open Graph image.
|
||||
*
|
||||
* When Yoast SEO is active, this plugin acts as a corrector only,
|
||||
* replacing incompatible image formats with JPEG/PNG alternatives.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $image Original image URL from Yoast.
|
||||
* @return string Filtered image URL.
|
||||
*/
|
||||
public function filter_yoast_image( string $image ): string {
|
||||
if ( ! (bool) get_option( 'robotstxt_og_enable_facebook', true ) ) {
|
||||
return $image;
|
||||
}
|
||||
|
||||
$image_url = $this->get_contextual_image();
|
||||
|
||||
return ! empty( $image_url ) ? $image_url : $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter RankMath's Open Graph image.
|
||||
*
|
||||
* When RankMath is active, this plugin acts as a corrector only,
|
||||
* replacing incompatible image formats with JPEG/PNG alternatives.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $image Original image URL from RankMath.
|
||||
* @return string Filtered image URL.
|
||||
*/
|
||||
public function filter_rankmath_image( string $image ): string {
|
||||
if ( ! (bool) get_option( 'robotstxt_og_enable_facebook', true ) ) {
|
||||
return $image;
|
||||
}
|
||||
|
||||
$image_url = $this->get_contextual_image();
|
||||
|
||||
return ! empty( $image_url ) ? $image_url : $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resolved image for the current context.
|
||||
*
|
||||
* Handles homepage, singular pages, and taxonomy archives.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @return string Image URL or empty string.
|
||||
*/
|
||||
private function get_contextual_image(): string {
|
||||
// Homepage / front page.
|
||||
if ( is_front_page() || is_home() ) {
|
||||
return $this->resolver->get_homepage_image();
|
||||
}
|
||||
|
||||
// Singular post/page.
|
||||
if ( is_singular() ) {
|
||||
$post_id = get_queried_object_id();
|
||||
|
||||
if ( empty( $post_id ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->resolver->get_fallback_image( $post_id );
|
||||
}
|
||||
|
||||
// Taxonomy archive.
|
||||
if ( is_tax() || is_category() || is_tag() ) {
|
||||
$term = get_queried_object();
|
||||
|
||||
if ( ! ( $term instanceof WP_Term ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->resolver->get_taxonomy_fallback_image( $term->term_id );
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the og:title for the current context.
|
||||
*
|
||||
* For singular posts/pages, checks for a custom _og_title override first.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return string The title string.
|
||||
*/
|
||||
private function get_og_title(): string {
|
||||
if ( is_singular() ) {
|
||||
$post_id = get_queried_object_id();
|
||||
$custom_title = (string) get_post_meta( $post_id, '_og_title', true );
|
||||
|
||||
if ( ! empty( $custom_title ) ) {
|
||||
return $custom_title;
|
||||
}
|
||||
|
||||
return (string) get_the_title( $post_id );
|
||||
}
|
||||
|
||||
if ( is_front_page() || is_home() ) {
|
||||
return (string) get_bloginfo( 'name' );
|
||||
}
|
||||
|
||||
if ( is_tax() || is_category() || is_tag() ) {
|
||||
$term = get_queried_object();
|
||||
|
||||
if ( $term instanceof WP_Term ) {
|
||||
return (string) $term->name;
|
||||
}
|
||||
}
|
||||
|
||||
return (string) get_bloginfo( 'name' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the og:description for the current context.
|
||||
*
|
||||
* For singular posts/pages, checks for a custom _og_description override first,
|
||||
* then falls back to the post excerpt.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return string The description string.
|
||||
*/
|
||||
private function get_og_description(): string {
|
||||
if ( is_singular() ) {
|
||||
$post_id = get_queried_object_id();
|
||||
$custom_desc = (string) get_post_meta( $post_id, '_og_description', true );
|
||||
|
||||
if ( ! empty( $custom_desc ) ) {
|
||||
return $custom_desc;
|
||||
}
|
||||
|
||||
$excerpt = (string) get_post_field( 'post_excerpt', $post_id );
|
||||
|
||||
if ( ! empty( $excerpt ) ) {
|
||||
return wp_strip_all_tags( $excerpt );
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
if ( is_front_page() || is_home() ) {
|
||||
return (string) get_bloginfo( 'description' );
|
||||
}
|
||||
|
||||
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 );
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the og:url for the current context.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return string The canonical URL.
|
||||
*/
|
||||
private function get_og_url(): string {
|
||||
if ( is_singular() ) {
|
||||
return (string) get_permalink();
|
||||
}
|
||||
|
||||
if ( is_front_page() || is_home() ) {
|
||||
return home_url( '/' );
|
||||
}
|
||||
|
||||
if ( is_tax() || is_category() || is_tag() ) {
|
||||
$term = get_queried_object();
|
||||
|
||||
if ( $term instanceof WP_Term ) {
|
||||
$term_link = get_term_link( $term );
|
||||
|
||||
if ( ! is_wp_error( $term_link ) ) {
|
||||
return (string) $term_link;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return home_url( '/' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the og:type for the current context.
|
||||
*
|
||||
* Returns 'article' for singular blog posts (post type = post),
|
||||
* and 'website' for all other contexts.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return string 'article' or 'website'.
|
||||
*/
|
||||
private function get_og_type(): string {
|
||||
if ( is_singular( 'post' ) ) {
|
||||
return 'article';
|
||||
}
|
||||
|
||||
return 'website';
|
||||
}
|
||||
|
||||
/**
|
||||
* Output basic Open Graph meta tags (non-image).
|
||||
*
|
||||
* Outputs og:title, og:type, og:url, og:description, og:site_name, og:locale.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function output_og_basic_tags(): void {
|
||||
$title = $this->get_og_title();
|
||||
$type = $this->get_og_type();
|
||||
$url = $this->get_og_url();
|
||||
$description = $this->get_og_description();
|
||||
$site_name = (string) get_bloginfo( 'name' );
|
||||
$locale = (string) get_locale();
|
||||
|
||||
if ( ! empty( $title ) ) {
|
||||
printf(
|
||||
'<meta property="og:title" content="%s" />' . "\n",
|
||||
esc_attr( $title )
|
||||
);
|
||||
}
|
||||
|
||||
printf(
|
||||
'<meta property="og:type" content="%s" />' . "\n",
|
||||
esc_attr( $type )
|
||||
);
|
||||
|
||||
if ( ! empty( $url ) ) {
|
||||
printf(
|
||||
'<meta property="og:url" content="%s" />' . "\n",
|
||||
esc_url( $url )
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! empty( $description ) ) {
|
||||
printf(
|
||||
'<meta property="og:description" content="%s" />' . "\n",
|
||||
esc_attr( $description )
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! empty( $site_name ) ) {
|
||||
printf(
|
||||
'<meta property="og:site_name" content="%s" />' . "\n",
|
||||
esc_attr( $site_name )
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! empty( $locale ) ) {
|
||||
printf(
|
||||
'<meta property="og:locale" content="%s" />' . "\n",
|
||||
esc_attr( $locale )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output Open Graph image meta tags.
|
||||
*
|
||||
* Outputs og:image, og:image:secure_url (HTTPS only), og:image:width,
|
||||
* og:image:height, og:image:type, and og:image:alt when available.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param string $image_url Image URL.
|
||||
* @return void
|
||||
*/
|
||||
private function output_og_image_tags( string $image_url ): void {
|
||||
// Basic OG image tag.
|
||||
printf(
|
||||
'<meta property="og:image" content="%s" />' . "\n",
|
||||
esc_url( $image_url )
|
||||
);
|
||||
|
||||
// Secure URL for HTTPS.
|
||||
if ( is_ssl() ) {
|
||||
printf(
|
||||
'<meta property="og:image:secure_url" content="%s" />' . "\n",
|
||||
esc_url( $image_url )
|
||||
);
|
||||
}
|
||||
|
||||
// Try to get dimensions, MIME type, and alt text from the media library.
|
||||
$image_id = attachment_url_to_postid( $image_url );
|
||||
|
||||
if ( $image_id ) {
|
||||
$metadata = wp_get_attachment_metadata( $image_id );
|
||||
|
||||
if ( ! empty( $metadata['width'] ) ) {
|
||||
printf(
|
||||
'<meta property="og:image:width" content="%d" />' . "\n",
|
||||
absint( $metadata['width'] )
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! empty( $metadata['height'] ) ) {
|
||||
printf(
|
||||
'<meta property="og:image:height" content="%d" />' . "\n",
|
||||
absint( $metadata['height'] )
|
||||
);
|
||||
}
|
||||
|
||||
$mime_type = get_post_mime_type( $image_id );
|
||||
|
||||
if ( ! empty( $mime_type ) ) {
|
||||
printf(
|
||||
'<meta property="og:image:type" content="%s" />' . "\n",
|
||||
esc_attr( $mime_type )
|
||||
);
|
||||
}
|
||||
|
||||
// Alt text from the media library (set when uploading/editing the image).
|
||||
$alt_text = (string) get_post_meta( $image_id, '_wp_attachment_image_alt', true );
|
||||
|
||||
if ( ! empty( $alt_text ) ) {
|
||||
printf(
|
||||
'<meta property="og:image:alt" content="%s" />' . "\n",
|
||||
esc_attr( $alt_text )
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output article-specific Open Graph meta tags.
|
||||
*
|
||||
* Outputs article:published_time, article:modified_time, article:section,
|
||||
* and article:tag. Only runs when the current page is a singular blog post.
|
||||
*
|
||||
* @since 1.2.0
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function output_article_tags(): void {
|
||||
if ( ! is_singular( 'post' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post = get_queried_object();
|
||||
|
||||
if ( ! ( $post instanceof WP_Post ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$published = get_the_date( 'c', $post );
|
||||
|
||||
if ( ! empty( $published ) ) {
|
||||
printf(
|
||||
'<meta property="article:published_time" content="%s" />' . "\n",
|
||||
esc_attr( $published )
|
||||
);
|
||||
}
|
||||
|
||||
$modified = get_the_modified_date( 'c', $post );
|
||||
|
||||
if ( ! empty( $modified ) ) {
|
||||
printf(
|
||||
'<meta property="article:modified_time" content="%s" />' . "\n",
|
||||
esc_attr( $modified )
|
||||
);
|
||||
}
|
||||
|
||||
// Primary category as og:article:section.
|
||||
$categories = get_the_category( $post->ID );
|
||||
|
||||
if ( ! empty( $categories ) ) {
|
||||
printf(
|
||||
'<meta property="article:section" content="%s" />' . "\n",
|
||||
esc_attr( $categories[0]->name )
|
||||
);
|
||||
}
|
||||
|
||||
// Post tags as og:article:tag (one tag per meta tag).
|
||||
$tags = get_the_tags( $post->ID );
|
||||
|
||||
if ( is_array( $tags ) ) {
|
||||
foreach ( $tags as $tag ) {
|
||||
printf(
|
||||
'<meta property="article:tag" content="%s" />' . "\n",
|
||||
esc_attr( $tag->name )
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output Twitter Card meta tags.
|
||||
*
|
||||
* Outputs twitter:card (always), twitter:site (when configured),
|
||||
* and twitter:image (when an image is available). Twitter falls back
|
||||
* to og:title, og:description automatically, so those are not duplicated.
|
||||
*
|
||||
* @since 1.1.0
|
||||
*
|
||||
* @param string $image_url Image URL, or empty string if no image available.
|
||||
* @return void
|
||||
*/
|
||||
private function output_twitter_card_tags( string $image_url ): void {
|
||||
$card_type = (string) get_option( 'robotstxt_og_twitter_card_type', 'summary_large_image' );
|
||||
|
||||
if ( ! in_array( $card_type, array( 'summary', 'summary_large_image' ), true ) ) {
|
||||
$card_type = 'summary_large_image';
|
||||
}
|
||||
|
||||
// twitter:card is mandatory for Twitter Cards to function.
|
||||
printf(
|
||||
'<meta name="twitter:card" content="%s" />' . "\n",
|
||||
esc_attr( $card_type )
|
||||
);
|
||||
|
||||
// Site handle (e.g. @example).
|
||||
$twitter_site = sanitize_text_field( (string) get_option( 'robotstxt_og_twitter_site', '' ) );
|
||||
|
||||
if ( ! empty( $twitter_site ) ) {
|
||||
// Ensure the handle includes the @ prefix.
|
||||
if ( '@' !== substr( $twitter_site, 0, 1 ) ) {
|
||||
$twitter_site = '@' . $twitter_site;
|
||||
}
|
||||
|
||||
printf(
|
||||
'<meta name="twitter:site" content="%s" />' . "\n",
|
||||
esc_attr( $twitter_site )
|
||||
);
|
||||
}
|
||||
|
||||
// Image (only when available; Twitter falls back to og:image otherwise).
|
||||
if ( ! empty( $image_url ) ) {
|
||||
printf(
|
||||
'<meta name="twitter:image" content="%s" />' . "\n",
|
||||
esc_url( $image_url )
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue