PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
← All changes | inc/generate-form-markup.php +555 -36 2.12.1 → 2.12.8 View file →
@@ -24,8 +24,16 @@
24 24 class Generate_Form_Markup {
25 25 use Get_Instance;
26 26
27 27 /**
28 + * Query arg marking an editor visit as arriving from the front-end "Edit Form"
29 + * pill, so the click can be attributed without any front-end JavaScript.
30 + *
31 + * @since 2.12.6
32 + */
33 + public const EDIT_FORM_BUTTON_SOURCE_ARG = 'srfm_edit_src';
34 +
35 + /**
28 36 * Current block attributes for the form being rendered.
29 37 * Used by child blocks (like inline button) to access parent form's embed styling.
30 38 *
31 39 * @var array<string,mixed>
@@ -33,8 +41,22 @@
33 41 */
34 42 private static $current_block_attrs = [];
35 43
36 44 /**
45 + * IDs of the forms known to be on the current request, keyed by form ID.
46 + *
47 + * Seeded at the `wp` hook (collect_queried_form_ids(), before any output) by
48 + * parsing the queried post, and added to at render time by get_form_markup().
49 + * The seed is load-bearing: on modern themes the admin bar renders at
50 + * wp_body_open (priority 0) — BEFORE the_content — so the render-time registry
51 + * alone would be empty when the node is built.
52 + *
53 + * @var array<int,bool>
54 + * @since 2.12.3
55 + */
56 + private static $rendered_form_ids = [];
57 +
58 + /**
37 59 * Constructor
38 60 *
39 61 * @since 0.0.1
40 62 */
@@ -39,11 +61,61 @@
39 61 * @since 0.0.1
40 62 */
41 63 public function __construct() {
42 64 add_action( 'rest_api_init', [ $this, 'register_custom_endpoint' ] );
65 + // Seed the form registry from the queried post before any output, so the
66 + // admin bar (which renders at wp_body_open, before the_content) has the list.
67 + add_action( 'wp', [ $this, 'collect_queried_form_ids' ] );
68 + // Frontend admin-bar "Entries" deep-link. Priority 100 mirrors the
69 + // existing "Edit Form" node in Post_Types.
70 + add_action( 'admin_bar_menu', [ $this, 'add_entries_admin_bar_node' ], 100 );
43 71 }
44 72
45 73 /**
74 + * Seed the rendered-form registry from the queried singular post's content,
75 + * before any output.
76 + *
77 + * The admin bar renders at wp_body_open (priority 0) on modern themes — before
78 + * the_content — so relying on the render-time registry alone would leave the
79 + * node empty on essentially every embed. Parsing the queried post here (srfm/form
80 + * blocks incl. reusable/synced patterns, and [sureforms] shortcodes, via the
81 + * shared Form_Styling helper) covers those; get_form_markup() then adds anything
82 + * a static parse can't see (page builders, FSE template parts).
83 + *
84 + * @since 2.12.3
85 + * @return void
86 + */
87 + public function collect_queried_form_ids() {
88 + if ( is_admin() || ! is_singular() ) {
89 + return;
90 + }
91 +
92 + // The only consumer is the admin-bar node, which bails for anyone without
93 + // manage_options. Without this guard every anonymous front-end request ran
94 + // parse_blocks() plus recursive get_post() expansion of synced patterns for a
95 + // feature it could never see. The current user is already resolved at `wp`.
96 + if ( ! is_admin_bar_showing() || ! Helper::current_user_can() ) {
97 + return;
98 + }
99 +
100 + $post_id = absint( get_queried_object_id() );
101 + if ( 0 === $post_id ) {
102 + return;
103 + }
104 +
105 + // 'raw' context: the default 'display' context applies the post_content filter,
106 + // so the parsed list could disagree with Form_Styling::should_skip_frontend_styles(),
107 + // which reads raw.
108 + $content = Helper::get_string_value( get_post_field( 'post_content', $post_id, 'raw' ) );
109 + foreach ( Form_Styling::get_form_ids_from_content( $content ) as $form_id ) {
110 + $fid = absint( $form_id );
111 + if ( $fid > 0 ) {
112 + self::$rendered_form_ids[ $fid ] = true;
113 + }
114 + }
115 + }
116 +
117 + /**
46 118 * Get the current block attributes.
47 119 *
48 120 * @return array<string,mixed>
49 121 * @since 2.7.0
@@ -52,8 +124,133 @@
52 124 return self::$current_block_attrs;
53 125 }
54 126
55 127 /**
128 + * Add an "Entries" node to the frontend admin bar on any page that contains a
129 + * SureForms form, deep-linking to the Entries admin page pre-filtered to that
130 + * form. The form list comes from collect_queried_form_ids() (seeded at `wp`)
131 + * plus the render-time registry.
132 + *
133 + * ACTUAL COVERAGE: srfm/form blocks, synced/reusable patterns (core/block) and
134 + * [sureforms] shortcodes in the queried post's content, plus a singular form CPT
135 + * page. Page builders that store layout outside post_content (Elementor in
136 + * _elementor_data, Bricks in _bricks_page_content_*) and FSE template parts are
137 + * NOT covered: the render-time registry is written during the_content, which on
138 + * block themes runs after wp_admin_bar_render() at wp_body_open, so the node is
139 + * already built. On classic themes those paths happen to work via core's wp_footer
140 + * fallback, which makes the feature silently theme-dependent. Use the
141 + * `srfm_admin_bar_entries_form_ids` filter to contribute builder-sourced IDs until
142 + * early builder detection lands. With multiple forms the node becomes a
143 + * submenu (one child per form); the parent then links to the unfiltered page.
144 + *
145 + * Runs on admin_bar_menu, which fires as the bar renders (wp_body_open on modern
146 + * themes). Gated to users who can view the Entries page (the same
147 + * `manage_options` capability the admin page and entries REST endpoints use).
148 + *
149 + * @param \WP_Admin_Bar $wp_admin_bar The admin bar instance.
150 + * @since 2.12.3
151 + * @return void
152 + */
153 + public function add_entries_admin_bar_node( $wp_admin_bar ) {
154 + // Frontend only, and only when the bar is actually shown for this user.
155 + if ( is_admin() || ! is_admin_bar_showing() || ! $wp_admin_bar instanceof \WP_Admin_Bar ) {
156 + return;
157 + }
158 +
159 + // Match who can view entries (admin page + entries REST capability).
160 + if ( ! Helper::current_user_can() ) {
161 + return;
162 + }
163 +
164 + $form_ids = array_map( 'absint', array_keys( self::$rendered_form_ids ) );
165 +
166 + // Fallback for a form's own singular page if nothing was recorded.
167 + if ( empty( $form_ids ) && is_singular( SRFM_FORMS_POST_TYPE ) ) {
168 + $singular_id = absint( get_the_ID() );
169 + if ( $singular_id > 0 ) {
170 + $form_ids[] = $singular_id;
171 + }
172 + }
173 +
174 + /**
175 + * Filter the form IDs offered in the admin-bar Entries node. Lets sources a
176 + * content parse / render can't see contribute — Elementor (_elementor_data),
177 + * Bricks (_bricks_page_content_*), FSE template parts, or Pro's
178 + * [srfm_show_entries] shortcode.
179 + *
180 + * @since 2.12.3
181 + * @param array<int> $form_ids Form IDs detected on the current request.
182 + */
183 + $form_ids = array_map( 'absint', (array) apply_filters( 'srfm_admin_bar_entries_form_ids', $form_ids ) );
184 +
185 + // Keep only real SureForms forms. The [sureforms] shortcode accepts any
186 + // published post ID, so esc_html() below must not be the only barrier
187 + // against a hostile post title (e.g. authored by an Editor with unfiltered_html).
188 + $form_ids = array_values(
189 + array_unique(
190 + array_filter(
191 + $form_ids,
192 + static function ( $fid ) {
193 + return $fid > 0 && SRFM_FORMS_POST_TYPE === get_post_type( $fid );
194 + }
195 + )
196 + )
197 + );
198 + if ( empty( $form_ids ) ) {
199 + return;
200 + }
201 +
202 + $entries_base = admin_url( 'admin.php?page=' . SRFM_ENTRIES );
203 + $node_id = 'srfm-entries';
204 + $icon = '<span class="ab-icon dashicons dashicons-list-view" style="line-height:1.2;margin-right:4px;"></span>';
205 +
206 + // Single form — link straight to its filtered entries.
207 + if ( 1 === count( $form_ids ) ) {
208 + $wp_admin_bar->add_node(
209 + [
210 + 'id' => $node_id,
211 + 'title' => $icon . '<span class="ab-label">' . esc_html__( 'Entries', 'sureforms' ) . '</span>',
212 + 'href' => esc_url( $entries_base . '#/?form=' . $form_ids[0] ),
213 + // Core esc_attr()s meta['title'], so pass it unescaped here.
214 + 'meta' => [ 'title' => __( 'View entries for this form', 'sureforms' ) ],
215 + ]
216 + );
217 + return;
218 + }
219 +
220 + // Multiple forms — parent links to unfiltered Entries, one child per form.
221 + $wp_admin_bar->add_node(
222 + [
223 + 'id' => $node_id,
224 + 'title' => $icon . '<span class="ab-label">' . esc_html__( 'Entries', 'sureforms' ) . '</span>',
225 + 'href' => esc_url( $entries_base ),
226 + 'meta' => [ 'title' => __( 'View form entries', 'sureforms' ) ],
227 + ]
228 + );
229 +
230 + // Cap the submenu; the parent's unfiltered link covers the overflow so a page
231 + // with many forms can't blow past the (non-scrolling) admin bar.
232 + foreach ( array_slice( $form_ids, 0, 10 ) as $form_id ) {
233 + $title = get_the_title( $form_id );
234 + // get_the_title() runs the_title filters that may inject markup, and
235 + // WP_Admin_Bar does not escape node titles — strip tags and escape here.
236 + $title = '' !== $title
237 + ? esc_html( wp_strip_all_tags( $title ) )
238 + /* translators: %d: form ID. */
239 + : esc_html( sprintf( __( 'Form #%d', 'sureforms' ), $form_id ) );
240 +
241 + $wp_admin_bar->add_node(
242 + [
243 + 'id' => $node_id . '-' . $form_id,
244 + 'parent' => $node_id,
245 + 'title' => $title,
246 + 'href' => esc_url( $entries_base . '#/?form=' . $form_id ),
247 + ]
248 + );
249 + }
250 + }
251 +
252 + /**
56 253 * Add custom API Route to generate form markup.
57 254 *
58 255 * @return void
59 256 * @since 0.0.1
@@ -63,15 +260,84 @@
63 260 'sureforms/v1',
64 261 '/generate-form-markup',
65 262 [
66 263 'methods' => 'GET',
67 - 'callback' => [ $this, 'get_form_markup' ],
68 - 'permission_callback' => '__return_true',
264 + 'callback' => [ $this, 'render_form_markup_endpoint' ],
265 + 'permission_callback' => [ $this, 'render_form_markup_permissions_check' ],
266 + 'args' => [
267 + 'id' => [
268 + 'required' => true,
269 + 'type' => 'integer',
270 + 'sanitize_callback' => 'absint',
271 + 'validate_callback' => static function ( $value ) {
272 + return absint( $value ) > 0;
273 + },
274 + ],
275 + ],
69 276 ]
70 277 );
71 278 }
72 279
73 280 /**
281 + * Permission check for the form-markup endpoint.
282 + *
283 + * The endpoint exists for one purpose: rendering the editor preview when a user
284 + * picks a form in the srfm/form block. So the caller must at least be able to
285 + * edit content. A nonce is not sufficient — `srfm_form_markup` is minted in
286 + * enqueue_block_editor_assets, so passing it proves only that the caller reached
287 + * the editor, never what they are allowed to read.
288 + *
289 + * @since 2.12.3
290 + * @return bool|\WP_Error True when allowed, WP_Error otherwise.
291 + */
292 + public function render_form_markup_permissions_check() {
293 + if ( ! current_user_can( 'edit_posts' ) ) {
294 + return new \WP_Error(
295 + 'srfm_rest_cannot_render_form',
296 + __( 'Sorry, you are not allowed to render form markup.', 'sureforms' ),
297 + [ 'status' => rest_authorization_required_code() ]
298 + );
299 + }
300 +
301 + return true;
302 + }
303 +
304 + /**
305 + * Render the requested form for the block-editor preview.
306 + *
307 + * Constrains the requested ID to a SureForms form, and to one the caller is
308 + * allowed to see: published forms are already public, anything else (draft,
309 + * pending, private, trashed) needs the SureForms forms capability.
310 + *
311 + * @param \WP_REST_Request<array<string,mixed>> $request REST request.
312 + *
313 + * @since 2.12.3
314 + * @return string|\WP_Error Form markup, or WP_Error when the form is not renderable for this caller.
315 + */
316 + public function render_form_markup_endpoint( $request ) {
317 + $form_id = Helper::get_integer_value( $request->get_param( 'id' ) );
318 + $form = $form_id > 0 ? get_post( $form_id ) : null;
319 +
320 + if ( ! $form instanceof \WP_Post || SRFM_FORMS_POST_TYPE !== $form->post_type ) {
321 + return new \WP_Error(
322 + 'srfm_rest_form_not_found',
323 + __( 'No form was found with the given ID.', 'sureforms' ),
324 + [ 'status' => 404 ]
325 + );
326 + }
327 +
328 + if ( 'publish' !== $form->post_status && ! Helper::current_user_can() ) {
329 + return new \WP_Error(
330 + 'srfm_rest_cannot_render_form',
331 + __( 'Sorry, you are not allowed to render this form.', 'sureforms' ),
332 + [ 'status' => rest_authorization_required_code() ]
333 + );
334 + }
335 +
336 + return Helper::get_string_value( self::get_form_markup( $form_id ) );
337 + }
338 +
339 + /**
74 340 * Handle Form status
75 341 *
76 342 * @param int|string $id Contains form ID.
77 343 * @param bool $show_title_current_page Boolean to srfm-show/srfm-hide form title.
@@ -83,17 +349,27 @@
83 349 * @return string|false
84 350 * @since 0.0.1
85 351 */
86 352 public static function get_form_markup( $id, $show_title_current_page = true, $sf_classname = '', $post_type = 'post', $do_blocks = false, $block_attrs = [] ) {
87 - if ( isset( $_GET['id'] ) && isset( $_GET['srfm_form_markup_nonce'] ) ) {
88 - $nonce = isset( $_GET['srfm_form_markup_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['srfm_form_markup_nonce'] ) ) : '';
89 - $id = wp_verify_nonce( $nonce, 'srfm_form_markup' ) && ! empty( $_GET['srfm_form_markup_nonce'] ) ? Helper::get_integer_value( sanitize_text_field( wp_unslash( $_GET['id'] ) ) ) : '';
90 - } else {
91 - $id = Helper::get_integer_value( $id );
92 - }
353 + // SECURITY INVARIANT — a renderer must never read the request to decide what to
354 + // render. The caller's `$id` is the only source of truth here; the REST route
355 + // owns request parsing (see render_form_markup_endpoint). Reintroducing any
356 + // query-string override would let a URL change which form a page renders.
357 + $id = Helper::get_integer_value( $id );
93 358
94 359 // Check for any form restrictions.
95 360 $form_id = Helper::get_integer_value( $id );
361 +
362 + // Additively record the form for the admin-bar "Entries" node. The registry
363 + // is primarily seeded at `wp` (collect_queried_form_ids) because the bar
364 + // renders before the_content; this render-time write is what covers paths a
365 + // content parse can't see — page builders (Elementor/Bricks) and FSE template
366 + // parts. Recorded before the restriction check: a restricted form is still on
367 + // the page, and its admin still wants its entries link.
368 + if ( $form_id > 0 ) {
369 + self::$rendered_form_ids[ $form_id ] = true;
370 + }
371 +
96 372 if ( Form_Restriction::is_form_restricted( $form_id ) ) {
97 373 return Form_Restriction::display_form_restriction_message( $form_id );
98 374 }
99 375
@@ -135,11 +411,18 @@
135 411 $form_blocks = ! empty( $form_blocks ) ? $form_blocks : parse_blocks( $content );
136 412 $block_count = count( $form_blocks );
137 413 $current_post_type = get_post_type();
138 414
139 - // load all the frontend assets.
140 - Frontend_Assets::enqueue_scripts_and_styles();
415 + // When enabled, the form renders without the SureForms inline CSS variables so
416 + // the site's own CSS fully controls its appearance. Per-form Custom CSS still applies.
417 + // Read ONCE through the canonical checker so the `srfm_disable_default_styles`
418 + // filter runs a single time per render and governs the enqueue path, the
419 + // marker class and the inline CSS guard alike.
420 + $disable_default_styles = Form_Styling::is_default_styling_disabled( $id );
141 421
422 + // load all the frontend assets. Skips the SureForms stylesheets when the form has default styling disabled.
423 + Frontend_Assets::enqueue_scripts_and_styles( $disable_default_styles );
424 +
142 425 ob_start();
143 426 if ( '' !== $id && 0 !== $block_count ) {
144 427
145 428 // Create unique container ID using blockId if available (for multiple embeds of same form).
@@ -153,8 +436,9 @@
153 436 // Apply per-embed styling customization when formTheme is not 'inherit'.
154 437 if ( Form_Styling::has_custom_styling( $block_attrs ) ) {
155 438 $form_styling = Form_Styling::map_block_attrs_to_styling( $form_styling, $block_attrs );
156 439 }
440 +
157 441 // Background Settings.
158 442 $bg_type = $form_styling['bg_type'] ?? 'color';
159 443 $bg_color = $form_styling['bg_color'] ?? '';
160 444 $bg_image = $form_styling['bg_image'] ?? '';
@@ -240,8 +524,9 @@
240 524 $base_container_class, // Base class for JS compatibility (frontend.js, phone.js).
241 525 ! empty( $block_id_suffix ) ? $container_id : '', // Unique class for CSS scoping when blockId exists.
242 526 $sf_classname,
243 527 'Neve' === $theme_name ? $neve_theme_margin_class_name : '', // compatibility with Neve theme for margin between main content and footer.
528 + $disable_default_styles ? 'srfm-styling-none' : '', // Marker class when default styling is disabled, so custom CSS can target the state.
244 529 $background_classes,
245 530 ];
246 531
247 532 $custom_added_classes = Helper::get_meta_value( $id, '_srfm_additional_classes' );
@@ -253,11 +538,30 @@
253 538 }
254 539 }
255 540 }
256 541
257 - $page_break_settings = defined( 'SRFM_PRO_VER' ) && apply_filters( 'srfm_use_page_break_layout', true ) ? get_post_meta( $id, '_srfm_page_break_settings', true ) : [];
258 - $page_break_settings = ! empty( $page_break_settings ) && is_array( $page_break_settings ) ? $page_break_settings : [];
259 - $is_page_break = ! empty( $page_break_settings ) ? $page_break_settings['is_page_break'] : false;
542 + $page_break_settings = defined( 'SRFM_PRO_VER' ) && apply_filters( 'srfm_use_page_break_layout', true ) ? get_post_meta( $id, '_srfm_page_break_settings', true ) : [];
543 + $page_break_settings = ! empty( $page_break_settings ) && is_array( $page_break_settings ) ? $page_break_settings : [];
544 + $is_page_break = ! empty( $page_break_settings ) ? $page_break_settings['is_page_break'] : false;
545 + // Auto-advance is read here rather than in Pro's button renderer because
546 + // save & resume replaces that whole container through the
547 + // srfm_page_break_buttons_html filter, which would drop the attributes.
548 + // The form tag is rendered exactly once and is already how both step
549 + // runtimes receive their per-form settings (form-id, ajaxurl,
550 + // data-submit-token).
551 + //
552 + // Two stored settings rather than one because the two layouts are
553 + // mutually exclusive: Pro filters srfm_use_page_break_layout to false
554 + // when the conversational layout is on, so $page_break_settings is
555 + // empty there and its editor panel is hidden. Each layout keeps the
556 + // toggle with the rest of its own settings, and only one can apply.
557 + $conversational_settings = defined( 'SRFM_PRO_VER' ) ? get_post_meta( $id, '_srfm_conversational_form', true ) : [];
558 + $conversational_settings = ! empty( $conversational_settings ) && is_array( $conversational_settings ) ? $conversational_settings : [];
559 + $is_conversational = ! empty( $conversational_settings['is_cf_enabled'] );
560 + $active_step_settings = $is_conversational ? $conversational_settings : ( $is_page_break ? $page_break_settings : [] );
561 + $auto_advance_key = $is_conversational ? 'cf_auto_advance' : 'auto_advance';
562 + $auto_advance = ! empty( $active_step_settings[ $auto_advance_key ] );
563 + $auto_advance_hide_next = $auto_advance && ! empty( $active_step_settings[ $auto_advance_key . '_hide_next' ] );
260 564 $page_break_progress_type = ! empty( $page_break_settings ) ? $page_break_settings['progress_indicator_type'] : 'none';
261 565 $form_confirmation = get_post_meta( $id, '_srfm_form_confirmation' );
262 566 $confirmation_type = '';
263 567 $submission_action = '';
@@ -354,13 +658,19 @@
354 658 if ( ! $should_show_submit_button ) {
355 659 $form_classes[] = 'srfm-submit-button-hidden';
356 660 }
357 661
662 + // The scoped Custom CSS below is for embedded views only: on the form's own
663 + // single/instant view, templates/single-form.php already outputs the Custom
664 + // CSS (unscoped) in <head> — emitting it here too would duplicate it.
665 + $embed_custom_css = 'sureforms_form' !== $current_post_type ? $custom_css : '';
358 666 ?>
359 667 <div class="<?php echo esc_attr( implode( ' ', array_filter( $form_classes ) ) ); ?>">
668 + <?php if ( ! $disable_default_styles || '' !== $embed_custom_css ) { // Nothing to print otherwise — avoid an empty style block. ?>
360 669 <style>
361 670 /* Need to check and remove the input variables related to the Style Tab. */
362 671 <?php echo esc_html( ".{$container_id}" ); ?> {
672 + <?php if ( ! $disable_default_styles ) { ?>
363 673 /* New test variables */
364 674 --srfm-color-scheme-primary: <?php echo esc_html( $primary_color_var ); ?>;
365 675 --srfm-color-scheme-text-on-primary: <?php echo esc_html( $label_text_color_var ); ?>;
366 676 --srfm-color-scheme-text: <?php echo esc_html( $help_color_var ); ?>;
@@ -397,9 +707,9 @@
397 707 --srfm-dropdown-icon-color: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.65 );
398 708 --srfm-dropdown-icon-disabled: hsl( from <?php echo esc_html( $help_color_var ); ?> h s l / 0.25 );
399 709
400 710 /* Background Control Variables */
401 - <?php
711 + <?php
402 712 // Form Styles.
403 713 $styling_vars = [
404 714 // Instant Form Padding.
405 715 '--srfm-instant-form-padding-top' => sanitize_text_field( "{$instant_form['padding_top']}{$instant_form['padding_unit']}" ),
@@ -473,33 +783,33 @@
473 783 foreach ( $styling_vars as $key => $value ) {
474 784 echo esc_html( Helper::get_string_value( $key ) ) . ': ' . esc_html( Helper::get_string_value( $value ) ) . ';';
475 785 }
476 786 ?>
477 - <?php
478 - // Echo the CSS variables for the form according to the field spacing selected.
479 - foreach ( $selected_size as $variable => $value ) {
480 - echo esc_html( Helper::get_string_value( $variable ) ) . ': ' . esc_html( Helper::get_string_value( $value ) ) . ';';
481 - }
482 - do_action(
483 - 'srfm_form_css_variables',
484 - [
485 - 'id' => $id,
486 - 'primary_color' => $primary_color_var,
487 - 'help_color' => $help_color_var,
488 - 'form_styling' => $form_styling,
489 - 'block_attrs' => $block_attrs,
490 - ]
491 - );
492 - // echo custom css on page/post.
493 - if ( 'sureforms_form' !== $current_post_type ) {
494 - echo wp_kses_post( $custom_css );
495 - }
787 + <?php
788 + // Echo the CSS variables for the form according to the field spacing selected.
789 + foreach ( $selected_size as $variable => $value ) {
790 + echo esc_html( Helper::get_string_value( $variable ) ) . ': ' . esc_html( Helper::get_string_value( $value ) ) . ';';
791 + }
792 + do_action(
793 + 'srfm_form_css_variables',
794 + [
795 + 'id' => $id,
796 + 'primary_color' => $primary_color_var,
797 + 'help_color' => $help_color_var,
798 + 'form_styling' => $form_styling,
799 + 'block_attrs' => $block_attrs,
800 + ]
801 + );
802 + } // End if default styling is not disabled.
803 + echo wp_kses_post( $embed_custom_css );
496 804 ?>
497 805 }
498 806 </style>
807 + <?php } // End if the style block has content. ?>
499 808 <?php
500 809 if ( 'sureforms_form' !== $current_post_type && true === $show_title_current_page ) {
501 810 $title = ! empty( get_the_title( (int) $id ) ) ? get_the_title( (int) $id ) : '';
811 + $title = String_Translator::get_instance()->translate_form_title( (int) $id, $title );
502 812 ?>
503 813 <h2 class="srfm-form-title"><?php echo esc_html( $title ); ?></h2>
504 814 <?php
505 815 }
@@ -571,12 +881,25 @@
571 881 self::$current_block_attrs = [];
572 882 return ob_get_clean();
573 883 }
574 884 $submit_token = Submit_Token::generate( (int) $id );
885 + // Separately namespaced from the submission token: this one is only good
886 + // for incrementing a view counter, so scraping it from the page buys an
887 + // attacker nothing beyond what the beacon already does, and it cannot be
888 + // replayed against the submit endpoint.
889 + $view_token = Submit_Token::generate( (int) $id, Submit_Token::NAMESPACE_VIEW );
575 890
891 + // Admin-only shortcut into the form editor. Emitted here, immediately
892 + // above the <form>, so it occupies its own row in normal flow and can
893 + // never overlap a field. Already inside the `.srfm-form-container`
894 + // branch, so a zero-block form (no container) never reaches here and
895 + // cannot emit an orphaned pill. Works for every embed method (block,
896 + // shortcode, widget) because they all render through this function.
897 + self::render_edit_form_button( (int) $id );
898 +
576 899 ?>
577 900 <form method="post" enctype="multipart/form-data" id="srfm-form-<?php echo esc_attr( Helper::get_string_value( $id ) ); ?>" class="srfm-form <?php echo esc_attr( 'sureforms_form' === $post_type ? 'srfm-single-form ' : '' ); ?>"
578 - form-id="<?php echo esc_attr( Helper::get_string_value( $id ) ); ?>" after-submission="<?php echo esc_attr( $submission_action ); ?>" message-type="<?php echo esc_attr( $confirmation_type ? $confirmation_type : 'same page' ); ?>" success-url="<?php echo esc_attr( $success_url ? $success_url : '' ); ?>" ajaxurl="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>" data-submit-token="<?php echo esc_attr( $submit_token ); ?>"
901 + form-id="<?php echo esc_attr( Helper::get_string_value( $id ) ); ?>" after-submission="<?php echo esc_attr( $submission_action ); ?>" message-type="<?php echo esc_attr( $confirmation_type ? $confirmation_type : 'same page' ); ?>" success-url="<?php echo esc_attr( $success_url ? $success_url : '' ); ?>" ajaxurl="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>" data-submit-token="<?php echo esc_attr( $submit_token ); ?>" data-view-token="<?php echo esc_attr( $view_token ); ?>"<?php echo $auto_advance ? ' data-srfm-auto-advance="1"' : ''; ?><?php echo $auto_advance_hide_next ? ' data-srfm-hide-next="1"' : ''; ?>
579 902 >
580 903 <?php
581 904 // Submission security is handled via the HMAC token in data-submit-token.
582 905 $global_setting_options = get_option( 'srfm_security_settings_options' );
@@ -791,10 +1114,9 @@
791 1114 'https://challenges.cloudflare.com/turnstile/v0/api.js',
792 1115 [],
793 1116 null,
794 1117 [
795 - false,
796 - 'defer' => true,
1118 + 'strategy' => 'defer',
797 1119 ]
798 1120 );
799 1121 // phpcs:enable WordPress.WP.EnqueuedResourceParameters.MissingVersion, PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent
800 1122 ?>
@@ -1030,6 +1352,203 @@
1030 1352 $redirect_url = html_entity_decode( str_replace( $multi_value_delimiters, '|', $resolved_redirect_url ) );
1031 1353 }
1032 1354
1033 1355 return esc_url_raw( apply_filters( 'srfm_after_submit_redirect_url', $redirect_url ) );
1356 + }
1357 +
1358 + /**
1359 + * Print the admin-only "Edit Form" shortcut on an embedded form.
1360 + *
1361 + * Renders a small pill link that opens the block editor for this form, on its
1362 + * own right-aligned row directly above the form.
1363 + *
1364 + * It sits in normal flow rather than being absolutely positioned over the
1365 + * form's top-right corner, which is what it used to do. An overlay can only
1366 + * avoid the fields when the container happens to have enough top padding —
1367 + * with the default theme styling it landed on top of the first row's last
1368 + * field (#3062). Flow layout cannot overlap anything by construction, at any
1369 + * width, with any theme. The cost is that the form shifts down by the pill's
1370 + * height, which happens only for users who can edit the form; the markup and
1371 + * its styles remain entirely absent from the DOM for everyone else, so no
1372 + * regular visitor sees a layout change.
1373 + *
1374 + * Admin-only by construction: the `sureforms_form` CPT registers with
1375 + * `map_meta_cap => false`, so `edit_post` collapses to a blanket
1376 + * `manage_options` check with no per-post component — an editor never sees the
1377 + * pill on any form. For every other viewer the markup and its styles are
1378 + * entirely absent from the DOM.
1379 + *
1380 + * The stylesheet is attached to a registered inline-only handle so `WP_Styles`
1381 + * dedupes it by handle (surviving a discarded `the_content` pass, e.g. an SEO
1382 + * plugin building `og:description` during `wp_head`) and it survives a strict
1383 + * `style-src` CSP. It is not cache-signalled here: the payload is only a
1384 + * `wp-admin/post.php?post=N` link an anonymous visitor cannot act on, and a
1385 + * `DONOTCACHEPAGE` define from a fragment renderer is both inert on the normal
1386 + * (headers-already-sent) path and an irreversible process-global side effect.
1387 + *
1388 + * @param int $form_id Form post ID.
1389 + *
1390 + * @return void
1391 + * @since 2.12.4
1392 + */
1393 + public static function render_edit_form_button( $form_id ) {
1394 + $form_id = absint( $form_id );
1395 +
1396 + // Only for real SureForms forms — the [sureforms] shortcode accepts any
1397 + // post ID, and a non-form target would map `edit_post` normally and leak
1398 + // the pill to an ordinary editor.
1399 + if ( 0 === $form_id || ! defined( 'SRFM_FORMS_POST_TYPE' ) || SRFM_FORMS_POST_TYPE !== get_post_type( $form_id ) ) {
1400 + return;
1401 + }
1402 +
1403 + // Capability gate first, before the suppression filter, so no work is done
1404 + // for the anonymous visitors who make up almost every page view.
1405 + if ( ! current_user_can( 'edit_post', $form_id ) ) {
1406 + return;
1407 + }
1408 +
1409 + // Contexts where the pill is redundant or wrong:
1410 + // - the single-form / Instant Form page, where the form IS the whole page
1411 + // and the admin bar already links to its editor. This is also what
1412 + // suppresses the block editor's preview — that preview is an iframe to
1413 + // the form's own permalink (an ordinary front-end request), NOT a REST
1414 + // render, so `is_singular` is the load-bearing guard there;
1415 + // - any admin / AJAX / REST / JSON request, or a feed (the markup would
1416 + // otherwise land inside `content:encoded` CDATA).
1417 + if (
1418 + is_singular( SRFM_FORMS_POST_TYPE )
1419 + || is_admin()
1420 + || wp_doing_ajax()
1421 + || wp_is_json_request()
1422 + || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
1423 + || is_feed()
1424 + ) {
1425 + return;
1426 + }
1427 +
1428 + // Page-builder editor canvases render the form directly (not over REST),
1429 + // where their own element-edit handles would collide with the pill.
1430 + // `$instance` is checked as well as the class name: Elementor declares
1431 + // `public static $instance = null` and only populates it on boot, so the
1432 + // class can exist while the singleton is still null. Dereferencing it then
1433 + // is a fatal Error, not a warning, and guarding only on class_exists() left
1434 + // that reachable — test-generate-form-markup.php hit it. The bundled stub
1435 + // types $instance as non-nullable, which is why PHPStan reads the isset()
1436 + // as redundant and has to be told otherwise.
1437 + //
1438 + // ->editor is checked for the same reason one level down: Elementor assigns it
1439 + // in init_components() on `init`, while the singleton itself is created on
1440 + // `plugins_loaded`. Between those two hooks $instance is set and ->editor is
1441 + // still null, so checking only the singleton reproduces the original fatal a
1442 + // property later.
1443 + // @phpstan-ignore-next-line -- Stub disagrees with runtime; see above.
1444 + if ( class_exists( '\Elementor\Plugin' ) && isset( \Elementor\Plugin::$instance->editor ) && \Elementor\Plugin::$instance->editor->is_edit_mode() ) {
1445 + return;
1446 + }
1447 + if ( function_exists( 'bricks_is_builder' ) && bricks_is_builder() ) {
1448 + return;
1449 + }
1450 +
1451 + /**
1452 + * Allow integrations to suppress the admin "Edit Form" shortcut entirely.
1453 + *
1454 + * @param bool $show Whether to render the shortcut. Default true.
1455 + * @param int $form_id Form post ID.
1456 + *
1457 + * @since 2.12.4
1458 + */
1459 + if ( ! apply_filters( 'srfm_show_edit_form_button', true, $form_id ) ) {
1460 + return;
1461 + }
1462 +
1463 + $edit_link = get_edit_post_link( $form_id, 'raw' );
1464 +
1465 + if ( empty( $edit_link ) ) {
1466 + return;
1467 + }
1468 +
1469 + // Attribution marker read back by Admin::maybe_track_edit_form_button_click()
1470 + // when the editor loads. Added before the filter below so an integration that
1471 + // replaces the link wholesale drops the marker with it, rather than having our
1472 + // query arg appended to a third-party URL.
1473 + // 'url' context, not the default 'display': the latter returns &amp;-escaped
1474 + // separators, and feeding those to add_query_arg() only round-trips because
1475 + // build_query() happens to re-emit the mangled `amp;action` key verbatim. The
1476 + // raw form has no such dependency, and esc_url() below still escapes on output.
1477 + $edit_link = add_query_arg( self::EDIT_FORM_BUTTON_SOURCE_ARG, 'embed', $edit_link );
1478 +
1479 + /**
1480 + * Filter the target of the admin "Edit Form" shortcut.
1481 + *
1482 + * @param string $edit_link Editor URL for the form.
1483 + * @param int $form_id Form post ID.
1484 + *
1485 + * @since 2.12.4
1486 + */
1487 + $edit_link = Helper::get_string_value( apply_filters( 'srfm_edit_form_button_link', $edit_link, $form_id ) );
1488 +
1489 + if ( '' === $edit_link ) {
1490 + return;
1491 + }
1492 +
1493 + // Registered inline-only handle: WP_Styles dedupes by handle across every
1494 + // embedded form and prints via print_late_styles() in the footer even when
1495 + // enqueued this late (during the_content).
1496 + $style_handle = 'srfm-edit-form-btn';
1497 + if ( ! wp_style_is( $style_handle, 'registered' ) ) {
1498 + wp_register_style( $style_handle, false, [], SRFM_VER );
1499 + wp_add_inline_style( $style_handle, self::get_edit_form_button_css() );
1500 + }
1501 + wp_enqueue_style( $style_handle );
1502 + ?>
1503 + <div class="srfm-edit-form-btn-wrap">
1504 + <a class="srfm-edit-form-btn" href="<?php echo esc_url( $edit_link ); ?>" target="_blank" rel="noopener noreferrer">
1505 + <svg width="20" height="20" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>
1506 + <span><?php esc_html_e( 'Edit Form', 'sureforms' ); ?></span>
1507 + <span class="screen-reader-text"><?php esc_html_e( '(opens in a new tab)', 'sureforms' ); ?></span>
1508 + </a>
1509 + </div>
1510 + <?php
1511 + }
1512 +
1513 + /**
1514 + * Stylesheet for the admin "Edit Form" pill (#3029).
1515 + *
1516 + * The wrapper is a flow-level flex row rather than an absolute overlay, so the
1517 + * pill reserves its own space and cannot cover a field (#3062). `justify-content`
1518 + * uses the logical `flex-end`, which follows the writing direction and is
1519 + * therefore RTL-correct without a separate rule.
1520 + *
1521 + * No `position: relative` on the container any more: that rule existed solely to
1522 + * be the positioning context for the old overlay.
1523 + *
1524 + * @return string
1525 + * @since 2.12.4
1526 + */
1527 + private static function get_edit_form_button_css() {
1528 + return '
1529 + .srfm-edit-form-btn-wrap {
1530 + display: flex;
1531 + justify-content: flex-end;
1532 + margin-block-end: 8px;
1533 + }
1534 + .srfm-edit-form-btn {
1535 + display: inline-flex;
1536 + align-items: center;
1537 + gap: 6px;
1538 + padding: 6px 12px;
1539 + font-size: 13px;
1540 + font-weight: 500;
1541 + line-height: 1;
1542 + color: #1e293b;
1543 + background: #ffffff;
1544 + border: 1px solid #e2e8f0;
1545 + border-radius: 9999px;
1546 + box-shadow: 0 2px 6px rgba( 0, 0, 0, 0.12 );
1547 + text-decoration: none;
1548 + }
1549 + .srfm-edit-form-btn:hover { border-color: #cbd5e1; color: #0f172a; }
1550 + .srfm-edit-form-btn:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
1551 + .srfm-edit-form-btn svg { width: 14px; height: 14px; }
1552 + ';
1034 1553 }
1035 1554 }