card_post_id = $post instanceof \WP_Post ? (int) $post->ID : 0; return $data; } /** * Close the card window once the response is complete. * * @since 2.7.0 * * @param mixed $data Response data, passed through untouched. * @return mixed */ public function end_card($data) { $this->card_post_id = 0; return $data; } /** * Put the SEO title wherever the embed card prints the post title. * * That is the `html` core builds for the oEmbed response (blockquote link * and iframe title) and the heading of the embed template. Everywhere else * the title is left alone, and the cheap checks run before any setting is * read, since `the_title` fires for every title on every page. * * Core prints this value as HTML, so the plain-text SEO title is escaped. * * @since 2.7.0 * * @param mixed $title Title. * @param mixed $post_id Post ID. * @return mixed */ public function filter_card_title($title, $post_id = 0) { $post_id = (int) $post_id; if ($post_id <= 0 || !$this->is_card_title($post_id)) { return $title; } if (empty($this->settings()['oembed_use_seo_title'])) { return $title; } $post = get_post($post_id); if (!$post instanceof \WP_Post) { return $title; } $seo_title = $this->seo_title($post); return '' === $seo_title ? $title : esc_html($seo_title); } /** * Whether a title is being printed for an embed card. * * @since 2.7.0 * * @param int $post_id Post ID. * @return bool */ private function is_card_title(int $post_id): bool { if ($post_id === $this->card_post_id) { return true; } return function_exists('is_embed') && is_embed() && $post_id === (int) get_queried_object_id(); } /** * Apply the site's choices to one oEmbed response. * * @since 2.7.0 * * Untyped in and out on purpose. WordPress hands a filter whatever the * previous callback returned, so the guards below have to be able to hand * it straight back — and a declared `: array` return would turn passing a * non-array through into a TypeError on the front end, which is the one * thing a defensive guard must not do. * * @param mixed $data The response data, normally an array. * @param mixed $post The post the response describes. * @return mixed */ public function filter_response($data, $post) { if (!is_array($data)) { return $data; } if (!$post instanceof \WP_Post) { return $data; } $settings = $this->settings(); if (!empty($settings['oembed_use_seo_title'])) { $title = $this->seo_title($post); if ('' !== $title) { $data['title'] = $title; } } if (!empty($settings['oembed_use_social_image'])) { $data = $this->with_social_image($data, $post, $settings); } if (!empty($settings['oembed_remove_author'])) { unset($data['author_name'], $data['author_url']); } return $data; } /** * The post's SEO title, as an embed card should carry it. * * Deliberately the same chain the breadcrumb trail uses: the post's own * `_thinkrank_seo_title` with its variable tags resolved, and nothing else. * The global title pattern is not part of it, for the reason it is not part * of a breadcrumb either — resolving it appends the site name, and an * oEmbed payload already carries the site name in `provider_name`, so the * card would read "Post Title | Site Name — Site Name". * * @since 2.7.0 * @param \WP_Post $post Post being embedded. * @return string Title, or '' to leave WordPress's alone. */ private function seo_title(\WP_Post $post): string { $seo_title = trim((string) get_post_meta($post->ID, '_thinkrank_seo_title', true)); if ('' === $seo_title) { return ''; } return trim(Pattern_Resolver::resolve_value($seo_title, $post->ID)); } /** * Put the site's chosen social image on the card. * * Only ever replaces a thumbnail with a deliberate choice: the post's own * Open Graph image beats the featured image WordPress already used, and the * site-wide default is consulted only when the post has neither, so a post * with a featured image and no social image keeps the card it had. * * @since 2.7.0 * * @param array $data Response data. * @param \WP_Post $post Post being embedded. * @param array $settings Social settings. * @return array */ private function with_social_image(array $data, \WP_Post $post, array $settings): array { $image = trim((string) get_post_meta($post->ID, '_thinkrank_og_image', true)); if ('' === $image) { // WordPress already put the featured image here, and it is a // better answer than a site-wide default. if (!empty($data['thumbnail_url'])) { return $data; } foreach (['default_og_image', 'default_image'] as $key) { $candidate = trim((string) ($settings[$key] ?? '')); if ('' !== $candidate) { $image = $candidate; break; } } } if ('' === $image) { return $data; } $image = esc_url_raw($image); if ('' === $image) { return $data; } $data['thumbnail_url'] = $image; // oEmbed requires width and height alongside a thumbnail_url, and a // consumer is entitled to reject a payload carrying one without them. // WordPress itself always sends all three, so a partial replacement // would leave the old image's dimensions describing the new image. [$width, $height] = $this->image_dimensions($image, $settings); if ($width > 0 && $height > 0) { $data['thumbnail_width'] = $width; $data['thumbnail_height'] = $height; } else { unset($data['thumbnail_width'], $data['thumbnail_height']); } return $data; } /** * Real dimensions for an image URL, or the configured Open Graph size. * * @since 2.7.0 * * @param string $url Image URL. * @param array $settings Social settings. * @return array{0:int,1:int} Width and height; 0,0 when unknown. */ private function image_dimensions(string $url, array $settings): array { $attachment_id = attachment_url_to_postid($url); if ($attachment_id) { $meta = wp_get_attachment_image_src($attachment_id, 'full'); if (is_array($meta) && !empty($meta[1]) && !empty($meta[2])) { return [(int) $meta[1], (int) $meta[2]]; } } // An image hosted elsewhere cannot be measured without fetching it, so // fall back to the dimensions the site already declares for its Open // Graph images rather than dropping the thumbnail entirely. $width = (int) ($settings['og_image_width'] ?? 0); $height = (int) ($settings['og_image_height'] ?? 0); return [max(0, $width), max(0, $height)]; } /** * Site-context social settings, read once. * * @since 2.7.0 * @return array */ private function settings(): array { if (null === $this->settings) { $this->settings = $this->load_settings(); } return $this->settings; } /** * Read the social settings. * * Its own method so the filter can be exercised without standing up a * settings store: the interesting behaviour here is which of three * switches changed which field, not where the switches came from. * * @since 2.7.0 * @return array */ protected function load_settings(): array { return (array) (new Social_Meta_Manager())->get_settings('site', null); } }