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
5 changes: 2 additions & 3 deletions .github/workflows/php-transformer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,8 @@ jobs:
WORDPRESS_DEVELOP_DIR: ${{ runner.temp }}/wordpress-develop
WORDPRESS_TEST_DB: wordpress_site_plan_test
run: |
git clone --depth=1 --branch 6.7.4 https://github.com/WordPress/wordpress-develop.git "$WORDPRESS_DEVELOP_DIR"
composer config --working-dir="$WORDPRESS_DEVELOP_DIR" --json policy.advisories.ignore-id '{"PKSA-mh9b-91zm-m1gy":{"on-audit":false,"reason":"Pinned WordPress 6.7.4 test runtime; WPCS is not loaded by the integration test."},"PKSA-kh6k-gs3g-dgr6":{"on-audit":false,"reason":"Pinned WordPress 6.7.4 test runtime; PHPCSUtils is not loaded by the integration test."},"PKSA-rdkp-vv9z-mjkg":{"on-audit":false,"reason":"Pinned WordPress 6.7.4 test runtime; affected build tooling is not loaded by the integration test."},"PKSA-6vdd-n4sx-knhy":{"on-audit":false,"reason":"Pinned WordPress 6.7.4 test runtime; affected build tooling is not loaded by the integration test."}}'
composer require --working-dir="$WORDPRESS_DEVELOP_DIR" --dev phpcsstandards/phpcsutils:1.0.12 --no-update
git clone --depth=1 --branch 7.0.4 https://github.com/WordPress/wordpress-develop.git "$WORDPRESS_DEVELOP_DIR"
composer config --working-dir="$WORDPRESS_DEVELOP_DIR" --json policy.advisories.ignore-id '{"PKSA-mh9b-91zm-m1gy":{"on-audit":false,"reason":"Pinned WordPress 7.0.4 test runtime; WPCS is not loaded by the integration test."},"PKSA-kh6k-gs3g-dgr6":{"on-audit":false,"reason":"Pinned WordPress 7.0.4 test runtime; PHPCSUtils is not loaded by the integration test."},"PKSA-rdkp-vv9z-mjkg":{"on-audit":false,"reason":"Pinned WordPress 7.0.4 test runtime; affected build tooling is not loaded by the integration test."},"PKSA-6vdd-n4sx-knhy":{"on-audit":false,"reason":"Pinned WordPress 7.0.4 test runtime; affected build tooling is not loaded by the integration test."}}'
composer install --working-dir="$WORDPRESS_DEVELOP_DIR" --no-interaction --prefer-dist --no-progress
for attempt in $(seq 1 30); do
if mysql --host=127.0.0.1 --port=3306 --user=root --execute='SELECT 1'; then break; fi
Expand Down
2 changes: 1 addition & 1 deletion php-transformer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ Unsupported or unsafe artifact inputs are reported through diagnostics instead o

Run the package contract, parity fixtures, and clean package-install proof with `composer test`. The checked-in fixtures assert current transformer behavior, and the install proof verifies that Composer can install `automattic/blocks-engine-php-transformer` from the `php-transformer/` package root without symlinking back to the working tree.

Run the real WordPress materialization integration against a standard WordPress test-suite runtime with `REQUIRE_WP_TESTS=1 WP_TESTS_DIR=/path/to/wordpress-develop/tests/phpunit composer test:wordpress-integration`. Without `REQUIRE_WP_TESTS=1`, the command reports an explicit local skip when that runtime is unavailable. CI provisions WordPress `6.7.4`, MySQL `8.0.36`, and runs the required form of this command in `.github/workflows/php-transformer.yml`.
Run the real WordPress materialization integration against a standard WordPress test-suite runtime with `REQUIRE_WP_TESTS=1 WP_TESTS_DIR=/path/to/wordpress-develop/tests/phpunit composer test:wordpress-integration`. Without `REQUIRE_WP_TESTS=1`, the command reports an explicit local skip when that runtime is unavailable. CI provisions WordPress `7.0.4`, MySQL `8.0.36`, and runs the required form of this command in `.github/workflows/php-transformer.yml`.

## Release Consumption

Expand Down
18 changes: 11 additions & 7 deletions php-transformer/src/HtmlToBlocks/BlockFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -735,13 +735,11 @@ private function imageHtml(array $attrs): string
}

$imageAttrs = array(
'src' => $attrs['url'] ?? '',
'alt' => $attrs['alt'] ?? '',
'title' => $attrs['title'] ?? '',
'srcset' => $attrs['srcset'] ?? '',
'sizes' => $attrs['sizes'] ?? '',
'class' => $this->mergeClassNames(! empty($attrs['id']) ? 'wp-image-' . (string) $attrs['id'] : '', $borderSupport['classes']),
'style' => trim($this->imageDimensionStyle($attrs) . ';' . $borderSupport['style'], ';'),
'src' => $attrs['url'] ?? '',
'alt' => $attrs['alt'] ?? '',
'title' => $attrs['title'] ?? '',
'class' => $this->mergeClassNames(! empty($attrs['id']) ? 'wp-image-' . (string) $attrs['id'] : '', $borderSupport['classes']),
'style' => trim($this->imageDimensionStyle($attrs) . ';' . $borderSupport['style'], ';'),
);

$img = '<img' . $this->htmlAttrs($imageAttrs, array( 'alt' )) . '/>';
Expand Down Expand Up @@ -879,6 +877,12 @@ private function mediaHtml(string $tagName, array $attrs): string
'height' => (string) ($attrs['height'] ?? ''),
'controls' => ! empty($attrs['controls']) ? 'controls' : '',
);
if ( 'video' === $tagName ) {
$mediaAttrs['autoplay'] = ! empty($attrs['autoplay']) ? 'autoplay' : '';
$mediaAttrs['loop'] = ! empty($attrs['loop']) ? 'loop' : '';
$mediaAttrs['muted'] = ! empty($attrs['muted']) ? 'muted' : '';
$mediaAttrs['playsinline'] = ! empty($attrs['playsInline']) ? 'playsinline' : '';
}
$caption = ! empty($attrs['caption']) ? '<figcaption class="wp-element-caption">' . $this->preserveRichTextPunctuation((string) $attrs['caption']) . '</figcaption>' : '';

return '<figure' . $this->blockSupportAttrs($attrs, 'wp-block-' . $tagName) . '><' . $tagName . $this->htmlAttrs($mediaAttrs) . '></' . $tagName . '>' . $caption . '</figure>';
Expand Down
13 changes: 13 additions & 0 deletions php-transformer/src/HtmlToBlocks/FallbackDiagnostic.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,19 @@ private static function defaults(array $fields): array
'suggested_primitive' => 'image_asset',
'materialization_hint' => 'sanitize_svg_before_materializing_asset',
),
'html_responsive_image_fallback' => array(
'severity' => 'warning',
'conversion_classification' => 'editable_approximation',
'loss_class' => 'native_block_gap_preserved',
'diagnostic_class' => 'responsive_image_preserved',
'preservation_strategy' => 'sanitized_core_html',
'runtime_requirement' => 'none',
'recoverability' => 'recoverable_with_native_responsive_image_block_support',
'actionability' => 'retain_core_html_or_materialize_responsive_sources_as_media_attachments',
'suggested_repair_class' => 'preserve_responsive_image_markup',
'suggested_primitive' => 'core/html',
'materialization_hint' => 'preserve_picture_and_srcset_markup_until_core_image_can_serialize_the_source_selection',
),
'html_iframe_embed_fallback' => array(
'severity' => 'warning',
'conversion_classification' => 'runtime_island_preserved',
Expand Down
127 changes: 123 additions & 4 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,18 @@ final class HtmlTransformer

private readonly FallbackEmitter $fallbackEmitter;

/**
* Responsive image markup core/image cannot represent without invalidating
* its native save shape. Collected separately because image conversion is
* also used by pattern callbacks that do not receive the fallback accumulator.
*
* @var array<int, array<string, mixed>>
*/
private array $responsiveImageFallbacks = array();

/** @var array<string, bool> */
private array $responsiveImageFallbackSelectors = array();

/**
* @var array<string, string>
*/
Expand Down Expand Up @@ -591,6 +603,8 @@ public function transform(string $html, array $options = array()): TransformerRe
$this->formSelectBlockGenerated = false;
$this->formInputBlockGenerated = false;
$this->formControlEchoTexts = array();
$this->responsiveImageFallbacks = array();
$this->responsiveImageFallbackSelectors = array();
$this->generatedBlockNamespace = $this->generatedBlockNamespaceFromOptions($options);
$this->preserveShellLandmarks = !empty($options['extract_global_shell']);
$this->fallbackEmitter->resetGeneratedBlocks();
Expand Down Expand Up @@ -722,6 +736,7 @@ public function transform(string $html, array $options = array()): TransformerRe
$this->collectSupersededNavToggleSelectors($body);
$shellArtifacts = !array_key_exists('extract_global_shell', $options) || !empty($options['extract_global_shell']) ? $this->globalShellArtifacts($body, (string) ($options['source'] ?? 'html')) : array();
$blocks = $this->deduplicateNavigationBlocks($this->convertChildren($body, $fallbacks, true));
$fallbacks = array_merge($fallbacks, $this->responsiveImageFallbacks);
$this->recordRuntimeIslandsForPreservedHtmlBlocks($blocks);
$this->appendInteractiveControlBehaviorLossFallbacks($body, $fallbacks);
$this->appendProductGridFallbacks($body, $fallbacks, $blocks);
Expand Down Expand Up @@ -3038,11 +3053,18 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca

$this->captureDivBasedPseudoFormFallback($element, $fallbacks);

// A gallery can only contain native image blocks. Preserve the
// complete media collection before author-layout recognition can
// create a core/gallery with a responsive core/html child.
if ( $this->hasResponsiveImageSources($element) && $this->hasGalleryMediaItems($element) ) {
return $this->responsiveImageFallbackBlock($element);
}

if ( $this->isDirectChildOfAuthorOwnedLayout($element) && '' !== $this->attr($element, 'role') ) {
return $this->authorLayoutBlockFromElement($element, $fallbacks);
}

if ( in_array($tagName, array( 'div', 'section', 'article' ), true) ) {
if ( in_array($tagName, array( 'div', 'section', 'article' ), true) && ! $this->hasResponsiveImageSources($element) ) {
// A strict two-pane media/text candidate is a more specific
// recognition than generic author-owned layout preservation:
// media-text candidates are by definition authored flex/grid
Expand Down Expand Up @@ -3362,6 +3384,12 @@ private function mediaGalleryBlockFromElement(DOMElement $element): ?array
return null;
}

if ( $this->hasResponsiveImageSources($element) ) {
// GalleryPattern probes child conversions before it knows whether it
// has enough images. Avoid emitting speculative child fallbacks.
return $this->hasGalleryMediaItems($element) ? $this->responsiveImageFallbackBlock($element) : null;
}

return $this->galleryPattern->match(
$element,
fn (DOMElement $image, ?DOMElement $figure = null, ?DOMElement $picture = null, ?DOMElement $link = null): ?array => $this->convertImageElement($image, $figure, $picture, $link),
Expand Down Expand Up @@ -3404,6 +3432,28 @@ private function isGalleryCompatibleMediaLayout(DOMElement $element): bool
return true;
}

private function hasGalleryMediaItems(DOMElement $element): bool
{
$items = 0;
foreach ( $element->childNodes as $child ) {
if ( XML_TEXT_NODE === $child->nodeType && '' === trim($child->textContent ?? '') ) {
continue;
}
if ( ! $child instanceof DOMElement || 'figcaption' === strtolower($child->tagName) ) {
if ( ! $child instanceof DOMElement ) {
return false;
}
continue;
}
if ( ! in_array(strtolower($child->tagName), array( 'figure', 'img', 'picture' ), true) ) {
return false;
}
++$items;
}

return $items >= 2;
}

/**
* @return array<int, array<string, mixed>>
*/
Expand Down Expand Up @@ -11013,6 +11063,17 @@ private function convertMediaElement(DOMElement $element): ?array
'controls' => $element->hasAttribute('controls'),
)), static fn (mixed $value): bool => is_bool($value) ? $value : '' !== $value);

if ( 'video' === $tagName ) {
foreach ( array( 'autoplay', 'loop', 'muted' ) as $attribute ) {
if ( $element->hasAttribute($attribute) ) {
$attrs[$attribute] = true;
}
}
if ( $element->hasAttribute('playsinline') ) {
$attrs['playsInline'] = true;
}
}

return $this->createBlock('core/' . $tagName, $attrs, array(), $element);
}

Expand Down Expand Up @@ -11062,6 +11123,10 @@ private function convertPictureElement(DOMElement $picture, ?DOMElement $figure
return null;
}

if ( $this->hasResponsiveImageSources($picture) ) {
return $this->responsiveImageFallbackBlock($figure ?? $picture);
}

return $this->convertImageElement($image, $figure ?? $picture, $picture, $link);
}

Expand Down Expand Up @@ -11105,6 +11170,10 @@ private function isImageOnlyAnchor(DOMElement $anchor): bool

private function convertImageElement(DOMElement $image, ?DOMElement $figure = null, ?DOMElement $picture = null, ?DOMElement $link = null): ?array
{
if ( $this->hasResponsiveImageSources($picture ?? $image) ) {
return $this->responsiveImageFallbackBlock($figure ?? $picture ?? $image);
}

$originalUrl = $this->safeImageUrl($this->attr($image, 'src'));
$url = $this->resolvedAssetImageUrl($originalUrl);
if ( '' === $url ) {
Expand All @@ -11117,7 +11186,6 @@ private function convertImageElement(DOMElement $image, ?DOMElement $figure = nu
}
$width = $this->attr($image, 'width');
$height = $this->attr($image, 'height');
$sourceAttrs = $picture instanceof DOMElement ? $this->pictureSourceAttributes($picture) : array();
if ( '' !== $width || '' !== $height ) {
$attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), 'is-resized');
}
Expand All @@ -11126,8 +11194,6 @@ private function convertImageElement(DOMElement $image, ?DOMElement $figure = nu
'url' => $url,
'alt' => $this->attr($image, 'alt'),
'title' => $this->attr($image, 'title'),
'srcset' => $this->resolvedAssetImageSrcset('' !== $this->attr($image, 'srcset') ? $this->attr($image, 'srcset') : (string) ($sourceAttrs['srcset'] ?? '')),
'sizes' => '' !== $this->attr($image, 'sizes') ? $this->attr($image, 'sizes') : (string) ($sourceAttrs['sizes'] ?? ''),
'width' => $width,
'height' => $height,
)), static fn ($value): bool => '' !== $value);
Expand Down Expand Up @@ -11156,6 +11222,59 @@ private function convertImageElement(DOMElement $image, ?DOMElement $figure = nu
return $this->createBlock('core/image', $attrs, array(), $figure ?? $image);
}

private function hasResponsiveImageSources(DOMElement $element): bool
{
if ( 'img' === strtolower($element->tagName) ) {
return '' !== $this->attr($element, 'srcset') || '' !== $this->attr($element, 'sizes');
}

foreach ( $element->getElementsByTagName('source') as $source ) {
if ( $source instanceof DOMElement && '' !== $this->attr($source, 'srcset') ) {
return true;
}
}

foreach ( $element->getElementsByTagName('img') as $image ) {
if ( $image instanceof DOMElement && ( '' !== $this->attr($image, 'srcset') || '' !== $this->attr($image, 'sizes') ) ) {
return true;
}
}

return false;
}

/**
* Preserve responsive sources as valid raw HTML rather than placing
* unsupported attributes in a core/image save shape.
*
* @return array<string, mixed>
*/
private function responsiveImageFallbackBlock(DOMElement $element): array
{
$boundedHtml = $this->boundedFallbackHtml($this->safeFallbackHtml($element));
$selector = $this->elementSelector($element);
if ( ! isset($this->responsiveImageFallbackSelectors[$selector]) ) {
$this->responsiveImageFallbackSelectors[$selector] = true;
$this->responsiveImageFallbacks[] = FallbackDiagnostic::build(array(
'type' => 'html',
'reason' => 'responsive_image_fallback',
'diagnostic_code' => 'html_responsive_image_fallback',
'message' => 'Responsive image sources were preserved as sanitized core/html because core/image cannot serialize srcset, sizes, or picture source selection.',
'source_format' => 'html',
'tag' => strtolower($element->tagName),
'selector' => $selector,
'attributes' => $this->htmlAttributes($element),
'context' => $this->sourceContext($element),
'classification' => $this->fallbackEmitter->classifyFallbackSubtree($element),
'html' => $boundedHtml['html'],
'html_bytes' => $boundedHtml['bytes'],
'html_truncated' => $boundedHtml['truncated'],
), $this->fallbackProvenance);
}

return $this->createBlock('core/html', array( 'content' => $this->safeFallbackHtml($element) ), array(), $element);
}

/**
* @return array<string, string>
*/
Expand Down
48 changes: 48 additions & 0 deletions php-transformer/src/HtmlToBlocks/Support/DomHelpersTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ private function safeFallbackHtml(DOMElement $element): string
function (array $matches): string {
$attribute = strtolower($matches[1]);
$value = $matches[3] ?? $matches[4] ?? $matches[5] ?? '';
if ( 'srcset' === $attribute ) {
$srcset = $this->safeFallbackSrcset($value);
return '' === $srcset
? ''
: ' ' . $matches[1] . '="' . htmlspecialchars($srcset, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"';
}
return $this->isFallbackUrlAttribute($attribute) && ! $this->safeFallbackUrl($value, $attribute)
? ''
: $matches[0];
Expand Down Expand Up @@ -244,6 +250,48 @@ private function isFallbackUrlAttribute(string $attribute): bool
), true);
}

/**
* Keep only safe srcset candidates while retaining their source-selection
* descriptors. The URL policy deliberately matches fallback image `src`.
*/
private function safeFallbackSrcset(string $srcset): string
{
$candidates = array();
$length = strlen($srcset);
$offset = 0;

while ( $offset < $length ) {
while ( $offset < $length && ( ctype_space($srcset[$offset]) || ',' === $srcset[$offset] ) ) {
++$offset;
}
if ( $offset >= $length ) {
break;
}

$start = $offset;
$isDataUrl = str_starts_with(strtolower(substr($srcset, $offset)), 'data:');
while ( $offset < $length && ! ctype_space($srcset[$offset]) && ( $isDataUrl || ',' !== $srcset[$offset] ) ) {
++$offset;
}
$url = substr($srcset, $start, $offset - $start);

while ( $offset < $length && ctype_space($srcset[$offset]) ) {
++$offset;
}
$descriptorStart = $offset;
while ( $offset < $length && ',' !== $srcset[$offset] ) {
++$offset;
}
$descriptor = trim(substr($srcset, $descriptorStart, $offset - $descriptorStart));

if ( $this->safeFallbackUrl($url, 'src') ) {
$candidates[] = $url . ( '' !== $descriptor ? ' ' . $descriptor : '' );
}
}

return implode(', ', $candidates);
}

private function safeFallbackUrl(string $url, string $attribute): bool
{
$normalized = strtolower(preg_replace('/[\x00-\x20\x7f]+/', '', html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8')) ?? '');
Expand Down
Loading
Loading