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
← All changes | includes/seo/class-pattern-resolver.php +160 -7 1.27.02.7.0 View file →
@@ -82,8 +82,159 @@
82 82 return self::process($value, self::placeholders_for($post_id));
83 83 }
84 84
85 85 /**
86 + * Sanitize a variable-tag template for storage.
87 + *
88 + * The write-side counterpart of resolve_value(): every template that
89 + * reaches this class has to survive the trip into the database first.
90 + *
91 + * sanitize_text_field() cannot be used for that. Core's
92 + * _sanitize_text_fields() strips percent-encoded characters, looping
93 + * `preg_replace( '/%[a-f0-9]{2}/i', ... )` until nothing matches, so any
94 + * token whose first two characters are hex digits is eaten on save:
95 + * %date% is stored as "te%" and %category% as "tegory%" (#521). They are
96 + * the only two tags in the language that collide, which is why the
97 + * corruption looked arbitrary — %title%, %sitename%, %sep%, %excerpt%,
98 + * %modified% and %author% all pass through core untouched.
99 + *
100 + * This mirrors what core does either side of that percent loop — invalid
101 + * UTF-8 dropped, tags stripped, control characters removed, whitespace
102 + * collapsed — and simply omits the loop itself.
103 + *
104 + * @since 2.1.1
105 + *
106 + * @param string $value Raw template as submitted.
107 + * @param bool $keep_newlines Preserve newlines, as sanitize_textarea_field() does.
108 + * @return string Sanitized template with its %tokens% intact.
109 + */
110 + public static function sanitize_template(string $value, bool $keep_newlines = false): string {
111 + $filtered = wp_check_invalid_utf8($value);
112 +
113 + if (strpos($filtered, '<') !== false) {
114 + $filtered = wp_pre_kses_less_than($filtered);
115 + // Tags out, the text between them kept.
116 + $filtered = wp_strip_all_tags($filtered, false);
117 + $filtered = str_replace("<\n", "&lt;\n", $filtered);
118 + }
119 +
120 + // C0 controls and DEL, less the tab/newline/carriage-return handled below.
121 + $filtered = (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $filtered);
122 +
123 + if (!$keep_newlines) {
124 + $filtered = (string) preg_replace('/[\r\n\t ]+/', ' ', $filtered);
125 + }
126 +
127 + return trim($filtered);
128 + }
129 +
130 + /**
131 + * Sanitize a variable-tag template that may span multiple lines.
132 + *
133 + * The sanitize_textarea_field() counterpart of sanitize_template().
134 + *
135 + * @since 2.1.1
136 + *
137 + * @param string $value Raw template as submitted.
138 + * @return string Sanitized template with its %tokens% and newlines intact.
139 + */
140 + public static function sanitize_template_textarea(string $value): string {
141 + return self::sanitize_template($value, true);
142 + }
143 +
144 + /**
145 + * Derive a description from raw post content.
146 + *
147 + * wp_strip_all_tags() removes HTML but not shortcodes, so a page built with
148 + * them published its shortcode source as the description — `[woocommerce_cart]`
149 + * as the meta description, og:description and twitter:description of the
150 + * cart page. Core's own wp_trim_excerpt() runs strip_shortcodes() and
151 + * excerpt_remove_blocks() first; this path did neither, which is why the
152 + * two disagreed about the same post (#387).
153 + *
154 + * @since 2.0.1
155 + *
156 + * @param string $content Raw post content.
157 + * @param int $words Word cap.
158 + * @return string Derived description, or '' when nothing survives.
159 + */
160 + public static function derive_excerpt(string $content, int $words = 25): string {
161 + if ('' === trim($content)) {
162 + return '';
163 + }
164 +
165 + $text = excerpt_remove_blocks($content);
166 + $text = strip_shortcodes($text);
167 + $text = wp_strip_all_tags($text);
168 +
169 + // $words is a WORD cap, but wp_trim_words() counts CHARACTERS on
170 + // th/ja/zh_*, where it would cut to ~25 characters instead of ~25
171 + // words — about six times too short (#687). trim_words() keeps the
172 + // word cap where words are the unit and falls back to a character
173 + // budget where they are not.
174 + return trim(\ThinkRank\Core\Seo_Text::trim_words($text, $words));
175 + }
176 +
177 + /**
178 + * Resolve any variable-tag string against a term's values.
179 + *
180 + * The term counterpart of resolve_value(). Term SEO fields reach the
181 + * frontend from three writers — the term UI, the abilities API and the
182 + * Yoast/RankMath/AIOSEO/SEOPress importer — and the importers already
183 + * substitute their own term tokens (%%term_title%%, %term%) with the term
184 + * name at export time, so what lands here is either literal text or
185 + * ThinkRank's own tags.
186 + *
187 + * @since 2.0.1
188 + *
189 + * @param string $value Raw string, possibly containing variable tags.
190 + * @param int $term_id Term ID.
191 + * @return string Resolved string.
192 + */
193 + public static function resolve_term_value(string $value, int $term_id): string {
194 + if (strpos($value, '%') === false) {
195 + return $value;
196 + }
197 + return self::process($value, self::placeholders_for_term($term_id));
198 + }
199 +
200 + /**
201 + * Token => value map for a term.
202 + *
203 + * The post-only tokens resolve to an empty string rather than being left
204 + * unreplaced: they have no meaning on an archive, and process() collapses
205 + * the separators an empty token leaves behind. A raw "%author%" in the
206 + * rendered title would be worse than nothing.
207 + *
208 + * @since 2.0.1
209 + *
210 + * @param int $term_id Term ID.
211 + * @return array<string,string> Placeholder map.
212 + */
213 + private static function placeholders_for_term(int $term_id): array {
214 + $term = get_term($term_id);
215 +
216 + $name = ($term && !is_wp_error($term)) ? $term->name : '';
217 + $description = ($term && !is_wp_error($term)) ? (string) $term->description : '';
218 +
219 + return [
220 + '%title%' => $name,
221 + '%term%' => $name,
222 + '%sitename%' => get_bloginfo('name'),
223 + '%sep%' => self::separator(),
224 + // Same locale trap as derive_excerpt(): a word cap here is a
225 + // ~25-character cap on th/ja/zh_* (#687).
226 + '%excerpt%' => $description !== ''
227 + ? self::derive_excerpt($description)
228 + : '',
229 + '%date%' => '',
230 + '%modified%' => '',
231 + '%author%' => '',
232 + '%category%' => '',
233 + ];
234 + }
235 +
236 + /**
86 237 * Token => value map for a post, keyed WITHOUT the surrounding percents
87 238 * (e.g. 'title' => 'My Post'). Used by the editor for live client-side
88 239 * preview of a pattern as the user types.
89 240 *
@@ -109,11 +260,13 @@
109 260 public static function description(int $post_id): string {
110 261 $template = self::template_for($post_id, 'description', self::DEFAULT_DESCRIPTION);
111 262 $description = self::resolve_value($template, $post_id);
112 263
113 - if (strlen($description) > 160) {
114 - $description = wp_trim_words($description, 25, '...');
115 - }
264 + // Measure and cut in CHARACTERS. strlen() counts bytes, so a Thai or
265 + // CJK description tripped this limit at a third of its length, and
266 + // wp_trim_words() then cut by a unit the locale chooses — 25 words in
267 + // English, 25 characters in Thai (#687).
268 + $description = \ThinkRank\Core\Seo_Text::trim_to_length($description);
116 269
117 270 return $description;
118 271 }
119 272
@@ -204,17 +357,17 @@
204 357 * Get the configured pattern for a post type, falling back to a default.
205 358 *
206 359 * @param int $post_id Post ID.
207 360 * @param string $key Setting key ('title' or 'description').
208 - * @param string $default Default pattern.
361 + * @param string $fallback Default pattern.
209 362 * @return string Pattern template.
210 363 */
211 - private static function template_for(int $post_id, string $key, string $default): string {
364 + private static function template_for(int $post_id, string $key, string $fallback): string {
212 365 $post_type = get_post_type($post_id) ?: 'post';
213 366 $all = get_option(self::OPTION_NAME, []);
214 367 $template = $all[$post_type][$key] ?? '';
215 368
216 - return is_string($template) && $template !== '' ? $template : $default;
369 + return is_string($template) && $template !== '' ? $template : $fallback;
217 370 }
218 371
219 372 /**
220 373 * Build placeholder values for an explicit post (no loop dependency).
@@ -228,9 +381,9 @@
228 381 $excerpt = '';
229 382 if ($post) {
230 383 $excerpt = !empty($post->post_excerpt)
231 384 ? $post->post_excerpt
232 - : wp_trim_words(wp_strip_all_tags($post->post_content), 25, '...');
385 + : self::derive_excerpt(Builder_Content::visible_content($post));
233 386 }
234 387
235 388 $author_id = (int) get_post_field('post_author', $post_id);
236 389