'; /** * Register the category and abilities. * * Forms abilities shipped (see #45998) before the * `jetpack_wp_abilities_enabled` rollout filter existed, so this * deliberately bypasses that gate — backing the `get-responses`, * `update-response`, and `get-status-counts` abilities out behind a * default-off filter would silently break consumers that already * dispatch them. The newer admin abilities ride along for parity: * Forms abilities are uniformly available wherever the package is * loaded. * * @return void */ public static function init() { if ( did_action( self::CATEGORIES_INIT_ACTION ) ) { static::register_category(); } else { add_action( self::CATEGORIES_INIT_ACTION, array( static::class, 'register_category' ) ); } if ( did_action( self::ABILITIES_INIT_ACTION ) ) { static::register_abilities(); } else { add_action( self::ABILITIES_INIT_ACTION, array( static::class, 'register_abilities' ) ); } } /** * {@inheritDoc} */ public static function get_category_slug(): string { return self::CATEGORY_SLUG; } /** * {@inheritDoc} */ public static function get_category_definition(): array { return array( // "Jetpack Forms" is a product name and should not be translated. 'label' => 'Jetpack Forms', 'description' => __( 'Abilities for managing Jetpack Forms and their responses.', 'jetpack-forms' ), ); } /** * {@inheritDoc} */ public static function get_abilities(): array { return array( 'jetpack-forms/list-forms' => self::spec_list_forms(), 'jetpack-forms/get-form' => self::spec_get_form(), 'jetpack-forms/create-form' => self::spec_create_form(), 'jetpack-forms/delete-form' => self::spec_delete_form(), 'jetpack-forms/get-responses' => self::spec_get_responses(), 'jetpack-forms/update-response' => self::spec_update_response(), 'jetpack-forms/bulk-update-responses' => self::spec_bulk_update_responses(), 'jetpack-forms/get-status-counts' => self::spec_get_status_counts(), ); } /* --------------------------------------------------------------------- * Ability specs * --------------------------------------------------------------------- */ /** * Spec: jetpack-forms/list-forms. */ private static function spec_list_forms(): array { return array( 'label' => __( 'List forms (admin)', 'jetpack-forms' ), 'description' => __( 'List all forms with admin detail including response counts, status, and edit URLs. Supports pagination, search, and status filtering.', 'jetpack-forms' ), 'input_schema' => array( 'type' => 'object', 'default' => array(), 'properties' => array( 'page' => array( 'type' => 'integer', 'description' => __( 'Page number for paginated results.', 'jetpack-forms' ), 'default' => 1, 'minimum' => 1, ), 'per_page' => array( 'type' => 'integer', 'description' => __( 'Number of forms per page.', 'jetpack-forms' ), 'default' => 10, 'minimum' => 1, 'maximum' => 100, ), 'search' => array( 'type' => 'string', 'description' => __( 'Search forms by title.', 'jetpack-forms' ), ), 'status' => array( 'type' => 'string', 'description' => __( 'Filter by form status.', 'jetpack-forms' ), 'enum' => self::FORM_STATUSES, ), ), 'additionalProperties' => false, ), 'execute_callback' => array( __CLASS__, 'list_forms' ), 'permission_callback' => array( __CLASS__, 'can_edit_pages' ), 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => true, 'mcp' => array( 'public' => true, 'type' => 'tool', // default is already "tool", but can be explicit. ), ), ); } /** * Spec: jetpack-forms/get-form. */ private static function spec_get_form(): array { return array( 'label' => __( 'Get form details', 'jetpack-forms' ), 'description' => __( 'Get a single form with its full structure including field definitions, status, and edit URL.', 'jetpack-forms' ), 'input_schema' => array( 'type' => 'object', 'required' => array( 'id' ), 'properties' => array( 'id' => array( 'type' => 'integer', 'description' => __( 'The form ID.', 'jetpack-forms' ), ), ), 'additionalProperties' => false, ), 'execute_callback' => array( __CLASS__, 'get_form' ), 'permission_callback' => array( __CLASS__, 'can_edit_pages' ), 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => true, 'mcp' => array( 'public' => true, 'type' => 'tool', // default is already "tool", but can be explicit. ), ), ); } /** * Spec: jetpack-forms/create-form. */ private static function spec_create_form(): array { return array( 'label' => __( 'Create a form', 'jetpack-forms' ), 'description' => __( 'Create a new form with a title. Optionally provide block content for the form structure. Returns the new form ID and edit URL.', 'jetpack-forms' ), 'input_schema' => array( 'type' => 'object', 'required' => array( 'title' ), 'properties' => array( 'title' => array( 'type' => 'string', 'description' => __( 'The form title/name.', 'jetpack-forms' ), ), 'content' => array( 'type' => 'string', 'description' => __( 'Block content for the form structure. If omitted, creates an empty form with a submit button.', 'jetpack-forms' ), ), 'status' => array( 'type' => 'string', 'description' => __( 'Initial form status.', 'jetpack-forms' ), 'enum' => self::CREATE_FORM_STATUSES, 'default' => 'publish', ), ), 'additionalProperties' => false, ), 'execute_callback' => array( __CLASS__, 'create_form' ), 'permission_callback' => array( __CLASS__, 'can_edit_pages' ), 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => false, ), 'show_in_rest' => true, 'mcp' => array( 'public' => true, 'type' => 'tool', // default is already "tool", but can be explicit. ), ), ); } /** * Spec: jetpack-forms/delete-form. */ private static function spec_delete_form(): array { return array( 'label' => __( 'Delete a form', 'jetpack-forms' ), 'description' => __( 'Move a form to the trash. Does not permanently delete. Trashed forms can be restored.', 'jetpack-forms' ), 'input_schema' => array( 'type' => 'object', 'required' => array( 'id' ), 'properties' => array( 'id' => array( 'type' => 'integer', 'description' => __( 'The form ID to delete.', 'jetpack-forms' ), ), ), 'additionalProperties' => false, ), 'execute_callback' => array( __CLASS__, 'delete_form' ), 'permission_callback' => array( __CLASS__, 'can_edit_pages' ), 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => true, 'idempotent' => true, ), 'show_in_rest' => true, 'mcp' => array( 'public' => true, 'type' => 'tool', // default is already "tool", but can be explicit. ), ), ); } /** * Spec: jetpack-forms/get-responses. */ private static function spec_get_responses(): array { return array( 'label' => __( 'Get form responses', 'jetpack-forms' ), 'description' => __( 'List or search form responses. Returns response data including sender info, form fields, and metadata. Supports filtering by status, date range, read state, and search terms.', 'jetpack-forms' ), 'input_schema' => array( 'type' => 'object', 'default' => array(), 'properties' => array( 'ids' => array( 'type' => 'array', 'description' => __( 'Fetch specific responses by their IDs.', 'jetpack-forms' ), 'items' => array( 'type' => 'integer' ), ), 'page' => array( 'type' => 'integer', 'description' => __( 'Page number for paginated results.', 'jetpack-forms' ), 'default' => 1, 'minimum' => 1, ), 'per_page' => array( 'type' => 'integer', 'description' => __( 'Number of responses to return per page.', 'jetpack-forms' ), 'default' => 10, 'minimum' => 1, 'maximum' => 100, ), 'parent' => array( 'type' => 'array', 'description' => __( 'Filter by the page or post ID where the form is embedded.', 'jetpack-forms' ), 'items' => array( 'type' => 'integer' ), ), 'status' => array( 'type' => 'string', 'description' => __( 'Filter by response status.', 'jetpack-forms' ), 'enum' => self::RESPONSE_STATUSES, ), 'is_unread' => array( 'type' => 'boolean', 'description' => __( 'Set true for unread only, false for read only.', 'jetpack-forms' ), ), 'search' => array( 'type' => 'string', 'description' => __( 'Search within response content and sender info.', 'jetpack-forms' ), ), 'before' => array( 'type' => 'string', 'description' => __( 'Only responses before this date (ISO8601 format).', 'jetpack-forms' ), 'format' => 'date-time', ), 'after' => array( 'type' => 'string', 'description' => __( 'Only responses after this date (ISO8601 format).', 'jetpack-forms' ), 'format' => 'date-time', ), ), 'additionalProperties' => false, ), 'execute_callback' => array( __CLASS__, 'get_form_responses' ), 'permission_callback' => array( __CLASS__, 'can_edit_pages' ), 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => true, 'mcp' => array( 'public' => true, 'type' => 'tool', // default is already "tool", but can be explicit. ), ), ); } /** * Spec: jetpack-forms/update-response. */ private static function spec_update_response(): array { return array( 'label' => __( 'Update form response', 'jetpack-forms' ), 'description' => __( 'Modify a form response. Use to mark as spam, move to trash, restore from trash, or toggle read/unread state.', 'jetpack-forms' ), 'input_schema' => array( 'type' => 'object', 'required' => array( 'id' ), 'properties' => array( 'id' => array( 'type' => 'integer', 'description' => __( 'The response ID to update.', 'jetpack-forms' ), ), 'status' => array( 'type' => 'string', 'description' => __( 'New status: "publish" (restore), "spam" (mark spam), "trash" (soft delete).', 'jetpack-forms' ), 'enum' => self::RESPONSE_STATUSES, ), 'is_unread' => array( 'type' => 'boolean', 'description' => __( 'Set false to mark as read, true to mark as unread.', 'jetpack-forms' ), ), ), 'additionalProperties' => false, ), 'execute_callback' => array( __CLASS__, 'update_form_response' ), 'permission_callback' => array( __CLASS__, 'can_edit_pages' ), 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => true, 'mcp' => array( 'public' => true, 'type' => 'tool', // default is already "tool", but can be explicit. ), ), ); } /** * Spec: jetpack-forms/bulk-update-responses. */ private static function spec_bulk_update_responses(): array { return array( 'label' => __( 'Bulk update form responses', 'jetpack-forms' ), 'description' => __( 'Mark multiple responses as spam (or restore from spam) in a single call. Each response is processed individually; the result reports per-id success and any per-id failures so callers can see exactly which responses were updated. Also teaches Akismet from the successful updates.', 'jetpack-forms' ), 'input_schema' => array( 'type' => 'object', 'required' => array( 'action', 'ids' ), 'properties' => array( 'action' => array( 'type' => 'string', 'description' => __( 'The bulk action to perform.', 'jetpack-forms' ), 'enum' => self::BULK_ACTIONS, ), 'ids' => array( 'type' => 'array', 'description' => __( 'Response IDs to update.', 'jetpack-forms' ), 'items' => array( 'type' => 'integer' ), 'minItems' => 1, ), ), 'additionalProperties' => false, ), 'execute_callback' => array( __CLASS__, 'bulk_update_responses' ), 'permission_callback' => array( __CLASS__, 'can_edit_pages' ), 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => true, 'mcp' => array( 'public' => true, 'type' => 'tool', // default is already "tool", but can be explicit. ), ), ); } /** * Spec: jetpack-forms/get-status-counts. */ private static function spec_get_status_counts(): array { return array( 'label' => __( 'Get response status counts', 'jetpack-forms' ), 'description' => __( 'Get a summary of form responses grouped by status. Returns counts for inbox (active), spam, and trash. Useful for dashboard stats or checking if there are new responses.', 'jetpack-forms' ), 'input_schema' => array( 'type' => 'object', 'default' => array(), 'properties' => array( 'search' => array( 'type' => 'string', 'description' => __( 'Only count responses matching this search term.', 'jetpack-forms' ), ), 'parent' => array( 'type' => 'integer', 'description' => __( 'Only count responses from a specific page or post.', 'jetpack-forms' ), ), 'before' => array( 'type' => 'string', 'description' => __( 'Only count responses before this date (ISO8601 format).', 'jetpack-forms' ), 'format' => 'date-time', ), 'after' => array( 'type' => 'string', 'description' => __( 'Only count responses after this date (ISO8601 format).', 'jetpack-forms' ), 'format' => 'date-time', ), 'is_unread' => array( 'type' => 'boolean', 'description' => __( 'Set true to count only unread, false for only read.', 'jetpack-forms' ), ), ), 'additionalProperties' => false, ), 'execute_callback' => array( __CLASS__, 'get_status_counts' ), 'permission_callback' => array( __CLASS__, 'can_edit_pages' ), 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => true, 'mcp' => array( 'public' => true, 'type' => 'tool', // default is already "tool", but can be explicit. ), ), ); } /* --------------------------------------------------------------------- * Permission callbacks * --------------------------------------------------------------------- */ /** * Permission callback shared by every Forms ability. The delegated REST * controller re-checks the user's edit permission per-route, so this is * a coarse early-rejection gate, not the authoritative authorization. * * @return bool */ public static function can_edit_pages() { return current_user_can( 'edit_pages' ); } /* --------------------------------------------------------------------- * Execute callbacks * --------------------------------------------------------------------- */ /** * Execute: list-forms. * * Delegates to GET /wp/v2/jetpack-forms with dashboard context, then * reshapes to a compact format for AI consumption. * * @param array $args Arguments from the ability input. * @return array|\WP_Error */ public static function list_forms( $args = array() ) { $args = is_array( $args ) ? $args : array(); $request = new \WP_REST_Request( 'GET', '/wp/v2/jetpack-forms' ); $request->set_param( 'jetpack_forms_context', 'dashboard' ); self::set_params_from_args( $request, $args, array( 'page', 'per_page', 'search', 'status' ) ); $data = self::dispatch( $request ); if ( is_wp_error( $data ) ) { return $data; } if ( ! is_array( $data ) ) { return array(); } $result = array(); foreach ( $data as $form ) { $result[] = array( 'id' => $form['id'], 'title' => $form['title']['rendered'] ?? '', 'status' => $form['status'], 'entries_count' => $form['entries_count'] ?? 0, 'edit_url' => $form['edit_url'] ?? '', 'date' => $form['date'], 'modified' => $form['modified'], ); } return $result; } /** * Execute: get-form. * * Delegates to GET /wp/v2/jetpack-forms/{id} with `context=edit` so the * raw block content comes back in the response — no second `get_post()` * fetch needed. * * @param array $args Arguments from the ability input. * @return array|\WP_Error */ public static function get_form( $args ) { if ( ! isset( $args['id'] ) ) { return new \WP_Error( 'missing_id', __( 'Form ID is required.', 'jetpack-forms' ) ); } $request = new \WP_REST_Request( 'GET', '/wp/v2/jetpack-forms/' . absint( $args['id'] ) ); $request->set_param( 'context', 'edit' ); $data = self::dispatch( $request ); if ( is_wp_error( $data ) ) { return $data; } $raw_content = $data['content']['raw'] ?? ''; return array( 'id' => $data['id'], 'title' => $data['title']['raw'] ?? $data['title']['rendered'] ?? '', 'status' => $data['status'], 'fields' => self::extract_fields_from_content( $raw_content ), 'date' => $data['date'], 'modified' => $data['modified'], 'edit_url' => $data['link'] ?? get_edit_post_link( $data['id'], 'raw' ), ); } /** * Execute: create-form. * * @param array $args Arguments from the ability input. * @return array|\WP_Error */ public static function create_form( $args ) { if ( empty( $args['title'] ) ) { return new \WP_Error( 'missing_title', __( 'Form title is required.', 'jetpack-forms' ) ); } $content = $args['content'] ?? ''; if ( '' === $content ) { $content = self::DEFAULT_FORM_CONTENT; } $request = new \WP_REST_Request( 'POST', '/wp/v2/jetpack-forms' ); $request->set_body_params( array( 'title' => $args['title'], 'content' => $content, 'status' => $args['status'] ?? 'publish', ) ); $data = self::dispatch( $request ); if ( is_wp_error( $data ) ) { return $data; } return array( 'id' => $data['id'], 'title' => $data['title']['raw'] ?? $data['title']['rendered'] ?? '', 'status' => $data['status'], 'edit_url' => get_edit_post_link( $data['id'], 'raw' ), ); } /** * Execute: delete-form. * * @param array $args Arguments from the ability input. * @return array|\WP_Error */ public static function delete_form( $args ) { if ( ! isset( $args['id'] ) ) { return new \WP_Error( 'missing_id', __( 'Form ID is required.', 'jetpack-forms' ) ); } $request = new \WP_REST_Request( 'DELETE', '/wp/v2/jetpack-forms/' . absint( $args['id'] ) ); $data = self::dispatch( $request ); if ( is_wp_error( $data ) ) { return $data; } return array( 'id' => $data['id'] ?? absint( $args['id'] ), 'deleted' => true, 'status' => $data['status'] ?? 'trash', ); } /** * Execute: get-responses. * * @param array $args Arguments from the ability input. * @return array|\WP_Error */ public static function get_form_responses( $args = array() ) { $args = is_array( $args ) ? $args : array(); $request = new \WP_REST_Request( 'GET', '/wp/v2/feedback' ); self::set_params_from_args( $request, $args, array( 'page', 'per_page', 'parent', 'status', 'is_unread', 'search', 'before', 'after' ) ); if ( isset( $args['ids'] ) && is_array( $args['ids'] ) ) { $request->set_param( 'include', $args['ids'] ); } return self::dispatch( $request ); } /** * Execute: update-response. * * @param array $args Arguments from the ability input. * @return array|\WP_Error */ public static function update_form_response( $args ) { if ( ! isset( $args['id'] ) ) { return new \WP_Error( 'missing_id', __( 'Response ID is required.', 'jetpack-forms' ) ); } $id = absint( $args['id'] ); $result = array(); if ( isset( $args['status'] ) ) { $request = new \WP_REST_Request( 'POST', '/wp/v2/feedback/' . $id ); $request->set_body_params( array( 'status' => $args['status'] ) ); $data = self::dispatch( $request ); if ( is_wp_error( $data ) ) { return $data; } $result = $data; } if ( isset( $args['is_unread'] ) ) { $request = new \WP_REST_Request( 'POST', '/wp/v2/feedback/' . $id . '/read' ); $request->set_body_params( array( 'is_unread' => $args['is_unread'] ) ); $data = self::dispatch( $request ); if ( is_wp_error( $data ) ) { return $data; } $result = array_merge( $result, $data ); } return $result; } /** * Execute: bulk-update-responses. * * The `/wp/v2/feedback/bulk_actions` REST endpoint only teaches Akismet — * it does not change post status. The dashboard handles bulk spam/not-spam * by issuing per-id status updates first, then calling bulk_actions to * teach Akismet from the successful flips. We mirror that here so callers * get a faithful per-id confirmation: each id either lands in `succeeded` * or in `failed` with the underlying error code/message. * * @param array $args Arguments from the ability input. * @return array|\WP_Error */ public static function bulk_update_responses( $args ) { if ( empty( $args['action'] ) || empty( $args['ids'] ) || ! is_array( $args['ids'] ) ) { return new \WP_Error( 'missing_params', __( 'Action and IDs are required.', 'jetpack-forms' ) ); } $action = $args['action']; $target_status = 'mark_as_spam' === $action ? 'spam' : 'publish'; $ids = array_values( array_unique( array_map( 'absint', $args['ids'] ) ) ); $succeeded = array(); $failed = array(); foreach ( $ids as $id ) { if ( $id <= 0 ) { $failed[] = array( 'id' => $id, 'code' => 'invalid_id', 'message' => __( 'Response IDs must be positive integers.', 'jetpack-forms' ), ); continue; } $request = new \WP_REST_Request( 'POST', '/wp/v2/feedback/' . $id ); $request->set_body_params( array( 'status' => $target_status ) ); $data = self::dispatch( $request ); if ( is_wp_error( $data ) ) { $failed[] = array( 'id' => $id, 'code' => $data->get_error_code(), 'message' => $data->get_error_message(), ); continue; } $succeeded[] = $id; } // Teach Akismet from the responses whose status actually flipped. // Akismet learning is best-effort and independent of the status update, // so a failure here doesn't roll back the per-id changes above. if ( ! empty( $succeeded ) ) { $teach = new \WP_REST_Request( 'POST', '/wp/v2/feedback/bulk_actions' ); $teach->set_body_params( array( 'action' => $action, 'post_ids' => $succeeded, ) ); self::dispatch( $teach ); } return array( 'action' => $action, 'succeeded' => $succeeded, 'failed' => $failed, ); } /** * Execute: get-status-counts. * * @param array $args Arguments from the ability input. * @return array|\WP_Error */ public static function get_status_counts( $args = array() ) { $args = is_array( $args ) ? $args : array(); $request = new \WP_REST_Request( 'GET', '/wp/v2/feedback/counts' ); self::set_params_from_args( $request, $args, array( 'search', 'parent', 'before', 'after', 'is_unread' ) ); return self::dispatch( $request ); } /* --------------------------------------------------------------------- * Helpers * --------------------------------------------------------------------- */ /** * Dispatch an internal REST request and unwrap the response. * * @param \WP_REST_Request $request The REST request to dispatch. * @return array|\WP_Error Response data array, or WP_Error on failure. */ private static function dispatch( $request ) { $response = rest_do_request( $request ); if ( $response->is_error() ) { return $response->as_error(); } return $response->get_data(); } /** * Copy a whitelisted subset of `$args` onto a REST request as parameters. * * @param \WP_REST_Request $request Target request. * @param array $args Caller-supplied arguments. * @param array $keys Allowed keys to forward. */ private static function set_params_from_args( \WP_REST_Request $request, array $args, array $keys ): void { foreach ( $keys as $key ) { if ( isset( $args[ $key ] ) ) { $request->set_param( $key, $args[ $key ] ); } } } /** * Extract field definitions from raw block content. * * Walks `jetpack/field-*` blocks and projects each into a compact * `{ label, type, required, options?, placeholder?, help_text? }` shape. Modern * field blocks store the label/placeholder in `jetpack/label` and * `jetpack/input` sub-blocks; legacy fixtures keep them inline as * top-level attrs. Both layouts are supported. * * @param string $raw_content Raw block content (typically `content.raw` from REST). * @return array */ private static function extract_fields_from_content( string $raw_content ): array { if ( '' === $raw_content ) { return array(); } $fields = array(); self::collect_field_blocks( parse_blocks( $raw_content ), $fields ); return $fields; } /** * Recursively walk parsed blocks and append field definitions to `$fields`. * * Field blocks are not recursed into — their inner blocks are layout * sub-blocks (`jetpack/label`, `jetpack/input`), not nested fields. * Non-field containers (columns, groups, the contact-form block itself) * are recursed so fields nested inside them still get picked up. * * @param array $blocks Parsed blocks. * @param array $fields Reference to the fields array being built. */ private static function collect_field_blocks( array $blocks, array &$fields ): void { foreach ( $blocks as $block ) { $name = $block['blockName'] ?? ''; if ( strpos( $name, 'jetpack/field-' ) === 0 ) { $summary = self::summarize_field_block( $block ); if ( null !== $summary ) { $fields[] = $summary; } continue; } if ( ! empty( $block['innerBlocks'] ) ) { self::collect_field_blocks( $block['innerBlocks'], $fields ); } } } /** * Project a single `jetpack/field-*` block into the compact field shape. * * @param array $block Parsed block array. * @return array|null Field summary, or null if the block has no usable label. */ private static function summarize_field_block( array $block ): ?array { $attrs = $block['attrs'] ?? array(); $inner_attrs = self::collect_inner_attrs( $block['innerBlocks'] ?? array() ); $label_attrs = $inner_attrs['jetpack/label'] ?? array(); $input_attrs = $inner_attrs['jetpack/input'] ?? array(); $label = (string) ( $label_attrs['label'] ?? $attrs['label'] ?? '' ); if ( '' === $label ) { return null; } $field = array( 'label' => $label, 'type' => str_replace( 'jetpack/field-', '', (string) ( $block['blockName'] ?? '' ) ), 'required' => ! empty( $attrs['required'] ), ); if ( ! empty( $attrs['options'] ) ) { $field['options'] = $attrs['options']; } $placeholder = $input_attrs['placeholder'] ?? $attrs['placeholder'] ?? ''; if ( '' !== $placeholder ) { $field['placeholder'] = $placeholder; } $help_text = trim( (string) ( $attrs['helpText'] ?? '' ) ); if ( '' !== $help_text ) { $field['help_text'] = $help_text; } return $field; } /** * Build a `block_name => attrs` lookup from the field's direct children. * * @param array $inner_blocks Direct children of a field block. * @return array */ private static function collect_inner_attrs( array $inner_blocks ): array { $out = array(); foreach ( $inner_blocks as $child ) { $name = $child['blockName'] ?? ''; if ( '' !== $name && ! isset( $out[ $name ] ) ) { $out[ $name ] = $child['attrs'] ?? array(); } } return $out; } }