` markup and offers a one-click conversion to a SureForms form. * * This is the free-plugin prototype: detection + UI only. The conversion * callback in the JS layer currently parses locally and logs the result; * the AI-assisted REST endpoint that actually creates the form lives in a * follow-up patch so the detection wiring can be validated independently. * * Script is enqueued when: * - User can manage SureForms forms (same `manage_options` gate as the * rest of the form-admin surface — there is no point offering a CTA to * users who cannot create forms). * - Current screen is the block editor and not the SureForms form CPT * (we never run on the form editor itself — the source `
` only * appears on host posts/pages). * * @package sureforms. */ namespace SRFM\Inc\Admin; use SRFM\Inc\Abilities\Forms\Create_Form; use SRFM\Inc\AI_Form_Builder\AI_Helper; use SRFM\Inc\Helper; use SRFM\Inc\Traits\Get_Instance; use WP_Error; use WP_REST_Request; use WP_REST_Server; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * HTML form detector handler. * * @since 2.10.0 */ class Html_Form_Detector { use Get_Instance; /** * Confidence level below which we route the raw HTML through the AI * middleware instead of trusting the local parser output. * * @since 2.10.0 */ public const AI_FALLBACK_CONFIDENCE = 'low'; /** * Hard cap on the size of raw HTML accepted by the conversion endpoint. * * Anything larger is almost certainly the entire page rather than a * single `` and would waste an AI roundtrip on noise. Matches the * upper bound the AI middleware tolerates for `query` payloads. * * @since 2.10.0 */ public const MAX_HTML_BYTES = 32768; /** * Constructor. * * Registers the REST route only on admin / REST-dispatch requests * — that is the only context where the route is reachable, and * gating registration narrows the blast radius if the shared * `Helper::get_items_permissions_check` is ever loosened by an * unrelated change. The endpoint also re-checks `manage_options` * inside the handler so authorization survives both contexts. * * @since 2.10.0 */ public function __construct() { add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); // Register the REST endpoint unconditionally. The constructor runs on // the `init` hook (see plugin-loader.php), which fires *before* // `parse_request` — the point at which WordPress defines // `REST_REQUEST`. Gating on `REST_REQUEST` here meant the filter was // never attached for the actual REST dispatch, and the endpoint 404'd. // `apply_filters( 'srfm_rest_api_endpoints', ... )` is only invoked // from `Rest_Api::register_endpoints()` on `rest_api_init`, so // attaching this filter on non-REST requests has no runtime cost. add_filter( 'srfm_rest_api_endpoints', [ $this, 'register_rest_endpoint' ] ); } /** * Decide whether the detector script should be loaded for the current request. * * @since 2.10.0 * @return bool */ public function allow_load() { if ( ! is_admin() ) { return false; } // Gate on the cap required to actually manage SureForms forms — same // rationale as the Editor_Nudge: never surface a "Convert to // SureForms" CTA to a user who cannot reach the form-creation flow. if ( ! Helper::current_user_can( 'manage_options' ) ) { return false; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; if ( ! $screen || ! method_exists( $screen, 'is_block_editor' ) || ! $screen->is_block_editor() ) { return false; } // Skip on the SureForms form editor itself — the source `` // markup we look for only appears on host posts/pages. if ( SRFM_FORMS_POST_TYPE === $screen->post_type ) { return false; } return true; } /** * Enqueue the detector script when allowed. * * @since 2.10.0 * @return void */ public function enqueue_scripts() { if ( ! $this->allow_load() ) { return; } $handle = SRFM_SLUG . '-html-form-detector'; $asset_path = SRFM_DIR . 'assets/build/htmlFormDetector.asset.php'; $asset = file_exists( $asset_path ) ? include $asset_path : [ 'dependencies' => [ 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n' ], 'version' => SRFM_VER, ]; wp_enqueue_script( $handle, SRFM_URL . 'assets/build/htmlFormDetector.js', $asset['dependencies'], $asset['version'], true ); wp_localize_script( $handle, 'srfm_html_form_detector', [ 'rest_nonce' => wp_create_nonce( 'wp_rest' ), ] ); Helper::register_script_translations( $handle ); } /** * Register the conversion REST endpoint on the existing SureForms route map. * * Hooked into `srfm_rest_api_endpoints` so we land alongside the other * `sureforms/v1/*` routes without touching `Rest_Api::get_endpoints()` — * keeping every concern of the detector co-located in this class. * * @since 2.10.0 * @param array> $endpoints Existing endpoints map. * @return array> */ public function register_rest_endpoint( $endpoints ) { if ( ! is_array( $endpoints ) ) { $endpoints = []; } $endpoints['convert-html-form'] = [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'handle_convert_html_form' ], 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ], 'args' => [ 'parsed_fields' => [ 'required' => false, 'type' => 'array', 'description' => __( 'Array of fields produced by the editor-side parser.', 'sureforms' ), ], 'submit_text' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ], 'confidence' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'high', ], 'html' => [ 'required' => false, 'type' => 'string', 'description' => __( 'Raw HTML of the source . Required when parser confidence is low so we can hand the markup to the AI middleware.', 'sureforms' ), ], 'form_title' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ], 'styling' => [ 'required' => false, 'type' => 'object', 'description' => __( 'Best-effort styling descriptor (hex colors) extracted from inline styles on the source .', 'sureforms' ), ], ], ]; return $endpoints; } /** * Convert a raw HTML form into a SureForms form. * * Flow: * - If the editor-side parser returned `confidence === 'low'` AND raw * HTML is supplied, send the HTML to the AI middleware and use the * structured schema it returns (hybrid path — AI handles markup the * deterministic parser could not confidently classify). * - Otherwise trust the parsed fields and pass them straight to the * existing `Create_Form` ability so the same code that creates AI- / * MCP-generated forms also handles this conversion. Means a single * code path produces the final `sureforms_form` CPT; no parallel * insert logic to maintain. * * @since 2.10.0 * @param WP_REST_Request $request REST request. * @return array|\WP_Error */ public function handle_convert_html_form( $request ) { // Capability check runs first — cheaper than `wp_verify_nonce`, // and the REST framework's `permission_callback` already passed // at this point, so a failure here means a `current_user_can` // filter or capability-mapping shim was loosened between the // permission callback and the handler. Bail early to keep // the expensive AI middleware path off the table for users who // could not legitimately complete the action. if ( ! Helper::current_user_can( 'manage_options' ) ) { return new WP_Error( 'srfm_html_convert_forbidden', __( 'You are not allowed to convert HTML forms.', 'sureforms' ), [ 'status' => 403 ] ); } // Nonce check is CSRF defense for the legitimate // `manage_options` user we just confirmed. Running it after the // cap check means cap-less requests never pay the // `hash_hmac`/session-lookup cost of nonce verification, and // the error-code precedence is also more honest: a subscriber // without `manage_options` gets `forbidden`, not the misleading // `nonce_failed`. $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) ); if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) { return new WP_Error( 'srfm_html_convert_nonce_failed', __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ), [ 'status' => 403 ] ); } $raw_fields = $request->get_param( 'parsed_fields' ); $confidence = Helper::get_string_value( $request->get_param( 'confidence' ) ); $raw_html = Helper::get_string_value( $request->get_param( 'html' ) ); $form_title = Helper::get_string_value( $request->get_param( 'form_title' ) ); $submit_text = Helper::get_string_value( $request->get_param( 'submit_text' ) ); $styling_in = $request->get_param( 'styling' ); $styling_in = is_array( $styling_in ) ? $styling_in : []; $used_ai = false; if ( '' === $form_title ) { $form_title = __( 'Converted form', 'sureforms' ); } // AI fallback path. We only invoke the middleware when the local // parser flagged the input as ambiguous AND the caller actually // supplied raw HTML — otherwise there is nothing useful to send. if ( self::AI_FALLBACK_CONFIDENCE === $confidence && '' !== $raw_html ) { if ( strlen( $raw_html ) > self::MAX_HTML_BYTES ) { return new WP_Error( 'srfm_html_convert_too_large', __( 'The HTML form is too large to convert. Please simplify the markup or build the form manually.', 'sureforms' ), [ 'status' => 413 ] ); } $ai_fields = $this->extract_fields_via_ai( $raw_html ); if ( is_wp_error( $ai_fields ) ) { return $ai_fields; } $raw_fields = $ai_fields; $used_ai = true; } if ( ! is_array( $raw_fields ) || empty( $raw_fields ) ) { return new WP_Error( 'srfm_html_convert_no_fields', __( 'No fields could be derived from the supplied form.', 'sureforms' ), [ 'status' => 400 ] ); } // Strip the parser-internal hints (`_groupName`, `_optionValue`, // `confidence`) before handing fields to Create_Form — the schema // for that ability rejects unknown keys via additionalProperties. $clean_fields = $this->strip_internal_hints( $raw_fields ); /** * Filter the field list before handing it to `Create_Form`. * * Lets extensions (notably SureForms Pro) re-inspect the raw * source HTML and refine the parsed fields — e.g. promote a * `` from a plain `input` to a `date-picker` * block when the pro field type is registered. The JS parser * cannot do this on its own because the pro field types are * only valid when the pro plugin is active; gating that on the * server is simpler and avoids leaking pro-specific behavior * into the public block-editor bundle. * * @since 2.10.0 * @param array> $clean_fields Sanitized field list ready for Create_Form. * @param string $raw_html Original HTML of the source `` block. * @param string $confidence Parser confidence (`high`/`medium`/`low`). * * SECURITY CONTRACT: callbacks MUST return values already * sanitized for storage as block attributes. `Create_Form` * re-sanitizes a hardcoded list of properties (label, * placeholder, helpText, defaultValue, fieldOptions), but any * property a callback introduces beyond that set — a pro * field's `allowedFormats`, `dateFormat`, `step`, etc. — is NOT * covered by the downstream sanitization pass. Strings should * pass through `sanitize_text_field` / `wp_kses_post`, scalars * through `absint` / `floatval`, arrays should have each leaf * sanitized. The defensive `strip_unsafe_html_in_fields` pass * below catches obvious raw-tag injection but is not a * substitute for proper per-property sanitization. */ $clean_fields = apply_filters( 'srfm_html_form_detector_refine_fields', $clean_fields, $raw_html, $confidence ); // Defensive post-filter sweep: strip raw HTML tags from every // string leaf in every field property. The filter contract // above documents that callbacks must sanitize, but a sloppy // callback could re-introduce attacker markup in properties // `Create_Form` does not know to clean — at which point a // later block renderer that emits the attribute as inner HTML // becomes a stored-XSS sink. Stripping tags here is a narrow // safety net that loses no legitimate value: form-field // attributes are not HTML containers. $clean_fields = $this->strip_unsafe_html_in_fields( $clean_fields ); $create_form = new Create_Form(); $result = $create_form->execute( [ 'formTitle' => $form_title, 'formFields' => $clean_fields, 'formStatus' => 'publish', 'formMetaData' => $this->build_form_metadata( $submit_text, $styling_in ), ] ); if ( is_wp_error( $result ) ) { return $result; } // Layer in the native form-card styling (background, padding, // border radius). These live in `_srfm_forms_styling` and are // exposed in the per-form Styling sidebar — the same UI users get // when they build a form by hand — so populating them keeps the // converted form fully editable post-creation instead of locking // the look behind opaque custom CSS. $form_id = isset( $result['form_id'] ) ? Helper::get_integer_value( $result['form_id'] ) : 0; if ( $form_id > 0 ) { $this->apply_native_card_styling( $form_id, $styling_in ); /** * Fires after the converter writes its baseline form * metadata, giving extensions a chance to layer in * additional `_srfm_forms_styling` keys — e.g. a pro * `form_theme` preset chosen from inline-style hints. * * @since 2.10.0 * @param int $form_id Newly-created SureForms form ID. * @param array $styling Parser styling descriptor (inline-style hints). * @param string $raw_html Original HTML of the source `` block. */ do_action( 'srfm_html_form_detector_after_styling', $form_id, $styling_in, $raw_html ); } // Compute the markup that survives once the source `` is // removed from the original block — wrapping `
`s, a // heading above the form, a post-submit paragraph below it, // inline `