PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.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 All 57 releases
thinkrank / includes / seo / class-word-count-index.php

class-word-count-index.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.14.2, at includes/seo/class-word-count-index.php

984 lines 39.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Persisted word count per post.
4 *
5 * @package ThinkRank\SEO
6 * @since 2.10.0
7 */
8
9 declare(strict_types=1);
10
11 namespace ThinkRank\SEO;
12
13 use ThinkRank\Core\Seo_Text;
14
15 // Prevent direct access
16 if (!defined('ABSPATH')) {
17 exit;
18 }
19
20 /**
21 * Word Count Index
22 *
23 * "Which of my pages are thin?" is a question about every published page, and
24 * answering it honestly means counting the words a visitor actually reads —
25 * which on an Elementor, Divi, Oxygen, Beaver or Bricks page are not in
26 * `post_content` at all (#565).
27 *
28 * That makes the count expensive in a way the snippet index's is not.
29 * {@see Builder_Content::resolve()} walks a builder's stored tree, so it cannot
30 * run over a whole site inside one request. The answer is the same shape as
31 * {@see Snippet_Index}: count once, store the number in post meta, and answer
32 * every report by SQL over the stored numbers. Counting happens a bounded batch
33 * at a time and the caller is told how many are left.
34 *
35 * Deliberately a **separate** index rather than a fourth field on the snippet
36 * index, because the two are invalidated by different things. A word count
37 * changes when the body changes; a snippet verdict changes when a title
38 * template, the site name or the separator changes. Sharing one entry would
39 * mean every edit to an SEO template re-resolved every builder page on the
40 * site, which is the one cost this class exists to avoid.
41 *
42 * The stored value is `{version}:{unit}:{count}`. There is no generation:
43 * almost nothing global changes a post's word count, so a post's own edits are
44 * what make its entry stale. The version exists so that changing how counting
45 * works retires every entry with one constant.
46 *
47 * The two global things that do change a count are handled explicitly. The
48 * **unit** is written into the entry, because a locale switch between words
49 * and characters changes every number at once, and an entry is only current
50 * while its unit is the one the site counts in now. **Content a post pulls in
51 * by reference** (a synced pattern, a navigation menu) is expanded by
52 * `do_blocks()` before counting, so editing it changes the count of every post
53 * that references it; {@see on_referenced_change()} retires those entries.
54 *
55 * @since 2.10.0
56 */
57 class Word_Count_Index {
58
59 /**
60 * Post meta holding the entry.
61 */
62 public const META_KEY = '_thinkrank_word_count';
63
64 /**
65 * Counting rules version. Bump to retire every stored entry.
66 *
67 * Version 3 stopped counting shortcode syntax as words and stopped merging
68 * two words that only a tag separated (#893), so every version 2 entry is
69 * recounted once.
70 *
71 * Version 4 stopped counting tokens with no letter or digit in them. The
72 * tag-to-space change in version 3 left the punctuation after an inline
73 * tag ("<a>link</a>.") standing alone, where it was counted as a word, so
74 * every version 3 entry is recounted once.
75 */
76 public const VERSION = 4;
77
78 /**
79 * Short codes for the counting unit, as stored in an entry.
80 *
81 * Version 1 entries carried no unit, so a site that switched between a
82 * words locale and a characters one kept its old numbers as "current" and
83 * the report printed word counts as character counts. Version 2 is the
84 * first to carry it; every version 1 entry is recounted once.
85 *
86 * @var array<string,string>
87 */
88 private const UNIT_CODES = [
89 'words' => 'w',
90 'characters_excluding_spaces' => 'c',
91 'characters_including_spaces' => 'cs',
92 ];
93
94 /**
95 * Post types whose content other posts pull in by `"ref":ID` and that
96 * `do_blocks()` expands in place: synced patterns (`core/block`) and
97 * navigation menus (`core/navigation`).
98 *
99 * Builder templates are not here. Elementor global widgets and templates,
100 * and Bricks templates, are expanded by the builder at render time and are
101 * referenced in shapes that differ per builder; a page using one is
102 * recounted on its own next edit, or by Rescan.
103 *
104 * @var string[]
105 */
106 private const REFERENCED_POST_TYPES = ['wp_block', 'wp_navigation'];
107
108 /**
109 * How deep {@see posts_referencing()} follows a pattern nested inside a
110 * pattern. Core refuses to render a pattern inside itself; this bound is
111 * for the same reason, so a cycle in stored content cannot loop here.
112 */
113 private const MAX_REFERENCE_DEPTH = 5;
114
115 /**
116 * Posts per `IN (...)` list when retiring entries in bulk.
117 */
118 private const BULK_CHUNK = 500;
119
120 /**
121 * Most entries one refresh call will build, and the time it may spend.
122 *
123 * Much smaller than the snippet index's 500, and for a real reason:
124 * resolving a builder tree is orders of magnitude dearer than reading two
125 * meta values, so a batch sized for the cheap case would time out on a
126 * site built entirely in Elementor.
127 */
128 private const REFRESH_MAX_POSTS = 50;
129 private const REFRESH_MAX_SECONDS = 3.0;
130
131 /**
132 * Memo for {@see watched_meta()}.
133 *
134 * @var string[]|null
135 */
136 private static $watched_meta = null;
137
138 /**
139 * Memo for {@see unit()} when it has to switch locale to answer: site
140 * locale => unit.
141 *
142 * @var array<string,string>
143 */
144 private static $site_units = [];
145
146 /**
147 * Register invalidation hooks. Runs on every request, because posts are
148 * edited everywhere and not only on the report's screen.
149 *
150 * @return void
151 */
152 public function init(): void {
153 add_action('save_post', [self::class, 'mark_post_stale'], 99, 1);
154
155 add_action('added_post_meta', [self::class, 'on_meta_change'], 10, 3);
156 add_action('updated_post_meta', [self::class, 'on_meta_change'], 10, 3);
157 add_action('deleted_post_meta', [self::class, 'on_meta_change'], 10, 3);
158
159 // A post *leaving* the report is the case no other hook here covers.
160 // Trashing, unpublishing or deleting one removes it from every query
161 // this class answers, but it changes no entry, so nothing would tell a
162 // report derived from the index that its answer had moved.
163 add_action('transition_post_status', [self::class, 'on_status_change'], 10, 3);
164 add_action('before_delete_post', [self::class, 'on_post_deleted'], 10, 1);
165
166 // Content other posts pull in by reference. Saving covers trashing and
167 // restoring too (both go through wp_update_post()), and a trashed
168 // pattern renders nothing, so the posts that use it lose those words.
169 foreach (self::REFERENCED_POST_TYPES as $post_type) {
170 add_action('save_post_' . $post_type, [self::class, 'on_referenced_change'], 99, 1);
171 }
172 }
173
174 /**
175 * Post meta whose change changes a post's word count.
176 *
177 * Read from {@see Builder_Content::builder_meta_keys()} rather than listed
178 * here, so a builder added to the resolver is invalidated by the same
179 * commit that teaches the resolver to read it. Bricks stores its tree
180 * outside that list, so it is named explicitly.
181 *
182 * Memoised because {@see on_meta_change()} is the callback on three hooks
183 * that fire for every post meta write anywhere in WordPress — an import
184 * writing a dozen fields across a thousand posts rebuilt this list tens of
185 * thousands of times to answer the same question. The source is a class
186 * constant, so there is nothing for the memo to go stale against.
187 *
188 * @return string[]
189 */
190 public static function watched_meta(): array {
191 if (null === self::$watched_meta) {
192 self::$watched_meta = array_values(array_unique(array_merge(
193 Builder_Content::builder_meta_keys(),
194 ['_bricks_page_content_2', '_bricks_editor_mode']
195 )));
196 }
197
198 return self::$watched_meta;
199 }
200
201 /**
202 * Post meta changed.
203 *
204 * @param int|array $meta_id Meta ID(s).
205 * @param int $object_id Post ID.
206 * @param string $meta_key Meta key.
207 * @return void
208 */
209 public static function on_meta_change($meta_id, $object_id, $meta_key): void {
210 if (in_array($meta_key, self::watched_meta(), true)) {
211 self::mark_post_stale($object_id);
212 }
213 }
214
215 /**
216 * A post moved between statuses.
217 *
218 * Only one direction needs announcing: a post *leaving* `publish`. It takes
219 * its row out of every query here while its entry sits untouched, so nothing
220 * else can tell a report derived from the index that its answer moved.
221 *
222 * A post *arriving* at `publish` deliberately does not bump. It has no entry
223 * yet, so it is pending, and a report is never served from cache while
224 * anything is pending — the batch that counts it bumps the revision itself.
225 * Bumping here as well would write one option row per post through a bulk
226 * import of a thousand published posts, to say something already known.
227 *
228 * @param string $new_status Status now.
229 * @param string $old_status Status before.
230 * @param \WP_Post|null $post Post.
231 * @return void
232 */
233 public static function on_status_change($new_status, $old_status, $post = null): void {
234 if ($new_status === $old_status || !$post instanceof \WP_Post) {
235 return;
236 }
237
238 if ('publish' !== $old_status) {
239 return;
240 }
241
242 if (wp_is_post_revision($post->ID) || wp_is_post_autosave($post->ID)) {
243 return;
244 }
245
246 self::bump_revision();
247 }
248
249 /**
250 * A post is about to be deleted for good.
251 *
252 * Hooked before the delete rather than after it, so the entry is still
253 * readable: only a post that had been counted can change an answer, and
254 * revisions never have an entry, which keeps revision cleanup out of this.
255 *
256 * @param int|mixed $post_id Post ID.
257 * @return void
258 */
259 public static function on_post_deleted($post_id): void {
260 $post_id = (int) $post_id;
261 if ($post_id <= 0) {
262 return;
263 }
264
265 // A synced pattern or menu being deleted for good takes its words out
266 // of every post that referenced it. It never has an entry of its own.
267 if (in_array(get_post_type($post_id), self::REFERENCED_POST_TYPES, true)) {
268 self::on_referenced_change($post_id);
269 return;
270 }
271
272 if (null !== self::decode((string) get_post_meta($post_id, self::META_KEY, true))) {
273 self::bump_revision();
274 }
275 }
276
277 /**
278 * A synced pattern or navigation menu changed.
279 *
280 * Counting renders a post through `do_blocks()`, which expands
281 * `<!-- wp:block {"ref":12} /-->` into pattern 12's content. Nothing about
282 * the referencing post changes when pattern 12 is edited, so without this
283 * every page using a 400-word pattern kept its 400-word count after the
284 * pattern was cut to one line, and was never reported as thin.
285 *
286 * Found by searching `post_content`, which is a scan of the posts table.
287 * That is acceptable here and nowhere hotter: it runs when someone saves a
288 * pattern or a menu, which is rare, and it replaces recounting the site.
289 *
290 * @param int|mixed $post_id Pattern or menu ID.
291 * @return void
292 */
293 public static function on_referenced_change($post_id): void {
294 $post_id = (int) $post_id;
295 if ($post_id <= 0 || wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
296 return;
297 }
298
299 self::forget(self::posts_referencing($post_id));
300 }
301
302 /**
303 * Counted posts whose content references a post by `"ref":ID`, directly or
304 * through patterns nested inside patterns.
305 *
306 * @param int $post_id Referenced post.
307 * @return int[] IDs of posts that hold an entry.
308 */
309 private static function posts_referencing(int $post_id): array {
310 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the interpolated fragment is built by reference_sql() through prepare(); every value is a placeholder. Runs on a pattern save, never on a front-end request.
311 global $wpdb;
312
313 $seen = [$post_id => true];
314 $frontier = [$post_id];
315 $counted = [];
316
317 for ($depth = 0; !empty($frontier) && $depth < self::MAX_REFERENCE_DEPTH; $depth++) {
318 $match = self::reference_sql($frontier);
319
320 // Posts with an entry that use anything in the frontier.
321 $ids = $wpdb->get_col($wpdb->prepare(
322 "SELECT DISTINCT p.ID FROM {$wpdb->posts} p
323 INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s
324 WHERE " . $match,
325 self::META_KEY
326 ));
327 foreach ((array) $ids as $id) {
328 $counted[(int) $id] = true;
329 }
330
331 // Patterns that nest anything in the frontier: every post using
332 // *them* renders the edited content too.
333 $nested = $wpdb->get_col($wpdb->prepare(
334 "SELECT p.ID FROM {$wpdb->posts} p
335 WHERE p.post_type = %s AND " . $match,
336 'wp_block'
337 ));
338
339 $frontier = [];
340 foreach ((array) $nested as $id) {
341 $id = (int) $id;
342 if ($id > 0 && !isset($seen[$id])) {
343 $seen[$id] = true;
344 $frontier[] = $id;
345 }
346 }
347 }
348
349 return array_keys($counted);
350 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
351 }
352
353 /**
354 * WHERE fragment matching `post_content` that references any of these IDs.
355 *
356 * Core serialises block attributes as compact JSON, so a reference to 12
357 * reads `"ref":12` followed by `}` or `,`. Matching the terminator is what
358 * keeps an edit to pattern 12 from recounting every page that uses 120.
359 *
360 * @param int[] $ids Referenced post IDs.
361 * @return string Trusted SQL.
362 */
363 private static function reference_sql(array $ids): string {
364 global $wpdb;
365
366 $likes = [];
367 foreach ($ids as $id) {
368 foreach (['}', ','] as $terminator) {
369 $likes[] = $wpdb->prepare(
370 'p.post_content LIKE %s',
371 '%' . $wpdb->esc_like('"ref":' . (int) $id . $terminator) . '%'
372 );
373 }
374 }
375
376 return '(' . implode(' OR ', $likes) . ')';
377 }
378
379 /**
380 * Retire the entries of many posts in one statement per chunk.
381 *
382 * `delete_post_meta()` per post would be one query and one set of hooks
383 * each, for a pattern that can sit on every page of the site. The meta
384 * cache is cleared per post so a persistent object cache does not keep
385 * serving the entry that was just removed.
386 *
387 * @param int[] $post_ids Post IDs.
388 * @return void
389 */
390 private static function forget(array $post_ids): void {
391 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the IN list is a run of %d built from the chunk size, so the sniff cannot see the placeholders it looks for; every value is still passed to prepare(). The per-post meta cache is cleared below.
392 global $wpdb;
393
394 $post_ids = array_values(array_unique(array_filter(array_map('intval', $post_ids))));
395 if (empty($post_ids)) {
396 return;
397 }
398
399 foreach (array_chunk($post_ids, self::BULK_CHUNK) as $chunk) {
400 $in = implode(',', array_fill(0, count($chunk), '%d'));
401 $wpdb->query($wpdb->prepare(
402 "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id IN ({$in})",
403 array_merge([self::META_KEY], $chunk)
404 ));
405
406 foreach ($chunk as $id) {
407 wp_cache_delete($id, 'post_meta');
408 }
409 }
410
411 self::bump_revision();
412 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
413 }
414
415 /**
416 * Make one post's entry stale.
417 *
418 * The revision is bumped only when an entry was really removed. A post that
419 * had none cannot have contributed to any answer, which keeps a bulk import
420 * of fresh posts from writing one option row per post — and it means a
421 * change that takes a counted post out of scope without changing its status
422 * (adding a password, most of all) still retires the cached report.
423 *
424 * @param int|mixed $post_id Post ID.
425 * @return void
426 */
427 public static function mark_post_stale($post_id): void {
428 $post_id = (int) $post_id;
429 if ($post_id <= 0 || wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
430 return;
431 }
432
433 if (delete_post_meta($post_id, self::META_KEY)) {
434 self::bump_revision();
435 }
436 }
437
438 /**
439 * How many times a batch of entries has been rebuilt.
440 *
441 * A report derived from the whole index needs to know that *some* entry
442 * changed, which no single post's meta can tell it. Bumped once per batch
443 * rather than once per entry.
444 *
445 * @return int
446 */
447 public static function revision(): int {
448 return (int) get_option('thinkrank_word_count_index_revision', 0);
449 }
450
451 /**
452 * Record that entries changed. Not autoloaded: only the report reads it,
453 * and never on a front-end request.
454 *
455 * @return void
456 */
457 private static function bump_revision(): void {
458 update_option('thinkrank_word_count_index_revision', self::revision() + 1, false);
459 }
460
461 /**
462 * Encode an entry.
463 *
464 * @param int $count Words (or characters, per the unit).
465 * @param string|null $unit Unit the count is in. Defaults to the site's.
466 * @return string
467 */
468 public static function encode(int $count, ?string $unit = null): string {
469 return self::prefix($unit ?? self::unit()) . max(0, $count);
470 }
471
472 /**
473 * Decode an entry.
474 *
475 * An entry counted in another unit is as stale as one from another
476 * version: 40 words and 40 characters are not the same page.
477 *
478 * @param string $value Stored value.
479 * @return int|null Count, or null when malformed, from an older version or
480 * in a unit the site no longer counts in.
481 */
482 public static function decode(string $value): ?int {
483 $prefix = self::prefix(self::unit());
484 if (0 !== strpos($value, $prefix)) {
485 return null;
486 }
487
488 $count = substr($value, strlen($prefix));
489
490 return '' !== $count && ctype_digit($count) ? (int) $count : null;
491 }
492
493 /**
494 * The part of an entry before the count: version and unit.
495 *
496 * @param string $unit Counting unit.
497 * @return string
498 */
499 private static function prefix(string $unit): string {
500 $code = self::UNIT_CODES[$unit] ?? self::UNIT_CODES['characters_excluding_spaces'];
501
502 return self::VERSION . ':' . $code . ':';
503 }
504
505 /**
506 * LIKE pattern matching an entry written by the current rules, in the
507 * unit the site counts in now.
508 *
509 * @return string
510 */
511 private static function current_like(): string {
512 global $wpdb;
513
514 return $wpdb->esc_like(self::prefix(self::unit())) . '%';
515 }
516
517 /**
518 * Count the content of a post the way the locale counts it.
519 *
520 * Two things here are easy to get wrong and invisible in English.
521 *
522 * **What to count.** `post_content` is empty on a builder page, so counting
523 * it reports a 900-word Elementor page as 0 and calls it thin. Every count
524 * goes through {@see Builder_Content::resolve()}, which is the same
525 * resolution the editor score and the meta description already use.
526 *
527 * **What a "word" is.** WordPress reads the unit from a per-locale gettext
528 * string, and `th`, `ja` and `zh_*` set it to characters rather than words
529 * (#687). Splitting those on whitespace returns 1 for an entire article, so
530 * a Japanese site would report every page as thin. This mirrors core's own
531 * counter: words where the locale counts words, characters otherwise.
532 *
533 * @param \WP_Post $post Post to count.
534 * @return int Count in {@see self::unit()}.
535 */
536 public static function count_post(\WP_Post $post): int {
537 return self::count_text(Builder_Content::resolve($post));
538 }
539
540 /**
541 * Count a string the way the locale counts it.
542 *
543 * @param string $content HTML or text.
544 * @return int
545 */
546 public static function count_text(string $content): int {
547 $text = self::reading_text($content);
548
549 if ('' === $text) {
550 return 0;
551 }
552
553 switch (self::unit()) {
554 case 'characters_including_spaces':
555 return mb_strlen($text);
556
557 case 'characters_excluding_spaces':
558 return mb_strlen(str_replace(' ', '', $text));
559
560 default:
561 return self::count_words($text);
562 }
563 }
564
565 /**
566 * Count the words in a line of reading text.
567 *
568 * A word is a whitespace-separated token holding at least one letter or
569 * digit, in any script. A token of punctuation or symbols alone is not
570 * one: reading_text() turns every tag into a space, so the full stop in
571 * "<a>link</a>." and the "৳" in WooCommerce's
572 * "<span>৳</span>100" stand on their own, and a dash set between spaces
573 * ("one — two") does the same in plain prose. Core's JS word counter drops
574 * punctuation too. Letters and digits are matched by Unicode property, so
575 * Bengali, Arabic or Cyrillic words still count; a combining mark is part
576 * of the letter before it, so a Bengali word keeps its vowel signs.
577 *
578 * Only the words unit uses this. The character units count every
579 * character, as core does, which is how CJK locales are counted.
580 *
581 * @since 2.14.2
582 *
583 * @param string $text Text as returned by {@see self::reading_text()}.
584 * @return int
585 */
586 public static function count_words(string $text): int {
587 $tokens = preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY);
588
589 if (!is_array($tokens)) {
590 return 0;
591 }
592
593 return count(preg_grep('/[\p{L}\p{N}]/u', $tokens) ?: []);
594 }
595
596 /**
597 * The text a reader reads in a piece of stored content, as one line.
598 *
599 * Everything that is markup rather than reading matter is removed:
600 *
601 * - **Scripts and styles.** A page builder's output can hold a great deal
602 * of both. Counting them would make an empty page look substantial,
603 * which is the failure that matters here.
604 * - **Shortcode syntax** (#893). `[vc_column width="1/2"]` is not three
605 * words, and a WPBakery or Divi classic page is mostly made of it, so
606 * counting it reported a 216-word page as 325 and called it not thin.
607 * The shortcodes are stripped, not rendered: `do_shortcode()` would
608 * count what they output more accurately, but it means executing every
609 * shortcode on the site inside a batch count, with whatever side effects
610 * each one has (#860, #864). A shortcode that renders real prose is
611 * undercounted, which is the safe direction for a thin content report.
612 * Anything shortcode-shaped is stripped, registered or not, because a
613 * builder's shortcodes are often not registered when the count runs.
614 * Bracketed prose that looks like one ("see [note 4]") goes with it;
615 * "[1]" and "[...]" do not, since a shortcode name starts with a letter.
616 * - **Tags**, replaced with a space rather than deleted, the way core's
617 * own word counter does, so `<p>five</p><p>six</p>` stays two words.
618 *
619 * @since 2.14.2
620 *
621 * @param string $content HTML or text.
622 * @return string Plain text with whitespace collapsed to single spaces.
623 */
624 public static function reading_text(string $content): string {
625 // A `/u` pattern answers null on bytes that are not valid UTF-8, and
626 // the string casts below turned that into "": one Latin-1 byte from an
627 // old import made the whole page read as empty, a word count of 0 in
628 // both this report and the SEO score. Replace the bad bytes instead.
629 if ('' !== $content && 1 !== preg_match('//u', $content) && function_exists('mb_scrub')) {
630 $content = mb_scrub($content, 'UTF-8');
631 }
632
633 $text = (string) preg_replace('#<(script|style)\b[^>]*>.*?</\1>#is', ' ', $content);
634
635 // Before tags: an attribute value may hold a ">", which would end a
636 // tag match early. WordPress does not allow "[" or "]" inside a
637 // shortcode's attributes, so neither is crossed; that also keeps a
638 // stray "[" in prose from swallowing the text after it. The optional
639 // outer brackets take the escaped form "[[name]]" whole, rather than
640 // leaving two stray brackets to be counted as words.
641 $text = (string) preg_replace('/\[?\[\/?[A-Za-z][\w-]*[^\[\]]*\]\]?/u', ' ', $text);
642
643 $text = (string) preg_replace('/<!--.*?-->/s', ' ', $text);
644 $text = (string) preg_replace('#</?[A-Za-z][^>]*>#', ' ', $text);
645 // Backstop for anything malformed the patterns above did not take.
646 $text = wp_strip_all_tags($text);
647 $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
648 // Non-breaking spaces are spaces to a reader.
649 $text = str_replace(["\xc2\xa0", "\xe2\x80\x8b"], ' ', $text);
650
651 return trim((string) preg_replace('/\s+/u', ' ', $text));
652 }
653
654 /**
655 * The unit the site counts in.
656 *
657 * The **site** locale, not the viewer's. The report is one answer shared by
658 * every user, but a REST request from wp-admin loads the translations of
659 * the requesting user's profile language (`_locale=user`), which is where
660 * core reads the unit from. Asking the loaded locale meant an administrator
661 * whose profile is Japanese counted a batch in characters, a colleague in
662 * English counted the next batch in words, and the index held both.
663 *
664 * Switching locale loads core's translations, so the answer is memoised
665 * per site locale for the rest of the request. When the loaded locale is
666 * already the site's, nothing is switched and nothing is memoised.
667 *
668 * @return string 'words', 'characters_excluding_spaces' or 'characters_including_spaces'.
669 */
670 public static function unit(): string {
671 $site = (string) get_locale();
672
673 // The switcher is created during setup_theme; a count asked for before
674 // then (a save during plugins_loaded) cannot switch, and uses what is
675 // loaded rather than fataling.
676 if (!function_exists('determine_locale')
677 || !function_exists('switch_to_locale')
678 || empty($GLOBALS['wp_locale_switcher'])
679 || determine_locale() === $site
680 ) {
681 return self::loaded_locale_unit();
682 }
683
684 if (!isset(self::$site_units[$site])) {
685 $switched = switch_to_locale($site);
686 try {
687 self::$site_units[$site] = self::loaded_locale_unit();
688 } finally {
689 if ($switched) {
690 restore_previous_locale();
691 }
692 }
693 }
694
695 return self::$site_units[$site];
696 }
697
698 /**
699 * The unit of whichever locale is loaded right now.
700 *
701 * @return string
702 */
703 private static function loaded_locale_unit(): string {
704 if (Seo_Text::locale_counts_words()) {
705 return 'words';
706 }
707
708 if (function_exists('wp_get_word_count_type')) {
709 $type = (string) wp_get_word_count_type();
710
711 return 'characters_including_spaces' === $type
712 ? 'characters_including_spaces'
713 : 'characters_excluding_spaces';
714 }
715
716 return 'characters_excluding_spaces';
717 }
718
719 /**
720 * Build a bounded batch of missing entries.
721 *
722 * @param string[] $post_types Post types in scope.
723 * @param string[] $statuses Post statuses.
724 * @return int How many entries remain to build after this batch.
725 */
726 public static function refresh(array $post_types, array $statuses): int {
727 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the interpolated fragment is built by scope_sql() through prepare(); every value is a placeholder. The index is itself the cache.
728 global $wpdb;
729
730 if (empty($post_types)) {
731 return 0;
732 }
733
734 $scope = self::scope_sql($post_types, $statuses);
735 $started = microtime(true);
736
737 $ids = $wpdb->get_col($wpdb->prepare(
738 "SELECT p.ID FROM {$wpdb->posts} p
739 LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s
740 WHERE " . $scope . "
741 AND (m.meta_value IS NULL OR m.meta_value NOT LIKE %s)
742 ORDER BY p.ID DESC
743 LIMIT %d",
744 self::META_KEY,
745 self::current_like(),
746 self::REFRESH_MAX_POSTS
747 ));
748
749 $ids = array_map('intval', (array) $ids);
750
751 if (!empty($ids)) {
752 _prime_post_caches($ids, false, true);
753
754 foreach ($ids as $post_id) {
755 self::build($post_id);
756
757 // Checked per post, not per chunk: one Bricks page can take
758 // longer than the whole budget, and a batch that only checks
759 // between chunks would sail past it.
760 if (microtime(true) - $started > self::REFRESH_MAX_SECONDS) {
761 break;
762 }
763 }
764
765 self::bump_revision();
766 }
767
768 return max(0, self::pending($post_types, $statuses));
769 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
770 }
771
772 /**
773 * Compute and store one entry.
774 *
775 * @param int $post_id Post ID.
776 * @return void
777 */
778 private static function build(int $post_id): void {
779 $post = get_post($post_id);
780 if (!$post instanceof \WP_Post) {
781 return;
782 }
783
784 update_post_meta($post_id, self::META_KEY, self::encode(self::count_post($post)));
785 }
786
787 /**
788 * How many posts in scope still have no current entry.
789 *
790 * @param string[] $post_types Post types.
791 * @param string[] $statuses Post statuses.
792 * @return int
793 */
794 public static function pending(array $post_types, array $statuses): int {
795 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the interpolated fragment is built by scope_sql() through prepare(); every value is a placeholder. The index is itself the cache.
796 global $wpdb;
797
798 if (empty($post_types)) {
799 return 0;
800 }
801
802 return (int) $wpdb->get_var($wpdb->prepare(
803 "SELECT COUNT(*) FROM {$wpdb->posts} p
804 LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s
805 WHERE " . self::scope_sql($post_types, $statuses) . "
806 AND (m.meta_value IS NULL OR m.meta_value NOT LIKE %s)",
807 self::META_KEY,
808 self::current_like()
809 ));
810 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
811 }
812
813 /**
814 * Counted posts per post type, and how many of them fall under that post
815 * type's threshold.
816 *
817 * One query for every post type rather than one each, and the comparison
818 * happens in SQL so a site with 20,000 products never loads them.
819 *
820 * @param array<string,int> $thresholds Post type => threshold.
821 * @param string[] $statuses Post statuses.
822 * @return array<string,array{counted:int, thin:int}>
823 */
824 public static function totals(array $thresholds, array $statuses): array {
825 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the interpolated fragment is built by scope_sql() through prepare(); the CASE compares a column against integers cast from the threshold map. The index is itself the cache.
826 global $wpdb;
827
828 if (empty($thresholds)) {
829 return [];
830 }
831
832 $count_sql = self::count_sql('m');
833
834 // One CASE arm per post type, each prepared on its own and
835 // concatenated rather than left as placeholders in the outer query.
836 //
837 // The order matters and is easy to get wrong: these placeholders sit
838 // in the SELECT clause, *before* the ones in the JOIN and WHERE, so a
839 // single prepare() over the whole statement binds them in that order.
840 // Getting it wrong does not error — it silently hands `meta_key` a
841 // post type name, the INNER JOIN matches nothing and the report reads
842 // "0 posts counted" on a site full of content. Preparing each fragment
843 // where it is built removes the ordering question entirely.
844 $arms = [];
845 foreach ($thresholds as $post_type => $threshold) {
846 $arms[] = $wpdb->prepare(
847 'WHEN p.post_type = %s THEN %d',
848 (string) $post_type,
849 max(0, (int) $threshold)
850 );
851 }
852 $threshold_sql = 'CASE ' . implode(' ', $arms) . ' ELSE 0 END';
853
854 $scope = self::scope_sql(array_keys($thresholds), $statuses);
855 $meta_key_sql = $wpdb->prepare('m.meta_key = %s', self::META_KEY);
856 $current_sql = $wpdb->prepare('m.meta_value LIKE %s', self::current_like());
857
858 $rows = $wpdb->get_results(
859 "SELECT p.post_type,
860 COUNT(*) AS counted,
861 SUM({$count_sql} < {$threshold_sql}) AS thin
862 FROM {$wpdb->posts} p
863 INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND {$meta_key_sql}
864 WHERE {$scope} AND {$current_sql}
865 GROUP BY p.post_type",
866 ARRAY_A
867 );
868
869 $totals = [];
870 foreach ((array) $rows as $row) {
871 $totals[(string) $row['post_type']] = [
872 'counted' => (int) $row['counted'],
873 'thin' => (int) $row['thin'],
874 ];
875 }
876
877 return $totals;
878 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
879 }
880
881 /**
882 * The thinnest posts of one post type, under its threshold.
883 *
884 * Thinnest first: the emptiest page is the one worth opening, and on a site
885 * with hundreds of thin pages the tail is noise.
886 *
887 * @param string $post_type Post type.
888 * @param int $threshold Count below which a post is thin.
889 * @param string[] $statuses Post statuses.
890 * @param int $limit Most posts to return.
891 * @return array<int,array{post_id:int, count:int}>
892 */
893 public static function thinnest(string $post_type, int $threshold, array $statuses, int $limit): array {
894 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the interpolated fragment is built by scope_sql() through prepare(); every value is a placeholder. The index is itself the cache.
895 global $wpdb;
896
897 $count_sql = self::count_sql('m');
898
899 $rows = $wpdb->get_results($wpdb->prepare(
900 "SELECT p.ID, {$count_sql} AS word_count
901 FROM {$wpdb->posts} p
902 INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s
903 WHERE " . self::scope_sql([$post_type], $statuses) . "
904 AND m.meta_value LIKE %s
905 AND {$count_sql} < %d
906 ORDER BY word_count ASC, p.ID DESC
907 LIMIT %d",
908 self::META_KEY,
909 self::current_like(),
910 max(0, $threshold),
911 max(1, $limit)
912 ), ARRAY_A);
913
914 $posts = [];
915 foreach ((array) $rows as $row) {
916 $posts[] = [
917 'post_id' => (int) $row['ID'],
918 'count' => (int) $row['word_count'],
919 ];
920 }
921
922 return $posts;
923 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
924 }
925
926 /**
927 * SQL reading the count out of a stored entry.
928 *
929 * @param string $alias Postmeta table alias.
930 * @return string Trusted SQL.
931 */
932 private static function count_sql(string $alias): string {
933 $alias = self::alias($alias);
934
935 return "CAST(SUBSTRING_INDEX({$alias}.meta_value, ':', -1) AS UNSIGNED)";
936 }
937
938 /**
939 * WHERE fragment for post types and statuses.
940 *
941 * @param string[] $post_types Post types.
942 * @param string[] $statuses Post statuses.
943 * @return string Trusted SQL.
944 */
945 private static function scope_sql(array $post_types, array $statuses): string {
946 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- both IN lists are runs of %s built from the argument counts, so the sniff cannot see the placeholders it looks for; every value is still passed to prepare().
947 global $wpdb;
948
949 $post_types = array_values(array_unique(array_filter($post_types, 'is_string')));
950 if (empty($post_types)) {
951 // No post type matches nothing. Falling back to every post type
952 // would silently widen a scope the caller meant to narrow.
953 return '1 = 0';
954 }
955
956 $statuses = array_values(array_intersect($statuses, ['publish', 'future', 'draft', 'pending', 'private']));
957 if (empty($statuses)) {
958 $statuses = ['publish'];
959 }
960
961 $types_in = implode(',', array_fill(0, count($post_types), '%s'));
962 $statuses_in = implode(',', array_fill(0, count($statuses), '%s'));
963
964 return $wpdb->prepare(
965 "p.post_type IN ({$types_in}) AND p.post_status IN ({$statuses_in}) AND p.post_password = ''",
966 array_merge($post_types, $statuses)
967 );
968 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
969 }
970
971 /**
972 * A table alias this class uses, and nothing else.
973 *
974 * Aliases are interpolated into SQL (identifiers cannot be placeholders),
975 * so only the fixed set this class writes is accepted.
976 *
977 * @param string $alias Requested alias.
978 * @return string
979 */
980 private static function alias(string $alias): string {
981 return in_array($alias, ['p', 'm'], true) ? $alias : 'm';
982 }
983 }
984