*' : '' ); // Fields that do not render a labelled control. if ( 'hidden' === $type ) { $val = '' !== $value ? $value : (string) $field['default']; return ''; } if ( 'section' === $type ) { return '

' . $label . '

'; } if ( 'html' === $type ) { return '
' . wp_kses_post( $field['content'] ) . '
'; } $width = $field['width'] ? ' pubforms-width-' . esc_attr( $field['width'] ) : ''; $cond = ! empty( $field['logic'] ) ? ' pubforms-conditional' : ''; $data = ''; if ( ! empty( $field['logic'] ) ) { $logic = $field['logic']; $data = ' data-pubforms-logic-match="' . esc_attr( $logic['match'] ) . '" data-pubforms-logic-op="' . esc_attr( $logic['op'] ) . '" data-pubforms-logic-value="' . esc_attr( $logic['value'] ) . '"'; } $out = '
'; // Choice groups use a non-interactive legend so each option keeps its own
'; } /** * Ask page caches not to store a page that contains a form: its nonce would go stale and be shared * between visitors. DONOTCACHEPAGE is honoured by the major caching plugins; nocache_headers() covers * browsers, proxies and CDNs. */ function pubforms_prevent_page_cache() { if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals -- standard constant read by caching plugins. } if ( ! headers_sent() ) { nocache_headers(); } } // Send the no-cache headers before output starts, on any singular page whose content embeds a form. add_action( 'template_redirect', function () { if ( ! is_singular() ) { return; } $post = get_queried_object(); if ( $post instanceof WP_Post && ( has_shortcode( $post->post_content, 'pubforms' ) || has_block( 'pubforms/form', $post ) ) ) { pubforms_prevent_page_cache(); } } ); /** * The form's HTML. It carries a nonce, so pages containing a form must not be cached * (see pubforms_prevent_page_cache()). */ function pubforms_render_form( $form_id, $style = array() ) { $form = pubforms_get_form( $form_id ); if ( ! $form ) { return ''; } if ( 'publish' !== $form['status'] ) { return current_user_can( 'edit_post', $form['id'] ) ? '

' . esc_html__( 'This form is not published yet, so visitors cannot see it.', 'pubforms' ) . '

' : ''; } if ( ! $form['fields'] ) { return ''; } pubforms_prevent_page_cache(); wp_enqueue_style( 'pubforms', PUBFORMS_URL . 'assets/forms.css', array(), pubforms_asset_version( 'forms.css' ) ); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- display only; nothing is changed. $sent = isset( $_GET['pubforms_sent'] ) && absint( $_GET['pubforms_sent'] ) === $form['id']; $state = array(); if ( isset( $_GET['pubforms_state'] ) ) { $stored = get_transient( 'pubforms_state_' . sanitize_key( wp_unslash( $_GET['pubforms_state'] ) ) ); if ( is_array( $stored ) && (int) ( $stored['form'] ?? 0 ) === $form['id'] ) { $state = $stored; } } // A resumed draft overrides new-form state. Stored server-side; only an opaque token is in the URL. $resume = array(); $resume_token = ''; if ( '' === ( $state['values'] ?? '' ) && isset( $_GET['pubforms_resume'] ) ) { $resume_token = sanitize_key( wp_unslash( $_GET['pubforms_resume'] ) ); $resume = get_transient( 'pubforms_resume_' . $resume_token ); if ( ! is_array( $resume ) || (int) ( $resume['form'] ?? 0 ) !== $form['id'] ) { $resume = array(); } } // phpcs:enable // Field values to prefill: error-render state wins, otherwise a resumed draft. $values = (array) ( $state['values'] ?? array() ); if ( ! empty( $resume['values'] ) && is_array( $resume['values'] ) ) { $values = array_merge( (array) $resume['values'], $values ); } $anchor = 'pubforms-' . $form['id']; $look = pubforms_style_attributes( $style ); $out = '
'; if ( $sent ) { return $out . '
' . esc_html( $form['settings']['success'] ) . '
'; } if ( ! empty( $state['errors'] ) ) { $out .= ''; } if ( ! empty( $state['message'] ) ) { $out .= ''; } if ( $resume && '' !== $resume_token ) { $url = add_query_arg( 'pubforms_resume', $resume_token, pubforms_return_url() ); /* translators: %s: resume URL */ $out .= '
' . wp_kses_post( sprintf( __( 'You are editing a saved draft. You can return to it anytime with this link: resume this form.', 'pubforms' ), esc_url( $url ) ) ) . '
'; } $has_files = false; $max_page = 0; foreach ( $form['fields'] as $field ) { if ( 'file' === $field['type'] ) { $has_files = true; } $max_page = max( $max_page, (int) $field['page'] ); } $multi = $max_page > 0; // Always on: also handles conditional logic and abandonment capture on plain forms. wp_enqueue_script( 'pubforms-ui', PUBFORMS_URL . 'assets/form-ui.js', array(), pubforms_asset_version( 'form-ui.js' ), true ); $spam = pubforms_spam_provider( $form ); if ( $spam ) { $spam['keys'] = pubforms_spam_keys( $spam['id'] ); } $save_abandoned = ! empty( $form['settings']['save_abandoned'] ); $out .= '
'; $out .= ''; $out .= wp_nonce_field( 'pubforms_form', 'pubforms_nonce', false, false ); $out .= ''; $out .= ''; $out .= pubforms_spam_timestamp_field(); $out .= ''; if ( $save_abandoned ) { $out .= '

' . esc_html__( 'Answers you type here may be saved if you leave this page without submitting.', 'pubforms' ) . '

'; } if ( $multi ) { $total = $max_page + 1; $out .= ''; $pages = array(); foreach ( $form['fields'] as $field ) { $pages[ (int) $field['page'] ][] = $field; } ksort( $pages ); $i = 0; foreach ( $pages as $fields ) { $out .= '
0 ? ' hidden' : '' ) . '>'; foreach ( $fields as $field ) { $out .= pubforms_render_field( $form['id'], $field, (string) ( $values[ $field['id'] ] ?? '' ), $state['errors'][ $field['id'] ] ?? '' ); } $out .= '
'; $i++; } $out .= pubforms_spam_widget_html( $spam, $form['id'] ); $out .= '

'; $out .= ' '; $out .= ' '; $out .= ''; $out .= '

'; } else { foreach ( $form['fields'] as $field ) { $out .= pubforms_render_field( $form['id'], $field, (string) ( $values[ $field['id'] ] ?? '' ), $state['errors'][ $field['id'] ] ?? '' ); } $out .= pubforms_spam_widget_html( $spam, $form['id'] ); $out .= '

'; } return $out . '
'; } /** * Which spam provider a form uses, if it is configured site-wide. Returns null when none applies. */ function pubforms_spam_provider( $form ) { $id = pubforms_effective_spam_method( $form ); if ( '' === $id ) { return null; } return array( 'id' => $id, 'keys' => 'question' === $id ? array() : pubforms_spam_keys( $id ) ); } /** * Site + secret keys for a spam provider, from the Settings screen. */ function pubforms_spam_keys( $provider ) { return array( 'site' => trim( (string) get_option( 'pubforms_' . $provider . '_site_key', '' ) ), 'secret' => trim( (string) get_option( 'pubforms_' . $provider . '_secret_key', '' ) ), ); } /** * The CAPTCHA widget for a form, or '' when none is configured. This deliberately loads the * provider's own script so the widget works on cached pages without a per-visitor token. */ function pubforms_spam_widget_html( $spam, $form_id = 0 ) { if ( $spam && 'question' === $spam['id'] ) { return pubforms_spam_challenge_html( $form_id ); } if ( ! $spam || empty( $spam['keys']['site'] ) ) { return ''; } if ( 'turnstile' === $spam['id'] ) { wp_enqueue_script( 'pubforms-turnstile', 'https://challenges.cloudflare.com/turnstile/v0/api.js', array(), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent -- provider-hosted CAPTCHA script, loaded only for forms that opt in to Turnstile. return '
'; } wp_enqueue_script( 'pubforms-hcaptcha', 'https://hcaptcha.com/1/api.js', array(), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent -- provider-hosted CAPTCHA script, loaded only for forms that opt in to hCaptcha. return '
'; } /** * The per-embed style options a block or shortcode can set. One definition feeds the block * attributes, the shortcode and the sanitizer, so they cannot drift apart. */ function pubforms_style_options() { return array( 'preset' => array( 'type' => 'string', 'default' => 'card', 'choices' => array( 'card', 'outlined', 'minimal', 'soft' ) ), 'layout' => array( 'type' => 'string', 'default' => 'stacked', 'choices' => array( 'stacked', 'grid', 'flow' ) ), 'density' => array( 'type' => 'string', 'default' => 'comfortable', 'choices' => array( 'compact', 'comfortable', 'spacious' ) ), 'labelPosition' => array( 'type' => 'string', 'default' => 'above', 'choices' => array( 'above', 'left' ) ), 'corners' => array( 'type' => 'string', 'default' => 'rounded', 'choices' => array( 'square', 'rounded', 'pill' ) ), 'shadow' => array( 'type' => 'boolean', 'default' => true ), 'buttonAlign' => array( 'type' => 'string', 'default' => 'right', 'choices' => array( 'left', 'center', 'right', 'full' ) ), 'maxWidth' => array( 'type' => 'number', 'default' => 640 ), 'accent' => array( 'type' => 'string', 'default' => '' ), 'background' => array( 'type' => 'string', 'default' => '' ), 'textColor' => array( 'type' => 'string', 'default' => '' ), ); } /** * Turns raw style options (block attributes or shortcode attributes) into a class suffix and an * inline custom-property string. Everything is whitelisted or sanitized; unknown values fall * back to the defaults. */ function pubforms_style_attributes( $raw ) { $raw = is_array( $raw ) ? $raw : array(); $options = pubforms_style_options(); $class = ''; $vars = array(); foreach ( array( 'preset', 'layout', 'density', 'labelPosition', 'corners', 'buttonAlign' ) as $key ) { $value = isset( $raw[ $key ] ) ? sanitize_key( $raw[ $key ] ) : $options[ $key ]['default']; if ( ! in_array( $value, $options[ $key ]['choices'], true ) ) { $value = $options[ $key ]['default']; } $class .= ' pubforms-' . $key . '-' . $value; } $shadow = array_key_exists( 'shadow', $raw ) ? filter_var( $raw['shadow'], FILTER_VALIDATE_BOOLEAN ) : $options['shadow']['default']; if ( ! $shadow ) { $class .= ' pubforms-no-shadow'; } $width = isset( $raw['maxWidth'] ) ? absint( $raw['maxWidth'] ) : $options['maxWidth']['default']; if ( $width >= 240 && $width <= 1400 && $width !== $options['maxWidth']['default'] ) { $vars[] = '--pubforms-maxw:' . $width . 'px'; } foreach ( array( 'accent' => '--pubforms-accent', 'background' => '--pubforms-bg', 'textColor' => '--pubforms-text', ) as $key => $property ) { $color = isset( $raw[ $key ] ) ? sanitize_hex_color( trim( (string) $raw[ $key ] ) ) : ''; if ( $color ) { $vars[] = $property . ':' . $color; } } return array( 'class' => $class, 'style' => implode( ';', $vars ) ); } add_shortcode( 'pubforms', function ( $atts ) { // Shortcode attributes are lowercase: [pubforms id="1" preset="soft" accent="#db2777" button="center"]. $atts = shortcode_atts( array( 'id' => 0, 'preset' => '', 'layout' => '', 'density' => '', 'labels' => '', 'corners' => '', 'shadow' => '', 'button' => '', 'width' => '', 'accent' => '', 'background' => '', 'text' => '', ), $atts, 'pubforms' ); $style = array(); foreach ( array( 'preset' => 'preset', 'layout' => 'layout', 'density' => 'density', 'labels' => 'labelPosition', 'corners' => 'corners', 'shadow' => 'shadow', 'button' => 'buttonAlign', 'width' => 'maxWidth', 'accent' => 'accent', 'background' => 'background', 'text' => 'textColor' ) as $att => $key ) { if ( '' !== $atts[ $att ] ) { $style[ $key ] = $atts[ $att ]; } } return pubforms_render_form( absint( $atts['id'] ), $style ); } ); /* ----------------------------------------------------------------- Block */ add_action( 'init', function () { wp_register_style( 'pubforms', PUBFORMS_URL . 'assets/forms.css', array(), pubforms_asset_version( 'forms.css' ) ); wp_register_script( 'pubforms-block', PUBFORMS_URL . 'assets/blocks.js', array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-server-side-render', 'wp-api-fetch', 'wp-i18n' ), pubforms_asset_version( 'blocks.js' ), true ); $attributes = array( 'formId' => array( 'type' => 'number', 'default' => 0 ) ); foreach ( pubforms_style_options() as $key => $option ) { $attributes[ $key ] = array( 'type' => $option['type'], 'default' => $option['default'] ); } register_block_type( 'pubforms/form', array( 'api_version' => 3, 'attributes' => $attributes, 'editor_script' => 'pubforms-block', 'editor_style' => 'pubforms', 'render_callback' => function ( $atts ) { return pubforms_render_form( absint( $atts['formId'] ?? 0 ), $atts ); }, ) ); } ); // Lets the block editor list forms to choose from. add_action( 'rest_api_init', function () { register_rest_route( 'pubforms/v1', '/forms', array( 'methods' => 'GET', 'permission_callback' => function () { return current_user_can( 'edit_posts' ); }, 'callback' => function () { $out = array(); // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- a bounded list for the block picker. foreach ( get_posts( array( 'post_type' => 'pubforms_form', 'post_status' => 'publish', 'posts_per_page' => 200 ) ) as $form ) { $out[] = array( 'id' => $form->ID, 'title' => get_the_title( $form ) ); } return $out; }, ) ); } );