). * * @return string */ function aafm_write_content_description(): string { return __( 'Gutenberg block markup. Put every bit of styling inside the block delimiter attributes, never as inline style="" on the HTML, or the editor flags the block as invalid content. Right:

Hi

. Wrong:

Hi

. Use a core/html block for markup no block matches. Stored content loses inline rgba() and hex-with-alpha colors, inline , and
` is just a * layout element. So for core/button we target that anchor and fall back to the first element if it * is absent. We deliberately do NOT scan arbitrary descendants: an inline-styled `` or `` * inside paragraph or heading text is legitimate content that cannot be a block attribute. * * @param string $html The block's innerHTML. * @param string $block_name The block name, used to pick the styling element. * @return array{classes:list,style:string} */ function aafm_block_root_markup( string $html, string $block_name = '' ): array { // core/button carries its block-supports styling on the inner button link, not the wrapper div. if ( 'core/button' === $block_name && 1 === preg_match( '/]*\bclass\s*=\s*(?:"[^"]*wp-block-button__link[^"]*"|\'[^\']*wp-block-button__link[^\']*\')[^>]*)>/i', $html, $anchor ) ) { return aafm_extract_tag_attrs( $anchor[1] ); } if ( 1 !== preg_match( '/<([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/', $html, $tag ) ) { return array( 'classes' => array(), 'style' => '', ); } return aafm_extract_tag_attrs( $tag[2] ); } /** * Extract the class list and inline style from a single element's attribute string. * * @param string $attr_str The raw attribute portion of one opening tag. * @return array{classes:list,style:string} */ function aafm_extract_tag_attrs( string $attr_str ): array { $result = array( 'classes' => array(), 'style' => '', ); if ( 1 === preg_match( '/\bclass\s*=\s*("|\')(.*?)\1/i', $attr_str, $class_match ) ) { $split = preg_split( '/\s+/', trim( $class_match[2] ) ); $result['classes'] = array_values( array_filter( is_array( $split ) ? $split : array() ) ); } if ( 1 === preg_match( '/\bstyle\s*=\s*("|\')(.*?)\1/i', $attr_str, $style_match ) ) { $result['style'] = trim( $style_match[2] ); } return $result; } /** * Whether an inline style string declares a given CSS property. * * Matches the property at the start of a declaration (line start or after a semicolon) so a * `color` query does not falsely match `background-color`. The `background` property matches both * `background:` and `background-color:`. * * @param string $style Inline style string (property:value; ...). * @param string $property One of 'color', 'background', or 'font-size'. * @return bool */ function aafm_style_declares( string $style, string $property ): bool { if ( '' === $style ) { return false; } $pattern = 'background' === $property ? '/(?:^|;)\s*background(?:-color)?\s*:/i' : '/(?:^|;)\s*' . preg_quote( $property, '/' ) . '\s*:/i'; return 1 === preg_match( $pattern, $style ); } /** * Read a nested value out of an attribute array by path, or null when any hop is missing. * * @param array $arr Attribute array. * @param array $path Ordered keys to descend. * @return mixed|null The value at the path, or null. */ function aafm_arr_path( array $arr, array $path ) { $node = $arr; foreach ( $path as $key ) { if ( ! is_array( $node ) || ! array_key_exists( $key, $node ) ) { return null; } $node = $node[ $key ]; } return ( '' === $node || array() === $node ) ? null : $node; } /** * Build one structured warning row. * * The message is human, translatable prose (it rides back to the agent and can surface in a * strict-mode error); the code is a stable machine token for programmatic handling. * * @param string $block Block name (for example core/heading). * @param string $code Machine code for the warning type. * @param string $detail The code's detail: the offending class name, the removed element name, * the unknown ACF keys or the dropped blockGap values ('' otherwise). * @return array{block:string,code:string,message:string} */ function aafm_block_warning( string $block, string $code, string $detail ): array { switch ( $code ) { case 'text_color_class_without_attr': case 'background_class_without_attr': case 'border_color_class_without_attr': case 'custom_font_size_class_without_attr': case 'font_size_class_without_attr': $message = sprintf( /* translators: 1: block name, 2: CSS class name. */ __( 'The %1$s block has the "%2$s" class but no matching value in its block attributes. Encode the styling inside the block delimiter so the editor does not flag the block as invalid.', 'agent-abilities-for-mcp' ), $block, $detail ); break; case 'inline_style_without_attrs': $message = sprintf( /* translators: %s: block name. */ __( 'The %s block uses an inline style="" attribute with no styling in its block attributes. Move the styling into the block delimiter, or the editor will flag the block as invalid.', 'agent-abilities-for-mcp' ), $block ); break; case 'color_attr_value_dropped': case 'background_attr_value_dropped': case 'font_size_attr_value_dropped': $message = sprintf( /* translators: %s: block name. */ __( 'The %s block declares a color or size in its attributes, but the value is missing from the stored HTML (the sanitizer likely dropped an rgba() or alpha color). Use a plain hex color so the attribute and the markup agree.', 'agent-abilities-for-mcp' ), $block ); break; case 'removed_element': $message = sprintf( /* translators: %s: HTML element name, style or script. */ __( 'A <%s> element was removed together with its contents. Stored content cannot carry styles or scripts; put styling in block attributes instead.', 'agent-abilities-for-mcp' ), $detail ); break; case 'unregistered_block': $message = sprintf( /* translators: %s: block name. */ __( 'No block type named %s is registered on this site, so it will not render as that block. If aafm-list-block-types is enabled, check the name there.', 'agent-abilities-for-mcp' ), $block ); break; case 'unknown_acf_field': $valid = wp_list_pluck( (array) aafm_acf_block_fields( $block ), 'name' ); $message = sprintf( /* translators: 1: block name, 2: comma-separated data keys, 3: comma-separated field names. */ __( 'The %1$s block data has keys that match none of its ACF fields, so ACF ignores them: %2$s. Its field names are: %3$s. If aafm-get-block-type is enabled, it shows an example.', 'agent-abilities-for-mcp' ), $block, $detail, implode( ', ', array_slice( $valid, 0, 20 ) ) ); break; case 'block_gap_dropped': $message = sprintf( /* translators: 1: block name, 2: blockGap value(s). */ __( 'WordPress ignores the blockGap value %2$s on the %1$s block, because it contains (, \\, &, =, } or /*. Use a length such as 24px or a spacing preset such as var:preset|spacing|40.', 'agent-abilities-for-mcp' ), $block, $detail ); break; case 'invalid_block_attributes': $message = sprintf( /* translators: %s: block name. */ __( 'The %s block has delimiter attributes that are not valid JSON, so WordPress ignores all of them. Fix the JSON (for example a trailing comma) and write the block again.', 'agent-abilities-for-mcp' ), $block ); break; default: $message = sprintf( /* translators: %s: block name. */ __( 'The %s block may render as invalid content in the editor. Encode all styling as block attributes.', 'agent-abilities-for-mcp' ), $block ); break; } return array( 'block' => $block, 'code' => $code, 'message' => $message, ); } /** * The warning codes strict mode refuses on: the editor-invalid-content codes the guard had before * the informational ones (unregistered_block, unknown_acf_field, block_gap_dropped, * invalid_block_attributes, removed_element) were added. Those never refuse, so a strict site does * not start refusing writes it accepted before. * * @return list */ function aafm_block_guard_strict_codes(): array { return array( 'text_color_class_without_attr', 'background_class_without_attr', 'border_color_class_without_attr', 'custom_font_size_class_without_attr', 'font_size_class_without_attr', 'inline_style_without_attrs', 'color_attr_value_dropped', 'background_attr_value_dropped', 'font_size_attr_value_dropped', ); } /** * Run the guard against final content and, in strict mode, return a blocking error. * * The single chokepoint every write executor calls. It returns the scan warnings in every case. * Strict mode refuses only warnings whose codes are listed by aafm_block_guard_strict_codes(); * informational warnings never block a write. On such a refusal it ALSO returns a WP_Error the * caller must return before writing. The caller attaches the warnings to its success response. * * @param string $content Final, post-sanitization block markup. * @return array{warnings:list,error:WP_Error|null} */ function aafm_block_guard_evaluate( string $content ): array { $warnings = aafm_scan_block_content( $content ); $error = null; // Strict mode refuses only on the editor-invalid codes; the informational ones never refuse. $refusing = array_filter( $warnings, static fn( array $warning ): bool => in_array( $warning['code'], aafm_block_guard_strict_codes(), true ) ); if ( ! empty( $refusing ) && aafm_block_guard_is_strict() ) { $blocks = implode( ', ', array_values( array_unique( wp_list_pluck( $refusing, 'block' ) ) ) ); $error = new WP_Error( 'aafm_invalid_block_content', sprintf( /* translators: %s: comma-separated list of block names. */ __( 'The block content was rejected because these blocks would show as invalid in the editor: %s. Encode all styling as block attributes inside the block delimiters and try again.', 'agent-abilities-for-mcp' ), $blocks ) ); } return array( 'warnings' => $warnings, 'error' => $error, ); } /** * Sanitize agent-written block markup: wp_kses_post(), minus its damage to plain attribute text. * * The wp_kses_post() pass entity-encodes &, < and > inside block attribute JSON for every user, so * a value such as "Saturday & Sunday" or an embed URL with a query string is stored changed. Core * does that on save only for users without unfiltered_html; for a user with it, the block editor * stores the attribute text raw. This helper keeps wp_kses_post() as the sanitizer and, for a user with * unfiltered_html only, puts back each attribute string that cannot open an HTML tag and that kses * only entity-encoded, exactly as it was sent. Any other value keeps the kses result, and * innerHTML is never restored. * When nothing is restored, the content is wp_kses_post() of the input byte for byte, unless kses * of the raw input parses differently from kses of the normalized input (a raw "<" in the comment * JSON broke or merged the comment): then it is the normalized kses tree, serialized. A user * without unfiltered_html always gets wp_kses_post() of the input byte for byte. Both apart from * the