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
43 changes: 43 additions & 0 deletions packages/blocks-engine/src/wp/__tests__/video-grid-layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { expect, it } from 'vitest';
import { chromium } from 'playwright';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';

const require = createRequire(import.meta.url) as { (id: string): any; resolve(id: string): string };
const root = fileURLToPath(new URL('../../../../../php-transformer/', import.meta.url));

it('retains source text/video grid geometry at 390, 768 and 1440px', async () => {
const source = readFileSync(`${root}/tests/fixtures/text-video-grid.html`, 'utf8');
const output = JSON.parse(execFileSync('php', ['-r', `
require 'vendor/autoload.php';
$r = (new Automattic\\BlocksEngine\\PhpTransformer\\HtmlToBlocks\\HtmlTransformer())->transform(file_get_contents('tests/fixtures/text-video-grid.html'))->toArray();
echo json_encode(['html' => $r['serialized_blocks'], 'css' => implode("\\n", array_column($r['assets'], 'content'))]);
`], { cwd: root, encoding: 'utf8' })) as { html: string; css: string };
const coreCss = readFileSync(require.resolve('@wordpress/block-library/build-style/style.css'), 'utf8');
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
for (const width of [390, 768, 1440]) {
await page.setViewportSize({ width, height: 1000 });
const measure = async (html: string) => {
await page.setContent(html);
return page.evaluate(() => ['h2', 'video'].map((selector) => {
const box = document.querySelector(selector)!.getBoundingClientRect();
return { x: box.x, y: box.y, width: box.width, height: box.height };
}));
};
const before = await measure(source);
const after = await measure(`<!doctype html><style>${coreCss}\n${output.css}</style>${output.html}`);
console.log(width, { source: before, imported: after });
for (const [index, box] of after.entries()) {
for (const key of ['x', 'y', 'width', 'height'] as const) {
expect(Math.abs(box[key] - before[index][key]), `${width}px ${index} ${key}`).toBeLessThan(1);
}
}
}
} finally {
await browser.close();
}
}, 30000);
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { setupDomGlobals } from '../dom-globals.js';

const require = createRequire(import.meta.url) as { (id: string): any; resolve(id: string): string };
type Block = { name: string; innerBlocks: Block[]; attributes: Record<string, unknown> };
type Wp = {
parse(markup: string): Block[];
validateBlock(block: Block): [boolean, unknown[]];
};
const transformer = fileURLToPath(new URL('../../../../../php-transformer/', import.meta.url));

describe('text/video author grid Gutenberg save validity', () => {
let wp: Wp;
beforeAll(() => {
setupDomGlobals();
wp = require('@wordpress/blocks') as Wp;
require('@wordpress/block-library').registerCoreBlocks();
});

it('detects the former media-text video attribute mismatch', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
try {
const blocks = wp.parse('<!-- wp:media-text {"mediaType":"video"} --><div class="wp-block-media-text is-stacked-on-mobile"><figure class="wp-block-media-text__media"><video controls src="clip.mp4" poster="poster.jpg" preload="metadata" loop="loop" muted="muted" playsinline="playsinline"></video></figure><div class="wp-block-media-text__content"></div></div><!-- /wp:media-text -->');
expect(wp.validateBlock(blocks[0])[0]).toBe(false);
} finally {
error.mockRestore();
warn.mockRestore();
}
});

it.each([true, false])('validates the public transformer output (controls=%s)', (controls) => {
const markup = execFileSync('php', ['-r', `
require 'vendor/autoload.php';
$html = file_get_contents('tests/fixtures/text-video-grid.html');
if (${controls ? 'false' : 'true'}) $html = str_replace(' controls', '', $html);
echo (new Automattic\\BlocksEngine\\PhpTransformer\\HtmlToBlocks\\HtmlTransformer())->transform($html)->toArray()['serialized_blocks'];
`], { cwd: transformer, encoding: 'utf8' });
const flatten = (blocks: Block[]): Block[] => blocks.flatMap((block) => [block, ...flatten(block.innerBlocks)]);
const blocks = flatten(wp.parse(markup));
expect(blocks.map((block) => wp.validateBlock(block)[0])).toEqual(blocks.map(() => true));
expect(blocks.some((block) => block.name === 'core/media-text')).toBe(false);
const video = blocks.find((block) => block.name === 'core/video');
expect(video?.attributes).toMatchObject({ src: 'clip.mp4', poster: 'poster.jpg', preload: 'metadata', controls, loop: true, muted: true, playsInline: true });
});
});
1 change: 1 addition & 0 deletions php-transformer/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@
"php tests/unit/embed-author-height-overrides-attribute.php",
"php tests/unit/media-text-block-factory.php",
"php tests/unit/video-element-conversion.php",
"php tests/unit/text-video-grid.php",
"php tests/contract/native-video-poster.php",
"php tests/contract/native-progress-meter.php",
"php tests/contract/small-fixed-size-image-beside-text.php",
Expand Down
54 changes: 3 additions & 51 deletions php-transformer/src/HtmlToBlocks/BlockFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -227,23 +227,6 @@ private function commentAttrs(string $name, array $attrs): array
// core's save() never reads it back, so it never belongs in the
// serialized comment.
unset($attrs['mediaFigureClassName']);
// Internal-only: consumed by mediaTextVideoAttrs() to build the
// video pane's <video> tag (dimensions, poster, native playback
// state). core/media-text's save() has no attribute for any of
// these — it renders a fixed `<video controls src={mediaUrl} />`
// — so none of them belong in the serialized comment.
foreach ( array(
'mediaVideoWidth',
'mediaVideoHeight',
'mediaVideoPoster',
'mediaVideoPreload',
'mediaVideoAutoplay',
'mediaVideoLoop',
'mediaVideoMuted',
'mediaVideoPlaysInline',
) as $internalKey ) {
unset($attrs[ $internalKey ]);
}
if ( '' === ($attrs['mediaAlt'] ?? null) ) {
unset($attrs['mediaAlt']);
}
Expand Down Expand Up @@ -666,7 +649,7 @@ private function mediaTextMediaHtml(array $attrs): string
{
$mediaUrl = (string) ($attrs['mediaUrl'] ?? '');
if ( 'video' === ($attrs['mediaType'] ?? '') ) {
return '<video controls' . $this->htmlAttrs($this->mediaTextVideoAttrs($attrs, $mediaUrl)) . '></video>';
return '<video controls' . $this->htmlAttrs(array('src' => $mediaUrl)) . '></video>';
}

if ( 'image' !== ($attrs['mediaType'] ?? '') ) {
Expand Down Expand Up @@ -694,38 +677,6 @@ private function mediaTextMediaHtml(array $attrs): string
)) . '>' . $image . '</a>';
}

/**
* core/media-text's save() has no block attribute for a video pane's
* intrinsic dimensions, poster, or native playback state — see the
* `mediaVideo*` capture in `MediaTextPattern::match()`. Project those
* internal-only facts onto the pane's `<video>` tag here.
*
* @param array<string, mixed> $attrs
* @return array<string, string>
*/
private function mediaTextVideoAttrs(array $attrs, string $mediaUrl): array
{
$videoAttrs = array(
'src' => $mediaUrl,
'poster' => (string) ($attrs['mediaVideoPoster'] ?? ''),
'preload' => (string) ($attrs['mediaVideoPreload'] ?? ''),
'width' => (string) ($attrs['mediaVideoWidth'] ?? ''),
'height' => (string) ($attrs['mediaVideoHeight'] ?? ''),
);
foreach ( array(
'mediaVideoAutoplay' => 'autoplay',
'mediaVideoLoop' => 'loop',
'mediaVideoMuted' => 'muted',
'mediaVideoPlaysInline' => 'playsinline',
) as $attributeName => $htmlName ) {
if ( ! empty($attrs[ $attributeName ]) ) {
$videoAttrs[ $htmlName ] = $htmlName;
}
}

return $videoAttrs;
}

/**
* Resolve the wrapper tag for a `core/group`. Core's group `save()` renders
* `<TagName>` from the `tagName` attribute, defaulting to `div`. Only the
Expand Down Expand Up @@ -990,7 +941,8 @@ private function mediaHtml(string $tagName, array $attrs): string
$mediaAttrs = array(
'src' => (string) ($attrs['src'] ?? ''),
'poster' => (string) ($attrs['poster'] ?? ''),
'preload' => (string) ($attrs['preload'] ?? ''),
// Core video omits its default preload value from save markup.
'preload' => 'video' === $tagName && 'metadata' === ($attrs['preload'] ?? '') ? '' : (string) ($attrs['preload'] ?? ''),
'width' => (string) ($attrs['width'] ?? ''),
'height' => (string) ($attrs['height'] ?? ''),
'controls' => ! empty($attrs['controls']) ? 'controls' : '',
Expand Down
52 changes: 6 additions & 46 deletions php-transformer/src/HtmlToBlocks/Patterns/MediaTextPattern.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*
* exactly two element children? -------- no --> null
* |
* +-- exactly one pure img/video side? -- no --> null
* +-- exactly one pure image side? ------ no --> null
* |
* +-- text side out of flow (overlay)? -- yes -> null
* |
Expand Down Expand Up @@ -124,7 +124,10 @@ public function match(
if ( 'img' === $mediaType && (new SourceElementClassifier())->hasResponsiveImageSources($resolution['media']) ) {
return null;
}
if ( 'video' === $mediaType && $resolution['anchor'] instanceof DOMElement ) {
// core/media-text cannot retain native video playback attributes or
// the source pane layout. Keep videos editable through core/video
// inside the ordinary source group layout instead.
if ( 'video' === $mediaType ) {
return null;
}

Expand Down Expand Up @@ -316,52 +319,9 @@ public function match(
}
unset($attrs['layout']);

$attrs['mediaType'] = 'img' === $mediaType ? 'image' : 'video';
$attrs['mediaType'] = 'image';
$attrs['mediaUrl'] = $mediaUrl;

// core/media-text's save() renders a video pane as a fixed
// `<video controls src={mediaUrl} />` — no attribute exists for
// intrinsic dimensions, poster, or native playback state. Without
// width/height the video collapses to the browser's 300x150 default
// intrinsic size before it can load, shifting every section below
// it. These facts still have to reach the saved `<video>` markup
// without becoming part of the serialized comment (core's save()
// never reads them back), so they use the same internal-only carrier
// shape as `mediaFigureClassName`: BlockFactory consumes the
// `mediaVideo*` keys to build the pane's `<video>` tag and strips
// them from the comment attrs before serialization.
if ( 'video' === $mediaType ) {
$videoAttrs = array_filter(array(
'mediaVideoWidth' => trim($this->attr($resolution['media'], 'width')),
'mediaVideoHeight' => trim($this->attr($resolution['media'], 'height')),
'mediaVideoPreload' => trim($this->attr($resolution['media'], 'preload')),
), static fn (string $value): bool => '' !== $value);
$attrs = array_merge($attrs, $videoAttrs);

$poster = trim($this->attr($resolution['media'], 'poster'));
if ( '' !== $poster ) {
try {
$posterUrl = $this->safeMediaUrl(trim($resolveAssetUrl($poster)));
} catch ( \Throwable ) {
$posterUrl = '';
}
if ( '' !== $posterUrl ) {
$attrs['mediaVideoPoster'] = $posterUrl;
}
}

foreach ( array(
'autoplay' => 'mediaVideoAutoplay',
'loop' => 'mediaVideoLoop',
'muted' => 'mediaVideoMuted',
'playsinline' => 'mediaVideoPlaysInline',
) as $sourceAttribute => $attributeName ) {
if ( $resolution['media']->hasAttribute($sourceAttribute) ) {
$attrs[ $attributeName ] = true;
}
}
}

// core/media-text's save() puts `className` on the outer wrapper
// `<div>` only — never on the generated `<figure
// class="wp-block-media-text__media">` pane. When the matched
Expand Down
5 changes: 5 additions & 0 deletions php-transformer/src/HtmlToBlocks/Style/EngineSupportCss.php
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ public function beforeAuthorCss(string $serializedBlocks): array
$parts[] = $emptyParagraph . '{line-height:0}'
. $emptyParagraph . '>[data-rich-text-placeholder]{display:none}';
}
if ( str_contains($serializedBlocks, SourceBlockAttributeProjector::SYNTHETIC_FILL_VIDEO_FIGURE_CLASS) ) {
// The injected figure must pass the source's percentage height
// through to its original containing block, not create an auto box.
$parts[] = ':root :where(figure.' . SourceBlockAttributeProjector::SYNTHETIC_FILL_VIDEO_FIGURE_CLASS . '){width:100%;height:100%;margin:0}';
}
if ( str_contains($serializedBlocks, '<video') ) {
$parts[] = 'video{max-width:100%}';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ final class SourceBlockAttributeProjector
public const SYNTHETIC_INLINE_IMAGE_FIGURE_CLASS = 'blocks-engine-synthetic-image-figure-inline';
public const SYNTHETIC_IMAGE_GAP_CLASS = 'blocks-engine-synthetic-image-gap';
public const SYNTHETIC_FILL_IMAGE_FIGURE_CLASS = 'blocks-engine-synthetic-image-figure-fill';
public const SYNTHETIC_FILL_VIDEO_FIGURE_CLASS = 'blocks-engine-synthetic-video-figure-fill';
public const SYNTHETIC_EMBED_FIGURE_CLASS = 'blocks-engine-synthetic-embed-figure';
public const CSS_OWNED_INLINE_FLOW_CLASS = 'blocks-engine-css-owned-inline-flow';
public const CSS_OWNED_LAYOUT_ITEM_CLASS = 'blocks-engine-css-owned-layout-item';
Expand Down Expand Up @@ -57,6 +58,7 @@ final class SourceBlockAttributeProjector
self::PROPAGATED_LINK_CLASS,
self::PROPAGATED_LINK_COLOR_CLASS,
),
'core/video' => array(self::SYNTHETIC_FILL_VIDEO_FIGURE_CLASS),
'core/image' => array(
self::SYNTHETIC_IMAGE_FIGURE_CLASS,
self::SYNTHETIC_FLEX_IMAGE_FIGURE_CLASS,
Expand Down Expand Up @@ -117,6 +119,13 @@ public function project(
$attrs['className'] = SourceDom::mergeClassNames((string) ($attrs['className'] ?? ''), self::SYNTHETIC_FILL_IMAGE_FIGURE_CLASS);
}
}
if ( 'core/video' === $name && 'video' === $sourceTagName ) {
$declarations = $this->styleResolver->resolvedPresentationDeclarations($sourceElement);
if ( '100%' === CssValueInspector::comparable((string) ($declarations['width'] ?? ''))
&& '100%' === CssValueInspector::comparable((string) ($declarations['height'] ?? '')) ) {
$attrs['className'] = SourceDom::mergeClassNames((string) ($attrs['className'] ?? ''), self::SYNTHETIC_FILL_VIDEO_FIGURE_CLASS);
}
}
if ( 'core/paragraph' === $name && $facts->isInlineSourceElement ) {
$attrs['className'] = SourceDom::mergeClassNames((string) ($attrs['className'] ?? ''), self::SYNTHETIC_PARAGRAPH_CLASS);
if ( 'a' === $sourceTagName && $this->sourceAnchorHasNoTextDecoration($sourceElement) ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,13 @@ public static function resolveMarkup(string $markup, array $references, array $a
foreach ($blocks as &$block) {
$attrs = &$block['attrs'];
$name = $block['blockName'];
$field = 'core/media-text' === $name ? 'mediaUrl' : 'url';
$field = 'core/media-text' === $name ? 'mediaUrl' : ('core/video' === $name ? 'src' : 'url');
$url = $attrs[$field] ?? '';
if ('core/image' === $name && preg_match('/<img\b[^>]*\bsrc=["\']([^"\']*)["\']/i', $block['innerHTML'], $match)) $url = html_entity_decode($match[1], ENT_QUOTES | ENT_HTML5);
if ('core/video' === $name && preg_match('/<video\b[^>]*\bsrc=["\']([^"\']*)["\']/i', $block['innerHTML'], $match)) $url = html_entity_decode($match[1], ENT_QUOTES | ENT_HTML5);
foreach ($attachments as $token => $attachment) {
$canonical = WordPressSitePlan::TOKEN_PREFIX . $token . '}}';
if ($url === $canonical && in_array($name, array('core/image', 'core/cover', 'core/media-text'), true)) {
if ($url === $canonical && in_array($name, array('core/image', 'core/cover', 'core/media-text', 'core/video'), true)) {
$attrs['core/media-text' === $name ? 'mediaId' : 'id'] = $attachment['id'];
$attrs[$field] = $canonical;
if ('core/image' === $name) {
Expand Down
13 changes: 7 additions & 6 deletions php-transformer/tests/fixtures/parity/html-media-text.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"schema": "blocks-engine/php-transformer/parity-fixture/v1",
"name": "html-media-text",
"description": "Converts video/text layouts to native core/media-text while preserving responsive picture image/text layouts through a generated companion block.",
"description": "Keeps video/text layouts in native groups with editable core/video while preserving responsive picture image/text layouts through a generated companion block.",
"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; responsive source selection remains intact through a generated block while the representable video layout remains native."
"notes": "Covers a linked picture-backed image on the left and a video on the right; responsive source selection remains intact through a generated block while the video retains its source layout."
},
"legacy_comparison": {
"skip": true,
Expand All @@ -20,9 +20,10 @@
{ "path": "blocks.0.innerBlocks.0", "name": "custom/responsive-media" },
{ "path": "blocks.0.innerBlocks.1.innerBlocks.0", "name": "core/heading", "attrs": { "content": "Image feature", "level": 2 } },
{ "path": "blocks.0.innerBlocks.1.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." } }
{ "path": "blocks.1", "name": "core/group" },
{ "path": "blocks.1.innerBlocks.0.innerBlocks.0", "name": "core/heading", "attrs": { "content": "Video feature", "level": 3 } },
{ "path": "blocks.1.innerBlocks.0.innerBlocks.1", "name": "core/paragraph", "attrs": { "content": "Video renders on the right." } },
{ "path": "blocks.1.innerBlocks.1", "name": "core/video", "attrs": { "src": "https://example.com/feature.mp4", "controls": false } }
],
"expected_fallbacks": [],
"expect": [
Expand All @@ -34,6 +35,6 @@
{ "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": "https://example.com/feature.webp" },
{ "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:media-text" }
{ "path": "serialized_blocks", "assert": "contains", "value": "<!-- wp:video" }
]
}
Loading
Loading