message errors, * and field name => reason code (missing, format, * out_of_range, invalid_option, too_long, too_short, * choice_count) for answer-free insights. */ public static function validate( array $schema, array $raw ) { $values = array(); $errors = array(); $codes = array(); $types = Schema::field_types(); foreach ( $schema['fields'] as $field ) { // A field of an inactive add-on's type is paused: it is not asked, so not checked. if ( ! isset( $types[ $field['type'] ] ) ) { continue; } // Add-ons may leave a field out, such as one hidden by a condition. They // decide on the cleaned answers so far, as the page decides what to show, // so a value that is not one of a choice's options reveals nothing. if ( ! Schema::is_answer_field( $field ) || ! Extensions::filter( 'accessible_forms_field_active', true, $field, $values, $schema ) ) { continue; } $name = $field['name']; $type = $field['type']; $label = $field['label']; $required = ! empty( $field['required'] ); $input = $raw[ $name ] ?? ''; $code = ''; // Add-ons check the answers to their own field types. if ( ! empty( $types[ $type ]['addon'] ) ) { $result = Extensions::filter( 'accessible_forms_validate_field', null, $field, $input, $values ); if ( ! is_array( $result ) ) { continue; } [ $clean, $error, $code ] = array_values( $result ) + array( '', '', '' ); if ( '' !== (string) $error ) { $errors[ $name ] = (string) $error; $codes[ $name ] = '' !== (string) $code ? (string) $code : 'format'; } $values[ $name ] = $clean; continue; } switch ( $type ) { case 'number': $clean = trim( self::scalar( $input ) ); if ( ( $required && '' === $clean ) || ( '' !== $clean && ( ! is_numeric( $clean ) || ! is_finite( (float) $clean ) ) ) ) { $code = '' === $clean ? 'missing' : 'format'; $errors[ $name ] = __( 'Enter a valid number.', 'accessible-forms-by-accessible-org' ); } elseif ( '' !== $clean ) { if ( ( '' !== ( $field['min'] ?? '' ) && (float) $clean < (float) $field['min'] ) || ( '' !== ( $field['max'] ?? '' ) && (float) $clean > (float) $field['max'] ) ) { $code = 'out_of_range'; $errors[ $name ] = __( 'Enter a number within the displayed range.', 'accessible-forms-by-accessible-org' ); } $step = (float) ( $field['increment'] ?? 0 ); if ( $step > 0 ) { $offset = ( (float) $clean - (float) ( $field['min'] ?? 0 ) ) / $step; if ( abs( $offset - round( $offset ) ) > 0.000001 ) { $code = 'format'; $errors[ $name ] = __( 'Use the displayed increment for this number.', 'accessible-forms-by-accessible-org' ); } } } break; case 'date': case 'time': $clean = trim( self::scalar( $input ) ); $format = 'date' === $type ? 'Y-m-d' : 'H:i'; $parsed = preg_match( 'date' === $type ? '/^\d{4}-\d{2}-\d{2}$/' : '/^\d{2}:\d{2}$/', $clean ) ? \DateTimeImmutable::createFromFormat( '!' . $format, $clean ) : false; if ( ( $required && '' === $clean ) || ( '' !== $clean && ( ! $parsed || $parsed->format( $format ) !== $clean ) ) ) { $code = '' === $clean ? 'missing' : 'format'; $errors[ $name ] = 'date' === $type ? __( 'Enter a valid date, for example 2026-09-23.', 'accessible-forms-by-accessible-org' ) : __( 'Enter a valid time, for example 14:30.', 'accessible-forms-by-accessible-org' ); } break; case 'checkbox': $clean = self::clean_choice_list( $input, $field['options'] ); if ( $required && ! $clean ) { $code = 'missing'; $errors[ $name ] = self::choose_message( $label, true ); } break; case 'radio': case 'select': $clean = self::clean_choice( $input, $field['options'] ); if ( $required && '' === $clean ) { $code = 'missing'; $errors[ $name ] = self::choose_message( $label, false ); } elseif ( '' !== self::scalar( $input ) && '' === $clean ) { $code = 'invalid_option'; $errors[ $name ] = sprintf( /* translators: %s: field label. */ __( 'Select a valid option for %s', 'accessible-forms-by-accessible-org' ), self::lower( $label ) ); $clean = ''; } break; case 'consent': $clean = '1' === self::scalar( $input ) ? '1' : ''; if ( $required && '' === $clean ) { $code = 'missing'; $errors[ $name ] = sprintf( /* translators: %s: field label. */ __( 'You must tick "%s" to continue', 'accessible-forms-by-accessible-org' ), $label ); } break; case 'email': $clean = sanitize_text_field( self::scalar( $input ) ); if ( $required && '' === $clean ) { $code = 'missing'; $errors[ $name ] = __( 'Enter an email address', 'accessible-forms-by-accessible-org' ); } elseif ( '' !== $clean && ! is_email( $clean ) ) { $code = 'format'; $errors[ $name ] = __( 'Enter an email address in the correct format, like name@example.com', 'accessible-forms-by-accessible-org' ); } elseif ( '' !== $clean && ! EmailDomains::allows( $field, $clean ) ) { $code = 'email_domain'; $errors[ $name ] = EmailDomains::instruction( $field ); } break; case 'tel': $clean = sanitize_text_field( self::scalar( $input ) ); if ( $required && '' === $clean ) { $code = 'missing'; $errors[ $name ] = __( 'Enter a telephone number', 'accessible-forms-by-accessible-org' ); } elseif ( '' !== $clean && preg_match_all( '/\d/', $clean ) < 7 ) { $code = 'format'; $errors[ $name ] = __( 'Enter a telephone number, like 01632 960001 or +1 555 0100', 'accessible-forms-by-accessible-org' ); } break; case 'url': $raw_url = trim( self::scalar( $input ) ); $clean = '' === $raw_url ? '' : esc_url_raw( $raw_url ); if ( $required && '' === $clean ) { $code = 'missing'; $errors[ $name ] = __( 'Enter a web address', 'accessible-forms-by-accessible-org' ); } elseif ( '' !== $raw_url && ! filter_var( $clean, FILTER_VALIDATE_URL ) ) { $code = 'format'; $errors[ $name ] = __( 'Enter a web address in the correct format, like https://example.com', 'accessible-forms-by-accessible-org' ); } break; case 'textarea': $clean = sanitize_textarea_field( self::scalar( $input ) ); if ( $required && '' === $clean ) { $code = 'missing'; $errors[ $name ] = sprintf( /* translators: %s: field label. */ __( 'Enter your %s', 'accessible-forms-by-accessible-org' ), self::lower( $label ) ); } elseif ( self::length( $clean ) > self::MAX_LONG ) { $code = 'too_long'; $errors[ $name ] = sprintf( /* translators: 1: field label, 2: maximum number of characters. */ __( 'Shorten your %1$s to %2$s characters or fewer', 'accessible-forms-by-accessible-org' ), self::lower( $label ), number_format_i18n( self::MAX_LONG ) ); } break; default: $clean = sanitize_text_field( self::scalar( $input ) ); if ( $required && '' === $clean ) { $code = 'missing'; $errors[ $name ] = sprintf( /* translators: %s: field label. */ __( 'Enter your %s', 'accessible-forms-by-accessible-org' ), self::lower( $label ) ); } elseif ( self::length( $clean ) > self::MAX_SHORT ) { $code = 'too_long'; $errors[ $name ] = sprintf( /* translators: 1: field label, 2: maximum number of characters. */ __( 'Shorten your %1$s to %2$s characters or fewer', 'accessible-forms-by-accessible-org' ), self::lower( $label ), number_format_i18n( self::MAX_SHORT ) ); } break; } if ( ! in_array( $type, array( 'textarea', 'checkbox' ), true ) && self::length( (string) $clean ) > self::MAX_SHORT ) { $code = 'too_long'; $errors[ $name ] = sprintf( /* translators: 1: field label, 2: maximum number of characters. */ __( 'Shorten your %1$s to %2$s characters or fewer', 'accessible-forms-by-accessible-org' ), self::lower( $label ), number_format_i18n( self::MAX_SHORT ) ); } if ( is_array( $input ) && 'checkbox' !== $type ) { $code = 'format'; $errors[ $name ] = sprintf( /* translators: %s: field label. */ __( 'Enter a valid value for %s', 'accessible-forms-by-accessible-org' ), self::lower( $label ) ); } if ( is_string( $clean ) && '' !== $clean ) { if ( ! empty( $field['min_length'] ) && self::length( $clean ) < $field['min_length'] ) { $code = 'too_short'; $errors[ $name ] = sprintf( /* translators: %d: minimum number of characters. */ __( 'Use at least %d characters.', 'accessible-forms-by-accessible-org' ), $field['min_length'] ); } if ( ! empty( $field['max_length'] ) && self::length( $clean ) > $field['max_length'] ) { $code = 'too_long'; $errors[ $name ] = sprintf( /* translators: %d: maximum number of characters. */ __( 'Use no more than %d characters.', 'accessible-forms-by-accessible-org' ), $field['max_length'] ); } } if ( 'checkbox' === $type ) { if ( ! empty( $field['min_choices'] ) && count( $clean ) < $field['min_choices'] ) { $code = 'choice_count'; $errors[ $name ] = sprintf( /* translators: %d: minimum number of choices. */ _n( 'Choose at least %d option.', 'Choose at least %d options.', (int) $field['min_choices'], 'accessible-forms-by-accessible-org' ), $field['min_choices'] ); } if ( ! empty( $field['max_choices'] ) && count( $clean ) > $field['max_choices'] ) { $code = 'choice_count'; $errors[ $name ] = sprintf( /* translators: %d: maximum number of choices. */ _n( 'Choose no more than %d option.', 'Choose no more than %d options.', (int) $field['max_choices'], 'accessible-forms-by-accessible-org' ), $field['max_choices'] ); } } if ( isset( $errors[ $name ] ) ) { $codes[ $name ] = $code; } // Browsers empty number, date and time boxes that hold an invalid value, // so keep what the page will show: later conditions then match the page. if ( 'format' === $code && in_array( $type, array( 'number', 'date', 'time' ), true ) ) { $clean = ''; } $values[ $name ] = $clean; } // Add-ons compare already cleaned answers without changing values or // removing core validation failures. $extra = Extensions::filter( 'accessible_forms_answer_errors', array( 'errors' => $errors, 'codes' => $codes ), $schema, $values ); $shape = is_array( $extra ) && is_array( $extra['errors'] ?? null ) && is_array( $extra['codes'] ?? null ); if ( $shape ) { $names = array_fill_keys( array_merge( array( '_form' ), array_column( $schema['fields'], 'name' ) ), true ); foreach ( $extra['errors'] as $name => $message ) { $shape = $shape && isset( $names[ $name ] ) && is_string( $message ) && '' !== $message; } foreach ( $extra['codes'] as $name => $code ) { $shape = $shape && isset( $names[ $name ] ) && is_string( $code ); } } if ( ! $shape ) { $extra = array( 'errors' => array( '_form' => __( 'These answers could not be checked. Please try again.', 'accessible-forms-by-accessible-org' ) ), 'codes' => array( '_form' => 'extension' ) ); } return array( $values, $errors + $extra['errors'], $codes + $extra['codes'] ); } /** Unicode code-point count, matching the public counter even without mbstring. */ private static function length( $value ) { if ( function_exists( 'mb_strlen' ) ) { return mb_strlen( $value, 'UTF-8' ); } $count = preg_match_all( '/./us', $value ); // Sanitized text is valid UTF-8. Keep malformed extension values bounded too. return false === $count ? strlen( $value ) : $count; } /** * Reduce a possibly-array request value to a scalar string. * * @param mixed $input Raw value. * @return string */ private static function scalar( $input ) { if ( is_array( $input ) ) { return ''; } return is_scalar( $input ) ? (string) $input : ''; } /** * Match a single submitted value against the allowed option list. * * @param mixed $input Raw value. * @param array $options Allowed options. * @return string */ private static function clean_choice( $input, array $options ) { $candidate = sanitize_text_field( self::scalar( $input ) ); return in_array( $candidate, $options, true ) ? $candidate : ''; } /** * Fields shown for these answers, by the same rule validate() uses. * * @param array $schema Form schema. * @param array $raw Submitted answers. * @return array Field name => true. */ public static function active( array $schema, array $raw ) { return array_fill_keys( array_keys( self::validate( $schema, $raw )[0] ), true ); } /** * Match submitted values against the allowed option list. * * @param mixed $input Raw value. * @param array $options Allowed options. * @return array */ private static function clean_choice_list( $input, array $options ) { if ( ! is_array( $input ) ) { $input = '' === $input ? array() : array( $input ); } $clean = array(); foreach ( $input as $candidate ) { $candidate = sanitize_text_field( is_scalar( $candidate ) ? (string) $candidate : '' ); if ( in_array( $candidate, $options, true ) && ! in_array( $candidate, $clean, true ) ) { $clean[] = $candidate; } } return $clean; } /** * Build an error message for a choice field. * * Choice labels are very often questions ("How can we help?"). Dropping a * question into "Select %s" produces "Select how can we help?", which reads * as nonsense in an error summary. Questions are quoted instead. * * @param string $label Field label. * @param bool $multiple Whether more than one answer is allowed. * @return string */ private static function choose_message( $label, $multiple ) { $label = trim( $label ); if ( preg_match( '/\?\s*$/u', $label ) ) { /* translators: %s: the question asked by the field. */ return sprintf( __( 'Answer "%s"', 'accessible-forms-by-accessible-org' ), $label ); } if ( $multiple ) { /* translators: %s: field label. */ return sprintf( __( 'Select at least one option for %s', 'accessible-forms-by-accessible-org' ), self::lower( $label ) ); } /* translators: %s: field label. */ return sprintf( __( 'Select %s', 'accessible-forms-by-accessible-org' ), self::lower( $label ) ); } /** * Lowercase a label for use mid-sentence, leaving acronyms intact. * * @param string $label Field label. * @return string */ private static function lower( $label ) { $label = trim( $label ); // Leave labels that are entirely uppercase, or start with two capitals, alone. if ( preg_match( '/^[A-Z]{2}/', $label ) ) { return $label; } return function_exists( 'mb_strtolower' ) ? mb_strtolower( $label ) : strtolower( $label ); } }