|null */ private static ?array $settings = null; /** * Draft settings standing in for the stored ones, inside `with_overlay()`. * * A full settings array, not a partial one: `sanitize()` fills every * section it was not given from the settings the overlay was built on. * * @var array|null */ private static ?array $overlay = null; /** * Memoized schema. * * @var array>|null */ private static ?array $schema = null; /** * Memoized defaults. * * @var array|null */ private static ?array $defaults = null; /** * Describes every setting: type, default and validation rules. * * Node types: `bool`, `int`, `float`, `string`, `enum`, `list`, `repeater`, * `map` and `group`. See `sanitize_node()` for how each one is handled. * * @return array> Section name => node spec. */ public static function schema(): array { if ( null !== self::$schema ) { return self::$schema; } $text = [ 'type' => 'string', 'sanitize' => 'text', 'default' => '', ]; $url = [ 'type' => 'string', 'sanitize' => 'url', 'default' => '', ]; $key = [ 'type' => 'string', 'sanitize' => 'key', 'default' => '', ]; $email = [ 'type' => 'string', 'sanitize' => 'email', 'default' => '', ]; $type_name = [ 'type' => 'string', 'sanitize' => 'type_name', 'default' => '', ]; $id = [ 'type' => 'int', 'default' => 0, 'min' => 0, ]; $on = [ 'type' => 'bool', 'default' => true, ]; $off = [ 'type' => 'bool', 'default' => false, ]; $text_list = [ 'type' => 'list', 'of' => $text, 'default' => [], ]; $url_list = [ 'type' => 'list', 'of' => $url, 'default' => [], ]; $key_list = [ 'type' => 'list', 'of' => $key, 'default' => [], ]; self::$schema = [ 'general' => [ 'type' => 'group', 'fields' => [ 'site_type' => [ 'type' => 'enum', 'values' => [ 'business', 'personal', 'blog', 'ecommerce', 'organization' ], 'default' => 'business', ], 'language' => $key, 'agency_credit' => $off, 'wizard_completed' => $off, 'wizard_step' => [ 'type' => 'int', 'default' => 0, 'min' => 0, 'max' => 6, ], 'remove_data' => $off, ], ], 'identity' => [ 'type' => 'group', 'fields' => [ 'schema_type' => [ 'type' => 'string', 'sanitize' => 'type_name', 'default' => 'Organization', ], 'name' => $text, 'alternate_name' => $text, 'legal_name' => $text, 'description' => [ 'type' => 'string', 'sanitize' => 'textarea', 'default' => '', 'max' => 600, ], 'tagline' => [ 'type' => 'string', 'sanitize' => 'textarea', 'default' => '', 'max' => 600, ], 'logo_id' => $id, 'image_id' => $id, 'email' => $email, 'phone' => $text, 'founding_date' => $text, 'tax_id' => $text, 'address' => [ 'type' => 'group', 'fields' => [ 'street' => $text, 'city' => $text, 'region' => $text, 'postal' => $text, 'country' => $text, ], ], 'geo' => [ 'type' => 'group', 'fields' => [ 'lat' => [ 'type' => 'float', 'default' => 0.0, 'min' => -90.0, 'max' => 90.0, ], 'lng' => [ 'type' => 'float', 'default' => 0.0, 'min' => -180.0, 'max' => 180.0, ], ], ], 'opening_hours' => $text_list, 'price_range' => $text, 'area_served' => $text_list, 'languages' => $key_list, 'same_as' => $url_list, 'founder_user_id' => $id, ], ], 'policy' => [ 'type' => 'group', 'fields' => [ 'enabled' => $on, 'preset' => [ 'type' => 'enum', 'values' => [ 'citable', 'search_only', 'open', 'closed', 'custom' ], 'default' => 'citable', ], 'bots' => [ 'type' => 'map', 'of' => [ 'type' => 'enum', 'values' => [ 'allow', 'disallow', 'inherit' ], 'default' => 'inherit', ], 'default' => [], ], 'disallow_paths' => $text_list, 'content_signal' => [ 'type' => 'group', 'fields' => [ 'search' => [ 'type' => 'enum', 'values' => [ 'yes', 'no' ], 'default' => 'yes', ], 'ai_input' => [ 'type' => 'enum', 'values' => [ 'yes', 'no' ], 'default' => 'yes', ], 'ai_train' => [ 'type' => 'enum', 'values' => [ 'yes', 'no' ], 'default' => 'no', ], ], ], 'license' => [ 'type' => 'enum', 'values' => [ 'all_rights', 'cc_by', 'cc_by_nc', 'cc_by_sa', 'custom' ], 'default' => 'all_rights', ], 'license_url' => $url, 'permissions_contact' => $email, 'noai_meta' => $off, ], ], 'llms' => [ 'type' => 'group', 'fields' => [ 'enabled' => $on, 'key_pages' => [ 'type' => 'list', 'of' => $id, 'default' => [], ], 'post_types' => [ 'type' => 'list', 'of' => $key, 'default' => [ 'post', 'page' ], ], 'posts_limit' => [ 'type' => 'int', 'default' => 20, 'min' => 0, 'max' => 500, ], 'taxonomies' => $key_list, 'include_faq' => $on, 'include_offerings' => $on, 'full_enabled' => $on, 'full_max_bytes' => [ 'type' => 'int', 'default' => 2000000, 'min' => 100000, 'max' => 20000000, ], 'about_text' => [ 'type' => 'string', 'sanitize' => 'textarea', 'default' => '', 'max' => 2000, ], ], ], 'offerings' => [ 'type' => 'repeater', 'required' => 'name', 'default' => [], 'fields' => [ 'name' => $text, 'url' => $url, 'summary' => [ 'type' => 'string', 'sanitize' => 'textarea', 'default' => '', 'max' => 300, ], 'price_from' => [ 'type' => 'float', 'default' => 0.0, 'min' => 0.0, ], 'currency' => [ 'type' => 'string', 'sanitize' => 'currency', 'default' => '', ], 'delivery' => $text, ], ], 'faq' => [ 'type' => 'repeater', 'required' => 'q', 'default' => [], 'fields' => [ 'q' => $text, 'a' => [ 'type' => 'string', 'sanitize' => 'textarea', 'default' => '', 'max' => 2000, ], ], ], 'humans' => [ 'type' => 'group', 'fields' => [ 'enabled' => $on, 'team' => [ 'type' => 'repeater', 'required' => 'name', 'default' => [], 'fields' => [ 'user_id' => $id, 'name' => $text, 'role' => $text, 'contact' => $text, 'url' => $url, 'social' => $url_list, ], ], 'thanks' => $text_list, 'components' => $text_list, 'software' => $text_list, ], ], 'security' => [ 'type' => 'group', 'fields' => [ 'enabled' => $on, 'contact' => $text_list, 'expires' => $text, 'languages' => $key_list, 'policy_url' => $url, 'acknowledgments_url' => $url, 'encryption_url' => $url, ], ], 'ai_context' => [ 'type' => 'group', 'fields' => [ 'enabled' => $on, ], ], 'ai_txt' => [ 'type' => 'group', 'fields' => [ 'enabled' => $on, ], ], 'schema' => [ 'type' => 'group', 'fields' => [ 'enabled' => $on, 'pieces' => [ 'type' => 'group', 'fields' => [ 'organization' => $on, 'website' => $on, 'webpage' => $on, 'breadcrumb' => $on, 'article' => $on, 'person' => $on, 'faq' => $on, 'speakable' => $on, 'image_meta' => $on, ], ], 'post_type_map' => [ 'type' => 'map', 'of' => $type_name, 'default' => [ 'post' => 'BlogPosting', 'page' => '', ], ], 'defer_to_seo' => $on, ], ], 'head' => [ 'type' => 'group', 'fields' => [ 'enabled' => $on, 'robots_directives' => $on, 'link_tags' => $on, 'meta_author' => $on, 'og_fallback' => [ 'type' => 'enum', 'values' => [ 'auto', 'on', 'off' ], 'default' => 'auto', ], ], ], 'headers' => [ 'type' => 'group', 'fields' => [ 'enabled' => $on, 'link_header' => $on, 'content_signal_header' => $on, ], ], 'indexnow' => [ 'type' => 'group', 'fields' => [ 'enabled' => $on, 'key' => $key, 'auto_submit' => $on, 'post_types' => [ 'type' => 'list', 'of' => $key, 'default' => [ 'post', 'page' ], ], ], ], 'citations' => [ 'type' => 'group', 'fields' => [ 'enabled' => $on, 'track_crawls' => $on, 'track_referrals' => $on, 'beacon' => $off, 'retention_days' => [ 'type' => 'int', 'default' => 90, 'min' => 7, 'max' => 730, ], 'engines_extra' => $text_list, ], ], 'content' => [ 'type' => 'group', 'fields' => [ 'enabled' => $on, 'blocks' => $on, 'meta_box' => $on, 'last_updated' => $off, 'markdown' => [ 'type' => 'enum', 'values' => [ 'off', 'query', 'negotiate' ], 'default' => 'query', ], 'editor_panel' => $on, ], ], 'static_files' => [ 'type' => 'group', 'fields' => [ 'enabled' => $off, 'files' => $key_list, ], ], ]; return self::$schema; } /** * Default value of every setting, derived from the schema. * * @return array Section name => default value. */ public static function defaults(): array { if ( null !== self::$defaults ) { return self::$defaults; } $defaults = []; foreach ( self::schema() as $section => $spec ) { $defaults[ $section ] = self::node_default( $spec ); } self::$defaults = $defaults; return self::$defaults; } /** * All settings, stored values merged over the defaults. * * The option is read once per request. Inside `with_overlay()` the draft * answers instead, so every reader — the modules, the generators, the * memoized data classes — sees the settings being previewed. * * @return array Section name => value. */ public static function all(): array { if ( null !== self::$overlay ) { return self::$overlay; } if ( null === self::$settings ) { $stored = get_option( self::OPTION, [] ); self::$settings = self::merge( is_array( $stored ) ? $stored : [], self::defaults() ); } return self::$settings; } /** * Reads one setting by dot path, e.g. `identity.name`. * * @param string $path Dot path. Empty string returns every setting. * @param mixed $default_value Value returned when the path does not exist. * @return mixed Setting value, or `$default_value`. */ public static function get( string $path = '', $default_value = null ) { $settings = self::all(); if ( '' === $path ) { return $settings; } $value = $settings; foreach ( explode( '.', $path ) as $segment ) { if ( ! is_array( $value ) || ! array_key_exists( $segment, $value ) ) { return $default_value; } $value = $value[ $segment ]; } return $value; } /** * Writes one setting by dot path and saves the whole option. * * @param string $path Dot path, e.g. `indexnow.key`. * @param mixed $value Value to store. * @return void */ public static function set( string $path, $value ): void { if ( '' === $path ) { return; } $settings = self::all(); $cursor = &$settings; foreach ( explode( '.', $path ) as $segment ) { if ( ! is_array( $cursor ) ) { $cursor = []; } if ( ! isset( $cursor[ $segment ] ) || ! is_array( $cursor[ $segment ] ) ) { $cursor[ $segment ] = []; } $cursor = &$cursor[ $segment ]; } $cursor = $value; unset( $cursor ); self::save( $settings ); } /** * Replaces every setting at once (used by settings import). * * @param array $data Raw settings array. * @return void */ public static function set_all( array $data ): void { self::save( self::sanitize( $data, self::defaults() ) ); Cache::invalidate_all(); /** * Fires after settings have been saved. * * @param string $section Section name that changed, or `*` for all of them. */ do_action( 'acid_aeo_settings_updated', '*' ); } /** * Runs `$callback` with `$overlay` standing in for the stored settings. * * This is what makes a preview honest. A draft the administrator has not * saved yet cannot be rendered by handing the changed values to one * generator: a generated file is assembled from `Data\SiteData`, * `Data\ContentData` and a dozen `Options::get()` calls spread across a * module, a generator and the pieces they call in turn. So rather than * threading a draft through all of them, the draft becomes what `all()` * answers with, for the length of one call. * * The overlay is sanitized against the schema on the way in, so a preview * cannot be talked into rendering something a save would never produce. * `SiteData` and `ContentData` memoize answers derived from the settings, * so both are dropped on the way in and again on the way out. Nested calls * stack: each overlay is merged onto the one already in force and only that * one is restored. * * Nothing inside `$callback` may write settings: `set()` and * `update_section()` would read the draft back and store it. Previews only * render, which is the single reason this exists. * * @param array $overlay Partial settings to apply, in the shape of the stored option. * @param callable $callback Runs while the overlay is in force. * @return mixed Whatever `$callback` returned. */ public static function with_overlay( array $overlay, callable $callback ) { $previous = self::$overlay; self::$overlay = self::sanitize( $overlay, self::all() ); SiteData::reset(); ContentData::reset(); try { return $callback(); } finally { self::$overlay = $previous; SiteData::reset(); ContentData::reset(); } } /** * Sanitizes and saves one section. * * @param string $section Section name, e.g. `identity`. * @param array $data Raw section data. * @return array The saved (sanitized) section, or an empty array for an unknown section. */ public static function update_section( string $section, array $data ): array { if ( ! array_key_exists( $section, self::schema() ) ) { return []; } $settings = self::all(); $clean = self::sanitize( [ $section => $data ], $settings ); $settings[ $section ] = $clean[ $section ]; self::save( $settings ); Cache::invalidate_all(); /** * Fires after a settings section has been saved. * * @param string $section Section name that changed. */ do_action( 'acid_aeo_settings_updated', $section ); return $settings[ $section ]; } /** * Sanitizes a raw settings array against the schema. * * Unknown top-level sections are dropped. Keys missing from `$input` keep * their value from `$current` (falling back to the default). * * @param array $input Raw input. * @param array $current Current settings used as the fallback. * @return array Sanitized settings. */ public static function sanitize( array $input, array $current = [] ): array { $defaults = self::defaults(); $current = empty( $current ) ? $defaults : $current; $clean = []; foreach ( self::schema() as $section => $spec ) { $fallback = array_key_exists( $section, $current ) ? $current[ $section ] : $defaults[ $section ]; $clean[ $section ] = array_key_exists( $section, $input ) ? self::sanitize_node( $spec, $input[ $section ], $fallback, $defaults[ $section ] ) : $fallback; } return $clean; } /** * Drops the settings memo so the next read hits the database again. * * Unlike `reset()`, a settings overlay in progress is left alone: a preview * that switched blogs mid-way must keep previewing the same values. * * @return void */ public static function forget(): void { self::$settings = null; } /** * Drops the memoized settings, schema and defaults. * * For tests, and for code that writes `acid_aeo_settings` directly instead * of going through `set()`, `set_all()` or `update_section()`. * * @return void */ public static function reset(): void { self::$settings = null; self::$overlay = null; self::$schema = null; self::$defaults = null; } /** * Stores settings both in the option and in the per-request cache. * * @param array $settings Full settings array. * @return void */ private static function save( array $settings ): void { self::$settings = $settings; update_option( self::OPTION, $settings, true ); } /** * Merges stored values over the defaults; groups merge recursively. * * Lists, repeaters and maps are replaced wholesale so that removing an item * is not undone by the defaults. * * @param array $stored Stored settings. * @param array $defaults Defaults. * @return array Merged settings. */ private static function merge( array $stored, array $defaults ): array { $merged = $defaults; foreach ( self::schema() as $section => $spec ) { if ( ! array_key_exists( $section, $stored ) ) { continue; } $merged[ $section ] = self::merge_node( $spec, $stored[ $section ], $defaults[ $section ] ); } return $merged; } /** * Merges one stored node over its default. * * @param array $spec Node spec. * @param mixed $stored Stored value. * @param mixed $default_value Default value. * @return mixed Merged value. */ private static function merge_node( array $spec, $stored, $default_value ) { if ( 'group' !== $spec['type'] ) { return $stored; } if ( ! is_array( $stored ) ) { return $default_value; } $merged = is_array( $default_value ) ? $default_value : []; foreach ( $spec['fields'] as $field => $field_spec ) { if ( ! array_key_exists( $field, $stored ) ) { continue; } $field_default = array_key_exists( $field, $merged ) ? $merged[ $field ] : null; $merged[ $field ] = self::merge_node( $field_spec, $stored[ $field ], $field_default ); } return $merged; } /** * Default value for one schema node. * * @param array $spec Node spec. * @return mixed Default value. */ private static function node_default( array $spec ) { if ( 'group' === $spec['type'] ) { $default = []; foreach ( $spec['fields'] as $field => $field_spec ) { $default[ $field ] = self::node_default( $field_spec ); } return $default; } if ( array_key_exists( 'default', $spec ) ) { return $spec['default']; } switch ( $spec['type'] ) { case 'bool': return false; case 'int': return 0; case 'float': return 0.0; case 'list': case 'repeater': case 'map': return []; default: return ''; } } /** * Sanitizes one value against its schema node. * * @param array $spec Node spec. * @param mixed $value Raw value. * @param mixed $current Current value, used as an enum fallback. * @param mixed $default_value Default value. * @return mixed Sanitized value. */ private static function sanitize_node( array $spec, $value, $current, $default_value ) { switch ( $spec['type'] ) { case 'group': return self::sanitize_group( $spec, $value, $current, $default_value ); case 'repeater': return self::sanitize_repeater( $spec, $value, $current, $default_value ); case 'list': return self::sanitize_list( $spec, $value, $current, $default_value ); case 'map': return self::sanitize_map( $spec, $value, $current, $default_value ); case 'bool': return self::sanitize_bool( $value ); case 'int': return self::sanitize_int( $spec, $value ); case 'float': return self::sanitize_float( $spec, $value ); case 'enum': return self::sanitize_enum( $spec, $value, $current ); case 'string': return self::sanitize_string( $spec, $value ); default: return $default_value; } } /** * Sanitizes a group node field by field. * * @param array $spec Node spec. * @param mixed $value Raw value. * @param mixed $current Current value. * @param mixed $default_value Default value. * @return array Sanitized group. */ private static function sanitize_group( array $spec, $value, $current, $default_value ): array { $value = is_array( $value ) ? $value : []; $current = is_array( $current ) ? $current : []; $default_value = is_array( $default_value ) ? $default_value : []; $clean = []; foreach ( $spec['fields'] as $field => $field_spec ) { $field_default = array_key_exists( $field, $default_value ) ? $default_value[ $field ] : self::node_default( $field_spec ); $field_current = array_key_exists( $field, $current ) ? $current[ $field ] : $field_default; $clean[ $field ] = array_key_exists( $field, $value ) ? self::sanitize_node( $field_spec, $value[ $field ], $field_current, $field_default ) : $field_current; } return $clean; } /** * Sanitizes a repeater row by row, dropping rows with an empty required field. * * @param array $spec Node spec. * @param mixed $value Raw value. * @param mixed $current Current value. * @param mixed $default_value Default value. * @return array> Sanitized rows, re-indexed. */ private static function sanitize_repeater( array $spec, $value, $current, $default_value ): array { if ( ! is_array( $value ) ) { return self::collection_fallback( $current, $default_value ); } $required = isset( $spec['required'] ) ? (string) $spec['required'] : ''; $rows = []; foreach ( $value as $row ) { if ( ! is_array( $row ) ) { continue; } $clean = []; foreach ( $spec['fields'] as $field => $field_spec ) { $field_default = self::node_default( $field_spec ); $clean[ $field ] = array_key_exists( $field, $row ) ? self::sanitize_node( $field_spec, $row[ $field ], $field_default, $field_default ) : $field_default; } if ( '' !== $required && self::is_blank( $clean[ $required ] ?? null ) ) { continue; } $rows[] = $clean; } return $rows; } /** * Sanitizes a list node, dropping empty items and re-indexing. * * @param array $spec Node spec. * @param mixed $value Raw value. * @param mixed $current Current value. * @param mixed $default_value Default value. * @return array Sanitized list. */ private static function sanitize_list( array $spec, $value, $current, $default_value ): array { if ( ! is_array( $value ) ) { return self::collection_fallback( $current, $default_value ); } $leaf = $spec['of']; $leaf_default = self::node_default( $leaf ); $items = []; foreach ( $value as $item ) { $clean = self::sanitize_node( $leaf, $item, $leaf_default, $leaf_default ); if ( self::is_blank( $clean ) ) { continue; } $items[] = $clean; } return $items; } /** * Sanitizes a map node: keys through `sanitize_key()`, values through the leaf spec. * * @param array $spec Node spec. * @param mixed $value Raw value. * @param mixed $current Current value. * @param mixed $default_value Default value. * @return array Sanitized map. */ private static function sanitize_map( array $spec, $value, $current, $default_value ): array { if ( ! is_array( $value ) ) { return self::collection_fallback( $current, $default_value ); } $leaf = $spec['of']; $leaf_default = self::node_default( $leaf ); $map = []; foreach ( $value as $map_key => $item ) { $clean_key = sanitize_key( (string) $map_key ); if ( '' === $clean_key ) { continue; } $map[ $clean_key ] = self::sanitize_node( $leaf, $item, $leaf_default, $leaf_default ); } return $map; } /** * Casts a raw value to a boolean, understanding form-post strings. * * @param mixed $value Raw value. * @return bool Boolean value. */ private static function sanitize_bool( $value ): bool { if ( is_string( $value ) ) { return in_array( strtolower( trim( $value ) ), [ '1', 'true', 'yes', 'on' ], true ); } return (bool) $value; } /** * Casts a raw value to an integer and clamps it to the node bounds. * * @param array $spec Node spec. * @param mixed $value Raw value. * @return int Sanitized integer. */ private static function sanitize_int( array $spec, $value ): int { $number = is_scalar( $value ) ? (int) $value : 0; if ( isset( $spec['min'] ) ) { $number = max( (int) $spec['min'], $number ); } if ( isset( $spec['max'] ) ) { $number = min( (int) $spec['max'], $number ); } return $number; } /** * Casts a raw value to a float and clamps it to the node bounds. * * @param array $spec Node spec. * @param mixed $value Raw value. * @return float Sanitized float. */ private static function sanitize_float( array $spec, $value ): float { $number = is_scalar( $value ) ? (float) $value : 0.0; if ( isset( $spec['min'] ) ) { $number = max( (float) $spec['min'], $number ); } if ( isset( $spec['max'] ) ) { $number = min( (float) $spec['max'], $number ); } return $number; } /** * Keeps a value inside the allowed enum, falling back to current then default. * * @param array $spec Node spec. * @param mixed $value Raw value. * @param mixed $current Current value. * @return string Sanitized enum value. */ private static function sanitize_enum( array $spec, $value, $current ): string { $values = is_array( $spec['values'] ) ? $spec['values'] : []; $value = is_scalar( $value ) ? (string) $value : ''; if ( in_array( $value, $values, true ) ) { return $value; } $current = is_scalar( $current ) ? (string) $current : ''; return in_array( $current, $values, true ) ? $current : (string) $spec['default']; } /** * Trims, sanitizes and truncates a string node. * * @param array $spec Node spec. * @param mixed $value Raw value. * @return string Sanitized string. */ private static function sanitize_string( array $spec, $value ): string { $raw = is_scalar( $value ) ? trim( (string) $value ) : ''; $mode = isset( $spec['sanitize'] ) ? (string) $spec['sanitize'] : 'text'; switch ( $mode ) { case 'textarea': $clean = sanitize_textarea_field( $raw ); break; case 'url': $clean = esc_url_raw( $raw ); break; case 'email': $clean = sanitize_email( $raw ); break; case 'key': $clean = sanitize_key( $raw ); break; case 'html': $clean = wp_kses_post( $raw ); break; case 'currency': $clean = self::sanitize_currency( $raw ); break; case 'type_name': $clean = self::sanitize_type_name( $raw ); break; default: $clean = sanitize_text_field( $raw ); break; } $clean = (string) $clean; $max = isset( $spec['max'] ) ? (int) $spec['max'] : 0; if ( $max > 0 ) { $clean = function_exists( 'mb_substr' ) ? mb_substr( $clean, 0, $max ) : substr( $clean, 0, $max ); } return $clean; } /** * Normalizes a currency to a three-letter uppercase code. * * @param string $value Raw value. * @return string Currency code, e.g. `EUR`. */ private static function sanitize_currency( string $value ): string { $letters = (string) preg_replace( '/[^a-z]/i', '', $value ); return strtoupper( substr( $letters, 0, 3 ) ); } /** * Normalizes a schema.org type name, keeping its case. * * `sanitize_key()` cannot be used here: it lowercases, and `LocalBusiness` * is not the same type as `localbusiness`. An empty result is allowed and * means "no type". * * @param string $value Raw value. * @return string Type name, e.g. `LocalBusiness`. */ private static function sanitize_type_name( string $value ): string { return (string) preg_replace( '/[^A-Za-z]/', '', $value ); } /** * Fallback for a list, repeater or map whose incoming value is not an array. * * Keeps what is already stored rather than wiping a collection because one * malformed request sent a scalar. * * @param mixed $current Current value. * @param mixed $default_value Default value. * @return array Current value, the default, or an empty array. */ private static function collection_fallback( $current, $default_value ): array { if ( is_array( $current ) ) { return $current; } return is_array( $default_value ) ? $default_value : []; } /** * Whether a sanitized leaf value counts as empty and should be dropped. * * @param mixed $value Sanitized value. * @return bool True when the value is empty. */ private static function is_blank( $value ): bool { if ( null === $value || '' === $value || [] === $value ) { return true; } if ( is_int( $value ) || is_float( $value ) ) { return 0.0 === (float) $value; } return false; } }