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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements;

use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\PatternContext;
use Closure;
use DOMElement;

Expand All @@ -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,
Expand Down Expand Up @@ -83,8 +84,7 @@ public function shouldPreserveEmptyVisualElement(DOMElement $element): bool { re
public function emptyVisualElementAttributes(DOMElement $element): array { return ($this->emptyVisualElementAttributes)($element); }
/** @param array<string, mixed> $attributes @param array<int, array<string, mixed>> $innerBlocks @return array<string, mixed> */
public function createBlock(string $name, array $attributes, array $innerBlocks, ?DOMElement $sourceElement): array { return ($this->createBlock)($name, $attributes, $innerBlocks, $sourceElement); }
/** @return array<string, mixed>|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<string, mixed> $block @return array<string, mixed> */
public function rememberAccordionDisclosureRoot(array $block, DOMElement $element): array { return ($this->rememberAccordionDisclosureRoot)($block, $element); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
{
}

Expand Down Expand Up @@ -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 ));
Expand Down
58 changes: 1 addition & 57 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<string, mixed>|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);
Expand Down
72 changes: 72 additions & 0 deletions php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions php-transformer/tests/unit/pattern-registry-staged-dispatch.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div class="footer-links"><h3>Resources</h3><a href="/docs">Docs</a><a href="/help">Help</a></div>')->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('<div class="footer-links"><h3>Follow</h3><a href="https://github.com/example">GitHub</a><a href="https://instagram.com/example">Instagram</a></div>')->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('<div class="nav.foo"><h3>Resources</h3><a href="/docs">Docs</a></div>')->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";
Loading