model ) ) {
swiftling_yoast_localize( $presentation->model );
}
return $presentation;
}
add_filter( 'wpseo_frontend_presentation', 'swiftling_yoast_presentation' );
/**
* Does the same for the indexables of Yoast SEO's breadcrumbs, the page's own and its ancestors',
* which it prints and puts in its structured data.
*
* @param array $indexables Indexables, the home page first.
* @return array
*/
function swiftling_yoast_breadcrumb_indexables( $indexables ) {
if ( '' !== swiftling_mode() && is_array( $indexables ) ) {
array_map( 'swiftling_yoast_localize', $indexables );
}
return $indexables;
}
add_filter( 'wpseo_breadcrumb_indexables', 'swiftling_yoast_breadcrumb_indexables' );
/**
* Gives one of Yoast SEO's indexables, for this request only, its address in the page's language and
* the texts of its post or term as they are read now.
*
* @param object $indexable Indexable.
*/
function swiftling_yoast_localize( $indexable ) {
if ( ! is_object( $indexable ) ) {
return;
}
$state = &swiftling_state();
if ( 'translate' === $state['mode'] && is_string( $indexable->permalink ) && '' !== $indexable->permalink ) {
$indexable->permalink = swiftling_yoast_address( $indexable, (string) $state['language'] );
}
// A canonical address typed into its field, which Yoast SEO prints instead of the permalink.
if ( 'translate' === $state['mode'] && isset( $indexable->canonical ) && is_string( $indexable->canonical ) && '' !== $indexable->canonical ) {
$indexable->canonical = swiftling_page_address( $indexable->canonical, (string) $state['language'] );
}
$id = (int) $indexable->object_id;
if ( 'post' === $indexable->object_type && $id ) {
$fields = array(
'title' => '_yoast_wpseo_title',
'description' => '_yoast_wpseo_metadesc',
'open_graph_title' => '_yoast_wpseo_opengraph-title',
'open_graph_description' => '_yoast_wpseo_opengraph-description',
'twitter_title' => '_yoast_wpseo_twitter-title',
'twitter_description' => '_yoast_wpseo_twitter-description',
);
foreach ( $fields as $field => $key ) {
$value = get_post_meta( $id, $key, true );
if ( is_string( $value ) && '' !== $value ) {
$indexable->$field = $value;
}
}
// The breadcrumb shows the post's title, unless it has a breadcrumb title of its own.
$crumb = get_post_meta( $id, '_yoast_wpseo_bctitle', true );
$indexable->breadcrumb_title = is_string( $crumb ) && '' !== $crumb ? $crumb : swiftling_filter_title( (string) get_post_field( 'post_title', $id, 'raw' ), $id );
} elseif ( 'home-page' === $indexable->object_type ) {
// Without a description of its own, Yoast SEO stores the tagline as it was when it built the
// indexable, in the original; read again, it is the page's language's, as in the title.
$titles = get_option( 'wpseo_titles' );
if ( ! is_array( $titles ) || empty( $titles['metadesc-home-wpseo'] ) ) {
$indexable->description = get_bloginfo( 'description' );
}
} elseif ( 'term' === $indexable->object_type && $id && class_exists( 'WPSEO_Taxonomy_Meta' ) ) {
$term = get_term( $id );
if ( $term instanceof WP_Term && '' === (string) WPSEO_Taxonomy_Meta::get_term_meta( $term, $term->taxonomy, 'bctitle' ) ) {
$indexable->breadcrumb_title = $term->name;
}
}
}
/**
* Returns the address of one of Yoast SEO's indexables in a language, with its translated slugs.
*
* @param object $indexable Indexable.
* @param string $slug Address slug of a target language.
* @return string
*/
function swiftling_yoast_address( $indexable, $slug ) {
$home = static function () {
return home_url( '/' );
};
$build = null;
if ( 'home-page' === $indexable->object_type ) {
$build = $home;
} elseif ( 'post' === $indexable->object_type ) {
// A copy's address is its original's.
$post_id = (int) $indexable->object_id;
$original = swiftling_copy_original_id( $post_id );
$post_id = $original ? $original : $post_id;
$build = swiftling_real_front_page() === $post_id ? $home : static function () use ( $post_id ) {
return get_permalink( $post_id );
};
} elseif ( 'term' === $indexable->object_type ) {
$term = get_term( (int) $indexable->object_id );
if ( $term instanceof WP_Term ) {
$build = static function () use ( $term ) {
$link = get_term_link( $term );
return is_wp_error( $link ) ? '' : $link;
};
}
}
$url = null === $build ? '' : (string) swiftling_in_link_language(
$slug,
static function () use ( $build ) {
return swiftling_unlocalized_url( $build );
}
);
// Archives of post types and authors, dates and the search have no slugs of their own.
return swiftling_language_url( '' !== $url ? $url : swiftling_address_without_language( (string) $indexable->permalink ), $slug );
}
/**
* Gives the site in Yoast SEO's structured data the address of the page's language, as the page
* and its breadcrumbs have: the WebSite, and the organization or person behind it.
*
* @param array $graph Pieces of the structured data.
* @return array
*/
function swiftling_yoast_schema_graph( $graph ) {
$state = &swiftling_state();
if ( 'translate' !== $state['mode'] || ! is_array( $graph ) ) {
return $graph;
}
$from = swiftling_unlocalized_url(
static function () {
return home_url( '/' );
}
);
$to = swiftling_language_url( $from, (string) $state['language'] );
array_walk_recursive(
$graph,
static function ( &$value ) use ( $from, $to ) {
if ( is_string( $value ) && ( $value === $from || 0 === strpos( $value, $from . '#' ) ) ) {
$value = $to . substr( $value, strlen( $from ) );
}
}
);
return $graph;
}
add_filter( 'wpseo_schema_graph', 'swiftling_yoast_schema_graph' );
/**
* Gives the title variable of Yoast SEO and Rank Math, %%title%% and %title%, the post's title as
* SwiftLing reads it: marked in a page check, translated in a language. Both take the title from
* the post object, past the filters that translate it.
*
* @param array $replacements Variable => value.
* @param object $args Post, term or other object the values come from.
* @return array
*/
function swiftling_seo_title_variable( $replacements, $args ) {
if ( '' === swiftling_mode() || ! is_array( $replacements ) || ! is_object( $args ) || empty( $args->ID ) || ! isset( $args->post_title ) ) {
return $replacements;
}
foreach ( array( '%%title%%', '%title%' ) as $variable ) {
if ( isset( $replacements[ $variable ] ) && is_string( $replacements[ $variable ] ) ) {
$replacements[ $variable ] = swiftling_filter_title( (string) $args->post_title, (int) $args->ID );
}
}
return $replacements;
}
add_filter( 'wpseo_replacements', 'swiftling_seo_title_variable', 10, 2 );
add_filter( 'rank_math/replacements', 'swiftling_seo_title_variable', 10, 2 );
/**
* Hello Elementor names a singular page in the head with its excerpt, which it reads from the post
* object, past get_the_excerpt where SwiftLing translates an excerpt
* (hello_elementor_add_description_meta_tag(), checked in Hello Elementor 3.5.1): a page in a
* language named itself in the original. The theme asks its filter whether to print it just before
* it does, on wp_head; SwiftLing prints it there itself, with the excerpt as SwiftLing reads it,
* translated on a page in a language and marked in a page check, and tells the theme not to. A page
* in the original, and one without an excerpt, are left to the theme.
*
* @param mixed $show Whether the theme prints its description.
* @return mixed
*/
function swiftling_hello_elementor_description( $show ) {
if ( ! $show || '' === swiftling_mode() || ! is_singular() ) {
return $show;
}
$post = get_queried_object();
if ( ! $post instanceof WP_Post || '' === (string) $post->post_excerpt ) {
return $show;
}
echo '' . "\n";
return false;
}
// Last, so it prints only where nothing else turned the theme's description off.
add_filter( 'hello_elementor_description_meta_tag', 'swiftling_hello_elementor_description', PHP_INT_MAX );
/*
* SEOPress.
*/
/**
* Gives SEOPress's variables of a post the post's texts as SwiftLing reads them. SEOPress reads them
* from the post as stored, past the filters that translate them (seopress_get_dynamic_variables(),
* checked in SEOPress 10.3):
*
* - the title, %%title%% and %%post_title%%, read with get_post_field(): marked in a page check,
* translated in a language, as the title variable of Yoast SEO and Rank Math;
* - the excerpt of a post without one of its own, %%post_excerpt%%, %%excerpt%% and
* %%wc_single_short_desc%%, which SEOPress also prints as the meta description where none is
* set, and the content, %%post_content%%: both cut from the content as stored. In a language
* they are cut, as SEOPress cuts them, from the content in the language. A page check leaves
* them as they are: it finds the content's texts where the page shows them.
*
* An excerpt of the post's own SEOPress reads with get_the_excerpt(), in the language already. A
* variable SEOPress left empty, as the title outside the post's own page, stays empty.
*
* @param array $variables The post, the variables and their values in two lists beside each
* other, the words of an excerpt, and the excerpt SEOPress made.
* @return array
*/
function swiftling_seopress_variables( $variables ) {
$mode = swiftling_mode();
if ( '' === $mode || ! is_array( $variables ) || ! isset( $variables['post'] ) || ! $variables['post'] instanceof WP_Post
|| ! isset( $variables['seopress_titles_template_variables_array'], $variables['seopress_titles_template_replace_array'] )
|| ! is_array( $variables['seopress_titles_template_variables_array'] ) || ! is_array( $variables['seopress_titles_template_replace_array'] ) ) {
return $variables;
}
$post = $variables['post'];
$title = esc_attr( wp_strip_all_tags( str_replace( '
', ' ', (string) swiftling_filter_title( (string) $post->post_title, (int) $post->ID ) ) ) );
$values = array(
'%%title%%' => $title,
'%%post_title%%' => $title,
);
if ( 'translate' === $mode && '' !== (string) $post->post_content ) {
// SEOPress builds its variables again for each tag it prints, the content is translated once.
$state = &swiftling_state();
$length = isset( $variables['seopress_excerpt_length'] ) ? (int) $variables['seopress_excerpt_length'] : 50;
$cached = 'seopress|' . $post->ID . '|' . $length;
if ( ! isset( $state['values'][ $cached ] ) ) {
$state['values'][ $cached ] = swiftling_seopress_cut( swiftling_translate_post_content( swiftling_post_owner( $post->ID ), (string) $post->post_content ), $length );
}
$content = $state['values'][ $cached ];
$values['%%post_content%%'] = $content;
if ( '' === (string) $post->post_excerpt ) {
$values += array_fill_keys( array( '%%post_excerpt%%', '%%excerpt%%', '%%wc_single_short_desc%%' ), $content );
if ( isset( $variables['seopress_get_the_excerpt'] ) && is_string( $variables['seopress_get_the_excerpt'] ) && '' !== $variables['seopress_get_the_excerpt'] ) {
$variables['seopress_get_the_excerpt'] = $content;
}
}
}
foreach ( $variables['seopress_titles_template_variables_array'] as $index => $variable ) {
$value = isset( $variables['seopress_titles_template_replace_array'][ $index ] ) ? $variables['seopress_titles_template_replace_array'][ $index ] : null;
if ( is_string( $variable ) && isset( $values[ $variable ] ) && is_string( $value ) && '' !== $value ) {
$variables['seopress_titles_template_replace_array'][ $index ] = $values[ $variable ];
}
}
return $variables;
}
add_filter( 'seopress_titles_template_variables', 'swiftling_seopress_variables' );
/**
* Cuts the excerpt of a post from its content as SEOPress cuts it: without shortcodes, tags and
* line breaks of HTML, the first words.
*
* @param string $content Content of the post.
* @param int $length Words.
* @return string
*/
function swiftling_seopress_cut( $content, $length ) {
// SEOPress's own, which leaves out shortcodes that are not registered too.
$content = is_callable( array( '\SEOPress\Helpers\Shortcodes', 'stripTags' ) ) ? \SEOPress\Helpers\Shortcodes::stripTags( $content ) : strip_shortcodes( $content );
return wp_trim_words( esc_attr( stripslashes_deep( wp_filter_nohtml_kses( wp_strip_all_tags( (string) $content ) ) ) ), $length );
}
/**
* Gives SEOPress's canonical address, which it prints as a whole tag, the page in its language: its
* own, built from the original's address while the page renders in the language, and one typed
* into its field of a post or a term, which names the original's page.
*
* @param mixed $tag The link tag.
* @return mixed
*/
function swiftling_seopress_canonical( $tag ) {
if ( ! is_string( $tag ) || 'translate' !== swiftling_mode() ) {
return $tag;
}
return (string) preg_replace_callback(
'~(\shref=")([^"]*)"~',
static function ( $found ) {
$url = html_entity_decode( $found[2], ENT_QUOTES | ENT_HTML401, 'UTF-8' );
$new = swiftling_translated_canonical( $url );
// Escaped as SEOPress escapes it.
return $new === $url ? $found[0] : $found[1] . htmlspecialchars( urldecode( $new ), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) . '"';
},
$tag,
1
);
}
add_filter( 'seopress_titles_canonical', 'swiftling_seopress_canonical' );
/*
* All in One SEO.
*/
/**
* Gives All in One SEO's texts of a post, its SEO title, meta description and Facebook and X
* texts, which it reads from a table of its own (Models\Post::getPost(), checked in AIOSEO 5.0.3),
* as SwiftLing reads them: as the copy AIOSEO keeps in the post's meta for translation plugins
* (_aioseo_title, …) would be read, marked in a page check, translated in a language. The value
* comes from the table: AIOSEO writes that copy when the post is saved in its editor, not when it
* imports another SEO plugin's texts.
*
* In AIOSEO's code a post's row is written when the post is saved, by its scheduled scans and
* upgrades, and while a page renders only for a post without a row yet, with the texts of AIOSEO's
* earlier version, which this leaves alone: never the texts this gives.
*
* @param mixed $post AIOSEO's row of a post.
* @return mixed
*/
function swiftling_aioseo_post( $post ) {
if ( '' === swiftling_mode() || ! is_object( $post ) || empty( $post->post_id ) ) {
return $post;
}
$owner = swiftling_post_owner( (int) $post->post_id );
foreach ( array( 'title', 'description', 'og_title', 'og_description', 'twitter_title', 'twitter_description' ) as $field ) {
if ( isset( $post->$field ) && is_string( $post->$field ) && '' !== $post->$field ) {
$key = '_aioseo_' . $field;
$post->$field = swiftling_transform( $owner, swiftling_meta_path( $key, 0 ), 'meta:' . $key, $post->$field );
}
}
return $post;
}
add_filter( 'aioseo_get_post', 'swiftling_aioseo_post' );
/**
* Gives All in One SEO's tags that read the post a page shows as stored, past the filters that
* translate it, the post as SwiftLing reads it, in every title and description AIOSEO prints for
* the page: its own, those of Facebook and X, and those of its structured data
* (Utils\Tags::getTagValue(), checked in AIOSEO 5.0.3). AIOSEO puts the tags' values in before its
* filters aioseo_title and aioseo_description, so the values are replaced there, each where it
* stands as a whole, not as part of a longer word or next to a marker:
*
* - #post_excerpt and #post_excerpt_only: the post's own excerpt, marked in a page check,
* translated in a language;
* - #parent_title: the title of the post's parent, the same;
* - #post_content, and #post_excerpt of a post without an excerpt of its own: the description
* AIOSEO cuts from the content, cut in a language from the content in the language. A page check
* leaves it: it finds the content's texts where the page shows them. Where AIOSEO cuts it from
* anything else as well, such as a page builder's output or custom fields, it stays.
*
* #post_title reads the title with get_the_title(), which SwiftLing translates already.
*
* @param mixed $value Title or description, with the values of its tags.
* @return mixed
*/
function swiftling_aioseo_tag_values( $value ) {
$mode = swiftling_mode();
if ( '' === $mode || ! is_string( $value ) || '' === $value || ! function_exists( 'aioseo' ) || ! isset( aioseo()->helpers ) ) {
return $value;
}
$post = aioseo()->helpers->getPost();
if ( ! $post instanceof WP_Post ) {
return $value;
}
$values = swiftling_aioseo_post_values( $post, $mode );
if ( ! $values ) {
return $value;
}
$stored = array_keys( $values );
usort(
$stored,
static function ( $a, $b ) {
return strlen( (string) $b ) - strlen( (string) $a );
}
);
$pattern = '~(? As stored => as read, only where they differ.
*/
function swiftling_aioseo_post_values( $post, $mode ) {
$state = &swiftling_state();
$cached = 'aioseo|' . $post->ID;
if ( isset( $state['values'][ $cached ] ) ) {
return $state['values'][ $cached ];
}
$values = array();
if ( '' !== (string) $post->post_excerpt ) {
$values[ (string) $post->post_excerpt ] = (string) swiftling_filter_excerpt( (string) $post->post_excerpt, $post );
}
$parent = $post->post_parent ? get_post( $post->post_parent ) : null;
if ( $parent instanceof WP_Post && '' !== (string) $parent->post_title ) {
$values[ (string) $parent->post_title ] = (string) swiftling_filter_title( (string) $parent->post_title, $parent->ID );
}
if ( 'translate' === $mode && '' !== (string) $post->post_content ) {
$cut = (string) aioseo()->helpers->getDescriptionFromContent( $post );
if ( '' !== $cut && swiftling_aioseo_cut( $post->post_content ) === $cut ) {
$values[ $cut ] = swiftling_aioseo_cut( swiftling_translate_post_content( swiftling_post_owner( $post->ID ), (string) $post->post_content ) );
}
}
foreach ( $values as $stored => $read ) {
if ( '' === $read || (string) $stored === $read ) {
unset( $values[ $stored ] );
}
}
$state['values'][ $cached ] = $values;
return $values;
}
/**
* Cuts a description from a post's content as AIOSEO cuts it (getDescriptionFromContent(),
* AIOSEO 5.0.3): the content as AIOSEO renders it, without images, captions, embeds, tags and
* shortcodes, its first 55 words.
*
* @param string $content Content of the post.
* @return string
*/
function swiftling_aioseo_cut( $content ) {
$content = (string) aioseo()->helpers->theContent( (string) $content );
$content = (string) preg_replace( '/(||.*?<\/div>)/s', '', $content );
$content = str_replace( ']]>', ']]>', $content );
return wp_trim_words( trim( wp_strip_all_tags( strip_shortcodes( $content ) ) ), 55, '' );
}
// AIOSEO's canonical address, its own or typed into its field, is the page in its language.
add_filter( 'aioseo_canonical_url', 'swiftling_translated_canonical' );
/*
* The variables of SEO plugins.
*/
/**
* Returns the variables of SEO plugins a text holds: the plugin's syntax, which it replaces with a
* value as the page renders, not words. Machine translation keeps them as written, see
* swiftling_machine_translate(). Read as the plugins read them, checked in Yoast SEO 28.6, Rank Math
* 1.0.280, SEOPress 10.3 and All in One SEO 5.0.3:
*
* - Yoast SEO and SEOPress: %%name%%;
* - Rank Math: %name% and %name(arguments)%, as %customfield(subtitle)%;
* - All in One SEO: #name, only the tags it registers and only while it is active, as an ordinary
* hashtag looks the same; also #custom_field-name and #tax_name-name.
*
* @param string $text Text.
* @return string[] The variables, each once.
*/
function swiftling_seo_variables( $text ) {
$text = (string) $text;
if ( false === strpos( $text, '%' ) && false === strpos( $text, '#' ) ) {
return array();
}
$found = array();
// Yoast SEO's and SEOPress's first, so Rank Math's pattern takes no part of one.
$rest = (string) preg_replace_callback(
'/%%[^%\s]+%%/u',
static function ( $variable ) use ( &$found ) {
$found[] = $variable[0];
return ' ';
},
$text
);
if ( preg_match_all( '/%[a-z0-9_-]+(?:\([^()%]*\))?%/iu', $rest, $variables ) ) {
array_push( $found, ...$variables[0] );
}
$tags = swiftling_aioseo_tag_ids();
if ( $tags ) {
usort(
$tags,
static function ( $a, $b ) {
return strlen( $b ) - strlen( $a );
}
);
$names = implode(
'|',
array_map(
static function ( $tag ) {
return preg_quote( $tag, '/' );
},
$tags
)
);
if ( preg_match_all( '/#(?:(?:custom_field|tax_name)-[a-zA-Z0-9_-]+|(?:' . $names . '))(?![a-zA-Z0-9_])/i', $text, $variables ) ) {
array_push( $found, ...$variables[0] );
}
}
return array_values( array_unique( $found ) );
}
/**
* Returns the IDs of the tags All in One SEO registers, as post_title of #post_title, while it is
* active (Utils\Tags::$tags, AIOSEO 5.0.3).
*
* @return string[]
*/
function swiftling_aioseo_tag_ids() {
if ( ! function_exists( 'aioseo' ) || ! isset( aioseo()->tags->tags ) || ! is_array( aioseo()->tags->tags ) ) {
return array();
}
$ids = array();
foreach ( aioseo()->tags->tags as $tag ) {
if ( is_array( $tag ) && isset( $tag['id'] ) && is_string( $tag['id'] ) && '' !== $tag['id'] ) {
$ids[] = $tag['id'];
}
}
return $ids;
}