← All changes
|
jetpack_vendor/automattic/jetpack-seo/src/class-content-coverage.php
+285
-0
16.2-beta
→
16.3-beta
View file →
| @@ -1,0 +1,285 @@ | ||
| 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 | +} | |