'multiple', 'includeBlank' => 'include_blank', 'counter' => 'counter', 'readonly' => 'readonly', 'labelFirst' => 'label_first', 'exclusive' => 'exclusive', 'freeText' => 'free_text', 'firstLast' => 'first_last', ); /** Schema "display" keys and the tag options they map to. */ private const DISPLAY_MAP = array( 'id' => 'id', 'class' => 'class', 'autocomplete' => 'autocomplete', 'size' => 'size', 'rows' => 'rows', 'cols' => 'cols', 'tabindex' => 'tabindex', ); /** Schema "rules" keys and the tag options they map to. */ private const RULE_MAP = array( 'minlength' => 'minlength', 'maxlength' => 'maxlength', 'min' => 'min', 'max' => 'max', 'step' => 'step', 'maxFileSize' => 'limit', ); /** Schema settings keys and Form settings keys. */ private const SETTINGS_MAP = array( 'demoMode' => 'demo_mode', 'loggedInOnly' => 'logged_in_only', 'storeEntries' => 'store_entries', 'captcha' => 'captcha', 'akismet' => 'akismet', ); /** * Compiled segments, cached. * * @var array|null */ private ?array $segments = null; /** * Problems found while compiling. * * @var string[] */ private array $errors = array(); /** * Constructor. Use from_array() or from_json(), which sanitize. * * @param array $data Sanitized schema. */ private function __construct( private readonly array $data ) {} /** * Loads a schema from JSON. Returns null when the JSON is not a schema * this version understands (the form then falls back to its template). * * @param string $json Stored JSON. */ public static function from_json( string $json ): ?self { $data = json_decode( $json, true ); return is_array( $data ) ? self::from_array( $data ) : null; } /** * Builds a schema from decoded data, upgrading older versions. * * @param array $data Decoded schema. */ public static function from_array( array $data ): ?self { $data = self::upgrade( $data ); return null === $data ? null : new self( self::sanitize( $data ) ); } /** * Brings older schema versions up to VERSION. Null for a missing or newer * version, which this code cannot read safely. * * @param array $data Decoded schema. * @return array|null */ public static function upgrade( array $data ): ?array { $version = $data['schemaVersion'] ?? null; if ( ! is_int( $version ) || $version < 1 || $version > self::VERSION ) { return null; } // Upgrade steps go here as "if ( 1 === $version ) { ...; $version = 2; }". $data['schemaVersion'] = $version; return $data; } /** * Post meta sanitize callback: JSON in, normalized JSON out ('' when the * value is not a readable schema). * * @param mixed $value Raw meta value (JSON string or array). */ public static function sanitize_meta( mixed $value ): string { if ( is_string( $value ) ) { $value = json_decode( $value, true ); } $schema = is_array( $value ) ? self::from_array( $value ) : null; return null === $schema ? '' : $schema->to_json(); } /** * The schema as an array. * * @return array */ public function to_array(): array { return $this->data; } /** * The schema as JSON for storage. */ public function to_json(): string { return (string) wp_json_encode( $this->data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } // ----------------------------------------------------------- Sanitizing. /** * Keeps only known keys with the right types. Content is cleaned here; * whether fields are valid is decided by compile(), which reports errors. * * @param array $data Upgraded schema. * @return array */ public static function sanitize( array $data ): array { $out = array( 'schemaVersion' => self::VERSION, 'fields' => array(), ); foreach ( array_slice( self::list_of( $data['fields'] ?? null ), 0, self::MAX_ELEMENTS ) as $field ) { $clean = self::sanitize_field( $field ); if ( null !== $clean ) { $out['fields'][] = $clean; } } if ( isset( $data['layout'] ) && is_array( $data['layout'] ) ) { $out['layout'] = array( 'rows' => array() ); foreach ( self::list_of( $data['layout']['rows'] ?? null ) as $row ) { $columns = array(); foreach ( self::list_of( $row['columns'] ?? null ) as $column ) { $width = (string) ( $column['width'] ?? '1/1' ); $columns[] = array( 'width' => in_array( $width, self::WIDTHS, true ) ? $width : '1/1', 'items' => self::strings( $column['items'] ?? null, 64 ), ); } if ( array() !== $columns ) { $out['layout']['rows'][] = array( 'columns' => $columns ); } } } if ( isset( $data['conditions'] ) ) { $out['conditions'] = array(); foreach ( self::list_of( $data['conditions'] ) as $condition ) { $when = is_array( $condition['when'] ?? null ) ? $condition['when'] : array(); $op = strtolower( (string) ( $when['op'] ?? '' ) ); if ( ! in_array( $op, Condition::OPERATORS, true ) ) { continue; } $out['conditions'][] = array( 'items' => self::strings( $condition['items'] ?? null, self::MAX_ELEMENTS ), 'when' => array( 'field' => self::text( $when['field'] ?? '' ), 'op' => $op, 'value' => self::text( $when['value'] ?? '' ), ), ); } } if ( isset( $data['notifications'] ) ) { $out['notifications'] = self::sanitize_notifications( self::list_of( $data['notifications'] ) ); } if ( isset( $data['confirmations'] ) ) { $out['confirmations'] = array(); foreach ( array_slice( self::list_of( $data['confirmations'] ), 0, 10 ) as $confirmation ) { $redirect = Form::sanitize_settings( array( 'redirect_url' => (string) ( $confirmation['redirectUrl'] ?? '' ) ) )['redirect_url']; $out['confirmations'][] = array( 'type' => 'message', 'message' => self::text( $confirmation['message'] ?? '' ), 'redirectUrl' => $redirect, ); } } if ( isset( $data['settings'] ) && is_array( $data['settings'] ) ) { $settings = array(); foreach ( array_keys( self::SETTINGS_MAP ) as $key ) { $settings[ $key ] = ! empty( $data['settings'][ $key ] ); } $messages = Form::sanitize_messages( is_array( $data['settings']['messages'] ?? null ) ? $data['settings']['messages'] : array() ); if ( array() !== $messages ) { $settings['messages'] = $messages; } $out['settings'] = $settings; } return $out; } /** * Cleans one element of "fields". * * @param array $field Raw element. * @return array|null */ private static function sanitize_field( array $field ): ?array { $type = (string) ( $field['type'] ?? '' ); $addon = self::addon_type( $type ); if ( ! in_array( $type, Tag_Parser::TYPES, true ) && ! in_array( $type, self::LAYOUT_TYPES, true ) && null === $addon ) { return null; } $out = array( 'type' => $type ); if ( 'html' === $type ) { $out['id'] = self::ref( $field['id'] ?? '' ); $out['content'] = wp_kses( (string) ( $field['content'] ?? '' ), Form::allowed_template_html() ); if ( ! empty( $field['inline'] ) ) { $out['inline'] = true; } return $out; } if ( in_array( $type, Tag_Parser::NAMELESS, true ) ) { $out['id'] = self::ref( $field['id'] ?? '' ); } else { $out['name'] = substr( self::text( $field['name'] ?? '' ), 0, 64 ); } foreach ( array( 'label', 'help', 'placeholder', 'default', 'text', 'firstLabel' ) as $key ) { if ( isset( $field[ $key ] ) && '' !== self::text( $field[ $key ] ) ) { $out[ $key ] = self::text( $field[ $key ] ); } } if ( isset( $field['prefill'] ) && '' !== (string) $field['prefill'] ) { $out['prefill'] = sanitize_key( (string) $field['prefill'] ); } if ( ! empty( $field['required'] ) ) { $out['required'] = true; } if ( ! empty( $field['inline'] ) ) { $out['inline'] = true; } if ( isset( $field['requiredMark'] ) && false === $field['requiredMark'] ) { $out['requiredMark'] = false; } $rules = is_array( $field['rules'] ?? null ) ? $field['rules'] : array(); foreach ( array_keys( self::RULE_MAP ) as $key ) { if ( isset( $rules[ $key ] ) && ( is_int( $rules[ $key ] ) || is_float( $rules[ $key ] ) || ( is_string( $rules[ $key ] ) && '' !== trim( $rules[ $key ] ) ) ) ) { $out['rules'][ $key ] = is_string( $rules[ $key ] ) ? preg_replace( '/\s+/', '', $rules[ $key ] ) : $rules[ $key ]; } } if ( isset( $rules['fileTypes'] ) ) { $out['rules']['fileTypes'] = array_values( array_filter( array_map( 'strtolower', self::strings( $rules['fileTypes'], 10 ) ) ) ); } if ( isset( $field['choices'] ) ) { $out['choices'] = array(); foreach ( array_slice( self::list_of( $field['choices'] ), 0, Tag_Parser::MAX_CHOICES ) as $choice ) { $label = self::text( $choice['label'] ?? '' ); if ( '' === $label ) { continue; } $item = array( 'label' => $label ); if ( isset( $choice['value'] ) && '' !== self::text( $choice['value'] ) ) { $item['value'] = self::text( $choice['value'] ); } $out['choices'][] = $item; } } if ( isset( $field['questions'] ) ) { $out['questions'] = array(); foreach ( array_slice( self::list_of( $field['questions'] ), 0, Tag_Parser::MAX_CHOICES ) as $question ) { $out['questions'][] = array( 'question' => self::text( $question['question'] ?? '' ), 'answers' => self::strings( $question['answers'] ?? null, 200 ), ); } } $display = is_array( $field['display'] ?? null ) ? $field['display'] : array(); foreach ( array_keys( self::DISPLAY_MAP ) as $key ) { if ( isset( $display[ $key ] ) && ( is_int( $display[ $key ] ) || ( is_string( $display[ $key ] ) && '' !== trim( $display[ $key ] ) ) ) ) { $out['display'][ $key ] = is_string( $display[ $key ] ) ? self::text( $display[ $key ] ) : $display[ $key ]; } } $options = is_array( $field['options'] ?? null ) ? $field['options'] : array(); foreach ( array_keys( self::FLAG_MAP ) as $key ) { if ( ! empty( $options[ $key ] ) ) { $out['options'][ $key ] = true; } } // Add-on types: their own tag options (strings) and flags (true). $own = is_array( $field['addon'] ?? null ) ? $field['addon'] : array(); foreach ( array_keys( $addon['options'] ?? array() ) as $key ) { if ( isset( $own[ $key ] ) && is_scalar( $own[ $key ] ) && '' !== self::text( $own[ $key ] ) ) { $out['addon'][ $key ] = preg_replace( '/\s+/', '', self::text( $own[ $key ] ) ); } } foreach ( $addon['flags'] ?? array() as $flag ) { if ( ! empty( $own[ $flag ] ) ) { $out['addon'][ $flag ] = true; } } return $out; } /** * Cleans "notifications" through the same rules as the Mail tab. * * @param array> $items Raw notifications. * @return array> */ private static function sanitize_notifications( array $items ): array { $out = array(); foreach ( array_slice( $items, 0, 10 ) as $item ) { $type = 'autoreply' === ( $item['type'] ?? '' ) ? 'autoreply' : 'notification'; if ( 'autoreply' === $type ) { $mail = Form::sanitize_mail_settings( array( 'autoreply_to' => (string) ( $item['to'] ?? '' ), 'autoreply_subject' => (string) ( $item['subject'] ?? '' ), 'autoreply_body' => (string) ( $item['body'] ?? '' ), 'use_html' => ! empty( $item['html'] ), ) ); $out[] = array( 'type' => 'autoreply', 'enabled' => ! empty( $item['enabled'] ), 'to' => $mail['autoreply_to'], 'subject' => $mail['autoreply_subject'], 'body' => $mail['autoreply_body'], 'html' => $mail['use_html'], ); continue; } $mail = Form::sanitize_mail_settings( array( 'recipient' => (string) ( $item['to'] ?? '' ), 'cc' => (string) ( $item['cc'] ?? '' ), 'bcc' => (string) ( $item['bcc'] ?? '' ), 'from_name' => (string) ( $item['fromName'] ?? '' ), 'from_email' => (string) ( $item['fromEmail'] ?? '' ), 'reply_to' => (string) ( $item['replyTo'] ?? '' ), 'subject' => (string) ( $item['subject'] ?? '' ), 'body' => (string) ( $item['body'] ?? '' ), 'use_html' => ! empty( $item['html'] ), 'exclude_blank' => ! empty( $item['excludeBlank'] ), 'attach_files' => ! empty( $item['attachFiles'] ), ) ); $out[] = array( 'type' => 'notification', 'to' => $mail['recipient'], 'cc' => $mail['cc'], 'bcc' => $mail['bcc'], 'fromName' => $mail['from_name'], 'fromEmail' => $mail['from_email'], 'replyTo' => $mail['reply_to'], 'subject' => $mail['subject'], 'body' => $mail['body'], 'html' => $mail['use_html'], 'excludeBlank' => $mail['exclude_blank'], 'attachFiles' => $mail['attach_files'], ); } return $out; } // ------------------------------------------------- Form settings parts. /** * Mail settings (Form::$mail shape) from "notifications", or null when * the schema has none (the form then uses its Mail tab settings). * * @return array|null */ public function mail(): ?array { if ( ! isset( $this->data['notifications'] ) ) { return null; } $mail = array(); foreach ( $this->data['notifications'] as $item ) { if ( 'notification' === $item['type'] && ! isset( $mail['recipient'] ) ) { $mail += array( 'recipient' => $item['to'], 'cc' => $item['cc'], 'bcc' => $item['bcc'], 'from_name' => $item['fromName'], 'from_email' => $item['fromEmail'], 'reply_to' => $item['replyTo'], 'subject' => $item['subject'], 'body' => $item['body'], 'use_html' => $item['html'], 'exclude_blank' => $item['excludeBlank'], 'attach_files' => $item['attachFiles'], ); } elseif ( 'autoreply' === $item['type'] && ! isset( $mail['autoreply_enabled'] ) ) { $mail += array( 'autoreply_enabled' => $item['enabled'], 'autoreply_to' => $item['to'], 'autoreply_subject' => $item['subject'], 'autoreply_body' => $item['body'], ); } } $mail += array( 'autoreply_enabled' => false ); return $mail; } /** * Response messages from "settings.messages" and the first confirmation. * * @return array */ public function messages(): array { $messages = $this->data['settings']['messages'] ?? array(); $message = $this->data['confirmations'][0]['message'] ?? ''; if ( '' !== $message ) { $messages['success'] = $message; } return $messages; } /** * Behaviour settings (Form::$settings shape) from "settings" and the * first confirmation's redirect, or null when the schema has neither. * * @return array|null */ public function settings(): ?array { if ( ! isset( $this->data['settings'] ) && ! isset( $this->data['confirmations'] ) ) { return null; } $settings = array(); if ( isset( $this->data['settings'] ) ) { foreach ( self::SETTINGS_MAP as $key => $setting ) { $settings[ $setting ] = $this->data['settings'][ $key ]; } } if ( isset( $this->data['confirmations'] ) ) { $settings['redirect_url'] = $this->data['confirmations'][0]['redirectUrl'] ?? ''; } return $settings; } // ------------------------------------------------------------ Compiling. /** * The form as HTML strings, Field objects and Condition markers, in * render order (the same stream as Tag_Parser::segments()). * * @return array */ public function segments(): array { if ( null === $this->segments ) { $this->compile(); } return $this->segments; } /** * Problems found while compiling (shown in the configuration check). * * @return string[] */ public function errors(): array { $this->segments(); return $this->errors; } /** * The form as a tag template: the same markup the renderer gets, with * form tags in place of fields and [if]...[/if] around conditional * groups. Shown in the editor's Tags view, read back by Form_Converter, * and stored as the template backup. */ public function template(): string { $out = ''; foreach ( $this->build( true ) as $segment ) { $out .= $segment instanceof Condition ? self::condition_tag( $segment ) : (string) $segment; } return $out; } /** * Builds segments and errors. */ private function compile(): void { $this->segments = $this->build( false ); } /** * Places every element and its conditions in render order. * * @param bool $tags Whether fields are written as form tags (template()) * instead of Field objects; errors are only kept when not. * @return array */ private function build( bool $tags ): array { $parser = new Tag_Parser(); $elements = array(); // Segments of each element, keyed by its reference. $errors = array(); $count = 0; foreach ( $this->data['fields'] as $index => $def ) { $ref = self::ref_of( $def, $index ); if ( isset( $elements[ $ref ] ) ) { $errors[] = sprintf( /* translators: %s: field name. */ __( 'Duplicate field name "%s"; only the first one is used.', 'flexsolutions-form-builder' ), $ref ); continue; } if ( 'html' !== $def['type'] && ++$count > Tag_Parser::MAX_FIELDS ) { if ( Tag_Parser::MAX_FIELDS + 1 === $count ) { $errors[] = sprintf( /* translators: %d: maximum number of fields. */ __( 'Only the first %d fields are used.', 'flexsolutions-form-builder' ), Tag_Parser::MAX_FIELDS ); } continue; } $segments = $this->element_segments( $parser, $def, $ref, $tags ); if ( null !== $segments ) { $elements[ $ref ] = $segments; } } array_push( $errors, ...$parser->errors() ); // Placement: layout order first, then anything the layout leaves out. // Each slot is an element with its layout row and column (null: no row). $slots = array(); $placed = array(); $grid = array(); // Row index => column index => width, for rows drawn as columns. foreach ( $this->data['layout']['rows'] ?? array() as $r => $row ) { $used = array(); foreach ( $row['columns'] as $c => $column ) { foreach ( $column['items'] as $ref ) { if ( ! isset( $elements[ $ref ] ) ) { $errors[] = sprintf( /* translators: %s: field name. */ __( 'The layout refers to an unknown field "%s".', 'flexsolutions-form-builder' ), $ref ); continue; } if ( isset( $placed[ $ref ] ) ) { continue; } $placed[ $ref ] = true; $slots[] = array( $ref, $r, $c ); $used[ $c ] = $column['width']; } } // One full-width column is an ordinary row: no grid markup. if ( ! $tags && ( count( $used ) > 1 || ( 1 === count( $used ) && '1/1' !== reset( $used ) ) ) ) { $grid[ $r ] = $used; } } foreach ( array_keys( $elements ) as $ref ) { if ( ! isset( $placed[ $ref ] ) ) { $slots[] = array( $ref, null, 0 ); } } // Conditions per element, outermost first. $chains = array(); foreach ( $this->data['conditions'] ?? array() as $index => $condition ) { foreach ( $condition['items'] as $ref ) { $chains[ $ref ][ $index ] = new Condition( $condition['when']['field'], $condition['when']['op'], $condition['when']['value'] ); } } // Tokens in render order: elements (their reference), condition // open/close markers and column markup (an array). Conditional groups // never cross a column edge: they close before it and reopen after. $tokens = array(); $open = array(); // Condition indexes currently open, outermost first. $at = array( null, null ); // Current grid row and column. $close = static function () use ( &$tokens, &$open ): void { foreach ( $open as $unused ) { $tokens[] = Condition::close(); } $open = array(); }; foreach ( $slots as list( $ref, $r, $c ) ) { $here = null !== $r && isset( $grid[ $r ] ) ? array( $r, $c ) : array( null, null ); if ( $here !== $at ) { $close(); if ( null !== $at[0] ) { $tokens[] = array( '' ); if ( $here[0] !== $at[0] ) { $tokens[] = array( '' ); } } if ( null !== $here[0] ) { if ( $here[0] !== $at[0] ) { $tokens[] = array( '
' ); } $tokens[] = array( '
' ); } $at = $here; } $chain = $chains[ $ref ] ?? array(); $ids = array_keys( $chain ); $keep = 0; $limit = min( count( $open ), count( $ids ) ); while ( $keep < $limit && $open[ $keep ] === $ids[ $keep ] ) { ++$keep; } foreach ( array_slice( $open, $keep ) as $unused ) { $tokens[] = Condition::close(); } $open = array_slice( $open, 0, $keep ); foreach ( array_slice( $ids, $keep ) as $id ) { $tokens[] = $chain[ $id ]; $open[] = $id; } $tokens[] = $ref; } $close(); if ( null !== $at[0] ) { array_push( $tokens, array( '
' ), array( '
' ) ); } // Tokens are separated by a line break, like the rows the builder // writes, except next to inline elements, whose markup is exact. $out = array(); $inline = array(); foreach ( $this->data['fields'] as $index => $def ) { $inline[ self::ref_of( $def, $index ) ] = ! empty( $def['inline'] ); } $previous = false; // Whether the nearest element before the gap is inline. foreach ( $tokens as $i => $token ) { if ( $i > 0 ) { $next = false; for ( $j = $i, $n = count( $tokens ); $j < $n; $j++ ) { if ( is_string( $tokens[ $j ] ) ) { $next = $inline[ $tokens[ $j ] ]; break; } } if ( ! $previous && ! $next ) { $out[] = "\n"; } } if ( is_string( $token ) ) { array_push( $out, ...$elements[ $token ] ); $previous = $inline[ $token ]; } elseif ( is_array( $token ) ) { $out[] = $token[0]; } else { $out[] = $token; } } if ( ! $tags ) { $this->errors = $errors; } return self::merge_strings( $out ); } /** * Segments for one element: its row markup around the Field. * * @param Tag_Parser $parser Parser (collects errors). * @param array $def Sanitized schema element. * @param string $ref Element reference (name or id). * @param bool $tags Whether to write the field as its form tag. * @return array|null */ private function element_segments( Tag_Parser $parser, array $def, string $ref, bool $tags = false ): ?array { $type = (string) $def['type']; if ( 'html' === $type ) { return array( (string) $def['content'] ); } $field = $parser->make_field( $type, ! empty( $def['required'] ), (string) ( $def['name'] ?? '' ), self::field_options( $def ), self::field_flags( $def ), self::field_values( $def ), sprintf( '"%s"', $ref ) ); if ( null === $field ) { return null; } $item = $tags ? self::tag( $def ) : $field; if ( ! empty( $def['inline'] ) ) { return array( $item ); } // The same row markup the builder writes for a card. $label = (string) ( $def['label'] ?? '' ); $help = self::shows( $type, 'help' ) && '' !== (string) ( $def['help'] ?? '' ) ? '' . esc_html( (string) $def['help'] ) . '' : ''; $star = $field->required && ! in_array( $type, self::NEVER_REQUIRED, true ) && false !== ( $def['requiredMark'] ?? true ) ? ' ' : ''; if ( ! self::shows( $type, 'label' ) || '' === $label ) { return array( '
', $item, $help . '
' ); } if ( in_array( $type, array( 'checkbox', 'radio' ), true ) || $field->is_composite() ) { return array( '
' . esc_html( $label ) . $star . '', $item, '
' . $help . '
' ); } return array( '
' . $help . '
' ); } /** * The form tag for a schema field, e.g. [select* topic include_blank "A" "B"]. * * @param array $def Sanitized schema field. */ public static function tag( array $def ): string { $type = (string) $def['type']; $parts = array( $type . ( ! empty( $def['required'] ) && ! in_array( $type, self::NEVER_REQUIRED, true ) ? '*' : '' ) ); if ( ! in_array( $type, Tag_Parser::NAMELESS, true ) ) { $parts[] = (string) ( $def['name'] ?? '' ); } array_push( $parts, ...self::field_flags( $def ) ); foreach ( self::field_options( $def ) as $option ) { $value = (string) preg_replace( '/\s+/', '', $option[1] ); if ( '' !== $value ) { $parts[] = $option[0] . ':' . $value; } } foreach ( self::field_values( $def ) as $value ) { $parts[] = self::quote( $value ); } return '[' . implode( ' ', array_filter( $parts, static fn( string $p ): bool => '' !== $p ) ) . ']'; } /** * The [if ...] or [/if] marker for a condition. * * @param Condition $condition Condition marker. */ private static function condition_tag( Condition $condition ): string { if ( $condition->is_close() ) { return '[/if]'; } $value = in_array( $condition->op, array( 'is', 'not' ), true ) ? ' ' . self::quote( $condition->value ) : ''; return '[if ' . $condition->field . ' ' . $condition->op . $value . ']'; } /** * A tag value in quotes: double quotes unless the value contains one. * * @param string $value Value. */ private static function quote( string $value ): string { $value = (string) preg_replace( '/[\r\n]+/', ' ', $value ); if ( ! str_contains( $value, '"' ) ) { return '"' . $value . '"'; } if ( ! str_contains( $value, "'" ) ) { return "'" . $value . "'"; } return '"' . str_replace( '"', '', $value ) . '"'; } /** * Tag options for a schema field, as [key, value, original text]. * * @param array $def Schema field. * @return array */ private static function field_options( array $def ): array { $options = array(); $add = static function ( string $key, mixed $value, string $original ) use ( &$options ): void { $value = is_string( $value ) ? $value : (string) $value; $options[] = array( $key, $value, $original . ':' . $value ); }; foreach ( self::RULE_MAP as $key => $option ) { if ( isset( $def['rules'][ $key ] ) ) { $add( $option, $def['rules'][ $key ], 'rules.' . $key ); } } if ( ! empty( $def['rules']['fileTypes'] ) ) { $add( 'filetypes', implode( '|', $def['rules']['fileTypes'] ), 'rules.fileTypes' ); } foreach ( self::DISPLAY_MAP as $key => $option ) { if ( isset( $def['display'][ $key ] ) ) { $value = 'class' === $key ? implode( '|', preg_split( '/\s+/', trim( (string) $def['display'][ $key ] ) ) ) : $def['display'][ $key ]; $add( $option, $value, 'display.' . $key ); } } if ( isset( $def['prefill'] ) ) { $add( 'default', $def['prefill'], 'prefill' ); } foreach ( $def['addon'] ?? array() as $key => $value ) { if ( true !== $value ) { $add( (string) $key, $value, 'addon.' . $key ); } } return $options; } /** * Tag flags for a schema field. * * @param array $def Schema field. * @return string[] */ private static function field_flags( array $def ): array { $flags = array(); foreach ( self::FLAG_MAP as $key => $flag ) { if ( ! empty( $def['options'][ $key ] ) ) { $flags[] = $flag; } } if ( isset( $def['placeholder'] ) && ! in_array( $def['type'], array( 'select', 'checkbox', 'radio', 'quiz', 'acceptance', 'submit', 'address' ), true ) ) { $flags[] = 'placeholder'; } if ( 'select' === $def['type'] && isset( $def['firstLabel'] ) ) { $flags[] = 'first_as_label'; } foreach ( $def['addon'] ?? array() as $key => $value ) { if ( true === $value ) { $flags[] = (string) $key; } } return $flags; } /** * Tag values (the quoted parts) for a schema field. * * @param array $def Schema field. * @return string[] */ private static function field_values( array $def ): array { $type = (string) $def['type']; if ( in_array( $type, array( 'select', 'checkbox', 'radio' ), true ) ) { $values = 'select' === $type && isset( $def['firstLabel'] ) ? array( (string) $def['firstLabel'] ) : array(); foreach ( $def['choices'] ?? array() as $choice ) { // "|" separates a label from its value, so it cannot appear in a label. $label = str_replace( '|', '', (string) $choice['label'] ); $values[] = isset( $choice['value'] ) ? $label . '|' . $choice['value'] : $label; } return $values; } if ( 'quiz' === $type ) { $values = array(); foreach ( $def['questions'] ?? array() as $question ) { $answers = array_map( static fn( string $a ): string => str_replace( '|', '', $a ), $question['answers'] ); $values[] = implode( '|', array_merge( array( str_replace( '|', '', $question['question'] ) ), $answers ) ); } return $values; } if ( in_array( $type, array( 'acceptance', 'submit' ), true ) ) { return isset( $def['text'] ) ? array( (string) $def['text'] ) : array(); } $values = array(); if ( isset( $def['placeholder'] ) ) { $values[] = (string) $def['placeholder']; } if ( isset( $def['default'] ) ) { $values[] = (string) $def['default']; } return $values; } // ----------------------------------------------------- From form tags. /** * The schema element for a Field parsed from a form tag (the reverse of * how segments() builds Fields), without label or help. * * @param Field $field Parsed field. * @param string $id Element id for nameless types (submit, response). * @return array */ public static function field_definition( Field $field, string $id = '' ): array { $def = array( 'type' => $field->type ); if ( in_array( $field->type, Tag_Parser::NAMELESS, true ) ) { $def['id'] = $id; } else { $def['name'] = $field->name; } if ( $field->required && ! in_array( $field->type, self::NEVER_REQUIRED, true ) ) { $def['required'] = true; } $rules = array_flip( self::RULE_MAP ); $display = array_flip( self::DISPLAY_MAP ); foreach ( $field->options as $key => $value ) { $value = (string) $value; if ( isset( $rules[ $key ] ) ) { $def['rules'][ $rules[ $key ] ] = $value; } elseif ( isset( $display[ $key ] ) ) { $def['display'][ $display[ $key ] ] = $value; } elseif ( 'filetypes' === $key ) { $def['rules']['fileTypes'] = explode( '|', strtolower( $value ) ); } elseif ( 'default' === $key ) { $def['prefill'] = $value; } } $flags = array_flip( self::FLAG_MAP ); foreach ( $field->flags as $flag ) { if ( isset( $flags[ $flag ] ) ) { $def['options'][ $flags[ $flag ] ] = true; } } $addon = self::addon_type( $field->type ); foreach ( array_keys( $addon['options'] ?? array() ) as $key ) { if ( null !== $field->option( $key ) ) { $def['addon'][ $key ] = (string) $field->option( $key ); } } foreach ( $addon['flags'] ?? array() as $flag ) { if ( $field->has_flag( $flag ) ) { $def['addon'][ $flag ] = true; } } $values = $field->values; switch ( $field->type ) { case 'select': case 'checkbox': case 'radio': if ( 'select' === $field->type && $field->has_flag( 'first_as_label' ) ) { $def['firstLabel'] = (string) array_shift( $values ); } $def['choices'] = array(); foreach ( $values as $value ) { $parts = explode( '|', $value, 2 ); $choice = array( 'label' => $parts[0] ); if ( isset( $parts[1] ) ) { $choice['value'] = $parts[1]; } $def['choices'][] = $choice; } break; case 'quiz': $def['questions'] = array(); foreach ( $values as $value ) { $parts = explode( '|', $value ); $def['questions'][] = array( 'question' => (string) array_shift( $parts ), 'answers' => $parts, ); } break; case 'acceptance': case 'submit': if ( isset( $values[0] ) ) { $def['text'] = $values[0]; } break; default: if ( $field->has_flag( 'placeholder' ) ) { $def['placeholder'] = (string) array_shift( $values ); } if ( isset( $values[0] ) ) { $def['default'] = $values[0]; } } return $def; } // -------------------------------------------------------------- Helpers. /** * The registered add-on field type (see Extensions), or null for core * and unknown types. Inactive add-on types are kept in the document; the * parser leaves their fields out until their feature is on. * * @param string $type Element type. * @return array|null */ private static function addon_type( string $type ): ?array { return in_array( $type, Tag_Parser::TYPES, true ) ? null : ( Extensions::field_types()[ $type ] ?? null ); } /** * Whether fields of a type are rendered with a label or help text, as * the builder does: fixed for core types, from "props" for add-on types. * * @param string $type Element type. * @param string $part "label" or "help". */ private static function shows( string $type, string $part ): bool { $addon = self::addon_type( $type ); if ( null !== $addon ) { return in_array( $part, $addon['props'], true ); } return in_array( $type, 'label' === $part ? self::LABELLED : self::HELPED, true ); } /** * The reference used for an element in layout and conditions. * * @param array $def Schema field. * @param int $index Position in "fields". */ private static function ref_of( array $def, int $index ): string { if ( isset( $def['name'] ) ) { return (string) $def['name']; } return '' !== (string) ( $def['id'] ?? '' ) ? (string) $def['id'] : $def['type'] . '-' . ( $index + 1 ); } /** * Merges adjacent strings and drops empty ones. * * @param array $segments Segments. * @return array */ private static function merge_strings( array $segments ): array { $out = array(); foreach ( $segments as $segment ) { $last = array_key_last( $out ); if ( is_string( $segment ) && null !== $last && is_string( $out[ $last ] ) ) { $out[ $last ] .= $segment; } else { $out[] = $segment; } } return array_values( array_filter( $out, static fn( $s ) => ! is_string( $s ) || '' !== $s ) ); } /** * A list of arrays (anything else is dropped). * * @param mixed $value Raw value. * @return array> */ private static function list_of( mixed $value ): array { return is_array( $value ) ? array_values( array_filter( $value, 'is_array' ) ) : array(); } /** * A list of single-line strings. * * @param mixed $value Raw value. * @param int $max Maximum number of items. * @return string[] */ private static function strings( mixed $value, int $max ): array { if ( ! is_array( $value ) ) { return array(); } $out = array(); foreach ( $value as $item ) { if ( is_string( $item ) || is_int( $item ) || is_float( $item ) ) { $text = self::text( $item ); if ( '' !== $text ) { $out[] = $text; } } } return array_slice( $out, 0, $max ); } /** * A single line of plain text. * * @param mixed $value Raw value. */ private static function text( mixed $value ): string { return is_scalar( $value ) ? sanitize_text_field( (string) $value ) : ''; } /** * An element id for non-input elements. * * @param mixed $value Raw value. */ private static function ref( mixed $value ): string { return is_string( $value ) ? substr( (string) preg_replace( '/[^A-Za-z0-9_-]/', '', $value ), 0, 64 ) : ''; } }