diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
index 9817f4ee..9562085a 100644
--- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
+++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
@@ -1934,6 +1934,7 @@ private function compiledSiteReport(array $artifact, string $entryPath, array $d
}
}
$blockMarkup = (string) ($compiledBlocks['serialized_blocks'] ?? '');
+ $canonicalBlockMarkup = $blockMarkup;
if ( $path === $entryPath ) {
foreach ( $entryShellArtifacts as $shellArtifact ) {
if ( ! is_array($shellArtifact) ) {
@@ -1962,6 +1963,7 @@ private function compiledSiteReport(array $artifact, string $entryPath, array $d
'html' => $file['content'] ?? '',
'body_format' => $bodyFormat,
'block_markup' => $blockMarkup,
+ 'canonical_block_markup' => $canonicalBlockMarkup,
'shell_artifacts' => is_array($compiledBlocks['shell_artifacts'] ?? null) ? $compiledBlocks['shell_artifacts'] : array(),
'runtime_islands' => is_array($compiledBlocks['runtime_islands'] ?? null) ? $compiledBlocks['runtime_islands'] : array(),
'bytes' => $file['bytes'] ?? 0,
@@ -2012,9 +2014,9 @@ private function compiledSiteReport(array $artifact, string $entryPath, array $d
$shellArtifact['slug'] = 'entry-' . $slug;
}
$partSlugs[(string) $shellArtifact['slug']] = true;
- if ( is_string($shellArtifact['inner_block_markup'] ?? null) ) {
- $shellArtifact['block_markup'] = $shellArtifact['inner_block_markup'];
- unset($shellArtifact['inner_block_markup']);
+ if ( is_string($shellArtifact['template_part_block_markup'] ?? null) ) {
+ $shellArtifact['block_markup'] = $shellArtifact['template_part_block_markup'];
+ unset($shellArtifact['inner_block_markup'], $shellArtifact['template_part_block_markup']);
}
$templateParts[] = $shellArtifact;
}
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/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
index c2cbefdd..5a3571e3 100644
--- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
+++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
@@ -355,6 +355,12 @@ final class HtmlTransformer
/** @var array Source wrapper paths promoted into core/button. */
private array $sourceButtonPresentationMarkers = array();
+ /** @var array */
+ private array $sourceNavigationControlMarkers = array();
+
+ /** @var array */
+ private array $sourceRichTextAnchorMarkers = array();
+
/** @var array Source controls that need selector projection. */
private array $sourceControlPaths = array();
@@ -380,6 +386,9 @@ final class HtmlTransformer
/** @var list */
private array $authorStylesheetAssets = array();
+ /** @var array */
+ private array $placeholderCaptionRules = array();
+
/** A collision-checked custom element used solely to retain type specificity. */
private string $authorSpecificityShim = '';
@@ -451,6 +460,8 @@ public function transform(string $html, array $options = array()): TransformerRe
$this->sourceTagMarkers = array();
$this->sourceControlMarkers = array();
$this->sourceButtonPresentationMarkers = array();
+ $this->sourceNavigationControlMarkers = array();
+ $this->sourceRichTextAnchorMarkers = array();
$this->sourceControlPaths = array();
$this->sourceSemanticMarkers = array();
$this->sourceRootChildMarkers = array();
@@ -461,6 +472,7 @@ public function transform(string $html, array $options = array()): TransformerRe
$this->authorMarkerCounter = 0;
$this->authorMarkerCollisionText = '';
$this->authorStylesheetAssets = array();
+ $this->placeholderCaptionRules = array();
$this->authorSpecificityShim = '';
$this->authorClassSpecificityShim = '';
$this->authorIdSpecificityShim = '';
@@ -544,6 +556,8 @@ public function transform(string $html, array $options = array()): TransformerRe
$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);
@@ -662,6 +676,9 @@ private function globalShellArtifacts(DOMElement $body, string $source, bool $re
// landmark around an independently converted landmark block.
$blocks = array($this->createBlock('core/group', $wrapperAttrs, $blocks, $child));
$markup = $this->runtime->serializeBlocks($blocks);
+ $templatePartBlocks = $blocks;
+ unset($templatePartBlocks[0]['attrs']['tagName']);
+ $templatePartMarkup = $this->runtime->serializeBlocks($templatePartBlocks);
if ( '' === trim($markup) ) {
continue;
}
@@ -673,6 +690,7 @@ private function globalShellArtifacts(DOMElement $body, string $source, bool $re
'body_format' => 'blocks',
'block_markup' => $markup,
'inner_block_markup' => $innerMarkup,
+ 'template_part_block_markup' => $templatePartMarkup,
'source_selector' => strtolower($child->tagName),
'source_classes' => $this->shellSourceClasses($child),
'source_hash' => hash('sha256', $this->outerHtml($child)),
@@ -730,12 +748,20 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo
$cssParts[] = $markerReset;
}
if ( str_contains($serializedBlocks, 'blocks-engine-list-navigation') ) {
- $cssParts[] = '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation-item.wp-block-navigation-link{display:list-item;font:inherit}'
+ $cssParts[] = '.wp-block-navigation.blocks-engine-list-navigation{align-items:normal}'
+ . "\n" . '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation-item.wp-block-navigation-link{display:list-item;font:inherit}'
. "\n" . '.wp-block-navigation.blocks-engine-list-navigation .wp-block-navigation-item__content{display:inline}';
}
if ( $includeAuthorStyles && '' !== $this->combinedAuthorCss ) {
$cssParts[] = $this->rewriteAuthorStylesheet($this->combinedAuthorCss);
}
+ if ( array() !== $this->placeholderCaptionRules ) {
+ $cssParts[] = implode("\n", $this->placeholderCaptionRules);
+ }
+ if ( str_contains($serializedBlocks, 'blocks-engine-propagated-link') || str_contains($serializedBlocks, 'blocks-engine-propagated-image-link') ) {
+ $cssParts[] = '.blocks-engine-propagated-link{background-color:transparent;color:inherit}.blocks-engine-propagated-link>a{color:inherit;border:0;text-decoration:inherit}'
+ . "\n" . '.blocks-engine-propagated-image-link>a{color:inherit;border:0;text-decoration:inherit}';
+ }
$css = trim(implode("\n\n", $cssParts));
if ( '' === $css ) {
@@ -763,13 +789,80 @@ 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 ) {
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 */
@@ -848,10 +941,15 @@ private function discoverAuthorInlineSemanticPaths(): void
continue;
}
foreach ( $this->matchingAuthorSourceElements($parsed) as $element ) {
- if ( 'span' !== strtolower($element->tagName) ) {
+ $tagName = strtolower($element->tagName);
+ $path = $this->sourceElementIdentity($element);
+ if ( 'a' === $tagName && $this->isRichTextInlineContext($element) ) {
+ $this->sourceRichTextAnchorMarkers[$path] ??= $this->allocateAuthorMarker('richtext-anchor');
+ continue;
+ }
+ if ( 'span' !== $tagName ) {
continue;
}
- $path = $this->sourceElementIdentity($element);
if ( '' === $path ) {
continue;
}
@@ -1139,12 +1237,18 @@ function (DOMElement $element) use ($shellTags): string {
}
$controls = array();
+ $navigationControls = array();
$semanticLeaves = array();
$richTextLeaves = array();
+ $richTextAnchors = array();
$hasNonProjected = false;
foreach ( $matches as $element ) {
$path = $element->getNodePath() ?? '';
- if ( isset($this->sourceControlMarkers[$path]) ) {
+ if ( isset($this->sourceNavigationControlMarkers[$path]) ) {
+ $navigationControls[] = $this->sourceNavigationControlMarkers[$path];
+ } elseif ( isset($this->sourceRichTextAnchorMarkers[$path]) ) {
+ $richTextAnchors[] = $this->sourceRichTextAnchorMarkers[$path];
+ } elseif ( isset($this->sourceControlMarkers[$path]) ) {
$controls[] = $this->sourceControlMarkers[$path];
} elseif ( isset($this->sourceSemanticMarkers[$this->sourceElementIdentity($element)]) ) {
$semanticLeaves[] = $this->sourceSemanticMarkers[$this->sourceElementIdentity($element)];
@@ -1155,26 +1259,37 @@ function (DOMElement $element) use ($shellTags): string {
}
}
$controls = array_values(array_unique($controls));
+ $navigationControls = array_values(array_unique($navigationControls));
$semanticLeaves = array_values(array_unique($semanticLeaves));
$richTextLeaves = array_values(array_unique($richTextLeaves));
- if ( array() === $controls && array() === $semanticLeaves && array() === $richTextLeaves ) {
+ $richTextAnchors = array_values(array_unique($richTextAnchors));
+ if ( array() === $controls && array() === $navigationControls && array() === $semanticLeaves && array() === $richTextLeaves && array() === $richTextAnchors ) {
$rewritten[] = $this->rewriteSourceTagTypes($selector, $parsed);
continue;
}
- $projectedMarkers = array_merge($controls, $semanticLeaves, $richTextLeaves);
- if ( $hasNonProjected ) {
+ $projectedMarkers = array_merge($controls, $semanticLeaves, $richTextLeaves, $richTextAnchors);
+ if ( $hasNonProjected && array() !== $projectedMarkers ) {
$rewritten[] = $this->rewriteSourceTagTypes($selector, $parsed, ':not(:where(.' . implode(',.', $projectedMarkers) . '))');
}
+ if ( array() !== $navigationControls ) {
+ $rewritten[] = $this->rewriteSourceTagTypes($selector, $parsed);
+ }
foreach ( $controls as $marker ) {
$rewritten[] = $this->projectControlSelector($selector, $parsed, $marker, $controlWrapper);
}
+ foreach ( $navigationControls as $marker ) {
+ $rewritten[] = $this->projectNavigationControlSelector($selector, $parsed, $marker);
+ }
foreach ( $semanticLeaves as $marker ) {
$rewritten[] = $this->projectSemanticLeafSelector($selector, $parsed, $marker);
}
foreach ( $richTextLeaves as $marker ) {
$rewritten[] = $this->projectRichTextSemanticSelector($selector, $parsed, $marker);
}
+ foreach ( $richTextAnchors as $marker ) {
+ $rewritten[] = $this->projectRichTextAnchorSelector($selector, $parsed, $marker);
+ }
}
return implode(',', $rewritten);
}
@@ -1270,6 +1385,20 @@ private function projectControlSelector(string $selector, array $parsed, string
return ':where(.' . $marker . ')' . ($wrapper ? ':where(.wp-block-buttons)' : $this->selectorSpecificityShims($parsed) . '> :where(.wp-block-button__link)') . $suffix;
}
+ /** @param array $parsed */
+ private function projectNavigationControlSelector(string $selector, array $parsed, string $marker): string
+ {
+ $suffix = null === $parsed['pseudo_state_suffix_span'] ? '' : substr($selector, $parsed['pseudo_state_suffix_span']['start']);
+ return '.wp-block-navigation-item.' . $marker . $this->selectorSpecificityShims($parsed) . '> .wp-block-navigation-item__content' . $suffix;
+ }
+
+ /** @param array $parsed */
+ private function projectRichTextAnchorSelector(string $selector, array $parsed, string $marker): string
+ {
+ $suffix = null === $parsed['pseudo_state_suffix_span'] ? '' : substr($selector, $parsed['pseudo_state_suffix_span']['start']);
+ return 'mark.' . $marker . $this->selectorSpecificityShims($parsed) . '>a' . $suffix;
+ }
+
/** @param array $parsed */
private function projectSemanticLeafSelector(string $selector, array $parsed, string $marker): string
{
@@ -1281,7 +1410,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 */
@@ -1652,6 +1781,17 @@ private function navigationUnderlineColor(DOMElement $item, DOMElement $anchor):
);
}
+ /** @return array */
+ private function svgPhrasingHostAttributes(DOMElement $element, string $imageMarkup): array
+ {
+ $className = $this->syntheticFlexItemClassName($element);
+
+ return $this->paragraphAttributesForNonParagraphContent($element, array_filter(array(
+ 'content' => $imageMarkup,
+ 'className' => $className,
+ ), static fn (string $value): bool => '' !== $value), false);
+ }
+
/**
* @param array> $fallbacks
* @return array|null
@@ -1673,7 +1813,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);
}
}
@@ -1766,11 +1906,33 @@ 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;
+ $declarations = array_merge($this->structuralPresentationDeclarations($element), $this->presentationDeclarations($element));
+ $hasSourceMargin = 0 < count(array_intersect(array_keys($declarations), array( 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left' )));
+ $isPlainStructuralChild = $parent instanceof DOMElement
+ && $this->isStructuralLayoutElement($parent)
+ && ! $this->hasAuthorSemanticMarker($parent)
+ && ! $this->requiresIndependentSemanticWrapper($parent);
+ if ( ! $isPlainStructuralChild || ! $hasSourceMargin ) {
+ $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);
}
}
@@ -1783,9 +1945,9 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
if ( 'transparent' === strtolower((string) ($declarations['-webkit-text-fill-color'] ?? '')) ) {
$declarations['color'] = 'transparent';
}
- $declarations['--blocks-engine-richtext-marker'] = $richTextMarker;
+ $style = $this->cssDeclarationString($declarations);
return $this->createBlock('core/paragraph', array(
- 'content' => '' . $content . '',
+ 'content' => '' . $content . '',
), array(), $element);
}
}
@@ -1815,7 +1977,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 ), false));
}
if ( 'ul' === $tagName || 'ol' === $tagName ) {
@@ -2114,7 +2276,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);
@@ -2131,7 +2293,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);
}
}
@@ -2398,6 +2560,7 @@ private function convertMediaDispatchElement(DOMElement $element, string $tagNam
$element,
fn (DOMElement $sourceElement): array => $this->presentationAttributes($sourceElement),
fn (string $value): string => $this->runtime->escapeHtml($value),
+ fn (DOMElement $sourceElement): array => $this->placeholderCaptionAttributes($sourceElement),
fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement)
);
if ( null !== $placeholderMedia ) {
@@ -2430,6 +2593,25 @@ private function convertMediaDispatchElement(DOMElement $element, string $tagNam
return array( 'handled' => false, 'block' => null );
}
+ /** @return array */
+ private function placeholderCaptionAttributes(DOMElement $element): array
+ {
+ $declarations = array();
+ foreach ( $this->staticPseudoElementStyleRules as $rule ) {
+ if ( 'after' === ($rule['pseudo'] ?? '') && $this->matchesCssSelector($element, (string) ($rule['selector'] ?? '')) ) {
+ $declarations = array_merge($declarations, $rule['declarations'] ?? array());
+ }
+ }
+ unset($declarations['content']);
+ $declarations['margin'] = '0';
+
+ $hash = substr(hash('sha256', json_encode($declarations, JSON_UNESCAPED_SLASHES) ?: ''), 0, 16);
+ $className = 'blocks-engine-placeholder-caption-' . $hash;
+ $this->placeholderCaptionRules[$className] = '.' . $className . '{' . $this->cssDeclarationString($declarations) . '}';
+
+ return array( 'className' => $className );
+ }
+
/**
* @return array|null
*/
@@ -2542,6 +2724,15 @@ private function createBlock(string $name, array $attrs = array(), array $innerB
$this->sourceButtonPresentationMarkers[$presentationPath] = $this->sourceControlMarkers[$path];
}
}
+ if ( 'core/navigation-link' === $name && 'a' === strtolower($logicalControl->tagName) && '' !== $this->combinedAuthorCss ) {
+ $path = $logicalControl->getNodePath() ?? '';
+ if ( '' !== $path && ! isset($this->sourceNavigationControlMarkers[$path]) ) {
+ $this->sourceNavigationControlMarkers[$path] = $this->allocateAuthorMarker('navigation-control');
+ }
+ if ( isset($this->sourceNavigationControlMarkers[$path]) ) {
+ $attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), $this->sourceNavigationControlMarkers[$path]);
+ }
+ }
$provenanceId = $this->nextSourceProvenanceId++;
$this->recordPresentationProvenance($name, $attrs, $sourceElement);
$this->recordStructureProvenance($name, $attrs, $sourceElement);
@@ -2590,16 +2781,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 +2810,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));
@@ -2669,7 +2910,8 @@ private function richTextMarkerForElement(DOMElement $element): string
return $marker;
}
- return $this->sourceRichTextSemanticMarkers[$this->sourceElementIdentity($element)] ?? '';
+ $identity = $this->sourceElementIdentity($element);
+ return $this->sourceRichTextAnchorMarkers[$identity] ?? $this->sourceRichTextSemanticMarkers[$identity] ?? '';
}
/**
@@ -2944,10 +3186,13 @@ private function richTextRequiresHtmlFallback(string $content): bool
return (bool) preg_match('/<(?:svg|canvas|img|picture|video|audio|iframe|object|embed|input|button|select|textarea|form)\b/i', $content);
}
- private function richTextContentWithMaterializedInlineStyles(DOMElement $element): string
+ /** @param list $excludedDescendantTags */
+ private function richTextContentWithMaterializedInlineStyles(DOMElement $element, array $excludedDescendantTags = array()): string
{
- $content = $this->innerHtml($element);
- if ( '' === $content || ! preg_match('/<(?:span|em|i|strong|b|mark|small|sub|sup)\b/i', $content) ) {
+ $content = array() === $excludedDescendantTags
+ ? $this->innerHtml($element)
+ : $this->innerHtmlWithoutTags($element, $excludedDescendantTags);
+ if ( '' === $content || ! preg_match('/<(?:a|span|em|i|strong|b|mark|small|sub|sup)\b/i', $content) ) {
return $content;
}
@@ -2964,14 +3209,16 @@ private function richTextContentWithMaterializedInlineStyles(DOMElement $element
$sourceInlines = array();
foreach ( $element->getElementsByTagName('*') as $sourceInline ) {
- if ( $sourceInline instanceof DOMElement && in_array(strtolower($sourceInline->tagName), array( 'span', 'em', 'i', 'strong', 'b', 'mark', 'small', 'sub', 'sup' ), true) ) {
+ if ( $sourceInline instanceof DOMElement
+ && in_array(strtolower($sourceInline->tagName), array( 'a', 'span', 'em', 'i', 'strong', 'b', 'mark', 'small', 'sub', 'sup' ), true)
+ && ! $this->hasExcludedRichTextAncestor($sourceInline, $element, $excludedDescendantTags) ) {
$sourceInlines[] = $sourceInline;
}
}
$targetInlines = array();
foreach ( $body->getElementsByTagName('*') as $targetInline ) {
- if ( $targetInline instanceof DOMElement && in_array(strtolower($targetInline->tagName), array( 'span', 'em', 'i', 'strong', 'b', 'mark', 'small', 'sub', 'sup' ), true) ) {
+ if ( $targetInline instanceof DOMElement && in_array(strtolower($targetInline->tagName), array( 'a', 'span', 'em', 'i', 'strong', 'b', 'mark', 'small', 'sub', 'sup' ), true) ) {
$targetInlines[] = $targetInline;
}
}
@@ -2985,7 +3232,17 @@ private function richTextContentWithMaterializedInlineStyles(DOMElement $element
$inline = $this->richTextInlineVisualDeclarations($sourceInline);
$marker = $this->richTextMarkerForElement($sourceInline);
if ( '' !== $marker ) {
- $inline['--blocks-engine-richtext-marker'] = $marker;
+ if ( 'a' === strtolower($targetInline->tagName) && isset($this->sourceRichTextAnchorMarkers[$this->sourceElementIdentity($sourceInline)]) ) {
+ $parent = $targetInline->parentNode;
+ if ( null !== $parent ) {
+ $mark = $document->createElement('mark');
+ $mark->setAttribute('class', $marker);
+ $parent->replaceChild($mark, $targetInline);
+ $mark->appendChild($targetInline);
+ }
+ continue;
+ }
+ $targetInline->setAttribute('class', $this->mergeClassNames($this->attr($targetInline, 'class'), $marker));
}
if ( array() === $inline ) {
continue;
@@ -2998,6 +3255,18 @@ private function richTextContentWithMaterializedInlineStyles(DOMElement $element
return $this->innerHtml($body);
}
+ /** @param list $excludedTags */
+ private function hasExcludedRichTextAncestor(DOMElement $element, DOMElement $root, array $excludedTags): bool
+ {
+ for ( $parent = $element->parentNode; $parent instanceof DOMElement && $parent !== $root; $parent = $parent->parentNode ) {
+ if ( in_array(strtolower($parent->tagName), $excludedTags, true) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/**
* @return array
*/
@@ -3021,6 +3290,7 @@ private function richTextInlineVisualDeclarations(DOMElement $element): array
'font-family',
'font-size',
'font-style',
+ 'font-variant-caps',
'font-weight',
'letter-spacing',
'line-height',
@@ -3065,15 +3335,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 +3359,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 +3739,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,16 +3757,16 @@ 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;
}
- return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $content )), array(), $element);
+ return $this->createBlock('core/paragraph', $this->paragraphAttributesForNonParagraphContent($element, array_merge($this->presentationAttributes($element), array( 'content' => $content ))), array(), $element);
}
private function richTextContentWithoutDecorativeSvg(DOMElement $element): string
@@ -3910,13 +4189,49 @@ private function paragraphBlockFromInlineContentWrapper(DOMElement $element): ?a
return null;
}
- return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $content )), array(), $element);
+ $attrs = array_merge($this->presentationAttributes($element), array( 'content' => $content ));
+ $declarations = array_merge($this->structuralPresentationDeclarations($element), $this->presentationDeclarations($element));
+ if ( array_filter(array_keys($declarations), static fn (string $property): bool => 'margin' === $property || str_starts_with($property, 'margin-')) ) {
+ $attrs = $this->paragraphAttributesForNonParagraphContent($element, $attrs);
+ }
+
+ return $this->createBlock('core/paragraph', $attrs, array(), $element);
+ }
+
+ /**
+ * A paragraph is the valid native host for non-paragraph source content. Its
+ * theme default margins are not part of that source box, so fill unspecified
+ * wrapper margins with zero while retaining margins projected onto the host.
+ *
+ * @param array $attrs
+ * @return array
+ */
+ private function paragraphAttributesForNonParagraphContent(DOMElement $element, array $attrs, bool $sourcePresentationOnWrapper = true): array
+ {
+ $sourceMargin = $attrs['style']['spacing']['margin'] ?? array();
+ $sourceMargin = is_array($sourceMargin) ? $sourceMargin : array();
+ $declarations = $sourcePresentationOnWrapper
+ ? array_merge($this->structuralPresentationDeclarations($element), $this->presentationDeclarations($element))
+ : array();
+ foreach ( array( 'top', 'right', 'bottom', 'left' ) as $side ) {
+ if ( ! array_key_exists($side, $sourceMargin) && (! $sourcePresentationOnWrapper || (! isset($declarations['margin']) && ! isset($declarations['margin-' . $side]))) ) {
+ $sourceMargin[$side] = '0';
+ }
+ }
+
+ return array_replace_recursive(array(
+ 'style' => array(
+ 'spacing' => array(
+ 'margin' => $sourceMargin,
+ ),
+ ),
+ ), $attrs);
}
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;
}
}
@@ -5517,7 +5832,7 @@ private function listItems(DOMElement $list, array &$fallbacks): array
}
}
- $content = $this->innerHtmlWithoutTags($child, array( 'ul', 'ol' ));
+ $content = $this->richTextContentWithMaterializedInlineStyles($child, array( 'ul', 'ol' ));
if ( '' === trim($this->runtime->stripAllTags($content)) && array() === $nested ) {
continue;
}
@@ -5836,18 +6151,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;
}
@@ -8080,6 +8402,8 @@ private function propagateLinkOntoImage(array &$block, array $linkAttrs): bool
'rel' => (string) ($linkAttrs['rel'] ?? ''),
), static fn (string $value): bool => '' !== trim($value));
+ $imageLink['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), 'blocks-engine-propagated-image-link');
+
$block = $this->rebuildBlock($block, array_merge($attrs, $imageLink));
return true;
}
@@ -8148,7 +8472,7 @@ private function wrapInlineLink(string $content, array $linkAttrs): string
$attributes .= ' rel="' . htmlspecialchars($linkAttrs['rel'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"';
}
- return '' . $content . '';
+ return '' . $content . '';
}
/**
diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
index 38b30114..9653d549 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 ) {
@@ -73,6 +94,7 @@ public function match(DOMElement $element, PatternContext $context): ?array
$navigationAttrs,
$commonTextAttrs
);
+ $this->promoteDirectAnchorSpacing($element, $presentationAttributes, $navigationAttrs, $links);
$navigation = $createBlock('core/navigation', $navigationAttrs, $links, $element);
@@ -114,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
@@ -412,6 +484,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,25 +592,112 @@ 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;
}
+ /**
+ * 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) ) {
@@ -896,7 +1060,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/src/HtmlToBlocks/Patterns/PlaceholderMediaPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/PlaceholderMediaPattern.php
index dd19739b..3edf501e 100644
--- a/php-transformer/src/HtmlToBlocks/Patterns/PlaceholderMediaPattern.php
+++ b/php-transformer/src/HtmlToBlocks/Patterns/PlaceholderMediaPattern.php
@@ -12,10 +12,11 @@ final class PlaceholderMediaPattern
/**
* @param callable(DOMElement): array $presentationAttributes
* @param callable(string): string $escapeHtml
+ * @param callable(DOMElement): array $captionAttributes
* @param callable(string, array, array>, DOMElement|null): array $createBlock
* @return array|null
*/
- public function match(DOMElement $element, callable $presentationAttributes, callable $escapeHtml, callable $createBlock): ?array
+ public function match(DOMElement $element, callable $presentationAttributes, callable $escapeHtml, callable $captionAttributes, callable $createBlock): ?array
{
if ( ! $this->isPlaceholderMediaElement($element) ) {
return null;
@@ -29,7 +30,7 @@ public function match(DOMElement $element, callable $presentationAttributes, cal
unset($attrs['style']);
$label = $this->placeholderLabel($element);
- $children = '' !== $label ? array( $createBlock('core/paragraph', array( 'content' => $escapeHtml($label) ), array(), null) ) : array();
+ $children = '' !== $label ? array( $createBlock('core/paragraph', array_merge($captionAttributes($element), array( 'content' => $escapeHtml($label) )), array(), null) ) : array();
return $createBlock('core/group', array_filter($attrs, static fn ($value): bool => is_array($value) ? array() !== $value : '' !== trim((string) $value)), $children, $element);
}
@@ -37,6 +38,12 @@ public function match(DOMElement $element, callable $presentationAttributes, cal
private function isPlaceholderMediaElement(DOMElement $element): bool
{
$className = strtolower($this->attr($element, 'class'));
+ if ( 'img' === strtolower($this->attr($element, 'role'))
+ && '' !== trim($this->attr($element, 'aria-label'))
+ && '' !== trim($this->attr($element, 'data-caption'))
+ && 0 === $element->childElementCount ) {
+ return true;
+ }
if ( ! preg_match('/(?:^|\s)(?:ph|placeholder|media-placeholder|image-placeholder|video-placeholder)(?:\s|$)/', $className) && ! preg_match('/(?:^|\s)ratio-[0-9]+(?:x|:|-)[0-9]+(?:\s|$)/', $className) ) {
return false;
}
@@ -62,6 +69,10 @@ private function placeholderAspectRatio(DOMElement $element): string
private function placeholderLabel(DOMElement $element): string
{
+ $caption = trim($this->attr($element, 'data-caption'));
+ if ( '' !== $caption ) {
+ return $caption;
+ }
foreach ( $element->getElementsByTagName('span') as $span ) {
if ( ! $span instanceof DOMElement ) {
continue;
diff --git a/php-transformer/src/HtmlToBlocks/Patterns/QuotePattern.php b/php-transformer/src/HtmlToBlocks/Patterns/QuotePattern.php
index 1aeb8063..fa742123 100644
--- a/php-transformer/src/HtmlToBlocks/Patterns/QuotePattern.php
+++ b/php-transformer/src/HtmlToBlocks/Patterns/QuotePattern.php
@@ -53,7 +53,7 @@ public function matchBlockquote(
$innerBlocks = $convertChildrenWithoutTags($element, $fallbacks, array( 'cite', 'footer' ));
}
if ( array() === $innerBlocks ) {
- $innerBlocks[] = $createBlock('core/paragraph', array( 'content' => $value ));
+ $innerBlocks[] = $createBlock('core/paragraph', $this->syntheticParagraphAttributes($value));
}
return $createBlock('core/quote', array_filter(array_merge($presentationAttributes($element), array( 'citation' => $citation )), static fn ($value): bool => '' !== $value), $innerBlocks, $element);
@@ -118,7 +118,7 @@ public function matchFigureBlockquote(
}
$innerBlocks = array_merge($innerBlocks, $convertChildrenWithoutTags($blockquote, $fallbacks, array( 'cite', 'footer' )));
if ( array() === $innerBlocks ) {
- $innerBlocks[] = $createBlock('core/paragraph', array( 'content' => $value ));
+ $innerBlocks[] = $createBlock('core/paragraph', $this->syntheticParagraphAttributes($value));
}
return $createBlock('core/quote', $attrs, $innerBlocks, $figure);
@@ -150,7 +150,27 @@ private function phrasingQuoteChildren(DOMElement $element, string $value, calla
return array();
}
- return array( $createBlock('core/paragraph', array( 'content' => $value )) );
+ return array( $createBlock('core/paragraph', $this->syntheticParagraphAttributes($value)) );
+ }
+
+ /**
+ * @return array
+ */
+ private function syntheticParagraphAttributes(string $content): array
+ {
+ return array(
+ 'content' => $content,
+ 'style' => array(
+ 'spacing' => array(
+ 'margin' => array(
+ 'top' => '0',
+ 'right' => '0',
+ 'bottom' => '0',
+ 'left' => '0',
+ ),
+ ),
+ ),
+ );
}
}
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/src/HtmlToBlocks/Style/StyleResolutionTrait.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php
index c245bccc..2e4e48a9 100644
--- a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php
+++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php
@@ -73,6 +73,9 @@ private function inlineGeometryProperties(): array
'flex-basis',
'object-fit',
'object-position',
+ 'gap',
+ 'row-gap',
+ 'column-gap',
);
}
@@ -233,11 +236,16 @@ private function hasConditionalStyleFamily(DOMElement $element, string $family):
private function inlineGeometryClassName(DOMElement $element, array $excludedProperties = array()): string
{
$declarations = $this->cssDeclarations($this->attr($element, 'style'));
+ $isInlineFlex = preg_match('/^inline-flex$|^flex$/i', trim((string) ($declarations['display'] ?? ''))) === 1;
+ $carriesFlexGap = $isInlineFlex && 'a' === strtolower($element->tagName) && 0 < $element->getElementsByTagName('svg')->length;
$geometry = array();
foreach ($this->inlineGeometryProperties() as $property) {
if (in_array($property, $excludedProperties, true)) {
continue;
}
+ if (in_array($property, array('gap', 'row-gap', 'column-gap'), true) && ! $carriesFlexGap) {
+ continue;
+ }
$rawValue = trim((string) ($declarations[$property] ?? ''));
if (1 === preg_match('/\s*!important\s*$/i', $rawValue)) {
continue;
@@ -255,6 +263,10 @@ private function inlineGeometryClassName(DOMElement $element, array $excludedPro
ksort($geometry);
$declarations = array();
foreach ($geometry as $property => $value) {
+ if (in_array($property, array('gap', 'row-gap', 'column-gap'), true)) {
+ $declarations[] = $property . ':' . $value;
+ continue;
+ }
// A converted inline declaration must continue to outrank authored
// normal selectors, including ID selectors. Authored !important
// rules retain their normal cascade priority through specificity.
@@ -286,12 +298,17 @@ private function geometryStructuralPath(DOMElement $element): string
private function inlineGeometryStyle(DOMElement $element, array $excludedProperties = array()): string
{
$declarations = $this->cssDeclarations($this->attr($element, 'style'));
+ $isInlineFlex = preg_match('/^inline-flex$|^flex$/i', trim((string) ($declarations['display'] ?? ''))) === 1;
+ $carriesFlexGap = $isInlineFlex && 'a' === strtolower($element->tagName) && 0 < $element->getElementsByTagName('svg')->length;
$style = array();
$geometryValues = array();
foreach ($this->inlineGeometryProperties() as $property) {
if (in_array($property, $excludedProperties, true)) {
continue;
}
+ if (in_array($property, array('gap', 'row-gap', 'column-gap'), true) && ! $carriesFlexGap) {
+ continue;
+ }
$value = trim((string) ($declarations[$property] ?? ''));
$geometryValues[] = $value;
if (1 === preg_match('/\s*!important\s*$/i', $value)) {
@@ -336,6 +353,71 @@ 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;
+ }
+
+ private function blockifiedLinkClassName(DOMElement $element): string
+ {
+ $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 '';
+ }
+
+ $sourceDisplay = strtolower(trim((string) ($this->structuralPresentationDeclarations($element)['display'] ?? '')));
+ $display = match ( $sourceDisplay ) {
+ 'flex', 'inline-flex' => 'flex',
+ 'grid', 'inline-grid' => 'grid',
+ default => 'block',
+ };
+ $className = ($this->geometryCarrierClassAllocator ??= new GeometryCarrierClassAllocator())->allocate($this->geometryStructuralPath($element) . "\nblockified-link\n" . $display);
+ $this->generatedGeometryRules[$className] = '.' . $className . '>a{display:' . $display . '}';
+
+ return $className;
+ }
+
+ private function buttonLinkLayoutClassName(DOMElement $element): string
+ {
+ $inline = $this->cssDeclarations($this->attr($element, 'style'));
+ $declarations = array_intersect_key($inline, array_flip(array(
+ 'display',
+ 'align-items',
+ 'justify-content',
+ 'flex-direction',
+ 'flex-wrap',
+ 'gap',
+ 'row-gap',
+ 'column-gap',
+ )));
+ if ( array() === $declarations ) {
+ return '';
+ }
+
+ $rule = $this->cssDeclarationString($declarations);
+ $className = ($this->geometryCarrierClassAllocator ??= new GeometryCarrierClassAllocator())->allocate($this->geometryStructuralPath($element) . "\nbutton-link-layout\n" . $rule);
+ $this->generatedGeometryRules[$className] = '.' . $className . '> .wp-block-button__link{' . $rule . '}';
+
+ return $className;
+ }
+
/**
* @return array
*/
@@ -794,10 +876,12 @@ private function safeVisualDeclarations(array $declarations): array
'column-gap',
'display',
'flex-direction',
+ 'flex-shrink',
'flex-wrap',
'font-family',
'font-size',
'font-style',
+ 'font-variant-caps',
'font-weight',
'letter-spacing',
'gap',
@@ -821,9 +905,14 @@ private function safeVisualDeclarations(array $declarations): array
'padding-right',
'padding-top',
'position',
+ 'bottom',
+ 'left',
+ 'right',
+ 'top',
'row-gap',
'text-align',
'text-decoration',
+ 'text-shadow',
'text-transform',
'width',
'z-index',
@@ -951,6 +1040,10 @@ private function layoutAttribute(DOMElement $element, string $mergedStyle = ''):
$flexWrap = $this->layoutFlexWrap((string) ($inlineDeclarations['flex-wrap'] ?? $mergedDeclarations['flex-wrap'] ?? ''));
if ( '' !== $flexWrap ) {
$layout['flexWrap'] = $flexWrap;
+ } elseif ( 'a' === strtolower($element->tagName) && 0 < $element->getElementsByTagName('svg')->length ) {
+ // CSS flex rows default to nowrap, while core/group's Row layout
+ // defaults to wrapping after linked SVG content is decomposed.
+ $layout['flexWrap'] = 'nowrap';
}
return $layout;
diff --git a/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php b/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php
index b1b2e78e..d3316cad 100644
--- a/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php
+++ b/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php
@@ -27,6 +27,14 @@ private function convertAnchorDispatchElement(DOMElement $element, array &$fallb
fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null, ?DOMElement $logicalSourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement, $logicalSourceElement)
);
if ( null !== $logo ) {
+ $button = $logo['innerBlocks'][0] ?? null;
+ $layoutClass = $this->buttonLinkLayoutClassName($element);
+ if ( is_array($button) && '' !== $layoutClass ) {
+ $buttonAttrs = is_array($button['attrs'] ?? null) ? $button['attrs'] : array();
+ $buttonAttrs['className'] = $this->mergePresentationClassNames((string) ($buttonAttrs['className'] ?? ''), $layoutClass);
+ $logo['innerBlocks'][0] = $this->rebuildBlock($button, $buttonAttrs);
+ $logo = $this->rebuildBlock($logo, is_array($logo['attrs'] ?? null) ? $logo['attrs'] : array());
+ }
return $logo;
}
@@ -45,7 +53,7 @@ private function convertAnchorDispatchElement(DOMElement $element, array &$fallb
}
if ( '' === trim($element->textContent ?? '') && '' !== $this->safeLinkUrl($this->attr($element, 'href')) && '' !== trim($this->attr($element, 'aria-label')) ) {
- return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $this->outerHtml($element) )), array(), $element);
+ return $this->createBlock('core/paragraph', $this->paragraphAttributesForNonParagraphContent($element, array_merge($this->presentationAttributes($element), array( 'content' => $this->outerHtml($element) ))), array(), $element);
}
if ( '' === trim($element->textContent ?? '') ) {
@@ -62,7 +70,9 @@ private function convertAnchorDispatchElement(DOMElement $element, array &$fallb
// A non-button anchor has no native width support. Promote its source
// presentation to the paragraph wrapper so generated geometry remains
// attached to the rendered block rather than being silently discarded.
- return $this->createBlock('core/paragraph', array_merge($this->presentationAttributes($element), array( 'content' => $this->outerHtml($element) )), array(), $element);
+ $attrs = $this->presentationAttributes($element);
+ $attrs['className'] = $this->mergePresentationClassNames((string) ($attrs['className'] ?? ''), $this->blockifiedLinkClassName($element));
+ return $this->createBlock('core/paragraph', $this->paragraphAttributesForNonParagraphContent($element, array_merge($attrs, array( 'content' => $this->outerHtml($element) ))), array(), $element);
}
/**
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/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('/');
$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');
@@ -121,7 +129,7 @@
$svgLogo = $transform('');
$svgLogoMarkup = (string) ($svgLogo['serialized_blocks'] ?? '');
-$assert(str_contains($svgLogoMarkup, 'Block Party') && 1 === count(array_filter($svgLogo['assets'] ?? array(), static fn (array $asset): bool => 'inline-svg' === ($asset['source'] ?? ''))) && 'pass' === ($svgLogo['source_reports']['wp_block_validity']['status'] ?? ''), 'structured text logos preserve passive inline SVG artwork and native RichText-safe container geometry');
+$assert(str_contains($svgLogoMarkup, 'Block Party') && 1 === count(array_filter($svgLogo['assets'] ?? array(), static fn (array $asset): bool => 'inline-svg' === ($asset['source'] ?? ''))) && 'pass' === ($svgLogo['source_reports']['wp_block_validity']['status'] ?? ''), 'structured text logos preserve passive inline SVG artwork and native RichText-safe container geometry');
$plainSvgLogo = $transform('');
$plainSvgLogoMarkup = (string) ($plainSvgLogo['serialized_blocks'] ?? '');
@@ -144,21 +152,38 @@
$inlineCss = $css($inlineLeaves);
$assert(3 === substr_count($inlineMarkup, 'Jun253:00 PM
Defense
Thesis title
');
+$flowTokenMarkup = (string) ($flowTokens['serialized_blocks'] ?? '');
+$flowTokenCss = $css($flowTokens);
+$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');
+$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-') && 3 === substr_count($linkedBrandMarkup, 'margin-top:0;margin-right:0;margin-bottom:0;margin-left:0'), 'linked brand SVG and text retain 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'] ?? '');
+$assert(! str_contains($resetInlineMetadataMarkup, 'blocks-engine-semantic-') && str_contains($resetInlineMetadataMarkup, 'FirstSecond
Third
');
$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'] ?? '');
@@ -166,11 +191,11 @@
$standaloneBadge = $transform('Feature
Stable');
$standaloneBadgeMarkup = (string) ($standaloneBadge['serialized_blocks'] ?? '');
-$assert(str_contains($standaloneBadgeMarkup, '.stat-num{font-size:4rem}.stat-num .suffix{font-size:2rem;color:#6040cc}43%
');
$inlineStatMarkup = (string) ($inlineStat['serialized_blocks'] ?? '');
-$assert(1 === substr_count($inlineStatMarkup, '