". * * 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[^>]*>.*?@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 ), ); }