From 838f4c55107482b826d287222e453941ba531c54 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 26 Jul 2026 11:24:02 -0400 Subject: [PATCH] Add typed description list companion block --- php-transformer/composer.json | 1 + .../src/ArtifactCompiler/ArtifactCompiler.php | 24 ++- .../DescriptionListBlockGenerator.php | 128 ++++++++++++++ .../src/HtmlToBlocks/HtmlTransformer.php | 161 ++++++++++++++++++ php-transformer/tests/contract/run.php | 33 ++-- .../parity/html-expanded-gap-primitives.json | 3 +- .../tests/unit/description-list-block.php | 63 +++++++ 7 files changed, 395 insertions(+), 18 deletions(-) create mode 100644 php-transformer/src/HtmlToBlocks/DescriptionListBlockGenerator.php create mode 100644 php-transformer/tests/unit/description-list-block.php diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 49fdd749..1f95e550 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -58,6 +58,7 @@ "test:unit": [ "php tests/unit/subtree-classifier.php", "php tests/unit/custom-block-generator.php", + "php tests/unit/description-list-block.php", "php tests/unit/css-value-splitter.php", "php tests/unit/background-image-extractor.php", "php tests/unit/css-stylesheet-transformer.php", diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 9817f4ee..92c08d2b 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -69,15 +69,20 @@ public function compile(array $artifact): TransformerResult $companionPluginPayloadBuilder = new CompanionPluginPayload(); $normalized['files'] = $this->withStylesheetOccurrenceAssets($html, $entryPath, $normalized['files']); $entryBlocks = $this->compileEntryBlocks($html, $entryPath, $normalized['files'], $companionPluginPayloadBuilder->blockNamespace($artifact)); - $compiledHtmlDocuments = $this->compileHtmlSourceDocuments($normalized['files'], $entryPath); + $compiledHtmlDocuments = $this->compileHtmlSourceDocuments($normalized['files'], $entryPath, $companionPluginPayloadBuilder->blockNamespace($artifact)); $authorStylesheetProjections = $entryBlocks['author_stylesheet_projections']; $allDiagnostics = $this->entryTransformDiagnostics($entryBlocks['diagnostics'], $entryPath); $allFallbacks = $entryBlocks['fallbacks']; + $allGeneratedBlocks = $entryBlocks['generated_blocks']; + $allGutenbergGaps = $entryBlocks['gutenberg_gaps']; foreach ( $compiledHtmlDocuments as $sourcePath => $compiledHtmlDocument ) { $authorStylesheetProjections = array_merge($authorStylesheetProjections, $compiledHtmlDocument['author_stylesheet_projections'] ?? array()); $allDiagnostics = array_merge($allDiagnostics, $this->entryTransformDiagnostics($compiledHtmlDocument['diagnostics'] ?? array(), (string) $sourcePath)); $allFallbacks = array_merge($allFallbacks, $compiledHtmlDocument['fallbacks'] ?? array()); + $allGeneratedBlocks = array_merge($allGeneratedBlocks, $compiledHtmlDocument['generated_blocks'] ?? array()); + $allGutenbergGaps = array_merge($allGutenbergGaps, $compiledHtmlDocument['gutenberg_gaps'] ?? array()); } + $allGutenbergGaps = $this->dedupeRows($allGutenbergGaps); $normalized['runtime_declarations'] = $this->runtimeDeclarationsFromFallbacks($normalized['runtime_declarations'], $allFallbacks, $entryPath, $normalized['files']); $runtimeIslandPackage = ( new RuntimeIslandPackageBuilder() )->fromRuntimeIslands($entryBlocks['runtime_islands'], $normalized['files'], $entryPath); $normalized['files'] = $this->applyAuthorStylesheetProjections($normalized['files'], $authorStylesheetProjections, $entryBlocks['author_stylesheet_projections']); @@ -125,8 +130,11 @@ public function compile(array $artifact): TransformerResult ), ); $sourceReports['compiled_site'] = $this->compiledSiteReport($normalized, $entryPath, $documents['documents'], $assets, $blockTypes, $serializedBlocks, $entryBlocks['shell_artifacts'], $compiledHtmlDocuments); + if ( array() !== $allGutenbergGaps ) { + $sourceReports['gutenberg_gaps'] = $allGutenbergGaps; + } $sourceReports['materialization_plan'] = ( new MaterializationPlanBuilder() )->fromCompiledSite($sourceReports['compiled_site']); - $companionPluginPayload = $companionPluginPayloadBuilder->fromBlockTypes($blockTypes, $normalized['files'], $artifact, $entryBlocks['generated_blocks'], $runtimeIslandPackage); + $companionPluginPayload = $companionPluginPayloadBuilder->fromBlockTypes($blockTypes, $normalized['files'], $artifact, $allGeneratedBlocks, $runtimeIslandPackage); if ( array() !== $companionPluginPayload ) { $sourceReports['companion_plugin_payload'] = $companionPluginPayload; } @@ -375,7 +383,7 @@ public function compileFragment(string $content, string $source = 'fragment', st /** * @param array> $files - * @return array{blocks: array>, serialized_blocks: string, diagnostics: array>, fallbacks: array>, assets: array>, runtime_islands: array>, generated_blocks: array>, interaction_candidates: array>, superseded_selectors: array, author_stylesheet_projections: array>, shell_artifacts: array>} + * @return array{blocks: array>, serialized_blocks: string, diagnostics: array>, fallbacks: array>, assets: array>, runtime_islands: array>, generated_blocks: array>, gutenberg_gaps: array>, interaction_candidates: array>, superseded_selectors: array, author_stylesheet_projections: array>, shell_artifacts: array>} */ private function compileEntryBlocks(string $html, string $entryPath, array $files, string $generatedBlockNamespace = ''): array { @@ -389,6 +397,7 @@ private function compileEntryBlocks(string $html, string $entryPath, array $file 'assets' => $result['assets'], 'runtime_islands' => $result['runtime_islands'], 'generated_blocks' => $result['generated_blocks'], + 'gutenberg_gaps' => $result['gutenberg_gaps'], 'interaction_candidates' => $result['interaction_candidates'], 'superseded_selectors' => $result['superseded_selectors'], 'author_stylesheet_projections' => $result['author_stylesheet_projections'], @@ -398,7 +407,7 @@ private function compileEntryBlocks(string $html, string $entryPath, array $file /** * @param array> $files - * @return array{blocks: array>, serialized_blocks: string, diagnostics: array>, fallbacks: array>, assets: array>, runtime_islands: array>, generated_blocks: array>, interaction_candidates: array>, superseded_selectors: array, author_stylesheet_projections: array>, shell_artifacts: array>} + * @return array{blocks: array>, serialized_blocks: string, diagnostics: array>, fallbacks: array>, assets: array>, runtime_islands: array>, generated_blocks: array>, gutenberg_gaps: array>, interaction_candidates: array>, superseded_selectors: array, author_stylesheet_projections: array>, shell_artifacts: array>} */ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, array $files, string $sourceScope, string $generatedBlockNamespace = '', bool $extractGlobalShell = false): array { @@ -411,6 +420,7 @@ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, arr 'assets' => array(), 'runtime_islands' => array(), 'generated_blocks' => array(), + 'gutenberg_gaps' => array(), 'interaction_candidates' => array(), 'superseded_selectors' => array(), 'author_stylesheet_projections' => array(), @@ -427,6 +437,7 @@ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, arr 'assets' => array(), 'runtime_islands' => array(), 'generated_blocks' => array(), + 'gutenberg_gaps' => array(), 'interaction_candidates' => array(), 'superseded_selectors' => array(), 'author_stylesheet_projections' => array(), @@ -460,6 +471,7 @@ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, arr $files ), 'generated_blocks' => is_array($result['source_reports']['generated_blocks'] ?? null) ? $result['source_reports']['generated_blocks'] : array(), + 'gutenberg_gaps' => is_array($result['source_reports']['gutenberg_gaps'] ?? null) ? $result['source_reports']['gutenberg_gaps'] : array(), 'interaction_candidates' => is_array($result['source_reports']['interaction_candidates'] ?? null) ? $result['source_reports']['interaction_candidates'] : array(), 'superseded_selectors' => array_values(array_filter( is_array($result['source_reports']['superseded_selectors'] ?? null) ? $result['source_reports']['superseded_selectors'] : array(), @@ -1022,7 +1034,7 @@ private function applyAuthorStylesheetProjections(array $files, array $projectio * @param array> $files * @return array> */ - private function compileHtmlSourceDocuments(array $files, string $entryPath): array + private function compileHtmlSourceDocuments(array $files, string $entryPath, string $generatedBlockNamespace = ''): array { $documents = array(); foreach ( $files as $file ) { @@ -1033,7 +1045,7 @@ private function compileHtmlSourceDocuments(array $files, string $entryPath): ar if ( '' === $path || $entryPath === $path ) { continue; } - $documents[$path] = $this->compileHtmlDocumentBlocks((string) ($file['content'] ?? ''), $path, $files, 'artifact-document', '', true); + $documents[$path] = $this->compileHtmlDocumentBlocks((string) ($file['content'] ?? ''), $path, $files, 'artifact-document', $generatedBlockNamespace, true); } return $documents; } diff --git a/php-transformer/src/HtmlToBlocks/DescriptionListBlockGenerator.php b/php-transformer/src/HtmlToBlocks/DescriptionListBlockGenerator.php new file mode 100644 index 00000000..4410aac6 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/DescriptionListBlockGenerator.php @@ -0,0 +1,128 @@ + */ + public function blockJson(): array + { + return array( + 'apiVersion' => 3, + 'name' => self::NAME, + 'title' => 'Description List', + 'category' => 'text', + 'description' => 'A semantic description list with terms and descriptions.', + 'editorScript' => 'file:./index.js', + 'attributes' => array( + 'className' => array( 'type' => 'string', 'default' => '' ), + 'style' => array( 'type' => 'string', 'default' => '' ), + 'groups' => array( 'type' => 'array', 'default' => array() ), + ), + 'supports' => array( 'html' => false ), + ); + } + + /** @return array */ + public function assets(): array + { + $script = <<<'JS' +( function( blocks, blockEditor, element ) { + var createElement = element.createElement; + var RawHTML = element.RawHTML; + var RichText = blockEditor.RichText; + var useEffect = element.useEffect; + var attributes = __BLOCK_ATTRIBUTES__; + function escapeAttribute( value ) { return String( value || '' ).replace( /&/g, '&' ).replace( /"/g, '"' ).replace( //g, '>' ); } + function safeCssText( value ) { + var probe = document.createElement( 'span' ); + probe.setAttribute( 'style', value || '' ); + return probe.style.cssText; + } + function markupAttributes( item ) { + var output = ''; + if ( item.className ) { output += ' class="' + escapeAttribute( item.className ) + '"'; } + if ( item.style ) { output += ' style="' + escapeAttribute( item.style ) + '"'; } + return output; + } + function markup( blockAttributes ) { + var output = ''; + ( blockAttributes.groups || [] ).forEach( function( group ) { + ( group.terms || [] ).forEach( function( item ) { output += '' + ( item.content || '' ) + ''; } ); + ( group.descriptions || [] ).forEach( function( item ) { output += '' + ( item.content || '' ) + ''; } ); + } ); + return output + ''; + } + function updateItem( props, groupIndex, collection, itemIndex, content ) { + var groups = ( props.attributes.groups || [] ).map( function( group ) { + return { + terms: ( group.terms || [] ).map( function( item ) { return Object.assign( {}, item ); } ), + descriptions: ( group.descriptions || [] ).map( function( item ) { return Object.assign( {}, item ); } ) + }; + } ); + groups[ groupIndex ][ collection ][ itemIndex ].content = content; + props.setAttributes( { groups: groups } ); + } + function edit( props ) { + var children = []; + var scope = 'be-description-list-' + String( props.clientId || 'block' ).replace( /[^a-zA-Z0-9_-]/g, '' ); + var rules = safeCssText( props.attributes.style ) ? '.' + scope + '{' + safeCssText( props.attributes.style ) + '}' : ''; + ( props.attributes.groups || [] ).forEach( function( group, groupIndex ) { + ( group.terms || [] ).forEach( function( item, itemIndex ) { + var key = 'term-' + groupIndex + '-' + itemIndex; + var css = safeCssText( item.style ); + if ( css ) { rules += '.' + scope + ' [data-be-description-list-item="' + key + '"]{' + css + '}'; } + children.push( createElement( RichText, { tagName: 'dt', value: item.content || '', className: item.className || undefined, 'data-be-description-list-item': key, key: key, onChange: function( content ) { updateItem( props, groupIndex, 'terms', itemIndex, content ); } } ) ); + } ); + ( group.descriptions || [] ).forEach( function( item, itemIndex ) { + var key = 'description-' + groupIndex + '-' + itemIndex; + var css = safeCssText( item.style ); + if ( css ) { rules += '.' + scope + ' [data-be-description-list-item="' + key + '"]{' + css + '}'; } + children.push( createElement( RichText, { tagName: 'dd', value: item.content || '', className: item.className || undefined, 'data-be-description-list-item': key, key: key, onChange: function( content ) { updateItem( props, groupIndex, 'descriptions', itemIndex, content ); } } ) ); + } ); + } ); + useEffect( function() { + if ( ! rules ) { return undefined; } + var sheet = document.createElement( 'style' ); + sheet.textContent = rules; + document.head.appendChild( sheet ); + return function() { sheet.remove(); }; + }, [ rules ] ); + return createElement( 'dl', { className: [ props.attributes.className, scope ].filter( Boolean ).join( ' ' ) }, children ); + } + function save( props ) { return createElement( RawHTML, null, markup( props.attributes ) ); } + blocks.registerBlockType( 'blocks-engine/description-list', { attributes: attributes, supports: { html: false }, edit: edit, save: save } ); +} )( window.wp.blocks, window.wp.blockEditor, window.wp.element ); +JS; + + return array( + 'index.asset.php' => <<<'PHP' + array( 'wp-blocks', 'wp-block-editor', 'wp-element' ), 'version' => '1.0.0' ); +PHP, + 'index.js' => str_replace( + '__BLOCK_ATTRIBUTES__', + json_encode($this->blockJson()['attributes'], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + $script + ), + ); + } + + /** @return array */ + public function definition(): array + { + return array( + 'name' => 'description-list', + 'block_json' => $this->blockJson(), + 'assets' => $this->assets(), + ); + } +} diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index b5ec4f65..50184e04 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -268,6 +268,8 @@ final class HtmlTransformer */ private array $generatedBlocks = array(); + private bool $descriptionListBlockGenerated = false; + /** * Block namespace for generated custom-block references. The ArtifactCompiler * sets this to the per-site companion-plugin namespace (`ssi-`) so @@ -440,6 +442,7 @@ public function transform(string $html, array $options = array()): TransformerRe $this->runtimeIslands = array(); $this->nativeDisclosureRootIds = array(); $this->generatedBlocks = array(); + $this->descriptionListBlockGenerated = false; $this->formControlEchoTexts = array(); $this->generatedBlockNamespace = $this->generatedBlockNamespaceFromOptions($options); $this->preserveShellLandmarks = !empty($options['extract_global_shell']); @@ -570,6 +573,18 @@ public function transform(string $html, array $options = array()): TransformerRe $semanticParityReport, $contentRoundTripReport ); + if ( $this->descriptionListBlockGenerated ) { + $diagnostics[] = array( + 'code' => 'semantic_description_list_gutenberg_gap', + 'message' => 'A semantic description list was materialized with the Blocks Engine companion block because Gutenberg has no core description-list block.', + 'source' => self::class, + 'severity' => 'info', + 'references' => array( + 'https://github.com/WordPress/gutenberg/issues/4880', + 'https://github.com/WordPress/gutenberg/pull/20760', + ), + ); + } $metrics = $this->metrics($html, $blocks, $serializedBlocks, $fallbacks, $diagnostics, $startedAt); $nativeTargetBlocks = $this->runtime->availableCoreBlockNames(); @@ -578,6 +593,16 @@ public function transform(string $html, array $options = array()): TransformerRe 'available_core_blocks' => $nativeTargetBlocks, 'runtime_islands' => $this->runtimeIslands, 'generated_blocks' => $this->generatedBlocks, + 'gutenberg_gaps' => $this->descriptionListBlockGenerated ? array( + array( + 'id' => 'semantic-description-list', + 'block_name' => DescriptionListBlockGenerator::NAME, + 'references' => array( + 'https://github.com/WordPress/gutenberg/issues/4880', + 'https://github.com/WordPress/gutenberg/pull/20760', + ), + ), + ) : array(), 'interaction_candidates' => $interactionCandidates, 'superseded_selectors' => array_keys($this->supersededRuntimeSelectors), 'shell_artifacts' => $shellArtifacts, @@ -1841,6 +1866,11 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca } if ( 'dl' === $tagName ) { + $descriptionList = $this->descriptionListBlockFromElement($element); + if ( null !== $descriptionList ) { + return $descriptionList; + } + $metadataGrid = $this->metadataGridBlockFromElement($element); if ( null !== $metadataGrid ) { return $metadataGrid; @@ -5505,6 +5535,137 @@ private function definitionListItems(DOMElement $list): array return $items; } + /** + * Preserve a direct, valid description list as a static companion block. + * Wrapped or malformed lists deliberately return null for the established + * core/list and core/group safety paths below. + * + * @return array|null + */ + private function descriptionListBlockFromElement(DOMElement $list): ?array + { + $groups = array(); + $group = null; + + foreach ( $list->childNodes as $child ) { + if ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) { + continue; + } + if ( ! $child instanceof DOMElement ) { + return null; + } + + $tag = strtolower($child->tagName); + if ( ! in_array($tag, array( 'dt', 'dd' ), true) || ! $this->descriptionListItemSupportsRichText($child) ) { + return null; + } + if ( 'dt' === $tag ) { + if ( null === $group || array() !== $group['descriptions'] ) { + if ( null !== $group ) { + $groups[] = $group; + } + $group = array( 'terms' => array(), 'descriptions' => array() ); + } + $group['terms'][] = $this->descriptionListItem($child); + continue; + } + if ( 'dd' !== $tag || null === $group || array() === $group['terms'] ) { + return null; + } + $group['descriptions'][] = $this->descriptionListItem($child); + } + + if ( null === $group || array() === $group['descriptions'] ) { + return null; + } + $groups[] = $group; + + if ( ! $this->descriptionListBlockGenerated ) { + $this->generatedBlocks[] = ( new DescriptionListBlockGenerator() )->definition(); + $this->descriptionListBlockGenerated = true; + } + + $markup = $this->descriptionListMarkup($list, $groups); + return array( + 'blockName' => DescriptionListBlockGenerator::NAME, + 'attrs' => array_filter(array( + 'className' => $list->getAttribute('class'), + 'style' => $list->getAttribute('style'), + 'groups' => $groups, + ), static fn (mixed $value): bool => '' !== $value), + 'innerBlocks' => array(), + 'innerHTML' => $markup, + 'innerContent' => array( $markup ), + ); + } + + private function descriptionListItemSupportsRichText(DOMElement $element): bool + { + foreach ( $element->childNodes as $child ) { + if ( XML_TEXT_NODE === $child->nodeType ) { + continue; + } + if ( ! $child instanceof DOMElement ) { + return false; + } + + $tag = strtolower($child->tagName); + if ( 'a' !== $tag && 'br' !== $tag && ! $this->isInlineContentElement($tag) ) { + return false; + } + foreach ( $child->attributes as $attribute ) { + if ( 'a' !== $tag || ! in_array(strtolower($attribute->name), array( 'href', 'target', 'rel' ), true) ) { + return false; + } + } + if ( ! $this->descriptionListItemSupportsRichText($child) ) { + return false; + } + } + + return true; + } + + /** @return array */ + private function descriptionListItem(DOMElement $element): array + { + return array_filter(array( + 'content' => $this->innerHtml($element), + 'className' => $element->getAttribute('class'), + 'style' => $element->getAttribute('style'), + ), static fn (mixed $value): bool => '' !== $value); + } + + /** @param array> $groups */ + private function descriptionListMarkup(DOMElement $list, array $groups): string + { + $markup = 'descriptionListMarkupAttributes(array( + 'className' => $list->getAttribute('class'), + 'style' => $list->getAttribute('style'), + )) . '>'; + foreach ( $groups as $group ) { + foreach ( $group['terms'] as $term ) { + $markup .= 'descriptionListMarkupAttributes($term) . '>' . ($term['content'] ?? '') . ''; + } + foreach ( $group['descriptions'] as $description ) { + $markup .= 'descriptionListMarkupAttributes($description) . '>' . ($description['content'] ?? '') . ''; + } + } + return $markup . ''; + } + + /** @param array $attributes */ + private function descriptionListMarkupAttributes(array $attributes): string + { + $markup = ''; + foreach ( array( 'className' => 'class', 'style' => 'style' ) as $key => $name ) { + if ( '' !== (string) ($attributes[$key] ?? '') ) { + $markup .= ' ' . $name . '="' . htmlspecialchars((string) $attributes[$key], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"'; + } + } + return $markup; + } + /** * Convert compact label/value grids into native blocks without letting the * paragraph block's default margins turn each record into prose flow. diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 205f8640..e336265c 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -320,12 +320,9 @@ public function match(DOMElement $element, PatternContext $context): ?array )->toArray(); $metadataDefinitionListMarkup = (string) ($metadataDefinitionList['serialized_blocks'] ?? ''); $metadataDefinitionListBlock = $metadataDefinitionList['blocks'][0] ?? array(); -$assert('core/group' === ($metadataDefinitionListBlock['blockName'] ?? null), 'grid definition lists convert to a native group grid'); -$assert(4 === count($metadataDefinitionListBlock['innerBlocks'] ?? array()), 'definition-list metadata grid preserves each label and value cell'); -$assert(str_contains($metadataDefinitionListMarkup, 'facts') && ! str_contains($metadataDefinitionListMarkup, 'is-layout-grid'), 'definition-list metadata group preserves stylesheet-owned grid tracks without Gutenberg layout classes'); -$assert(! str_contains($metadataDefinitionListMarkup, 'gap:'), 'definition-list metadata group does not collapse source gaps into a Gutenberg gap shorthand'); -$assert(4 === substr_count($metadataDefinitionListMarkup, 'margin-top:0;margin-bottom:0'), 'metadata cells suppress paragraph flow margins'); -$assert('pass' === ($metadataDefinitionList['source_reports']['wp_block_validity']['status'] ?? ''), 'definition-list metadata grid emits editor-valid native blocks'); +$assert('blocks-engine/description-list' === ($metadataDefinitionListBlock['blockName'] ?? null), 'direct definition lists use the semantic companion block'); +$assert(str_contains($metadataDefinitionListMarkup, '
Office
North Hall
'), 'definition-list markup retains source dl, dt, and dd semantics'); +$assert('pass' === ($metadataDefinitionList['source_reports']['wp_block_validity']['status'] ?? ''), 'description-list block emits editor-valid static markup'); $repeatedMetadataRows = ( new HtmlTransformer() )->transform( '
RoleCoordinator
LocationRemote
' @@ -335,15 +332,15 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(! str_contains($repeatedMetadataMarkup, 'Role Coordinator'), 'repeated metadata rows do not flatten labels and values into prose'); $ordinaryDefinitionList = ( new HtmlTransformer() )->transform('
First topic
A full explanatory paragraph.
Second topic
Another explanatory paragraph.
')->toArray(); -$assert('core/list' === ($ordinaryDefinitionList['blocks'][0]['blockName'] ?? null), 'ordinary definition lists without layout evidence remain lists'); +$assert('blocks-engine/description-list' === ($ordinaryDefinitionList['blocks'][0]['blockName'] ?? null), 'ordinary direct definition lists retain semantic markup'); $ordinaryProseRows = ( new HtmlTransformer() )->transform('

First paragraph.

Second paragraph.

Third paragraph.

Fourth paragraph.

')->toArray(); $assert(0 === substr_count((string) ($ordinaryProseRows['serialized_blocks'] ?? ''), 'margin-top:0;margin-bottom:0'), 'ordinary grid prose is not misclassified as metadata rows'); $horizontalFlexDefinitionList = ( new HtmlTransformer() )->transform('
One
First
Two
Second
')->toArray(); -$assert('core/list' === ($horizontalFlexDefinitionList['blocks'][0]['blockName'] ?? null), 'ordinary horizontal flex definition lists remain lists without wrapping evidence'); +$assert('blocks-engine/description-list' === ($horizontalFlexDefinitionList['blocks'][0]['blockName'] ?? null), 'direct flex definition lists retain semantic markup'); $wrappingFlexDefinitionList = ( new HtmlTransformer() )->transform('
One
First
Two
Second
')->toArray(); $wrappingFlexMarkup = (string) ($wrappingFlexDefinitionList['serialized_blocks'] ?? ''); -$assert('core/group' === ($wrappingFlexDefinitionList['blocks'][0]['blockName'] ?? null), 'wrapping flex definition lists with repeated records convert to native groups'); -$assert(str_contains($wrappingFlexMarkup, 'terms') && ! str_contains($wrappingFlexMarkup, 'is-layout-flex') && ! str_contains($wrappingFlexMarkup, 'gap:'), 'wrapping flex metadata preserves separate source row and column gaps without Gutenberg shorthand'); +$assert('blocks-engine/description-list' === ($wrappingFlexDefinitionList['blocks'][0]['blockName'] ?? null), 'wrapping direct definition lists retain semantic markup'); +$assert(str_contains($wrappingFlexMarkup, '
') && ! str_contains($wrappingFlexMarkup, 'is-layout-flex'), 'wrapping definition lists preserve stylesheet classes without Gutenberg layout classes'); $navigationResult = ( new HtmlTransformer() )->transform('')->toArray(); $navigationBlock = $navigationResult['blocks'][0] ?? array(); @@ -3514,6 +3511,22 @@ public function detect(string $content): bool assertSame('fixture:format-bridge', $contextualBridgeResult['provenance'][0]['source'], 'convertResult should expose generic provenance source metadata.'); assertSame('contract-test', $contextualBridgeResult['provenance'][0]['scope'], 'convertResult should expose generic provenance scope metadata.'); +$descriptionListArtifact = ( new ArtifactCompiler() )->compile(array( + 'site' => array( 'slug' => 'description-lists' ), + 'files' => array( + 'index.html' => '
Home
Primary
', + 'contact.html' => '
Office
North Hall
', + 'about.html' => '
Office
North Hall
', + ), +))->toArray(); +$descriptionListPayload = $descriptionListArtifact['source_reports']['companion_plugin_payload'] ?? array(); +$descriptionListBlocks = $descriptionListPayload['blocks'] ?? array(); +$assert(1 === count($descriptionListBlocks), 'multi-page description lists project one deduplicated companion definition'); +$assert('blocks-engine/description-list' === ($descriptionListBlocks[0]['block_json']['name'] ?? null), 'companion payload projects the generated description-list block metadata'); +$assert(str_contains((string) ($descriptionListBlocks[0]['assets']['index.js'] ?? ''), 'registerBlockType'), 'companion payload projects the installable editor asset'); +$assert('semantic-description-list' === ($descriptionListArtifact['source_reports']['gutenberg_gaps'][0]['id'] ?? null), 'multi-page artifacts aggregate the Gutenberg gap once'); +$assert('https://github.com/WordPress/gutenberg/pull/20760' === ($descriptionListArtifact['source_reports']['gutenberg_gaps'][0]['references'][1] ?? null), 'gap diagnostic records the stalled Gutenberg implementation context'); + fwrite(STDOUT, "Format bridge scaffold passed.\n"); function assertSame(mixed $expected, mixed $actual, string $message): void diff --git a/php-transformer/tests/fixtures/parity/html-expanded-gap-primitives.json b/php-transformer/tests/fixtures/parity/html-expanded-gap-primitives.json index 2b0b6e58..c9b1c91b 100644 --- a/php-transformer/tests/fixtures/parity/html-expanded-gap-primitives.json +++ b/php-transformer/tests/fixtures/parity/html-expanded-gap-primitives.json @@ -20,8 +20,7 @@ { "path": "blocks.0.innerBlocks.0", "name": "core/group", "attrs": { "className": "decorative-placeholder be-inline-geometry-bae6fdaf6367fd3f2664c5430c496f2e013c09cb1cdb789296bbc1f66a889481" } }, { "path": "blocks.0.innerBlocks.1", "name": "core/group", "attrs": { "className": "media-shell" } }, { "path": "blocks.0.innerBlocks.1.innerBlocks.0", "name": "core/image", "attrs": { "className": "is-resized", "url": "logo.svg", "alt": "Logo", "width": "120", "height": "80" } }, - { "path": "blocks.0.innerBlocks.2", "name": "core/list" }, - { "path": "blocks.0.innerBlocks.2.innerBlocks.0", "name": "core/list-item", "attrs": { "content": "Term Definition" } }, + { "path": "blocks.0.innerBlocks.2", "name": "blocks-engine/description-list", "attrs": { "groups": [{ "terms": [{ "content": "Term" }], "descriptions": [{ "content": "Definition" }]}] } }, { "path": "blocks.0.innerBlocks.3", "name": "core/list", "attrs": { "className": "checks" } }, { "path": "blocks.0.innerBlocks.3.innerBlocks.0", "name": "core/list-item", "attrs": { "content": "Parent" } }, { "path": "blocks.0.innerBlocks.3.innerBlocks.0.innerBlocks.0", "name": "core/list" }, diff --git a/php-transformer/tests/unit/description-list-block.php b/php-transformer/tests/unit/description-list-block.php new file mode 100644 index 00000000..09a20566 --- /dev/null +++ b/php-transformer/tests/unit/description-list-block.php @@ -0,0 +1,63 @@ +definition(); +$assert(DescriptionListBlockGenerator::NAME === ($definition['block_json']['name'] ?? null), 'block metadata uses the stable companion name'); +$assert(3 === ($definition['block_json']['apiVersion'] ?? null), 'block metadata uses apiVersion 3'); +$assert(false === ($definition['block_json']['supports']['html'] ?? null), 'block metadata disables raw HTML editing'); +$assert('file:./index.js' === ($definition['block_json']['editorScript'] ?? null), 'block metadata declares the editor asset'); +$assert(str_contains((string) ($definition['assets']['index.js'] ?? ''), 'RawHTML'), 'editor asset serializes semantic static markup'); +$assert(str_contains((string) ($definition['assets']['index.js'] ?? ''), 'escapeAttribute'), 'editor asset escapes presentation attributes'); +$assert(str_contains((string) ($definition['assets']['index.js'] ?? ''), 'attributes: attributes'), 'client registration declares the block attribute schema'); +$assert(str_contains((string) ($definition['assets']['index.js'] ?? ''), 'safeCssText'), 'editor rendering sanitizes captured inline styles through the browser CSSOM'); +$assert(str_contains((string) ($definition['assets']['index.js'] ?? ''), 'useEffect'), 'editor rendering scopes exact inline CSS without React style-object loss'); + +$html = '
Office location
Alias
North Hall
Weekdays
Hours
09:00 & 17:00
'; +$result = ( new HtmlTransformer() )->transform($html)->toArray(); +$block = $result['blocks'][0] ?? array(); +$groups = $block['attrs']['groups'] ?? array(); +$serialized = (string) ($result['serialized_blocks'] ?? ''); +$assert(DescriptionListBlockGenerator::NAME === ($block['blockName'] ?? null), 'direct valid list maps to the companion block'); +$assert(2 === count($groups) && 2 === count($groups[0]['terms'] ?? array()) && 2 === count($groups[0]['descriptions'] ?? array()), 'term and description ordering is grouped deterministically'); +$assert('Office location' === ($groups[0]['terms'][0]['content'] ?? null) && 'North Hall' === ($groups[0]['descriptions'][0]['content'] ?? null), 'nested inline markup is preserved in the payload'); +$assert(str_contains($serialized, '
Office location
Alias
North Hall
Weekdays
Hours
09:00 & 17:00
'), 'static markup retains semantics and escapes attributes exactly once'); +$assert('pass' === ($result['source_reports']['wp_block_validity']['status'] ?? null), 'static companion serialization is editor-valid'); +$assert(1 === count($result['source_reports']['generated_blocks'] ?? array()), 'one definition is generated for multiple lists in one document'); +$assert('semantic-description-list' === ($result['source_reports']['gutenberg_gaps'][0]['id'] ?? null), 'source report records the Gutenberg description-list gap'); +$assert(str_contains((string) ($result['diagnostics'][count($result['diagnostics']) - 1]['references'][0] ?? ''), 'gutenberg/issues/4880'), 'diagnostic links the missing core capability to Gutenberg issue #4880'); + +foreach ( array( + '
Description before term
Term
Description
', + '
Term
', + '
Term
Description
', + '
Term
Description
Unexpected wrapper
', + '
Term

Block-level description

', + '
Term
Description
', +) as $malformed ) { + $converted = ( new HtmlTransformer() )->transform($malformed)->toArray(); + $assert(DescriptionListBlockGenerator::NAME !== ($converted['blocks'][0]['blockName'] ?? null), 'malformed or wrapped lists retain conservative fallback conversion'); + $assert(array() === ($converted['source_reports']['generated_blocks'] ?? null), 'malformed or wrapped lists do not generate a companion definition'); +} + +if ( 0 < $failures ) { + fwrite(STDERR, "Description-list block unit tests: {$passes} passed, {$failures} FAILED" . PHP_EOL); + exit(1); +} +fwrite(STDOUT, "Description-list block unit tests: {$passes} passed" . PHP_EOL);