Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand All @@ -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 ); }
} );
Expand All @@ -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')),
Expand Down
46 changes: 16 additions & 30 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -6073,13 +6065,13 @@ private function semanticGroupTagName(DOMElement $element): ?string
}

/** @param array<int, array<string, mixed>> $blocks @return array<int, array<string, mixed>> */
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<string, mixed> $block @return array<string, mixed> */
private function compressProjectedGroupBlock(array $block, bool $depthPressure = false): array
private function compressProjectedGroupBlock(array $block): array
{
$chain = array();
$cursor = $block;
Expand All @@ -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;
Expand All @@ -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']);
Expand Down Expand Up @@ -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<int,array<string,mixed>> $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<string, mixed> $block */
private function isLayoutShellBlock(array $block): bool
{
Expand Down Expand Up @@ -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<string, mixed>
*/
Expand Down
7 changes: 5 additions & 2 deletions php-transformer/src/WordPressSitePlan/WordPressSitePlan.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? ''), '<!-- wp:') && ! str_contains((string) ($canonicalEntryPage['canonical_block_markup'] ?? ''), '<!-- wp:custom/layout-shell'), 'canonical shell extraction recognizes a semantic outer wrapper projected through a layout shell');
$assert(str_contains((string) ($canonicalFooterPart['canonical_block_markup'] ?? ''), 'Global footer'), 'canonical entry footer part preserves global footer content');
$assert(! str_contains((string) ($canonicalHeaderPart['canonical_block_markup'] ?? ''), '<header') && ! str_contains((string) ($canonicalFooterPart['canonical_block_markup'] ?? ''), '<footer'), 'canonical shell parts rely on their semantic template-part references instead of nesting duplicate landmarks');
$assert(2 === count(array_filter($canonicalShellPlan['writes'] ?? array(), static fn (array $write): bool => 'theme_template_part' === ($write['kind'] ?? ''))), 'WordPress site plan exposes canonical entry header and footer writes');
Expand Down
2 changes: 1 addition & 1 deletion php-transformer/tests/contract/wordpress-site-plan.php
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@
$galleryEntry = current(array_filter($galleryResult['source_reports']['compiled_site']['pages'] ?? array(), static fn(array $page): bool => 'contact.html' === ($page['source_path'] ?? null)));
$galleryPlanEntry = current(array_filter($galleryResult['source_reports']['wordpress_site_plan']['pages'] ?? array(), static fn(array $page): bool => 'contact.html' === ($page['source_path'] ?? null)));
$galleryHeader = current(array_filter($galleryEntry['shell_artifacts'] ?? array(), static fn(array $shell): bool => 'header' === ($shell['area'] ?? null)));
$assert(1 === substr_count((string) ($galleryEntry['block_markup'] ?? ''), (string) ($galleryHeader['inner_block_markup'] ?? '')) && str_contains((string) ($galleryPlanEntry['canonical_block_markup'] ?? ''), 'site-header') && in_array('wordpress_site_plan_shell_retained_ambiguous', array_column($galleryResult['source_reports']['wordpress_site_plan']['diagnostics'] ?? array(), 'code'), true), 'Fixture 37 retains its ambiguous entry header exactly once through compiled-site and site-plan projection.');
$assert(1 === substr_count((string) ($galleryEntry['block_markup'] ?? ''), '<header class="wp-block-group site-header"') && str_contains((string) ($galleryPlanEntry['canonical_block_markup'] ?? ''), 'site-header') && in_array('wordpress_site_plan_shell_retained_ambiguous', array_column($galleryResult['source_reports']['wordpress_site_plan']['diagnostics'] ?? array(), 'code'), true), 'Fixture 37 retains its ambiguous entry header exactly once through compiled-site and site-plan projection.');
$galleryPages = array_column($galleryResult['source_reports']['wordpress_site_plan']['pages'] ?? array(), 'canonical_block_markup', 'source_path');
$galleryCurrentExhibition = (string) ($galleryPages['current-exhibition.html'] ?? '');
$assert(2 === substr_count($galleryCurrentExhibition, '<!-- wp:blocks-engine/description-list') && str_contains($galleryCurrentExhibition, '<div><dt>Opening date</dt><dd><time datetime="2026-04-19">19 April 2026</time></dd></div>') && str_contains($galleryCurrentExhibition, '<dl class="pub__meta"><div><dt>Publisher</dt><dd>Spector Books, Leipzig</dd></div>'), 'Fixture 37 sidebar and publication grouped description lists retain companion blocks, div row topology, and source ordering in canonical site-plan markup.');
Expand Down
Loading
Loading