PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.8.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.8.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 1.0.0 1.0.1 1.0.2 1.1.0 All 49 releases
thinkrank / includes / seo / class-content-visibility.php

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

374 lines 12.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Per-post exclusion from this site's own search results and archive listings.
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 * Keeps chosen posts out of on-site search and archive listings.
19 *
20 * **This is not noindex**, and the distinction is the whole feature. Per-post
21 * noindex already exists in the metabox and tells search engines what to do.
22 * This tells *this site* what to do: a thank-you page, an outdated announcement
23 * kept only for its links, a landing page that should never turn up when a
24 * visitor searches the site. The post stays published and reachable at its own
25 * URL; it simply stops appearing in lists.
26 *
27 * Two separate switches, because the two cases genuinely differ. A thank-you
28 * page should be out of both. An old announcement kept for link equity might
29 * reasonably stay findable by search while dropping out of the category feed.
30 *
31 * Also not to be confused with the sitemap's `exclude_posts`, which is a
32 * site-wide comma-separated list controlling what gets published in
33 * `sitemap.xml`. Same word, unrelated mechanism, and a reason the UI wording
34 * here avoids "exclude" entirely.
35 *
36 * On performance: the excluded set is fetched once per request per kind and
37 * cached, so a listing page costs one extra query at most and usually none.
38 * Asking `post__not_in` to carry a meta query per request would put the cost on
39 * every archive view of every site, whether or not anything is excluded.
40 *
41 * @since 2.7.0
42 */
43 class Content_Visibility {
44
45 /**
46 * Meta key: keep this post out of on-site search results.
47 *
48 * @since 2.7.0
49 * @var string
50 */
51 public const SEARCH_META = '_thinkrank_exclude_from_search';
52
53 /**
54 * Meta key: keep this post out of archive listings.
55 *
56 * @since 2.7.0
57 * @var string
58 */
59 public const ARCHIVE_META = '_thinkrank_exclude_from_archives';
60
61 /**
62 * Cache key for the excluded id sets.
63 *
64 * @since 2.7.0
65 * @var string
66 */
67 private const CACHE_KEY = 'thinkrank_hidden_post_ids';
68
69 /**
70 * Cache group. Non-persistent unless the site runs an object cache, which
71 * is the right trade: the set is cheap to rebuild and must never go stale.
72 *
73 * @since 2.7.0
74 * @var string
75 */
76 private const CACHE_GROUP = 'thinkrank';
77
78 /**
79 * Request-level memo of the id sets.
80 *
81 * @since 2.7.0
82 * @var array<string, int[]>|null
83 */
84 private static $ids = null;
85
86 /**
87 * Register the query filter and the cache invalidation.
88 *
89 * @since 2.7.0
90 * @return void
91 */
92 public function init(): void {
93 add_action('pre_get_posts', [$this, 'exclude_hidden_posts']);
94
95 // Any of these can change which posts are hidden.
96 add_action('save_post', [self::class, 'flush']);
97 add_action('deleted_post', [self::class, 'flush']);
98 add_action('trashed_post', [self::class, 'flush']);
99 add_action('untrashed_post', [self::class, 'flush']);
100 add_action('added_post_meta', [self::class, 'flush']);
101 add_action('updated_post_meta', [self::class, 'flush']);
102 add_action('deleted_post_meta', [self::class, 'flush']);
103
104 if (is_admin()) {
105 add_filter('display_post_states', [$this, 'post_states'], 10, 2);
106 }
107 }
108
109 /**
110 * Drop hidden posts from a listing.
111 *
112 * @since 2.7.0
113 * @param \WP_Query $query The query about to run.
114 * @return void
115 */
116 public function exclude_hidden_posts($query): void {
117 if (!$query instanceof \WP_Query) {
118 return;
119 }
120
121 $kind = $this->kind_for($query);
122
123 if (null === $kind) {
124 return;
125 }
126
127 /**
128 * Filter whether a query has hidden posts removed from it.
129 *
130 * The escape hatch for a listing that should show everything — a
131 * "recently updated" block an editor curates by hand, say.
132 *
133 * @since 2.7.0
134 *
135 * @param bool $apply Whether to apply the exclusion.
136 * @param string $kind 'search' or 'archive'.
137 * @param \WP_Query $query The query about to run.
138 */
139 if (!apply_filters('thinkrank_hide_posts_from_query', true, $kind, $query)) {
140 return;
141 }
142
143 $hidden = self::hidden_ids($kind);
144
145 if ([] === $hidden) {
146 return;
147 }
148
149 // Merged rather than assigned: another plugin may already be excluding
150 // posts from this query, and overwriting its list would quietly undo it.
151 //
152 // Not a bare `(array)` cast: WP_Query::get() answers '' for a variable
153 // that was never set, and casting that gives [''] rather than [], which
154 // would put an empty string into post__not_in for every listing on the
155 // site.
156 $existing = $query->get('post__not_in');
157 $existing = is_array($existing) ? $existing : [];
158
159 $query->set('post__not_in', array_values(array_unique(array_merge($existing, $hidden))));
160 }
161
162 /**
163 * Which exclusion, if any, applies to this query.
164 *
165 * @since 2.7.0
166 * @param \WP_Query $query The query about to run.
167 * @return string|null 'search', 'archive', or null to leave it alone.
168 */
169 private function kind_for(\WP_Query $query): ?string {
170 // Admin listings must show everything, or an author cannot find the
171 // post they hid. Feeds, REST and the sitemap are left alone too: this
172 // is about what a visitor browsing the site sees, and a post silently
173 // missing from a feed or a sitemap is a different decision nobody made.
174 if (is_admin()) {
175 return null;
176 }
177
178 if ($query->is_feed() || $query->is_singular() || $query->is_404()) {
179 return null;
180 }
181
182 if (defined('REST_REQUEST') && REST_REQUEST) {
183 return null;
184 }
185
186 // Never outside a page request. The sitemap generator, WP-CLI and cron
187 // all run WP_Query for public content with no listing context at all,
188 // and an early version of this hid posts from the sitemap and from
189 // `wp post list` because of it.
190 if (wp_doing_cron() || (defined('WP_CLI') && WP_CLI)) {
191 return null;
192 }
193
194 // Page builders render the listing from their own query rather than the
195 // main one — measured on a Bricks archive, the posts on screen come
196 // from `Bricks\Query->run`, not from `wp`. Restricting this to the main
197 // query left the feature doing nothing visible on any such site, which
198 // is a large share of them.
199 //
200 // A secondary query therefore counts, but only under two conditions
201 // that together separate a listing from everything else a page runs.
202 // `template_redirect` is the important one: a bare `new WP_Query()`
203 // reports `is_home()` as true by default, so the flags alone cannot
204 // tell a builder's front-page loop from the sitemap's query — but the
205 // sitemap, cron and CLI never reach template rendering, and a builder's
206 // loop only runs during it.
207 if (!$query->is_main_query()) {
208 if (!did_action('template_redirect')) {
209 return null;
210 }
211
212 if (!self::queries_public_content($query)) {
213 return null;
214 }
215 }
216
217 if ($query->is_search()) {
218 return 'search';
219 }
220
221 // The blog listing counts: it is the archive of everything, and a post
222 // hidden from category and author pages but still on the front page
223 // would be a confusing half-measure.
224 if ($query->is_home() || $query->is_archive()) {
225 return 'archive';
226 }
227
228 return null;
229 }
230
231 /**
232 * Whether a secondary query is asking for the site's own public content.
233 *
234 * The page a visitor loads runs a dozen queries that are not listings at
235 * all: ACF field groups, Bricks templates and webfonts, block template
236 * parts, global styles. Every one of them names an internal post type, and
237 * every one would be pointlessly rewritten by a blanket rule. Asking what
238 * the query is *for* separates them cleanly, and does it by a property the
239 * query states about itself rather than by a list of plugin class names
240 * that would need a new entry per builder.
241 *
242 * @since 2.7.0
243 * @param \WP_Query $query The query about to run.
244 * @return bool
245 */
246 private static function queries_public_content(\WP_Query $query): bool {
247 $types = $query->get('post_type');
248
249 // Unset or 'any' means WordPress's own default, which is public
250 // content. An explicit list has to be entirely public to qualify.
251 if (empty($types) || 'any' === $types) {
252 return true;
253 }
254
255 $public = get_post_types(['public' => true]);
256
257 foreach ((array) $types as $type) {
258 if (!in_array($type, $public, true)) {
259 return false;
260 }
261 }
262
263 return true;
264 }
265
266 /**
267 * Post ids hidden from one kind of listing.
268 *
269 * @since 2.7.0
270 * @param string $kind 'search' or 'archive'.
271 * @return int[]
272 */
273 public static function hidden_ids(string $kind): array {
274 if (null === self::$ids) {
275 $cached = wp_cache_get(self::CACHE_KEY, self::CACHE_GROUP);
276
277 self::$ids = is_array($cached) ? $cached : self::build();
278 }
279
280 return self::$ids[$kind] ?? [];
281 }
282
283 /**
284 * Read both sets from the database and cache them.
285 *
286 * One query for both keys rather than one each: the two sets overlap
287 * heavily in practice, since most posts hidden from one are hidden from
288 * both.
289 *
290 * @since 2.7.0
291 * @return array<string, int[]>
292 */
293 private static function build(): array {
294 global $wpdb;
295
296 $sets = ['search' => [], 'archive' => []];
297
298 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- The result is what is being cached, immediately below.
299 $rows = $wpdb->get_results(
300 $wpdb->prepare(
301 "SELECT post_id, meta_key FROM {$wpdb->postmeta}
302 WHERE meta_key IN (%s, %s) AND meta_value = '1'",
303 self::SEARCH_META,
304 self::ARCHIVE_META
305 )
306 );
307
308 foreach ((array) $rows as $row) {
309 $kind = (self::SEARCH_META === $row->meta_key) ? 'search' : 'archive';
310
311 $sets[$kind][] = (int) $row->post_id;
312 }
313
314 wp_cache_set(self::CACHE_KEY, $sets, self::CACHE_GROUP, HOUR_IN_SECONDS);
315
316 return $sets;
317 }
318
319 /**
320 * Forget the cached sets.
321 *
322 * @since 2.7.0
323 * @return void
324 */
325 public static function flush(): void {
326 self::$ids = null;
327
328 wp_cache_delete(self::CACHE_KEY, self::CACHE_GROUP);
329 }
330
331 /**
332 * Whether a post is hidden from a kind of listing.
333 *
334 * @since 2.7.0
335 * @param int $post_id Post to check.
336 * @param string $kind 'search' or 'archive'.
337 * @return bool
338 */
339 public static function is_hidden(int $post_id, string $kind): bool {
340 return in_array($post_id, self::hidden_ids($kind), true);
341 }
342
343 /**
344 * Label a hidden post in the admin list table.
345 *
346 * Without this an author has no way to tell, from the list, why a post is
347 * missing from the site. The setting is two clicks deep in a metabox tab.
348 *
349 * @since 2.7.0
350 *
351 * @param array $states Existing post states.
352 * @param \WP_Post $post The post being listed.
353 * @return array
354 */
355 public function post_states($states, $post): array {
356 if (!is_array($states) || !$post instanceof \WP_Post) {
357 return (array) $states;
358 }
359
360 $search = self::is_hidden((int) $post->ID, 'search');
361 $archive = self::is_hidden((int) $post->ID, 'archive');
362
363 if ($search && $archive) {
364 $states['thinkrank_hidden'] = __('Hidden from search and archives', 'thinkrank');
365 } elseif ($search) {
366 $states['thinkrank_hidden_search'] = __('Hidden from site search', 'thinkrank');
367 } elseif ($archive) {
368 $states['thinkrank_hidden_archive'] = __('Hidden from archives', 'thinkrank');
369 }
370
371 return $states;
372 }
373 }
374