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('
Utility copy
')->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( + '
LabelCopy
', + 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( + '
LabelCopy
', + 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('
Copy
')->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": "
" }, - { "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": "js-site-header" }, From ba412781d1c18e82f7d8b8cf6243b79f89a3bed1 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 20 Jul 2026 18:04:12 -0400 Subject: [PATCH 03/22] Preserve adjacent navigation link spacing --- .../Patterns/NavigationPattern.php | 68 +++++++++++++++++++ php-transformer/tests/contract/run.php | 16 +++++ 2 files changed, 84 insertions(+) diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index d1d4a8f2..1772bb27 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -73,6 +73,7 @@ public function match(DOMElement $element, PatternContext $context): ?array $navigationAttrs, $commonTextAttrs ); + $this->promoteDirectAnchorSpacing($element, $presentationAttributes, $containerAttrs, $links); $navigation = $createBlock('core/navigation', $navigationAttrs, $links, $element); @@ -559,6 +560,73 @@ private function navigationContainerAttributes(DOMElement $element, callable $pr return $attrs; } + /** + * Core navigation links do not reproduce sibling margins reliably. A uniform + * leading margin on every adjacent direct anchor is equivalent to row gap. + * + * @param array $containerAttrs + * @param array> $links + */ + private function promoteDirectAnchorSpacing(DOMElement $element, callable $presentationAttributes, array &$containerAttrs, array &$links): void + { + if ( 'vertical' === ($containerAttrs['layout']['orientation'] ?? '') ) { + return; + } + + $sourceAttrs = $this->withoutCoreNavigationClasses($presentationAttributes($element)); + if ( '' !== (string) ($sourceAttrs['style']['spacing']['blockGap'] ?? '') ) { + return; + } + + $anchors = array(); + foreach ( $element->childNodes as $child ) { + if ( $child instanceof DOMElement && 'a' === strtolower($child->tagName) ) { + $anchors[] = $child; + } + } + if ( count($anchors) < 2 || count($anchors) !== count($links) ) { + return; + } + + $leadingMargins = array_map(static function (array $link): string { + $margin = trim((string) ($link['attrs']['style']['spacing']['margin']['left'] ?? '')); + return '' === $margin ? '0' : $margin; + }, $links); + foreach ( $links as $link ) { + $trailingMargin = trim((string) ($link['attrs']['style']['spacing']['margin']['right'] ?? '')); + if ( '' !== $trailingMargin && ! in_array($trailingMargin, array( '0', '0px', '0rem', '0em' ), true) ) { + return; + } + } + $gap = $leadingMargins[1]; + if ( ! in_array($leadingMargins[0], array( '0', '0px', '0rem', '0em' ), true) || in_array($gap, array( '0', '0px', '0rem', '0em' ), true) ) { + return; + } + foreach ( array_slice($leadingMargins, 2) as $margin ) { + if ( $gap !== $margin ) { + return; + } + } + + $containerAttrs['style']['spacing']['blockGap'] = $gap; + foreach ( $links as $index => &$link ) { + if ( 0 === $index ) { + continue; + } + unset($link['attrs']['style']['spacing']['margin']['left']); + if ( empty($link['attrs']['style']['spacing']['margin']) ) { + unset($link['attrs']['style']['spacing']['margin']); + } + if ( empty($link['attrs']['style']['spacing']) ) { + unset($link['attrs']['style']['spacing']); + } + if ( empty($link['attrs']['style']) ) { + unset($link['attrs']['style']); + } + } + unset($link); + } + private function isListNavigationSource(DOMElement $element): bool { if ( in_array(strtolower($element->tagName), array( 'ul', 'ol' ), true) ) { diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 20469692..10611c5c 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -1237,6 +1237,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'); +$uniformMarginNavigation = ( new HtmlTransformer() )->transform( + '' +)->toArray(); +$uniformMarginNavigationBlock = $uniformMarginNavigation['blocks'][0] ?? array(); +$uniformMarginNavigationLinks = $uniformMarginNavigationBlock['innerBlocks'] ?? array(); +$assert('18px' === ($uniformMarginNavigationBlock['attrs']['style']['spacing']['blockGap'] ?? ''), 'uniform adjacent direct-anchor margins become core navigation gap'); +$assert('nowrap' === ($uniformMarginNavigationBlock['attrs']['layout']['flexWrap'] ?? ''), 'uniform adjacent direct-anchor margins retain the non-wrapping row'); +$assert(! isset($uniformMarginNavigationLinks[1]['attrs']['style']['spacing']['margin']['left']) && ! isset($uniformMarginNavigationLinks[2]['attrs']['style']['spacing']['margin']['left']), 'promoted direct-anchor margins are removed from navigation links to avoid duplicate spacing'); + +$explicitGapNavigation = ( new HtmlTransformer() )->transform( + '' +)->toArray(); +$explicitGapNavigationBlock = $explicitGapNavigation['blocks'][0] ?? array(); +$assert('4px' === ($explicitGapNavigationBlock['attrs']['style']['spacing']['blockGap'] ?? ''), 'explicit direct-anchor navigation gap remains authoritative over item margins'); +$assert('18px' === ($explicitGapNavigationBlock['innerBlocks'][1]['attrs']['style']['spacing']['margin']['left'] ?? ''), 'explicit navigation gap leaves authored item margins intact'); + $wrappingNavigation = ( new HtmlTransformer() )->transform( '' )->toArray(); From bbe78051ca66e9d266f60a100128da74e98de4ea Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 20 Jul 2026 18:14:10 -0400 Subject: [PATCH 04/22] Resolve first-of-type author selectors --- .../src/HtmlToBlocks/Style/CssSelectorMatcher.php | 14 +++++++++++++- php-transformer/tests/contract/run.php | 13 +++++++++++++ .../tests/unit/css-selector-matcher.php | 2 ++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php b/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php index 5c0b8b56..ed1f00c7 100644 --- a/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php +++ b/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php @@ -85,7 +85,7 @@ public static function matches(DOMElement $element, array $selector, bool $accou /** @return array{compound: array, suffix: array{start: int, end: int}|null, type_span: array{start: int, end: int, name: string}|null}|null */ private static function parseCompound(string $source, int $sourceStart, bool $isRightmost): ?array { - $compound = array( 'type' => null, 'universal' => false, 'classes' => array(), 'ids' => array(), 'attributes' => array(), 'nth_child' => null, 'first_child' => false, 'last_child' => false ); + $compound = array( 'type' => null, 'universal' => false, 'classes' => array(), 'ids' => array(), 'attributes' => array(), 'nth_child' => null, 'first_child' => false, 'last_child' => false, 'first_of_type' => false ); $offset = 0; $suffix = null; $typeSpan = null; @@ -115,6 +115,11 @@ private static function parseCompound(string $source, int $sourceStart, bool $is $hasSimple = true; continue; } + if ( 'first-of-type' === $lowerName && '(' !== ($source[ $offset ] ?? '') ) { + $compound['first_of_type'] = true; + $hasSimple = true; + continue; + } if ( 'nth-child' === $lowerName && '(' === ($source[ $offset ] ?? '') ) { $closing = strpos($source, ')', $offset + 1); if ( false === $closing ) { @@ -441,6 +446,13 @@ private static function matchesCompound(DOMElement $element, array $compound): b if ( $compound['last_child'] && self::hasNextElementSibling($element) ) { return false; } + if ( $compound['first_of_type'] ) { + for ( $previous = self::previousElementSibling($element); null !== $previous; $previous = self::previousElementSibling($previous) ) { + if ( 0 === strcasecmp($element->tagName, $previous->tagName) ) { + return false; + } + } + } return true; } diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 10611c5c..9ca961a3 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -1954,6 +1954,19 @@ public function match(DOMElement $element, PatternContext $context): ?array $artifactNavAnchorStaticCss = (string) ($artifactNavAnchorCss['source_reports']['compiled_site']['theme']['static_css'] ?? ''); $assert(str_contains($artifactNavAnchorStaticCss, '.site-header .subnav.wp-block-navigation .wp-block-navigation-item__content, .site-header .subnav .wp-block-navigation .wp-block-navigation-item__content { color:#31251c;text-decoration:none;border-color:#31251c }'), 'artifact static CSS replays nested nav anchor color through direct and descendant core/navigation wrappers'); $assert(str_contains($artifactNavAnchorStaticCss, '.site-header .subnav.wp-block-navigation .wp-block-navigation-item__content:hover, .site-header .subnav .wp-block-navigation .wp-block-navigation-item__content:hover { color:#8f5031;border-color:#8f5031 }'), 'artifact static CSS replays nested nav anchor hover color through core/navigation wrappers'); + +$artifactAdjacentNavSpacing = $compiler->compile( + array( + 'entry' => 'index.html', + 'files' => array( + 'index.html' => '', + 'styles.css' => '.utility-bar a{margin-left:18px}.utility-bar a:first-of-type{margin-left:0}', + ), + ) +)->toArray(); +$artifactAdjacentNavBlock = $artifactAdjacentNavSpacing['blocks'][0]['innerBlocks'][0] ?? array(); +$assert('18px' === ($artifactAdjacentNavBlock['attrs']['style']['spacing']['blockGap'] ?? ''), 'linked CSS first-of-type reset promotes uniform adjacent navigation spacing to native block gap'); +$assert(! isset($artifactAdjacentNavBlock['innerBlocks'][1]['attrs']['style']['spacing']['margin']['left']), 'linked CSS navigation spacing is not duplicated on promoted child margins'); $assert(! str_contains($artifactNavAnchorStaticCss, '.site-header.wp-block-navigation .subnav'), 'artifact static CSS does not attach core/navigation to the wrong ancestor selector'); $artifactNavAnchorRepairCss = (string) ($artifactNavAnchorCss['source_reports']['compiled_site']['visual_repair']['css'] ?? ''); $assert(str_contains($artifactNavAnchorRepairCss, '.site-header .subnav.wp-block-navigation .wp-block-navigation-item__content, .site-header .subnav .wp-block-navigation .wp-block-navigation-item__content { color:#31251c;text-decoration:none;border-color:#31251c }'), 'artifact visual repair CSS carries nav anchor replay for downstream theme materializers'); diff --git a/php-transformer/tests/unit/css-selector-matcher.php b/php-transformer/tests/unit/css-selector-matcher.php index a5be98b8..20e82d70 100644 --- a/php-transformer/tests/unit/css-selector-matcher.php +++ b/php-transformer/tests/unit/css-selector-matcher.php @@ -66,6 +66,8 @@ $assert($result['supported'] && $result['matches'], "matches structural pseudo-class {$selector}"); } $assert(! $match('p:nth-child(1)', $byId('two'))['matches'], 'rejects a non-matching structural child index'); +$assert($match('input:first-of-type', $byId('control'))['matches'], 'matches first-of-type independently of child position'); +$assert(! $match('p:first-of-type', $byId('two'))['matches'], 'rejects a later sibling of the same type'); foreach ( array( ':disabled', ':not(.x)', ':is(.x)', ':where(.x)', ':has(.x)', ':nth-child(0)', ':nth-child(2n+1)', ':nth-child()', 'p::before', 'svg|a', '.a||.b', '.a, .b', '.a[', '.a >', '-', '.-', '#-', '.10', '\\', ".a\\\n", ".a\\\r", ".a\\\r\n", "[data-value=\"a\nb\"]", "[data-value=\"a\\\nb\"]", "[data-value=\"a\\\r\nb\"]", "[data-value=\"a\\\"]" ) as $selector ) { $assert(! CssSelectorMatcher::parse($selector)['supported'], "rejects unsupported or malformed {$selector}"); From dd509579c3fd7f440fd7cd9b02cecccc6569d863 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 20 Jul 2026 19:57:50 -0400 Subject: [PATCH 05/22] Preserve imported shell and content structure --- .../src/HtmlToBlocks/HtmlTransformer.php | 29 ++++++++++++------- .../Patterns/NavigationPattern.php | 18 +++++++++++- php-transformer/tests/contract/run.php | 15 ++++++++++ ...tml-richtext-partial-span-visual-mark.json | 2 +- .../artifact-author-stylesheet-projection.php | 2 +- .../tests/unit/author-selector-semantics.php | 12 ++++---- 6 files changed, 59 insertions(+), 19 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 6d9d8e1d..112927d9 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -769,7 +769,7 @@ private function richTextMarkerResetCss(): string return ''; } - return ':where(mark)[style*="--blocks-engine-richtext-marker:"]{background-color:transparent;color:inherit}'; + return ':where(mark[class*="blocks-engine-richtext-"]){background-color:transparent;color:inherit}'; } /** @param array $options */ @@ -1281,7 +1281,7 @@ private function projectSemanticLeafSelector(string $selector, array $parsed, st private function projectRichTextSemanticSelector(string $selector, array $parsed, string $marker): string { $suffix = null === $parsed['pseudo_state_suffix_span'] ? '' : substr($selector, $parsed['pseudo_state_suffix_span']['start']); - return 'mark[style*="--blocks-engine-richtext-marker:' . $marker . '"]' . $this->selectorSpecificityShims($parsed) . $suffix; + return 'mark.' . $marker . $this->selectorSpecificityShims($parsed) . $suffix; } /** @param array $parsed */ @@ -2985,7 +2985,7 @@ private function richTextContentWithMaterializedInlineStyles(DOMElement $element $inline = $this->richTextInlineVisualDeclarations($sourceInline); $marker = $this->richTextMarkerForElement($sourceInline); if ( '' !== $marker ) { - $inline['--blocks-engine-richtext-marker'] = $marker; + $targetInline->setAttribute('class', $this->mergeClassNames($this->attr($targetInline, 'class'), $marker)); } if ( array() === $inline ) { continue; @@ -3065,15 +3065,17 @@ private function replaceRichTextStylingHookWithMark(DOMElement $element): bool $declarations = $this->richTextInlineVisualDeclarations($element); $existingDeclarations = $this->cssDeclarations($this->attr($element, 'style')); - $marker = trim((string) ($existingDeclarations['--blocks-engine-richtext-marker'] ?? '')); + $marker = ''; + foreach ( preg_split('/\s+/', trim($this->attr($element, 'class'))) ?: array() as $className ) { + if ( preg_match('/^blocks-engine-richtext-[a-f0-9]+-\d+$/', $className) ) { + $marker = $className; + break; + } + } if ( '' === $marker && array() === $declarations ) { return false; } - if ( '' !== $marker ) { - $declarations['--blocks-engine-richtext-marker'] = $marker; - } - if ( '' === $marker && ! isset($declarations['background-color']) ) { $declarations['background-color'] = 'transparent'; } @@ -3087,6 +3089,9 @@ private function replaceRichTextStylingHookWithMark(DOMElement $element): bool } $mark = $document->createElement('mark'); + if ( '' !== $marker ) { + $mark->setAttribute('class', $marker); + } $mark->setAttribute('style', $this->cssDeclarationString($declarations)); while ( null !== $element->firstChild ) { $mark->appendChild($element->firstChild); @@ -3464,8 +3469,12 @@ private function textFlowBlockFromElement(DOMElement $element): ?array } $hasLineBreak = false; + $hasDirectText = false; foreach ( $element->childNodes as $child ) { if ( ! $child instanceof DOMElement ) { + if ( '' !== trim($child->textContent ?? '') ) { + $hasDirectText = true; + } continue; } @@ -3478,11 +3487,11 @@ private function textFlowBlockFromElement(DOMElement $element): ?array } } - if ( ! $hasLineBreak ) { + if ( ! $hasLineBreak && ! $hasDirectText ) { return null; } - $content = $this->richTextContentWithoutDecorativeSvg($element); + $content = $this->richTextContentWithMaterializedInlineStyles($element); if ( '' === trim($this->runtime->stripAllTags($content)) ) { return null; } diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index 1772bb27..7bee0ce6 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -989,7 +989,23 @@ private function hasNavigationSignal(DOMElement $element): bool if ( in_array($token, array( 'nav', 'navbar', 'navigation', 'menu' ), true) ) { return true; } - if ( 'links' === $token && ! $this->isContactLinkCluster($element) ) { + if ( 'links' === $token && ! $this->isContactLinkCluster($element) && ! $this->hasCardLikeDirectAnchor($element) ) { + return true; + } + } + } + + return false; + } + + private function hasCardLikeDirectAnchor(DOMElement $element): bool + { + foreach ( $element->childNodes as $child ) { + if ( ! $child instanceof DOMElement || 'a' !== strtolower($child->tagName) ) { + continue; + } + foreach ( $child->childNodes as $anchorChild ) { + if ( $anchorChild instanceof DOMElement && preg_match('/^(?:' . self::BLOCK_LEVEL_LABEL_TAGS . ')$/i', $anchorChild->tagName) ) { return true; } } diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 9ca961a3..0dd5e5d3 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -1253,6 +1253,20 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert('4px' === ($explicitGapNavigationBlock['attrs']['style']['spacing']['blockGap'] ?? ''), 'explicit direct-anchor navigation gap remains authoritative over item margins'); $assert('18px' === ($explicitGapNavigationBlock['innerBlocks'][1]['attrs']['style']['spacing']['margin']['left'] ?? ''), 'explicit navigation gap leaves authored item margins intact'); +$quickLinkCards = ( new HtmlTransformer() )->transform( + '' +)->toArray(); +$quickLinkCardsBlock = $quickLinkCards['blocks'][0] ?? array(); +$assert('core/group' === ($quickLinkCardsBlock['blockName'] ?? '') && 'quick-links' === ($quickLinkCardsBlock['attrs']['className'] ?? ''), 'card-like quick-links container remains a layout group rather than a navigation menu'); +$assert('core/group' === ($quickLinkCardsBlock['innerBlocks'][0]['blockName'] ?? '') && 'quick-link' === ($quickLinkCardsBlock['innerBlocks'][0]['attrs']['className'] ?? ''), 'block-content quick link remains a card group'); + +$inlineMetadataFlow = ( new HtmlTransformer() )->transform( + '
Committee chair: Prof. Mahalingam · M-302 Mendel Hall
' +)->toArray(); +$inlineMetadataBlock = $inlineMetadataFlow['blocks'][0] ?? array(); +$assert('core/paragraph' === ($inlineMetadataBlock['blockName'] ?? '') && 'event-meta' === ($inlineMetadataBlock['attrs']['className'] ?? ''), 'phrasing-only div with direct text remains one metadata flow'); +$assert(str_contains((string) ($inlineMetadataBlock['attrs']['content'] ?? ''), 'Prof. Mahalingam') && str_contains((string) ($inlineMetadataBlock['attrs']['content'] ?? ''), 'M-302'), 'metadata flow preserves text and inline semantic tokens together'); + $wrappingNavigation = ( new HtmlTransformer() )->transform( '' )->toArray(); @@ -2145,6 +2159,7 @@ public function match(DOMElement $element, PatternContext $context): ?array $footerShellPart = $footerShellTemplateParts[0] ?? array(); $assert(! str_contains((string) ($footerShellIndexPage['block_markup'] ?? ''), 'Global footer copy'), 'compiled site removes global footer shell from page body when a footer template part exists'); $assert(str_contains((string) ($footerShellPart['block_markup'] ?? ''), 'Global footer copy'), 'compiled site preserves global footer copy in the footer template part'); +$assert(str_contains((string) ($footerShellPart['block_markup'] ?? ''), '"className":"site-footer"'), 'compiled footer template part preserves the source shell class boundary'); $assert(str_contains((string) ($footerShellAboutPage['block_markup'] ?? ''), 'Article byline footer'), 'compiled site preserves page-local article footer content while pruning global footer shell'); $assert(! str_contains((string) ($footerShellAboutPage['block_markup'] ?? ''), 'Global footer copy'), 'compiled site does not duplicate global footer shell on secondary page bodies'); diff --git a/php-transformer/tests/fixtures/parity/html-richtext-partial-span-visual-mark.json b/php-transformer/tests/fixtures/parity/html-richtext-partial-span-visual-mark.json index c14e848a..3764a9f9 100644 --- a/php-transformer/tests/fixtures/parity/html-richtext-partial-span-visual-mark.json +++ b/php-transformer/tests/fixtures/parity/html-richtext-partial-span-visual-mark.json @@ -22,7 +22,7 @@ "expect": [ { "path": "status", "assert": "equals", "value": "success" }, { "path": "source_reports.wp_block_validity.status", "assert": "equals", "value": "pass" }, - { "path": "serialized_blocks", "assert": "contains", "value": "Roasters" }, + { "path": "serialized_blocks", "assert": "contains", "value": "Roasters" }, { "path": "serialized_blocks", "assert": "not_contains", "value": "class=\"accent\"" }, { "path": "fallbacks", "assert": "count", "count": 0 } ] diff --git a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php index d527f253..ef72c330 100644 --- a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php +++ b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php @@ -48,7 +48,7 @@ ), ) )->toArray(); $richTextAssets = $richText['assets'] ?? array(); -$assert(str_starts_with((string) ($richTextAssets[0]['content'] ?? ''), ':where(mark)[style*="--blocks-engine-richtext-marker:"]{background-color:transparent;color:inherit}') && str_contains((string) ($richTextAssets[0]['content'] ?? ''), '{color:#e8a020}') && ! str_contains((string) ($richTextAssets[1]['content'] ?? ''), 'background-color:transparent;color:inherit'), 'artifact projection emits one marker reset before the first projected author stylesheet'); +$assert(str_starts_with((string) ($richTextAssets[0]['content'] ?? ''), ':where(mark[class*="blocks-engine-richtext-"]){background-color:transparent;color:inherit}') && str_contains((string) ($richTextAssets[0]['content'] ?? ''), '{color:#e8a020}') && ! str_contains((string) ($richTextAssets[1]['content'] ?? ''), 'background-color:transparent;color:inherit'), 'artifact projection emits one marker reset before the first projected author stylesheet'); $multiPage = ( new ArtifactCompiler() )->compile(array( 'files' => array( diff --git a/php-transformer/tests/unit/author-selector-semantics.php b/php-transformer/tests/unit/author-selector-semantics.php index 0112e152..0828d895 100644 --- a/php-transformer/tests/unit/author-selector-semantics.php +++ b/php-transformer/tests/unit/author-selector-semantics.php @@ -148,17 +148,17 @@ $repeatedMarkup = (string) ($repeatedParents['serialized_blocks'] ?? ''); $repeatedCss = $css($repeatedParents); preg_match_all('/blocks-engine-semantic-[a-f0-9]+-\d+/', $repeatedMarkup . "\n" . $repeatedCss, $repeatedMarkers); -$assert(2 === count(array_unique($repeatedMarkers[0] ?? array())) && 2 === substr_count($repeatedCss, ':where(.blocks-engine-semantic-') && str_contains($repeatedMarkup, '--blocks-engine-richtext-marker:blocks-engine-richtext-') && str_contains($repeatedCss, 'mark[style*="--blocks-engine-richtext-marker:blocks-engine-richtext-'), 'repeated structural parents allocate unique source-path markers without leaking their box styles into an unrelated inline sibling'); +$assert(2 === count(array_unique($repeatedMarkers[0] ?? array())) && 2 === substr_count($repeatedCss, ':where(.blocks-engine-semantic-') && str_contains($repeatedMarkup, 'more.

'); $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, '
'); +$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('

Decision

Byline
'); $resetInlineMetadataMarkup = (string) ($resetInlineMetadata['serialized_blocks'] ?? ''); $assert(! str_contains($resetInlineMetadataMarkup, 'blocks-engine-semantic-') && str_contains($resetInlineMetadataMarkup, '