PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.11.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.11.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 1.27.0 1.26.0 1.25.0 trunk 1.0.0 All 52 releases
← All changes | includes/seo/class-pattern-resolver.php +197 -8 2.9.0 → 2.11.0 View file →
@@ -392,21 +392,148 @@
392 392 $categories = get_the_category($post_id);
393 393 $category = !empty($categories) ? $categories[0]->name : '';
394 394 }
395 395
396 + return array_merge(
397 + [
398 + '%title%' => get_the_title($post_id),
399 + '%sitename%' => get_bloginfo('name'),
400 + '%sep%' => self::separator(),
401 + '%excerpt%' => $excerpt,
402 + '%date%' => get_the_date('', $post_id),
403 + '%modified%' => get_the_modified_date('', $post_id),
404 + '%author%' => $author_id ? get_the_author_meta('display_name', $author_id) : '',
405 + '%category%' => $category,
406 + ],
407 + self::product_placeholders($post_id)
408 + );
409 + }
410 +
411 + /**
412 + * Tokens that only mean anything on a WooCommerce product.
413 + *
414 + * Rank Math and Yoast WooCommerce SEO both let a product title or
415 + * description carry the price, the SKU and the stock status, and both of
416 + * our converters dropped every token they did not recognise — so
417 + * "Buy %title% for %wc_price%" imported as "Buy %title% for" and the
418 + * customer in support #171748 found four variables where they had had a
419 + * stock one (#715).
420 + *
421 + * Always present, never conditional on the post type. A token that exists
422 + * on a product and is an unknown token everywhere else would resolve on
423 + * one page and leak literally on another; resolving to an empty string off
424 + * a product is the same answer every other token gives when it has nothing
425 + * to say, and process() then collapses the separator it leaves behind.
426 + *
427 + * @since 2.10.1
428 + *
429 + * @param int $post_id Post ID.
430 + * @return array<string,string>
431 + */
432 + private static function product_placeholders(int $post_id): array {
433 + $empty = [
434 + '%price%' => '',
435 + '%sale_price%' => '',
436 + '%sku%' => '',
437 + '%stock_status%' => '',
438 + '%short_description%' => '',
439 + '%brand%' => '',
440 + ];
441 +
442 + if (!function_exists('wc_get_product') || 'product' !== get_post_type($post_id)) {
443 + return $empty;
444 + }
445 +
446 + $product = wc_get_product($post_id);
447 + if (!$product) {
448 + return $empty;
449 + }
450 +
451 + // Read through wc_price()/get_price_html() rather than formatting the
452 + // number here: currency symbol, position, decimals and the "from X"
453 + // form on a variable product are all store settings, and a second
454 + // formatter is how a template starts disagreeing with the price shown
455 + // three lines below it on the same page.
456 + $price = (string) $product->get_price();
457 + $sale_price = (string) $product->get_sale_price();
458 +
459 + $stock_status = (string) $product->get_stock_status();
460 + $stock_labels = [
461 + 'instock' => __('In stock', 'thinkrank'),
462 + 'outofstock' => __('Out of stock', 'thinkrank'),
463 + 'onbackorder' => __('On backorder', 'thinkrank'),
464 + ];
465 +
396 466 return [
397 - '%title%' => get_the_title($post_id),
398 - '%sitename%' => get_bloginfo('name'),
399 - '%sep%' => self::separator(),
400 - '%excerpt%' => $excerpt,
401 - '%date%' => get_the_date('', $post_id),
402 - '%modified%' => get_the_modified_date('', $post_id),
403 - '%author%' => $author_id ? get_the_author_meta('display_name', $author_id) : '',
404 - '%category%' => $category,
467 + '%price%' => self::formatted_price($price),
468 + '%sale_price%' => self::formatted_price($sale_price),
469 + '%sku%' => (string) $product->get_sku(),
470 + '%stock_status%' => $stock_labels[$stock_status] ?? $stock_status,
471 + '%short_description%' => self::derive_excerpt((string) $product->get_short_description()),
472 + '%brand%' => self::product_brand($post_id),
405 473 ];
406 474 }
407 475
408 476 /**
477 + * A price as text, in the store's own currency format.
478 + *
479 + * wc_price() returns markup, and stripping the tags off it leaves the
480 + * entities behind: a title read "11.05&#2547;&nbsp;" on the first live
481 + * run. Entities are decoded and the non-breaking space collapsed, because
482 + * this value ends up inside a `<title>` and a meta description, where
483 + * markup has no meaning and an entity is just noise a reader sees.
484 + *
485 + * @since 2.10.1
486 + *
487 + * @param string $price Raw price, or '' when the product has none.
488 + * @return string
489 + */
490 + private static function formatted_price(string $price): string {
491 + if ('' === $price) {
492 + return '';
493 + }
494 +
495 + $text = wp_strip_all_tags((string) wc_price((float) $price));
496 + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
497 +
498 + // \xC2\xA0 is the non-breaking space wc_price() puts between the
499 + // amount and the symbol; a literal one in a title is invisible to a
500 + // reader and awkward for everything else.
501 + $text = str_replace("\xC2\xA0", ' ', $text);
502 +
503 + return trim((string) preg_replace('/\s+/u', ' ', $text));
504 + }
505 +
506 + /**
507 + * A product's brand, from whichever taxonomy the store uses for one.
508 + *
509 + * WooCommerce core added `product_brand` in 9.4; before that every brand
510 + * plugin shipped its own taxonomy, and a store that migrated from one of
511 + * them still has the old terms. Asking each in turn costs one cached term
512 + * lookup and means the token is not empty on the stores most likely to
513 + * have used a brand token in the plugin they are leaving.
514 + *
515 + * @since 2.10.1
516 + *
517 + * @param int $post_id Product ID.
518 + * @return string
519 + */
520 + private static function product_brand(int $post_id): string {
521 + foreach (['product_brand', 'pwb-brand', 'yith_product_brand', 'berocket_brand'] as $taxonomy) {
522 + if (!taxonomy_exists($taxonomy)) {
523 + continue;
524 + }
525 +
526 + $terms = get_the_terms($post_id, $taxonomy);
527 + if (is_array($terms) && !empty($terms)) {
528 + return (string) $terms[0]->name;
529 + }
530 + }
531 +
532 + return '';
533 + }
534 +
535 + /**
409 536 * Active title separator symbol.
410 537 *
411 538 * @return string Separator.
412 539 */
@@ -424,8 +551,17 @@
424 551 * @param array<string,string> $placeholders Placeholder map.
425 552 * @return string Resolved string.
426 553 */
427 554 private static function process(string $template, array $placeholders): string {
555 + // Both forms go BEFORE substitution, and the strip is decided against
556 + // the placeholder map rather than by what is left over afterwards.
557 + // Running it on the substituted string scanned the resolved VALUES too,
558 + // so a post whose own title read "Using %name% placeholders in
559 + // %%mustache%% templates" published "Using placeholders in %%
560 + // templates" — its title edited, and a stray double percent where the
561 + // inner token had been eaten out of the middle of one.
562 + $template = self::strip_unresolved_tokens($template, $placeholders);
563 +
428 564 $value = str_replace(array_keys($placeholders), array_values($placeholders), $template);
429 565
430 566 // Collapse whitespace.
431 567 $value = preg_replace('/\s+/', ' ', $value);
@@ -441,6 +577,59 @@
441 577 );
442 578
443 579 // Strip leading/trailing separators and whitespace.
444 580 return trim($value, " \t\n\r\0\x0B" . $separator);
581 + }
582 +
583 + /**
584 + * Remove any token the placeholder map did not resolve.
585 + *
586 + * The last line of defence, and the reason it exists is that every layer
587 + * above it is a list someone has to remember to extend. A converter that
588 + * misses a token, a template typed by hand, a value written straight into
589 + * postmeta by an importer we have not met: each one ends with `%%title%%`
590 + * or `%some_token%` rendering literally in a `<title>` on a live site, and
591 + * that is precisely what was reported on 14 September (#715).
592 + *
593 + * Both syntaxes, because a migrated site carries both: Yoast's `%%x%%`
594 + * (which includes Rank Math tokens Yoast's own importer wrapped in double
595 + * percent signs without translating them) and the single-percent form
596 + * ThinkRank and Rank Math share. `%%x%%` is matched as a whole so the pass
597 + * cannot eat the inner `%x%` and leave a stray percent sign at each end.
598 + *
599 + * Applied to the TEMPLATE, and a token is kept only when the placeholder
600 + * map has it. Stripping whatever still looked like a token after
601 + * substitution read the resolved values as well, and content is not a
602 + * template: a post titled "Using %name% placeholders in %%mustache%%
603 + * templates" published "Using placeholders in %% templates", and an excerpt
604 + * of "Learn %name% and %fabric% placeholders." published "Learn and
605 + * placeholders." Deciding against the map also means a token this resolver
606 + * knows about is never at risk, whatever a value happens to contain.
607 + *
608 + * A bare percent is left alone: "50% off" is ordinary copy, and a guard
609 + * that ate it would be a worse bug than the one it prevents.
610 + *
611 + * @since 2.10.1
612 + *
613 + * @param string $template Raw template.
614 + * @param array<string, string> $placeholders Tokens this resolver can resolve.
615 + * @return string
616 + */
617 + private static function strip_unresolved_tokens(string $template, array $placeholders): string {
618 + if (false === strpos($template, '%')) {
619 + return $template;
620 + }
621 +
622 + $stripped = preg_replace_callback(
623 + '/%%[a-z0-9_-]+%%|%[a-z0-9_-]+%/i',
624 + static function (array $found) use ($placeholders): string {
625 + return array_key_exists($found[0], $placeholders) ? $found[0] : '';
626 + },
627 + $template
628 + );
629 +
630 + // preg_replace_callback() answers null on content that is not valid
631 + // UTF-8 rather than throwing, and returning null here would blank a
632 + // title outright.
633 + return null === $stripped ? $template : (string) $stripped;
445 634 }
446 635 }