PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.12.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.12.0
2.12.0 2.11.0 2.10.0 2.9.0 2.8.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 All 53 releases
thinkrank / includes / seo / class-social-images.php

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

345 lines 11.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The set of images a page offers to Open Graph consumers.
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 * Collects the secondary images that follow the primary `og:image`.
19 *
20 * Open Graph allows several `og:image` tags on one page and consumers treat
21 * the first as primary: Facebook lets the sharer pick between them, and others
22 * fall back down the list when the first fails validation (too small, wrong
23 * aspect, 404 behind a CDN). ThinkRank published exactly one, so a post whose
24 * social image failed validation shared with no image at all rather than with
25 * its second-best one.
26 *
27 * This produces everything *after* the primary, in the order the issue asked
28 * for: the featured image (when a per-post override displaced it), then images
29 * found in the content. The primary itself is resolved elsewhere and passed in,
30 * so there is one answer to "what is the main image" and this cannot disagree
31 * with it.
32 *
33 * Twitter and schema are deliberately not involved. A Twitter card renders one
34 * image and schema's `image` identifies the page's primary; handing either a
35 * list would change what they mean, not enrich it.
36 *
37 * @since 2.7.0
38 */
39 class Social_Images {
40
41 /**
42 * Total `og:image` tags a page may carry, primary included.
43 *
44 * Four is enough to give a sharer a real choice without turning the head
45 * into a gallery; consumers read the list in order and stop at the first
46 * that validates, so length past this point buys nothing.
47 *
48 * @since 2.7.0
49 * @var int
50 */
51 public const DEFAULT_LIMIT = 4;
52
53 /**
54 * Every image after the primary, ready to emit.
55 *
56 * @since 2.7.0
57 *
58 * @param int $post_id Post being rendered.
59 * @param string $primary_url The `og:image` already resolved for the page.
60 * @return array<int, array<string, mixed>> Each with url, and width/height/
61 * type/alt where they are known.
62 */
63 public static function additional(int $post_id, string $primary_url): array {
64 if ($post_id <= 0) {
65 return [];
66 }
67
68 /**
69 * Filter how many og:image tags a page may carry in total.
70 *
71 * @since 2.7.0
72 *
73 * @param int $limit Maximum tags, primary included.
74 * @param int $post_id Post being rendered.
75 */
76 $limit = (int) apply_filters('thinkrank_og_image_limit', self::DEFAULT_LIMIT, $post_id);
77
78 // One slot is the primary's, so nothing below can be offered unless the
79 // site asked for at least two.
80 if ($limit < 2) {
81 return [];
82 }
83
84 // The primary occupies the first slot whether or not it was resolvable
85 // here, so it seeds the seen-list and can never be offered twice.
86 $seen = [];
87
88 if ('' !== $primary_url) {
89 $seen[self::fingerprint($primary_url)] = true;
90 }
91
92 $images = [];
93
94 foreach (self::candidates($post_id) as $candidate) {
95 if (count($images) >= $limit - 1) {
96 break;
97 }
98
99 $url = self::usable($candidate['url']);
100
101 if ('' === $url) {
102 continue;
103 }
104
105 $print = self::fingerprint($url);
106
107 if (isset($seen[$print])) {
108 continue;
109 }
110
111 $seen[$print] = true;
112 $images[] = self::describe($url, $post_id, $candidate['attachment_id']);
113 }
114
115 /**
116 * Filter the secondary Open Graph images for a page.
117 *
118 * @since 2.7.0
119 *
120 * @param array $images Secondary images, primary excluded.
121 * @param int $post_id Post being rendered.
122 */
123 return (array) apply_filters('thinkrank_og_extra_images', $images, $post_id);
124 }
125
126 /**
127 * Candidate images, in the order they should be offered.
128 *
129 * Each carries the attachment ID it is believed to come from, where there
130 * is one to be had without asking the database: the featured image's own,
131 * or the `wp-image-{ID}` the editor wrote on a body image (#847).
132 *
133 * @since 2.7.0
134 * @param int $post_id Post being rendered.
135 * @return array<int, array{url:string, attachment_id:int}>
136 */
137 private static function candidates(int $post_id): array {
138 $candidates = [];
139
140 // The featured image. Usually this IS the primary and the fingerprint
141 // check drops it; it matters when a per-post social override displaced
142 // it, which is exactly the case where a second image is worth offering.
143 $thumbnail_id = (int) get_post_thumbnail_id($post_id);
144
145 if ($thumbnail_id) {
146 $src = wp_get_attachment_image_src($thumbnail_id, 'large');
147
148 if (is_array($src) && !empty($src[0])) {
149 $candidates[] = [
150 'url' => (string) $src[0],
151 'attachment_id' => $thumbnail_id,
152 ];
153 }
154 }
155
156 foreach (self::from_content($post_id) as $candidate) {
157 $candidates[] = $candidate;
158 }
159
160 return $candidates;
161 }
162
163 /**
164 * Images in the post body, in document order.
165 *
166 * Reads the stored content rather than running it through `the_content`.
167 * This is called while the document head is being written, and rendering
168 * the body there would run every shortcode and block on the page for the
169 * sake of a few `src` attributes.
170 *
171 * @since 2.7.0
172 * @param int $post_id Post being rendered.
173 * @return array<int, array{url:string, attachment_id:int}>
174 */
175 private static function from_content(int $post_id): array {
176 $content = (string) get_post_field('post_content', $post_id);
177
178 if ('' === $content || false === stripos($content, '<img')) {
179 return [];
180 }
181
182 if (!preg_match_all('/<img\b[^>]*>/i', $content, $tags)) {
183 return [];
184 }
185
186 $images = [];
187
188 foreach ($tags[0] as $tag) {
189 if (preg_match('/\bsrc\s*=\s*["\']([^"\']+)["\']/i', $tag, $match)) {
190 $images[] = [
191 'url' => $match[1],
192 'attachment_id' => Attachment_Lookup::hint_from_markup($tag),
193 ];
194 }
195 }
196
197 return $images;
198 }
199
200 /**
201 * A URL Open Graph can actually use, or '' to skip it.
202 *
203 * @since 2.7.0
204 * @param string $url Raw candidate.
205 * @return string
206 */
207 private static function usable(string $url): string {
208 $url = trim($url);
209
210 if ('' === $url) {
211 return '';
212 }
213
214 // Inline data and blob URLs are not fetchable by a crawler, and a
215 // protocol-relative or root-relative src cannot be resolved without
216 // guessing the host it belongs to.
217 $scheme = wp_parse_url($url, PHP_URL_SCHEME);
218
219 if (!is_string($scheme) || !in_array(strtolower($scheme), ['http', 'https'], true)) {
220 return '';
221 }
222
223 return (string) esc_url_raw($url);
224 }
225
226 /**
227 * An image plus whatever is known about it.
228 *
229 * Dimensions and type are only claimed for an attachment on this site.
230 * Guessing them for a remote image would publish numbers a consumer uses
231 * to lay out a card before it has fetched the file.
232 *
233 * @since 2.7.0
234 *
235 * @param string $url Image URL.
236 * @param int $post_id Post being rendered, for the alt fallback.
237 * @param int $attachment_id Attachment the URL is believed to come from,
238 * or 0. Checked before it is trusted.
239 * @return array<string, mixed>
240 */
241 private static function describe(string $url, int $post_id, int $attachment_id = 0): array {
242 $image = ['url' => $url];
243
244 $attachment_id = Attachment_Lookup::id_from_url($url, $attachment_id);
245
246 if ($attachment_id) {
247 // The file this URL names, which for a body image is usually a
248 // generated size rather than the original upload.
249 $file = Attachment_Lookup::describe($attachment_id, $url);
250
251 // Vector uploads report 0x0; publishing that as a dimension is
252 // invalid, so the companions are omitted rather than zeroed.
253 if ($file['width'] > 0 && $file['height'] > 0) {
254 $image['width'] = $file['width'];
255 $image['height'] = $file['height'];
256 }
257
258 if ('' !== $file['type']) {
259 $image['type'] = $file['type'];
260 }
261
262 $alt = trim((string) get_post_meta($attachment_id, '_wp_attachment_image_alt', true));
263
264 if ('' !== $alt) {
265 $image['alt'] = $alt;
266 }
267 }
268
269 if (!isset($image['alt'])) {
270 $alt = self::alt_from_content($url, $post_id);
271
272 if ('' !== $alt) {
273 $image['alt'] = $alt;
274 }
275 }
276
277 return $image;
278 }
279
280 /**
281 * The `alt` the author wrote on this image in the body.
282 *
283 * The attachment's own alt text wins where there is one, but an image
284 * inserted with a different alt — or one hosted elsewhere, which has no
285 * attachment at all — still has the author's words on it.
286 *
287 * @since 2.7.0
288 *
289 * @param string $url Image URL.
290 * @param int $post_id Post being rendered.
291 * @return string
292 */
293 private static function alt_from_content(string $url, int $post_id): string {
294 $content = (string) get_post_field('post_content', $post_id);
295
296 if ('' === $content) {
297 return '';
298 }
299
300 if (!preg_match_all('/<img\b[^>]*>/i', $content, $tags)) {
301 return '';
302 }
303
304 foreach ($tags[0] as $tag) {
305 if (!preg_match('/\bsrc\s*=\s*["\']([^"\']+)["\']/i', $tag, $src)) {
306 continue;
307 }
308
309 if (self::fingerprint(trim($src[1])) !== self::fingerprint($url)) {
310 continue;
311 }
312
313 if (preg_match('/\balt\s*=\s*["\']([^"\']*)["\']/i', $tag, $alt)) {
314 return trim($alt[1]);
315 }
316 }
317
318 return '';
319 }
320
321 /**
322 * An identity for a URL that survives WordPress's size derivatives.
323 *
324 * `logo.jpg`, `logo-1024x768.jpg` and the `large` derivative of the same
325 * upload are one image to a reader, and offering all three as alternatives
326 * is noise. Scheme and query are dropped for the same reason — the same
327 * file over http and https is not a second option.
328 *
329 * @since 2.7.0
330 * @param string $url Image URL.
331 * @return string
332 */
333 private static function fingerprint(string $url): string {
334 $parts = wp_parse_url($url);
335
336 $host = isset($parts['host']) ? strtolower((string) $parts['host']) : '';
337 $path = isset($parts['path']) ? (string) $parts['path'] : $url;
338
339 // Strip a trailing -WxH that WordPress appends to a resized copy.
340 $path = (string) preg_replace('/-\d+x\d+(\.[A-Za-z0-9]+)$/', '$1', $path);
341
342 return $host . $path;
343 }
344 }
345