PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / seo / class-oembed-manager.php

class-oembed-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/seo/class-oembed-manager.php

351 lines 11.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * What ThinkRank puts in the oEmbed card other sites show for a post.
4 *
5 * @package ThinkRank
6 * @since 2.7.0
7 */
8
9 declare(strict_types=1);
10
11 namespace ThinkRank\SEO;
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 /**
18 * Applies the site's SEO metadata to WordPress's oEmbed response.
19 *
20 * Paste a link to a post into another WordPress site, or into an app that
21 * speaks oEmbed, and WordPress answers with a small card: title, author,
22 * thumbnail. None of it knows about the SEO metadata the site has written. The
23 * card shows the raw post title rather than the SEO title, carries the author's
24 * personal name whether or not the site wants that travelling with every embed,
25 * and has no image at all unless the post happens to have a featured one — the
26 * social image the site chose is never consulted.
27 *
28 * Three settings, all default off, change that. Off by default because this
29 * rewrites what other people's sites display: an upgrade must not silently
30 * change the card an existing embed has been showing for months.
31 *
32 * Scope worth being precise about. A non-WordPress consumer renders the oEmbed
33 * *payload*. A WordPress-to-WordPress embed renders an iframe of this site's
34 * own embed template instead, with the payload's `html` blockquote as its
35 * fallback. `oembed_use_seo_title` covers all three: the payload `title`, the
36 * blockquote link text and iframe `title` inside `html`, and the heading of
37 * the embed template (#737). Core's template shows no author line, so
38 * `oembed_remove_author` is about the payload only: apps, bots and unfurlers.
39 *
40 * @since 2.7.0
41 */
42 class Oembed_Manager {
43
44 /**
45 * Social settings, read once per request.
46 *
47 * @since 2.7.0
48 * @var array|null
49 */
50 private $settings = null;
51
52 /**
53 * The post whose oEmbed response is being built, or 0.
54 *
55 * Core writes the response `html` with get_the_title() from its own
56 * `oembed_response_data` callback, so the title filter has to know that
57 * build is under way.
58 *
59 * @since 2.7.0
60 * @var int
61 */
62 private $card_post_id = 0;
63
64 /**
65 * Register the response filter.
66 *
67 * @since 2.7.0
68 * @return void
69 */
70 public function init(): void {
71 // Core adds the response `html` at priority 10, from default-filters,
72 // so ahead of this plugin's own callback at the same priority. The
73 // card window opens before that and closes after everything else.
74 add_filter('oembed_response_data', [$this, 'begin_card'], 9, 2);
75 add_filter('oembed_response_data', [$this, 'filter_response'], 10, 2);
76 add_filter('oembed_response_data', [$this, 'end_card'], PHP_INT_MAX);
77 add_filter('the_title', [$this, 'filter_card_title'], 20, 2);
78 }
79
80 /**
81 * Note which post an oEmbed response is being built for.
82 *
83 * @since 2.7.0
84 *
85 * @param mixed $data Response data, passed through untouched.
86 * @param mixed $post Post being embedded.
87 * @return mixed
88 */
89 public function begin_card($data, $post) {
90 $this->card_post_id = $post instanceof \WP_Post ? (int) $post->ID : 0;
91
92 return $data;
93 }
94
95 /**
96 * Close the card window once the response is complete.
97 *
98 * @since 2.7.0
99 *
100 * @param mixed $data Response data, passed through untouched.
101 * @return mixed
102 */
103 public function end_card($data) {
104 $this->card_post_id = 0;
105
106 return $data;
107 }
108
109 /**
110 * Put the SEO title wherever the embed card prints the post title.
111 *
112 * That is the `html` core builds for the oEmbed response (blockquote link
113 * and iframe title) and the heading of the embed template. Everywhere else
114 * the title is left alone, and the cheap checks run before any setting is
115 * read, since `the_title` fires for every title on every page.
116 *
117 * Core prints this value as HTML, so the plain-text SEO title is escaped.
118 *
119 * @since 2.7.0
120 *
121 * @param mixed $title Title.
122 * @param mixed $post_id Post ID.
123 * @return mixed
124 */
125 public function filter_card_title($title, $post_id = 0) {
126 $post_id = (int) $post_id;
127
128 if ($post_id <= 0 || !$this->is_card_title($post_id)) {
129 return $title;
130 }
131
132 if (empty($this->settings()['oembed_use_seo_title'])) {
133 return $title;
134 }
135
136 $post = get_post($post_id);
137 if (!$post instanceof \WP_Post) {
138 return $title;
139 }
140
141 $seo_title = $this->seo_title($post);
142
143 return '' === $seo_title ? $title : esc_html($seo_title);
144 }
145
146 /**
147 * Whether a title is being printed for an embed card.
148 *
149 * @since 2.7.0
150 *
151 * @param int $post_id Post ID.
152 * @return bool
153 */
154 private function is_card_title(int $post_id): bool {
155 if ($post_id === $this->card_post_id) {
156 return true;
157 }
158
159 return function_exists('is_embed') && is_embed() && $post_id === (int) get_queried_object_id();
160 }
161
162 /**
163 * Apply the site's choices to one oEmbed response.
164 *
165 * @since 2.7.0
166 *
167 * Untyped in and out on purpose. WordPress hands a filter whatever the
168 * previous callback returned, so the guards below have to be able to hand
169 * it straight back — and a declared `: array` return would turn passing a
170 * non-array through into a TypeError on the front end, which is the one
171 * thing a defensive guard must not do.
172 *
173 * @param mixed $data The response data, normally an array.
174 * @param mixed $post The post the response describes.
175 * @return mixed
176 */
177 public function filter_response($data, $post) {
178 if (!is_array($data)) {
179 return $data;
180 }
181
182 if (!$post instanceof \WP_Post) {
183 return $data;
184 }
185
186 $settings = $this->settings();
187
188 if (!empty($settings['oembed_use_seo_title'])) {
189 $title = $this->seo_title($post);
190
191 if ('' !== $title) {
192 $data['title'] = $title;
193 }
194 }
195
196 if (!empty($settings['oembed_use_social_image'])) {
197 $data = $this->with_social_image($data, $post, $settings);
198 }
199
200 if (!empty($settings['oembed_remove_author'])) {
201 unset($data['author_name'], $data['author_url']);
202 }
203
204 return $data;
205 }
206
207 /**
208 * The post's SEO title, as an embed card should carry it.
209 *
210 * Deliberately the same chain the breadcrumb trail uses: the post's own
211 * `_thinkrank_seo_title` with its variable tags resolved, and nothing else.
212 * The global title pattern is not part of it, for the reason it is not part
213 * of a breadcrumb either — resolving it appends the site name, and an
214 * oEmbed payload already carries the site name in `provider_name`, so the
215 * card would read "Post Title | Site Name — Site Name".
216 *
217 * @since 2.7.0
218 * @param \WP_Post $post Post being embedded.
219 * @return string Title, or '' to leave WordPress's alone.
220 */
221 private function seo_title(\WP_Post $post): string {
222 $seo_title = trim((string) get_post_meta($post->ID, '_thinkrank_seo_title', true));
223
224 if ('' === $seo_title) {
225 return '';
226 }
227
228 return trim(Pattern_Resolver::resolve_value($seo_title, $post->ID));
229 }
230
231 /**
232 * Put the site's chosen social image on the card.
233 *
234 * Only ever replaces a thumbnail with a deliberate choice: the post's own
235 * Open Graph image beats the featured image WordPress already used, and the
236 * site-wide default is consulted only when the post has neither, so a post
237 * with a featured image and no social image keeps the card it had.
238 *
239 * @since 2.7.0
240 *
241 * @param array $data Response data.
242 * @param \WP_Post $post Post being embedded.
243 * @param array $settings Social settings.
244 * @return array
245 */
246 private function with_social_image(array $data, \WP_Post $post, array $settings): array {
247 $image = trim((string) get_post_meta($post->ID, '_thinkrank_og_image', true));
248
249 if ('' === $image) {
250 // WordPress already put the featured image here, and it is a
251 // better answer than a site-wide default.
252 if (!empty($data['thumbnail_url'])) {
253 return $data;
254 }
255
256 foreach (['default_og_image', 'default_image'] as $key) {
257 $candidate = trim((string) ($settings[$key] ?? ''));
258
259 if ('' !== $candidate) {
260 $image = $candidate;
261 break;
262 }
263 }
264 }
265
266 if ('' === $image) {
267 return $data;
268 }
269
270 $image = esc_url_raw($image);
271
272 if ('' === $image) {
273 return $data;
274 }
275
276 $data['thumbnail_url'] = $image;
277
278 // oEmbed requires width and height alongside a thumbnail_url, and a
279 // consumer is entitled to reject a payload carrying one without them.
280 // WordPress itself always sends all three, so a partial replacement
281 // would leave the old image's dimensions describing the new image.
282 [$width, $height] = $this->image_dimensions($image, $settings);
283
284 if ($width > 0 && $height > 0) {
285 $data['thumbnail_width'] = $width;
286 $data['thumbnail_height'] = $height;
287 } else {
288 unset($data['thumbnail_width'], $data['thumbnail_height']);
289 }
290
291 return $data;
292 }
293
294 /**
295 * Real dimensions for an image URL, or the configured Open Graph size.
296 *
297 * @since 2.7.0
298 *
299 * @param string $url Image URL.
300 * @param array $settings Social settings.
301 * @return array{0:int,1:int} Width and height; 0,0 when unknown.
302 */
303 private function image_dimensions(string $url, array $settings): array {
304 $attachment_id = attachment_url_to_postid($url);
305
306 if ($attachment_id) {
307 $meta = wp_get_attachment_image_src($attachment_id, 'full');
308
309 if (is_array($meta) && !empty($meta[1]) && !empty($meta[2])) {
310 return [(int) $meta[1], (int) $meta[2]];
311 }
312 }
313
314 // An image hosted elsewhere cannot be measured without fetching it, so
315 // fall back to the dimensions the site already declares for its Open
316 // Graph images rather than dropping the thumbnail entirely.
317 $width = (int) ($settings['og_image_width'] ?? 0);
318 $height = (int) ($settings['og_image_height'] ?? 0);
319
320 return [max(0, $width), max(0, $height)];
321 }
322
323 /**
324 * Site-context social settings, read once.
325 *
326 * @since 2.7.0
327 * @return array
328 */
329 private function settings(): array {
330 if (null === $this->settings) {
331 $this->settings = $this->load_settings();
332 }
333
334 return $this->settings;
335 }
336
337 /**
338 * Read the social settings.
339 *
340 * Its own method so the filter can be exercised without standing up a
341 * settings store: the interesting behaviour here is which of three
342 * switches changed which field, not where the switches came from.
343 *
344 * @since 2.7.0
345 * @return array
346 */
347 protected function load_settings(): array {
348 return (array) (new Social_Meta_Manager())->get_settings('site', null);
349 }
350 }
351