PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.10.0 All 48 releases
thinkrank / includes / core / class-seo-text.php

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

154 lines 5.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Shared length handling for rendered SEO descriptions.
4 *
5 * @package ThinkRank
6 * @subpackage Core
7 * @since 2.7.0
8 */
9
10 declare(strict_types=1);
11
12 namespace ThinkRank\Core;
13
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 /**
19 * Trims SEO text to the length search engines display.
20 *
21 * Every path that renders a description must measure and cut through here
22 * rather than carrying its own copy, because the obvious spelling of this is
23 * wrong in two independent ways and both are invisible in English:
24 *
25 * - `strlen()` counts BYTES. UTF-8 Thai and CJK run three bytes per character,
26 * so a 54-character description already trips a 160-"character" gate it is
27 * nowhere near.
28 * - `wp_trim_words()` does not always count words. WordPress reads the unit
29 * from a per-locale gettext string, and `th`, `ja` and `zh_*` set it to
30 * `characters_excluding_spaces` — so `wp_trim_words($text, 25)` keeps 25
31 * words in English and 25 *characters* in Thai.
32 *
33 * Together they cut Thai meta descriptions to roughly 25 characters while the
34 * same stored value rendered in full in og:description, which reached the page
35 * by a different path (#687).
36 *
37 * @since 2.7.0
38 */
39 class Seo_Text {
40
41 /**
42 * Characters search engines display for a meta description.
43 *
44 * @since 2.7.0
45 * @var int
46 */
47 public const MAX_LENGTH = 160;
48
49 /**
50 * Characters search engines display for a title.
51 *
52 * @since 2.7.0
53 * @var int
54 */
55 public const TITLE_MAX_LENGTH = 60;
56
57 /**
58 * Whether this locale's word-count unit is actually words.
59 *
60 * WordPress reads the unit from a per-locale gettext string, and `th`,
61 * `ja` and `zh_*` set it to `characters_excluding_spaces`. Anything that
62 * passes a *word* cap to wp_trim_words() therefore has to ask first, or it
63 * silently becomes a character cap roughly six times tighter (#687).
64 *
65 * wp_get_word_count_type() only exists from WP 6.2; the plugin supports
66 * 6.0, so fall back to the same gettext string core reads.
67 *
68 * @since 2.7.0
69 * @return bool
70 */
71 public static function locale_counts_words(): bool {
72 if (function_exists('wp_get_word_count_type')) {
73 return 'words' === wp_get_word_count_type();
74 }
75
76 // Core's own string, in core's text domain — this is the value
77 // WP_Locale::get_word_count_type() returns from 6.2 onward. Reading it
78 // from 'thinkrank' would look up a translation we do not ship and
79 // always answer 'words', quietly disabling the check.
80 // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- deliberately core's string.
81 return 'words' === _x('words', 'Word count type. Do not translate!', 'default');
82 }
83
84 /**
85 * Trim a description to a character budget, multibyte-safe.
86 *
87 * Cuts on a word boundary when one is available inside the budget, so the
88 * result does not end mid-word; falls back to a hard character cut for
89 * scripts that do not use spaces (CJK, Thai), where a word-boundary search
90 * would find nothing and return the string untouched.
91 *
92 * Lifted from Author_Archives_Manager, which has carried the only correct
93 * copy since 2.2.0 while four other paths kept the broken pattern.
94 *
95 * @since 2.7.0
96 *
97 * @param string $description Description text.
98 * @param int $limit Maximum length in characters, ellipsis included.
99 * @return string
100 */
101 public static function trim_to_length(string $description, int $limit = self::MAX_LENGTH): string {
102 if ($limit <= 0) {
103 return '';
104 }
105
106 if (mb_strlen($description) <= $limit) {
107 return $description;
108 }
109
110 // Reserve one character for the ellipsis.
111 $budget = $limit - 1;
112 $cut = mb_substr($description, 0, $budget);
113 $last_gap = mb_strrpos($cut, ' ');
114
115 // Only honour a word boundary that is not absurdly early — otherwise a
116 // long unbroken token would collapse the description to a few chars.
117 if (false !== $last_gap && $last_gap > (int) ($budget * 0.6)) {
118 $cut = mb_substr($cut, 0, $last_gap);
119 }
120
121 return rtrim($cut) . '';
122 }
123
124 /**
125 * Apply a WORD cap that stays a word cap.
126 *
127 * wp_trim_words() reads its unit from the locale, so `$words` silently
128 * becomes a *character* cap on th/ja/zh_* — roughly six times tighter than
129 * intended. Callers that mean "about N words" go through here: word-counting
130 * locales keep wp_trim_words() byte for byte, and the rest get a character
131 * budget instead of a mangled one (#687).
132 *
133 * @since 2.7.0
134 *
135 * @param string $text Text to trim.
136 * @param int $words Word cap, honoured only where words are the unit.
137 * @param string $more Appended by wp_trim_words() when it trims.
138 * @param int $fallback Character budget used where words are not the unit.
139 * @return string
140 */
141 public static function trim_words(
142 string $text,
143 int $words,
144 string $more = '...',
145 int $fallback = self::MAX_LENGTH
146 ): string {
147 if (!self::locale_counts_words()) {
148 return self::trim_to_length($text, $fallback);
149 }
150
151 return wp_trim_words($text, $words, $more);
152 }
153 }
154