diff --git a/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php b/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php
index 33656ab5..e0d58f55 100644
--- a/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php
+++ b/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php
@@ -423,35 +423,6 @@ public function isTimeValueElement(DOMElement $element): bool
return $this->hasCommerceToken($element, array( 'time', 'hours', 'value', 'closed' )) || (bool) preg_match('/\b(?:closed|open|\d{1,2}(?::\d{2})?\s*(?:am|pm)?\s*(?:[\x{2013}\x{2014}-]|to)\s*\d{1,2}(?::\d{2})?\s*(?:am|pm)?)\b/iu', $element->textContent ?? '');
}
- public function isNavigationSectionHeading(DOMElement $element): bool
- {
- if ( preg_match('/^h[1-6]$/i', $element->tagName) ) {
- return true;
- }
-
- if ( ! in_array(strtolower($element->tagName), array( 'div', 'p', 'span' ), true) || '' === trim($element->textContent ?? '') ) {
- return false;
- }
-
- $name = strtolower(trim(SourceDom::attr($element, 'class') . ' ' . SourceDom::attr($element, 'id') . ' ' . SourceDom::attr($element, 'role') . ' ' . SourceDom::attr($element, 'aria-label')));
- return (bool) preg_match('/(?:^|[\s_-])(?:heading|label|title)(?:$|[\s_-])/', $name);
- }
-
- public function hasSoftNavigationSectionHeadingSignal(DOMElement $element): bool
- {
- return ! preg_match('/^h[1-6]$/i', $element->tagName) && $this->isNavigationSectionHeading($element);
- }
-
- public function hasNavigationContainerSignal(DOMElement $element): bool
- {
- if ( 'navigation' === strtolower(SourceDom::attr($element, 'role')) ) {
- return true;
- }
-
- $name = strtolower(trim(SourceDom::attr($element, 'class') . ' ' . SourceDom::attr($element, 'id')));
- return (bool) preg_match('/(?:^|[\s_-])(?:nav|navbar|navigation|menu|links)(?:$|[\s_-])/', $name);
- }
-
public function hasDirectChildElement(DOMElement $element, string $tagName): bool
{
foreach ( $element->childNodes as $child ) {
diff --git a/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementContext.php b/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementContext.php
index 572cc9d0..98224647 100644
--- a/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementContext.php
+++ b/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementContext.php
@@ -3,6 +3,7 @@
namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements;
+use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\PatternContext;
use Closure;
use DOMElement;
@@ -29,7 +30,7 @@ public function __construct(
private readonly Closure $shouldPreserveEmptyVisualElement,
private readonly Closure $emptyVisualElementAttributes,
private readonly Closure $createBlock,
- private readonly Closure $navigationSectionBlock,
+ private readonly PatternContext $patternContext,
private readonly Closure $shouldDeferNavigationPatternToChildren,
private readonly Closure $rememberAccordionDisclosureRoot,
private readonly Closure $metadataGridBlock,
@@ -83,8 +84,7 @@ public function shouldPreserveEmptyVisualElement(DOMElement $element): bool { re
public function emptyVisualElementAttributes(DOMElement $element): array { return ($this->emptyVisualElementAttributes)($element); }
/** @param array $attributes @param array> $innerBlocks @return array */
public function createBlock(string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array { return ($this->createBlock)($name, $attributes, $innerBlocks, $sourceElement); }
- /** @return array|null */
- public function navigationSectionBlock(DOMElement $element): ?array { return ($this->navigationSectionBlock)($element); }
+ public function patternContext(): PatternContext { return $this->patternContext; }
public function shouldDeferNavigationPatternToChildren(DOMElement $element): bool { return ($this->shouldDeferNavigationPatternToChildren)($element); }
/** @param array $block @return array */
public function rememberAccordionDisclosureRoot(array $block, DOMElement $element): array { return ($this->rememberAccordionDisclosureRoot)($block, $element); }
diff --git a/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementConverter.php b/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementConverter.php
index c598fcb3..5a4eb485 100644
--- a/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementConverter.php
+++ b/php-transformer/src/HtmlToBlocks/Elements/FlowContainerElementConverter.php
@@ -21,7 +21,10 @@
/** Converts flow containers through their ordered runtime, pattern, layout, and child strategies. */
final class FlowContainerElementConverter implements ElementConverter
{
- public function __construct(private readonly FlowContainerElementContext $context)
+ public function __construct(
+ private readonly FlowContainerElementContext $context,
+ private readonly NavigationPattern $navigationPattern = new NavigationPattern()
+ )
{
}
@@ -108,9 +111,9 @@ public function convert(DOMElement $element, string $tagName, array &$fallbacks)
if ( null !== $block ) {
return ConversionOutcome::handled($block);
}
- $block = $this->context->navigationSectionBlock($element);
- if ( null !== $block ) {
- return ConversionOutcome::handled($block);
+ $navigationSection = $this->navigationPattern->recognizeLabeledSection($element, $this->context->patternContext());
+ if ( null !== $navigationSection ) {
+ return ConversionOutcome::handled($navigationSection->block());
}
if ( ! $this->context->shouldDeferNavigationPatternToChildren($element) ) {
$block = $this->context->recognizePatterns($element, $fallbacks, array( AccordionPattern::class, SocialLinksPattern::class, NavigationPattern::class ));
diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
index adc0bd02..1fbf8854 100644
--- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
+++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php
@@ -801,7 +801,7 @@ function (DOMElement $element, array &$fallbacks, bool $captureUnsupported): arr
shouldPreserveEmptyVisualElement: fn (DOMElement $element): bool => $this->shouldPreserveEmptyVisualElement($element),
emptyVisualElementAttributes: fn (DOMElement $element): array => $this->emptyVisualElementAttributes($element),
createBlock: fn (string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array => $this->createBlock($name, $attributes, $innerBlocks, $sourceElement),
- navigationSectionBlock: fn (DOMElement $element): ?array => $this->navigationSectionBlockFromElement($element),
+ patternContext: $this->patternContext,
shouldDeferNavigationPatternToChildren: fn (DOMElement $element): bool => $this->shouldDeferNavigationPatternToChildren($element),
rememberAccordionDisclosureRoot: fn (array $block, DOMElement $element): array => $this->rememberAccordionDisclosureRoot($block, $element),
metadataGridBlock: fn (DOMElement $element): ?array => $this->metadataGridBlockFromElement($element),
@@ -9335,62 +9335,6 @@ private function looksLikeDateOrTimeText(string $text): bool
return (bool) preg_match('/\b(?:\d{1,2}(?::\d{2})?\s*(?:am|pm)?|\d{1,2}\s*(?:min|mins|minutes|hr|hrs|hours)|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?|sun(?:day)?|day\s+\d+)\b/i', trim($text));
}
- /**
- * @return array|null
- */
- private function navigationSectionBlockFromElement(DOMElement $element): ?array
- {
- $heading = null;
- $anchors = array();
- foreach ( $element->childNodes as $child ) {
- if ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) {
- continue;
- }
-
- if ( $child instanceof DOMElement && $this->sourceElementClassifier->isNavigationSectionHeading($child) ) {
- if ( $heading instanceof DOMElement ) {
- return null;
- }
- $heading = $child;
- continue;
- }
-
- if ( $child instanceof DOMElement && 'a' === strtolower($child->tagName) && '' !== trim($child->textContent ?? '') ) {
- $anchors[] = $child;
- continue;
- }
-
- return null;
- }
-
- if ( ! $heading instanceof DOMElement || array() === $anchors ) {
- return null;
- }
-
- if ( ! $this->sourceElementClassifier->hasNavigationContainerSignal($element) && ! $this->sourceElementClassifier->hasSoftNavigationSectionHeadingSignal($heading) ) {
- return null;
- }
-
- $sectionFallbacks = array();
- $blocks = array( $this->convertElement($heading, $sectionFallbacks, true) );
- $links = array();
- foreach ( $anchors as $anchor ) {
- $links[] = $this->createBlock('core/navigation-link', array_filter(array(
- 'label' => $this->innerHtml($anchor),
- 'url' => $this->safeNavigationUrl($this->attr($anchor, 'href')),
- 'kind' => 'custom',
- ), static fn ($value): bool => '' !== $value), array(), $anchor);
- }
- $overlayMenu = $this->navigationToggleSuppressor->navigationOverlayMenu($element);
- $navigationAttrs = array( 'overlayMenu' => $overlayMenu );
- if ( 'mobile' === $overlayMenu ) {
- $navigationAttrs['className'] = 'blocks-engine-native-responsive-navigation';
- }
- $blocks[] = $this->createBlock('core/navigation', $navigationAttrs, $links, $element);
-
- return $this->createBlock('core/group', $this->styleResolver->presentationAttributes($element), array_values(array_filter($blocks)), $element);
- }
-
private function convertMediaElement(DOMElement $element): ?array
{
$tagName = strtolower($element->tagName);
diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
index 94716f7f..9902d6a3 100644
--- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
+++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
@@ -162,6 +162,78 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter
);
}
+ public function recognizeLabeledSection(DOMElement $element, PatternContext $context): ?PatternRecognitionResult
+ {
+ $converter = $context->recursiveConverter();
+ if ( 'nav' === strtolower($element->tagName) || null === $converter ) {
+ return null;
+ }
+
+ $heading = null;
+ $anchors = array();
+ foreach ( $element->childNodes as $child ) {
+ if ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) {
+ continue;
+ }
+ if ( $child instanceof DOMElement && $this->isNavigationSectionHeading($child) ) {
+ if ( $heading instanceof DOMElement ) {
+ return null;
+ }
+ $heading = $child;
+ continue;
+ }
+ if ( $child instanceof DOMElement && 'a' === strtolower($child->tagName) && '' !== trim($child->textContent ?? '') ) {
+ $anchors[] = $child;
+ continue;
+ }
+ return null;
+ }
+
+ if ( ! $heading instanceof DOMElement || array() === $anchors ) {
+ return null;
+ }
+ $name = strtolower(trim(SourceDom::attr($element, 'class') . ' ' . SourceDom::attr($element, 'id')));
+ $hasContainerSignal = 'navigation' === strtolower(SourceDom::attr($element, 'role'))
+ || (bool) preg_match('/(?:^|[\s_-])(?:nav|navbar|navigation|menu|links)(?:$|[\s_-])/', $name);
+ if ( ! $hasContainerSignal && preg_match('/^h[1-6]$/i', $heading->tagName) ) {
+ return null;
+ }
+
+ $discardedFallbacks = array();
+ $blocks = array($converter->element($heading, $discardedFallbacks, true));
+ $links = array();
+ foreach ( $anchors as $anchor ) {
+ $links[] = $context->createBlock('core/navigation-link', array_filter(array(
+ 'label' => SourceDom::innerHtml($anchor),
+ 'url' => SourceDom::safeNavigationUrl(SourceDom::attr($anchor, 'href')),
+ 'kind' => 'custom',
+ ), static fn ($value): bool => '' !== $value), array(), $anchor);
+ }
+ $overlayMenu = $context->navigationContext()?->overlayMenu($element) ?? 'never';
+ $navigationAttrs = array('overlayMenu' => $overlayMenu);
+ if ( 'mobile' === $overlayMenu ) {
+ $navigationAttrs['className'] = 'blocks-engine-native-responsive-navigation';
+ }
+ $blocks[] = $context->createBlock('core/navigation', $navigationAttrs, $links, $element);
+
+ return new PatternRecognitionResult(
+ $context->createBlock('core/group', $context->presentationAttributes($element), array_values(array_filter($blocks)), $element)
+ );
+ }
+
+ private function isNavigationSectionHeading(DOMElement $element): bool
+ {
+ if ( preg_match('/^h[1-6]$/i', $element->tagName) ) {
+ return true;
+ }
+ if ( ! in_array(strtolower($element->tagName), array('div', 'p', 'span'), true) || '' === trim($element->textContent ?? '') ) {
+ return false;
+ }
+
+ $name = strtolower(trim(SourceDom::attr($element, 'class') . ' ' . SourceDom::attr($element, 'id') . ' ' . SourceDom::attr($element, 'role') . ' ' . SourceDom::attr($element, 'aria-label')));
+ return (bool) preg_match('/(?:^|[\s_-])(?:heading|label|title)(?:$|[\s_-])/', $name);
+ }
+
/**
* A nav container that holds a brand beside its link cluster authors THREE
* elements — the landmark, the brand, and the menu — each with its own CSS
diff --git a/php-transformer/tests/unit/flow-container-element-converter.php b/php-transformer/tests/unit/flow-container-element-converter.php
index 00a877b5..ec94ce3d 100644
--- a/php-transformer/tests/unit/flow-container-element-converter.php
+++ b/php-transformer/tests/unit/flow-container-element-converter.php
@@ -5,6 +5,7 @@
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements\FlowContainerElementContext;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements\FlowContainerElementConverter;
+use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\PatternContext;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\SpacerPattern;
$assertions = 0;
@@ -56,7 +57,7 @@
'innerBlocks' => $innerBlocks,
'sourceTag' => $sourceElement?->tagName,
),
- 'navigationSectionBlock' => $null,
+ 'patternContext' => new PatternContext($null, $null),
'shouldDeferNavigationPatternToChildren' => $false,
'rememberAccordionDisclosureRoot' => static fn (array $block): array => $block,
'metadataGridBlock' => $null,
diff --git a/php-transformer/tests/unit/pattern-registry-staged-dispatch.php b/php-transformer/tests/unit/pattern-registry-staged-dispatch.php
index 11906833..ecc02771 100644
--- a/php-transformer/tests/unit/pattern-registry-staged-dispatch.php
+++ b/php-transformer/tests/unit/pattern-registry-staged-dispatch.php
@@ -164,4 +164,14 @@ static function (DOMElement $source, bool $captureUnsupported) use (&$recursiveC
(new NavigationPattern())->recognize($navigationElement, $probeContext);
$assert(1 === $recursiveCalls, 'Navigation probe context performs no recursive conversion side effects.');
+$labeledNavigation = (new HtmlTransformer())->transform('')->toArray();
+$labeledNavigationGroup = $labeledNavigation['blocks'][0] ?? array();
+$labeledNavigationBlock = $labeledNavigationGroup['innerBlocks'][1] ?? array();
+$assert('core/group' === ($labeledNavigationGroup['blockName'] ?? null) && 'core/navigation' === ($labeledNavigationBlock['blockName'] ?? null), 'NavigationPattern owns labeled non-nav link sections.');
+$assert(! isset($labeledNavigationBlock['attrs']['layout']), 'Labeled non-nav sections retain their established source-flow navigation layout.');
+$labeledSocialNavigation = (new HtmlTransformer())->transform('')->toArray();
+$assert('core/navigation' === ($labeledSocialNavigation['blocks'][0]['innerBlocks'][1]['blockName'] ?? null), 'Labeled social-profile sections retain navigation precedence and their heading.');
+$punctuatedNavigation = (new HtmlTransformer())->transform('')->toArray();
+$assert('vertical' === ($punctuatedNavigation['blocks'][0]['innerBlocks'][1]['attrs']['layout']['orientation'] ?? null), 'Punctuation outside established navigation signal boundaries retains normal pattern lowering.');
+
echo "pattern registry staged dispatch passed ({$assertions} assertions)\n";