diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 630d1ebb..886f3b73 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -71,7 +71,7 @@ "php tests/unit/authored-marquee-block.php", "php tests/unit/responsive-document-variants.php", "php tests/unit/editability-report.php", - "php tests/unit/depth-pressure-compression.php", + "php tests/unit/projected-branch-compression.php", "php tests/unit/core-block-capability-matrix.php", "php tests/unit/list-item-lowering.php", "php tests/unit/artifact-normalizer-idempotence.php", diff --git a/php-transformer/src/HtmlToBlocks/Generators/LayoutShellBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/LayoutShellBlockGenerator.php index 75472f53..f5c5b65f 100644 --- a/php-transformer/src/HtmlToBlocks/Generators/LayoutShellBlockGenerator.php +++ b/php-transformer/src/HtmlToBlocks/Generators/LayoutShellBlockGenerator.php @@ -15,14 +15,49 @@ public function definition(string $blockName): array var InnerBlocks = blockEditor.InnerBlocks; var useBlockProps = blockEditor.useBlockProps; function reactStyle( value ) { - var probe = document.createElement( 'div' ); var style = {}; - probe.setAttribute( 'style', value || '' ); - for ( var index = 0; index < probe.style.length; index++ ) { - var property = probe.style.item( index ); + var declaration = ''; + var depth = 0; + var quote = ''; + function appendDeclaration( source ) { + var separator = -1; + var innerDepth = 0; + var innerQuote = ''; + for ( var index = 0; index < source.length; index++ ) { + var character = source.charAt( index ); + if ( innerQuote ) { + if ( character === '\\' ) { index++; } + else if ( character === innerQuote ) { innerQuote = ''; } + continue; + } + if ( character === '"' || character === "'" ) { innerQuote = character; } + else if ( character === '(' ) { innerDepth++; } + else if ( character === ')' && innerDepth ) { innerDepth--; } + else if ( character === ':' && ! innerDepth ) { separator = index; break; } + } + if ( separator < 1 ) { return; } + var property = source.slice( 0, separator ).trim(); + var propertyValue = source.slice( separator + 1 ).trim(); + if ( ! property || ! propertyValue ) { return; } var key = property.indexOf( '--' ) === 0 ? property : property.replace( /-([a-z])/g, function( _, letter ) { return letter.toUpperCase(); } ); - style[ key ] = probe.style.getPropertyValue( property ); + style[ key ] = propertyValue; } + value = value || ''; + for ( var index = 0; index < value.length; index++ ) { + var character = value.charAt( index ); + if ( quote ) { + declaration += character; + if ( character === '\\' && index + 1 < value.length ) { declaration += value.charAt( ++index ); } + else if ( character === quote ) { quote = ''; } + continue; + } + if ( character === '"' || character === "'" ) { quote = character; declaration += character; } + else if ( character === '(' ) { depth++; declaration += character; } + else if ( character === ')' && depth ) { depth--; declaration += character; } + else if ( character === ';' && ! depth ) { appendDeclaration( declaration ); declaration = ''; } + else { declaration += character; } + } + appendDeclaration( declaration ); return style; } function wrapperProps( attributes ) { @@ -44,15 +79,58 @@ function wrappedContent( wrappers, content, outerProps ) { } return content; } + function readableName( value ) { + value = String( value || '' ).trim(); + if ( ! value || value.indexOf( 'blocks-engine-' ) === 0 || value.indexOf( 'be-inline-' ) === 0 || value.indexOf( 'comp-' ) === 0 || /^[a-f0-9]{16,}$/.test( value ) || /^[a-z]{1,4}\d[a-z0-9_]*$/i.test( value ) || /^[A-Z][a-z][A-Z][a-z]{2,}$/.test( value ) ) { return ''; } + value = value.replace( /^_+/, '' ).replace( /_[a-z0-9]{5,}_\d+$/i, '' ); + value = value.replace( /([a-z])([A-Z])/g, '$1 $2' ).replace( /[-_]+/g, ' ' ).replace( /\s+/g, ' ' ).trim(); + if ( ! value || 40 < value.length ) { return ''; } + return value.replace( /\b\w/g, function( letter ) { return letter.toUpperCase(); } ); + } + function shellLabel( wrappers ) { + var semanticTags = { header: 'Header', nav: 'Navigation', main: 'Main', section: 'Section', article: 'Article', aside: 'Aside', footer: 'Footer' }; + var genericClasses = { container: true, root: true, section: true, responsive: true, background: true, item: true, undefined: true, 'builder-root': true, 'wp-block-group': true }; + var semantic = ''; + var detail = ''; + var component = false; + ( wrappers || [] ).forEach( function( wrapper ) { + var tagName = String( wrapper.tagName || 'div' ).toLowerCase(); + var attributes = wrapper.attributes || {}; + if ( ! semantic && semanticTags[ tagName ] ) { semantic = semanticTags[ tagName ]; } + if ( ! detail ) { detail = readableName( attributes.id ); } + if ( ! detail ) { + var classNames = String( attributes.class || '' ).split( /\s+/ ); + if ( -1 !== classNames.indexOf( 'builder-root' ) ) { component = true; } + classNames.some( function( className ) { + if ( genericClasses[ className ] ) { return false; } + if ( /^[A-Za-z]+$/.test( className ) && /[a-z][A-Z]/.test( className ) ) { return false; } + var candidate = readableName( className ); + if ( candidate === 'Root' || candidate === 'Internal Container Root' ) { return false; } + detail = candidate; + return !! detail; + } ); + } + } ); + if ( semantic && detail && semantic.toLowerCase() !== detail.toLowerCase() ) { return semantic + ': ' + detail; } + if ( semantic ) { return semantic; } + if ( detail ) { return 'Layout: ' + detail; } + if ( component ) { return 'Component container'; } + return 'Layout shell (' + ( wrappers || [] ).length + ' wrappers)'; + } function savedContent( wrappers ) { return wrappedContent( wrappers, createElement( InnerBlocks.Content ) ); } function edit( props ) { var wrappers = props.attributes.wrappers || []; var content = createElement( InnerBlocks ); - return wrappers.length ? wrappedContent( wrappers, content, useBlockProps ) : createElement( 'div', useBlockProps(), content ); + if ( wrappers.length ) { content = wrappedContent( wrappers, content ); } + return createElement( 'div', useBlockProps( { style: { display: 'contents' } } ), content ); } blocks.registerBlockType( '__BLOCK_NAME__', { attributes: { wrappers: { type: 'array', default: [] } }, - supports: { html: false, reusable: false }, + supports: { html: false, reusable: false, renaming: false }, + __experimentalLabel: function( attributes, options ) { + var context = options && options.context; + return context === 'list-view' || context === 'breadcrumb' ? shellLabel( attributes.wrappers ) : null; + }, edit: edit, save: function( props ) { return savedContent( props.attributes.wrappers ); } } ); @@ -67,7 +145,7 @@ function edit( props ) { 'category' => 'design', 'editorScript' => 'file:./index.js', 'attributes' => array('wrappers' => array('type' => 'array', 'default' => array())), - 'supports' => array('html' => false, 'reusable' => false), + 'supports' => array('html' => false, 'reusable' => false, 'renaming' => false), ), 'assets' => array('index.js' => str_replace('__BLOCK_NAME__', $blockName, $script)), 'script_dependencies' => array('index.js' => array('wp-blocks', 'wp-block-editor', 'wp-element')), diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 37ab88a2..685e0509 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -120,7 +120,6 @@ final class HtmlTransformer private const MAX_INTERACTION_CANDIDATES = 100; private const MAX_CAPTURED_LAYOUT_SOURCE_NESTING = 20; - private const MAX_NATIVE_LIST_VIEW_DEPTH = 20; /** * Core blocks this transformer can produce, keyed by the contract that @@ -871,13 +870,6 @@ public function transform(string $html, array $options = array()): TransformerRe $this->collectGeneratedComponentCandidates($body); $blocks = $this->navigationBlockNormalizer->normalize($this->convertChildren($body, $fallbacks, true), $this->transformationProvenance()->sources(), $this->transformationProvenance()->sourceBaseHiddenStates()); $blocks = $this->compressProjectedGroupChains($blocks); - // Last resort under measured depth pressure: past this cap the - // editability policy hard-fails the document anyway, so admit exact - // two-wrapper branch shells whether or not layout-geometry proofs - // accompanied the artifact. - if (self::MAX_NATIVE_LIST_VIEW_DEPTH < $this->blockTreeDepth($blocks)) { - $blocks = $this->compressProjectedGroupChains($blocks, true); - } $fallbacks = array_merge($fallbacks, $this->transformationEvidence()->responsiveImageFallbacks()); if (! $this->session->usesFallbackReductionMode()) { $blocks = $this->reduceCoreHtmlFallbackBlocks($blocks); @@ -6073,13 +6065,13 @@ private function semanticGroupTagName(DOMElement $element): ?string } /** @param array> $blocks @return array> */ - private function compressProjectedGroupChains(array $blocks, bool $depthPressure = false): array + private function compressProjectedGroupChains(array $blocks): array { - return array_values(array_map(fn (array $block): array => $this->compressProjectedGroupBlock($block, $depthPressure), $blocks)); + return array_values(array_map(fn (array $block): array => $this->compressProjectedGroupBlock($block), $blocks)); } /** @param array $block @return array */ - private function compressProjectedGroupBlock(array $block, bool $depthPressure = false): array + private function compressProjectedGroupBlock(array $block): array { $chain = array(); $cursor = $block; @@ -6102,7 +6094,7 @@ private function compressProjectedGroupBlock(array $block, bool $depthPressure = && null !== ($branchDescriptor = $this->groupWrapperDescriptor($cursor)) ) { $chain[] = array('block' => $cursor, 'descriptor' => $branchDescriptor); - $terminalBlocks = $this->compressProjectedGroupChains($cursorChildren, $depthPressure); + $terminalBlocks = $this->compressProjectedGroupChains($cursorChildren); $terminal = array(); $terminalIsShell = false; $branchEndpoint = true; @@ -6118,14 +6110,14 @@ private function compressProjectedGroupBlock(array $block, bool $depthPressure = $terminalIsShell = false; $emptyEndpoint = true; } else { - $terminal = array() !== $chain ? $this->compressProjectedGroupBlock($cursor, $depthPressure) : $cursor; + $terminal = array() !== $chain ? $this->compressProjectedGroupBlock($cursor) : $cursor; $terminalIsShell = $this->isLayoutShellBlock($terminal); $terminalBlocks = $terminalIsShell ? $terminal['innerBlocks'] : array($terminal); } $projectedCount = count(array_filter($chain, fn (array $entry): bool => $this->hasSourceProjectionClass($entry['block']))); - $minimumLength = $branchEndpoint ? ($depthPressure ? 2 : 3) : ($emptyEndpoint ? 2 : ($projectedCount === count($chain) ? 2 : 3)); + $minimumLength = $branchEndpoint || $emptyEndpoint ? 2 : ($projectedCount === count($chain) ? 2 : 3); if ((0 < $projectedCount && $minimumLength <= count($chain)) || (1 === count($chain) && $terminalIsShell && 0 < $projectedCount)) { $wrappers = array_column($chain, 'descriptor'); $terminalRuntimeOwned = $terminalIsShell && !empty($terminal['_editability_runtime_owned']); @@ -6155,23 +6147,11 @@ private function compressProjectedGroupBlock(array $block, bool $depthPressure = } if (is_array($block['innerBlocks'] ?? null)) { - $block['innerBlocks'] = $this->compressProjectedGroupChains($block['innerBlocks'], $depthPressure); + $block['innerBlocks'] = $this->compressProjectedGroupChains($block['innerBlocks']); } return $block; } - /** @param array> $blocks */ - private function blockTreeDepth(array $blocks): int - { - $maximum = 0; - foreach ($blocks as $block) { - if (!is_array($block)) continue; - $children = is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array(); - $maximum = max($maximum, 1 + $this->blockTreeDepth($children)); - } - return $maximum; - } - /** @param array $block */ private function isLayoutShellBlock(array $block): bool { @@ -6221,14 +6201,20 @@ private function groupWrapperDescriptor(array $block): ?array foreach ($element->attributes ?? array() as $attribute) { $attributes[strtolower($attribute->nodeName)] = (string) $attribute->nodeValue; } - // Core serializes style declarations differently from React's save path - // (notably unitless zero lengths). Keep styled wrappers as core groups. - if ('' !== trim((string) ($attributes['style'] ?? ''))) { + if (!$this->isLayoutShellSerializableStyle((string) ($attributes['style'] ?? ''))) { return null; } return array('tagName' => $tagName, 'attributes' => $attributes, 'opening' => $opening, 'closing' => $closing); } + private function isLayoutShellSerializableStyle(string $style): bool + { + // React style objects cannot express declaration priority. Other + // canonical serialized values remain strings and are parsed directly + // by layout-shell without a normalizing CSSOM round trip. + return !preg_match('/!\s*important/i', $style); + } + /** * @return array */ diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index 7ab42b85..f151e22b 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php @@ -600,9 +600,12 @@ private function topLevelShellRange(string $markup, string $area, string $candid if ($closing) { --$depth; if (is_array($candidate) && null === $candidate['end'] && $depth === $candidate['depth']) $candidate['end'] = $offset + strlen($full); continue; } $selfClosing = str_ends_with(trim($full), '/-->'); $name = $matches[2][$index][0]; $attributes = trim($matches[3][$index][0] ?? ''); - if (0 === $depth && 'group' === $name) { + if (0 === $depth && ('group' === $name || str_ends_with($name, '/layout-shell'))) { $decoded = json_decode($attributes, true); - if (is_array($decoded) && $area === ($decoded['tagName'] ?? null)) { + $tagName = 'group' === $name + ? ($decoded['tagName'] ?? null) + : ($decoded['wrappers'][0]['tagName'] ?? null); + if (is_array($decoded) && $area === $tagName) { if (null !== $candidate) return null; $candidate = array('start' => $offset, 'depth' => $depth, 'end' => $selfClosing ? $offset + strlen($full) : null); } diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index be1a9314..426929bf 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -3447,6 +3447,7 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter $canonicalFooterPart = array_values(array_filter($canonicalShellPlan['template_parts'] ?? array(), static fn (array $part): bool => 'footer' === ($part['area'] ?? '')))[0] ?? array(); $canonicalEntryPage = array_values(array_filter($canonicalShellPlan['pages'] ?? array(), static fn (array $page): bool => 'index.html' === ($page['source_path'] ?? '')))[0] ?? array(); $assert(! str_contains((string) ($canonicalEntryPage['canonical_block_markup'] ?? ''), 'Get started') && str_contains((string) ($canonicalHeaderPart['canonical_block_markup'] ?? ''), 'Get started'), 'canonical entry header is projected only to its shell part, without duplicate post-content chrome'); +$assert(str_contains((string) ($canonicalHeaderPart['canonical_block_markup'] ?? ''), '" }, + { "path": "serialized_blocks", "assert": "contains", "value": "" }, { "path": "serialized_blocks", "assert": "contains", "value": "" }, { "path": "serialized_blocks", "assert": "contains", "value": "
" }, { "path": "serialized_blocks", "assert": "contains", "value": "assets/materialized-svg/" }, diff --git a/php-transformer/tests/fixtures/parity/html-structured-list-descendant-css-row-styles.json b/php-transformer/tests/fixtures/parity/html-structured-list-descendant-css-row-styles.json index 510fd2fb..2cb4a367 100644 --- a/php-transformer/tests/fixtures/parity/html-structured-list-descendant-css-row-styles.json +++ b/php-transformer/tests/fixtures/parity/html-structured-list-descendant-css-row-styles.json @@ -19,16 +19,15 @@ } }, "expected_blocks": [ - { "path": "blocks.0", "name": "core/group", "attrs": { "className": "tracklist" } }, - { "path": "blocks.0.innerBlocks.0", "name": "core/group", "attrs": { "layout": { "type": "flex" } } }, - { "path": "blocks.0.innerBlocks.0.innerBlocks.0", "name": "core/paragraph", "attrs": { "className": "track-num", "content": "01" } }, - { "path": "blocks.0.innerBlocks.0.innerBlocks.1", "name": "core/paragraph", "attrs": { "className": "track-title", "content": "Kindling" } }, - { "path": "blocks.0.innerBlocks.0.innerBlocks.2", "name": "core/paragraph", "attrs": { "className": "track-dur", "content": "3:42" } } + { "path": "blocks.0", "name": "custom/layout-shell" }, + { "path": "blocks.0.innerBlocks.0", "name": "core/paragraph", "attrs": { "className": "track-num", "content": "01" } }, + { "path": "blocks.0.innerBlocks.1", "name": "core/paragraph", "attrs": { "className": "track-title", "content": "Kindling" } }, + { "path": "blocks.0.innerBlocks.2", "name": "core/paragraph", "attrs": { "className": "track-dur", "content": "3:42" } } ], "expected_fallbacks": [], "expect": [ { "path": "status", "assert": "equals", "value": "success" }, - { "path": "serialized_blocks", "assert": "contains", "value": "\"layout\":{\"type\":\"flex\"}" }, + { "path": "serialized_blocks", "assert": "contains", "value": "is-layout-flex wp-block-group-is-layout-flex" }, { "path": "serialized_blocks", "assert": "contains", "value": "blocks-engine-source-li-" }, { "path": "serialized_blocks", "assert": "contains", "value": "padding-top:0.9rem" }, { "path": "serialized_blocks", "assert": "contains", "value": "

Kindling

" }, diff --git a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php index 160d0997..60c3d748 100644 --- a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php +++ b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php @@ -267,13 +267,15 @@ ), ) )->toArray(); $externalLayoutPage = (string) ($externalLayouts['source_reports']['wordpress_site_plan']['pages'][0]['canonical_block_markup'] ?? ''); -$externalLayoutCard = $externalLayouts['blocks'][0]['innerBlocks'][0] ?? array(); +$externalLayoutCard = $externalLayouts['blocks'][0] ?? array(); $externalLayoutCardChildren = $externalLayoutCard['innerBlocks'] ?? array(); $externalLayoutCss = implode("\n", array_column($externalLayouts['assets'] ?? array(), 'content')); $assert( str_contains($externalLayoutPage, 'hero-visual blocks-engine-css-owned-layout blocks-engine-css-owned-grid') && str_contains($externalLayoutPage, 'artifact-card blocks-engine-css-owned-layout') && ! str_contains($externalLayoutPage, 'is-layout-grid') + && str_ends_with((string) ($externalLayoutCard['blockName'] ?? ''), '/layout-shell') + && 2 === count($externalLayoutCard['attrs']['wrappers'] ?? array()) && 4 === count($externalLayoutCard['innerBlocks'] ?? array()) && 'core/paragraph' === ($externalLayoutCardChildren[0]['blockName'] ?? '') && 'core/paragraph' === ($externalLayoutCardChildren[1]['blockName'] ?? '') diff --git a/php-transformer/tests/unit/author-selector-semantics.php b/php-transformer/tests/unit/author-selector-semantics.php index 9859aa83..dafd94c3 100644 --- a/php-transformer/tests/unit/author-selector-semantics.php +++ b/php-transformer/tests/unit/author-selector-semantics.php @@ -472,11 +472,12 @@ 'author grid cards retain direct child order and placement selectors through core/group' ); $nestedGridItem = $transform('
LabelValue
'); -$nestedGridItemBlock = $nestedGridItem['blocks'][0]['innerBlocks'][0] ?? array(); +$nestedGridItemBlock = $nestedGridItem['blocks'][0] ?? array(); $nestedGridItemChildren = $nestedGridItemBlock['innerBlocks'] ?? array(); $nestedGridItemCss = $css($nestedGridItem); $assert( - 'core/group' === ($nestedGridItemBlock['blockName'] ?? '') + str_ends_with((string) ($nestedGridItemBlock['blockName'] ?? ''), '/layout-shell') + && 2 === count($nestedGridItemBlock['attrs']['wrappers'] ?? array()) && 2 === count($nestedGridItemChildren) && 'core/paragraph' === ($nestedGridItemChildren[0]['blockName'] ?? '') && str_contains((string) ($nestedGridItemChildren[0]['attrs']['className'] ?? ''), 'blocks-engine-inline-layout-carrier') @@ -546,8 +547,8 @@ $assert(array() === ($logoControl['source_reports']['conversion_report']['gutenberg_incompatibilities']['author_layout_topology'] ?? array()), 'SVG-to-image materialization preserves author-layout topology without a false wrapper-change diagnostic'); $structuredAnchor = $transform(''); -$structuredAnchorBlock = $structuredAnchor['blocks'][0]['innerBlocks'][0] ?? array(); -$assert(! str_contains((string) ($structuredAnchor['serialized_blocks'] ?? ''), 'wp-block-blocks-engine-author-layout') && 0 < count($structuredAnchorBlock['innerBlocks'] ?? array()), 'block-structured anchor descendants retain native blocks without a companion block'); +$structuredAnchorBlock = $structuredAnchor['blocks'][0] ?? array(); +$assert(! str_contains((string) ($structuredAnchor['serialized_blocks'] ?? ''), 'wp-block-blocks-engine-author-layout') && str_ends_with((string) ($structuredAnchorBlock['blockName'] ?? ''), '/layout-shell') && 2 === count($structuredAnchorBlock['innerBlocks'] ?? array()), 'block-structured anchor descendants retain native blocks without a companion block'); $instance = new HtmlTransformer(); $first = $instance->transform('

First

')->toArray(); @@ -620,10 +621,10 @@ $assert(1 === substr_count($nestedFlexMarkup, '