From bd419a8625c73a025648d700f3941dd4dd74653f Mon Sep 17 00:00:00 2001 From: Matthew Batchelder Date: Fri, 31 Jul 2026 11:47:23 -0400 Subject: [PATCH 01/11] refactor(php-transformer): freeze media-text contracts --- .../HtmlToBlocks/Patterns/ColumnsPattern.php | 16 +----- .../HtmlToBlocks/Patterns/CoverPattern.php | 27 +--------- .../Patterns/MediaTextPattern.php | 52 +++++++++++++++++++ .../Patterns/PatternGateHelpersTrait.php | 47 +++++++++++++++++ 4 files changed, 102 insertions(+), 40 deletions(-) create mode 100644 php-transformer/src/HtmlToBlocks/Patterns/MediaTextPattern.php create mode 100644 php-transformer/src/HtmlToBlocks/Patterns/PatternGateHelpersTrait.php diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ColumnsPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/ColumnsPattern.php index 7c120311..fd09de41 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/ColumnsPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/ColumnsPattern.php @@ -28,6 +28,7 @@ final class ColumnsPattern { use PatternDomHelpersTrait; + use PatternGateHelpersTrait; /** * @param array> $fallbacks @@ -148,21 +149,6 @@ private function looksLikeColumnsContainer(DOMElement $element, string $resolved || preg_match('/(?:^|;)\s*display\s*:\s*(?:inline-)?flex/', $inlineStyle); } - /** - * True when the resolved style declares a flex container whose main axis is - * vertical (flex-direction: column / column-reverse). flex-direction only has - * meaning on a flex container, so both display:flex and the column direction - * are required before redirecting away from horizontal columns. - */ - private function isVerticalFlexContainer(string $style): bool - { - if ( ! preg_match('/(?:^|;)\s*display\s*:\s*(?:inline-)?flex\b/', $style) ) { - return false; - } - - return (bool) preg_match('/(?:^|;)\s*flex-direction\s*:\s*column(?:-reverse)?\b/', $style); - } - private function looksLikeSplitLayout(DOMElement $element): bool { $name = strtolower(trim($this->attr($element, 'class') . ' ' . $this->attr($element, 'id'))); diff --git a/php-transformer/src/HtmlToBlocks/Patterns/CoverPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/CoverPattern.php index 7179173a..70057d7e 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/CoverPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/CoverPattern.php @@ -29,6 +29,8 @@ */ final class CoverPattern { + use PatternGateHelpersTrait; + private CoverStyleResolver $styleResolver; private BackgroundImageExtractor $backgroundImageExtractor; @@ -245,31 +247,6 @@ private function directElementChildCount(DOMElement $element): int return $count; } - /** - * @param array> $blocks - */ - private function containsTextBearingBlock(array $blocks): bool - { - $textBearingNames = array( 'core/heading', 'core/paragraph', 'core/list', 'core/buttons', 'core/quote' ); - - foreach ( $blocks as $block ) { - if ( ! is_array($block) ) { - continue; - } - - if ( in_array($block['blockName'] ?? null, $textBearingNames, true) ) { - return true; - } - - $innerBlocks = $block['innerBlocks'] ?? array(); - if ( is_array($innerBlocks) && $this->containsTextBearingBlock($innerBlocks) ) { - return true; - } - } - - return false; - } - /** * @param array> $blocks */ diff --git a/php-transformer/src/HtmlToBlocks/Patterns/MediaTextPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/MediaTextPattern.php new file mode 100644 index 00000000..c0078b80 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Patterns/MediaTextPattern.php @@ -0,0 +1,52 @@ + null + * | + * +-- exactly one pure img/video side? -- no --> null + * | + * +-- convert text child once + * | + * +-- text-bearing block? -------------- no --> null + * | + * +-- vertical flex container? --------- yes -> null + * | + * `-- core/media-text + */ +final class MediaTextPattern +{ + use PatternDomHelpersTrait; + use PatternGateHelpersTrait; + + /** + * @param array> $fallbacks + * @param callable(DOMElement, array>&, bool): array> $convertChildren + * @param callable(DOMElement, array): array $presentationAttributes + * @param callable(DOMElement): string $mergedPresentationStyle + * @param callable(DOMElement): array $htmlAttributes + * @param callable(string): string $resolveAssetUrl + * @param callable(string, array, array>, DOMElement|null): array $createBlock + * @return array|null + */ + public function match( + DOMElement $element, + array &$fallbacks, + callable $convertChildren, + callable $presentationAttributes, + callable $mergedPresentationStyle, + callable $htmlAttributes, + callable $resolveAssetUrl, + callable $createBlock + ): ?array { + return null; + } +} diff --git a/php-transformer/src/HtmlToBlocks/Patterns/PatternGateHelpersTrait.php b/php-transformer/src/HtmlToBlocks/Patterns/PatternGateHelpersTrait.php new file mode 100644 index 00000000..31ad34f1 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Patterns/PatternGateHelpersTrait.php @@ -0,0 +1,47 @@ +> $blocks + */ + private function containsTextBearingBlock(array $blocks): bool + { + $textBearingNames = array( 'core/heading', 'core/paragraph', 'core/list', 'core/buttons', 'core/quote' ); + + foreach ( $blocks as $block ) { + if ( ! is_array($block) ) { + continue; + } + + if ( in_array($block['blockName'] ?? null, $textBearingNames, true) ) { + return true; + } + + $innerBlocks = $block['innerBlocks'] ?? array(); + if ( is_array($innerBlocks) && $this->containsTextBearingBlock($innerBlocks) ) { + return true; + } + } + + return false; + } + + /** + * True when the resolved style declares a flex container whose main axis is + * vertical (flex-direction: column / column-reverse). flex-direction only has + * meaning on a flex container, so both display:flex and the column direction + * are required before redirecting away from horizontal columns. + */ + private function isVerticalFlexContainer(string $style): bool + { + if ( ! preg_match('/(?:^|;)\s*display\s*:\s*(?:inline-)?flex\b/', $style) ) { + return false; + } + + return (bool) preg_match('/(?:^|;)\s*flex-direction\s*:\s*column(?:-reverse)?\b/', $style); + } +} From 38be07f8e9d014c638749b4942695b65cd0b5fb2 Mon Sep 17 00:00:00 2001 From: Matthew Batchelder Date: Fri, 31 Jul 2026 12:07:56 -0400 Subject: [PATCH 02/11] feat(php-transformer): add media-text block serialization --- .../src/HtmlToBlocks/BlockFactory.php | 109 ++++++++++++++- .../WordPress/CanonicalSaveShapeValidator.php | 14 +- .../GeneratedGutenbergClassPolicy.php | 1 + php-transformer/src/WordPress/Runtime.php | 1 + .../tests/unit/media-text-block-factory.php | 124 ++++++++++++++++++ 5 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 php-transformer/tests/unit/media-text-block-factory.php diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php index 4e67c8c7..16e05128 100644 --- a/php-transformer/src/HtmlToBlocks/BlockFactory.php +++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php @@ -89,7 +89,7 @@ private function normalizeAttrsForBlock(string $name, array $attrs): array } } - if ( in_array($name, array( 'core/buttons', 'core/column', 'core/columns', 'core/group', 'core/heading', 'core/list', 'core/list-item', 'core/paragraph' ), true) ) { + if ( in_array($name, array( 'core/buttons', 'core/column', 'core/columns', 'core/group', 'core/heading', 'core/list', 'core/list-item', 'core/media-text', 'core/paragraph' ), true) ) { unset($attrs['style']['spacing']['blockGap']); if ( empty($attrs['style']['spacing']) ) { unset($attrs['style']['spacing']); @@ -142,6 +142,17 @@ private function commentAttrs(string $name, array $attrs): array if ( 'core/cover' === $name && 'px' === ($attrs['minHeightUnit'] ?? null) ) { unset($attrs['minHeightUnit']); } + if ( 'core/media-text' === $name ) { + if ( 'left' === ($attrs['mediaPosition'] ?? null) ) { + unset($attrs['mediaPosition']); + } + if ( is_numeric($attrs['mediaWidth'] ?? null) && 50.0 === (float) $attrs['mediaWidth'] ) { + unset($attrs['mediaWidth']); + } + if ( true === ($attrs['isStackedOnMobile'] ?? null) ) { + unset($attrs['isStackedOnMobile']); + } + } return $attrs; } @@ -338,6 +349,10 @@ private function blockHtml(string $name, array $attrs, array $innerBlocks): stri return $this->coverHtml($attrs, $innerBlocks); } + if ( 'core/media-text' === $name ) { + return $this->mediaTextHtml($attrs, $innerBlocks); + } + if ( 'core/group' === $name ) { $tag = $this->groupTagName($attrs['tagName'] ?? null); return array( 'opening' => '<' . $tag . $this->blockSupportAttrs($attrs, 'wp-block-group') . '>', 'closing' => '' ); @@ -424,6 +439,98 @@ private function coverHtml(array $attrs, array $innerBlocks): array ); } + /** + * @param array $attrs + * @param array> $innerBlocks + * @return array{opening: string, closing: string} + */ + private function mediaTextHtml(array $attrs, array $innerBlocks): array + { + unset($innerBlocks); + + $mediaOnRight = 'right' === ($attrs['mediaPosition'] ?? 'left'); + $verticalAlignment = (string) ($attrs['verticalAlignment'] ?? ''); + if ( ! in_array($verticalAlignment, array( 'top', 'center', 'bottom' ), true) ) { + $verticalAlignment = ''; + } + + $wrapperClasses = array( 'wp-block-media-text' ); + if ( $mediaOnRight ) { + $wrapperClasses[] = 'has-media-on-the-right'; + } + if ( ! array_key_exists('isStackedOnMobile', $attrs) || false !== $attrs['isStackedOnMobile'] ) { + $wrapperClasses[] = 'is-stacked-on-mobile'; + } + if ( '' !== $verticalAlignment ) { + $wrapperClasses[] = 'is-vertically-aligned-' . $verticalAlignment; + } + + $wrapperAttrs = $attrs; + if ( is_numeric($attrs['mediaWidth'] ?? null) ) { + $mediaWidth = (int) round((float) $attrs['mediaWidth']); + if ( 50 !== $mediaWidth ) { + $gridTemplateColumns = $mediaOnRight + ? 'auto ' . (string) $mediaWidth . '%' + : (string) $mediaWidth . '% auto'; + $wrapperAttrs['inlineGeometryStyle'] = trim( + (string) ($wrapperAttrs['inlineGeometryStyle'] ?? '') . ';grid-template-columns:' . $gridTemplateColumns, + ';' + ); + } + } + + $wrapperOpening = 'blockSupportAttrs($wrapperAttrs, implode(' ', $wrapperClasses)) . '>'; + $contentOpening = '
'; + $figure = '
' . $this->mediaTextMediaHtml($attrs) . '
'; + + if ( $mediaOnRight ) { + return array( + 'opening' => $wrapperOpening . $contentOpening, + 'closing' => '
' . $figure . '', + ); + } + + return array( + 'opening' => $wrapperOpening . $figure . $contentOpening, + 'closing' => '', + ); + } + + /** + * @param array $attrs + */ + private function mediaTextMediaHtml(array $attrs): string + { + $mediaUrl = (string) ($attrs['mediaUrl'] ?? ''); + if ( 'video' === ($attrs['mediaType'] ?? '') ) { + return ''; + } + + if ( 'image' !== ($attrs['mediaType'] ?? '') ) { + return ''; + } + + $image = ''; + if ( '' !== $mediaUrl ) { + $image = 'htmlAttrs(array( + 'src' => $mediaUrl, + 'alt' => (string) ($attrs['mediaAlt'] ?? ''), + ), array( 'alt' )) . '/>'; + } + + $href = (string) ($attrs['href'] ?? ''); + if ( '' === $href ) { + return $image; + } + + return 'htmlAttrs(array( + 'class' => (string) ($attrs['linkClass'] ?? ''), + 'href' => $href, + 'target' => (string) ($attrs['linkTarget'] ?? ''), + 'rel' => (string) ($attrs['rel'] ?? ''), + )) . '>' . $image . ''; + } + /** * Resolve the wrapper tag for a `core/group`. Core's group `save()` renders * `` from the `tagName` attribute, defaulting to `div`. Only the diff --git a/php-transformer/src/WordPress/CanonicalSaveShapeValidator.php b/php-transformer/src/WordPress/CanonicalSaveShapeValidator.php index f1bf2aed..f3a0ed43 100644 --- a/php-transformer/src/WordPress/CanonicalSaveShapeValidator.php +++ b/php-transformer/src/WordPress/CanonicalSaveShapeValidator.php @@ -59,6 +59,7 @@ final class CanonicalSaveShapeValidator private const TARGET_BLOCKS = array( 'core/group', 'core/cover', + 'core/media-text', 'core/columns', 'core/column', 'core/buttons', @@ -166,7 +167,7 @@ private function validateWrapper(array $block, string $blockName, string $path, // token core would reproduce from the className attribute. A leftover // source class core's save() never regenerates breaks the round-trip. foreach ( $wrapperClasses as $class ) { - if ( $this->isStructuralClass($class) || in_array($class, $classNameTokens, true) ) { + if ( $this->isStructuralClass($class, $blockName) || in_array($class, $classNameTokens, true) ) { continue; } @@ -276,8 +277,17 @@ private function validateNoDuplicateClassTokens(array $block, ?string $blockName * input: the block base class, element classes, and attribute-derived * support classes (alignment, color/border has-*, and is-* state/style). */ - private function isStructuralClass(string $class): bool + private function isStructuralClass(string $class, string $blockName): bool { + if ( 'core/media-text' === $blockName && in_array($class, array( + 'is-stacked-on-mobile', + 'is-vertically-aligned-top', + 'is-vertically-aligned-center', + 'is-vertically-aligned-bottom', + ), true) ) { + return true; + } + return str_starts_with($class, 'wp-block-') || str_starts_with($class, 'wp-element-') || str_starts_with($class, 'wp-container-') diff --git a/php-transformer/src/WordPress/GeneratedGutenbergClassPolicy.php b/php-transformer/src/WordPress/GeneratedGutenbergClassPolicy.php index 3b6e3a86..41632bea 100644 --- a/php-transformer/src/WordPress/GeneratedGutenbergClassPolicy.php +++ b/php-transformer/src/WordPress/GeneratedGutenbergClassPolicy.php @@ -21,6 +21,7 @@ final class GeneratedGutenbergClassPolicy 'core/details' => 'wp-block-details', 'core/heading' => 'wp-block-heading', 'core/list' => 'wp-block-list', + 'core/media-text' => 'wp-block-media-text', 'core/quote' => 'wp-block-quote', 'core/separator' => 'wp-block-separator', ); diff --git a/php-transformer/src/WordPress/Runtime.php b/php-transformer/src/WordPress/Runtime.php index f1a314b3..9cc985a1 100644 --- a/php-transformer/src/WordPress/Runtime.php +++ b/php-transformer/src/WordPress/Runtime.php @@ -30,6 +30,7 @@ final class Runtime 'core/list', 'core/list-item', 'core/math', + 'core/media-text', 'core/navigation', 'core/navigation-link', 'core/navigation-submenu', diff --git a/php-transformer/tests/unit/media-text-block-factory.php b/php-transformer/tests/unit/media-text-block-factory.php new file mode 100644 index 00000000..f2966b09 --- /dev/null +++ b/php-transformer/tests/unit/media-text-block-factory.php @@ -0,0 +1,124 @@ +create('core/paragraph', array( 'content' => 'Story' )); + +$left = $factory->create('core/media-text', array( + 'mediaPosition' => 'left', + 'mediaType' => 'image', + 'mediaUrl' => 'https://example.com/photo.jpg', + 'mediaAlt' => '', + 'mediaWidth' => 50, + 'isStackedOnMobile' => true, +), array( $paragraph )); +$assertSame( + '
', + $left['innerContent'][0], + 'Default media-left opening matches core save shape.' +); +$assertSame(null, $left['innerContent'][1], 'Media-left innerContent reserves child slot inside content wrapper.'); +$assertSame('
', $left['innerContent'][2], 'Media-left closes content then wrapper.'); +$assertSame( + array( 'mediaType', 'mediaUrl', 'mediaAlt' ), + array_keys($left['attrs']), + 'Core defaults stay omitted from comment attrs.' +); +$assertNotContains('grid-template-columns', $left['innerHTML'], 'Default 50 percent width emits no inline grid style.'); +$assertNotContains('wp-image-', $left['innerHTML'], 'Image without mediaId emits no wp-image class.'); +$assertNotContains('size-', $left['innerHTML'], 'Image without mediaId emits no size class.'); + +$right = $factory->create('core/media-text', array( + 'mediaPosition' => 'right', + 'mediaType' => 'image', + 'mediaUrl' => 'https://example.com/photo?a=1&b=2', + 'mediaAlt' => 'A "quoted" alt', + 'mediaWidth' => 35, + 'verticalAlignment' => 'center', + 'href' => 'https://example.com/full?a=1&b=2', + 'linkTarget' => '_blank', + 'rel' => 'noopener noreferrer', + 'linkClass' => 'media-link', + 'anchor' => 'feature', + 'className' => 'promo', + 'style' => array( 'spacing' => array( 'blockGap' => '2rem', 'padding' => array( 'top' => '2rem' ) ) ), +), array( $paragraph )); +$assertSame( + '
', + $right['innerContent'][0], + 'Media-right opening carries position, stack, vertical, support, and width attributes.' +); +$assertSame( + '
A "quoted" alt
', + $right['innerContent'][2], + 'Media-right closes content before linked figure and escapes attributes.' +); +$assertContains('grid-template-columns:auto 35%', $right['innerHTML'], 'Right media width targets second grid track.'); +$assertContains('is-vertically-aligned-center', $right['innerHTML'], 'Vertical alignment class matches core save shape.'); +$assertSame(array( 'padding' => array( 'top' => '2rem' ) ), $right['attrs']['style']['spacing'] ?? null, 'Unsupported blockGap is removed while supported spacing remains.'); +$assertNotContains('gap:', $right['innerHTML'], 'Unsupported blockGap emits no wrapper CSS.'); + +$leftNarrow = $factory->create('core/media-text', array( + 'mediaType' => 'image', + 'mediaUrl' => 'https://example.com/narrow.jpg', + 'mediaWidth' => 40, +), array()); +$assertContains('style="grid-template-columns:40% auto"', $leftNarrow['innerHTML'], 'Left media width targets first grid track.'); + +$video = $factory->create('core/media-text', array( + 'mediaType' => 'video', + 'mediaUrl' => 'https://example.com/demo.mp4', + 'isStackedOnMobile' => false, + 'href' => 'https://example.com/ignored-for-video', +), array()); +$assertContains('
', $video['innerHTML'], 'Video emits controls and src without image link wrapper.'); +$assertNotContains('is-stacked-on-mobile', $video['innerHTML'], 'Explicit false omits stacked class.'); +$assertNotContains('validateBlockSerialization(array( $right )); +$assertSame('pass', $validity['status'] ?? null, 'Media-text block passes serialization validators.'); +$assertSame(0, $validity['summary']['finding_count'] ?? null, 'Media-text block has no serialization findings.'); + +$missingBase = $right; +$missingBase['innerHTML'] = str_replace('wp-block-media-text ', '', $right['innerHTML']); +$missingBase['innerContent'][0] = str_replace('wp-block-media-text ', '', $right['innerContent'][0]); +$missingBaseFindings = ( new CanonicalSaveShapeValidator() )->findings(array( $missingBase )); +$assertSame('missing_generated_class', $missingBaseFindings[0]['details']['reason'] ?? null, 'Canonical validator requires media-text generated wrapper class.'); + +if ( 0 === $failures ) { + echo "media-text block factory ok\n"; +} + +exit(0 === $failures ? 0 : 1); From b73cd6ad9dcbd43df1cbcc9614024db0eb970b60 Mon Sep 17 00:00:00 2001 From: Matthew Batchelder Date: Fri, 31 Jul 2026 12:08:04 -0400 Subject: [PATCH 03/11] feat(php-transformer): recognize strict media-text layouts --- .../src/HtmlToBlocks/HtmlTransformer.php | 18 + .../Patterns/MediaTextPattern.php | 416 ++++++++++++++++++ .../tests/unit/media-text-pattern.php | 375 ++++++++++++++++ 3 files changed, 809 insertions(+) create mode 100644 php-transformer/tests/unit/media-text-pattern.php diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 7180c0d3..1d78bcf4 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -20,6 +20,7 @@ use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\GalleryPattern; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\LogoPattern; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\MathPattern; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\MediaTextPattern; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\NavigationPattern; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\NavigationUnderlineColorResolver; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\ParameterTablePattern; @@ -166,6 +167,8 @@ final class HtmlTransformer private readonly CoverPattern $coverPattern; + private readonly MediaTextPattern $mediaTextPattern; + private readonly DetailsPattern $detailsPattern; private readonly GalleryPattern $galleryPattern; @@ -490,6 +493,7 @@ public function __construct(private readonly Runtime $runtime = new Runtime()) $this->codeWindowPattern = new CodeWindowPattern(); $this->columnsPattern = new ColumnsPattern(); $this->coverPattern = new CoverPattern(); + $this->mediaTextPattern = new MediaTextPattern(); $this->detailsPattern = new DetailsPattern(); $this->galleryPattern = new GalleryPattern(); $this->logoPattern = new LogoPattern(); @@ -2974,6 +2978,20 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca if ( null !== $cover ) { return $cover; } + + $mediaText = $this->mediaTextPattern->match( + $element, + $fallbacks, + fn (DOMElement $sourceElement, array &$sourceFallbacks, bool $captureUnsupported): array => $this->convertChildren($sourceElement, $sourceFallbacks, $captureUnsupported), + fn (DOMElement $sourceElement, array $excludedGeometryProperties = array()): array => $this->presentationAttributes($sourceElement, $excludedGeometryProperties), + fn (DOMElement $sourceElement): string => $this->mergedPresentationStyle($sourceElement), + fn (DOMElement $sourceElement): array => $this->htmlAttributes($sourceElement), + fn (string $url): string => $this->resolvedAssetImageUrl($url), + fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement) + ); + if ( null !== $mediaText ) { + return $mediaText; + } } $columns = $this->columnsPattern->match( diff --git a/php-transformer/src/HtmlToBlocks/Patterns/MediaTextPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/MediaTextPattern.php index c0078b80..a8afe814 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/MediaTextPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/MediaTextPattern.php @@ -47,6 +47,422 @@ public function match( callable $resolveAssetUrl, callable $createBlock ): ?array { + $elementChildren = $this->strictElementChildren($element); + if ( null === $elementChildren || 2 !== count($elementChildren) ) { + return null; + } + + $mediaCandidates = array(); + foreach ( $elementChildren as $index => $child ) { + $resolution = $this->pureMediaResolution($child); + if ( null !== $resolution ) { + $mediaCandidates[ $index ] = $resolution; + } + } + + if ( 1 !== count($mediaCandidates) ) { + return null; + } + + $mediaIndex = (int) array_key_first($mediaCandidates); + $textIndex = 0 === $mediaIndex ? 1 : 0; + $resolution = $mediaCandidates[ $mediaIndex ]; + if ( $this->containsMediaElement($elementChildren[ $textIndex ]) ) { + return null; + } + + $localFallbacks = array(); + try { + $innerBlocks = $convertChildren($elementChildren[ $textIndex ], $localFallbacks, true); + } catch ( \Throwable ) { + return null; + } + + if ( array() === $innerBlocks || ! $this->containsTextBearingBlock($innerBlocks) ) { + return null; + } + + try { + $containerStyle = $mergedPresentationStyle($element); + } catch ( \Throwable ) { + return null; + } + + if ( $this->isVerticalFlexContainer(strtolower($containerStyle)) ) { + return null; + } + + try { + $mediaAttributes = $htmlAttributes($resolution['media']); + $sourceUrl = trim((string) ($mediaAttributes['src'] ?? '')); + if ( '' === $sourceUrl ) { + return null; + } + $mediaUrl = trim($resolveAssetUrl($sourceUrl)); + if ( '' === $mediaUrl ) { + return null; + } + } catch ( \Throwable ) { + return null; + } + + try { + $attrs = $presentationAttributes( + $element, + array( 'display', 'grid-template-columns', 'align-items', 'gap' ) + ); + } catch ( \Throwable ) { + return null; + } + unset($attrs['layout']); + + $mediaType = strtolower($resolution['media']->tagName); + $attrs['mediaType'] = 'img' === $mediaType ? 'image' : 'video'; + $attrs['mediaUrl'] = $mediaUrl; + + if ( 'img' === $mediaType ) { + $attrs['mediaAlt'] = (string) ($mediaAttributes['alt'] ?? ''); + } + if ( 1 === $mediaIndex ) { + $attrs['mediaPosition'] = 'right'; + } + + $mediaWidth = $this->mediaWidthFromContainerStyle($containerStyle, $mediaIndex); + if ( null === $mediaWidth && ! $this->hasGridTemplateColumns($containerStyle) ) { + try { + $mediaStyle = $mergedPresentationStyle($elementChildren[ $mediaIndex ]); + $mediaWidth = $this->mediaWidthFromMediaStyle($mediaStyle); + } catch ( \Throwable ) { + return null; + } + } + if ( null !== $mediaWidth && 50 !== $mediaWidth ) { + $attrs['mediaWidth'] = $mediaWidth; + } + + $verticalAlignment = $this->verticalAlignmentFromStyle($containerStyle); + if ( null !== $verticalAlignment ) { + $attrs['verticalAlignment'] = $verticalAlignment; + } + + if ( $resolution['anchor'] instanceof DOMElement ) { + try { + $anchorAttributes = $htmlAttributes($resolution['anchor']); + } catch ( \Throwable ) { + return null; + } + + $href = $this->safeLinkUrl((string) ($anchorAttributes['href'] ?? '')); + if ( '' !== $href ) { + $attrs['href'] = $href; + } + + foreach ( array( + 'target' => 'linkTarget', + 'rel' => 'rel', + 'class' => 'linkClass', + ) as $sourceName => $attributeName ) { + $value = trim((string) ($anchorAttributes[ $sourceName ] ?? '')); + if ( '' !== $value ) { + $attrs[ $attributeName ] = $value; + } + } + } + + try { + $block = $createBlock('core/media-text', $attrs, $innerBlocks, $element); + } catch ( \Throwable ) { + return null; + } + + array_push($fallbacks, ...$localFallbacks); + + return $block; + } + + /** + * @return array|null + */ + private function strictElementChildren(DOMElement $element): ?array + { + $children = array(); + foreach ( $element->childNodes as $child ) { + if ( XML_COMMENT_NODE === $child->nodeType ) { + continue; + } + if ( XML_TEXT_NODE === $child->nodeType ) { + if ( '' !== trim($child->textContent ?? '') ) { + return null; + } + continue; + } + if ( ! $child instanceof DOMElement ) { + return null; + } + $children[] = $child; + } + + return $children; + } + + private function safeLinkUrl(string $url): string + { + $url = trim($url); + if ( '' === $url || preg_match('/[\x00-\x1f\x7f]|javascript\s*:/i', $url) ) { + return ''; + } + + return $url; + } + + private function containsMediaElement(DOMElement $element): bool + { + if ( in_array(strtolower($element->tagName), array( 'img', 'video' ), true) ) { + return true; + } + + foreach ( $element->childNodes as $child ) { + if ( $child instanceof DOMElement && $this->containsMediaElement($child) ) { + return true; + } + } + + return false; + } + + /** + * @return array{media: DOMElement, anchor: DOMElement|null}|null + */ + private function pureMediaResolution(DOMElement $element, ?DOMElement $anchor = null): ?array + { + $tagName = strtolower($element->tagName); + if ( in_array($tagName, array( 'img', 'video' ), true) ) { + if ( ! $this->hasOnlyIgnorableChildren($element) ) { + return null; + } + + return array( + 'media' => $element, + 'anchor' => $anchor, + ); + } + + if ( ! in_array($tagName, array( 'figure', 'div', 'a', 'picture' ), true) ) { + return null; + } + if ( 'a' === $tagName ) { + if ( $anchor instanceof DOMElement ) { + return null; + } + $anchor = $element; + } + + if ( 'picture' === $tagName ) { + $image = $this->purePictureImage($element); + return $image instanceof DOMElement + ? array( 'media' => $image, 'anchor' => $anchor ) + : null; + } + + $child = $this->strictSingleMediaChild($element); + if ( ! $child instanceof DOMElement ) { + return null; + } + + return $this->pureMediaResolution($child, $anchor); + } + + private function purePictureImage(DOMElement $picture): ?DOMElement + { + $image = null; + if ( ! $this->collectPurePictureImage($picture, $image) ) { + return null; + } + + return $image; + } + + private function collectPurePictureImage(DOMElement $element, ?DOMElement &$image): bool + { + foreach ( $element->childNodes as $child ) { + if ( XML_COMMENT_NODE === $child->nodeType ) { + continue; + } + if ( XML_TEXT_NODE === $child->nodeType ) { + if ( '' !== trim($child->textContent ?? '') ) { + return false; + } + continue; + } + if ( ! $child instanceof DOMElement ) { + return false; + } + + $tagName = strtolower($child->tagName); + if ( 'source' === $tagName ) { + if ( ! $this->collectPurePictureImage($child, $image) ) { + return false; + } + continue; + } + if ( 'img' !== $tagName || $image instanceof DOMElement || ! $this->hasOnlyIgnorableChildren($child) ) { + return false; + } + $image = $child; + } + + return true; + } + + private function hasOnlyIgnorableChildren(DOMElement $element): bool + { + foreach ( $element->childNodes as $child ) { + if ( XML_COMMENT_NODE === $child->nodeType ) { + continue; + } + if ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) { + continue; + } + + return false; + } + + return true; + } + + private function strictSingleMediaChild(DOMElement $element): ?DOMElement + { + $candidate = null; + foreach ( $element->childNodes as $child ) { + if ( XML_COMMENT_NODE === $child->nodeType ) { + continue; + } + if ( XML_TEXT_NODE === $child->nodeType ) { + if ( '' !== trim($child->textContent ?? '') ) { + return null; + } + continue; + } + if ( ! $child instanceof DOMElement ) { + return null; + } + if ( $candidate instanceof DOMElement ) { + return null; + } + $candidate = $child; + } + + return $candidate; + } + + /** + * @return array + */ + private function styleDeclarations(string $style): array + { + $declarations = array(); + foreach ( \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueSplitter::splitTopLevel($style, array( ';' )) as $declaration ) { + $separator = strpos($declaration, ':'); + if ( false === $separator ) { + continue; + } + + $name = strtolower(trim(substr($declaration, 0, $separator))); + $value = trim(substr($declaration, $separator + 1)); + if ( '' !== $name && '' !== $value ) { + $declarations[ $name ] = $value; + } + } + + return $declarations; + } + + private function hasGridTemplateColumns(string $style): bool + { + $declarations = $this->styleDeclarations($style); + return '' !== $this->normalizedCssValue((string) ($declarations['grid-template-columns'] ?? '')); + } + + private function mediaWidthFromContainerStyle(string $style, int $mediaIndex): ?int + { + $declarations = $this->styleDeclarations($style); + $template = $this->normalizedCssValue((string) ($declarations['grid-template-columns'] ?? '')); + if ( '' === $template ) { + return null; + } + + $tracks = \Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssValueSplitter::splitTopLevelWhitespace($template); + if ( 2 !== count($tracks) ) { + return null; + } + + $mediaPercentage = $this->percentageValue($tracks[ $mediaIndex ]); + if ( null !== $mediaPercentage ) { + $otherTrack = $tracks[ 0 === $mediaIndex ? 1 : 0 ]; + if ( 'auto' === strtolower($otherTrack) || null !== $this->percentageValue($otherTrack) ) { + return $mediaPercentage; + } + } + + $firstFr = $this->frValue($tracks[0]); + $secondFr = $this->frValue($tracks[1]); + if ( null === $firstFr || null === $secondFr || 0.0 >= $firstFr + $secondFr ) { + return null; + } + + return (int) round(100 * ( 0 === $mediaIndex ? $firstFr : $secondFr ) / ($firstFr + $secondFr)); + } + + private function mediaWidthFromMediaStyle(string $style): ?int + { + $declarations = $this->styleDeclarations($style); + foreach ( array( 'flex-basis', 'width' ) as $property ) { + $value = $this->percentageValue($this->normalizedCssValue((string) ($declarations[ $property ] ?? ''))); + if ( null !== $value ) { + return $value; + } + } + return null; } + + private function verticalAlignmentFromStyle(string $style): ?string + { + $declarations = $this->styleDeclarations($style); + $alignItems = strtolower($this->normalizedCssValue((string) ($declarations['align-items'] ?? ''))); + + return array( + 'flex-start' => 'top', + 'center' => 'center', + 'flex-end' => 'bottom', + )[ $alignItems ] ?? null; + } + + private function normalizedCssValue(string $value): string + { + return trim(preg_replace('/\s*!\s*important\s*$/i', '', $value) ?? $value); + } + + private function percentageValue(string $value): ?int + { + if ( ! preg_match('/^(?:\d+(?:\.\d*)?|\.\d+)%$/', trim($value), $matches) ) { + return null; + } + + $percentage = (float) rtrim($matches[0], '%'); + if ( 0 > $percentage || 100 < $percentage ) { + return null; + } + + return (int) round($percentage); + } + + private function frValue(string $value): ?float + { + if ( ! preg_match('/^(?:\d+(?:\.\d*)?|\.\d+)fr$/i', trim($value), $matches) ) { + return null; + } + + return (float) substr($matches[0], 0, -2); + } } diff --git a/php-transformer/tests/unit/media-text-pattern.php b/php-transformer/tests/unit/media-text-pattern.php new file mode 100644 index 00000000..92e59d32 --- /dev/null +++ b/php-transformer/tests/unit/media-text-pattern.php @@ -0,0 +1,375 @@ +loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + $element = $document->getElementsByTagName($tagName)->item(0); + if ( ! $element instanceof DOMElement ) { + throw new RuntimeException('Fixture did not produce expected DOMElement.'); + } + + return $element; +}; +$htmlAttributes = static function (DOMElement $element): array { + $attributes = array(); + foreach ( $element->attributes ?? array() as $attribute ) { + $attributes[ $attribute->nodeName ] = $attribute->nodeValue ?? ''; + } + + return $attributes; +}; +$transformHtml = static function (string $html): array { + return ( new HtmlTransformer() )->transform($html)->toArray(); +}; + +$pattern = new MediaTextPattern(); + +// Frozen public callback contract. +$matchMethod = new ReflectionMethod(MediaTextPattern::class, 'match'); +$matchParameters = $matchMethod->getParameters(); +$assertSame( + array( 'element', 'fallbacks', 'convertChildren', 'presentationAttributes', 'mergedPresentationStyle', 'htmlAttributes', 'resolveAssetUrl', 'createBlock' ), + array_map(static fn (ReflectionParameter $parameter): string => $parameter->getName(), $matchParameters), + 'match callback parameter names remain frozen.' +); +$assertSame( + array( 'DOMElement', 'array', 'callable', 'callable', 'callable', 'callable', 'callable', 'callable' ), + array_map(static fn (ReflectionParameter $parameter): string => (string) $parameter->getType(), $matchParameters), + 'match callback parameter types remain frozen.' +); +$assertTrue($matchParameters[1]->isPassedByReference(), 'match fallbacks parameter remains passed by reference.'); +$assertSame('?array', (string) $matchMethod->getReturnType(), 'match nullable-array return type remains frozen.'); + +$heading = array( + 'blockName' => 'core/heading', + 'attrs' => array( 'content' => 'Build' ), + 'innerBlocks' => array(), +); +$paragraph = array( + 'blockName' => 'core/paragraph', + 'attrs' => array( 'content' => 'Ship' ), + 'innerBlocks' => array(), +); + +/** + * @param array> $convertedText + * @param array> $fallbacks + * @param array $record + * @param array $presentation + * @return array|null + */ +$match = static function ( + DOMElement $element, + array $convertedText, + array &$fallbacks, + array &$record, + array $presentation = array(), + bool $emitFallback = false, + bool $throwMediaStyle = false +) use ($pattern, $htmlAttributes): ?array { + $record = array( + 'convertCalls' => 0, + 'convertedTag' => null, + 'excluded' => null, + ); + + return $pattern->match( + $element, + $fallbacks, + static function (DOMElement $sourceElement, array &$sourceFallbacks, bool $captureUnsupported) use (&$record, $convertedText, $emitFallback): array { + ++$record['convertCalls']; + $record['convertedTag'] = strtolower($sourceElement->tagName); + if ( $emitFallback ) { + $sourceFallbacks[] = array( 'type' => 'html', 'reason' => 'unsupported_element' ); + } + return $convertedText; + }, + static function (DOMElement $sourceElement, array $excludedGeometryProperties) use (&$record, $presentation): array { + $record['excluded'] = $excludedGeometryProperties; + return $presentation; + }, + static function (DOMElement $sourceElement) use ($element, $throwMediaStyle): string { + if ( $throwMediaStyle && ! $sourceElement->isSameNode($element) ) { + throw new RuntimeException('media style unavailable'); + } + + return $sourceElement->getAttribute('style'); + }, + $htmlAttributes, + static fn (string $url): string => 'resolved:' . $url, + static fn (string $name, array $attrs, array $innerBlocks, ?DOMElement $sourceElement): array => array( + 'blockName' => $name, + 'attrs' => $attrs, + 'innerBlocks' => $innerBlocks, + ) + ); +}; + +// Media-left defaults omit position, width, and stacking attrs. +$fallbacks = array(); +$record = array(); +$mediaLeftElement = $elementFromHtml( + '
Left

Build

' +); +$mediaLeft = $match( + $mediaLeftElement, + array( $heading ), + $fallbacks, + $record, + array( 'className' => 'feature', 'layout' => array( 'type' => 'flex' ) ) +); +$assertSame('core/media-text', $mediaLeft['blockName'] ?? null, 'Media-left strict pair matches core/media-text.'); +$assertSame('image', $mediaLeft['attrs']['mediaType'] ?? null, 'Image media emits mediaType image.'); +$assertSame('resolved:left.jpg', $mediaLeft['attrs']['mediaUrl'] ?? null, 'Image src passes through asset resolver.'); +$assertSame('Left', $mediaLeft['attrs']['mediaAlt'] ?? null, 'Image alt passes through.'); +$assertTrue(! array_key_exists('mediaPosition', $mediaLeft['attrs'] ?? array()), 'Default left position is omitted.'); +$assertTrue(! array_key_exists('mediaWidth', $mediaLeft['attrs'] ?? array()), 'Default width is omitted.'); +$assertTrue(! array_key_exists('isStackedOnMobile', $mediaLeft['attrs'] ?? array()), 'Default mobile stacking is omitted.'); +$assertTrue(! array_key_exists('layout', $mediaLeft['attrs'] ?? array()), 'Consumed layout attr is removed.'); +$assertSame(array( $heading ), $mediaLeft['innerBlocks'] ?? null, 'Converted text side becomes innerBlocks.'); +$assertSame(1, $record['convertCalls'], 'Text side converts exactly once.'); +$assertSame('div', $record['convertedTag'], 'Only text child is converted.'); +$assertSame(array( 'display', 'grid-template-columns', 'align-items', 'gap' ), $record['excluded'], 'Presentation extraction excludes consumed geometry.'); + +// Media-right emits position and uses media track, not left track. +$fallbacks = array(); +$record = array(); +$mediaRightElement = $elementFromHtml( + '

Ship

' +); +$mediaRight = $match($mediaRightElement, array( $paragraph ), $fallbacks, $record); +$assertSame('right', $mediaRight['attrs']['mediaPosition'] ?? null, 'Second media child emits right position.'); +$assertSame(35, $mediaRight['attrs']['mediaWidth'] ?? null, 'Right media width derives from second grid track.'); +$assertSame('center', $mediaRight['attrs']['verticalAlignment'] ?? null, 'align-items center maps to center.'); + +// Video media consumes video without alt. +$fallbacks = array(); +$record = array(); +$videoElement = $elementFromHtml('

Watch

'); +$video = $match($videoElement, array( $paragraph ), $fallbacks, $record); +$assertSame('video', $video['attrs']['mediaType'] ?? null, 'Video media emits mediaType video.'); +$assertSame('resolved:clip.mp4', $video['attrs']['mediaUrl'] ?? null, 'Video src passes through asset resolver.'); +$assertTrue(! array_key_exists('mediaAlt', $video['attrs'] ?? array()), 'Video media omits mediaAlt.'); + +// Link wrapper attributes survive. +$fallbacks = array(); +$record = array(); +$linkedElement = $elementFromHtml( + '
Fallback

Open

' +); +$linked = $match($linkedElement, array( $paragraph ), $fallbacks, $record); +$assertSame('resolved:fallback.jpg', $linked['attrs']['mediaUrl'] ?? null, 'Picture uses img fallback src, not source srcset.'); +$assertSame('/full', $linked['attrs']['href'] ?? null, 'Link href passes through.'); +$assertSame('_blank', $linked['attrs']['linkTarget'] ?? null, 'Link target maps to linkTarget.'); +$assertSame('noopener', $linked['attrs']['rel'] ?? null, 'Link rel passes through.'); +$assertSame('zoom', $linked['attrs']['linkClass'] ?? null, 'Link class maps to linkClass.'); + +// Unsafe link destinations never reach media-text attrs. +foreach ( array( 'javascript:alert(1)', "/ok\x01bad" ) as $unsafeHref ) { + $fallbacks = array(); + $record = array(); + $unsafeLinkedElement = $elementFromHtml( + '
Safe

Safe copy

' + ); + $unsafeAnchor = $unsafeLinkedElement->getElementsByTagName('a')->item(0); + if ( ! $unsafeAnchor instanceof DOMElement ) { + throw new RuntimeException('Unsafe-link fixture did not produce anchor.'); + } + $unsafeAnchor->setAttribute('href', $unsafeHref); + $unsafeLinked = $match($unsafeLinkedElement, array( $paragraph ), $fallbacks, $record); + $assertTrue(! array_key_exists('href', $unsafeLinked['attrs'] ?? array()), 'Unsafe link href is omitted: ' . json_encode($unsafeHref)); + $assertSame('_blank', $unsafeLinked['attrs']['linkTarget'] ?? null, 'Unsafe href does not erase anchor target metadata.'); + $assertSame('noopener', $unsafeLinked['attrs']['rel'] ?? null, 'Unsafe href does not erase anchor rel metadata.'); + $assertSame('unsafe-link', $unsafeLinked['attrs']['linkClass'] ?? null, 'Unsafe href does not erase anchor class metadata.'); +} + +$unsafeLinkResult = $transformHtml( + '
Safe

Safe copy

' +); +$unsafeLinkBlock = $unsafeLinkResult['blocks'][0] ?? array(); +$assertSame('core/media-text', $unsafeLinkBlock['blockName'] ?? null, 'Unsafe linked media still converts with safe media URL.'); +$assertTrue(! array_key_exists('href', $unsafeLinkBlock['attrs'] ?? array()), 'Unsafe href is absent from emitted block attrs.'); +$assertTrue(! str_contains((string) ($unsafeLinkBlock['innerHTML'] ?? ''), 'javascript'), 'Unsafe href is absent from emitted markup.'); + +// Width derives from media-child flex-basis, then width, and from two fr tracks. +$fallbacks = array(); +$record = array(); +$flexBasisElement = $elementFromHtml('

Basis

'); +$flexBasis = $match($flexBasisElement, array( $paragraph ), $fallbacks, $record); +$assertSame(42, $flexBasis['attrs']['mediaWidth'] ?? null, 'Media flex-basis percentage derives width.'); + +$fallbacks = array(); +$record = array(); +$widthElement = $elementFromHtml('

Width

'); +$width = $match($widthElement, array( $paragraph ), $fallbacks, $record); +$assertSame(38, $width['attrs']['mediaWidth'] ?? null, 'Media width percentage rounds to nearest integer.'); + +// Media-child style resolution failures decline and discard local fallbacks. +$fallbacks = array( array( 'reason' => 'existing' ) ); +$record = array(); +$styleFailure = $match($mediaLeftElement, array( $heading ), $fallbacks, $record, array(), true, true); +$assertNull($styleFailure, 'Media-child style failure declines match.'); +$assertSame(array( array( 'reason' => 'existing' ) ), $fallbacks, 'Media-child style failure leaves host fallbacks unchanged.'); + +$fallbacks = array(); +$record = array(); +$frElement = $elementFromHtml('

Fr

'); +$fr = $match($frElement, array( $paragraph ), $fallbacks, $record); +$assertSame(40, $fr['attrs']['mediaWidth'] ?? null, 'Two fr tracks derive media share.'); + +// Vertical alignment mapping covers all core values. +foreach ( array( 'flex-start' => 'top', 'center' => 'center', 'flex-end' => 'bottom' ) as $alignItems => $expectedAlignment ) { + $fallbacks = array(); + $record = array(); + $alignmentElement = $elementFromHtml('

Align

'); + $alignment = $match($alignmentElement, array( $paragraph ), $fallbacks, $record); + $assertSame($expectedAlignment, $alignment['attrs']['verticalAlignment'] ?? null, 'align-items ' . $alignItems . ' maps to core value.'); +} + +// Media-side impurity declines before text conversion. +$fallbacks = array( array( 'reason' => 'existing' ) ); +$record = array(); +$captionElement = $elementFromHtml('
Caption

Copy

'); +$assertNull($match($captionElement, array( $paragraph ), $fallbacks, $record, array(), true), 'Figcaption makes media side impure.'); +$assertSame(0, $record['convertCalls'], 'Impure media declines before text conversion.'); +$assertSame(array( array( 'reason' => 'existing' ) ), $fallbacks, 'Impure decline leaves host fallbacks unchanged.'); + +// A second media-bearing pane is gallery-shaped, not text-bearing. +$fallbacks = array(); +$record = array(); +$ambiguousGalleryElement = $elementFromHtml( + '
C
D
D caption
', + 'div' +); +$assertNull($match($ambiguousGalleryElement, array( $paragraph ), $fallbacks, $record), 'Second pane with media descendant declines as ambiguous gallery.'); +$assertSame(0, $record['convertCalls'], 'Second media-bearing pane declines before text conversion.'); + +// Exactly three element children decline before conversion. +$fallbacks = array(); +$record = array(); +$threeChildrenElement = $elementFromHtml('

Copy

'); +$assertNull($match($threeChildrenElement, array( $paragraph ), $fallbacks, $record), 'Three element children decline.'); +$assertSame(0, $record['convertCalls'], 'Three-child decline avoids conversion.'); + +// Vertical flex declines after one conversion and discards local fallbacks. +$fallbacks = array( array( 'reason' => 'existing' ) ); +$record = array(); +$verticalElement = $elementFromHtml('

Stacked

'); +$assertNull($match($verticalElement, array( $paragraph ), $fallbacks, $record, array(), true), 'Vertical flex container declines.'); +$assertSame(1, $record['convertCalls'], 'Vertical gate runs after single text conversion.'); +$assertSame(array( array( 'reason' => 'existing' ) ), $fallbacks, 'Vertical decline discards text-side local fallbacks.'); + +// Converted text side must contain a recursive text-bearing block. +$fallbacks = array(); +$record = array(); +$imageOnly = array( + 'blockName' => 'core/group', + 'attrs' => array(), + 'innerBlocks' => array( array( 'blockName' => 'core/image', 'attrs' => array(), 'innerBlocks' => array() ) ), +); +$assertNull($match($mediaLeftElement, array( $imageOnly ), $fallbacks, $record), 'Non-text text side declines.'); +$assertSame(1, $record['convertCalls'], 'Non-text side converts once for gate and output reuse.'); + +// Underivable grid geometries omit mediaWidth. +foreach ( array( 'minmax(12rem,1fr) auto', '1fr 40%' ) as $gridTemplate ) { + $fallbacks = array(); + $record = array(); + $underivableElement = $elementFromHtml('

Unknown

'); + $underivable = $match($underivableElement, array( $paragraph ), $fallbacks, $record); + $assertTrue(! array_key_exists('mediaWidth', $underivable['attrs'] ?? array()), 'Underivable grid width is omitted: ' . $gridTemplate); +} + +// Text-side fallbacks push only after successful block creation. +$fallbacks = array( array( 'reason' => 'existing' ) ); +$record = array(); +$matchedWithFallback = $match($mediaLeftElement, array( $heading ), $fallbacks, $record, array(), true); +$assertSame('core/media-text', $matchedWithFallback['blockName'] ?? null, 'Text fallback does not suppress valid match.'); +$assertSame( + array( array( 'reason' => 'existing' ), array( 'type' => 'html', 'reason' => 'unsupported_element' ) ), + $fallbacks, + 'Matched text-side fallback pushes to host accumulator.' +); + +// Ladder fallthrough remains unchanged for strict declines. +$captionResult = $transformHtml('
Caption

Copy

'); +$assertSame('core/columns', $captionResult['blocks'][0]['blockName'] ?? null, 'Figcaption decline falls through to existing columns path.'); + +$ambiguousGalleryResult = $transformHtml( + '
C
D
D caption
' +); +$assertSame('core/gallery', $ambiguousGalleryResult['blocks'][0]['blockName'] ?? null, 'Two media-bearing figure panes remain core/gallery.'); + +$threeChildrenResult = $transformHtml('

Copy

'); +$assertSame('core/columns', $threeChildrenResult['blocks'][0]['blockName'] ?? null, 'Three-child decline falls through to existing columns path.'); + +$verticalResult = $transformHtml('

Stacked

'); +$assertTrue('core/media-text' !== ($verticalResult['blocks'][0]['blockName'] ?? null), 'Vertical flex decline never emits media-text.'); +$assertTrue('core/columns' !== ($verticalResult['blocks'][0]['blockName'] ?? null), 'Vertical flex decline keeps existing columns rejection.'); + +// Existing wp-block-media-text markup round-trips through same strict gate. +$roundTripResult = $transformHtml( + '
' + . '

Round trip

' + . '
Round
' + . '
' +); +$roundTrip = $roundTripResult['blocks'][0] ?? array(); +$assertSame('core/media-text', $roundTrip['blockName'] ?? null, 'wp-block-media-text markup passes strict round-trip gate.'); +$assertSame('right', $roundTrip['attrs']['mediaPosition'] ?? null, 'Round-trip DOM order restores right position.'); +$assertContains('has-media-on-the-right', (string) ($roundTrip['innerHTML'] ?? ''), 'Round-trip save shape restores right class.'); + +// Emitted media-text markup passes Runtime serialization validation. +$runtime = new Runtime(); +$serializedRoundTrip = $runtime->serializeBlocks(array( $roundTrip )); +$validity = $runtime->validateBlockSerialization($serializedRoundTrip); +$assertSame('pass', $validity['status'] ?? null, 'Emitted media-text markup passes serialization validity.'); + +if ( 0 === $failures ) { + echo "media text pattern ok\n"; +} + +exit(0 === $failures ? 0 : 1); From 17d053a5f0e5945312a4ca658f7c0418215eca1a Mon Sep 17 00:00:00 2001 From: Matthew Batchelder Date: Fri, 31 Jul 2026 12:08:13 -0400 Subject: [PATCH 04/11] test(php-transformer): cover media-text conversion --- php-transformer/CHANGELOG.md | 1 + php-transformer/composer.json | 2 + .../docs/html-transform-coverage.md | 3 +- .../fixtures/parity/html-media-text.json | 37 +++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 php-transformer/tests/fixtures/parity/html-media-text.json diff --git a/php-transformer/CHANGELOG.md b/php-transformer/CHANGELOG.md index 952cfccb..fbb73fcc 100644 --- a/php-transformer/CHANGELOG.md +++ b/php-transformer/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. Entries are ## [0.4.10] - 2026-07-28 ### Changed +- Convert strict two-pane media/text layouts to core/media-text - Preserve label value row geometry - Cover synthetic paragraph parity - Reset synthetic paragraph margins diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 1dab12f9..c230e645 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -66,6 +66,8 @@ "php tests/unit/background-image-extractor.php", "php tests/unit/cover-block-factory.php", "php tests/unit/cover-pattern.php", + "php tests/unit/media-text-block-factory.php", + "php tests/unit/media-text-pattern.php", "php tests/unit/cover-style-resolver.php", "php tests/unit/css-stylesheet-transformer.php", "php tests/unit/css-selector-matcher.php", diff --git a/php-transformer/docs/html-transform-coverage.md b/php-transformer/docs/html-transform-coverage.md index cb712656..5d8e7424 100644 --- a/php-transformer/docs/html-transform-coverage.md +++ b/php-transformer/docs/html-transform-coverage.md @@ -15,6 +15,7 @@ Run the coverage fixtures with `composer parity` or as part of `composer test`. | Code | `html-core-text-structure.json` | `core/code`, `core/preformatted` | | Tables | `html-core-media-actions.json` | `core/table` with head/body/caption attrs | | Images | `html-core-media-actions.json`, `html-figure-quote-media.json` | `core/image` with URL, alt, dimensions, caption, identity, size, and class attrs | +| Media and text | `html-media-text.json` | `core/media-text` for strict two-pane image/video and text layouts; ambiguous or impure media layouts continue through existing columns/group handling | | Buttons | `html-core-media-actions.json` | `core/buttons` containing `core/button` children | | Shortcodes | `html-core-media-actions.json` | `core/shortcode` for standalone shortcode text | | Wrapper provenance and safety | `html-provenance-wrapper-safety.json` | Presentational semantic wrappers are preserved as `core/group`; unsupported fallback records include selector/source metadata and sanitized fallback HTML | @@ -28,7 +29,7 @@ Run the coverage fixtures with `composer parity` or as part of `composer test`. | Category | Status | Notes | | --- | --- | --- | -| Supported | Heading, paragraph, unordered/ordered list, quote, pullquote, code, preformatted, table, image, buttons/button, shortcode | Fixtures assert the block names and representative attrs currently emitted by `HtmlTransformer`. | +| Supported | Heading, paragraph, unordered/ordered list, quote, pullquote, code, preformatted, table, image, media-text, buttons/button, shortcode | Fixtures assert the block names and representative attrs currently emitted by `HtmlTransformer`. Media-text requires exactly two element children: one pure image/video side and one text-bearing side; ambiguous layouts retain existing columns/group behavior. | | Unsupported fallback | Unknown/custom elements, SVG markup, form controls, other unsupported top-level HTML | Fallbacks use `type: unsupported_element`, include the source tag, selector, caller source/scope when provided, sanitized HTML, and increment `coverage.0.fallback_count`. | | Context-required | Interactive/form behavior, embeds, advanced layout semantics, raw-handler hooks | These require WordPress/Gutenberg runtime context or richer product converter behavior and remain outside the PHP transformer's supported slice. | | Gutenberg editor validation | Gap | The repository has no browser harness that boots Gutenberg, registers generated companion blocks, and validates load/edit/save output. `wp_block_validity` is a PHP structural and canonical save-shape check; the WordPress integration test and Playwright visual-parity tooling do not exercise the editor. | diff --git a/php-transformer/tests/fixtures/parity/html-media-text.json b/php-transformer/tests/fixtures/parity/html-media-text.json new file mode 100644 index 00000000..7c3203a0 --- /dev/null +++ b/php-transformer/tests/fixtures/parity/html-media-text.json @@ -0,0 +1,37 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "html-media-text", + "description": "Converts strict two-pane image/text and video/text layouts to native core/media-text blocks while preserving key media geometry and link attributes.", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/html-media-text.json", + "notes": "Covers a linked picture-backed image on the left and a video on the right; both layouts have exactly two element children, pure media subtrees, text-bearing content, and zero fallbacks." + }, + "legacy_comparison": { + "skip": true, + "reason": "This covers the PHP transformer's native core/media-text recognizer and canonical save shape." + }, + "operation": "html_transformer.transform", + "input": { + "content": "
\"Feature

Image feature

Linked media stays beside readable copy.

Video feature

Video renders on the right.

" + }, + "expected_blocks": [ + { "path": "blocks.0", "name": "core/media-text", "attrs": { "className": "feature-split", "mediaType": "image", "mediaUrl": "https://example.com/feature.jpg", "mediaAlt": "Feature view", "mediaWidth": 40, "verticalAlignment": "bottom", "href": "https://example.com/story", "linkTarget": "_blank", "rel": "noreferrer", "linkClass": "media-link" } }, + { "path": "blocks.0.innerBlocks.0", "name": "core/heading", "attrs": { "content": "Image feature", "level": 2 } }, + { "path": "blocks.0.innerBlocks.1", "name": "core/paragraph", "attrs": { "content": "Linked media stays beside readable copy." } }, + { "path": "blocks.1", "name": "core/media-text", "attrs": { "className": "video-split", "mediaPosition": "right", "mediaType": "video", "mediaUrl": "https://example.com/feature.mp4", "mediaWidth": 35, "verticalAlignment": "center" } }, + { "path": "blocks.1.innerBlocks.0", "name": "core/heading", "attrs": { "content": "Video feature", "level": 3 } }, + { "path": "blocks.1.innerBlocks.1", "name": "core/paragraph", "attrs": { "content": "Video renders on the right." } } + ], + "expected_fallbacks": [], + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "blocks", "assert": "count", "count": 2 }, + { "path": "blocks.0.innerBlocks", "assert": "count", "count": 2 }, + { "path": "blocks.1.innerBlocks", "assert": "count", "count": 2 }, + { "path": "fallbacks", "assert": "count", "count": 0 }, + { "path": "source_reports.conversion_report.metrics.fallback_count", "assert": "equals", "value": 0 }, + { "path": "coverage.0.fallback_count", "assert": "equals", "value": 0 }, + { "path": "serialized_blocks", "assert": "contains", "value": " null * | + * +-- strict media/layout gates pass? ---- no --> null + * | * +-- convert text child once * | * +-- text-bearing block? -------------- no --> null * | - * +-- vertical flex container? --------- yes -> null - * | * `-- core/media-text */ final class MediaTextPattern @@ -85,9 +85,9 @@ public function match( } $displayType = $this->containerDisplayType($containerStyle); + $flexDirection = strtolower($this->normalizedCssValue((string) ($this->styleDeclarations($containerStyle)['flex-direction'] ?? ''))); if ( - $this->isVerticalFlexContainer(strtolower($containerStyle)) - || ( 'flex' === $displayType && $this->styleValueEquals($containerStyle, 'flex-direction', 'row-reverse') ) + ( 'flex' === $displayType && in_array($flexDirection, array( 'column', 'column-reverse', 'row-reverse' ), true) ) || $this->styleValueEquals($containerStyle, 'direction', 'rtl') ) { return null; diff --git a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php index 8bddaa77..521cda52 100644 --- a/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php +++ b/php-transformer/src/HtmlToBlocks/Style/StyleResolutionTrait.php @@ -513,6 +513,15 @@ private function structuralPresentationDeclarations(DOMElement $element): array return $this->mergeCssDeclarationMaps($declarations, $this->cssDeclarations($this->attr($element, 'style'))); } + /** + * Resolve full authored layout style for media-text strict gates, including + * low-value direct children that general presentation resolution skips. + */ + private function mediaTextPresentationStyle(DOMElement $element): string + { + return $this->cssDeclarationString($this->structuralPresentationDeclarations($element)); + } + /** * Remove responsive/JS-revealed hidden base states (display:none / * visibility:hidden / opacity:0) from content-bearing or interactive @@ -993,6 +1002,7 @@ private function safeVisualDeclarations(array $declarations): array 'color', 'align-items', 'column-gap', + 'direction', 'display', 'flex-direction', 'flex', @@ -1020,6 +1030,7 @@ private function safeVisualDeclarations(array $declarations): array 'max-width', 'min-height', 'min-width', + 'order', 'padding', 'padding-bottom', 'padding-left', diff --git a/php-transformer/tests/unit/media-text-block-factory.php b/php-transformer/tests/unit/media-text-block-factory.php index fb113124..9c411efb 100644 --- a/php-transformer/tests/unit/media-text-block-factory.php +++ b/php-transformer/tests/unit/media-text-block-factory.php @@ -73,6 +73,7 @@ 'linkClass' => 'media-link', 'anchor' => 'feature', 'className' => 'promo', + 'backgroundColor' => 'accent', 'style' => array( 'color' => array( 'text' => '#123456' ), 'dimensions' => array( 'maxWidth' => '900px' ), @@ -81,7 +82,7 @@ 'inlineGeometryStyle' => 'min-height:30rem;aspect-ratio:16/9;max-width:900px;--media-ratio:1', ), array( $paragraph )); $assertSame( - '
', + '
', $right['innerContent'][0], 'Media-right opening carries position, stack, vertical, and width attributes.' ); @@ -92,9 +93,10 @@ ); $assertContains('grid-template-columns:auto 35%', $right['innerHTML'], 'Right media width targets second grid track.'); $assertContains('is-vertically-aligned-center', $right['innerHTML'], 'Vertical alignment class matches core save shape.'); -$assertContains('has-text-color', $right['innerHTML'], 'Style-derived support class survives wrapper style filtering.'); -$assertSame(array( 'padding' => array( 'top' => '2rem' ) ), $right['attrs']['style']['spacing'] ?? null, 'Unsupported blockGap is removed while supported spacing remains.'); -$assertSame(null, $right['attrs']['style']['dimensions']['maxWidth'] ?? null, 'Media-text comment attrs omit unsupported maxWidth.'); +$assertContains('has-accent-background-color has-background', $right['innerHTML'], 'Top-level preset support classes survive media-text style suppression.'); +$assertNotContains('has-text-color', $right['innerHTML'], 'Suppressed style attrs emit no ghost support class.'); +$assertSame('accent', $right['attrs']['backgroundColor'] ?? null, 'Top-level preset attr survives media-text style suppression.'); +$assertSame(null, $right['attrs']['style'] ?? null, 'Media-text comment attrs omit suppressed style object entirely.'); $assertNotContains('gap:', $right['innerHTML'], 'Unsupported blockGap emits no wrapper CSS.'); $assertNotContains('padding-top:', $right['innerHTML'], 'Supported comment attrs do not leak into media-text wrapper style.'); $assertNotContains('min-height:', $right['innerHTML'], 'Source min-height does not leak into media-text wrapper style.'); diff --git a/php-transformer/tests/unit/media-text-pattern.php b/php-transformer/tests/unit/media-text-pattern.php index 569eb0ce..2a116831 100644 --- a/php-transformer/tests/unit/media-text-pattern.php +++ b/php-transformer/tests/unit/media-text-pattern.php @@ -111,7 +111,8 @@ bool $emitFallback = false, bool $throwMediaStyle = false, ?callable $resolveMediaUrl = null, - bool $throwCreate = false + bool $throwCreate = false, + ?callable $resolvePresentationStyle = null ) use ($pattern, $htmlAttributes): ?array { $record = array( 'convertCalls' => 0, @@ -121,6 +122,7 @@ ); $resolveMediaUrl ??= static fn (string $url): string => '/resolved/' . ltrim($url, '/'); + $resolvePresentationStyle ??= static fn (DOMElement $sourceElement): string => $sourceElement->getAttribute('style'); return $pattern->match( $element, @@ -149,12 +151,12 @@ static function (DOMElement $sourceElement, array $excludedGeometryProperties) u $record['excluded'] = $excludedGeometryProperties; return $presentation; }, - static function (DOMElement $sourceElement) use ($element, $throwMediaStyle): string { + static function (DOMElement $sourceElement) use ($element, $throwMediaStyle, $resolvePresentationStyle): string { if ( $throwMediaStyle && ! $sourceElement->isSameNode($element) ) { throw new RuntimeException('media style unavailable'); } - return $sourceElement->getAttribute('style'); + return $resolvePresentationStyle($sourceElement); }, $htmlAttributes, $resolveMediaUrl, @@ -517,6 +519,49 @@ static function (string $name, array $attrs, array $innerBlocks, ?DOMElement $so $row = $match($rowElement, array( $paragraph ), $fallbacks, $record); $assertSame('core/media-text', $row['blockName'] ?? null, 'Normal flex row remains eligible.'); +// Authored direction/order rules reach strict gates for low-value direct children. +$fallbacks = array( array( 'reason' => 'existing' ) ); +$record = array(); +$authoredOrderElement = $elementFromHtml('

Ordered

'); +$authoredOrder = $match( + $authoredOrderElement, + array( $paragraph ), + $fallbacks, + $record, + array(), + true, + false, + null, + false, + static fn (DOMElement $sourceElement): string => str_contains(' ' . $sourceElement->getAttribute('class') . ' ', ' copy ') + ? 'order:2' + : $sourceElement->getAttribute('style') +); +$assertNull($authoredOrder, 'Authored child order declines match.'); +$assertSame(0, $record['convertCalls'], 'Authored child order declines before text conversion.'); +$assertSame(array( array( 'reason' => 'existing' ) ), $fallbacks, 'Authored child order leaves host fallbacks unchanged.'); + +$fallbacks = array( array( 'reason' => 'existing' ) ); +$record = array(); +$authoredRtlElement = $elementFromHtml('

RTL

'); +$authoredRtl = $match( + $authoredRtlElement, + array( $paragraph ), + $fallbacks, + $record, + array(), + true, + false, + null, + false, + static fn (DOMElement $sourceElement): string => str_contains(' ' . $sourceElement->getAttribute('class') . ' ', ' shell ') + ? 'display:grid;direction:rtl' + : $sourceElement->getAttribute('style') +); +$assertNull($authoredRtl, 'Authored container rtl declines match.'); +$assertSame(0, $record['convertCalls'], 'Authored container rtl declines before text conversion.'); +$assertSame(array( array( 'reason' => 'existing' ) ), $fallbacks, 'Authored container rtl leaves host fallbacks unchanged.'); + // Link-wrapped video is not representable by core/media-text save markup. $fallbacks = array( array( 'reason' => 'existing' ) ); $record = array(); @@ -583,13 +628,16 @@ static function (string $name, array $attrs, array $innerBlocks, ?DOMElement $so // Ladder fallthrough remains unchanged for strict declines. $geometryResult = $transformHtml( - '

Copy

' + '

Copy

' ); $geometryBlock = $geometryResult['blocks'][0] ?? array(); $geometryOpening = (string) ($geometryBlock['innerContent'][0] ?? ''); $geometryAssets = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $geometryResult['assets'] ?? array())); $assertSame('core/media-text', $geometryBlock['blockName'] ?? null, 'Grid geometry case emits media-text.'); -$assertTrue(! isset($geometryBlock['attrs']['style']['dimensions']['maxWidth']), 'Media-text attrs omit maxWidth.'); +$assertTrue(! isset($geometryBlock['attrs']['style']), 'Media-text attrs omit suppressed style object.'); +$assertSame('accent', $geometryBlock['attrs']['backgroundColor'] ?? null, 'Media-text preserves top-level preset attr.'); +$assertContains('has-accent-background-color has-background', $geometryOpening, 'Media-text preserves top-level preset classes.'); +$assertContains('be-inline-geometry-', (string) ($geometryBlock['attrs']['className'] ?? ''), 'Media-text preserves generated geometry carrier class.'); $assertContains('style="grid-template-columns:30% auto"', $geometryOpening, 'Media-text wrapper style contains grid tracks.'); foreach ( array( 'max-width', 'min-height', 'aspect-ratio', '--media-gap', 'padding-' ) as $leakedProperty ) { $assertTrue(! str_contains($geometryOpening, $leakedProperty), 'Media-text wrapper style omits source property: ' . $leakedProperty); @@ -601,6 +649,24 @@ static function (string $name, array $attrs, array $innerBlocks, ?DOMElement $so $rowReverseResult = $transformHtml('

Copy

'); $assertTrue('core/media-text' !== ($rowReverseResult['blocks'][0]['blockName'] ?? null), 'Flex row-reverse falls through without media-text.'); +$authoredOrderResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($authoredOrderResult['blocks'][0]['blockName'] ?? null), 'Authored .copy{order:2} declines media-text.'); + +$authoredRtlResult = $transformHtml('

Copy

'); +$assertTrue('core/media-text' !== ($authoredRtlResult['blocks'][0]['blockName'] ?? null), 'Authored .shell{display:grid;direction:rtl} declines media-text.'); + +$lastDisplayWinsResult = $transformHtml('

Copy

'); +$assertSame('core/media-text', $lastDisplayWinsResult['blocks'][0]['blockName'] ?? null, 'Last duplicate display declaration controls row-reverse gate.'); + +$lastFlexDirectionWinsResult = $transformHtml('

Copy

'); +$assertSame('core/media-text', $lastFlexDirectionWinsResult['blocks'][0]['blockName'] ?? null, 'Last duplicate flex-direction declaration controls row-reverse gate.'); + +$reviewerLastDisplayResult = $transformHtml('

Copy

'); +$assertSame('core/media-text', $reviewerLastDisplayResult['blocks'][0]['blockName'] ?? null, 'Last display:block declaration makes stale flex column inapplicable.'); + +$reviewerLastDirectionResult = $transformHtml('

Copy

'); +$assertSame('core/media-text', $reviewerLastDirectionResult['blocks'][0]['blockName'] ?? null, 'Last flex-direction:row declaration supersedes stale column.'); + $linkedVideoResult = $transformHtml('

Copy

'); $assertTrue('core/media-text' !== ($linkedVideoResult['blocks'][0]['blockName'] ?? null), 'Linked video falls through without media-text.'); From 1f66c534f5924409be18de7e27e7f86295b73748 Mon Sep 17 00:00:00 2001 From: Matthew Batchelder Date: Fri, 31 Jul 2026 13:10:22 -0400 Subject: [PATCH 08/11] fix(php-transformer): preserve media-text support styles --- .../src/HtmlToBlocks/BlockFactory.php | 27 ++++++++++++--- .../tests/unit/media-text-block-factory.php | 34 ++++++++++++++----- .../tests/unit/media-text-pattern.php | 11 ++++-- 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/BlockFactory.php b/php-transformer/src/HtmlToBlocks/BlockFactory.php index ebfd51f3..a3226c9d 100644 --- a/php-transformer/src/HtmlToBlocks/BlockFactory.php +++ b/php-transformer/src/HtmlToBlocks/BlockFactory.php @@ -77,7 +77,20 @@ private function normalizeAttrsForBlock(string $name, array $attrs): array } if ( 'core/media-text' === $name ) { - unset($attrs['style']); + $supportedStyleGroups = array( 'border', 'color', 'elements', 'spacing', 'typography' ); + if ( is_array($attrs['style'] ?? null) ) { + foreach ( array_keys($attrs['style']) as $styleGroup ) { + if ( ! in_array($styleGroup, $supportedStyleGroups, true) ) { + unset($attrs['style'][ $styleGroup ]); + } + } + if ( empty($attrs['style']) ) { + unset($attrs['style']); + } + } else { + unset($attrs['style']); + } + unset($attrs['inlineGeometryStyle']); } if ( 'core/separator' === $name ) { @@ -471,6 +484,9 @@ private function mediaTextHtml(array $attrs, array $innerBlocks): array if ( '' !== $verticalAlignment ) { $wrapperClasses[] = 'is-vertically-aligned-' . $verticalAlignment; } + if ( ! empty($attrs['style']['elements']['link']['color']) ) { + $wrapperClasses[] = 'has-link-color'; + } $wrapperAttrs = $attrs; $wrapperStyle = ''; @@ -922,9 +938,12 @@ private function blockSupportAttrs(array $attrs, string $baseClass = '', ?string $layoutClasses = $this->layoutClasses($attrs['layout'] ?? null, $baseClass); $alignmentClasses = $this->textAlignmentClasses($attrs); $classes = $this->mergeClassNames($baseClass, $presetClasses, $support['classes'], $layoutClasses, $alignmentClasses, (string) ($attrs['className'] ?? '')); - $style = null === $styleOverride - ? trim((string) $support['style'] . ';' . (string) ($attrs['inlineGeometryStyle'] ?? ''), ';') - : trim($styleOverride, ';'); + $style = trim( + (string) $support['style'] . ';' . (null === $styleOverride + ? (string) ($attrs['inlineGeometryStyle'] ?? '') + : $styleOverride), + ';' + ); return $this->htmlAttrs(array( 'id' => (string) ($attrs['anchor'] ?? ''), 'class' => $classes, diff --git a/php-transformer/tests/unit/media-text-block-factory.php b/php-transformer/tests/unit/media-text-block-factory.php index 9c411efb..48b4c56c 100644 --- a/php-transformer/tests/unit/media-text-block-factory.php +++ b/php-transformer/tests/unit/media-text-block-factory.php @@ -76,13 +76,18 @@ 'backgroundColor' => 'accent', 'style' => array( 'color' => array( 'text' => '#123456' ), - 'dimensions' => array( 'maxWidth' => '900px' ), + 'border' => array( 'radius' => '8px' ), + 'dimensions' => array( 'maxWidth' => '900px', 'minHeight' => '30rem' ), + 'elements' => array( 'link' => array( 'color' => array( 'text' => '#654321' ) ) ), + 'position' => array( 'type' => 'sticky', 'top' => '0px' ), 'spacing' => array( 'blockGap' => '2rem', 'padding' => array( 'top' => '2rem' ) ), + 'typography' => array( 'lineHeight' => '1.4' ), + '--media-ratio' => '1', ), 'inlineGeometryStyle' => 'min-height:30rem;aspect-ratio:16/9;max-width:900px;--media-ratio:1', ), array( $paragraph )); $assertSame( - '
', + '