, innerBlocks: array, innerHTML: string, innerContent: array} * @phpstan-type TemplateResult array{0: array, 1: string, 2: array} * * @since 1.2.0 */ class BlockSerializer { /** * Severity for issues that degrade the markup and should block saving. * * @var string * * @since 1.2.0 */ public const SEVERITY_ERROR = 'error'; /** * Severity for recoverable degradations that do not block saving. * * @var string * * @since 1.2.0 */ public const SEVERITY_WARNING = 'warning'; /** * Block type registry reader, used to classify untemplated block names. * * @var BlockSchema * * @since 1.2.0 */ private BlockSchema $schema; /** * Construct the serializer. * * @param BlockSchema|null $schema Optional registry reader (defaults to a fresh instance). * * @since 1.2.0 */ public function __construct( ?BlockSchema $schema = null ) { $this->schema = $schema ?? new BlockSchema(); } /** * Convert block specs (or a string) into block markup. * * @param array>|string $input Block specs or an HTML/Markdown string. * @return string Comment-delimited block markup. * * @since 1.2.0 */ public function serialize( $input ): string { return $this->serialize_with_issues( $input )['markup']; } /** * Convert block specs (or a string) into markup and collect validation issues. * * Each issue is an `array{severity: string, message: string}` where the * message is actionable and path-qualified for an LLM to self-correct, e.g. * "content[0].attributes.level must be between 1 and 6". * * @param array>|string $input Block specs or an HTML/Markdown string. * @return array{markup: string, issues: array} Markup plus a list of issues. * * @since 1.2.0 */ public function serialize_with_issues( $input ): array { // Defensive fallback: a string is routed through BlockConverter, which // turns HTML/Markdown into block markup (and passes existing block // markup through untouched). if ( is_string( $input ) ) { return [ 'markup' => ( new BlockConverter( $input ) )->convert(), 'issues' => [], ]; } $issues = []; $blocks = $this->build_blocks( $input, 'content', $issues ); $markup = serialize_blocks( $blocks ); // Round-trip self-check: confirm every top-level block name survived. $this->verify_round_trip( $blocks, $markup, $issues ); return [ 'markup' => $markup, 'issues' => $issues, ]; } /** * Build a single parsed block from one block spec. * * The write-side counterpart used by the granular block-edit abilities * ({@see BlockTreeEditor}) — they need exactly one ParsedBlock to splice into * a parse_blocks() tree, not a full top-level serialize. This wraps the same * private {@see build_block()} the whole-content path uses, so the resulting * block carries the same per-block-template innerHTML/innerContent and is * byte-correct when serialize_blocks() rebuilds it next to untouched siblings. * * Returns the built block (or null when the spec produced nothing, e.g. an * unknown block name with no content to preserve) alongside the same * severity-tagged issue list {@see serialize_with_issues()} reports, so callers * can fold them through {@see BlockIssues} exactly as whole-content writes do. * * @param array $spec Single block spec ({ name, attributes, innerBlocks }). * @return array{block: ParsedBlock|null, issues: array} Built block (or null) and issues. * * @since 1.2.0 */ public function build_one( array $spec ): array { $issues = []; $block = $this->build_block( $spec, 'block', $issues ); return [ 'block' => $block, 'issues' => $issues, ]; } /** * Build an issue array. * * @param string $severity One of the SEVERITY_* constants. * @param string $message Actionable, path-qualified message. * @return Issue * * @since 1.2.0 */ private function issue( string $severity, string $message ): array { return [ 'severity' => $severity, 'message' => $message, ]; } /** * Build a list of parsed-block arrays from a list of specs. * * @param array> $specs Block specs. * @param string $path Dotted path prefix for issue messages. * @param array $issues Issues collector (by reference). * @return array Parsed-block arrays ready for serialize_blocks(). * * @since 1.2.0 */ private function build_blocks( array $specs, string $path, array &$issues ): array { $blocks = []; foreach ( $specs as $index => $spec ) { $item_path = "{$path}[{$index}]"; if ( ! is_array( $spec ) ) { $issues[] = $this->issue( self::SEVERITY_ERROR, "{$item_path} must be an object with a 'name' and optional 'attributes'/'innerBlocks'." ); continue; } $block = $this->build_block( $spec, $item_path, $issues ); if ( $block !== null ) { $blocks[] = $block; } } return $blocks; } /** * Build a single parsed-block array from a spec. * * @param array $spec Block spec. * @param string $path Dotted path for issue messages. * @param array $issues Issues collector (by reference). * @return ParsedBlock|null Parsed-block array, or null to skip. * * @since 1.2.0 */ private function build_block( array $spec, string $path, array &$issues ): ?array { $name = $spec['name'] ?? $spec['blockName'] ?? null; $name = is_string( $name ) ? trim( $name ) : ''; if ( $name === '' ) { $issues[] = $this->issue( self::SEVERITY_ERROR, "{$path}.name is required (e.g. 'core/paragraph')." ); return null; } $attributes = $spec['attributes'] ?? $spec['attrs'] ?? []; if ( ! is_array( $attributes ) ) { $issues[] = $this->issue( self::SEVERITY_ERROR, "{$path}.attributes must be an object." ); $attributes = []; } $inner_specs = $spec['innerBlocks'] ?? []; if ( ! is_array( $inner_specs ) ) { $issues[] = $this->issue( self::SEVERITY_ERROR, "{$path}.innerBlocks must be an array of block specs." ); $inner_specs = []; } $plaintext = isset( $spec['plaintext'] ) ? (string) $spec['plaintext'] : ''; $inner_blocks = $this->build_blocks( $inner_specs, "{$path}.innerBlocks", $issues ); $template = $this->template_for( $name ); // No per-block template here — fall back to registry classification. if ( $template === null ) { return $this->serialize_untemplated( $name, $spec, $attributes, $inner_blocks, $path, $issues ); } $context = new BlockContext( $attributes, $plaintext, $path ); // Templates take exactly one argument (the context) and return // [ array $attrs, string $inner_html, array $tpl_issues ]. Any messages // the template generated are recoverable degradations (clamped value, // missing url, …), so they are recorded as warnings. [ $attrs, $inner_html, $tpl_issues ] = $template( $context ); foreach ( $tpl_issues as $message ) { $issues[] = $this->issue( self::SEVERITY_WARNING, $message ); } return $this->make_block( $name, $attrs, $inner_html, $inner_blocks ); } /** * Serialize a block that has no per-block template here, using the registry. * * Resolution order: * - core/html (registered) — legitimate raw HTML, emitted with no issue. * - registered dynamic — emit comment + attrs, empty innerHTML (valid). * - registered static — best-effort markup + one "untemplated" warning. * - unregistered — error with an actionable hint; still emit a * core/html fallback so output is never broken. * * @param string $name Block name. * @param array $spec Original spec (for raw html/plaintext fallbacks). * @param array $attributes Normalized attributes. * @param array $inner_blocks Already-built inner blocks. * @param string $path Dotted path for issue messages. * @param array $issues Issues collector (by reference). * @return ParsedBlock|null Parsed-block array, or null when nothing to emit. * * @since 1.2.0 */ private function serialize_untemplated( string $name, array $spec, array $attributes, array $inner_blocks, string $path, array &$issues ): ?array { // Explicit, registered core/html is legitimate: emit it as-is, no issue. if ( $name === 'core/html' ) { $raw = $this->raw_html_from_spec( $spec ); return $this->make_block( 'core/html', [], $raw, [] ); } // Unregistered/unknown block name — this is an error. if ( ! $this->schema->is_registered( $name ) ) { return $this->unknown_block_fallback( $name, $spec, $path, $issues ); } // Registered dynamic block: it renders from a render_callback, so it // stores no innerHTML. A bare comment + attributes is valid markup. if ( $this->schema->is_dynamic( $name ) ) { return $this->make_block( $name, $attributes, '', $inner_blocks ); } // Registered static block without a template here — best effort. $inner_html = $this->best_effort_inner_html( $spec, $inner_blocks ); $issues[] = $this->issue( self::SEVERITY_WARNING, "{$path}.name '{$name}' is a registered block without dedicated handling — serialized best-effort. Verify the result, or use a fully supported block (e.g. core/paragraph, core/heading, core/group)." ); return $this->make_block( $name, $attributes, $inner_html, $inner_blocks ); } /** * Build a best-effort innerHTML string for an untemplated static block. * * Uses raw HTML / plaintext from the spec, and leaves a `%children%` marker * for inner blocks when present so make_block() can splice them in. * * @param array $spec Original spec. * @param array $inner_blocks Already-built inner blocks. * @return string Inner HTML (possibly containing a `%children%` marker). * * @since 1.2.0 */ private function best_effort_inner_html( array $spec, array $inner_blocks ): string { $raw = $this->raw_html_from_spec( $spec ); if ( $inner_blocks !== [] ) { return $raw . '%children%'; } return $raw; } /** * Extract raw inner HTML from a spec for html/best-effort serialization. * * @param array $spec Original spec. * @return string Raw HTML, or an empty string. * * @since 1.2.0 */ private function raw_html_from_spec( array $spec ): string { if ( isset( $spec['attributes']['html'] ) && is_string( $spec['attributes']['html'] ) ) { return $spec['attributes']['html']; } if ( isset( $spec['html'] ) && is_string( $spec['html'] ) ) { return $spec['html']; } if ( isset( $spec['plaintext'] ) && is_string( $spec['plaintext'] ) && $spec['plaintext'] !== '' ) { return '

' . esc_html( $spec['plaintext'] ) . '

'; } return ''; } /** * Resolve the innerHTML template callable for a block name. * * @param string $name Block name. * @return (callable(BlockContext): TemplateResult)|null Template callable, or null for untemplated blocks. * * @since 1.2.0 */ private function template_for( string $name ): ?callable { $map = [ 'core/paragraph' => [ $this, 'tpl_paragraph' ], 'core/heading' => [ $this, 'tpl_heading' ], 'core/list' => [ $this, 'tpl_list' ], 'core/list-item' => [ $this, 'tpl_list_item' ], 'core/quote' => [ $this, 'tpl_quote' ], 'core/image' => [ $this, 'tpl_image' ], 'core/columns' => [ $this, 'tpl_wrapper_columns' ], 'core/column' => [ $this, 'tpl_wrapper_column' ], 'core/group' => [ $this, 'tpl_wrapper_group' ], 'core/buttons' => [ $this, 'tpl_wrapper_buttons' ], 'core/button' => [ $this, 'tpl_button' ], 'core/separator' => [ $this, 'tpl_separator' ], 'core/spacer' => [ $this, 'tpl_spacer' ], 'core/code' => [ $this, 'tpl_code' ], 'core/pullquote' => [ $this, 'tpl_pullquote' ], ]; return $map[ $name ] ?? null; } /** * Assemble a parsed-block array, computing innerContent for serialize_blocks(). * * The innerContent array interleaves literal HTML strings with null markers * that indicate where each inner block is spliced in. For blocks whose * template already produced the full inner HTML (no nested blocks), * innerContent is just the single HTML string. * * @param string $name Block name. * @param array $attrs Block attributes. * @param string $inner_html The block's own inner HTML. * @param array $inner_blocks Already-built inner blocks. * @return ParsedBlock Parsed-block array. * * @since 1.2.0 */ private function make_block( string $name, array $attrs, string $inner_html, array $inner_blocks ): array { // Wrapper templates embed a "%children%" placeholder where children // belong. We split on it to build the alternating innerContent array. if ( str_contains( $inner_html, '%children%' ) && $inner_blocks !== [] ) { [ $before, $after ] = explode( '%children%', $inner_html, 2 ); $inner_content = [ $before ]; foreach ( $inner_blocks as $unused ) { $inner_content[] = null; } $inner_content[] = $after; // The literal innerHTML stored on the block is the wrapper minus markers. $flat_inner_html = $before . $after; } else { $flat_inner_html = str_replace( '%children%', '', $inner_html ); $inner_content = $flat_inner_html === '' ? [] : [ $flat_inner_html ]; } return [ 'blockName' => $name, 'attrs' => $attrs, 'innerBlocks' => $inner_blocks, 'innerHTML' => $flat_inner_html, 'innerContent' => $inner_content, ]; } /** * Build a core/html fallback for an unregistered/unknown block name. * * Always records an error: an unknown name must never be silently accepted. * When there is content to preserve it is wrapped as core/html so the * serialized output stays valid; otherwise the block is dropped (still an * error, since its content was lost). * * @param string $name Original (unknown) block name. * @param array $spec Original spec. * @param string $path Dotted path for issue messages. * @param array $issues Issues collector (by reference). * @return ParsedBlock|null core/html block, or null if nothing to wrap. * * @since 1.2.0 */ private function unknown_block_fallback( string $name, array $spec, string $path, array &$issues ): ?array { $raw = $this->raw_html_from_spec( $spec ); if ( trim( $raw ) === '' ) { $issues[] = $this->issue( self::SEVERITY_ERROR, "{$path}.name '{$name}' is not a registered block and was dropped (no 'html' or 'plaintext' content to preserve). Use a registered block name (e.g. core/paragraph, core/heading) or core/html for raw HTML." ); return null; } $issues[] = $this->issue( self::SEVERITY_ERROR, "{$path}.name '{$name}' is not a registered block. Use a registered block name (e.g. core/paragraph, core/heading) or core/html for raw HTML." ); return $this->make_block( 'core/html', [], $raw, [] ); } /** * Round-trip self-check: re-parse the markup and flag dropped top-level names. * * @param array $blocks Built blocks (top level). * @param string $markup Serialized markup. * @param array $issues Issues collector (by reference). * @return void * * @since 1.2.0 */ private function verify_round_trip( array $blocks, string $markup, array &$issues ): void { if ( $markup === '' ) { return; } $reparsed = parse_blocks( $markup ); // Keep only the meaningful (non-empty-freeform) reparsed names. $reparsed_names = []; foreach ( $reparsed as $block ) { $name = $block['blockName'] ?? null; if ( $name === null && trim( (string) ( $block['innerHTML'] ?? '' ) ) === '' ) { continue; } $reparsed_names[] = $name; } $expected_names = array_map( static fn ( $block ) => $block['blockName'], $blocks ); if ( $expected_names !== $reparsed_names ) { $issues[] = $this->issue( self::SEVERITY_ERROR, sprintf( 'content round-trip mismatch: expected blocks [%s] but serialized markup re-parsed as [%s]. Check nesting and attributes.', implode( ', ', array_map( static fn ( $n ) => (string) $n, $expected_names ) ), implode( ', ', array_map( static fn ( $n ) => (string) $n, $reparsed_names ) ) ) ); } } // ===================================================================== // Per-block templates // // Each takes a single BlockContext and returns // [ array $attrs, string $inner_html, array $issues ]. Wrapper blocks place // a literal "%children%" marker where their inner blocks are spliced in. // Template issues are recoverable, so the caller records them as warnings. // ===================================================================== /** * Template: core/paragraph. * * @param BlockContext $context Block context. * @return array{0: array, 1: string, 2: array} * * @since 1.2.0 */ private function tpl_paragraph( BlockContext $context ): array { $content = $this->text_content( $context->attributes, [ 'content' ], $context->plaintext ); return [ $context->attributes, '

' . $content . '

', [] ]; } /** * Template: core/heading. * * @param BlockContext $context Block context. * @return array{0: array, 1: string, 2: array} * * @since 1.2.0 */ private function tpl_heading( BlockContext $context ): array { $attributes = $context->attributes; $issues = []; $level = isset( $attributes['level'] ) ? (int) $attributes['level'] : 2; if ( $level < 1 || $level > 6 ) { $issues[] = "{$context->path}.attributes.level must be between 1 and 6 (got {$level}). Defaulting to 2."; $level = 2; } $attributes['level'] = $level; $content = $this->text_content( $attributes, [ 'content' ], $context->plaintext ); return [ $attributes, sprintf( '%2$s', $level, $content ), $issues ]; } /** * Template: core/list (wrapper around list-item children). * * @param BlockContext $context Block context. * @return array{0: array, 1: string, 2: array} * * @since 1.2.0 */ private function tpl_list( BlockContext $context ): array { $ordered = ! empty( $context->attributes['ordered'] ); $tag = $ordered ? 'ol' : 'ul'; // core/list is a dynamic block: its render_callback injects the // `wp-block-list` class at render time, so the stored markup must be a // bare