From 8d613b90e1cec2985d0201bf5eb891a0817863c0 Mon Sep 17 00:00:00 2001
From: Chris Huber
Date: Mon, 20 Jul 2026 16:27:58 -0400
Subject: [PATCH 01/22] Preserve inline flex item margins
---
.../src/HtmlToBlocks/HtmlTransformer.php | 37 ++++++++-
.../unit/block-style-support-conversion.php | 80 +++++++++++++++++--
2 files changed, 108 insertions(+), 9 deletions(-)
diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
index c2cbefdd..6d9d8e1d 100644
--- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
+++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
@@ -1815,7 +1815,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
return null;
}
- return $this->createBlock('core/paragraph', array( 'content' => $content ));
+ return $this->createBlock('core/paragraph', $this->paragraphAttributesForNonParagraphContent($element, array( 'content' => $content )));
}
if ( 'ul' === $tagName || 'ol' === $tagName ) {
@@ -3913,6 +3913,41 @@ private function paragraphBlockFromInlineContentWrapper(DOMElement $element): ?a
return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $content )), array(), $element);
}
+ /**
+ * A paragraph is the valid native host for a standalone source inline flex or
+ * grid item. Its theme default margins are not part of that element's box, so
+ * use its resolved source margin when present or establish a zero baseline.
+ *
+ * @param array $attrs
+ * @return array
+ */
+ private function paragraphAttributesForNonParagraphContent(DOMElement $element, array $attrs): array
+ {
+ $parent = $element->parentNode;
+ $parentDisplay = $parent instanceof DOMElement ? strtolower(trim((string) ($this->structuralPresentationDeclarations($parent)['display'] ?? ''))) : '';
+ if ( ! in_array($parentDisplay, array( 'flex', 'inline-flex', 'grid', 'inline-grid' ), true) ) {
+ return $attrs;
+ }
+
+ $sourceMargin = $this->presentationAttributes($element)['style']['spacing']['margin'] ?? array();
+ if ( ! is_array($sourceMargin) || array() === $sourceMargin ) {
+ $sourceMargin = array(
+ 'top' => '0',
+ 'right' => '0',
+ 'bottom' => '0',
+ 'left' => '0',
+ );
+ }
+
+ return array_replace_recursive(array(
+ 'style' => array(
+ 'spacing' => array(
+ 'margin' => $sourceMargin,
+ ),
+ ),
+ ), $attrs);
+ }
+
private function hasAuthorSemanticMarkedChild(DOMElement $element): bool
{
foreach ( $element->childNodes as $child ) {
diff --git a/php-transformer/tests/unit/block-style-support-conversion.php b/php-transformer/tests/unit/block-style-support-conversion.php
index 52f34846..ea6f2d79 100644
--- a/php-transformer/tests/unit/block-style-support-conversion.php
+++ b/php-transformer/tests/unit/block-style-support-conversion.php
@@ -251,25 +251,89 @@
$assert('masthead__bio' === ($textPresetAttrs['className'] ?? ''), '37: source paragraph class remains preserved without generated color class leakage', json_encode($textPresetAttrs));
$assert(! str_contains($textPresetInnerHtml, 'has-text-color has-text-color'), '38: rendered paragraph does not carry duplicate generated text color classes', $textPresetInnerHtml);
+$inlineFlexText = ( new HtmlTransformer() )->transform('')->toArray();
+$inlineFlexParagraph = $inlineFlexText['blocks'][0]['innerBlocks'][0]['innerBlocks'][0] ?? array();
+$inlineFlexMarkup = (string) ($inlineFlexText['serialized_blocks'] ?? '');
+$inlineFlexMargins = $inlineFlexParagraph['attrs']['style']['spacing']['margin'] ?? array();
+
+$assert(
+ array( 'top' => '0', 'right' => '0', 'bottom' => '0', 'left' => '0' ) === $inlineFlexMargins,
+ '39: standalone inline text retains its zero source margin when canonicalized as a flex-item paragraph',
+ json_encode($inlineFlexMargins)
+);
+$assert(
+ str_contains($inlineFlexMarkup, 'margin-top:0;margin-right:0;margin-bottom:0;margin-left:0'),
+ '40: standalone inline text serializes the margin reset through native paragraph supports',
+ $inlineFlexMarkup
+);
+
+$selectorMarginText = ( new HtmlTransformer() )->transform(
+ '',
+ array( 'static_css' => '.utility > span:nth-child(2){margin:3px 7px 11px 13px}' )
+)->toArray();
+$selectorMarginCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $selectorMarginText['assets'] ?? array()));
+$selectorMarginFlexItem = $selectorMarginText['blocks'][0]['innerBlocks'][1] ?? array();
+$selectorMarginWrapper = $selectorMarginFlexItem['innerBlocks'][0] ?? array();
+$selectorMarginParagraph = $selectorMarginWrapper['innerBlocks'][0] ?? array();
+$selectorMarginClass = (string) ($selectorMarginFlexItem['attrs']['className'] ?? '');
+
+$assert(
+ str_starts_with($selectorMarginClass, 'blocks-engine-semantic-')
+ && $selectorMarginClass === ($selectorMarginWrapper['attrs']['className'] ?? '')
+ && str_contains($selectorMarginCss, '.' . $selectorMarginClass)
+ && ! isset($selectorMarginParagraph['attrs']['style']['spacing']['margin']),
+ '41: structural-selector margin stays on the emitted flex-item wrapper without a competing paragraph margin',
+ json_encode(array( 'flex_item' => $selectorMarginFlexItem, 'paragraph' => $selectorMarginParagraph, 'css' => $selectorMarginCss ))
+);
+
+$idMarginText = ( new HtmlTransformer() )->transform(
+ '',
+ array( 'static_css' => '#utility-copy{margin:3px 7px 11px 13px}' )
+)->toArray();
+$idMarginCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $idMarginText['assets'] ?? array()));
+$idMarginFlexItem = $idMarginText['blocks'][0]['innerBlocks'][1] ?? array();
+$idMarginWrapper = $idMarginFlexItem['innerBlocks'][0] ?? array();
+$idMarginParagraph = $idMarginWrapper['innerBlocks'][0] ?? array();
+$idMarginClass = (string) ($idMarginFlexItem['attrs']['className'] ?? '');
+
+$assert(
+ 'utility-copy' === ($idMarginFlexItem['attrs']['anchor'] ?? '')
+ && $idMarginClass === ($idMarginWrapper['attrs']['className'] ?? '')
+ && str_contains($idMarginCss, '.' . $idMarginClass)
+ && ! isset($idMarginParagraph['attrs']['style']['spacing']['margin']),
+ '42: ID-selector margin stays on the emitted flex-item wrapper without a competing paragraph margin',
+ json_encode(array( 'flex_item' => $idMarginFlexItem, 'paragraph' => $idMarginParagraph, 'css' => $idMarginCss ))
+);
+
+$inlineMarginText = ( new HtmlTransformer() )->transform('')->toArray();
+$inlineMarginParagraph = $inlineMarginText['blocks'][0]['innerBlocks'][0]['innerBlocks'][0] ?? array();
+$inlineMargins = $inlineMarginParagraph['attrs']['style']['spacing']['margin'] ?? array();
+
+$assert(
+ array( 'top' => '3px', 'right' => '7px', 'bottom' => '11px', 'left' => '13px' ) === $inlineMargins,
+ '43: resolved source inline margins become native paragraph margins after canonicalization',
+ json_encode($inlineMargins)
+);
+
$paintCss = '.pricing-card{background:radial-gradient(circle at 20% 10%,rgba(255,255,255,.9),rgba(255,255,255,0) 38%),linear-gradient(180deg,#fff,#f5efe4);background-position:center top;background-size:120% 80%,100% 100%;background-repeat:no-repeat;box-shadow:0 28px 80px rgba(20,12,4,.18);padding:2rem;border-radius:24px}';
$paintHtml = 'Roast Club
Fresh coffee every week.
';
$paintResult = ( new HtmlTransformer() )->transform($paintHtml, array('static_css' => $paintCss))->toArray();
$paintBlock = $paintResult['blocks'][0] ?? array();
$paintAttrs = is_array($paintBlock['attrs'] ?? null) ? $paintBlock['attrs'] : array();
-$assert('pricing-card' === ($paintAttrs['className'] ?? ''), '39: high-value card wrapper keeps source class for class-owned paint CSS', json_encode($paintAttrs));
-$assert(! isset($paintAttrs['style']['box-shadow']), '40: class-owned box-shadow is not stored as an unsupported block style attr', json_encode($paintAttrs['style'] ?? array()));
-$assert(! isset($paintAttrs['style']['background-position']) && ! isset($paintAttrs['style']['background-size']), '41: background layer controls stay out of block style attrs', json_encode($paintAttrs['style'] ?? array()));
+$assert('pricing-card' === ($paintAttrs['className'] ?? ''), '44: high-value card wrapper keeps source class for class-owned paint CSS', json_encode($paintAttrs));
+$assert(! isset($paintAttrs['style']['box-shadow']), '45: class-owned box-shadow is not stored as an unsupported block style attr', json_encode($paintAttrs['style'] ?? array()));
+$assert(! isset($paintAttrs['style']['background-position']) && ! isset($paintAttrs['style']['background-size']), '46: background layer controls stay out of block style attrs', json_encode($paintAttrs['style'] ?? array()));
$rulesMethod = new ReflectionMethod(HtmlTransformer::class, 'staticStyleRules');
$paintRules = $rulesMethod->invoke(new HtmlTransformer(), '', $paintCss);
$paintDeclarations = $paintRules[0]['declarations'] ?? array();
-$assert(($paintDeclarations['background'] ?? '') === 'radial-gradient(circle at 20% 10%,rgba(255,255,255,.9),rgba(255,255,255,0) 38%),linear-gradient(180deg,#fff,#f5efe4)', '42: radial and layered backgrounds survive safe CSS resolution', json_encode($paintDeclarations));
-$assert(($paintDeclarations['background-position'] ?? '') === 'center top', '43: background-position survives safe CSS resolution', json_encode($paintDeclarations));
-$assert(($paintDeclarations['background-size'] ?? '') === '120% 80%,100% 100%', '44: background-size survives safe CSS resolution', json_encode($paintDeclarations));
-$assert(($paintDeclarations['background-repeat'] ?? '') === 'no-repeat', '45: background-repeat survives safe CSS resolution', json_encode($paintDeclarations));
-$assert(($paintDeclarations['box-shadow'] ?? '') === '0 28px 80px rgba(20,12,4,.18)', '46: box-shadow survives safe CSS resolution', json_encode($paintDeclarations));
+$assert(($paintDeclarations['background'] ?? '') === 'radial-gradient(circle at 20% 10%,rgba(255,255,255,.9),rgba(255,255,255,0) 38%),linear-gradient(180deg,#fff,#f5efe4)', '47: radial and layered backgrounds survive safe CSS resolution', json_encode($paintDeclarations));
+$assert(($paintDeclarations['background-position'] ?? '') === 'center top', '48: background-position survives safe CSS resolution', json_encode($paintDeclarations));
+$assert(($paintDeclarations['background-size'] ?? '') === '120% 80%,100% 100%', '49: background-size survives safe CSS resolution', json_encode($paintDeclarations));
+$assert(($paintDeclarations['background-repeat'] ?? '') === 'no-repeat', '50: background-repeat survives safe CSS resolution', json_encode($paintDeclarations));
+$assert(($paintDeclarations['box-shadow'] ?? '') === '0 28px 80px rgba(20,12,4,.18)', '51: box-shadow survives safe CSS resolution', json_encode($paintDeclarations));
if ( $failures > 0 ) {
fwrite(STDERR, "Block style support conversion tests: {$failures} failed, {$passes} passed\n");
From 3d3ef79b082be4ad45834bcbe42ce172c8723d40 Mon Sep 17 00:00:00 2001
From: Chris Huber
Date: Mon, 20 Jul 2026 17:28:46 -0400
Subject: [PATCH 02/22] Preserve native navigation row geometry
---
.../Patterns/NavigationPattern.php | 41 +++++++++++++++----
php-transformer/tests/contract/run.php | 25 ++++++++++-
...l-brand-anchor-beside-active-nav-list.json | 2 +-
.../parity/html-code-pre-nav-shape.json | 2 +-
.../html-list-navigation-classification.json | 2 +-
.../html-nav-overlay-menu-interactivity.json | 2 +-
.../html-semantic-selector-preservation.json | 2 +-
7 files changed, 62 insertions(+), 14 deletions(-)
diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
index 38b30114..d1d4a8f2 100644
--- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
+++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
@@ -412,6 +412,11 @@ private function navigationItemAttributes(DOMElement $item, DOMElement $anchor,
$itemAttrs = $item->isSameNode($anchor) ? array() : $this->withoutCoreNavigationClasses($presentationAttributes($item));
$anchorAttrs = $this->withoutCoreNavigationClasses($presentationAttributes($anchor));
$submenuAttrs = $submenuContainer instanceof DOMElement ? $this->withoutCoreNavigationClasses($presentationAttributes($submenuContainer)) : array();
+ if ( $item->isSameNode($anchor) && isset($anchorAttrs['style']['spacing']['margin']) ) {
+ // Direct anchors become navigation items. Keep their item margins on
+ // the item rather than reinterpreting horizontal spacing as row gap.
+ $itemAttrs['style']['spacing']['margin'] = $anchorAttrs['style']['spacing']['margin'];
+ }
if ( '' === (string) ($itemAttrs['className'] ?? '') && '' !== (string) ($anchorAttrs['className'] ?? '') ) {
$itemAttrs['className'] = $anchorAttrs['className'];
}
@@ -515,20 +520,40 @@ private function navigationContainerAttributes(DOMElement $element, callable $pr
if ( $this->isListNavigationSource($element) ) {
$attrs['className'] = trim((string) ($attrs['className'] ?? '') . ' blocks-engine-list-navigation');
}
- if ( '' !== (string) ($attrs['style']['spacing']['blockGap'] ?? '') ) {
- return $attrs;
- }
+ // core/navigation owns the item row. For a direct list, its declarations
+ // (rather than the nav wrapper's one-child layout) control that row.
+ $layoutOwner = $element;
foreach ( $element->childNodes as $child ) {
if ( ! $child instanceof DOMElement || ! in_array(strtolower($child->tagName), array( 'ul', 'ol' ), true) ) {
continue;
}
- $listGap = (string) ($presentationAttributes($child)['style']['spacing']['blockGap'] ?? '');
- if ( '' !== $listGap ) {
- $attrs['style']['spacing']['blockGap'] = $listGap;
- break;
- }
+ $layoutOwner = $child;
+ break;
+ }
+
+ $ownerAttrs = $layoutOwner->isSameNode($element) ? $attrs : $this->withoutCoreNavigationClasses($presentationAttributes($layoutOwner));
+ $ownerLayout = is_array($ownerAttrs['layout'] ?? null) ? $ownerAttrs['layout'] : array();
+ $ownerGap = (string) ($ownerAttrs['style']['spacing']['blockGap'] ?? '');
+
+ if ( '' !== $ownerGap ) {
+ $attrs['style']['spacing']['blockGap'] = $ownerGap;
+ } else {
+ // Native navigation supplies a theme gap by default; source inline
+ // links and un-gapped list rows have no equivalent item spacing.
+ $attrs['style']['spacing']['blockGap'] = '0';
+ }
+
+ if ( 'flex' === ($ownerLayout['type'] ?? '') ) {
+ $attrs['layout'] = $ownerLayout;
+ } else {
+ $attrs['layout'] = array( 'type' => 'flex' );
+ }
+
+ if ( 'vertical' !== ($attrs['layout']['orientation'] ?? '') && ! isset($attrs['layout']['flexWrap']) ) {
+ // CSS flex rows default to nowrap, whereas core/navigation defaults to wrap.
+ $attrs['layout']['flexWrap'] = 'nowrap';
}
return $attrs;
diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php
index cf39a899..20469692 100644
--- a/php-transformer/tests/contract/run.php
+++ b/php-transformer/tests/contract/run.php
@@ -1220,6 +1220,7 @@ public function match(DOMElement $element, PatternContext $context): ?array
$listGapNavigationBlock = $listGapNavigation['blocks'][0] ?? array();
$listGapNavigationSerialized = (string) ($listGapNavigation['serialized_blocks'] ?? '');
$assert('0' === ($listGapNavigationBlock['attrs']['style']['spacing']['blockGap'] ?? ''), 'direct navigation list gap projects onto core/navigation');
+$assert('nowrap' === ($listGapNavigationBlock['attrs']['layout']['flexWrap'] ?? ''), 'direct navigation list preserves its implicit non-wrapping row');
$assert('pass' === ($listGapNavigation['source_reports']['semantic_parity']['status'] ?? ''), 'direct navigation list gap preserves semantic parity');
$assert('pass' === ($listGapNavigation['source_reports']['wp_block_validity']['status'] ?? ''), 'direct navigation list gap serializes to a valid WordPress block');
$assert(str_contains($listGapNavigationSerialized, '" },
+ { "path": "serialized_blocks", "assert": "contains", "value": "" },
{ "path": "serialized_blocks", "assert": "contains", "value": "" },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "\"label\":\"Mara \\u003cem" },
{ "path": "fallbacks", "assert": "count", "count": 0 }
diff --git a/php-transformer/tests/fixtures/parity/html-code-pre-nav-shape.json b/php-transformer/tests/fixtures/parity/html-code-pre-nav-shape.json
index 5da9d9de..ae2dc9f0 100644
--- a/php-transformer/tests/fixtures/parity/html-code-pre-nav-shape.json
+++ b/php-transformer/tests/fixtures/parity/html-code-pre-nav-shape.json
@@ -33,7 +33,7 @@
{ "path": "blocks.0.innerBlocks.4.innerBlocks", "assert": "count", "count": 2 },
{ "path": "fallbacks", "assert": "count", "count": 0 },
{ "path": "blocks.0.innerBlocks.4.innerBlocks.1.attrs.url", "assert": "equals", "value": null },
- { "path": "serialized_blocks", "assert": "contains", "value": "" },
+ { "path": "serialized_blocks", "assert": "contains", "value": "" },
{ "path": "serialized_blocks", "assert": "contains", "value": "" },
{ "path": "serialized_blocks", "assert": "contains", "value": "" },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "wp-block-navigation__container" },
diff --git a/php-transformer/tests/fixtures/parity/html-list-navigation-classification.json b/php-transformer/tests/fixtures/parity/html-list-navigation-classification.json
index dc8808bb..ed1da2cd 100644
--- a/php-transformer/tests/fixtures/parity/html-list-navigation-classification.json
+++ b/php-transformer/tests/fixtures/parity/html-list-navigation-classification.json
@@ -32,7 +32,7 @@
{ "path": "blocks.0.innerBlocks.1.innerBlocks", "assert": "count", "count": 3 },
{ "path": "blocks.1.innerBlocks", "assert": "count", "count": 2 },
{ "path": "fallbacks", "assert": "count", "count": 0 },
- { "path": "serialized_blocks", "assert": "contains", "value": "" },
- { "path": "serialized_blocks", "assert": "contains", "value": "" },
+ { "path": "serialized_blocks", "assert": "contains", "value": "" },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "" },
{ "path": "serialized_blocks", "assert": "contains", "value": "
');
$richTextPillMarkup = (string) ($richTextPill['serialized_blocks'] ?? '');
$richTextPillCss = $css($richTextPill);
-$assert(str_contains($richTextPillMarkup, '"Testimonial');
$richTextColorMarkup = (string) ($richTextColor['serialized_blocks'] ?? '');
$richTextColorCss = $css($richTextColor);
-$assert(str_contains($richTextColorMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && ! str_contains($richTextColorMarkup, 'color:inherit') && ! str_contains($richTextColorMarkup, 'background-color:transparent') && str_contains($richTextColorCss, ':where(mark)[style*="--blocks-engine-richtext-marker:"]{background-color:transparent;color:inherit}') && str_contains($richTextColorCss, '{font-size:4rem;color:var(--amber)}') && strpos($richTextColorCss, 'color:inherit') < strpos($richTextColorCss, 'color:var(--amber)'), 'RichText marker reset stays below projected author paint instead of overriding it inline');
+$assert(str_contains($richTextColorMarkup, '.quote-mark{font-size:4rem}"The team\'s launch
');
$richTextPunctuationMarkup = (string) ($richTextPunctuation['serialized_blocks'] ?? '');
@@ -179,17 +179,17 @@
$richTextStates = $transform('Read more.
');
$richTextStatesMarkup = (string) ($richTextStates['serialized_blocks'] ?? '');
$richTextStatesCss = $css($richTextStates);
-$assert(str_contains($richTextStatesMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && 4 === substr_count($richTextStatesCss, 'mark[style*="--blocks-engine-richtext-marker:blocks-engine-richtext-') && str_contains($richTextStatesCss, ':hover{color:red}') && str_contains($richTextStatesCss, ':focus{color:blue}') && str_contains($richTextStatesCss, ':active{color:green}') && str_contains($richTextStatesCss, ':visited{color:purple}') && ! str_contains($richTextStatesMarkup, ':hover') && ! str_contains($richTextStatesMarkup, ':focus') && ! str_contains($richTextStatesMarkup, ':active') && ! str_contains($richTextStatesMarkup, ':visited'), 'RichText marker projections retain dynamic pseudo-state suffixes without inlining a permanent state');
+$assert(str_contains($richTextStatesMarkup, 'Nested
');
$nestedLeafMarkup = (string) ($nestedLeaf['serialized_blocks'] ?? '');
$nestedLeafCss = $css($nestedLeaf);
-$assert(! str_contains($nestedLeafMarkup, 'blocks-engine-semantic-') && str_contains($nestedLeafMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && str_contains($nestedLeafCss, 'mark[style*="--blocks-engine-richtext-marker:blocks-engine-richtext-') && 'pass' === ($nestedLeaf['source_reports']['wp_block_validity']['status'] ?? ''), 'nested selector-addressable leaves retain a valid inline marker instead of becoming a structural group');
+$assert(! str_contains($nestedLeafMarkup, 'blocks-engine-semantic-') && str_contains($nestedLeafMarkup, 'Read new notes.
');
$proseBadgeMarkup = (string) ($proseBadge['serialized_blocks'] ?? '');
$proseBadgeCss = $css($proseBadge);
-$assert(! str_contains($proseBadgeMarkup, 'blocks-engine-semantic-') && str_contains($proseBadgeMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && str_contains($proseBadgeCss, 'mark[style*="--blocks-engine-richtext-marker:blocks-engine-richtext-') && 'pass' === ($proseBadge['source_reports']['wp_block_validity']['status'] ?? ''), 'a padded badge inside prose in a flex card remains an inline RichText marker');
+$assert(! str_contains($proseBadgeMarkup, 'blocks-engine-semantic-') && str_contains($proseBadgeMarkup, 'AcmeLabs',
+ array()
+)->toArray();
+$linkedBrand = $linkedBrandResult['blocks'][0] ?? array();
+$linkedBrandAttrs = is_array($linkedBrand['attrs'] ?? null) ? $linkedBrand['attrs'] : array();
+$linkedBrandCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), is_array($linkedBrandResult['assets'] ?? null) ? $linkedBrandResult['assets'] : array()));
+$assert('nowrap' === ($linkedBrandAttrs['layout']['flexWrap'] ?? ''), '16a: inline flex logo groups retain the CSS non-wrapping default', json_encode($linkedBrandAttrs['layout'] ?? array()));
+$assert(str_contains((string) ($linkedBrandAttrs['className'] ?? ''), 'be-inline-geometry-') && str_contains($linkedBrandCss, '{gap:18px}'), '16b: inline flex logo groups retain their exact gap without escalating cascade priority', $linkedBrandCss);
+
$cardHtml = '';
$cardResult = ( new HtmlTransformer() )->transform($cardHtml, array())->toArray();
$cardShell = $cardResult['blocks'][0] ?? array();
From 0dbf1aa6b55c82ffaaa4ba121141bcaea19fa25e Mon Sep 17 00:00:00 2001
From: Chris Huber
Date: Tue, 21 Jul 2026 09:39:15 -0400
Subject: [PATCH 07/22] Preserve standalone visual text tokens
---
.../src/HtmlToBlocks/HtmlTransformer.php | 66 ++++++++++++++++++-
.../tests/unit/author-selector-semantics.php | 13 +++-
2 files changed, 75 insertions(+), 4 deletions(-)
diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
index 112927d9..e54af0d1 100644
--- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
+++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
@@ -1769,8 +1769,18 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
if ( $this->hasAuthorSemanticMarker($element) ) {
$content = $this->innerHtml($element);
if ( '' !== trim($this->runtime->stripAllTags($content)) ) {
+ $paragraphAttrs = array( 'content' => $content );
+ $parent = $element->parentNode instanceof DOMElement ? $element->parentNode : null;
+ if ( $parent instanceof DOMElement && ! $this->isStructuralLayoutElement($parent) ) {
+ $paragraphAttrs['style']['spacing']['margin'] = array(
+ 'top' => '0',
+ 'right' => '0',
+ 'bottom' => '0',
+ 'left' => '0',
+ );
+ }
return $this->createBlock('core/group', $this->presentationAttributes($element), array(
- $this->createBlock('core/paragraph', array( 'content' => $content )),
+ $this->createBlock('core/paragraph', $paragraphAttrs),
), $element);
}
}
@@ -2590,16 +2600,21 @@ private function authorSemanticMarkersForElement(DOMElement $element): array
private function requiresIndependentSemanticWrapper(DOMElement $element): bool
{
- if ( 'span' !== strtolower($element->tagName) || $this->isRichTextInlineContext($element) ) {
+ if ( 'span' !== strtolower($element->tagName) || $this->isRichTextInlineContext($element) || 0 < $element->getElementsByTagName('svg')->length ) {
return false;
}
$parent = $element->parentNode instanceof DOMElement ? $element->parentNode : null;
- if ( ! $parent instanceof DOMElement || ! $this->isStructuralLayoutElement($parent) ) {
+ if ( ! $parent instanceof DOMElement ) {
return false;
}
$declarations = array_merge($this->presentationDeclarations($element), $this->authorSemanticDeclarations($element));
+ $structuralParent = $this->isStructuralLayoutElement($parent);
+ $authoredDeclarations = array_merge($this->authorSemanticDeclarations($element), $this->cssDeclarations($this->attr($element, 'style')));
+ if ( ! $structuralParent && (! $this->isStandaloneInlineLeaf($element) || ! $this->requiresFlowVisualTokenWrapper($element, $authoredDeclarations)) ) {
+ return false;
+ }
$display = strtolower(trim((string) ($declarations['display'] ?? 'inline')));
if ( ! in_array($display, array( '', 'inline', 'inherit', 'initial', 'unset' ), true) ) {
return true;
@@ -2614,6 +2629,51 @@ private function requiresIndependentSemanticWrapper(DOMElement $element): bool
return false;
}
+ /** @param array $declarations */
+ private function requiresFlowVisualTokenWrapper(DOMElement $element, array $declarations): bool
+ {
+ $display = strtolower(trim((string) ($declarations['display'] ?? 'inline')));
+ if ( in_array($display, array( 'inline-block', 'inline-flex', 'flex', 'grid', 'inline-grid' ), true) ) {
+ return true;
+ }
+
+ foreach ( array( 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'border', 'border-width', 'border-color', 'border-radius', 'background', 'background-color', 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height' ) as $property ) {
+ $value = strtolower(trim((string) ($declarations[$property] ?? '')));
+ if ( '' !== $value
+ && ! in_array($value, array( 'none', 'auto', 'normal', 'transparent', 'initial', 'inherit', 'unset' ), true)
+ && 1 !== preg_match('/^0(?:[a-z%]*)?(?:\s+0(?:[a-z%]*)?){0,3}$/', $value)
+ && ! preg_match('/^rgba\([^)]*,\s*0(?:\.0+)?\)$/', $value)
+ ) {
+ return true;
+ }
+ }
+
+ $spanSiblings = 0;
+ foreach ( $element->parentNode?->childNodes ?? array() as $sibling ) {
+ if ( $sibling instanceof DOMElement && 'span' === strtolower($sibling->tagName) ) {
+ ++$spanSiblings;
+ }
+ }
+
+ return 1 < $spanSiblings && 'block' === $display;
+ }
+
+ private function isStandaloneInlineLeaf(DOMElement $element): bool
+ {
+ $parent = $element->parentNode;
+ if ( ! $parent instanceof DOMElement ) {
+ return false;
+ }
+
+ foreach ( $parent->childNodes as $sibling ) {
+ if ( XML_TEXT_NODE === $sibling->nodeType && '' !== trim($sibling->textContent ?? '') ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
private function isStructuralLayoutElement(DOMElement $element): bool
{
$declarations = array_merge($this->presentationDeclarations($element), $this->authorSemanticDeclarations($element));
diff --git a/php-transformer/tests/unit/author-selector-semantics.php b/php-transformer/tests/unit/author-selector-semantics.php
index 0828d895..9f35e1c7 100644
--- a/php-transformer/tests/unit/author-selector-semantics.php
+++ b/php-transformer/tests/unit/author-selector-semantics.php
@@ -144,6 +144,17 @@
$inlineCss = $css($inlineLeaves);
$assert(3 === substr_count($inlineMarkup, 'Jun25
Defense
Thesis title
');
+$flowTokenMarkup = (string) ($flowTokens['serialized_blocks'] ?? '');
+$flowTokenCss = $css($flowTokens);
+$assert(6 === substr_count($flowTokenMarkup, 'blocks-engine-semantic-') && 3 <= substr_count($flowTokenCss, ':where(.blocks-engine-semantic-'), 'standalone styled inline leaves in ordinary flow retain independent visual-token wrappers');
+$assert(3 === substr_count($flowTokenMarkup, 'margin-top:0;margin-right:0;margin-bottom:0;margin-left:0'), 'synthetic visual-token paragraphs do not contribute source-absent spacing');
+$assert(! str_contains($flowTokenMarkup, 'Decision
Byline');
+$resetInlineMetadataMarkup = (string) ($resetInlineMetadata['serialized_blocks'] ?? '');
+$assert(! str_contains($resetInlineMetadataMarkup, 'blocks-engine-semantic-') && str_contains($resetInlineMetadataMarkup, 'FirstSecond
Third
');
$repeatedMarkup = (string) ($repeatedParents['serialized_blocks'] ?? '');
$repeatedCss = $css($repeatedParents);
@@ -184,7 +195,7 @@
$nestedLeaf = $transform('');
$nestedLeafMarkup = (string) ($nestedLeaf['serialized_blocks'] ?? '');
$nestedLeafCss = $css($nestedLeaf);
-$assert(! str_contains($nestedLeafMarkup, 'blocks-engine-semantic-') && str_contains($nestedLeafMarkup, '.card{display:flex}.badge{padding:2px 8px;border:1px solid #999}.card .badge{color:red}');
$proseBadgeMarkup = (string) ($proseBadge['serialized_blocks'] ?? '');
From 5b47b33ee14db8d04fdf22610f621c6d94b2541c Mon Sep 17 00:00:00 2001
From: Chris Huber
Date: Tue, 21 Jul 2026 10:17:32 -0400
Subject: [PATCH 08/22] Preserve linked brand and navigation state
---
.../src/HtmlToBlocks/HtmlTransformer.php | 82 ++++++++++++++++++-
.../tests/unit/author-selector-semantics.php | 8 ++
2 files changed, 86 insertions(+), 4 deletions(-)
diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
index e54af0d1..6f3c175b 100644
--- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
+++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
@@ -538,6 +538,7 @@ public function transform(string $html, array $options = array()): TransformerRe
);
}
+ $this->markCurrentNavigationLinks($body, (string) ($options['source'] ?? ''));
$this->prepareAuthorSelectorSemantics($html, (string) ($options['static_css'] ?? ''), $body, $options);
$fallbacks = array();
@@ -763,6 +764,73 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo
);
}
+ private function markCurrentNavigationLinks(DOMElement $body, string $source): void
+ {
+ $sourcePath = $this->normalizedDocumentPath($source);
+ if ( '' === $sourcePath ) {
+ return;
+ }
+
+ foreach ( $body->getElementsByTagName('a') as $anchor ) {
+ if ( ! $anchor instanceof DOMElement || ! $this->hasNavigationAncestor($anchor) ) {
+ continue;
+ }
+
+ $href = trim($this->attr($anchor, 'href'));
+ if ( '' === $href || '#' === $href[0] || preg_match('#^[a-z][a-z0-9+.-]*:#i', $href) || str_starts_with($href, '//') ) {
+ continue;
+ }
+
+ $hrefPath = parse_url($href, PHP_URL_PATH);
+ if ( ! is_string($hrefPath) || '' === $hrefPath ) {
+ continue;
+ }
+
+ $targetPath = str_starts_with($hrefPath, '/')
+ ? $this->normalizedDocumentPath($hrefPath)
+ : $this->normalizedDocumentPath(dirname($sourcePath) . '/' . $hrefPath);
+ if ( $targetPath !== $sourcePath ) {
+ continue;
+ }
+
+ $anchor->setAttribute('aria-current', 'page');
+ $anchor->setAttribute('class', $this->mergeClassNames($this->attr($anchor, 'class'), 'active'));
+ }
+ }
+
+ private function hasNavigationAncestor(DOMElement $element): bool
+ {
+ for ( $parent = $element->parentNode; $parent instanceof DOMElement; $parent = $parent->parentNode ) {
+ if ( 'nav' === strtolower($parent->tagName) || 'navigation' === strtolower(trim($this->attr($parent, 'role'))) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private function normalizedDocumentPath(string $path): string
+ {
+ $urlPath = parse_url(str_replace('\\', '/', trim($path)), PHP_URL_PATH);
+ if ( ! is_string($urlPath) || '' === $urlPath ) {
+ return '';
+ }
+
+ $segments = array();
+ foreach ( explode('/', ltrim($urlPath, '/')) as $segment ) {
+ if ( '' === $segment || '.' === $segment ) {
+ continue;
+ }
+ if ( '..' === $segment ) {
+ array_pop($segments);
+ continue;
+ }
+ $segments[] = $segment;
+ }
+
+ return implode('/', $segments);
+ }
+
private function richTextMarkerResetCss(): string
{
if ( array() === $this->sourceRichTextSemanticMarkers ) {
@@ -1766,12 +1834,18 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
return $inlineSvgTextGroup;
}
- if ( $this->hasAuthorSemanticMarker($element) ) {
- $content = $this->innerHtml($element);
+ if ( $this->hasAuthorSemanticMarker($element) || $this->requiresIndependentSemanticWrapper($element) ) {
+ if ( $this->hasAuthorSemanticMarkedChild($element) ) {
+ $children = $this->convertChildren($element, $fallbacks, true);
+ if ( array() !== $children ) {
+ return $this->createBlock('core/group', $this->presentationAttributes($element), $children, $element);
+ }
+ }
+ $content = $this->richTextContentWithMaterializedInlineStyles($element);
if ( '' !== trim($this->runtime->stripAllTags($content)) ) {
$paragraphAttrs = array( 'content' => $content );
$parent = $element->parentNode instanceof DOMElement ? $element->parentNode : null;
- if ( $parent instanceof DOMElement && ! $this->isStructuralLayoutElement($parent) ) {
+ if ( $parent instanceof DOMElement && (! $this->isStructuralLayoutElement($parent) || $this->hasAuthorSemanticMarker($parent) || $this->requiresIndependentSemanticWrapper($parent)) ) {
$paragraphAttrs['style']['spacing']['margin'] = array(
'top' => '0',
'right' => '0',
@@ -4020,7 +4094,7 @@ private function paragraphAttributesForNonParagraphContent(DOMElement $element,
private function hasAuthorSemanticMarkedChild(DOMElement $element): bool
{
foreach ( $element->childNodes as $child ) {
- if ( $child instanceof DOMElement && $this->hasAuthorSemanticMarker($child) ) {
+ if ( $child instanceof DOMElement && ($this->hasAuthorSemanticMarker($child) || $this->requiresIndependentSemanticWrapper($child)) ) {
return true;
}
}
diff --git a/php-transformer/tests/unit/author-selector-semantics.php b/php-transformer/tests/unit/author-selector-semantics.php
index 9f35e1c7..b24088ea 100644
--- a/php-transformer/tests/unit/author-selector-semantics.php
+++ b/php-transformer/tests/unit/author-selector-semantics.php
@@ -36,6 +36,10 @@
$navigationShellClass = (string) ($navigationShellBlock['attrs']['className'] ?? '');
$assert(str_contains($navigationShellClass, 'blocks-engine-source-nav-') && ! str_contains((string) ($navigationMenuBlock['attrs']['className'] ?? ''), 'blocks-engine-source-nav-') && str_contains($navigationShellCss, ':where(.' . $navigationShellClass . '):not(blocks-engine-specificity-') && ! preg_match('/(^|[},])nav\s*\{/', $navigationShellCss), 'nav type selectors stay scoped to the canonical source navigation shell instead of matching nested core navigation markup');
+$currentNavigation = ( new HtmlTransformer() )->transform('', array( 'source' => 'index.html' ))->toArray();
+$currentNavigationMarkup = (string) ($currentNavigation['serialized_blocks'] ?? '');
+$assert(str_contains($currentNavigationMarkup, 'anchorClassName":"active') && str_contains($currentNavigationMarkup, 'textDecoration":"underline') && 1 === substr_count($currentNavigationMarkup, 'anchorClassName":"active'), 'source-matching navigation links retain their current-page state before routes are rewritten');
+
$controls = $transform('Go');
$controlCss = $css($controls);
$assert(2 === substr_count($controlCss, '> :where(.wp-block-button__link)') && str_contains($controlCss, ':hover') && str_contains($controlCss, ':focus'), 'promoted anchors and native buttons project dynamic selectors onto their links once');
@@ -151,6 +155,10 @@
$assert(3 === substr_count($flowTokenMarkup, 'margin-top:0;margin-right:0;margin-bottom:0;margin-left:0'), 'synthetic visual-token paragraphs do not contribute source-absent spacing');
$assert(! str_contains($flowTokenMarkup, 'Brennan UniversityEarth Sciences');
+$linkedBrandMarkup = (string) ($linkedBrand['serialized_blocks'] ?? '');
+$assert(str_contains($linkedBrandMarkup, 'className":"institution blocks-engine-semantic-') && str_contains($linkedBrandMarkup, 'className":"department blocks-engine-semantic-') && 2 === substr_count($linkedBrandMarkup, 'margin-top:0;margin-right:0;margin-bottom:0;margin-left:0'), 'linked brand text retains independent native flex children without synthetic paragraph margins when shell conversion changes DOM paths');
+
$resetInlineMetadata = $transform('');
$resetInlineMetadataMarkup = (string) ($resetInlineMetadata['serialized_blocks'] ?? '');
$assert(! str_contains($resetInlineMetadataMarkup, 'blocks-engine-semantic-') && str_contains($resetInlineMetadataMarkup, 'listen
to the garden." } },
+ { "path": "blocks.2.innerBlocks.0", "name": "core/paragraph", "attrs": { "content": "We listen
to the garden.", "style": { "spacing": { "margin": { "top": "0", "right": "0", "bottom": "0", "left": "0" } } } } },
{ "path": "blocks.3", "name": "core/quote", "attrs": { "className": "testimonial-card", "citation": "Grace, Founder" } },
{ "path": "blocks.3.innerBlocks.0", "name": "core/paragraph", "attrs": { "content": "Stay curious." } },
{ "path": "blocks.4", "name": "core/pullquote", "attrs": { "className": "alignfull", "citation": "Critic", "value": "Quote me.
" } }
@@ -35,7 +35,7 @@
{ "path": "blocks.3.innerBlocks", "assert": "count", "count": 1 },
{ "path": "serialized_blocks", "assert": "contains", "value": "Build with care.
Then ship it.
Ada
" },
{ "path": "serialized_blocks", "assert": "contains", "value": "Pull this hard.Editor
" },
- { "path": "serialized_blocks", "assert": "contains", "value": "We listen
to the garden.
The distiller
" },
+ { "path": "serialized_blocks", "assert": "contains", "value": "We listen
to the garden.
The distiller
" },
{ "path": "serialized_blocks", "assert": "contains", "value": "Stay curious.
Grace, Founder
" },
{ "path": "serialized_blocks", "assert": "contains", "value": "Quote me.
Critic
" },
{ "path": "fallbacks", "assert": "count", "count": 0 },
diff --git a/php-transformer/tests/unit/author-selector-semantics.php b/php-transformer/tests/unit/author-selector-semantics.php
index b24088ea..6b179899 100644
--- a/php-transformer/tests/unit/author-selector-semantics.php
+++ b/php-transformer/tests/unit/author-selector-semantics.php
@@ -148,11 +148,11 @@
$inlineCss = $css($inlineLeaves);
$assert(3 === substr_count($inlineMarkup, 'Jun25
Defense
Thesis title
');
+$flowTokens = $transform('Jun253:00 PM
Defense
Thesis title
');
$flowTokenMarkup = (string) ($flowTokens['serialized_blocks'] ?? '');
$flowTokenCss = $css($flowTokens);
-$assert(6 === substr_count($flowTokenMarkup, 'blocks-engine-semantic-') && 3 <= substr_count($flowTokenCss, ':where(.blocks-engine-semantic-'), 'standalone styled inline leaves in ordinary flow retain independent visual-token wrappers');
-$assert(3 === substr_count($flowTokenMarkup, 'margin-top:0;margin-right:0;margin-bottom:0;margin-left:0'), 'synthetic visual-token paragraphs do not contribute source-absent spacing');
+$assert(8 === substr_count($flowTokenMarkup, 'blocks-engine-semantic-') && 4 <= substr_count($flowTokenCss, ':where(.blocks-engine-semantic-'), 'standalone styled inline leaves in ordinary flow retain independent visual-token wrappers');
+$assert(4 === substr_count($flowTokenMarkup, 'margin-top:0;margin-right:0;margin-bottom:0;margin-left:0'), 'synthetic visual-token paragraphs do not contribute source-absent spacing, including tokens inside structural layout parents');
$assert(! str_contains($flowTokenMarkup, 'Brennan UniversityEarth Sciences');
From a3582e30347c3c26b18c82ff7d0c92fdf72508b5 Mon Sep 17 00:00:00 2001
From: Chris Huber
Date: Tue, 21 Jul 2026 14:59:15 -0400
Subject: [PATCH 10/22] Preserve inline utility navigation geometry
---
.../Diagnostics/SemanticParityReporter.php | 75 ++++++++++++++++++-
.../Patterns/NavigationPattern.php | 71 ++++++++++++++++++
php-transformer/tests/contract/run.php | 16 ++++
3 files changed, 160 insertions(+), 2 deletions(-)
diff --git a/php-transformer/src/HtmlToBlocks/Diagnostics/SemanticParityReporter.php b/php-transformer/src/HtmlToBlocks/Diagnostics/SemanticParityReporter.php
index 1805cbc2..76c57f98 100644
--- a/php-transformer/src/HtmlToBlocks/Diagnostics/SemanticParityReporter.php
+++ b/php-transformer/src/HtmlToBlocks/Diagnostics/SemanticParityReporter.php
@@ -188,7 +188,7 @@ private function collectBlockNavigationLandmarks(array $blocks, array &$counts):
continue;
}
- if ( 'core/navigation' === ($block['blockName'] ?? '') ) {
+ if ( 'core/navigation' === ($block['blockName'] ?? '') || $this->isStaticNavigationGroup($block) ) {
++$counts['nav'];
}
@@ -430,6 +430,17 @@ private function collectBlockNavigationMenus(array $blocks, string $path, array
$menus[] = array(
'block_path' => $blockPath,
'represented_as_core_navigation' => true,
+ 'represented_as_native_navigation' => true,
+ 'item_count' => count($items),
+ 'items' => $items,
+ );
+ } elseif ( $this->isStaticNavigationGroup($block) ) {
+ $items = array();
+ $this->collectStaticNavigationItems(is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array(), $items);
+ $menus[] = array(
+ 'block_path' => $blockPath,
+ 'represented_as_core_navigation' => false,
+ 'represented_as_native_navigation' => true,
'item_count' => count($items),
'items' => $items,
);
@@ -466,6 +477,66 @@ private function collectBlockNavigationItems(array $blocks, array &$items): void
}
}
+ /** @param array $block */
+ private function isStaticNavigationGroup(array $block): bool
+ {
+ return 'core/group' === ($block['blockName'] ?? '')
+ && 'nav' === strtolower((string) ($block['attrs']['tagName'] ?? ''))
+ && in_array('blocks-engine-inline-navigation', preg_split('/\s+/', trim((string) ($block['attrs']['className'] ?? ''))) ?: array(), true)
+ && ! $this->containsCoreNavigation(is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array());
+ }
+
+ /** @param array> $blocks */
+ private function containsCoreNavigation(array $blocks): bool
+ {
+ foreach ( $blocks as $block ) {
+ if ( ! is_array($block) ) {
+ continue;
+ }
+ if ( 'core/navigation' === ($block['blockName'] ?? '') ) {
+ return true;
+ }
+ if ( $this->containsCoreNavigation(is_array($block['innerBlocks'] ?? null) ? $block['innerBlocks'] : array()) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param array> $blocks
+ * @param array> $items
+ */
+ private function collectStaticNavigationItems(array $blocks, array &$items): void
+ {
+ foreach ( $blocks as $block ) {
+ if ( ! is_array($block) ) {
+ continue;
+ }
+
+ $content = is_string($block['attrs']['content'] ?? null)
+ ? $block['attrs']['content']
+ : (is_string($block['innerHTML'] ?? null) ? $block['innerHTML'] : '');
+ if ( '' !== $content && preg_match_all('/]*)>(.*?)<\/a>/is', $content, $matches, PREG_SET_ORDER) ) {
+ foreach ( $matches as $match ) {
+ $href = '';
+ if ( preg_match('/(?:^|\s)href\s*=\s*(["\'])(.*?)\1/is', (string) $match[1], $hrefMatch) ) {
+ $href = html_entity_decode((string) $hrefMatch[2], ENT_QUOTES | ENT_HTML5);
+ }
+ $items[] = array(
+ 'label' => $this->normalizedNavigationLabel((string) $match[2]),
+ 'url' => $href,
+ );
+ }
+ }
+
+ if ( ! empty($block['innerBlocks']) && is_array($block['innerBlocks']) ) {
+ $this->collectStaticNavigationItems($block['innerBlocks'], $items);
+ }
+ }
+ }
+
private function sourceNavigationAnchorLabel(DOMElement $anchor): string
{
$label = $this->normalizedNavigationLabel($anchor->textContent ?? '');
@@ -536,7 +607,7 @@ private function semanticParityFindings(array $sourceLandmarks, array $blockLand
continue;
}
- if ( true !== ($blockMenu['represented_as_core_navigation'] ?? false) ) {
+ if ( true !== ($blockMenu['represented_as_native_navigation'] ?? $blockMenu['represented_as_core_navigation'] ?? false) ) {
$findings[] = array(
'code' => 'navigation_core_block_missing',
'severity' => 'warning',
diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
index 7bee0ce6..3f96bf9c 100644
--- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
+++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
@@ -44,6 +44,27 @@ public function match(DOMElement $element, PatternContext $context): ?array
return null;
}
+ if ( $this->isInlineUtilityNavigation($element) ) {
+ $content = $this->inlineUtilityNavigationContent($element);
+ if ( '' !== $content ) {
+ $paragraph = $createBlock('core/paragraph', array(
+ 'content' => $content,
+ 'style' => array( 'spacing' => array( 'margin' => array(
+ 'top' => '0',
+ 'right' => '0',
+ 'bottom' => '0',
+ 'left' => '0',
+ ) ) ),
+ ));
+
+ $groupAttrs = $presentationAttributes($element);
+ $groupAttrs['tagName'] = 'nav';
+ $groupAttrs['className'] = trim((string) ($groupAttrs['className'] ?? '') . ' blocks-engine-inline-navigation');
+
+ return $createBlock('core/group', $groupAttrs, array( $paragraph ), $element);
+ }
+ }
+
$links = $this->navigationBlocks($element, $presentationAttributes, $innerHtml, $createBlock, $isRuntimeDomTarget, false, $navigationUnderlineColor);
if ( array() === $links ) {
@@ -115,6 +136,56 @@ private function nestedLabeledNavigationAttributes(DOMElement $element, callable
);
}
+ private function isInlineUtilityNavigation(DOMElement $element): bool
+ {
+ if ( 'nav' !== strtolower($element->tagName) || '' !== trim($this->attr($element, 'class')) || '' !== trim($this->attr($element, 'id')) || '' === trim($this->attr($element, 'aria-label')) ) {
+ return false;
+ }
+
+ $anchorCount = 0;
+ foreach ( $element->childNodes as $child ) {
+ if ( ! $child instanceof DOMElement ) {
+ continue;
+ }
+ if ( 'a' !== strtolower($child->tagName) ) {
+ return false;
+ }
+ $anchorCount++;
+ }
+
+ return $anchorCount > 1;
+ }
+
+ private function inlineUtilityNavigationContent(DOMElement $element): string
+ {
+ $anchors = array();
+ foreach ( $element->childNodes as $child ) {
+ if ( ! $child instanceof DOMElement || 'a' !== strtolower($child->tagName) ) {
+ continue;
+ }
+
+ $href = $this->safeNavigationUrl($this->attr($child, 'href'));
+ if ( '' === $href ) {
+ continue;
+ }
+
+ $attributes = ' href="' . htmlspecialchars($href, ENT_QUOTES | ENT_HTML5) . '"';
+ foreach ( array( 'class', 'target', 'rel', 'aria-label' ) as $name ) {
+ $value = trim($this->attr($child, $name));
+ if ( '' !== $value ) {
+ $attributes .= ' ' . $name . '="' . htmlspecialchars($value, ENT_QUOTES | ENT_HTML5) . '"';
+ }
+ }
+ $label = trim((string) $child->textContent);
+ if ( '' === $label ) {
+ continue;
+ }
+ $anchors[] = '' . htmlspecialchars($label, ENT_QUOTES | ENT_HTML5) . '';
+ }
+
+ return implode(' ', $anchors);
+ }
+
/**
* Core navigation links render text styles from their parent block context.
* Promote only values shared by every link so mixed menus retain their own
diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php
index 8e48e3a1..3fd2cde6 100644
--- a/php-transformer/tests/contract/run.php
+++ b/php-transformer/tests/contract/run.php
@@ -1238,6 +1238,22 @@ public function match(DOMElement $element, PatternContext $context): ?array
$assert('0' === ($directAnchorNavigationBlock['attrs']['style']['spacing']['blockGap'] ?? ''), 'direct anchor navigation preserves its implicit zero item gap');
$assert('nowrap' === ($directAnchorNavigationBlock['attrs']['layout']['flexWrap'] ?? ''), 'direct anchor navigation preserves its implicit non-wrapping row');
+$inlineUtilityNavigation = ( new HtmlTransformer() )->transform(
+ ''
+)->toArray();
+$inlineUtilityNavigationBlock = $inlineUtilityNavigation['blocks'][0] ?? array();
+$inlineUtilityNavigationParagraph = $inlineUtilityNavigationBlock['innerBlocks'][0] ?? array();
+$inlineUtilityNavigationParity = $inlineUtilityNavigation['source_reports']['semantic_parity'] ?? array();
+$assert('core/group' === ($inlineUtilityNavigationBlock['blockName'] ?? '') && 'nav' === ($inlineUtilityNavigationBlock['attrs']['tagName'] ?? '') && str_contains((string) ($inlineUtilityNavigationBlock['attrs']['className'] ?? ''), 'blocks-engine-inline-navigation'), 'anonymous labeled direct-link navigation retains inline source flow in a native semantic group');
+$assert('core/paragraph' === ($inlineUtilityNavigationParagraph['blockName'] ?? '') && str_contains((string) ($inlineUtilityNavigationParagraph['innerHTML'] ?? ''), 'Portal & tools'), 'inline utility navigation keeps escaped editable links in one source-equivalent line box');
+$assert('pass' === ($inlineUtilityNavigationParity['status'] ?? '') && 1 === ($inlineUtilityNavigationParity['landmarks']['blocks']['nav'] ?? 0), 'native static navigation groups satisfy landmark semantic parity');
+$assert(2 === ($inlineUtilityNavigationParity['navigation_menus']['blocks'][0]['item_count'] ?? 0) && false === ($inlineUtilityNavigationParity['navigation_menus']['blocks'][0]['represented_as_core_navigation'] ?? true), 'native static navigation groups preserve menu labels and URLs in semantic parity');
+
+$unsafeInlineUtilityNavigation = ( new HtmlTransformer() )->transform(
+ ''
+)->toArray();
+$assert(! str_contains((string) ($unsafeInlineUtilityNavigation['serialized_blocks'] ?? ''), 'javascript:'), 'inline utility navigation drops unsafe link URLs');
+
$uniformMarginNavigation = ( new HtmlTransformer() )->transform(
''
)->toArray();
From 9f74a0e43b5317ece295a175999db55869d51bb2 Mon Sep 17 00:00:00 2001
From: Chris Huber
Date: Tue, 21 Jul 2026 15:25:22 -0400
Subject: [PATCH 11/22] Preserve synthetic SVG flex sizing
---
.../src/HtmlToBlocks/HtmlTransformer.php | 17 +++++++++++++---
.../Style/StyleResolutionTrait.php | 20 +++++++++++++++++++
.../tests/unit/author-selector-semantics.php | 4 +++-
3 files changed, 37 insertions(+), 4 deletions(-)
diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
index c050e0f6..ae9a8231 100644
--- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
+++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
@@ -1720,6 +1720,17 @@ private function navigationUnderlineColor(DOMElement $item, DOMElement $anchor):
);
}
+ /** @return array */
+ private function svgPhrasingHostAttributes(DOMElement $element, string $imageMarkup): array
+ {
+ $className = $this->syntheticFlexItemClassName($element);
+
+ return array_filter(array(
+ 'content' => $imageMarkup,
+ 'className' => $className,
+ ), static fn (string $value): bool => '' !== $value);
+ }
+
/**
* @param array> $fallbacks
* @return array|null
@@ -1741,7 +1752,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
if ( 'svg' === $tagName && $this->svgNeedsPhrasingHost($element) ) {
$imageMarkup = $this->inlineSvgRichTextImageMarkup($element);
if ( null !== $imageMarkup ) {
- return $this->createBlock('core/paragraph', array( 'content' => $imageMarkup ), array(), $element);
+ return $this->createBlock('core/paragraph', $this->svgPhrasingHostAttributes($element, $imageMarkup), array(), $element);
}
}
@@ -2204,7 +2215,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
if ( $this->svgNeedsPhrasingHost($element) ) {
$imageMarkup = $this->inlineSvgRichTextImageMarkup($element);
if ( null !== $imageMarkup ) {
- return $this->createBlock('core/paragraph', array( 'content' => $imageMarkup ), array(), $element);
+ return $this->createBlock('core/paragraph', $this->svgPhrasingHostAttributes($element, $imageMarkup), array(), $element);
}
}
$svgBlock = $this->inlineSvgBlockFromElement($element);
@@ -2221,7 +2232,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
if ( $this->svgNeedsPhrasingHost($element) ) {
$imageMarkup = $this->inlineSvgRichTextImageMarkup($element);
if ( null !== $imageMarkup ) {
- return $this->createBlock('core/paragraph', array( 'content' => $imageMarkup ), array(), $element);
+ return $this->createBlock('core/paragraph', $this->svgPhrasingHostAttributes($element, $imageMarkup), array(), $element);
}
}
diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php
index 83e6421d..9f59ba7b 100644
--- a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php
+++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php
@@ -353,6 +353,25 @@ private function generatedGeometryCss(string $serializedBlocks): string
return implode("\n", $rules);
}
+ private function syntheticFlexItemClassName(DOMElement $element): string
+ {
+ $parent = $element->parentNode;
+ if ( ! $parent instanceof DOMElement || ! in_array(strtolower((string) ($this->structuralPresentationDeclarations($parent)['display'] ?? '')), array( 'flex', 'inline-flex' ), true) ) {
+ return '';
+ }
+
+ $flexShrink = trim((string) ($this->structuralPresentationDeclarations($element)['flex-shrink'] ?? ''));
+ if ( '' === $flexShrink || preg_match('/[{}<>;]/', $flexShrink) ) {
+ return '';
+ }
+
+ $rule = 'flex-shrink:' . $flexShrink;
+ $className = ($this->geometryCarrierClassAllocator ??= new GeometryCarrierClassAllocator())->allocate($this->geometryStructuralPath($element) . "\nsynthetic-flex-item\n" . $rule);
+ $this->generatedGeometryRules[$className] = '.' . $className . '{' . $rule . '}';
+
+ return $className;
+ }
+
/**
* @return array
*/
@@ -811,6 +830,7 @@ private function safeVisualDeclarations(array $declarations): array
'column-gap',
'display',
'flex-direction',
+ 'flex-shrink',
'flex-wrap',
'font-family',
'font-size',
diff --git a/php-transformer/tests/unit/author-selector-semantics.php b/php-transformer/tests/unit/author-selector-semantics.php
index 6b179899..ea296e03 100644
--- a/php-transformer/tests/unit/author-selector-semantics.php
+++ b/php-transformer/tests/unit/author-selector-semantics.php
@@ -155,9 +155,11 @@
$assert(4 === substr_count($flowTokenMarkup, 'margin-top:0;margin-right:0;margin-bottom:0;margin-left:0'), 'synthetic visual-token paragraphs do not contribute source-absent spacing, including tokens inside structural layout parents');
$assert(! str_contains($flowTokenMarkup, 'Brennan UniversityEarth Sciences');
+$linkedBrand = $transform('');
$linkedBrandMarkup = (string) ($linkedBrand['serialized_blocks'] ?? '');
+$linkedBrandCss = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), array_filter($linkedBrand['assets'] ?? array(), static fn (array $asset): bool => 'text/css' === ($asset['mime_type'] ?? ''))));
$assert(str_contains($linkedBrandMarkup, 'className":"institution blocks-engine-semantic-') && str_contains($linkedBrandMarkup, 'className":"department blocks-engine-semantic-') && 2 === substr_count($linkedBrandMarkup, 'margin-top:0;margin-right:0;margin-bottom:0;margin-left:0'), 'linked brand text retains independent native flex children without synthetic paragraph margins when shell conversion changes DOM paths');
+$assert(str_contains($linkedBrandMarkup, 'be-inline-geometry-') && str_contains($linkedBrandCss, '{flex-shrink:0}'), 'synthetic SVG phrasing hosts inherit source flex-item shrink constraints');
$resetInlineMetadata = $transform('');
$resetInlineMetadataMarkup = (string) ($resetInlineMetadata['serialized_blocks'] ?? '');
From 51ce1a5325cae8f9bc668c6813efd40bc2fdc9ba Mon Sep 17 00:00:00 2001
From: Chris Huber
Date: Tue, 21 Jul 2026 18:13:35 -0400
Subject: [PATCH 12/22] Preserve shared shell identity across routes
---
php-transformer/src/HtmlToBlocks/HtmlTransformer.php | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
index ae9a8231..d0d76229 100644
--- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
+++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
@@ -538,13 +538,14 @@ public function transform(string $html, array $options = array()): TransformerRe
);
}
- $this->markCurrentNavigationLinks($body, (string) ($options['source'] ?? ''));
$this->prepareAuthorSelectorSemantics($html, (string) ($options['static_css'] ?? ''), $body, $options);
$fallbacks = array();
$interactionCandidates = $this->interactionCandidates($body);
$this->collectSupersededNavToggleSelectors($body);
$shellArtifacts = !array_key_exists('extract_global_shell', $options) || !empty($options['extract_global_shell']) ? $this->globalShellArtifacts($body, (string) ($options['source'] ?? 'html')) : array();
+ // Shared shell identity must not vary by route-derived current state.
+ $this->markCurrentNavigationLinks($body, (string) ($options['source'] ?? ''));
$blocks = $this->deduplicateNavigationBlocks($this->convertChildren($body, $fallbacks, true));
$this->recordRuntimeIslandsForPreservedHtmlBlocks($blocks);
$this->appendInteractiveControlBehaviorLossFallbacks($body, $fallbacks);
From 0bc5f207810033047736a6ffac603eb19bcc0210 Mon Sep 17 00:00:00 2001
From: Chris Huber
Date: Tue, 21 Jul 2026 18:13:35 -0400
Subject: [PATCH 13/22] Deduplicate generated site plan assets
---
.../src/WordPressSitePlan/WordPressSitePlan.php | 10 +++++++++-
php-transformer/tests/contract/wordpress-site-plan.php | 8 ++++++++
2 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php
index 81b76787..7ac3c84f 100644
--- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php
+++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php
@@ -357,6 +357,7 @@ private function assets(mixed $assets): array
{
if ( ! is_array($assets) ) throw new InvalidArgumentException('Compiled site assets must be an array.');
$rows = array();
+ $rowsBySource = array();
foreach ( $assets as $asset ) {
if ( ! is_array($asset) || ! self::safePath($asset['path'] ?? null) ) throw new InvalidArgumentException('Compiled site asset lacks a safe source identity.');
// The compiler retains rejected source assets for diagnostics. They have no
@@ -367,7 +368,14 @@ private function assets(mixed $assets): array
$target = 'assets/' . str_replace('\\', '/', $compiledTarget);
if ( ! self::safePath($target) ) throw new InvalidArgumentException('Compiled site asset lacks a safe target identity.');
$payload = is_string($asset['content_base64'] ?? null) ? $asset['content_base64'] : (string) ($asset['content'] ?? '');
- $rows[] = array('source_path' => $asset['path'], 'target_path' => $target, 'token' => 'asset-' . substr(hash('sha256', $target), 0, 16), 'source' => self::value($asset, 'source'), 'kind' => self::value($asset, 'kind'), 'role' => self::value($asset, 'role'), 'intent' => self::value($asset, 'intent'), 'mime_type' => self::value($asset, 'mime_type'), 'media' => self::value($asset, 'media'), 'bytes' => (int) ($asset['bytes'] ?? 0), 'hash' => self::value($asset, 'hash'), 'content' => $asset['content'] ?? null, 'content_base64' => $asset['content_base64'] ?? null, 'binary' => ! empty($asset['binary']), 'reconciliation_identity' => self::identity('asset', $asset['path'], $target), 'content_hash' => self::contentHash($payload));
+ $row = array('source_path' => $asset['path'], 'target_path' => $target, 'token' => 'asset-' . substr(hash('sha256', $target), 0, 16), 'source' => self::value($asset, 'source'), 'kind' => self::value($asset, 'kind'), 'role' => self::value($asset, 'role'), 'intent' => self::value($asset, 'intent'), 'mime_type' => self::value($asset, 'mime_type'), 'media' => self::value($asset, 'media'), 'bytes' => (int) ($asset['bytes'] ?? 0), 'hash' => self::value($asset, 'hash'), 'content' => $asset['content'] ?? null, 'content_base64' => $asset['content_base64'] ?? null, 'binary' => ! empty($asset['binary']), 'reconciliation_identity' => self::identity('asset', $asset['path'], $target), 'content_hash' => self::contentHash($payload));
+ $sourceIdentity = str_replace('\\', '/', strtolower($row['source_path']));
+ if ( isset($rowsBySource[$sourceIdentity]) ) {
+ if ( $rowsBySource[$sourceIdentity] !== $row ) throw new InvalidArgumentException('Compiled site assets have colliding source identities with different payloads.');
+ continue;
+ }
+ $rowsBySource[$sourceIdentity] = $row;
+ $rows[] = $row;
}
return $rows;
}
diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php
index 05c5d241..49b4a2ce 100644
--- a/php-transformer/tests/contract/wordpress-site-plan.php
+++ b/php-transformer/tests/contract/wordpress-site-plan.php
@@ -139,6 +139,14 @@
$assert(isset($externalCommaUrl['source_reports']['wordpress_site_plan']), 'External browser URLs containing commas are validated as one URL rather than srcset candidates.');
$nestedDataUrl = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => 'Texture')))->toArray();
$assert(isset($nestedDataUrl['source_reports']['wordpress_site_plan']), 'Quoted CSS data URLs may contain encoded nested URL fragments without fabricating local references.');
+
+$repeatedGeneratedAsset = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array(
+ 'index.html' => '',
+ 'about.html' => '',
+)))->toArray();
+$repeatedGeneratedPlan = $repeatedGeneratedAsset['source_reports']['wordpress_site_plan'] ?? array();
+$repeatedGeneratedSvgAssets = array_values(array_filter($repeatedGeneratedPlan['assets'] ?? array(), static fn(array $asset): bool => 'image/svg+xml' === ($asset['mime_type'] ?? null)));
+$assert(1 === count($repeatedGeneratedSvgAssets) && !isset($repeatedGeneratedAsset['source_reports']['wordpress_site_plan_diagnostics']), 'Byte-identical generated assets shared by multiple pages produce one canonical site-plan identity.');
$publicationSvg = '';
$publicationCss = '@font-face{font-family:Example;src:url(font.woff2)}';
$publicationToken = 'asset-' . substr(hash('sha256', 'assets/assets/font.woff2'), 0, 16);
From bfe726545f00dac586971eaefee6b02640566399 Mon Sep 17 00:00:00 2001
From: Chris Huber
Date: Tue, 21 Jul 2026 18:26:43 -0400
Subject: [PATCH 14/22] Supersede native navigation runtime targets
---
.../src/HtmlToBlocks/HtmlTransformer.php | 13 ++++++++++---
.../NavigationToggleSuppressionTrait.php | 14 +++++++-------
php-transformer/tests/contract/run.php | 19 +++++++++++++++++++
3 files changed, 36 insertions(+), 10 deletions(-)
diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
index d0d76229..a3e5ecbe 100644
--- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
+++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
@@ -6032,18 +6032,25 @@ private function runtimeCanvasSelectorsFromOptions(array $options): array
private function isRuntimeDomTarget(DOMElement $element): bool
{
$id = trim($this->attr($element, 'id'));
- if ( '' !== $id && isset($this->runtimeDomSelectors['#' . $id]) && ! $this->isPresentationalAnimationSelector('#' . $id) ) {
+ if ( '' !== $id
+ && isset($this->runtimeDomSelectors['#' . $id])
+ && ! isset($this->supersededRuntimeSelectors['#' . $id])
+ && ! $this->isPresentationalAnimationSelector('#' . $id) ) {
return true;
}
foreach ( preg_split('/\s+/', trim($this->attr($element, 'class'))) ?: array() as $class ) {
- if ( '' !== $class && isset($this->runtimeDomSelectors['.' . $class]) && ! $this->isPresentationalAnimationSelector('.' . $class) ) {
+ if ( '' !== $class
+ && isset($this->runtimeDomSelectors['.' . $class])
+ && ! isset($this->supersededRuntimeSelectors['.' . $class])
+ && ! $this->isPresentationalAnimationSelector('.' . $class) ) {
return true;
}
}
foreach ( array_keys($this->runtimeDomSelectors) as $selector ) {
- if ( $this->isPresentationalAnimationSelector((string) $selector) ) {
+ if ( isset($this->supersededRuntimeSelectors[(string) $selector])
+ || $this->isPresentationalAnimationSelector((string) $selector) ) {
continue;
}
diff --git a/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressionTrait.php b/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressionTrait.php
index e4a7da16..92cb8b07 100644
--- a/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressionTrait.php
+++ b/php-transformer/src/HtmlToBlocks/Support/NavigationToggleSuppressionTrait.php
@@ -56,10 +56,10 @@ private function collectSupersededNavToggleSelectors(DOMElement $root): void
/**
* Record the source selectors made redundant when a hamburger menu-toggle is
* dropped in favor of the native navigation overlay: the toggle's own id and
- * class selectors, plus the id/class selectors of the menu/overlay it
- * controlled via `aria-controls`. A preserved site script may still reference
- * these selectors (e.g. `.nav-toggle`, `#nav-mobile`); the runtime-dependency
- * parity report uses this set to mark a resulting "missing DOM target"
+ * class selectors, plus the id/class selectors of a native-convertible
+ * menu/overlay it controlled via `aria-controls`. A preserved site script may
+ * still reference these selectors (e.g. `.nav-toggle`, `#nav-mobile`); the
+ * runtime-dependency parity report uses this set to mark a resulting "missing DOM target"
* finding as a superseded, acceptable loss rather than a materialization bug.
* Only selectors of menu-toggles the transformer actually removed are
* recorded, so genuinely-broken targets stay flagged.
@@ -74,10 +74,10 @@ private function recordSupersededNavToggleSelectors(DOMElement $toggle): void
continue;
}
- $this->supersededRuntimeSelectors['#' . $controlledId] = true;
-
$target = $this->elementWithId($toggle, $controlledId);
- if ( $target instanceof DOMElement && ! $target->isSameNode($toggle) ) {
+ if ( $target instanceof DOMElement
+ && ! $target->isSameNode($toggle)
+ && $this->convertsToCoreNavigation($target) ) {
$this->recordSupersededSelectorsForElement($target);
}
}
diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php
index 3fd2cde6..8919dcf5 100644
--- a/php-transformer/tests/contract/run.php
+++ b/php-transformer/tests/contract/run.php
@@ -1409,6 +1409,16 @@ public function match(DOMElement $element, PatternContext $context): ?array
$assert(! str_contains($redundantToggleSerialized, '" },
- { "path": "serialized_blocks", "assert": "contains", "value": "" },
+ { "path": "serialized_blocks", "assert": "contains", "value": "" },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "\"label\":\"Mara \\u003cem" },
{ "path": "fallbacks", "assert": "count", "count": 0 }
]
diff --git a/php-transformer/tests/fixtures/parity/html-linked-card-anchor-grid.json b/php-transformer/tests/fixtures/parity/html-linked-card-anchor-grid.json
index 87cd415d..67c94bef 100644
--- a/php-transformer/tests/fixtures/parity/html-linked-card-anchor-grid.json
+++ b/php-transformer/tests/fixtures/parity/html-linked-card-anchor-grid.json
@@ -21,7 +21,7 @@
{ "path": "blocks.0.innerBlocks.0.innerBlocks.0", "name": "core/group", "attrs": { "className": "article-card featured" } },
{ "path": "blocks.0.innerBlocks.0.innerBlocks.0.innerBlocks.0", "name": "core/group", "attrs": { "className": "article-thumb" } },
{ "path": "blocks.0.innerBlocks.0.innerBlocks.0.innerBlocks.1", "name": "core/group", "attrs": { "className": "article-card-body" } },
- { "path": "blocks.0.innerBlocks.0.innerBlocks.0.innerBlocks.1.innerBlocks.1", "name": "core/heading", "attrs": { "className": "article-headline", "content": "City Hall Rewrites the Map", "level": 3 } },
+ { "path": "blocks.0.innerBlocks.0.innerBlocks.0.innerBlocks.1.innerBlocks.1", "name": "core/heading", "attrs": { "className": "article-headline", "content": "City Hall Rewrites the Map", "level": 3 } },
{ "path": "blocks.0.innerBlocks.0.innerBlocks.1", "name": "core/group", "attrs": { "className": "article-card" } },
{ "path": "blocks.0.innerBlocks.1", "name": "core/buttons", "attrs": { "className": "link-row" } },
{ "path": "blocks.0.innerBlocks.1.innerBlocks.0", "name": "core/button", "attrs": { "text": "Start", "url": "/start" } },
diff --git a/php-transformer/tests/fixtures/parity/html-whole-element-link-card-propagated.json b/php-transformer/tests/fixtures/parity/html-whole-element-link-card-propagated.json
index 2f486b7e..7d9c89bf 100644
--- a/php-transformer/tests/fixtures/parity/html-whole-element-link-card-propagated.json
+++ b/php-transformer/tests/fixtures/parity/html-whole-element-link-card-propagated.json
@@ -18,7 +18,7 @@
"expected_blocks": [
{ "path": "blocks.0", "name": "core/group", "attrs": { "className": "feature-card" } },
{ "path": "blocks.0.innerBlocks.0", "name": "core/image", "attrs": { "url": "/img/feature.jpg", "alt": "Feature", "href": "https://example.com/feature/", "linkDestination": "custom" } },
- { "path": "blocks.0.innerBlocks.1", "name": "core/heading", "attrs": { "content": "Feature Title", "level": 3 } },
+ { "path": "blocks.0.innerBlocks.1", "name": "core/heading", "attrs": { "content": "Feature Title", "level": 3 } },
{ "path": "blocks.0.innerBlocks.2", "name": "core/paragraph" }
],
"expected_fallbacks": [],
@@ -31,9 +31,9 @@
{ "path": "blocks.0.innerBlocks.0.blockName", "assert": "equals", "value": "core/image" },
{ "path": "blocks.0.innerBlocks.0.attrs.href", "assert": "equals", "value": "https://example.com/feature/" },
{ "path": "blocks.0.innerBlocks.0.attrs.linkDestination", "assert": "equals", "value": "custom" },
- { "path": "blocks.0.innerBlocks.1.attrs.content", "assert": "contains", "value": "Feature Title" },
+ { "path": "blocks.0.innerBlocks.1.attrs.content", "assert": "contains", "value": "Feature Title" },
{ "path": "serialized_blocks", "assert": "contains", "value": "
Feature Title" },
+ { "path": "serialized_blocks", "assert": "contains", "value": "Feature Title" },
{ "path": "serialized_blocks", "assert": "contains", "value": "Feature description text." },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "\"className\":\"feature-card\",\"href\"" },
{ "path": "source_reports.html.dropped_link_wrappers", "assert": "count", "count": 0 },
diff --git a/php-transformer/tests/fixtures/parity/html-whole-element-link-group-no-href.json b/php-transformer/tests/fixtures/parity/html-whole-element-link-group-no-href.json
index 81279e43..aa59bc90 100644
--- a/php-transformer/tests/fixtures/parity/html-whole-element-link-group-no-href.json
+++ b/php-transformer/tests/fixtures/parity/html-whole-element-link-group-no-href.json
@@ -17,7 +17,7 @@
},
"expected_blocks": [
{ "path": "blocks.0", "name": "core/group", "attrs": { "className": "panel-link" } },
- { "path": "blocks.0.innerBlocks.0", "name": "core/heading", "attrs": { "content": "Quarterly Review", "level": 2 } },
+ { "path": "blocks.0.innerBlocks.0", "name": "core/heading", "attrs": { "content": "Quarterly Review", "level": 2 } },
{ "path": "blocks.0.innerBlocks.1", "name": "core/paragraph" }
],
"expected_fallbacks": [],
@@ -27,8 +27,8 @@
{ "path": "blocks.0.blockName", "assert": "equals", "value": "core/group" },
{ "path": "blocks.0.attrs.href", "assert": "equals", "value": null },
{ "path": "blocks.0.innerBlocks", "assert": "count", "count": 2 },
- { "path": "serialized_blocks", "assert": "contains", "value": "Quarterly Review" },
- { "path": "serialized_blocks", "assert": "contains", "value": "A summary of the latest reporting period." },
+ { "path": "serialized_blocks", "assert": "contains", "value": "Quarterly Review" },
+ { "path": "serialized_blocks", "assert": "contains", "value": "A summary of the latest reporting period." },
{ "path": "serialized_blocks", "assert": "contains", "value": "Quarterly Review" },
{ "path": "serialized_blocks", "assert": "contains", "value": "A summary of the latest reporting period." },
{ "path": "serialized_blocks", "assert": "not_contains", "value": "\"href\":\"https://example.com/details/\"" },
diff --git a/php-transformer/tests/unit/author-selector-semantics.php b/php-transformer/tests/unit/author-selector-semantics.php
index ea296e03..279631c0 100644
--- a/php-transformer/tests/unit/author-selector-semantics.php
+++ b/php-transformer/tests/unit/author-selector-semantics.php
@@ -39,6 +39,10 @@
$currentNavigation = ( new HtmlTransformer() )->transform('', array( 'source' => 'index.html' ))->toArray();
$currentNavigationMarkup = (string) ($currentNavigation['serialized_blocks'] ?? '');
$assert(str_contains($currentNavigationMarkup, 'anchorClassName":"active') && str_contains($currentNavigationMarkup, 'textDecoration":"underline') && 1 === substr_count($currentNavigationMarkup, 'anchorClassName":"active'), 'source-matching navigation links retain their current-page state before routes are rewritten');
+$assert((bool) preg_match('/\.wp-block-navigation-item\.blocks-engine-navigation-control-[^,{]+[^{}]*> \.wp-block-navigation-item__content\{color:gold;border-bottom-color:gold\}/', $css($currentNavigation)), 'current navigation anchor paint projects through the exact native navigation item identity');
+
+$richTextAnchor = $transform('Read the full story.
');
+$assert(str_contains((string) ($richTextAnchor['serialized_blocks'] ?? ''), 'Go');
$controlCss = $css($controls);
From 6d57e89776cc556692b456db175893dd14fd4c7a Mon Sep 17 00:00:00 2001
From: Chris Huber
Date: Wed, 22 Jul 2026 15:29:45 -0400
Subject: [PATCH 18/22] Preserve authored SVG image geometry
---
.../Support/SvgMaterializationTrait.php | 21 ++++++++++++++++++-
php-transformer/tests/contract/run.php | 11 ++++++++++
2 files changed, 31 insertions(+), 1 deletion(-)
diff --git a/php-transformer/src/HtmlToBlocks/Support/SvgMaterializationTrait.php b/php-transformer/src/HtmlToBlocks/Support/SvgMaterializationTrait.php
index 93bf470b..340acb97 100644
--- a/php-transformer/src/HtmlToBlocks/Support/SvgMaterializationTrait.php
+++ b/php-transformer/src/HtmlToBlocks/Support/SvgMaterializationTrait.php
@@ -77,7 +77,7 @@ private function inlineSvgImageAttributesFromMarkup(DOMElement $element, string
return null;
}
- $html = $this->ensureSvgImageNamespace($this->minifyInlineSvgForImage($html));
+ $html = $this->ensureSvgImageNamespace($this->minifyInlineSvgForImage($this->stripProjectedSvgImageBoxStyle($html, $element)));
$path = $this->materializedInlineSvgPath($element, $html);
$this->generatedAssets[$path] = array(
'source' => 'inline-svg',
@@ -339,6 +339,25 @@ private function minifyInlineSvgForImage(string $html): string
return trim($html);
}
+ private function stripProjectedSvgImageBoxStyle(string $html, DOMElement $element): string
+ {
+ if ( ! preg_match('/