serializer = $serializer ?? new BlockSerializer(); $this->policy = $policy ?? new BlockPolicy(); } /** * Resolve the content string a write ability should store. * * On a classic-editor target a non-empty structured `blocks` field is * rejected (the classic editor stores plain HTML, so block specs make no * sense) and the raw `content` HTML is passed straight through. On a * block-editor target the input is serialized to valid block markup — * structured `blocks` take precedence over the `content` string — the * allowed-block set is enforced (exempting blocks already present in the post * on Update), and any fatal block issue aborts with a WP_Error while * recoverable warnings ride along in `block_issues`. * * @param array $args Ability input. Reads `content` (string) and `blocks` (array of specs). * @param string $post_type Post type slug for the editor context ('post' or 'page'). * @param int|null $post_id Target post ID on Update, null on Create. * * @return array{content: string, block_issues: array}|WP_Error Resolved content and warnings, or a WP_Error to return instead of saving. * @since 1.2.0 */ public function resolve( array $args, string $post_type, ?int $post_id = null ): array|WP_Error { $has_blocks_input = ! empty( $args['blocks'] ) && is_array( $args['blocks'] ); if ( ! EditorMode::is_block_editor( $post_type, $post_id ) ) { return $this->resolve_classic( $args, $post_type, $has_blocks_input ); } return $this->resolve_block( $args, $post_type, $post_id, $has_blocks_input ); } /** * Resolve the content string a block pattern (a wp_block) should store. * * A pattern is always block content, so there is no classic-editor branch: it * is not tied to a post type and is not narrowed to a post's or page's allowed * block set. Enforcement runs against the site-wide allowed set (a block a site * removes globally is still rejected), and serialization and warning handling * are the same as the post/page block path. * * @param array $args Ability input. Reads `content` (string) and `blocks` (array of specs). * @param int|null $post_id Target wp_block ID on Update, null on Create. * * @return array{content: string, block_issues: array}|WP_Error Resolved content and warnings, or a WP_Error to return instead of saving. * @since 1.5.0 */ public function resolve_pattern( array $args, ?int $post_id = null ): array|WP_Error { $has_blocks_input = ! empty( $args['blocks'] ) && is_array( $args['blocks'] ); return $this->resolve_block( $args, null, $post_id, $has_blocks_input ); } /** * Resolve content for a classic-editor target. * * Structured `blocks` do not apply to the classic editor, so a non-empty * `blocks` field is rejected up front. Otherwise the raw `content` HTML is * returned verbatim (the REST endpoint still runs wp_kses_post per the user's * caps) and there are never any block issues. * * @param array $args Ability input. * @param string $post_type Post type slug. * @param bool $has_blocks_input Whether a non-empty `blocks` field was sent. * * @return array{content: string, block_issues: array}|WP_Error Resolved content, or a WP_Error. * @since 1.2.0 */ private function resolve_classic( array $args, string $post_type, bool $has_blocks_input ): array|WP_Error { if ( $has_blocks_input ) { return new WP_Error( 'classic_editor_blocks_unsupported', sprintf( /* translators: %s: post type label (e.g. post or page). */ __( 'This %s uses the classic editor. Send HTML in the `content` field instead of structured `blocks`.', 'albert-ai-butler' ), $post_type ), [ 'status' => 400 ] ); } // Return the raw HTML as-is. It is NOT sanitised here on purpose: the write // ability persists it through the REST endpoint (wp_insert_post), which runs // capability-aware KSES (wp_filter_post_kses for users without // `unfiltered_html`) — the same protection the classic editor relies on. // Re-running wp_kses_post() here would ignore the user's capability and strip // HTML privileged users are allowed to store. Keep persistence on the REST // path so that KSES layer always applies. return [ 'content' => (string) ( $args['content'] ?? '' ), 'block_issues' => [], ]; } /** * Resolve content for a block-editor target. * * Structured `blocks` take precedence over the `content` string; both routes * go through {@see BlockSerializer::serialize_with_issues()} (specs → markup, * or a string → BlockConverter fallback for HTML/Markdown). Allowed-block * enforcement runs on submitted specs, exempting block names already present * in the post on Update. Enforcement and serializer errors both abort the * save; only warnings ride along. * * @param array $args Ability input. * @param string|null $post_type Post type slug for the editor context, or null for the site-wide (pattern) scope. * @param int|null $post_id Target post ID on Update, null on Create. * @param bool $has_blocks_input Whether a non-empty `blocks` field was sent. * * @return array{content: string, block_issues: array}|WP_Error Resolved content, or a WP_Error. * @since 1.2.0 */ private function resolve_block( array $args, ?string $post_type, ?int $post_id, bool $has_blocks_input ): array|WP_Error { $policy_issues = []; if ( $has_blocks_input ) { $exempt = $post_id ? $this->policy->existing_block_names( $post_id ) : []; $policy_issues = $this->policy->enforce( $args['blocks'], $post_type, $post_id, $exempt ); $serialized = $this->serializer->serialize_with_issues( $args['blocks'] ); } else { $serialized = $this->serializer->serialize_with_issues( (string) ( $args['content'] ?? '' ) ); } // Enforcement errors and serializer errors both abort the save; only // warnings ride along. $block_error = BlockIssues::to_wp_error( array_merge( $policy_issues, $serialized['issues'] ) ); if ( $block_error instanceof WP_Error ) { return $block_error; } return [ 'content' => $serialized['markup'], 'block_issues' => BlockIssues::warning_messages( $serialized['issues'] ), ]; } }