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.15.0 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 All 59 releases
← All changes | includes/seo/class-pattern-resolver.php +301 -17 2.0.2 → 2.14.3 View file →
@@ -82,8 +82,67 @@
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 + /**
86 145 * Derive a description from raw post content.
87 146 *
88 147 * wp_strip_all_tags() removes HTML but not shortcodes, so a page built with
89 148 * them published its shortcode source as the description — `[woocommerce_cart]`
@@ -106,9 +165,14 @@
106 165 $text = excerpt_remove_blocks($content);
107 166 $text = strip_shortcodes($text);
108 167 $text = wp_strip_all_tags($text);
109 168
110 - return trim(wp_trim_words($text, $words, '...'));
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));
111 175 }
112 176
113 177 /**
114 178 * Resolve any variable-tag string against a term's values.
@@ -156,10 +220,12 @@
156 220 '%title%' => $name,
157 221 '%term%' => $name,
158 222 '%sitename%' => get_bloginfo('name'),
159 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).
160 226 '%excerpt%' => $description !== ''
161 - ? wp_trim_words(wp_strip_all_tags($description), 25, '...')
227 + ? self::derive_excerpt($description)
162 228 : '',
163 229 '%date%' => '',
164 230 '%modified%' => '',
165 231 '%author%' => '',
@@ -194,11 +260,13 @@
194 260 public static function description(int $post_id): string {
195 261 $template = self::template_for($post_id, 'description', self::DEFAULT_DESCRIPTION);
196 262 $description = self::resolve_value($template, $post_id);
197 263
198 - if (strlen($description) > 160) {
199 - $description = wp_trim_words($description, 25, '...');
200 - }
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);
201 269
202 270 return $description;
203 271 }
204 272
@@ -301,8 +369,27 @@
301 369 return is_string($template) && $template !== '' ? $template : $fallback;
302 370 }
303 371
304 372 /**
373 + * Whether the post's content is behind a password for this request.
374 + *
375 + * The same test SEO_Manager uses for the Global SEO path (#363), guarded
376 + * because this class is also exercised outside a front-end request.
377 + *
378 + * @since 2.15.0
379 + *
380 + * @param \WP_Post $post Post.
381 + * @return bool
382 + */
383 + private static function is_password_required(\WP_Post $post): bool {
384 + if ('' === (string) $post->post_password) {
385 + return false;
386 + }
387 +
388 + return !function_exists('post_password_required') || post_password_required($post);
389 + }
390 +
391 + /**
305 392 * Build placeholder values for an explicit post (no loop dependency).
306 393 *
307 394 * @param int $post_id Post ID.
308 395 * @return array<string,string> Placeholder map.
@@ -309,13 +396,21 @@
309 396 */
310 397 private static function placeholders_for(int $post_id): array {
311 398 $post = get_post($post_id);
312 399
400 + // An authored excerpt is written for public consumption, so it stays.
401 + // The body is not: deriving %excerpt% from a protected post's body put
402 + // the gated text into the title, meta, og/twitter tags and JSON-LD of
403 + // the page that asks for the password. The Global SEO path closed this
404 + // in #363; the per-post templates resolve here, so the same rule
405 + // applies here too (#973).
313 406 $excerpt = '';
314 407 if ($post) {
315 - $excerpt = !empty($post->post_excerpt)
316 - ? $post->post_excerpt
317 - : self::derive_excerpt((string) $post->post_content);
408 + if (!empty($post->post_excerpt)) {
409 + $excerpt = $post->post_excerpt;
410 + } elseif (!self::is_password_required($post)) {
411 + $excerpt = self::derive_excerpt(Builder_Content::visible_content($post));
412 + }
318 413 }
319 414
320 415 $author_id = (int) get_post_field('post_author', $post_id);
321 416
@@ -324,21 +419,148 @@
324 419 $categories = get_the_category($post_id);
325 420 $category = !empty($categories) ? $categories[0]->name : '';
326 421 }
327 422
423 + return array_merge(
424 + [
425 + '%title%' => get_the_title($post_id),
426 + '%sitename%' => get_bloginfo('name'),
427 + '%sep%' => self::separator(),
428 + '%excerpt%' => $excerpt,
429 + '%date%' => get_the_date('', $post_id),
430 + '%modified%' => get_the_modified_date('', $post_id),
431 + '%author%' => $author_id ? get_the_author_meta('display_name', $author_id) : '',
432 + '%category%' => $category,
433 + ],
434 + self::product_placeholders($post_id)
435 + );
436 + }
437 +
438 + /**
439 + * Tokens that only mean anything on a WooCommerce product.
440 + *
441 + * Rank Math and Yoast WooCommerce SEO both let a product title or
442 + * description carry the price, the SKU and the stock status, and both of
443 + * our converters dropped every token they did not recognise — so
444 + * "Buy %title% for %wc_price%" imported as "Buy %title% for" and the
445 + * customer in support #171748 found four variables where they had had a
446 + * stock one (#715).
447 + *
448 + * Always present, never conditional on the post type. A token that exists
449 + * on a product and is an unknown token everywhere else would resolve on
450 + * one page and leak literally on another; resolving to an empty string off
451 + * a product is the same answer every other token gives when it has nothing
452 + * to say, and process() then collapses the separator it leaves behind.
453 + *
454 + * @since 2.10.1
455 + *
456 + * @param int $post_id Post ID.
457 + * @return array<string,string>
458 + */
459 + private static function product_placeholders(int $post_id): array {
460 + $empty = [
461 + '%price%' => '',
462 + '%sale_price%' => '',
463 + '%sku%' => '',
464 + '%stock_status%' => '',
465 + '%short_description%' => '',
466 + '%brand%' => '',
467 + ];
468 +
469 + if (!function_exists('wc_get_product') || 'product' !== get_post_type($post_id)) {
470 + return $empty;
471 + }
472 +
473 + $product = wc_get_product($post_id);
474 + if (!$product) {
475 + return $empty;
476 + }
477 +
478 + // Read through wc_price()/get_price_html() rather than formatting the
479 + // number here: currency symbol, position, decimals and the "from X"
480 + // form on a variable product are all store settings, and a second
481 + // formatter is how a template starts disagreeing with the price shown
482 + // three lines below it on the same page.
483 + $price = (string) $product->get_price();
484 + $sale_price = (string) $product->get_sale_price();
485 +
486 + $stock_status = (string) $product->get_stock_status();
487 + $stock_labels = [
488 + 'instock' => __('In stock', 'thinkrank'),
489 + 'outofstock' => __('Out of stock', 'thinkrank'),
490 + 'onbackorder' => __('On backorder', 'thinkrank'),
491 + ];
492 +
328 493 return [
329 - '%title%' => get_the_title($post_id),
330 - '%sitename%' => get_bloginfo('name'),
331 - '%sep%' => self::separator(),
332 - '%excerpt%' => $excerpt,
333 - '%date%' => get_the_date('', $post_id),
334 - '%modified%' => get_the_modified_date('', $post_id),
335 - '%author%' => $author_id ? get_the_author_meta('display_name', $author_id) : '',
336 - '%category%' => $category,
494 + '%price%' => self::formatted_price($price),
495 + '%sale_price%' => self::formatted_price($sale_price),
496 + '%sku%' => (string) $product->get_sku(),
497 + '%stock_status%' => $stock_labels[$stock_status] ?? $stock_status,
498 + '%short_description%' => self::derive_excerpt((string) $product->get_short_description()),
499 + '%brand%' => self::product_brand($post_id),
337 500 ];
338 501 }
339 502
340 503 /**
504 + * A price as text, in the store's own currency format.
505 + *
506 + * wc_price() returns markup, and stripping the tags off it leaves the
507 + * entities behind: a title read "11.05&#2547;&nbsp;" on the first live
508 + * run. Entities are decoded and the non-breaking space collapsed, because
509 + * this value ends up inside a `<title>` and a meta description, where
510 + * markup has no meaning and an entity is just noise a reader sees.
511 + *
512 + * @since 2.10.1
513 + *
514 + * @param string $price Raw price, or '' when the product has none.
515 + * @return string
516 + */
517 + private static function formatted_price(string $price): string {
518 + if ('' === $price) {
519 + return '';
520 + }
521 +
522 + $text = wp_strip_all_tags((string) wc_price((float) $price));
523 + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
524 +
525 + // \xC2\xA0 is the non-breaking space wc_price() puts between the
526 + // amount and the symbol; a literal one in a title is invisible to a
527 + // reader and awkward for everything else.
528 + $text = str_replace("\xC2\xA0", ' ', $text);
529 +
530 + return trim((string) preg_replace('/\s+/u', ' ', $text));
531 + }
532 +
533 + /**
534 + * A product's brand, from whichever taxonomy the store uses for one.
535 + *
536 + * WooCommerce core added `product_brand` in 9.4; before that every brand
537 + * plugin shipped its own taxonomy, and a store that migrated from one of
538 + * them still has the old terms. Asking each in turn costs one cached term
539 + * lookup and means the token is not empty on the stores most likely to
540 + * have used a brand token in the plugin they are leaving.
541 + *
542 + * @since 2.10.1
543 + *
544 + * @param int $post_id Product ID.
545 + * @return string
546 + */
547 + private static function product_brand(int $post_id): string {
548 + foreach (['product_brand', 'pwb-brand', 'yith_product_brand', 'berocket_brand'] as $taxonomy) {
549 + if (!taxonomy_exists($taxonomy)) {
550 + continue;
551 + }
552 +
553 + $terms = get_the_terms($post_id, $taxonomy);
554 + if (is_array($terms) && !empty($terms)) {
555 + return (string) $terms[0]->name;
556 + }
557 + }
558 +
559 + return '';
560 + }
561 +
562 + /**
341 563 * Active title separator symbol.
342 564 *
343 565 * @return string Separator.
344 566 */
@@ -356,8 +578,17 @@
356 578 * @param array<string,string> $placeholders Placeholder map.
357 579 * @return string Resolved string.
358 580 */
359 581 private static function process(string $template, array $placeholders): string {
582 + // Both forms go BEFORE substitution, and the strip is decided against
583 + // the placeholder map rather than by what is left over afterwards.
584 + // Running it on the substituted string scanned the resolved VALUES too,
585 + // so a post whose own title read "Using %name% placeholders in
586 + // %%mustache%% templates" published "Using placeholders in %%
587 + // templates" — its title edited, and a stray double percent where the
588 + // inner token had been eaten out of the middle of one.
589 + $template = self::strip_unresolved_tokens($template, $placeholders);
590 +
360 591 $value = str_replace(array_keys($placeholders), array_values($placeholders), $template);
361 592
362 593 // Collapse whitespace.
363 594 $value = preg_replace('/\s+/', ' ', $value);
@@ -372,7 +603,60 @@
372 603 $value
373 604 );
374 605
375 606 // Strip leading/trailing separators and whitespace.
376 - return trim($value, " \t\n\r\0\x0B" . $separator);
607 + return \ThinkRank\Core\Seo_Text::trim_separator($value, $separator);
608 + }
609 +
610 + /**
611 + * Remove any token the placeholder map did not resolve.
612 + *
613 + * The last line of defence, and the reason it exists is that every layer
614 + * above it is a list someone has to remember to extend. A converter that
615 + * misses a token, a template typed by hand, a value written straight into
616 + * postmeta by an importer we have not met: each one ends with `%%title%%`
617 + * or `%some_token%` rendering literally in a `<title>` on a live site, and
618 + * that is precisely what was reported on 14 September (#715).
619 + *
620 + * Both syntaxes, because a migrated site carries both: Yoast's `%%x%%`
621 + * (which includes Rank Math tokens Yoast's own importer wrapped in double
622 + * percent signs without translating them) and the single-percent form
623 + * ThinkRank and Rank Math share. `%%x%%` is matched as a whole so the pass
624 + * cannot eat the inner `%x%` and leave a stray percent sign at each end.
625 + *
626 + * Applied to the TEMPLATE, and a token is kept only when the placeholder
627 + * map has it. Stripping whatever still looked like a token after
628 + * substitution read the resolved values as well, and content is not a
629 + * template: a post titled "Using %name% placeholders in %%mustache%%
630 + * templates" published "Using placeholders in %% templates", and an excerpt
631 + * of "Learn %name% and %fabric% placeholders." published "Learn and
632 + * placeholders." Deciding against the map also means a token this resolver
633 + * knows about is never at risk, whatever a value happens to contain.
634 + *
635 + * A bare percent is left alone: "50% off" is ordinary copy, and a guard
636 + * that ate it would be a worse bug than the one it prevents.
637 + *
638 + * @since 2.10.1
639 + *
640 + * @param string $template Raw template.
641 + * @param array<string, string> $placeholders Tokens this resolver can resolve.
642 + * @return string
643 + */
644 + private static function strip_unresolved_tokens(string $template, array $placeholders): string {
645 + if (false === strpos($template, '%')) {
646 + return $template;
647 + }
648 +
649 + $stripped = preg_replace_callback(
650 + '/%%[a-z0-9_-]+%%|%[a-z0-9_-]+%/i',
651 + static function (array $found) use ($placeholders): string {
652 + return array_key_exists($found[0], $placeholders) ? $found[0] : '';
653 + },
654 + $template
655 + );
656 +
657 + // preg_replace_callback() answers null on content that is not valid
658 + // UTF-8 rather than throwing, and returning null here would blank a
659 + // title outright.
660 + return null === $stripped ? $template : (string) $stripped;
377 661 }
378 662 }