*/ public static function separators() { return array( 'dash' => '-', 'ndash' => "\u{2013}", 'mdash' => "\u{2014}", 'pipe' => '|', 'middot' => "\u{00B7}", 'bullet' => "\u{2022}", 'raquo' => "\u{00BB}", ); } /** * Default settings. * * @return array */ public static function defaults() { return array( 'separator' => 'ndash', 'home_title' => '%sitename% %sep% %page% %sep% %tagline%', 'home_description' => '%tagline%', 'post_types' => array(), 'taxonomies' => array(), 'author_archive' => array( 'title' => '%author% %sep% %page% %sep% %sitename%', 'description' => '%author_description%', 'noindex' => false, ), 'date_archive' => array( 'title' => '%date% %sep% %page% %sep% %sitename%', 'description' => '', 'noindex' => true, ), 'search_title' => '%search% %sep% %page% %sep% %sitename%', 'not_found_title' => '%not_found% %sep% %sitename%', 'og_default_image' => '', 'twitter_site' => '', 'sitemap_enabled' => true, 'schema_enabled' => true, 'knowledge_type' => 'organization', 'knowledge_name' => '', 'knowledge_logo' => '', 'knowledge_same_as' => array(), 'breadcrumb_home' => '', 'breadcrumb_separator' => "\u{203A}", 'check_external_links' => false, 'not_found_log' => true, 'redirect_pass_query' => true, 'change_retention_days' => 90, 'guardian_auto_redirect' => true, 'monitor_frequency' => 'weekly', 'monitor_email' => false, 'snapshot_retention' => 12, 'scan_frequency' => 'weekly', 'auto_redirected_links' => false, 'auto_redirect_chains' => false, 'auto_repair_email' => false, 'indexnow_enabled' => false, 'video_schema' => true, 'woo_schema' => true, 'local' => \Orbitstem\SEO\Schema\LocalBusiness::defaults(), 'image_sitemap' => true, 'video_sitemap' => true, 'news_sitemap' => false, 'news_name' => '', 'robots_extra' => '', 'ai_bots_block' => array(), 'llms_txt' => false, 'staging_guard' => true, 'noindex_empty_terms' => false, 'health_email' => 'off', 'update_guard' => true, 'force_output' => false, 'delete_data_on_uninstall' => false, ); } /** * Defaults of earlier versions that changed, so upgrades and importers can * tell an untouched old default from a template someone wrote. * * @return array{home_title:string,post_type_title:string} */ public static function legacy_defaults() { return array( 'home_title' => '%sitename% %sep% %tagline%', 'post_type_title' => '%title% %sep% %sitename%', ); } /** * Schema types a post type or a single post can use. * * @return string[] */ public static function schema_types() { return array( 'WebPage', 'Article', 'BlogPosting', 'NewsArticle' ); } /** * Defaults for one post type, keyed by machine name. * * @param string $post_type Post type name. * @return array */ public static function post_type_defaults( $post_type ) { return array( 'title' => '%title% %sep% %page% %sep% %sitename%', 'description' => '%excerpt%', 'noindex' => false, 'sitemap' => true, 'schema' => 'post' === $post_type ? 'Article' : 'WebPage', 'archive_title' => '%type% %sep% %page% %sep% %sitename%', 'archive_description' => '', ); } /** * Defaults for one taxonomy, keyed by machine name. * * @param string $taxonomy Taxonomy name. * @return array */ public static function taxonomy_defaults( $taxonomy ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- same signature as post_type_defaults(); per-taxonomy defaults may follow. return array( 'title' => '%term% %sep% %page% %sep% %sitename%', 'description' => '%term_description%', 'noindex' => false, 'sitemap' => true, ); } /** * All settings merged over defaults. * * @return array */ public function all() { $stored = get_option( self::OPTION, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } return array_merge( self::defaults(), $stored ); } /** * One top-level setting. * * @param string $key Setting key. * @return mixed */ public function get( $key ) { $all = $this->all(); return isset( $all[ $key ] ) ? $all[ $key ] : null; } /** * One field of a post type's settings, falling back to its default. * * @param string $post_type Post type name. * @param string $field Field key. * @return mixed */ public function post_type( $post_type, $field ) { $types = $this->get( 'post_types' ); $values = isset( $types[ $post_type ] ) && is_array( $types[ $post_type ] ) ? $types[ $post_type ] : array(); $values = array_merge( self::post_type_defaults( $post_type ), $values ); return isset( $values[ $field ] ) ? $values[ $field ] : null; } /** * One field of a taxonomy's settings, falling back to its default. * * @param string $taxonomy Taxonomy name. * @param string $field Field key. * @return mixed */ public function taxonomy( $taxonomy, $field ) { $taxes = $this->get( 'taxonomies' ); $values = isset( $taxes[ $taxonomy ] ) && is_array( $taxes[ $taxonomy ] ) ? $taxes[ $taxonomy ] : array(); $values = array_merge( self::taxonomy_defaults( $taxonomy ), $values ); return isset( $values[ $field ] ) ? $values[ $field ] : null; } /** * The separator character for the stored separator key. * * @return string */ public function separator() { $all = self::separators(); $key = $this->get( 'separator' ); return isset( $all[ $key ] ) ? $all[ $key ] : $all['ndash']; } /** * Sanitizes a full or partial settings array. Idempotent, so it is safe * when WordPress runs it again on add_option/update_option. * * @param mixed $input Raw input. * @return array */ public static function sanitize( $input ) { $input = is_array( $input ) ? $input : array(); $defaults = self::defaults(); $clean = array(); $separator = isset( $input['separator'] ) ? sanitize_key( $input['separator'] ) : $defaults['separator']; $clean['separator'] = array_key_exists( $separator, self::separators() ) ? $separator : $defaults['separator']; foreach ( array( 'home_title', 'home_description', 'search_title', 'not_found_title', 'twitter_site' ) as $key ) { $clean[ $key ] = isset( $input[ $key ] ) ? self::sanitize_text( $input[ $key ] ) : $defaults[ $key ]; } $clean['twitter_site'] = ltrim( $clean['twitter_site'], '@' ); $clean['twitter_site'] = preg_replace( '/[^A-Za-z0-9_]/', '', $clean['twitter_site'] ); $clean['og_default_image'] = isset( $input['og_default_image'] ) ? self::sanitize_absolute_url( $input['og_default_image'] ) : ''; foreach ( array( 'author_archive', 'date_archive' ) as $key ) { $clean[ $key ] = self::sanitize_group( isset( $input[ $key ] ) ? $input[ $key ] : array(), $defaults[ $key ], false ); } foreach ( array( 'post_types', 'taxonomies' ) as $group ) { $clean[ $group ] = array(); if ( empty( $input[ $group ] ) || ! is_array( $input[ $group ] ) ) { continue; } foreach ( $input[ $group ] as $name => $values ) { $name = sanitize_key( $name ); if ( '' === $name || ! is_array( $values ) ) { continue; } $base = 'post_types' === $group ? self::post_type_defaults( $name ) : self::taxonomy_defaults( $name ); $clean[ $group ][ $name ] = self::sanitize_group( $values, $base, false ); } } // Keep saved settings for types that are not registered right now (e.g. their plugin is off). $stored = get_option( self::OPTION, array() ); foreach ( array( 'post_types', 'taxonomies' ) as $group ) { if ( empty( $stored[ $group ] ) || ! is_array( $stored[ $group ] ) ) { continue; } foreach ( $stored[ $group ] as $name => $values ) { if ( ! isset( $clean[ $group ][ $name ] ) && is_array( $values ) && ! self::is_registered( $group, $name ) ) { $base = 'post_types' === $group ? self::post_type_defaults( $name ) : self::taxonomy_defaults( $name ); $clean[ $group ][ $name ] = self::sanitize_group( $values, $base, false ); } } } $clean['knowledge_type'] = isset( $input['knowledge_type'] ) && 'person' === $input['knowledge_type'] ? 'person' : 'organization'; $clean['knowledge_name'] = isset( $input['knowledge_name'] ) ? self::sanitize_text( $input['knowledge_name'] ) : ''; $clean['knowledge_logo'] = isset( $input['knowledge_logo'] ) ? self::sanitize_absolute_url( $input['knowledge_logo'] ) : ''; $clean['knowledge_same_as'] = isset( $input['knowledge_same_as'] ) ? self::sanitize_url_list( $input['knowledge_same_as'] ) : array(); $clean['breadcrumb_home'] = isset( $input['breadcrumb_home'] ) ? self::sanitize_text( $input['breadcrumb_home'] ) : ''; $clean['breadcrumb_separator'] = isset( $input['breadcrumb_separator'] ) ? mb_substr( self::sanitize_text( $input['breadcrumb_separator'] ), 0, 5 ) : $defaults['breadcrumb_separator']; // Absent switches keep their default; the settings form posts a hidden "0" before each checkbox. foreach ( array( 'sitemap_enabled', 'schema_enabled', 'check_external_links', 'not_found_log', 'redirect_pass_query', 'guardian_auto_redirect', 'monitor_email', 'auto_redirected_links', 'auto_redirect_chains', 'auto_repair_email', 'indexnow_enabled', 'video_schema', 'woo_schema', 'image_sitemap', 'video_sitemap', 'news_sitemap', 'llms_txt', 'staging_guard', 'noindex_empty_terms', 'update_guard', 'force_output', 'delete_data_on_uninstall' ) as $key ) { $clean[ $key ] = array_key_exists( $key, $input ) ? ! empty( $input[ $key ] ) : $defaults[ $key ]; } $clean['local'] = \Orbitstem\SEO\Schema\LocalBusiness::sanitize( isset( $input['local'] ) ? $input['local'] : array() ); $clean['news_name'] = isset( $input['news_name'] ) ? mb_substr( self::sanitize_text( $input['news_name'] ), 0, 100 ) : ''; $clean['robots_extra'] = isset( $input['robots_extra'] ) ? self::sanitize_robots( $input['robots_extra'] ) : ''; $clean['ai_bots_block'] = isset( $input['ai_bots_block'] ) && is_array( $input['ai_bots_block'] ) ? array_values( array_intersect( array_keys( \Orbitstem\SEO\Frontend\Robots::ai_bots() ), $input['ai_bots_block'] ) ) : array(); $clean['health_email'] = isset( $input['health_email'] ) && in_array( $input['health_email'], array( 'off', 'weekly', 'monthly' ), true ) ? $input['health_email'] : $defaults['health_email']; $clean['change_retention_days'] = isset( $input['change_retention_days'] ) ? min( 3650, max( 7, (int) $input['change_retention_days'] ) ) : $defaults['change_retention_days']; $clean['snapshot_retention'] = isset( $input['snapshot_retention'] ) ? min( 100, max( 2, (int) $input['snapshot_retention'] ) ) : $defaults['snapshot_retention']; $clean['monitor_frequency'] = isset( $input['monitor_frequency'] ) && in_array( $input['monitor_frequency'], array( 'off', 'daily', 'weekly' ), true ) ? $input['monitor_frequency'] : $defaults['monitor_frequency']; $clean['scan_frequency'] = isset( $input['scan_frequency'] ) && in_array( $input['scan_frequency'], array( 'off', 'daily', 'weekly' ), true ) ? $input['scan_frequency'] : $defaults['scan_frequency']; return $clean; } /** * Whether a post type or taxonomy is currently registered. * * @param string $group 'post_types' or 'taxonomies'. * @param string $name Machine name. * @return bool */ private static function is_registered( $group, $name ) { return 'post_types' === $group ? post_type_exists( $name ) : taxonomy_exists( $name ); } /** * Sanitizes a group of template fields, flags and a schema type. * * An absent flag keeps its default, so re-saving older stored settings never * flips a switch; the settings form posts a hidden "0" before each checkbox. * * @param mixed $values Raw values. * @param array $base Defaults for the group. * @param bool $submitted When true, an absent flag means false. * @return array */ private static function sanitize_group( $values, array $base, $submitted ) { $values = is_array( $values ) ? $values : array(); $out = array(); foreach ( $base as $field => $default ) { if ( is_bool( $default ) ) { if ( array_key_exists( $field, $values ) ) { $out[ $field ] = ! empty( $values[ $field ] ) && 'false' !== $values[ $field ]; } else { $out[ $field ] = $submitted ? false : $default; } continue; } if ( 'schema' === $field ) { $out[ $field ] = isset( $values[ $field ] ) && in_array( $values[ $field ], self::schema_types(), true ) ? $values[ $field ] : $default; continue; } $out[ $field ] = isset( $values[ $field ] ) ? self::sanitize_text( $values[ $field ] ) : $default; } return $out; } /** * Splits a list of profile URLs (array, or text with one per line) into valid URLs. * * @param mixed $input Raw input. * @return string[] */ public static function sanitize_url_list( $input ) { // One per line (or space). A comma inside a URL is kept; a comma that ends an // item or directly starts the next URL ("a,https://b") separates. $items = is_array( $input ) ? $input : preg_split( '/\s+|,(?=\s*https?:\/\/)/i', (string) $input ); $items = array_map( function ( $item ) { return is_string( $item ) ? rtrim( $item, ',;' ) : $item; }, (array) $items ); $urls = array(); foreach ( (array) $items as $item ) { $url = self::sanitize_absolute_url( is_string( $item ) ? $item : '' ); if ( '' !== $url ) { $urls[] = $url; } } return array_values( array_unique( $urls ) ); } /** * Single-line plain text that keeps template tokens intact. * * Not sanitize_text_field(): it strips "%" followed by two hex characters, * which would turn "%date%" into "te%". Not wp_strip_all_tags() either: it * treats "<12" as the start of a tag, so "Kids <12 years" became "Kids". * Limited to 1000 characters. * * @param mixed $text Raw text. * @return string */ public static function sanitize_text( $text ) { if ( ! is_scalar( $text ) ) { return ''; } $text = wp_check_invalid_utf8( (string) $text ); $text = self::strip_tags( $text ); $text = preg_replace( '/[\r\n\t ]+/', ' ', $text ); return mb_substr( trim( $text ), 0, 1000 ); } /** * Cleans extra robots.txt lines: plain text only, one directive or comment * per line, at most 200 lines. * * @param mixed $text Raw. * @return string */ public static function sanitize_robots( $text ) { $lines = array(); foreach ( preg_split( '/\r\n|\r|\n/', is_scalar( $text ) ? (string) $text : '' ) as $line ) { $line = trim( wp_check_invalid_utf8( self::strip_tags( $line ) ) ); // Directives ("Name: value") and comments; anything else would only confuse crawlers. if ( '' === $line || preg_match( '/^(#.*|[A-Za-z][A-Za-z-]*\s*:.*)$/', $line ) ) { $lines[] = mb_substr( $line, 0, 500 ); } } return trim( implode( "\n", array_slice( $lines, 0, 200 ) ) ); } /** * Removes HTML tags, comments and script/style blocks, but keeps a "<" that * does not start a tag ("3<5", "<12 years"). * * @param string $text Text. * @return string */ public static function strip_tags( $text ) { $text = (string) $text; // Quoted attribute values may contain ">" (title="a>b"). $tag = '#"\']|"[^"]*"|\'[^\']*\')*>#'; // Repeat until nothing changes, so nested pieces like "ipt>" cannot // form a new tag once the inner one is removed. for ( $pass = 0; $pass < 10; $pass++ ) { $before = $text; $text = (string) preg_replace( '@<(script|style)\b(?:[^>"\']|"[^"]*"|\'[^\']*\')*>.*?@si', '', $text ); $text = (string) preg_replace( '/|$)/s', '', $text ); $text = (string) preg_replace( $tag, '', $text ); if ( $before === $text ) { break; } } return $text; } /** * Accepts only absolute http(s) URLs. * * @param mixed $url Raw URL. * @return string Clean URL or empty string. */ public static function sanitize_absolute_url( $url ) { if ( ! is_string( $url ) ) { return ''; } $url = trim( $url ); // esc_url_raw() would turn a bare word like "example" into "http://example". if ( ! preg_match( '#^https?://#i', $url ) ) { return ''; } $url = esc_url_raw( $url, array( 'http', 'https' ) ); if ( '' === $url ) { return ''; } $parts = wp_parse_url( $url ); if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { return ''; } return $url; } }