Skip to content
Merged
13 changes: 11 additions & 2 deletions php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,13 @@ private function normalizeGeneratedBlock(array $block): array
if ( is_array($block['assets'] ?? null) && array() !== $block['assets'] ) {
$normalized['assets'] = $block['assets'];
}
$scriptDependencies = $this->normalizeScriptDependencies($block['script_dependencies'] ?? null, $normalized['assets'] ?? array());
// The view module is carried in its own slot and lands as `view.js`
// beside the declared assets, so it can be depended on by that name.
$dependencyAssets = $normalized['assets'] ?? array();
if ( isset($normalized['view_js']) ) {
$dependencyAssets['view.js'] = $normalized['view_js'];
}
$scriptDependencies = $this->normalizeScriptDependencies($block['script_dependencies'] ?? null, $dependencyAssets);
if ( array() !== $scriptDependencies ) {
$normalized['script_dependencies'] = $scriptDependencies;
}
Expand Down Expand Up @@ -232,7 +238,10 @@ private function normalizeScriptDependencies(mixed $dependencies, array $assets)

$validHandles = array();
foreach ( $handles as $handle ) {
if ( ! is_string($handle) || 1 !== preg_match('/^[A-Za-z0-9_-]+$/', $handle) || isset($validHandles[$handle]) ) {
// A classic script states a handle; a script module states its
// import specifier, which is how `@wordpress/interactivity`
// reaches the generated asset manifest.
if ( ! is_string($handle) || 1 !== preg_match('#^(?:@[a-z0-9][a-z0-9._-]*/)?[A-Za-z0-9][A-Za-z0-9._-]*$#', $handle) || isset($validHandles[$handle]) ) {
continue;
}
$validHandles[$handle] = true;
Expand Down
8 changes: 6 additions & 2 deletions php-transformer/src/HtmlToBlocks/BlockFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,9 @@ private function blockHtml(string $name, array $attrs, array $innerBlocks): stri
}

if ( 'core/column' === $name ) {
return array( 'opening' => '<div' . $this->blockSupportAttrs($attrs, 'wp-block-column') . '>', 'closing' => '</div>' );
$width = trim((string) ($attrs['width'] ?? ''));
$columnStyle = trim((string) ($attrs['inlineGeometryStyle'] ?? '') . (preg_match('/^\d+(?:\.\d+)?%$/', $width) ? ';flex-basis:' . $width : ''), ';');
return array( 'opening' => '<div' . $this->blockSupportAttrs($attrs, 'wp-block-column', $columnStyle) . '>', 'closing' => '</div>' );
}

if ( 'core/details' === $name ) {
Expand Down Expand Up @@ -385,7 +387,9 @@ private function blockHtml(string $name, array $attrs, array $innerBlocks): stri
? $size . ' has-' . $size . '-icon-size'
: '';
$labelsClass = ! empty($attrs['showLabels']) ? 'has-visible-labels' : '';
return array( 'opening' => '<ul' . $this->blockSupportAttrs($attrs, trim('wp-block-social-links ' . $labelsClass . ' ' . $sizeClass)) . '>', 'closing' => '</ul>' );
$justification = $this->safeSlug((string) ($attrs['justifyContent'] ?? ''));
$justificationClass = in_array($justification, array( 'left', 'center', 'right', 'space-between' ), true) ? 'is-content-justification-' . $justification : '';
return array( 'opening' => '<ul' . $this->blockSupportAttrs($attrs, trim('wp-block-social-links ' . $labelsClass . ' ' . $sizeClass . ' ' . $justificationClass)) . '>', 'closing' => '</ul>' );
}

if ( 'core/social-link' === $name ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -482,14 +482,14 @@ public function hasCapturedMediaContent(DOMElement $element): bool

public function hasCarouselIdentity(DOMElement $element): bool
{
$identity = strtolower(implode(' ', array(
$identity = strtolower((string) preg_replace(array('/([a-z0-9])([A-Z])/', '/([A-Z]+)([A-Z][a-z])/'), array('$1 $2', '$1 $2'), implode(' ', array(
$element->tagName,
SourceDom::attr($element, 'id'),
SourceDom::attr($element, 'class'),
SourceDom::attr($element, 'role'),
SourceDom::attr($element, 'data-hook'),
SourceDom::attr($element, 'data-testid'),
)));
))));

return 1 === preg_match('/(?:^|[^a-z0-9])(?:carousel|gallery|slider|slideshow)(?:[^a-z0-9]|$)/', $identity);
}
Expand Down

Large diffs are not rendered by default.

109 changes: 104 additions & 5 deletions php-transformer/src/HtmlToBlocks/HtmlCompilation.php
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,8 @@ public static function emittedCoreBlockContracts(): array

private const EMPTY_FLEX_ITEM_CLASS = 'blocks-engine-empty-flex-item';

private const LAYOUT_TABLE_COLUMNS_CLASS = 'blocks-engine-layout-table-columns';

public const EMPTY_VISUAL_GROUP_CLASS = 'blocks-engine-empty-visual-group';

/**
Expand Down Expand Up @@ -2026,6 +2028,9 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo
// paragraph blocks. Neutralize only those generated inner defaults.
$beforeAuthorCssParts[] = ':root :where(.wp-block-group.' . self::CSS_OWNED_LAYOUT_ITEM_CLASS . ')>*{margin-block-start:0;margin-block-end:0}';
}
if ( str_contains($serializedBlocks, self::LAYOUT_TABLE_COLUMNS_CLASS) ) {
$afterAuthorCssParts[] = ':root .wp-block-columns.' . self::LAYOUT_TABLE_COLUMNS_CLASS . '{gap:0}';
}
if ( str_contains($serializedBlocks, self::PROPAGATED_LINK_COLOR_CARRIER_CLASS) ) {
// The source painted this text; the anchor around it only exists
// because a content-wrapping link was pushed into the block. It
Expand Down Expand Up @@ -2056,6 +2061,10 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo
// through that same alignment, for centered and start-aligned
// containers alike, while an explicit justification still wins.
$afterAuthorCssParts[] = ':root ul.wp-block-social-links:not([class*="is-content-justification-"]){display:inline-flex}';
$afterAuthorCssParts[] = ':root ul.wp-block-social-links.is-content-justification-left{justify-content:flex-start}'
. ':root ul.wp-block-social-links.is-content-justification-center{justify-content:center}'
. ':root ul.wp-block-social-links.is-content-justification-right{justify-content:flex-end}'
. ':root ul.wp-block-social-links.is-content-justification-space-between{justify-content:space-between}';
}
array_push($afterAuthorCssParts, ...$this->generatedSupportStyles()->conditionalAfterAuthorCss($serializedBlocks));
if ( str_contains($serializedBlocks, 'blocks-engine-list-navigation') ) {
Expand Down Expand Up @@ -2297,7 +2306,7 @@ private function nestedLayoutTableColumnsBlock(DOMElement $table, array &$fallba
$columns[] = $column;
}

return $this->createBlock('core/columns', $this->styleResolver->presentationAttributes($table), $columns, $table);
return $this->createBlock('core/columns', $this->layoutTableColumnsAttributes($table), $columns, $table);
}

/**
Expand All @@ -2314,6 +2323,14 @@ private function layoutTableColumnAttributes(DOMElement $cell): array
return $attrs;
}

/** @return array<string, mixed> */
private function layoutTableColumnsAttributes(DOMElement $element): array
{
$attrs = $this->styleResolver->presentationAttributes($element);
$attrs['className'] = trim((string) ($attrs['className'] ?? '') . ' ' . self::LAYOUT_TABLE_COLUMNS_CLASS);
return $attrs;
}

/**
* @param array<int, array<string, mixed>> $fallbacks
* @return array<string, mixed>
Expand All @@ -2339,7 +2356,7 @@ private function mediaLayoutTableColumnsBlock(DOMElement $table, array &$fallbac
);
}
if (array() !== $columns) {
$rows[] = $this->createBlock('core/columns', array(), $columns, $row);
$rows[] = $this->createBlock('core/columns', $this->layoutTableColumnsAttributes($row), $columns, $row);
}
}

Expand Down Expand Up @@ -6494,12 +6511,24 @@ private function sourceContext(DOMElement $element): array
'role' => $this->attr($element, 'role'),
'id' => $this->attr($element, 'id'),
'class_names' => $this->classNames($element),
'ancestor_class_names' => $this->ancestorClassNames($element),
'data_attributes' => $this->safeDataAttributes($element),
'structure_signals' => $this->structureSignals($element, array()),
'interactive_attributes' => $this->interactiveAttributes($element),
), static fn (mixed $value): bool => '' !== $value && array() !== $value);
}

/** @return list<string> */
private function ancestorClassNames(DOMElement $element): array
{
$classes = array();
for ( $ancestor = $element->parentNode; $ancestor instanceof DOMElement && 'body' !== strtolower($ancestor->tagName); $ancestor = $ancestor->parentNode ) {
array_push($classes, ...$this->classNames($ancestor));
}

return array_values(array_unique($classes));
}

private function nearestPreviousHeadingText(DOMElement $element): string
{
for ( $node = $element->previousSibling; $node instanceof DOMNode; $node = $node->previousSibling ) {
Expand Down Expand Up @@ -9429,10 +9458,15 @@ private function isImageOnlyAnchor(DOMElement $anchor): bool
$imageChildren = 0;
foreach ( $anchor->childNodes as $child ) {
if ( $child instanceof DOMElement ) {
if ( ! in_array(strtolower($child->tagName), array( 'img', 'picture' ), true) && ! ( $this->imageOnlyCarrierElement($child) instanceof DOMElement ) ) {
if ( in_array(strtolower($child->tagName), array( 'img', 'picture' ), true) || $this->imageOnlyCarrierElement($child) instanceof DOMElement ) {
++$imageChildren;
continue;
}
// Lightbox links commonly append empty overlay elements beside
// their image. They are decoration, not additional link content.
if ( '' !== trim($child->textContent ?? '') ) {
return false;
}
++$imageChildren;
continue;
}

Expand Down Expand Up @@ -10046,12 +10080,77 @@ private function authoredCarouselBlock(DOMElement $element): ?array
$slides[] = $slide;
}

$listIdentity = strtolower(implode(' ', array($list->tagName, $this->attr($list, 'class'), $this->attr($list, 'role'))));
$presentation = 1 === preg_match('/(?:^|[^a-z0-9])slideshow(?:[^a-z0-9]|$)/', $listIdentity) ? 'slideshow' : 'track';
$initialSlide = 0;
foreach ( $items as $index => $item ) {
if ( '' !== $this->attr($item, 'aria-hidden') || '' !== $this->attr($item, 'data-slideshow-slide') ) {
$presentation = 'slideshow';
}
if ( 'false' === strtolower(trim($this->attr($item, 'aria-hidden'))) || str_contains(' ' . strtolower($this->attr($item, 'class')) . ' ', ' active ') ) {
$initialSlide = $index;
}
}

$showDots = false;
foreach ( $element->getElementsByTagName('*') as $candidate ) {
if ( ! $candidate instanceof DOMElement ) {
continue;
}
foreach ( array('data-slide', 'data-slide-index', 'data-carousel-index', 'data-slideshow-item', 'data-uk-slideshow-item') as $attribute ) {
if ( ctype_digit(trim($this->attr($candidate, $attribute))) ) {
$showDots = true;
break 2;
}
}
}

$durationMilliseconds = static function (string $value): int {
if ( 1 !== preg_match('/^([0-9]+(?:\.[0-9]+)?)(ms|s)$/', strtolower(trim($value)), $matches) ) {
return 0;
}
$milliseconds = (float) $matches[1] * ('s' === $matches[2] ? 1000 : 1);
return (int) round($milliseconds);
};
$transitionDuration = 0;
$autoplayInterval = 0;
foreach ( $items as $item ) {
$transitionDuration = max($transitionDuration, $durationMilliseconds((string) ($this->styleResolver->cssDeclarations($this->attr($item, 'style'))['animation-duration'] ?? '')));
foreach ( $item->getElementsByTagName('*') as $descendant ) {
if ( ! $descendant instanceof DOMElement ) {
continue;
}
$autoplayInterval = max($autoplayInterval, $durationMilliseconds((string) ($this->styleResolver->cssDeclarations($this->attr($descendant, 'style'))['animation-duration'] ?? '')));
}
}
if ( $autoplayInterval <= $transitionDuration ) {
$autoplayInterval = 0;
}
if ( 0 === $transitionDuration ) {
$transitionDuration = 300;
}

$listHeight = (string) ($this->styleResolver->cssDeclarations($this->attr($list, 'style'))['height'] ?? '');
$viewportHeight = 1 === preg_match('/^([0-9]+(?:\.[0-9]+)?)px$/', trim($listHeight), $heightMatch) ? (int) round((float) $heightMatch[1]) : 0;
$rootDeclarations = $this->styleResolver->cssDeclarations($this->attr($element, 'style'));
$rootWidth = strtolower((string) preg_replace('/\s+/', '', (string) ($rootDeclarations['width'] ?? '')));
$fullBleed = ('100vw' === $rootWidth || 1 === preg_match('/^[0-9]+(?:\.[0-9]+)?px$/', $rootWidth))
&& 1 === preg_match('/^-\s*(?:[0-9]+|[0-9]*\.[0-9]+)(?:px|rem|em|%)$/', strtolower(trim((string) ($rootDeclarations['left'] ?? ''))));

$generator = new AuthoredCarouselBlockGenerator();
$this->generatedBlocks()->register(AuthoredCarouselBlockGenerator::class, $generator->definition($this->generatedBlocks()->namespace()));
$attributes = array(
'ariaLabel' => trim($this->attr($element, 'aria-label')) ?: 'Carousel',
'itemsPerView' => min(4, count($slides)),
'itemsPerView' => 'slideshow' === $presentation ? 1 : min(4, count($slides)),
'wrap' => true,
'presentation' => $presentation,
'slideCount' => count($slides),
'initialSlide' => $initialSlide,
'viewportHeight' => 'slideshow' === $presentation ? $viewportHeight : 0,
'transitionDuration' => 'slideshow' === $presentation ? $transitionDuration : 300,
'autoplayInterval' => 'slideshow' === $presentation ? $autoplayInterval : 0,
'showDots' => 'slideshow' === $presentation && $showDots,
'fullBleed' => 'slideshow' === $presentation && $fullBleed,
);
$shell = $generator->shell($attributes);
$innerContent = array($shell['opening']);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,13 @@ private function isMobileDuplicate(array $block, array $sourceProvenance): bool
$attributes = is_array($source['source_attributes'] ?? null) ? $source['source_attributes'] : array();
$context = is_array($source['context'] ?? null) ? $source['context'] : array();
$classNames = is_array($context['class_names'] ?? null) ? implode(' ', $context['class_names']) : '';
$ancestorClassNames = is_array($context['ancestor_class_names'] ?? null) ? implode(' ', $context['ancestor_class_names']) : '';

$haystack = strtolower(trim(implode(' ', array(
(string) ($attributes['class'] ?? ''),
(string) ($attributes['id'] ?? ''),
$classNames,
$ancestorClassNames,
))));

return (bool) preg_match('/(?:^|[^a-z0-9])(?:mobile|drawer|offcanvas|overlay|collapsed|hamburger|menu-panel|nav-panel)(?:[^a-z0-9]|$)/', $haystack);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter
}

$attrs = $context->presentationAttributes($element);
for ( $carrier = $element; $carrier instanceof DOMElement && 'body' !== strtolower($carrier->tagName); $carrier = $carrier->parentNode ) {
if ( preg_match('/(?:^|;)\s*text-align\s*:\s*(left|center|right)\b/i', $this->attr($carrier, 'style'), $alignment) ) {
$attrs['justifyContent'] = strtolower($alignment[1]);
break;
}
}
if ( $showLabels ) {
$attrs['showLabels'] = true;
}
Expand Down
24 changes: 24 additions & 0 deletions php-transformer/src/HtmlToBlocks/Style/StyleResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -1782,6 +1782,7 @@ private function stripFrozenHiddenState(DOMElement $element, array $declarations
array() === $declarations
|| $this->isDecorativeHiddenElement($element)
|| $this->isExplicitlyInactiveState($element)
|| $this->isHiddenPositionedLayer($element, $declarations)
|| $this->context->hasRetainedPresentationRuntime($element)
) {
return $declarations;
Expand Down Expand Up @@ -1852,6 +1853,29 @@ private function isExplicitlyInactiveState(DOMElement $element): bool
return false;
}

/** @param array<string, string> $declarations */
private function isHiddenPositionedLayer(DOMElement $element, array $declarations): bool
{
$opacity = CssValueInspector::comparable((string) ($declarations['opacity'] ?? '1'));
$hidden = 'none' === CssValueInspector::comparable((string) ($declarations['display'] ?? ''))
|| 'hidden' === CssValueInspector::comparable((string) ($declarations['visibility'] ?? ''))
|| (is_numeric($opacity) && 0.0 === (float) $opacity);
if ( ! $hidden ) {
return false;
}

$resolved = array();
foreach ( $this->styleRuleCandidates($element, 'static') as $rule ) {
if ( $this->matchesCssSelector($element, $rule['selector']) ) {
$resolved = $this->mergeCssDeclarationMaps($resolved, $rule['declarations']);
}
}
$resolved = $this->mergeCssDeclarationMaps($resolved, $this->cssDeclarations(SourceDom::attr($element, 'style')));
$resolved = $this->mergeCssDeclarationMaps($resolved, $declarations);
$position = CssValueInspector::comparable((string) ($resolved['position'] ?? ''));
return in_array($position, array( 'absolute', 'fixed' ), true);
}

/** @return list<string> */
public function closedStateRepairCssRules(): array
{
Expand Down
Loading
Loading