false, 'delete_data_on_uninstall' => false, 'guard_site_editor_contrast' => true, 'allow_gravatar' => false, 'news_schema' => true, 'consent_embeds' => true, 'emoji_script' => false, 'map_tile_url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'news_categories' => array(), 'news_sitemap' => true, 'editorial_page' => 0, 'motion' => 'subtle', 'organization_street' => '', 'organization_postal_code' => '', 'organization_city' => '', 'organization_country' => '', 'organization_phone' => '', 'organization_email' => '', ); /** * A tile URL: https, with the placeholders {z}, {x} and {y}, no spaces, quotes or angle brackets. */ private const TILE_URL_PATTERN = '^https://(?=[^\s]*\{z\})(?=[^\s]*\{x\})(?=[^\s]*\{y\})[^\s"\'<>]+$'; /** * Registers the option for the settings API and REST. Runs on init. */ public static function register(): void { register_setting( 'leitvia_studio', self::OPTION, array( 'type' => 'object', 'description' => __( 'Leitvia Studio settings.', 'leitvia-studio' ), 'default' => self::DEFAULTS, 'sanitize_callback' => array( self::class, 'sanitize' ), 'show_in_rest' => array( 'schema' => array( 'type' => 'object', // array_map() with two arrays drops the keys, array_combine() puts them back. 'properties' => array_combine( array_keys( self::DEFAULTS ), array_map( static fn( $fallback, string $key ): array => match ( true ) { is_bool( $fallback ) => array( 'type' => 'boolean', 'default' => $fallback, ), is_int( $fallback ) => array( 'type' => 'integer', 'default' => $fallback, 'minimum' => 0, ), is_array( $fallback ) => array( 'type' => 'array', 'default' => $fallback, 'items' => array( 'type' => 'integer', 'minimum' => 1, ), ), 'motion' === $key => array( 'type' => 'string', 'default' => $fallback, 'enum' => \LeitviaStudio\Blocks\Animation::SITE, ), str_starts_with( $key, 'organization_' ) => array( 'type' => 'string', 'default' => $fallback, ), default => array( 'type' => 'string', 'default' => $fallback, 'pattern' => self::TILE_URL_PATTERN, ), }, self::DEFAULTS, array_keys( self::DEFAULTS ) ) ), 'additionalProperties' => false, ), ), ) ); } /** * Keeps the known keys as booleans, page IDs and lists of term IDs, the tile URL as a valid tile URL, motion as off, * subtle or bold (else the saved value or its default), the organization details as plain text, and drops the rest. Missing keys and an invalid * tile URL keep their saved value, so a partial update through /wp/v2/settings changes only the keys it sends; * without a saved value they get their default. * * @param mixed $value Submitted value. * @return array */ public static function sanitize( $value ): array { $value = is_array( $value ) ? $value : array(); $clean = array(); foreach ( self::DEFAULTS as $key => $fallback ) { $given = array_key_exists( $key, $value ) ? $value[ $key ] : self::get( $key ); $clean[ $key ] = match ( true ) { is_bool( $fallback ) => array_key_exists( $key, $value ) ? filter_var( $given, FILTER_VALIDATE_BOOLEAN ) : (bool) $given, is_int( $fallback ) => absint( $given ), is_array( $fallback ) => array_values( array_unique( array_filter( array_map( 'absint', (array) $given ) ) ) ), 'motion' === $key => in_array( $given, \LeitviaStudio\Blocks\Animation::SITE, true ) ? $given : self::motion(), 'organization_email' === $key => sanitize_email( (string) $given ), str_starts_with( $key, 'organization_' ) => sanitize_text_field( (string) $given ), default => self::tile_url( $given ), }; } return $clean; } /** * The saved motion setting when it is valid, else its default. */ private static function motion(): string { $saved = self::get( 'motion' ); return in_array( $saved, \LeitviaStudio\Blocks\Animation::SITE, true ) ? $saved : self::DEFAULTS['motion']; } /** * A valid map tile URL (https, with {z}, {x} and {y}), else the saved one, else the default. * * @param mixed $url Candidate URL. */ public static function tile_url( $url ): string { $saved = get_option( self::OPTION ); foreach ( array( $url, is_array( $saved ) ? ( $saved['map_tile_url'] ?? null ) : null ) as $candidate ) { if ( is_string( $candidate ) && preg_match( '#' . self::TILE_URL_PATTERN . '#', $candidate ) && filter_var( str_replace( array( '{z}', '{x}', '{y}', '{s}' ), '0', $candidate ), FILTER_VALIDATE_URL ) ) { return $candidate; } } return self::DEFAULTS['map_tile_url']; } /** * Removes the emoji loader of core unless the setting emoji_script is on. Core's detection script makes browsers * without native emoji load images from s.w.org, a request to another host without consent; feeds and emails get * the same images through wp_staticize_emoji(). Runs on init and admin_init. */ public static function remove_emoji_loader(): void { if ( self::get( 'emoji_script' ) ) { return; } remove_action( 'wp_head', 'print_emoji_detection_script', 7 ); remove_action( 'admin_print_scripts', 'print_emoji_detection_script' ); remove_action( 'embed_head', 'print_emoji_detection_script' ); remove_action( 'wp_enqueue_scripts', 'wp_enqueue_emoji_styles' ); remove_action( 'admin_enqueue_scripts', 'wp_enqueue_emoji_styles' ); remove_action( 'enqueue_embed_scripts', 'wp_enqueue_emoji_styles' ); // Kept by core for backwards compatibility and unhooked only by wp_enqueue_emoji_styles(); left alone it would // print a deprecation notice. remove_action( 'wp_print_styles', 'print_emoji_styles' ); remove_action( 'admin_print_styles', 'print_emoji_styles' ); remove_filter( 'the_content_feed', 'wp_staticize_emoji' ); remove_filter( 'comment_text_rss', 'wp_staticize_emoji' ); remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' ); } /** * One setting, or its default when it is not saved. * * @param string $key Setting name, a key of DEFAULTS. */ public static function get( string $key ): mixed { $value = get_option( self::OPTION ); return is_array( $value ) && array_key_exists( $key, $value ) ? $value[ $key ] : ( self::DEFAULTS[ $key ] ?? null ); } }