display label.
*/
public static function get_supported_types(): array {
$types = array(
// P0 types.
'text' => __( 'Text', 'repeaterkit' ),
'textarea' => __( 'Text Area', 'repeaterkit' ),
'number' => __( 'Number', 'repeaterkit' ),
'email' => __( 'Email', 'repeaterkit' ),
'url' => __( 'URL', 'repeaterkit' ),
'password' => __( 'Password', 'repeaterkit' ),
'true_false' => __( 'True / False', 'repeaterkit' ),
'select' => __( 'Select', 'repeaterkit' ),
'checkbox' => __( 'Checkbox', 'repeaterkit' ),
'radio' => __( 'Radio Button', 'repeaterkit' ),
'color_picker' => __( 'Color Picker', 'repeaterkit' ),
'date_picker' => __( 'Date Picker', 'repeaterkit' ),
'image' => __( 'Image', 'repeaterkit' ),
'file' => __( 'File', 'repeaterkit' ),
// P1 types.
'wysiwyg' => __( 'WYSIWYG Editor', 'repeaterkit' ),
'time_picker' => __( 'Time Picker', 'repeaterkit' ),
'date_time_picker' => __( 'Date Time Picker', 'repeaterkit' ),
'range' => __( 'Range', 'repeaterkit' ),
'button_group' => __( 'Button Group', 'repeaterkit' ),
'oembed' => __( 'oEmbed', 'repeaterkit' ),
);
return (array) apply_filters( 'repeaterkit/supported_subfield_types', $types );
}
/**
* Render an ACF subfield using ACF's native field rendering engine.
*
* @param array $subfield Subfield configuration array.
* @param string $prefix Input name prefix (e.g. acf[field_rep][r_abc]).
* @param mixed $value Current subfield value.
* @param string $context Render context ('table' or 'block').
* @param string $row_id Unique row ID string.
* @return void
*/
public static function render_subfield( array $subfield, string $prefix, $value, string $context = 'block', $row_id = '0' ): void {
$subfield['prefix'] = $prefix;
$subfield['value'] = $value;
// Handle WYSIWYG unique ID per row to avoid TinyMCE instance collisions.
if ( 'wysiwyg' === $subfield['type'] ) {
$subfield['id'] = 'acf-' . $subfield['key'] . '-' . $row_id;
}
if ( 'table' === $context ) {
// For table layout, render inside a td without outer label wrapper.
echo '
';
if ( function_exists( 'acf_render_field_wrap' ) ) {
acf_render_field_wrap( $subfield, 'div', 'none' );
} elseif ( function_exists( 'acf_render_field' ) ) {
acf_render_field( $subfield );
}
echo ' | ';
} elseif ( function_exists( 'acf_render_field_wrap' ) ) {
// For block or row layout, standard ACF wrapper.
acf_render_field_wrap( $subfield, 'div', 'label' );
}
}
/**
* Sanitize an individual subfield value strictly according to its field type.
*
* @param mixed $value Raw submitted input value.
* @param array $subfield Subfield definition array.
* @return mixed Sanitized value.
*/
public static function sanitize_value( $value, array $subfield ) {
if ( null === $value || '' === $value ) {
return '';
}
$type = $subfield['type'] ?? 'text';
// Guard: Reject array payloads submitted for scalar field types.
if ( is_array( $value ) && ! in_array( $type, array( 'checkbox', 'select' ), true ) ) {
return '';
}
switch ( $type ) {
case 'number':
case 'range':
if ( ! is_numeric( $value ) ) {
return 0;
}
$num = ( false !== strpos( (string) $value, '.' ) ) ? (float) $value : (int) $value;
if ( isset( $subfield['min'] ) && '' !== $subfield['min'] && $num < (float) $subfield['min'] ) {
$num = ( false !== strpos( (string) $subfield['min'], '.' ) ) ? (float) $subfield['min'] : (int) $subfield['min'];
}
if ( isset( $subfield['max'] ) && '' !== $subfield['max'] && $num > (float) $subfield['max'] ) {
$num = ( false !== strpos( (string) $subfield['max'], '.' ) ) ? (float) $subfield['max'] : (int) $subfield['max'];
}
return $num;
case 'email':
return sanitize_email( (string) $value );
case 'url':
return esc_url_raw( (string) $value );
case 'true_false':
return ! empty( $value ) ? 1 : 0;
case 'textarea':
return sanitize_textarea_field( (string) $value );
case 'wysiwyg':
return wp_kses_post( (string) $value );
case 'color_picker':
$hex = sanitize_hex_color( (string) $value );
return $hex ? $hex : sanitize_text_field( (string) $value );
case 'date_picker':
case 'time_picker':
case 'date_time_picker':
return sanitize_text_field( (string) $value );
case 'image':
case 'file':
return self::sanitize_attachment_id( $value );
case 'select':
case 'radio':
case 'button_group':
$choices = ! empty( $subfield['choices'] ) && is_array( $subfield['choices'] ) ? array_keys( $subfield['choices'] ) : array();
if ( ! empty( $choices ) ) {
if ( is_array( $value ) ) {
$valid_vals = array_filter(
$value,
function ( $v ) use ( $choices ) {
return in_array( (string) $v, array_map( 'strval', $choices ), true );
}
);
return array_values( array_map( 'sanitize_text_field', $valid_vals ) );
}
return in_array( (string) $value, array_map( 'strval', $choices ), true ) ? sanitize_text_field( (string) $value ) : '';
}
if ( is_array( $value ) ) {
return array_map( 'sanitize_text_field', $value );
}
return sanitize_text_field( (string) $value );
case 'checkbox':
$choices = ! empty( $subfield['choices'] ) && is_array( $subfield['choices'] ) ? array_keys( $subfield['choices'] ) : array();
$values = is_array( $value ) ? $value : array( $value );
if ( ! empty( $choices ) ) {
$values = array_filter(
$values,
function ( $v ) use ( $choices ) {
return in_array( (string) $v, array_map( 'strval', $choices ), true );
}
);
}
return array_values( array_map( 'sanitize_text_field', $values ) );
case 'text':
default:
// Fallback text sanitization (with 10,000 char maximum length guard).
$text_val = (string) $value;
if ( strlen( $text_val ) > 10000 ) {
$text_val = substr( $text_val, 0, 10000 );
}
return sanitize_text_field( $text_val );
}
}
/**
* Verify that an attachment ID belongs to a valid attachment post.
* Prevents IDOR by verifying post_type === 'attachment' and parent post read permissions.
*
* @param mixed $value Stored or submitted attachment ID.
* @return int Validated attachment ID, or 0 if invalid.
*/
public static function sanitize_attachment_id( $value ): int {
if ( is_array( $value ) ) {
$value = $value['ID'] ?? ( $value['id'] ?? 0 );
}
$attachment_id = (int) $value;
if ( $attachment_id <= 0 ) {
return 0;
}
$post = get_post( $attachment_id );
if ( ! $post || 'attachment' !== $post->post_type ) {
return 0;
}
// Verify accessibility if attachment is attached to a private or draft parent.
if ( $post->post_parent > 0 ) {
$parent = get_post( $post->post_parent );
if ( $parent && 'publish' !== $parent->post_status && ! current_user_can( 'read_post', $post->post_parent ) ) {
return 0;
}
}
// Verify accessibility if attachment itself is private.
if ( 'private' === $post->post_status && ! current_user_can( 'read_post', $attachment_id ) ) {
return 0;
}
return $attachment_id;
}
/**
* Format a subfield value for template output.
*
* @param mixed $value Raw stored value.
* @param array $subfield Subfield configuration array.
* @param mixed $post_id Current post ID.
* @return mixed Formatted value.
*/
public static function format_value( $value, array $subfield, $post_id ) {
if ( null === $value || '' === $value ) {
return $value;
}
$type = $subfield['type'] ?? 'text';
switch ( $type ) {
case 'image':
case 'file':
$attachment_id = (int) $value;
if ( $attachment_id <= 0 ) {
return false;
}
$return_format = $subfield['return_format'] ?? 'array';
if ( 'url' === $return_format ) {
$url = wp_get_attachment_url( $attachment_id );
return $url ? $url : false;
} elseif ( 'id' === $return_format ) {
return $attachment_id;
}
// Default 'array' return format.
$attachment = acf_get_attachment( $attachment_id );
return $attachment ? $attachment : false;
case 'wysiwyg':
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Standard WordPress post content filter.
return apply_filters( 'the_content', (string) $value );
case 'number':
return is_numeric( $value ) ? ( false !== strpos( (string) $value, '.' ) ? (float) $value : (int) $value ) : $value;
case 'true_false':
return (bool) $value;
default:
break;
}
return apply_filters( 'repeaterkit/format_subfield_value', $value, $subfield, $post_id );
}
}