".
*
* Only real markup is removed: an HTML element or custom element tag
* ("", "
", ""), a comment, a doctype or a processing
* instruction. Text such as "Ages 5<10", "3 > 2", "<3", "" or
* "Alex " survives. Removal repeats until stable so
* fragments cannot reassemble into a tag. Output is always escaped for its
* context later; this function only decides what counts as text.
*
* @param string $text Text that may contain HTML.
* @return string Text without markup.
*/
function sts_seo_strip_markup( $text ) {
$text = preg_replace( '@<(script|style|noscript|template)\b[^>]*>.*?\1\s*>@isu', ' ', $text );
$text = preg_replace( '//su', ' ', (string) $text );
// Block boundaries become spaces so adjacent paragraphs do not run together.
$text = preg_replace( '/<(?:br\b[^<>]*|\/(?:p|div|li|h[1-6]|blockquote|tr|td|th|dt|dd|figcaption|summary|pre|section|article))>/iu', ' ', (string) $text );
for ( $i = 0; $i < 5; $i++ ) {
$next = preg_replace( '/]*>|<\?[^<>]*>/u', '', (string) $text );
$next = preg_replace_callback(
'/<\/?([a-zA-Z][a-zA-Z0-9:-]*)(?:[\s\/][^<>]*)?>/u',
function ( $m ) {
return sts_seo_is_html_tag_name( $m[1] ) ? '' : $m[0];
},
(string) $next
);
if ( $next === $text ) {
break;
}
$text = $next;
}
return (string) $text;
}
/**
* Normalize an editor-entered or stored text field to plain text.
*
* Used for SEO titles, descriptions, business details, alt text and the site
* name. Keeps every character an editor typed except markup and control
* characters: brackets, "%" sequences and a lone "<" or ">" are preserved.
* Entities are decoded once, so values stored by 0.2.x (for example "<")
* read back as the intended character. Never renders blocks or shortcodes.
*
* @param mixed $value Raw value.
* @return string Plain text, or '' for non-scalar input.
*/
function sts_seo_normalize_field( $value ) {
if ( ! is_scalar( $value ) ) {
return '';
}
$text = (string) $value;
if ( '' !== $text && ! preg_match( '//u', $text ) ) {
// Drop only the invalid bytes; valid UTF-8 sequences are kept (W3C pattern, no extensions needed).
$text = (string) preg_replace( '/((?:[\x00-\x7F]|[\xC2-\xDF][\x80-\xBF]|\xE0[\xA0-\xBF][\x80-\xBF]|[\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}|\xED[\x80-\x9F][\x80-\xBF]|\xF0[\x90-\xBF][\x80-\xBF]{2}|[\xF1-\xF3][\x80-\xBF]{3}|\xF4[\x80-\x8F][\x80-\xBF]{2})+)|./s', '$1', $text );
}
$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
$text = sts_seo_strip_markup( $text );
// C0/C1 control characters (other than whitespace) and zero-width/bidi controls.
$text = preg_replace( '/[\x{0000}-\x{0008}\x{000B}\x{000C}\x{000E}-\x{001F}\x{007F}-\x{009F}\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2066}-\x{2069}\x{FEFF}]/u', '', $text );
return trim( (string) preg_replace( '/[\s\p{Z}]+/u', ' ', (string) $text ) );
}
/**
* Backward-compatible name for sts_seo_normalize_field().
*
* Kept because it is the registered meta sanitize callback and may be used by
* integrations. Since 0.3.0 it no longer removes bracketed text, "%XX"
* sequences or text after a lone "<".
*
* @param mixed $value Raw value.
* @return string Plain text.
*/
function sts_seo_plain_text( $value ) {
return sts_seo_normalize_field( $value );
}
/**
* Remove shortcode syntax from post content before it is summarized.
*
* Registered shortcodes are removed by WordPress. Unregistered ones (for
* example from a deactivated plugin) are removed only when they clearly look
* like shortcodes: a closing tag "[/name]", an opening tag with a matching
* closer, an attribute ("[name key=value]") or a self-closing tag ("[name /]").
* Plain bracketed prose such as "[ages 5–18]" or "[Draft]" is kept.
*
* @param string $text Raw post content or excerpt.
* @return string Content without shortcode syntax.
*/
function sts_seo_strip_shortcode_syntax( $text ) {
$text = strip_shortcodes( (string) $text );
preg_match_all( '/\[\/([a-zA-Z][a-zA-Z0-9_-]*)\]/u', $text, $closers );
$closed = array_unique( $closers[1] );
$text = preg_replace( '/\[\/[a-zA-Z][a-zA-Z0-9_-]*\]/u', ' ', $text );
return (string) preg_replace_callback(
'/\[([a-zA-Z][a-zA-Z0-9_-]*)(\s[^\[\]\r\n]*)?\]/u',
function ( $m ) use ( $closed ) {
$attrs = isset( $m[2] ) ? trim( $m[2] ) : '';
if ( in_array( $m[1], $closed, true ) || false !== strpos( $attrs, '=' ) || '/' === substr( $attrs, -1 ) ) {
return ' ';
}
return $m[0];
},
$text
);
}
/**
* Convert raw post content or an excerpt to plain summary text.
*
* @param string $html Raw content.
* @return string Plain text.
*/
function sts_seo_content_text( $html ) {
return sts_seo_normalize_field( sts_seo_strip_shortcode_syntax( $html ) );
}
/**
* Extract only static editorial text; ignore dynamic blocks, embeds, code and HTML.
*
* @param array $blocks Parsed blocks.
* @return string Plain text.
*/
function sts_seo_block_text( $blocks ) {
$text = array();
$leaf = array( 'core/paragraph', 'core/heading', 'core/list-item', 'core/table', 'core/verse', 'core/freeform' );
$containers = array( 'core/group', 'core/columns', 'core/column', 'core/list', 'core/quote', 'core/pullquote', 'core/cover', 'core/media-text', 'core/details' );
foreach ( $blocks as $block ) {
$name = $block['blockName'];
if ( null === $name || in_array( $name, $leaf, true ) ) {
$text[] = sts_seo_content_text( $block['innerHTML'] );
} elseif ( in_array( $name, $containers, true ) ) {
$text[] = $block['innerBlocks'] ? sts_seo_block_text( $block['innerBlocks'] ) : sts_seo_content_text( $block['innerHTML'] );
}
}
// Parts are already plain text; only join and collapse whitespace (no second entity decode).
return trim( (string) preg_replace( '/[\s\p{Z}]+/u', ' ', implode( ' ', $text ) ) );
}
/**
* Trim plain text to a number of words (or characters, for locales that count
* characters) without treating "<" as the start of an HTML tag.
*
* Core's wp_trim_words() strips tags first, which would cut "Ages 5<10" short.
*
* @param string $text Plain text.
* @param int $num_words Word limit.
* @return string Trimmed text.
*/
function sts_seo_trim_words( $text, $num_words = 30 ) {
$guard = "\u{E000}"; // Private-use placeholder for "<" while core trims.
$trimmed = wp_trim_words( str_replace( '<', $guard, (string) $text ), $num_words, '…' );
return str_replace( $guard, '<', $trimmed );
}
/**
* Whether a post is public: published, viewable and not password protected.
*
* @param mixed $post Post object.
* @return bool
*/
function sts_seo_public_post( $post ) {
return $post instanceof WP_Post && is_post_publicly_viewable( $post ) && '' === $post->post_password;
}
/**
* Return a normalized per-content title override for a public Post or Page.
*
* @param int $post_id Post ID.
* @return string Override, or ''.
*/
function sts_seo_title( $post_id ) {
$post = get_post( $post_id );
if ( ! $post_id || ! sts_seo_public_post( $post ) || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
return '';
}
/** Filter a title override for a public post/page; output is normalized again. */
return sts_seo_plain_text( apply_filters( 'sts_seo_title', get_post_meta( $post_id, '_sts_seo_title', true ), $post_id ) );
}
/**
* The Page assigned as WordPress's Posts page (Settings > Reading), or 0.
*
* Read from core settings on every call, so it follows any theme and any
* permalink structure, and is 0 when the posts index is the front page.
*/
function sts_seo_posts_page_id() {
return 'page' === get_option( 'show_on_front' ) ? (int) get_option( 'page_for_posts' ) : 0;
}
/**
* The Post or Page whose SEO fields describe the current request, or 0.
*
* Singular Posts/Pages use the queried object. The posts index uses the
* assigned Posts page; a latest-posts front page has no owning Page.
*/
function sts_seo_context_id() {
if ( is_singular( array( 'post', 'page' ) ) ) {
return get_queried_object_id();
}
return is_home() ? sts_seo_posts_page_id() : 0;
}
/**
* Let core emit the one document title while optionally replacing its calculated value.
*
* @param string $title Value from pre_get_document_title (normally '').
* @return string
*/
function sts_seo_document_title( $title ) {
if ( is_admin() || is_feed() || is_embed() || is_preview() || is_404() || is_search() || ! sts_seo_feature_enabled( 'title' ) ) {
return $title;
}
$id = sts_seo_context_id();
if ( ! $id ) {
return $title;
}
$override = sts_seo_title( $id );
if ( '' === $override ) {
return $title;
}
// Core prints a pre_get_document_title value without escaping it.
$override = esc_html( $override );
// Later pages of the posts index keep core's page marker, so each page has a distinct title.
$paged = is_home() ? (int) get_query_var( 'paged' ) : 0;
if ( $paged > 1 ) {
/** This filter is documented in wp-includes/general-template.php */
$sep = apply_filters( 'document_title_separator', '-' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter, applied so the separator matches core's.
$override .= wptexturize( esc_html( ' ' . $sep . ' ' . sprintf( sts_seo_page_marker_format(), $paged ) ) );
}
return $override;
}
/**
* Format string for the "Page N" marker on later posts-index pages.
*
* Uses the plugin's own translatable string. Until STS SEO has a translation
* for the site's language, WordPress's existing translation of its identical
* marker is used, so titles read exactly as WordPress writes them in every
* language (and exactly as STS SEO 0.3.x printed them).
*
* @return string Format string containing one %s placeholder.
*/
function sts_seo_page_marker_format() {
$source = 'Page %s';
/* translators: %s: Page number. Used in the title of later pages of the posts index, for example "News - Page 2". */
$format = __( 'Page %s', 'sts-seo' );
if ( $source === $format ) {
// Same result as core's __( 'Page %s' ): its translation, then the gettext filters core applies.
$core = get_translations_for_domain( 'default' )->translate( $source );
/** This filter is documented in wp-includes/l10n.php */
$core = apply_filters( 'gettext', $core, $source, 'default' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter, applied as core does for this string.
/** This filter is documented in wp-includes/l10n.php */
$core = apply_filters( 'gettext_default', $core, $source, 'default' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter, applied as core does for this string.
if ( is_string( $core ) && '' !== $core ) {
$format = $core;
}
}
return $format;
}
/**
* Build a generated summary; a manual excerpt takes priority over static block content.
*
* @param string $excerpt Manual excerpt.
* @param string $content Raw post content.
* @return string
*/
function sts_seo_summary( $excerpt, $content ) {
$source = sts_seo_content_text( $excerpt );
if ( '' === $source ) {
$source = sts_seo_block_text( parse_blocks( $content ) );
}
// WordPress handles word/character segmentation for the site's locale.
return sts_seo_trim_words( $source, 30 );
}
/**
* Description: explicit value, manual excerpt, then a concise static summary.
*
* @param int $post_id Post ID.
* @return string
*/
function sts_seo_description( $post_id ) {
$post = get_post( $post_id );
if ( ! $post_id || ! sts_seo_public_post( $post ) ) {
return '';
}
$description = sts_seo_plain_text( get_post_meta( $post_id, '_sts_seo_description', true ) );
if ( '' === $description ) {
$description = sts_seo_summary( $post->post_excerpt, $post->post_content );
}
/** Filter a description for a public post/page; output is normalized again. */
return sts_seo_plain_text( apply_filters( 'sts_seo_description', $description, $post_id ) );
}
/**
* Validate an attachment ID for storing: a real image attachment with an http(s) URL.
*
* Whether the image may be published is not decided here. The parent post's
* visibility can change after the ID is saved (a draft is published, a post is
* made private), so that check runs at output time in sts_seo_image_id(). This
* lets a social image be chosen on a draft and appear once the draft goes live.
*
* @param mixed $value Raw ID.
* @return int Valid image attachment ID, or 0.
*/
function sts_seo_storable_image_id( $value ) {
if ( ! is_scalar( $value ) || ! preg_match( '/^[1-9][0-9]*$/D', (string) $value ) ) {
return 0;
}
$id = (int) $value;
$attachment = get_post( $id );
if ( ! $attachment || 'attachment' !== $attachment->post_type || ! in_array( $attachment->post_status, array( 'inherit', 'publish' ), true ) || ! wp_attachment_is_image( $id ) ) {
return 0;
}
if ( $attachment->post_password ) {
return 0;
}
$url = wp_get_attachment_url( $id );
if ( ! $url || ! in_array( wp_parse_url( $url, PHP_URL_SCHEME ), array( 'http', 'https' ), true ) ) {
return 0;
}
return $id;
}
/**
* Validate an attachment ID strictly for output: no negative, fractional, non-image or
* missing attachments, and no image attached to a post that is not public.
*
* @param mixed $value Raw ID.
* @return int Valid public image attachment ID, or 0.
*/
function sts_seo_image_id( $value ) {
$id = sts_seo_storable_image_id( $value );
if ( ! $id ) {
return 0;
}
$parent = get_post( $id )->post_parent;
if ( $parent && ! sts_seo_public_post( get_post( $parent ) ) ) {
return 0;
}
return $id;
}
/**
* Resolve one shared social image for both Open Graph and Twitter.
*
* @param int $post_id Post ID, or 0 for the posts index without a Page.
* @return array Image id, url and alt, or an empty array.
*/
function sts_seo_social_image( $post_id ) {
$ids = array();
if ( $post_id ) {
$ids[] = get_post_meta( $post_id, '_sts_seo_social_image_id', true );
$ids[] = get_post_thumbnail_id( $post_id );
}
$ids[] = get_option( 'sts_seo_default_social_image_id', 0 );
$selected = 0;
foreach ( $ids as $id ) {
$valid = sts_seo_image_id( $id );
if ( $valid ) {
$selected = $valid;
break;
}
}
/** Filter the resolved attachment ID. Invalid replacements yield no image. */
$selected = sts_seo_image_id( apply_filters( 'sts_seo_social_image_id', $selected, $post_id ) );
if ( ! $selected ) {
return array();
}
return array(
'id' => $selected,
'url' => wp_get_attachment_url( $selected ),
'alt' => sts_seo_plain_text( get_post_meta( $selected, '_wp_attachment_image_alt', true ) ),
);
}
/**
* Build only the unambiguous posts-index URL, without arbitrary query parameters.
*
* @param int $page Page number.
* @return string URL, or ''.
*/
function sts_seo_posts_url( $page = 1 ) {
global $wp_rewrite;
$page = max( 1, (int) $page );
$id = sts_seo_posts_page_id();
$url = $id ? get_permalink( $id ) : home_url( '/' );
if ( ! $url ) {
return '';
}
if ( $page > 1 ) {
if ( $wp_rewrite->using_permalinks() ) {
$url = trailingslashit( $url );
if ( $wp_rewrite->using_index_permalinks() && ! $id && false === strpos( $url, '/index.php/' ) ) {
$url .= 'index.php/';
}
$url .= user_trailingslashit( $wp_rewrite->pagination_base . '/' . $page, 'paged' );
} else {
$url = add_query_arg( 'paged', $page, $url );
}
}
return $url;
}
/** Build metadata only for public Posts, Pages and the posts index. */
function sts_seo_metadata() {
if ( is_admin() || is_feed() || is_embed() || is_preview() || is_404() || is_search() || ( ! is_home() && ! is_singular( array( 'post', 'page' ) ) ) ) {
return array();
}
$id = sts_seo_context_id();
if ( $id && ! sts_seo_public_post( get_post( $id ) ) ) {
return array();
}
$url = is_home() ? sts_seo_posts_url( get_query_var( 'paged', 1 ) ) : wp_get_canonical_url( $id );
return array(
'title' => sts_seo_plain_text( wp_get_document_title() ),
'description' => $id ? sts_seo_description( $id ) : '',
'url' => $url,
'type' => is_singular( 'post' ) ? 'article' : 'website',
'site_name' => sts_seo_plain_text( get_bloginfo( 'name' ) ),
'image' => sts_seo_social_image( $id ),
);
}