*/ public static function networks(): array { return array( 'facebook' => 'Facebook', 'x' => 'X (Twitter)', 'instagram' => 'Instagram', 'linkedin' => 'LinkedIn', 'youtube' => 'YouTube', 'tiktok' => 'TikTok', 'pinterest' => 'Pinterest', 'wikipedia' => 'Wikipedia', ); } public function id(): string { return 'social'; } /** @return array */ public static function defaults(): array { return array( 'default_image' => '', // Site-wide fallback share image URL. 'fb_app_id' => '', 'fb_page' => '', // Facebook page URL → article:publisher. 'tw_site' => '', // @handle → twitter:site. 'og_locale' => true, 'article_meta' => true, // article:published_time & friends. 'profiles' => array(), ); } public function register( Container $container ): void {} public function boot( Container $container ): void { add_action( 'rest_api_init', array( $this, 'routes' ) ); // Google Rich Results prefers images in 16:9, 4:3 AND 1:1 — register // the crops so new uploads get all three automatically. add_action( 'init', static function (): void { add_image_size( 'airanksuite-schema-4x3', 1200, 900, true ); add_image_size( 'airanksuite-schema-1x1', 1200, 1200, true ); } ); // Upgrade the primary image of Article/WebPage nodes to the // multi-aspect array Google asks for. add_filter( 'airanksuite/schema/graph', static function ( array $graph ): array { if ( ! is_singular() ) { return $graph; } $thumb_id = get_post_thumbnail_id(); if ( ! $thumb_id ) { return $graph; } $urls = array(); foreach ( array( 'full', 'airanksuite-schema-4x3', 'airanksuite-schema-1x1' ) as $size ) { $src = wp_get_attachment_image_src( $thumb_id, $size ); if ( is_array( $src ) && (int) $src[1] > 0 ) { $urls[ $src[0] ] = array( '@type' => 'ImageObject', 'url' => $src[0], 'width' => (int) $src[1], 'height' => (int) $src[2], ); } } if ( empty( $urls ) ) { return $graph; } $images = array_values( $urls ); foreach ( $graph as $i => $node ) { $type = (array) ( $node['@type'] ?? array() ); if ( array_intersect( array( 'Article', 'BlogPosting', 'NewsArticle', 'WebPage' ), $type ) ) { $graph[ $i ]['image'] = count( $images ) > 1 ? $images : $images[0]; } } return $graph; }, 30 ); // Feed the social profiles into the Organization node as sameAs — // through our own public filter, the same way third parties would. add_filter( 'airanksuite/schema/graph', function ( array $graph ): array { $profiles = array_values( array_filter( array_map( 'strval', (array) $this->settings()['profiles'] ) ) ); if ( empty( $profiles ) ) { return $graph; } foreach ( $graph as $i => $node ) { if ( isset( $node['@id'] ) && str_ends_with( (string) $node['@id'], '#organization' ) ) { $existing = array_map( 'strval', (array) ( $node['sameAs'] ?? array() ) ); $graph[ $i ]['sameAs'] = array_values( array_unique( array_merge( $existing, $profiles ) ) ); break; } } return $graph; }, 20 ); // Site-wide head additions on singular views, after SeoModule's block. Guard::action( 'wp_head', array( $this, 'head_tags' ), 2 ); // The platform-specific crops every network documents. Registering them // means WordPress generates the file once at upload instead of letting // the network downscale a 4000px original badly. add_action( 'init', static function (): void { add_image_size( 'airanksuite-og', 1200, 630, true ); // Open Graph / oEmbed. add_image_size( 'airanksuite-twitter', 1200, 600, true ); // X summary_large_image. add_image_size( 'airanksuite-pinterest', 1000, 1500, true ); // Pinterest 2:3 pin. add_image_size( 'airanksuite-catalog', 1200, 1200, true ); // Commerce Manager. add_image_size( 'airanksuite-schema-thumb', 480, 480, true ); // Schema thumbnail. } ); // oEmbed is what LinkedIn, Discord and Drupal read — WordPress ships a // bare-bones response, so enrich it with our resolved values. add_filter( 'oembed_response_data', array( $this, 'oembed_data' ), 10, 2 ); } /** * Publisher-level tags SeoModule does not know about. */ public function head_tags(): void { if ( ! is_singular() ) { return; } $post = get_queried_object(); if ( ! $post instanceof \WP_Post ) { return; } $s = $this->settings(); if ( ! empty( $s['og_locale'] ) ) { printf( '' . "\n", esc_attr( str_replace( '-', '_', get_locale() ) ) ); } if ( '' !== (string) $s['fb_app_id'] ) { printf( '' . "\n", esc_attr( (string) $s['fb_app_id'] ) ); } if ( '' !== (string) $s['fb_page'] ) { printf( '' . "\n", esc_url( (string) $s['fb_page'] ) ); } if ( '' !== (string) $s['tw_site'] ) { printf( '' . "\n", esc_attr( '@' . ltrim( (string) $s['tw_site'], '@' ) ) ); } // First embedded video → og:video, so shared links get an inline player. $video = self::detect_video( (string) $post->post_content ); if ( '' !== $video ) { printf( '' . "\n", esc_url( $video ) ); printf( '' . "\n" ); } if ( ! empty( $s['article_meta'] ) && 'post' === $post->post_type ) { printf( '' . "\n", esc_attr( (string) get_the_date( 'c', $post ) ) ); printf( '' . "\n", esc_attr( (string) get_the_modified_date( 'c', $post ) ) ); $author = get_the_author_meta( 'display_name', (int) $post->post_author ); if ( '' !== $author ) { printf( '' . "\n", esc_attr( $author ) ); } $categories = get_the_terms( $post, 'category' ); if ( is_array( $categories ) && ! empty( $categories ) ) { printf( '' . "\n", esc_attr( (string) $categories[0]->name ) ); } $tags = get_the_terms( $post, 'post_tag' ); if ( is_array( $tags ) ) { foreach ( array_slice( $tags, 0, 6 ) as $tag ) { printf( '' . "\n", esc_attr( (string) $tag->name ) ); } } } } /** * Enrich the oEmbed payload LinkedIn, Discord and Drupal consume. * * @param array $data The response. * @param \WP_Post $post The post. * @return array */ public function oembed_data( $data, $post ) { if ( ! is_array( $data ) || ! $post instanceof \WP_Post ) { return $data; } $title = \AiRankSuite\Modules\Seo\SeoModule::resolve_title( $post ); $desc = (string) get_post_meta( $post->ID, '_airanksuite_seo_desc', true ); if ( '' === trim( $desc ) ) { $desc = wp_strip_all_tags( (string) get_the_excerpt( $post ) ); } $card = \AiRankSuite\Support\PostSeo::social_card( $post, $title, $desc ); $data['title'] = $card['og_title']; $data['description'] = $card['og_desc']; if ( '' !== $card['og_image'] ) { $data['thumbnail_url'] = $card['og_image']; if ( is_array( $card['image_meta'] ?? null ) ) { $data['thumbnail_width'] = (int) $card['image_meta']['width']; $data['thumbnail_height'] = (int) $card['image_meta']['height']; } } return $data; } /** * First YouTube/Vimeo embed URL in the content, or ''. */ public static function detect_video( string $content ): string { if ( preg_match( '#https?://(?:www\.)?(?:youtube\.com/(?:watch\?v=|embed/)|youtu\.be/)([A-Za-z0-9_-]{6,})#', $content, $m ) ) { return 'https://www.youtube.com/embed/' . $m[1]; } if ( preg_match( '#https?://(?:www\.)?vimeo\.com/(\d+)#', $content, $m ) ) { return 'https://player.vimeo.com/video/' . $m[1]; } return ''; } /** * Site-wide fallback share image (empty string when unset). */ public static function default_image(): string { $settings = \AiRankSuite\Kernel::service( Options::class )->get( 'social', self::defaults() ); return (string) $settings['default_image']; } /** * Resolve intrinsic size + alt for an image URL served from this site. * * @return array{width: int, height: int, alt: string}|null */ public static function image_meta( string $url ): ?array { if ( '' === $url ) { return null; } // attachment_url_to_postid() is an unindexed LIKE over postmeta — // cache the resolution, it never changes for a given URL. $cache_key = 'airanksuite_att_' . md5( $url ); $cached = wp_cache_get( $cache_key, 'airanksuite' ); if ( false !== $cached ) { return is_array( $cached ) ? $cached : null; } $attachment_id = attachment_url_to_postid( $url ); if ( 0 === $attachment_id ) { // Sized variants (…-1024x683.jpg) do not resolve — try the original. $original = (string) preg_replace( '/-\d+x\d+(\.[a-z]{3,4})$/i', '$1', $url ); if ( $original !== $url ) { $attachment_id = attachment_url_to_postid( $original ); } } if ( 0 === $attachment_id ) { wp_cache_set( $cache_key, 'none', 'airanksuite', HOUR_IN_SECONDS * 12 ); return null; } $src = wp_get_attachment_image_src( $attachment_id, 'full' ); // A missing file yields zero dimensions — emitting og:image:width="0" // would make crawlers distrust the whole tag set, so return nothing. if ( ! is_array( $src ) || (int) $src[1] <= 0 || (int) $src[2] <= 0 ) { wp_cache_set( $cache_key, 'none', 'airanksuite', HOUR_IN_SECONDS * 12 ); return null; } $meta = array( 'width' => (int) $src[1], 'height' => (int) $src[2], 'alt' => (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ), ); wp_cache_set( $cache_key, $meta, 'airanksuite', HOUR_IN_SECONDS * 12 ); return $meta; } public function routes(): void { register_rest_route( self::NAMESPACE, '/settings/social', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_settings' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), ), array( 'methods' => 'PUT', 'callback' => array( $this, 'update_settings' ), 'permission_callback' => static fn(): bool => current_user_can( 'manage_options' ), 'args' => array( 'default_image' => array( 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', ), 'fb_app_id' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'fb_page' => array( 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', ), 'tw_site' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'og_locale' => array( 'type' => 'boolean' ), 'article_meta' => array( 'type' => 'boolean' ), 'profiles' => array( 'type' => 'object' ), ), ), ) ); } public function get_settings(): WP_REST_Response { $settings = $this->settings(); $image = (string) $settings['default_image']; return new WP_REST_Response( array( 'settings' => $settings, 'networks' => self::networks(), 'image_meta' => self::image_meta( $image ), 'locale' => str_replace( '-', '_', get_locale() ), 'home_url' => home_url( '/' ), 'validators' => array( array( 'label' => 'Facebook Sharing Debugger', 'url' => 'https://developers.facebook.com/tools/debug/?q=' . rawurlencode( home_url( '/' ) ), ), array( 'label' => 'LinkedIn Post Inspector', 'url' => 'https://www.linkedin.com/post-inspector/inspect/' . rawurlencode( home_url( '/' ) ), ), array( 'label' => 'Google Rich Results Test', 'url' => 'https://search.google.com/test/rich-results?url=' . rawurlencode( home_url( '/' ) ), ), array( 'label' => 'Schema.org Validator', 'url' => 'https://validator.schema.org/#url=' . rawurlencode( home_url( '/' ) ), ), ), ) ); } public function update_settings( WP_REST_Request $request ): WP_REST_Response { $defaults = self::defaults(); $values = array(); foreach ( array_keys( $defaults ) as $key ) { if ( ! $request->has_param( $key ) ) { continue; } if ( 'profiles' === $key ) { $clean = array(); foreach ( (array) $request->get_param( 'profiles' ) as $network => $url ) { $network = sanitize_key( (string) $network ); $url = esc_url_raw( trim( (string) $url ) ); if ( isset( self::networks()[ $network ] ) && '' !== $url ) { $clean[ $network ] = $url; } } $values['profiles'] = $clean; continue; } $values[ $key ] = $request->get_param( $key ); } \AiRankSuite\Kernel::service( Options::class )->set( 'social', $values, $defaults ); return $this->get_settings(); } /** @return array */ private function settings(): array { return \AiRankSuite\Kernel::service( Options::class )->get( 'social', self::defaults() ); } }