[ 'type' => 'section', 'title' => 'Cloudflare Turnstile', 'description' => '…', 'icon' => '', * 'status' => [ 'Connected', 'success' ], 'fields' => [ … ] ], // values in turnstile.* * // 'status_callback' => fn() => [ … ]: the status worked out when shown * // 'addon' => 'brevo': an add-on's; while it's read-only its note shows in place of its fields * // 'layout' => 'card': a card of its own (a service), side by side with the next ones * 'general' => [ 'type' => 'section', 'nested' => false, 'fields' => [ … ] ], // values stay one level up * 'keys' => [ 'type' => 'row', 'columns' => 2, 'fields' => [ … ] ], // side by side (as many columns as fields by default), no level of its own * 'line' => [ 'type' => 'divider' ], * 'note' => [ 'type' => 'notice', 'text' => '…', 'tone' => 'info' ], // info, success, warning, danger * 'past' => [ 'type' => 'action', 'button' => 'Send them now', 'route' => 'my/v1/send' ], // a form's tab: POST { form } * * Controls: toggle, select, checkboxes (several of its options), map (pairs: a name typed or picked from * 'key_source', and an option of its own options and source: [ [ 'key' => 'FIRSTNAME', 'value' => 'your_name' ] ]), number, unit (a CSS length: 'units' => [ 'px', 'rem' ]), * text, textarea (one entry per line; 'paragraphs' => true keeps empty lines, for a text), secret (shown masked, kept * while left as is), slug, image (an attachment ID), color. Each takes label, help, placeholder, default, and: * * - condition: shown, and saved and printed, only while all of them hold: * [ [ 'control' => 'enabled', 'operator' => '==', 'value' => true ] ] * A name alone is a control of the same level, a dotted one (spam.turnstile.use) a path from the top. Operators: * ==, !=, in, not in, empty, not empty, >, <, >=, <=. [ 'enabled' => true ] is short for ==. * - sanitize_callback: ( $value, $field ) → the value kept, after the type's own cleaning. * - tags (textarea, in a form's tab): the form's fields and everyday tags ([_all_fields], [_form_title]…) to click in. * - as (select): 'checkbox' for two options, ticked for 'checked' and the other one when not; 'radio' for a few, one a * line, or side by side with 'inline' => true. In the form editor two or three short options are radios without it. * The values stay the options' keys. * - source (select, checkboxes; the form editor): options found there, after the fixed ones: * [ 'fields' => [ 'email' ] ] the form's fields of these types, by name ('*': all) * [ 'route' => 'my-addon/v1/lists', 'key' => 'lists' ] a GET of the add-on's REST route: [ { value, label } ] * Such a value is kept as text (a field name, an ID): its sanitize_callback says more. * - output: CSS from the value, [ [ 'selector' => 'h3' ( '' = the scope, '&.x' = the scope with a class ), * 'property' => 'color', 'mask' => '$' ] ] (Controls::css()). * * The same rules run in the browser (admin-settings.js, the editor's controls). */ class Controls { const CONTAINERS = [ 'section', 'row', 'divider', 'notice', 'action' ]; const TYPES = [ 'toggle', 'select', 'checkboxes', 'map', 'number', 'unit', 'text', 'textarea', 'secret', 'slug', 'image', 'color' ]; const OPERATORS = [ '==', '!=', 'in', 'not in', 'empty', 'not empty', '>', '<', '>=', '<=' ]; const UNITS = [ 'px', '%', 'em', 'rem', 'vw', 'vh' ]; /** * Values of a tree's controls, as they nest * * @param array $fields Tree * * @return array */ public static function defaults( array $fields ) { $values = []; foreach ( $fields as $key => $node ) { $type = $node['type'] ?? 'text'; if ( in_array( $type, [ 'section', 'row' ], true ) ) { $children = self::defaults( (array) ( $node['fields'] ?? [] ) ); self::nests( $node ) ? $values[ $key ] = $children : $values += $children; } elseif ( self::valued( $type ) ) { $values[ $key ] = self::clean( $node, null ); } } return $values; } /** * Clean values of a tree * * @param array $fields Tree * @param mixed $raw Sent values, as they nest * @param array $old Saved values, for secrets left as their mask * * @return array */ public static function sanitize( array $fields, $raw, array $old = [] ) { $raw = is_array( $raw ) ? $raw : []; $clean = []; foreach ( $fields as $key => $node ) { $type = $node['type'] ?? 'text'; if ( in_array( $type, [ 'section', 'row' ], true ) && !empty( $node['readonly'] ) ) { // Read-only (an add-on said so): what was saved stays $nests = self::nests( $node ); $children = array_replace( self::defaults( (array) ( $node['fields'] ?? [] ) ), array_intersect_key( $nests ? (array) ( $old[ $key ] ?? [] ) : $old, self::defaults( (array) ( $node['fields'] ?? [] ) ) ) ); $nests ? $clean[ $key ] = $children : $clean += $children; continue; } if ( in_array( $type, [ 'section', 'row' ], true ) ) { $nests = self::nests( $node ); $children = self::sanitize( (array) ( $node['fields'] ?? [] ), $nests ? ( $raw[ $key ] ?? [] ) : $raw, $nests ? (array) ( $old[ $key ] ?? [] ) : $old ); $nests ? $clean[ $key ] = $children : $clean += $children; continue; } if ( !self::valued( $type ) ) { continue; } if ( 'secret' === $type ) { $saved = (string) ( $old[ $key ] ?? '' ); $typed = trim( (string) ( $raw[ $key ] ?? '' ) ); // Its mask sent back: the saved secret stays $value = '' !== $saved && self::mask( $saved ) === $typed ? $saved : sanitize_text_field( $typed ); } else { $value = self::clean( $node, array_key_exists( $key, $raw ) ? $raw[ $key ] : null ); } $clean[ $key ] = isset( $node['sanitize_callback'] ) && is_callable( $node['sanitize_callback'] ) ? call_user_func( $node['sanitize_callback'], $value, $node ) : $value; } return $clean; } /** * A tree for the browser: callbacks left out, conditions in their long form with paths from the top * * @param array $fields Tree * @param string $level Path of this level * * @return object */ public static function export( array $fields, $level = '' ) { $tree = []; foreach ( $fields as $key => $node ) { $node = array_filter( (array) $node, static fn( $value ) => !is_object( $value ) && !( is_array( $value ) && is_callable( $value ) ) ); unset( $node['sanitize_callback'], $node['status_callback'] ); if ( isset( $node['condition'] ) ) { $node['condition'] = self::conditions( $node['condition'], $level ); } if ( isset( $node['fields'] ) ) { $nests = self::nests( $node ); $node['fields'] = self::export( (array) $node['fields'], $nests ? ltrim( $level . '.' . $key, '.' ) : $level ); $node['nested'] = $nests; } if ( isset( $node['options'] ) ) { // Options keep their order in JSON $node['options'] = array_map( static fn( $value, $label ) => [ 'value' => (string) $value, 'label' => (string) $label ], array_keys( (array) $node['options'] ), (array) $node['options'] ); } $tree[ $key ] = $node; } return (object) $tree; } /** * A value by its dotted path * * @param array $values Values * @param string $path spam.turnstile.use * @param mixed $default When there is none * * @return mixed */ public static function pick( array $values, $path, $default = null ) { foreach ( explode( '.', (string) $path ) as $key ) { if ( !is_array( $values ) || !array_key_exists( $key, $values ) ) { return $default; } $values = $values[ $key ]; } return $values; } /** * Conditions in their long form, each control named by its path from the top * * @param mixed $condition [ [ control, operator, value ] ] or [ control => value ] * @param string $level Path of the level a name alone belongs to * * @return array [ [ 'control' => path, 'operator' => …, 'value' => … ] ] */ public static function conditions( $condition, $level = '' ) { $clean = []; foreach ( (array) $condition as $control => $rule ) { $rule = is_array( $rule ) && isset( $rule['control'] ) ? $rule : [ 'control' => $control, 'operator' => '==', 'value' => $rule ]; $name = (string) $rule['control']; if ( !preg_match( '/^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/', $name ) ) { continue; } $clean[] = [ 'control' => false === strpos( $name, '.' ) && '' !== $level ? $level . '.' . $name : $name, 'operator' => in_array( $rule['operator'] ?? '==', self::OPERATORS, true ) ? $rule['operator'] ?? '==' : '==', 'value' => $rule['value'] ?? null, ]; } return $clean; } /** * Whether conditions hold; the same rules as matches() in admin-settings.js * * @param array $conditions conditions() * @param array $values Values from the top * * @return bool */ public static function matches( array $conditions, array $values ) { foreach ( $conditions as $rule ) { if ( !self::holds( self::pick( $values, $rule['control'] ), $rule['operator'], $rule['value'] ) ) { return false; } } return true; } /** * CSS of the controls with an output * * @param array $fields Tree * @param array $values Values of the tree * @param string $scope Selector the rules sit under * @param array $top Values from the top, for conditions ( = $values ) * @param string $level Path of this level * * @return string */ public static function css( array $fields, array $values, $scope, $top = null, $level = '' ) { $top = is_array( $top ) ? $top : $values; $css = ''; foreach ( $fields as $key => $node ) { $type = $node['type'] ?? 'text'; if ( !self::matches( self::conditions( $node['condition'] ?? [], $level ), $top ) ) { continue; } if ( in_array( $type, [ 'section', 'row' ], true ) ) { $nests = self::nests( $node ); $css .= self::css( (array) ( $node['fields'] ?? [] ), $nests ? (array) ( $values[ $key ] ?? [] ) : $values, $scope, $top, $nests ? ltrim( $level . '.' . $key, '.' ) : $level ); continue; } $value = self::css_value( $node, $values[ $key ] ?? null ); if ( '' === $value ) { continue; } foreach ( (array) ( $node['output'] ?? [] ) as $rule ) { $property = (string) ( $rule['property'] ?? '' ); $mask = (string) preg_replace( '/[;{}<>\\\\]+/', '', (string) ( $rule['mask'] ?? '$' ) ); if ( preg_match( '/^(?:--[A-Za-z0-9_-]+|[a-z][a-z-]*)$/', $property ) ) { $css .= self::selector( $scope, (string) ( $rule['selector'] ?? '' ) ) . '{' . $property . ':' . str_replace( '$', $value, false !== strpos( $mask, '$' ) ? $mask : '$' ) . '}'; } } } return $css; } /** * A secret as shown: as long as the key, its first and last characters around stars (356151***…***51535) * * @param string $secret Secret * * @return string */ public static function mask( $secret ) { $length = strlen( (string) $secret ); if ( 0 === $length ) { return ''; } $shown = $length >= 16 ? [ 6, 5 ] : [ (int) floor( $length / 4 ), (int) floor( $length / 4 ) ]; return substr( $secret, 0, $shown[0] ) . str_repeat( '*', $length - $shown[0] - $shown[1] ) . ( $shown[1] ? substr( $secret, -$shown[1] ) : '' ); } /** * Whether a section or row keeps its controls' values a level down * * @param array $node Container * * @return bool */ public static function nests( array $node ) { return 'section' === ( $node['type'] ?? '' ) && false !== ( $node['nested'] ?? true ); } /** * Whether a type holds a value * * @param string $type Type * * @return bool */ public static function valued( $type ) { return in_array( $type, self::TYPES, true ); } /** * A control's value cleaned by its type; missing gives the default * * @param array $field Control * @param mixed $value Raw, null = missing * * @return mixed */ public static function clean( array $field, $value ) { $type = $field['type'] ?? 'text'; $default = $field['default'] ?? self::empty_value( $type, $field ); if ( null === $value ) { return 'toggle' === $type ? (bool) $default : $default; } switch ( $type ) { case 'toggle': return !empty( $value ) && 'false' !== $value; case 'select': if ( isset( $field['options'][ (string) $value ] ) ) { return (string) $value; } // An option found in the editor (a field of the form, an item of a route) return !empty( $field['source'] ) && is_scalar( $value ) ? sanitize_text_field( (string) $value ) : $default; case 'map': $rows = []; // Lines of "name = value" (a value as a mail tag, [field], is that field) if ( is_string( $value ) ) { $value = array_map( static function ( $line ) { $parts = array_map( 'trim', explode( '=', $line, 2 ) ) + [ '', '' ]; return [ 'key' => $parts[0], 'value' => preg_match( '/^\[([a-z][a-z0-9_-]*)\]$/', $parts[1], $tag ) ? $tag[1] : $parts[1] ]; }, preg_split( '/\R/', $value ) ); } foreach ( is_array( $value ) ? $value : [] as $row ) { $key = is_array( $row ) ? trim( sanitize_text_field( (string) ( $row['key'] ?? '' ) ) ) : ''; if ( '' !== $key ) { $rows[] = [ 'key' => $key, 'value' => sanitize_text_field( (string) ( $row['value'] ?? '' ) ) ]; } } return $rows; case 'checkboxes': // The empty one is the Settings page's "none ticked" $items = array_values( array_filter( array_unique( array_map( static fn( $item ) => sanitize_text_field( (string) $item ), array_filter( (array) $value, 'is_scalar' ) ) ), 'strlen' ) ); return empty( $field['source'] ) ? array_values( array_filter( $items, static fn( $item ) => isset( $field['options'][ $item ] ) ) ) : $items; case 'number': if ( !is_numeric( $value ) ) { return $default; } $number = (float) $value; $number = isset( $field['min'] ) ? max( (float) $field['min'], $number ) : $number; $number = isset( $field['max'] ) ? min( (float) $field['max'], $number ) : $number; return floor( $number ) === $number ? (int) $number : $number; case 'unit': $value = strtolower( trim( (string) $value ) ); $units = (array) ( $field['units'] ?? self::UNITS ); return '' === $value || in_array( $value, (array) ( $field['keywords'] ?? [] ), true ) || ( preg_match( '/^-?\d*\.?\d+([a-z%]*)$/', $value, $match ) && in_array( $match[1], $units, true ) ) ? $value : $default; case 'slug': return sanitize_title( (string) $value ) ?: $default; case 'image': return wp_attachment_is_image( absint( $value ) ) ? absint( $value ) : 0; case 'color': $value = trim( (string) $value ); return '' === $value || preg_match( '/^(?:#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})|(?:rgba?|hsla?)\([\d\s.,%\/]+\)|var\(--[\w-]+\))$/i', $value ) ? $value : $default; case 'textarea': if ( !empty( $field['paragraphs'] ) ) { return sanitize_textarea_field( (string) $value ); } // One entry per line: empty lines and spaces around entries dropped return implode( "\n", array_filter( array_map( 'trim', explode( "\n", sanitize_textarea_field( (string) $value ) ) ) ) ); default: return sanitize_text_field( (string) $value ); } } /** * Whether a value holds against one rule * * @param mixed $actual Value * @param string $operator Operator * @param mixed $expected The rule's value * * @return bool */ private static function holds( $actual, $operator, $expected ) { $same = static fn( $a, $b ) => is_bool( $b ) ? (bool) $a === $b : (string) ( is_scalar( $a ) ? $a : '' ) === (string) $b; switch ( $operator ) { case '!=': return !$same( $actual, $expected ); case 'in': return (bool) array_filter( (array) $expected, static fn( $one ) => $same( $actual, $one ) ); case 'not in': return !array_filter( (array) $expected, static fn( $one ) => $same( $actual, $one ) ); case 'empty': return '' === (string) ( is_scalar( $actual ) ? $actual : '' ) || false === $actual || 0 === $actual; case 'not empty': return !self::holds( $actual, 'empty', null ); case '>': return (float) $actual > (float) $expected; case '<': return (float) $actual < (float) $expected; case '>=': return (float) $actual >= (float) $expected; case '<=': return (float) $actual <= (float) $expected; default: return $same( $actual, $expected ); } } /** * What a type holds when it has no default * * @param string $type Type * @param array $field Control * * @return mixed */ private static function empty_value( $type, array $field ) { switch ( $type ) { case 'toggle': return false; case 'number': return isset( $field['min'] ) ? $field['min'] : 0; case 'image': return 0; case 'checkboxes': case 'map': return []; case 'select': return (string) array_key_first( (array) ( $field['options'] ?? [] ) ); default: return ''; } } /** * A value as CSS: '' = nothing to print * * @param array $field Control * @param mixed $value Clean value * * @return string */ private static function css_value( array $field, $value ) { switch ( $field['type'] ?? 'text' ) { case 'toggle': return $value ? '1' : '0'; case 'image': $url = $value ? wp_get_attachment_image_url( (int) $value, 'full' ) : ''; return $url ? 'url("' . esc_url_raw( $url ) . '")' : ''; case 'number': return null === $value || '' === $value ? '' : (string) $value . (string) ( $field['unit_css'] ?? '' ); default: return trim( (string) preg_replace( '/[;{}<>\\\\]+/', '', (string) $value ) ); } } /** * A rule's selector under the scope * * @param string $scope Scope * @param string $selector '' = the scope, '&.x' = the scope with a class, else inside it * * @return string */ private static function selector( $scope, $selector ) { $selector = trim( (string) preg_replace( '/[{}<>;@\\\\]+/', '', $selector ) ); if ( '' === $selector ) { return $scope; } return implode( ',', array_map( static fn( $part ) => 0 === strpos( $part, '&' ) ? $scope . substr( $part, 1 ) : $scope . ' ' . $part, array_filter( array_map( 'trim', explode( ',', $selector ) ) ) ) ); } }