$field Schema entry. * @param mixed $raw Raw submitted value. * * @return bool|int|float|string */ public function sanitize( array $field, mixed $raw ): bool|int|float|string { $type = (string) ( $field['type'] ?? 'text' ); if ( 'checkbox' === $type ) { // `renderCheckbox()` emits a hidden companion carrying `0`, so an // unticked box posts the string `'0'` rather than going missing. PHP // already reads that as false; an array from a crafted POST would read // as true, which is why it is rejected rather than cast. return is_scalar( $raw ) && (bool) $raw; } if ( 'number' === $type ) { return $this->clampNumber( $field, $raw ); } if ( 'select' === $type ) { return $this->constrainToOptions( $field, $raw ); } if ( 'time' === $type ) { return $this->clampTime( $field, $raw ); } // Anything non-scalar is a submission this schema never described. Falling // back to the declared default keeps the stored shape predictable, which // matters because `Settings::save()` merges rather than replaces: a bad // value written once would otherwise persist until someone noticed. if ( ! is_scalar( $raw ) ) { $default = $field['default'] ?? ''; return is_scalar( $default ) ? $default : ''; } return 'textarea' === $type ? sanitize_textarea_field( (string) $raw ) : sanitize_text_field( (string) $raw ); } /** * Attributes that make the control inert while a field is locked. * * Kept as a property rather than threaded through six render methods, because * it is set and cleared around a single `match` and never outlives it. * * @var bool */ private bool $locked = false; /** * Render one field. * * @param string $name Input name attribute. * @param array $field Schema entry. * @param mixed $value Current value. * @param bool $locked Render disabled, showing the shipped default. */ public function render( string $name, array $field, mixed $value, bool $locked = false ): void { $type = (string) ( $field['type'] ?? 'text' ); $id = sanitize_html_class( str_replace( [ '[', ']' ], '-', $name ) ); if ( $locked ) { // A locked field shows the shipped default, not whatever is stored. The // stored value is kept — a licence that lapses and is renewed should // bring the old configuration back — but showing it here would describe // behaviour the site is not getting. $value = $field['default'] ?? ''; } $this->locked = $locked; match ( $type ) { 'checkbox' => $this->renderCheckbox( $name, $id, $value ), 'select' => $this->renderSelect( $name, $id, $field, $value ), 'textarea' => $this->renderTextarea( $name, $id, $field, $value ), 'number' => $this->renderNumber( $name, $id, $field, $value ), 'password' => $this->renderInput( $name, $id, $field, $value, 'password' ), 'time' => $this->renderInput( $name, $id, $field, $value, 'time' ), // A row that shows rather than asks: it posts nothing, and the schema // hands over a callable instead of markup so a field type can never // become a way to put raw HTML on an admin screen. 'preview' => $this->renderPreview( $field ), default => $this->renderInput( $name, $id, $field, $value, 'text' ), }; // A pattern-code field can carry a picker that reads the provider's // panel. `variable` names the sibling field the pattern's variable name // goes into; its id is derived the same way this field's was. if ( ! $locked && is_array( $field['picker'] ?? null ) ) { $variable = (string) ( $field['picker']['variable'] ?? '' ); $varName = '' === $variable ? '' : (string) preg_replace( '/\[[^\]]*\]$/', '[' . $variable . ']', $name ); echo wp_kses( PatternPicker::controls( (string) ( $field['picker']['gateway'] ?? '' ), $id, '' === $varName ? '' : sanitize_html_class( str_replace( [ '[', ']' ], '-', $varName ) ) ), Ui::allowedHtml() ); } $this->locked = false; } /** * Extra attributes every control in a locked row carries. * * Two guards, neither load-bearing alone: `disabled` keeps the browser from * submitting the value, and dropping the `name` means a submission forged past * the first guard still carries nothing the save handler would recognise. * * @return array */ private function lockAttrs(): array { return $this->locked ? [ 'disabled' => '', 'aria-disabled' => 'true' ] : []; } /** * The `name` a control should carry — nothing, while the field is locked. * * @param string $name Input name. */ private function fieldName( string $name ): string { return $this->locked ? '' : $name; } /** * Text or password input. * * @param string $name Input name. * @param string $id Input id. * @param array $field Schema entry. * @param mixed $value Current value. * @param string $type HTML input type. */ private function renderInput( string $name, string $id, array $field, mixed $value, string $type ): void { $attrs = [ 'id' => $id ] + $this->lockAttrs(); if ( ! empty( $field['placeholder'] ) ) { $attrs['placeholder'] = (string) $field['placeholder']; } // API keys and sender lines are latin strings that read wrongly in a // right-to-left field, so they get the mono treatment. $mono = 'password' === $type || ! empty( $field['monospace'] ); echo wp_kses( Ui::input( $type, $this->fieldName( $name ), (string) $value, $attrs, $mono ), Ui::allowedHtml() ); } /** * A 24-hour clock time, or the field's default. * * `` always posts `HH:MM` on a 24-hour clock whatever the * browser displays, so the stored format does not follow the visitor's locale. * Anything else is a hand-made submission and takes the default rather than * being half-parsed into a time nobody chose. * * @param array $field Schema entry. * @param mixed $raw Submitted value. */ private function clampTime( array $field, mixed $raw ): string { $default = is_scalar( $field['default'] ?? '' ) ? (string) $field['default'] : ''; if ( ! is_scalar( $raw ) ) { return $default; } $value = trim( (string) $raw ); return 1 === preg_match( '/^([01][0-9]|2[0-3]):[0-5][0-9]$/', $value ) ? $value : $default; } /** * A read-only row that draws itself. * * @param array $field Schema entry. */ private function renderPreview( array $field ): void { $render = $field['render'] ?? null; if ( is_callable( $render ) ) { $render(); } } /** * Number input. * * @param string $name Input name. * @param string $id Input id. * @param array $field Schema entry. * @param mixed $value Current value. */ private function renderNumber( string $name, string $id, array $field, mixed $value ): void { $attrs = [ 'id' => $id ] + $this->lockAttrs(); foreach ( [ 'min', 'max', 'step' ] as $bound ) { if ( isset( $field[ $bound ] ) ) { $attrs[ $bound ] = (string) $field[ $bound ]; } } echo wp_kses( Ui::input( 'number', $this->fieldName( $name ), (string) $value, $attrs, true ), Ui::allowedHtml() ); } /** * Checkbox, rendered as the design system's switch. * * A hidden companion field is emitted first so an unchecked box submits a * value. Without it, unchecking never reaches the sanitiser and the setting * can only ever be turned on. It is skipped while locked, because a locked * field must submit nothing at all. * * @param string $name Input name. * @param string $id Input id. * @param mixed $value Current value. */ private function renderCheckbox( string $name, string $id, mixed $value ): void { if ( ! $this->locked ) { printf( '', esc_attr( $name ) ); } echo wp_kses( Ui::toggle( $this->fieldName( $name ), (bool) $value, [ 'id' => $id ], '', '', $this->locked ), Ui::allowedHtml() ); } /** * Select. * * @param string $name Input name. * @param string $id Input id. * @param array $field Schema entry. * @param mixed $value Current value. */ private function renderSelect( string $name, string $id, array $field, mixed $value ): void { $options = is_array( $field['options'] ?? null ) ? $field['options'] : []; echo wp_kses( Ui::select( $this->fieldName( $name ), $options, (string) $value, [ 'id' => $id ] + $this->lockAttrs() ), Ui::allowedHtml() ); } /** * Textarea. * * @param string $name Input name. * @param string $id Input id. * @param array $field Schema entry. * @param mixed $value Current value. */ private function renderTextarea( string $name, string $id, array $field, mixed $value ): void { echo wp_kses( Ui::textarea( $this->fieldName( $name ), (string) $value, (int) ( $field['rows'] ?? 5 ), [ 'id' => $id ] + $this->lockAttrs(), ! empty( $field['monospace'] ) ), Ui::allowedHtml() ); } /** * Clamp a number to its declared bounds. * * @param array $field Schema entry. * @param mixed $raw Raw submitted value. */ private function clampNumber( array $field, mixed $raw ): int|float { $value = is_numeric( $raw ) ? $raw + 0 : ( $field['default'] ?? 0 ); if ( isset( $field['min'] ) ) { $value = max( $field['min'], $value ); } if ( isset( $field['max'] ) ) { $value = min( $field['max'], $value ); } return isset( $field['step'] ) && is_float( $field['step'] ) ? (float) $value : (int) $value; } /** * Reject anything the select did not offer. * * @param array $field Schema entry. * @param mixed $raw Raw submitted value. */ private function constrainToOptions( array $field, mixed $raw ): string { $options = is_array( $field['options'] ?? null ) ? $field['options'] : []; $value = sanitize_text_field( (string) $raw ); if ( array_key_exists( $value, $options ) ) { return $value; } return (string) ( $field['default'] ?? (string) array_key_first( $options ) ); } }