: str_replace(): Passing null to parameter #2 ($replace) of type array|string is deprecated in
* @package WPSEO\Internals
// Avoid direct calls to this file.
if ( ! defined( 'WPSEO_VERSION' ) ) {
header( 'Status: 403 Forbidden' );
header( 'HTTP/1.1 403 Forbidden' );
* Class: WPSEO_Replace_Vars.
* This class implements the replacing of `%%variable_placeholders%%` with their real value based on the current
* requested page/post/cpt/etc in text strings.
class WPSEO_Replace_Vars {
* Default post/page/cpt information.
* Current post/page/cpt information.
* Help texts for use in WPSEO -> Search appearance tabs.
protected static $help_texts = [];
* Register of additional variable replacements registered by other plugins/themes.
protected static $external_replacements = [];
* Setup the help texts and external replacements as statics so they will be available to all instances.
public static function setup_statics_once() {
if ( self::$help_texts === [] ) {
self::set_basic_help_texts();
self::set_advanced_help_texts();
if ( self::$external_replacements === [] ) {
* Action: 'wpseo_register_extra_replacements' - Allows for registration of additional
do_action( 'wpseo_register_extra_replacements' );
* Register new replacement %%variables%%.
* For use by other plugins/themes to register extra variables.
* @see wpseo_register_var_replacement() for a usage example.
* @param string $var_to_replace The name of the variable to replace, i.e. '%%var%%'.
* Note: the surrounding %% are optional.
* @param mixed $replace_function Function or method to call to retrieve the replacement value for the variable.
* Uses the same format as add_filter/add_action function parameter and
* should *return* the replacement value. DON'T echo it.
* @param string $type Type of variable: 'basic' or 'advanced', defaults to 'advanced'.
* @param string $help_text Help text to be added to the help tab for this variable.
* @return bool Whether the replacement function was succesfully registered.
public static function register_replacement( $var_to_replace, $replace_function, $type = 'advanced', $help_text = '' ) {
if ( is_string( $var_to_replace ) && $var_to_replace !== '' ) {
$var_to_replace = self::remove_var_delimiter( $var_to_replace );
if ( preg_match( '`^[A-Z0-9_-]+$`i', $var_to_replace ) === false ) {
trigger_error( esc_html__( 'A replacement variable can only contain alphanumeric characters, an underscore or a dash. Try renaming your variable.', 'wordpress-seo' ), E_USER_WARNING );
elseif ( strpos( $var_to_replace, 'cf_' ) === 0 || strpos( $var_to_replace, 'ct_' ) === 0 ) {
trigger_error( esc_html__( 'A replacement variable can not start with "%%cf_" or "%%ct_" as these are reserved for the WPSEO standard variable variables for custom fields and custom taxonomies. Try making your variable name unique.', 'wordpress-seo' ), E_USER_WARNING );
elseif ( ! method_exists( self::class, 'retrieve_' . $var_to_replace ) ) {
if ( $var_to_replace !== '' && ! isset( self::$external_replacements[ $var_to_replace ] ) ) {
self::$external_replacements[ $var_to_replace ] = $replace_function;
$replacement_variable = new WPSEO_Replacement_Variable( $var_to_replace, $var_to_replace, $help_text );
self::register_help_text( $type, $replacement_variable );
trigger_error( esc_html__( 'A replacement variable with the same name has already been registered. Try making your variable name unique.', 'wordpress-seo' ), E_USER_WARNING );
trigger_error( esc_html__( 'You cannot overrule a WPSEO standard variable replacement by registering a variable with the same name. Use the "wpseo_replacements" filter instead to adjust the replacement value.', 'wordpress-seo' ), E_USER_WARNING );
* Replace `%%variable_placeholders%%` with their real value based on the current requested page/post/cpt/etc.
* @param string $text The string to replace the variables in.
* @param array $args The object some of the replacement values might come from,
* could be a post, taxonomy or term.
* @param array $omit Variables that should not be replaced by this function.
public function replace( $text, $args, $omit = [] ) {
$text = wp_strip_all_tags( $text );
// Let's see if we can bail super early.
if ( strpos( $text, '%%' ) === false ) {
return YoastSEO()->helpers->string->standardize_whitespace( $text );
if ( isset( $args['post_content'] ) && ! empty( $args['post_content'] ) ) {
$args['post_content'] = YoastSEO()->helpers->string->strip_shortcode( $args['post_content'] );
if ( isset( $args['post_excerpt'] ) && ! empty( $args['post_excerpt'] ) ) {
$args['post_excerpt'] = YoastSEO()->helpers->string->strip_shortcode( $args['post_excerpt'] );
$this->args = (object) wp_parse_args( $args, $this->defaults );
if ( is_array( $omit ) && $omit !== [] ) {
$omit = array_map( [ self::class, 'remove_var_delimiter' ], $omit );
if ( preg_match_all( '`%%([^%]+(%%single)?)%%?`iu', $text, $matches ) ) {
$replacements = $this->set_up_replacements( $matches, $omit );
* Filter: 'wpseo_replacements' - Allow customization of the replacements before they are applied.
* @param array $replacements The replacements.
* @param array $args The object some of the replacement values might come from,
* could be a post, taxonomy or term.
$replacements = apply_filters( 'wpseo_replacements', $replacements, $this->args );
// Do the actual replacements.
if ( is_array( $replacements ) && $replacements !== [] ) {
array_keys( $replacements ),
// Make sure to exclude replacement values that are arrays e.g. coming from a custom field serialized value.
array_filter( array_values( $replacements ), 'is_scalar' ),
* Filter: 'wpseo_replacements_final' - Allow overruling of whether or not to remove placeholders
* which didn't yield a replacement.
* @example <code>add_filter( 'wpseo_replacements_final', '__return_false' );</code>
if ( apply_filters( 'wpseo_replacements_final', true ) === true && ( isset( $matches[1] ) && is_array( $matches[1] ) ) ) {
// Remove non-replaced variables.
$remove = array_diff( $matches[1], $omit ); // Make sure the $omit variables do not get removed.
$remove = array_map( [ self::class, 'add_var_delimiter' ], $remove );
$text = str_replace( $remove, '', $text );
// Undouble separators which have nothing between them, i.e. where a non-replaced variable was removed.
if ( isset( $replacements['%%sep%%'] ) && ( is_string( $replacements['%%sep%%'] ) && $replacements['%%sep%%'] !== '' ) ) {
$q_sep = preg_quote( $replacements['%%sep%%'], '`' );
$text = preg_replace( '`' . $q_sep . '(?:\s*' . $q_sep . ')*`u', $replacements['%%sep%%'], $text );
// Remove superfluous whitespace.
$text = YoastSEO()->helpers->string->standardize_whitespace( $text );
* Register a new replacement variable if it has not been registered already.
* @param string $var_to_replace The name of the variable to replace, i.e. '%%var%%'.
* Note: the surrounding %% are optional.
* @param mixed $replace_function Function or method to call to retrieve the replacement value for the variable.
* Uses the same format as add_filter/add_action function parameter and
* should *return* the replacement value. DON'T echo it.
* @param string $type Type of variable: 'basic' or 'advanced', defaults to 'advanced'.
* @param string $help_text Help text to be added to the help tab for this variable.
* @return bool `true` if the replace var has been registered, `false` if not.
public function safe_register_replacement( $var_to_replace, $replace_function, $type = 'advanced', $help_text = '' ) {
if ( ! $this->has_been_registered( $var_to_replace ) ) {
return self::register_replacement( $var_to_replace, $replace_function, $type, $help_text );
* Checks whether the given replacement variable has already been registered or not.
* @param string $replacement_variable The replacement variable to check, including the variable delimiter (e.g. `%%var%%`).
* @return bool `true` if the replacement variable has already been registered.
public function has_been_registered( $replacement_variable ) {
$replacement_variable = self::remove_var_delimiter( $replacement_variable );
return isset( self::$external_replacements[ $replacement_variable ] );
* Returns the list of hidden replace vars.
* E.g. the replace vars that should work, but are not advertised.
* @return string[] The list of hidden replace vars.
public function get_hidden_replace_vars() {
* Retrieve the replacements for the variables found.
* @param array $matches Variables found in the original string - regex result.
* @param array $omit Variables that should not be replaced by this function.
* @return array Retrieved replacements - this might be a smaller array as some variables
* may not yield a replacement in certain contexts.
private function set_up_replacements( $matches, $omit ) {
// @todo Figure out a way to deal with external functions starting with cf_/ct_.
foreach ( $matches[1] as $k => $var ) {
// Don't set up replacements which should be omitted.
if ( in_array( $var, $omit, true ) ) {
// Deal with variable variable names first.
if ( strpos( $var, 'cf_' ) === 0 ) {
$replacement = $this->retrieve_cf_custom_field_name( $var );
elseif ( strpos( $var, 'ct_desc_' ) === 0 ) {
$replacement = $this->retrieve_ct_desc_custom_tax_name( $var );
elseif ( strpos( $var, 'ct_' ) === 0 ) {
$single = ( isset( $matches[2][ $k ] ) && $matches[2][ $k ] !== '' );
$replacement = $this->retrieve_ct_custom_tax_name( $var, $single );
// Deal with non-variable variable names.
elseif ( method_exists( $this, 'retrieve_' . $var ) ) {
$method_name = 'retrieve_' . $var;
$replacement = $this->$method_name();
// Deal with externally defined variable names.
elseif ( isset( self::$external_replacements[ $var ] ) && ! is_null( self::$external_replacements[ $var ] ) ) {
$replacement = call_user_func( self::$external_replacements[ $var ], $var, $this->args );
// Replacement retrievals can return null if no replacement can be determined, root those outs.
if ( isset( $replacement ) ) {
$var = self::add_var_delimiter( $var );
$replacements[ $var ] = $replacement;
unset( $replacement, $single, $method_name );
/* *********************** BASIC VARIABLES ************************** */
* Retrieve the post/cpt categories (comma separated) for use as replacement string.
private function retrieve_category() {
if ( ! empty( $this->args->ID ) ) {
$cat = $this->get_terms( $this->args->ID, 'category' );
if ( isset( $this->args->cat_name ) && ! empty( $this->args->cat_name ) ) {
$replacement = $this->args->cat_name;
* Retrieve the category description for use as replacement string.
private function retrieve_category_description() {
return $this->retrieve_term_description();
* Retrieve the date of the post/page/cpt for use as replacement string.
private function retrieve_date() {
if ( $this->args->post_date !== '' ) {
$replacement = YoastSEO()->helpers->date->format_translated( $this->args->post_date, get_option( 'date_format' ) );
elseif ( get_query_var( 'day' ) && get_query_var( 'day' ) !== '' ) {
$replacement = get_the_date();
elseif ( single_month_title( ' ', false ) && single_month_title( ' ', false ) !== '' ) {
$replacement = single_month_title( ' ', false );
elseif ( get_query_var( 'year' ) !== '' ) {
// Returns an integer, let's cast to string.
$replacement = (string) get_query_var( 'year' );
* Retrieve the post/page/cpt excerpt for use as replacement string.
* The excerpt will be auto-generated if it does not exist.
private function retrieve_excerpt() {
// Japanese doesn't have a jp_JP variant in WP.
$limit = ( $locale === 'ja' ) ? 80 : 156;
// The check `post_password_required` is because excerpt must be hidden for a post with a password.
if ( ! empty( $this->args->ID ) && ! post_password_required( $this->args->ID ) ) {
if ( $this->args->post_excerpt !== '' ) {
$replacement = wp_strip_all_tags( $this->args->post_excerpt );
elseif ( $this->args->post_content !== '' ) {
$content = strip_shortcodes( $this->args->post_content );
$content = wp_strip_all_tags( $content );
if ( mb_strlen( $content ) <= $limit ) {
$replacement = wp_html_excerpt( $content, $limit );
// Check if the description has space and trim the auto-generated string to a word boundary.
if ( strrpos( $replacement, ' ' ) ) {
$replacement = substr( $replacement, 0, strrpos( $replacement, ' ' ) );
* Retrieve the post/page/cpt excerpt for use as replacement string (without auto-generation).
private function retrieve_excerpt_only() {
// The check `post_password_required` is because excerpt must be hidden for a post with a password.
if ( ! empty( $this->args->ID ) && $this->args->post_excerpt !== '' && ! post_password_required( $this->args->ID ) ) {
$replacement = wp_strip_all_tags( $this->args->post_excerpt );
* Retrieve the title of the parent page of the current page/cpt for use as replacement string.
* Only applicable for hierarchical post types.
* @todo Check: shouldn't this use $this->args as well ?
private function retrieve_parent_title() {
if ( ! empty( $this->args->ID ) ) {
$parent_id = wp_get_post_parent_id( $this->args->ID );
$replacement = get_the_title( $parent_id );
* Retrieve the current search phrase for use as replacement string.
private function retrieve_searchphrase() {
$search = get_query_var( 's' );
$replacement = esc_html( $search );
* Retrieve the separator for use as replacement string.
* @return string Retrieves the title separator.
private function retrieve_sep() {
return YoastSEO()->helpers->options->get_title_separator();
* Retrieve the site's tag line / description for use as replacement string.
* The `$replacement` variable is static because it doesn't change depending
* on the context. See https://github.com/Yoast/wordpress-seo/pull/1172#issuecomment-46019482.
private function retrieve_sitedesc() {
if ( ! isset( $replacement ) ) {
$description = wp_strip_all_tags( get_bloginfo( 'description' ) );
if ( $description !== '' ) {
$replacement = $description;