'/^-?\d{1,15}(\.\d{1,10})?$|^\d{4}-\d{2}-\d{2}$/', 'max' => '/^-?\d{1,15}(\.\d{1,10})?$|^\d{4}-\d{2}-\d{2}$/', 'step' => '/^(\d{1,10}(\.\d{1,10})?|any)$/', 'minlength' => '/^\d{1,6}$/', 'maxlength' => '/^\d{1,6}$/', 'rows' => '/^\d{1,3}$/', 'cols' => '/^\d{1,3}$/', 'size' => '/^\d{1,3}$/', 'tabindex' => '/^-?\d{1,3}$/', 'id' => '/^[A-Za-z][A-Za-z0-9_-]{0,63}$/', 'class' => '/^[A-Za-z0-9_|-]{1,200}$/', 'autocomplete' => '/^[a-z0-9 -]{1,60}$/', 'limit' => '/^\d{1,6}(kb|mb)?$/i', 'filetypes' => '/^[a-z0-9]{1,10}(\|[a-z0-9]{1,10}){0,30}$/i', 'default' => '/^(get|post_id|post_title|post_url|user_login|user_email|user_display_name)$/', ); /** * Regex for one tag; %s is the list of known types. Only known types * match, so mail placeholders such as [your-name] and ordinary bracketed * text are left alone. */ private const TAG_REGEX = '/\[(%s)(\*?)((?:\s+(?:"[^"\r\n]*"|\'[^\'\r\n]*\'|[^\s\]"\']+))*)\s*\]/'; /** * Tag types the parser recognises: core types plus add-on types. * * @param bool $include_locked Also include add-on types whose feature is * off (the parser removes their tags). * @return string[] */ public static function tag_types( bool $include_locked = false ): array { $extra = array_filter( array_keys( Extensions::field_types() ), static fn( string $type ): bool => $include_locked || null !== Extensions::field_type( $type ) ); return array_values( array_merge( self::TYPES, $extra ) ); } /** * Regex matching one tag of any recognised type (core and add-on). */ public static function tag_regex(): string { return sprintf( self::TAG_REGEX, implode( '|', array_map( static fn( string $t ): string => preg_quote( $t, '/' ), self::tag_types( true ) ) ) ); } /** * Whether a key is one of the core tag options. * * @param string $key Option key. */ public static function is_core_option( string $key ): bool { return isset( self::OPTION_RULES[ $key ] ); } /** * Problems found while parsing (shown on the admin screen). * * @var string[] */ private array $errors = array(); /** * Returns only the fields in a template. * * @param string $template Template markup. * @return Field[] */ public function parse( string $template ): array { return array_values( array_filter( $this->segments( $template ), static fn( $segment ) => $segment instanceof Field ) ); } /** * Conditions that apply to each field (outermost first), from [if] groups. * * @param string $template Template markup. * @return array */ public function conditions( string $template ): array { return self::conditions_in( $this->segments( $template ) ); } /** * Conditions that apply to each input field in a segment stream. * * @param array $segments Segments. * @return array */ public static function conditions_in( array $segments ): array { $stack = array(); $map = array(); foreach ( $segments as $segment ) { if ( $segment instanceof Condition ) { if ( $segment->is_close() ) { array_pop( $stack ); } else { $stack[] = $segment; } } elseif ( $segment instanceof Field && $segment->is_input() && array() !== $stack ) { $map[ $segment->name ] = $stack; } } return $map; } /** * Regex for conditional group markers. */ public const IF_REGEX = '/\[if\s+([A-Za-z][A-Za-z0-9_-]*)\s+(is|not|filled|empty)(?:\s+(?:"([^"\r\n]*)"|\'([^\'\r\n]*)\'))?\s*\]|\[\/if\]/i'; /** * Field names seen during the current parse. * * @var array */ private array $seen_names = array(); /** * Tags processed during the current parse. * * @var int */ private int $count = 0; /** * Splits a template into HTML strings, Field objects and Condition * markers, in order. Invalid tags are dropped (and reported via errors()); * unbalanced [if]/[/if] markers are repaired so output stays balanced. * * @param string $template Template markup. * @return array */ public function segments( string $template ): array { $this->errors = array(); $this->seen_names = array(); $this->count = 0; $out = array(); $depth = 0; $offset = 0; if ( false === preg_match_all( self::IF_REGEX, $template, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) { $this->errors[] = __( 'The template could not be parsed.', 'flexsolutions-form-builder' ); return array( $template ); } foreach ( $matches as $match ) { [ $raw, $position ] = $match[0]; array_push( $out, ...$this->parse_text( substr( $template, $offset, $position - $offset ) ) ); $offset = $position + strlen( $raw ); if ( '[/if]' === strtolower( $raw ) ) { if ( $depth > 0 ) { --$depth; $out[] = Condition::close(); } else { $this->errors[] = __( 'Found [/if] without a matching [if]; it was ignored.', 'flexsolutions-form-builder' ); } continue; } $op = strtolower( $match[2][0] ); $value = ( $match[3][1] ?? -1 ) >= 0 ? $match[3][0] : ( ( $match[4][1] ?? -1 ) >= 0 ? $match[4][0] : null ); if ( in_array( $op, array( 'is', 'not' ), true ) && null === $value ) { $this->errors[] = sprintf( /* translators: %s: the [if] tag. */ __( '%s needs a quoted value, e.g. [if contact is "Phone"].', 'flexsolutions-form-builder' ), $raw ); $value = ''; } ++$depth; $out[] = new Condition( $match[1][0], $op, sanitize_text_field( (string) $value ) ); } array_push( $out, ...$this->parse_text( substr( $template, $offset ) ) ); if ( $depth > 0 ) { $this->errors[] = __( 'An [if] group is missing its closing [/if]; it was closed at the end of the form.', 'flexsolutions-form-builder' ); for ( ; $depth > 0; --$depth ) { $out[] = Condition::close(); } } return array_values( array_filter( $out, static fn( $s ) => ! is_string( $s ) || '' !== $s ) ); } /** * Parses form tags in a piece of template without [if] markers. * * @param string $text Template part. * @return array */ private function parse_text( string $text ): array { $segments = array(); $offset = 0; if ( false === preg_match_all( self::tag_regex(), $text, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE ) ) { return array( $text ); } foreach ( $matches as $match ) { [ $raw, $position ] = $match[0]; $segments[] = substr( $text, $offset, $position - $offset ); $offset = $position + strlen( $raw ); if ( ++$this->count > self::MAX_FIELDS ) { if ( self::MAX_FIELDS + 1 === $this->count ) { $this->errors[] = sprintf( /* translators: %d: maximum number of fields. */ __( 'Only the first %d fields are used.', 'flexsolutions-form-builder' ), self::MAX_FIELDS ); } continue; } $field = $this->parse_tag( $match[1][0], '*' === $match[2][0], $match[3][0], $raw ); if ( null === $field ) { continue; } if ( $field->is_input() ) { if ( isset( $this->seen_names[ $field->name ] ) ) { $this->errors[] = sprintf( /* translators: %s: field name. */ __( 'Duplicate field name "%s"; only the first one is used.', 'flexsolutions-form-builder' ), $field->name ); continue; } $this->seen_names[ $field->name ] = true; } $segments[] = $field; } $segments[] = substr( $text, $offset ); return $segments; } /** * Problems found during the last call to segments()/parse(). * * @return string[] */ public function errors(): array { return $this->errors; } /** * Builds a Field from one tag's parts, or returns null if it is invalid. * * @param string $type Tag type. * @param bool $required Whether "*" was present. * @param string $args Everything after the type. * @param string $raw The full tag (for error messages). */ private function parse_tag( string $type, bool $required, string $args, string $raw ): ?Field { preg_match_all( '/"([^"\r\n]*)"|\'([^\'\r\n]*)\'|(\S+)/', $args, $tokens, PREG_SET_ORDER ); $name = ''; $options = array(); $flags = array(); $values = array(); foreach ( $tokens as $token ) { // Group 3 only matches unquoted words; groups 1/2 are the quoted forms. if ( ! isset( $token[3] ) ) { $value = '' !== ( $token[1] ?? '' ) ? $token[1] : ( $token[2] ?? '' ); if ( count( $values ) < self::MAX_CHOICES ) { $values[] = sanitize_text_field( $value ); } continue; } $word = $token[3]; if ( '' === $name && ! in_array( $type, self::NAMELESS, true ) && ! str_contains( $word, ':' ) ) { $name = $word; continue; } if ( str_contains( $word, ':' ) ) { [ $key, $value ] = explode( ':', $word, 2 ); $options[] = array( strtolower( $key ), $value, $word ); continue; } $flags[] = $word; } return $this->make_field( $type, $required, $name, $options, $flags, $values, $raw ); } /** * Builds a validated Field from its parts, or returns null (with an * error) when it is invalid. Shared by the tag syntax and the form schema, * so both accept exactly the same fields. * * @param string $type Field type (one of TYPES or an add-on type). * @param bool $required Whether the field is required. * @param string $name Field name ('' for nameless types). * @param array $options Options as [key, value, original text]. * @param string[] $flags Bare-word flags; unknown ones are ignored. * @param string[] $values Quoted values (already sanitized). * @param string $raw How to name the field in error messages. */ public function make_field( string $type, bool $required, string $name, array $options, array $flags, array $values, string $raw ): ?Field { $custom = null; if ( ! in_array( $type, self::TYPES, true ) ) { if ( ! array_key_exists( $type, Extensions::field_types() ) ) { $this->errors[] = sprintf( /* translators: %s: field description. */ __( 'Unknown field type in %s.', 'flexsolutions-form-builder' ), $raw ); return null; } $custom = Extensions::field_type( $type ); if ( null === $custom ) { $this->errors[] = sprintf( /* translators: %s: form tag. */ __( '%s needs an add-on feature that is not active on this site; it was removed from the form.', 'flexsolutions-form-builder' ), $raw ); return null; } } // Add-on types add their own options and flags; core ones always apply. $option_rules = self::OPTION_RULES + ( $custom['options'] ?? array() ); $known_flags = array_merge( self::FLAGS, $custom['flags'] ?? array() ); $valid_options = array(); foreach ( $options as [ $key, $value, $original ] ) { if ( isset( $option_rules[ $key ] ) && preg_match( $option_rules[ $key ], $value ) ) { $valid_options[ $key ] = $value; } else { $this->errors[] = sprintf( /* translators: 1: option, 2: form tag. */ __( 'Ignored unknown or invalid option "%1$s" in %2$s.', 'flexsolutions-form-builder' ), $original, $raw ); } } $options = $valid_options; $flags = array_values( array_intersect( $flags, $known_flags ) ); $values = array_slice( $values, 0, self::MAX_CHOICES ); if ( ! in_array( $type, self::NAMELESS, true ) && ! self::is_valid_name( $name ) ) { $this->errors[] = sprintf( /* translators: %s: form tag. */ __( 'Missing or invalid field name in %s. Names must start with a letter and contain only letters, digits, "-" and "_".', 'flexsolutions-form-builder' ), $raw ); return null; } if ( in_array( $type, array( 'select', 'checkbox', 'radio' ), true ) ) { $values = array_values( array_unique( array_filter( $values, static fn( string $v ) => '' !== $v ) ) ); if ( array() === $values ) { $this->errors[] = sprintf( /* translators: %s: form tag. */ __( '%s needs at least one quoted choice.', 'flexsolutions-form-builder' ), $raw ); return null; } } if ( 'quiz' === $type && array() === array_filter( $values, static fn( string $v ) => 1 === preg_match( '/^[^|]+\|[^|]+/', $v ) ) ) { $this->errors[] = sprintf( /* translators: %s: form tag. */ __( '%s needs at least one quoted "Question|answer" pair.', 'flexsolutions-form-builder' ), $raw ); return null; } if ( 'select' === $type && in_array( 'first_as_label', $flags, true ) && count( $values ) < 2 ) { $this->errors[] = sprintf( /* translators: %s: form tag. */ __( '%s uses first_as_label but has no choices after the first one.', 'flexsolutions-form-builder' ), $raw ); return null; } if ( isset( $options['class'] ) ) { $options['class'] = implode( ' ', array_filter( array_map( 'sanitize_html_class', explode( '|', $options['class'] ) ) ) ); } // Quizzes are always required; buttons, markers, hidden fields and sliders never are. $required = 'quiz' === $type || ( ! in_array( $type, array( 'submit', 'response', 'hidden', 'range' ), true ) && $required ); return new Field( $type, $name, $required, $options, array_values( array_unique( $flags ) ), $values ); } /** * Validates a field name. Names beginning with "_" or "tflexforms" are * reserved for the plugin's own hidden fields and mail placeholders. * * @param string $name Candidate name. */ public static function is_valid_name( string $name ): bool { return 1 === preg_match( '/^[A-Za-z][A-Za-z0-9_-]{0,63}$/', $name ) && ! str_starts_with( strtolower( $name ), 'tflexforms' ); } }