PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-seo / src / class-content-coverage.php

class-content-coverage.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at jetpack_vendor/automattic/jetpack-seo/src/class-content-coverage.php

286 lines 9.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The Overview's content-coverage counts: how many published supported content
4 * items have each SEO field set, computed in a single aggregate query and
5 * cached in a transient that post/meta writes invalidate.
6 *
7 * @package automattic/jetpack-seo-package
8 */
9
10 namespace Automattic\Jetpack\SEO;
11
12 /**
13 * Computes, caches, and invalidates the content-coverage counts.
14 */
15 class Content_Coverage {
16
17 /**
18 * Post-meta keys mirrored from `Jetpack_SEO_Posts` (in plugins/jetpack).
19 * Duplicated here as literals on purpose: that plugin class is NOT reliably
20 * loaded in this package's admin context (the `Jetpack_SEO_Utils`
21 * `class_exists` guard in {@see Dashboard_Data::get_overview_data()} is there
22 * for the same reason), so referencing its constants would fatal.
23 * Content-coverage counting only needs the key strings, which are stable.
24 */
25 const META_DESCRIPTION = 'advanced_seo_description';
26 const META_SCHEMA_TYPE = 'jetpack_seo_schema_type';
27 const META_TITLE = 'jetpack_seo_html_title';
28 const META_NOINDEX = 'jetpack_seo_noindex';
29
30 /**
31 * Transient holding the coverage counts.
32 *
33 * Versioned, so a future change to the payload's shape can't read a stale array
34 * written by an older version of this code.
35 *
36 * @var string
37 */
38 const TRANSIENT = 'jetpack_seo_content_coverage_counts_v1';
39
40 /**
41 * How long the counts survive without being invalidated.
42 *
43 * @var int
44 */
45 const TTL = HOUR_IN_SECONDS;
46
47 /**
48 * Post types the counts span.
49 *
50 * @return string[]
51 */
52 private static function post_types() {
53 return Post_Types::get_supported_content_types();
54 }
55
56 /**
57 * The SEO post-meta keys the counts read.
58 *
59 * @return string[]
60 */
61 private static function meta_keys() {
62 return array(
63 self::META_SCHEMA_TYPE,
64 self::META_TITLE,
65 self::META_DESCRIPTION,
66 self::META_NOINDEX,
67 );
68 }
69
70 /**
71 * Factual content-coverage counts for the Overview card: how many published
72 * supported content items have each SEO field set. State, not a score — the
73 * card shows proportions + raw counts and lets the admin decide what matters.
74 *
75 * Served from {@see self::TRANSIENT} when it's warm. The counts are read on
76 * every load of the SEO page — and by every tab of it, since the dashboard preloads
77 * all of its REST reads at once — so without a cache a plain reload pays for the
78 * query again having changed nothing.
79 *
80 * @return array{total:int,with_schema:int,with_title:int,with_description:int,with_search_visible:int}
81 */
82 public static function get() {
83 $cached = get_transient( self::TRANSIENT );
84
85 if ( self::is_valid( $cached ) ) {
86 return $cached;
87 }
88
89 $coverage = self::compute();
90
91 set_transient( self::TRANSIENT, $coverage, self::TTL );
92
93 return $coverage;
94 }
95
96 /**
97 * Whether a value read back from the cache is a coverage payload this code can use.
98 *
99 * @param mixed $value Value read from the transient.
100 * @return bool
101 */
102 private static function is_valid( $value ) {
103 if ( ! is_array( $value ) ) {
104 return false;
105 }
106
107 foreach ( array( 'total', 'with_schema', 'with_title', 'with_description', 'with_search_visible' ) as $key ) {
108 if ( ! isset( $value[ $key ] ) || ! is_int( $value[ $key ] ) ) {
109 return false;
110 }
111 }
112
113 return true;
114 }
115
116 /**
117 * Count the coverage metrics straight from the database.
118 *
119 * @return array{total:int,with_schema:int,with_title:int,with_description:int,with_search_visible:int}
120 */
121 private static function compute() {
122 global $wpdb;
123
124 $post_types = self::post_types();
125 $meta_keys = self::meta_keys();
126
127 $meta_key_placeholders = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) );
128 $post_type_placeholders = implode( ', ', array_fill( 0, count( $post_types ), '%s' ) );
129
130 /*
131 * Driven from `wp_postmeta`, not `wp_posts`: most sites have far more published
132 * posts than SEO fields set, so the join starts from the small side, where the
133 * `meta_key` index serves `meta_key IN (…)` directly.
134 *
135 * The aggregate has no GROUP BY, so it still returns its single row — counts at
136 * zero, `total` intact — on a site with no SEO meta at all.
137 *
138 * COUNT( DISTINCT p.ID ) because a post can carry more than one row for the same
139 * meta key. `<> ''` counts a field as set; noindex alone is an exact `= '1'`.
140 */
141 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
142 $sql = $wpdb->prepare(
143 "SELECT
144 (
145 SELECT COUNT(*)
146 FROM {$wpdb->posts}
147 WHERE post_status = 'publish' AND post_type IN ( {$post_type_placeholders} )
148 ) AS total,
149 COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value <> '' THEN p.ID END ) AS with_schema,
150 COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value <> '' THEN p.ID END ) AS with_title,
151 COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value <> '' THEN p.ID END ) AS with_description,
152 COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value = '1' THEN p.ID END ) AS noindexed
153 FROM {$wpdb->postmeta} pm
154 INNER JOIN {$wpdb->posts} p
155 ON p.ID = pm.post_id
156 AND p.post_status = 'publish'
157 AND p.post_type IN ( {$post_type_placeholders} )
158 WHERE pm.meta_key IN ( {$meta_key_placeholders} )",
159 array_merge(
160 $post_types,
161 // The CASE arms above, in the order they appear.
162 array( self::META_SCHEMA_TYPE, self::META_TITLE, self::META_DESCRIPTION, self::META_NOINDEX ),
163 $post_types,
164 $meta_keys
165 )
166 );
167 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
168
169 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Aggregate count with no core API equivalent; $sql is the prepared statement built directly above. The result is cached in self::TRANSIENT by the get() wrapper, which is the only caller — the sniff just can't see across the two methods.
170 $row = $wpdb->get_row( $sql, ARRAY_A );
171
172 // Defaults, so a query that returns nothing at all reads as an empty site rather
173 // than fataling on a missing key.
174 $counts = array_map(
175 'intval',
176 array_merge(
177 array(
178 'total' => 0,
179 'with_schema' => 0,
180 'with_title' => 0,
181 'with_description' => 0,
182 'noindexed' => 0,
183 ),
184 is_array( $row ) ? $row : array()
185 )
186 );
187
188 return array(
189 'total' => $counts['total'],
190 'with_schema' => $counts['with_schema'],
191 'with_title' => $counts['with_title'],
192 'with_description' => $counts['with_description'],
193 // Search-engine visibility is the inverse of the per-post noindex meta: a
194 // post is visible unless it's explicitly set to noindex (stored as '1'), so
195 // most posts (no meta row) count as visible.
196 'with_search_visible' => max( 0, $counts['total'] - $counts['noindexed'] ),
197 );
198 }
199
200 /**
201 * Hook the writes that can move the coverage counts.
202 *
203 * @return void
204 */
205 public static function register_invalidation() {
206 // Covers publish, unpublish, trash, untrash and scheduled posts going live — every
207 // route by which a post enters or leaves the published set.
208 add_action( 'transition_post_status', array( __CLASS__, 'invalidate_on_status_change' ), 10, 3 );
209 add_action( 'deleted_post', array( __CLASS__, 'invalidate_on_delete' ), 10, 2 );
210
211 foreach ( array( 'added_post_meta', 'updated_post_meta', 'deleted_post_meta' ) as $hook ) {
212 add_action( $hook, array( __CLASS__, 'invalidate_on_meta_change' ), 10, 3 );
213 }
214 }
215
216 /**
217 * Drop the cached counts when a post enters or leaves the published set.
218 *
219 * Known limitation: this hook only ever sees the post's new type, so converting a
220 * published post to an uncounted post type (or the reverse) isn't caught here and
221 * leaves `total` stale until the next tracked write or the transient's TTL expiry.
222 * A direct `set_post_type()` bypasses every hook anyway, so the TTL backstop is what
223 * ultimately bounds that staleness.
224 *
225 * @param string $new_status Status the post is moving to.
226 * @param string $old_status Status the post is moving from.
227 * @param \WP_Post|null $post The post being transitioned.
228 * @return void
229 */
230 public static function invalidate_on_status_change( $new_status, $old_status, $post ) {
231 if ( ! $post instanceof \WP_Post || ! in_array( $post->post_type, self::post_types(), true ) ) {
232 return;
233 }
234
235 // Draft to draft, pending to draft, and the like never touch the counts.
236 if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
237 return;
238 }
239
240 self::invalidate();
241 }
242
243 /**
244 * Drop the cached counts when a post is deleted outright.
245 *
246 * Trashing already goes through `transition_post_status`; this catches a hard delete,
247 * which for an already-trashed post transitions nothing.
248 *
249 * @param int $post_id Deleted post ID.
250 * @param \WP_Post|null $post The post that was deleted.
251 * @return void
252 */
253 public static function invalidate_on_delete( $post_id, $post = null ) {
254 if ( ! $post instanceof \WP_Post || ! in_array( $post->post_type, self::post_types(), true ) ) {
255 return;
256 }
257
258 self::invalidate();
259 }
260
261 /**
262 * Drop the cached counts when one of the SEO fields they count is written.
263 *
264 * @param int|int[] $meta_id Meta row ID, or IDs on delete. Unused.
265 * @param int $object_id Post the meta belongs to. Unused.
266 * @param string $meta_key Meta key written.
267 * @return void
268 */
269 public static function invalidate_on_meta_change( $meta_id, $object_id, $meta_key ) {
270 if ( ! in_array( $meta_key, self::meta_keys(), true ) ) {
271 return;
272 }
273
274 self::invalidate();
275 }
276
277 /**
278 * Drop the cached counts.
279 *
280 * @return void
281 */
282 private static function invalidate() {
283 delete_transient( self::TRANSIENT );
284 }
285 }
286