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

295 lines 11.0 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 * The text a reader sees for a resolved title or description.
86 *
87 * Pattern_Resolver hands back what the page will print, and that is still
88 * HTML: get_the_title() runs wptexturize, so "Foo & Bar" arrives as
89 * `Foo &#038; Bar`, while the same words typed into the SEO title field
90 * arrive as `Foo &amp; Bar` or a bare `&`. All three render as one `<title>`.
91 * Anything that compares, measures or displays the value as text has to
92 * look at it after the browser would have decoded it, or it groups two
93 * identical titles apart, counts `&#038;` as six characters, and shows the
94 * entity to the user.
95 *
96 * @since 2.10.0
97 *
98 * @param string $value Resolved title or description.
99 * @return string The same value with HTML entities decoded.
100 */
101 public static function as_displayed(string $value): string {
102 return html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
103 }
104
105 /**
106 * Strip leading and trailing whitespace and title separators.
107 *
108 * The separator is removed as a whole string, never byte by byte.
109 * `trim($title, " \t..." . $separator)` reads its second argument as a
110 * list of single bytes, so with the Bullet separator (`•`, E2 80 A2) it
111 * also stripped any E2, 80 or A2 byte at either end of the title: the
112 * middle of €, �
113 , curly quotes and many Cyrillic letters. The broken
114 * UTF-8 left behind made `<title>` invalid and `esc_attr()` blank out
115 * og:title and twitter:title (#972).
116 *
117 * @since 2.15.0
118 *
119 * @param string $value Title or description.
120 * @param string $separator The active separator, possibly multibyte.
121 * @return string
122 */
123 public static function trim_separator(string $value, string $separator): string {
124 $alternatives = '[\s\x00]';
125 if ('' !== $separator) {
126 $alternatives .= '|' . preg_quote($separator, '/');
127 }
128
129 $trimmed = preg_replace('/^(?:' . $alternatives . ')+|(?:' . $alternatives . ')+$/u', '', $value);
130
131 // Invalid UTF-8 makes a /u pattern fail. Fall back to whitespace only,
132 // which cannot cut a character in half.
133 return null === $trimmed ? trim($value) : $trimmed;
134 }
135
136 /**
137 * Trim a description to a character budget, multibyte-safe.
138 *
139 * Cuts on a word boundary when one is available inside the budget, so the
140 * result does not end mid-word; falls back to a hard character cut for
141 * scripts that do not use spaces (CJK, Thai), where a word-boundary search
142 * would find nothing and return the string untouched.
143 *
144 * Lifted from Author_Archives_Manager, which has carried the only correct
145 * copy since 2.2.0 while four other paths kept the broken pattern.
146 *
147 * @since 2.7.0
148 *
149 * @param string $description Description text.
150 * @param int $limit Maximum length in characters, ellipsis included.
151 * @return string
152 */
153 public static function trim_to_length(string $description, int $limit = self::MAX_LENGTH): string {
154 if ($limit <= 0) {
155 return '';
156 }
157
158 if (mb_strlen($description) <= $limit) {
159 return $description;
160 }
161
162 // Reserve one character for the ellipsis.
163 $budget = $limit - 1;
164 $cut = mb_substr($description, 0, $budget);
165 $last_gap = mb_strrpos($cut, ' ');
166
167 // Only honour a word boundary that is not absurdly early — otherwise a
168 // long unbroken token would collapse the description to a few chars.
169 if (false !== $last_gap && $last_gap > (int) ($budget * 0.6)) {
170 $cut = mb_substr($cut, 0, $last_gap);
171 }
172
173 return rtrim($cut) . '…';
174 }
175
176 /**
177 * Make text fit to appear in JSON-LD.
178 *
179 * JSON-LD is not HTML, so an HTML entity in it is not decoded by anything
180 * downstream: `&amp;` was published to answer engines literally. And the
181 * excerpt path appends core's trimming marker, so descriptions arrived
182 * ending in `[…]`, a truncation artefact presented as the page's own
183 * summary.
184 *
185 * Lives here rather than in the schema class that introduced it (#766)
186 * because two producers build description nodes: the automatic
187 * Global_SEO_Schema_Output and the Schema Manager's Schema_Builder. Only
188 * the first normalised, so a deployed node, which outranks the automatic
189 * one, published the raw entity again.
190 *
191 * @since 2.10.0
192 *
193 * @param string $text Raw text.
194 * @return string
195 */
196 public static function normalize_schema_text(string $text): string {
197 if ('' === trim($text)) {
198 return '';
199 }
200
201 $text = self::decode_schema_entities(wp_strip_all_tags($text));
202
203 // Core's excerpt marker, in both its entity and literal forms, with or
204 // without the surrounding brackets it is normally wrapped in.
205 $text = (string) preg_replace(
206 '/\s*(\[\s*(\x{2026}|\.\.\.)\s*\]|\x{2026})\s*$/u',
207 '',
208 $text
209 );
210
211 return trim((string) preg_replace('/\s+/u', ' ', $text));
212 }
213
214 /**
215 * Make a title or name fit to appear in JSON-LD.
216 *
217 * get_the_title() runs wptexturize and keeps any markup in the title, and
218 * WordPress stores term names and the site name esc_html()'d, so a page
219 * called "R&D at Tom's" reached the graph as `R&#038;D at Tom&#8217;s` and
220 * a category "Cats & Dogs" as `Cats &amp; Dogs`. Nothing downstream
221 * decodes JSON-LD, so those were published literally (#976).
222 *
223 * normalize_schema_text() without its excerpt-marker strip: that marker
224 * is an artefact of a derived description, while a title that ends in an
225 * ellipsis was written that way and keeps it. A decoded `<` stays safe
226 * because the graph is encoded with JSON_HEX_TAG (#118).
227 *
228 * @since 2.15.0
229 *
230 * @param string $text Raw title or name.
231 * @return string Plain text with tags removed and entities decoded.
232 */
233 public static function normalize_schema_name(string $text): string {
234 if ('' === trim($text)) {
235 return '';
236 }
237
238 $text = self::decode_schema_entities(wp_strip_all_tags($text));
239
240 return trim((string) preg_replace('/\s+/u', ' ', $text));
241 }
242
243 /**
244 * Decode HTML entities in text bound for JSON-LD, and nothing else.
245 *
246 * The narrow half of normalize_schema_text(), for values that must keep
247 * their exact shape otherwise: a stored snapshot already truncated with an
248 * ellipsis would lose it to the excerpt-marker strip.
249 *
250 * @since 2.10.0
251 *
252 * @param string $text Text that may carry HTML entities.
253 * @return string
254 */
255 public static function decode_schema_entities(string $text): string {
256 // Twice: a description that has been through an escaping pass already
257 // (core stores `&amp;amp;` for a literal `&amp;` in some paths) would
258 // otherwise still carry an entity after one decode. Decoding an
259 // already-plain string is a no-op, so this is safe to repeat.
260 $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
261
262 return html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
263 }
264
265 /**
266 * Apply a WORD cap that stays a word cap.
267 *
268 * wp_trim_words() reads its unit from the locale, so `$words` silently
269 * becomes a *character* cap on th/ja/zh_* — roughly six times tighter than
270 * intended. Callers that mean "about N words" go through here: word-counting
271 * locales keep wp_trim_words() byte for byte, and the rest get a character
272 * budget instead of a mangled one (#687).
273 *
274 * @since 2.7.0
275 *
276 * @param string $text Text to trim.
277 * @param int $words Word cap, honoured only where words are the unit.
278 * @param string $more Appended by wp_trim_words() when it trims.
279 * @param int $fallback Character budget used where words are not the unit.
280 * @return string
281 */
282 public static function trim_words(
283 string $text,
284 int $words,
285 string $more = '...',
286 int $fallback = self::MAX_LENGTH
287 ): string {
288 if (!self::locale_counts_words()) {
289 return self::trim_to_length($text, $fallback);
290 }
291
292 return wp_trim_words($text, $words, $more);
293 }
294 }
295