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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);

namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements;

use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\LiveClockBlockGenerator;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Generators\MotionSequenceBlockGenerator;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Session\HtmlTransformerSession;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\SourceBlockCreator;
use DOMElement;

/** Lowers independently authored portable runtime markers to editable blocks. */
final class AuthoredMotionMarkerConverter
{
public function __construct(private readonly HtmlTransformerSession $session, private readonly SourceBlockCreator $createBlock)
{
}

/** @return array<string, mixed>|null */
public function convert(DOMElement $element): ?array
{
if ('span' !== strtolower($element->tagName) || !$element->hasAttribute('hidden') || '' !== trim($element->textContent ?? '')) return null;
foreach (array(
'data-blocks-engine-motion-steps' => array(MotionSequenceBlockGenerator::class, 'steps', 'array'),
'data-blocks-engine-live-clock' => array(LiveClockBlockGenerator::class, 'config', 'object'),
) as $attribute => $spec) {
if (!$element->hasAttribute($attribute)) continue;
$raw = $element->getAttribute($attribute);
if (strlen($raw) > 8192) return null;
$value = json_decode($raw, true);
if (!is_array($value) || ('array' === $spec[2] && (!array_is_list($value) || count($value) > 8)) || ('object' === $spec[2] && array_is_list($value))) return null;
$registry = $this->session->generatedBlockRegistry();
if (null === $registry) throw new \LogicException('Generated block registry has not been prepared.');
$generator = new $spec[0]();
$name = $generator::LOCAL_NAME;
$registry->register($spec[0], $generator->definition($registry->blockName($name)));
$json = json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$block = $this->createBlock->createBlock($registry->blockName($name), array($spec[1] => $value), array(), $element);
$block['innerHTML'] = '<span hidden ' . $attribute . '="' . htmlspecialchars($json, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"></span>';
$block['innerContent'] = array($block['innerHTML']);
return $block;
}
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,12 @@ public function recordBlockRuntimeDomContract(DOMElement $element, string $block

public function canRetainRuntimeDomContractNatively(DOMElement $element, string $blockName): bool
{
// The authored hidden marker is the exact save() DOM contract for these
// companion blocks. A native editable marker is not a runtime island.
if ('span' === strtolower($element->tagName) && $element->hasAttribute('hidden') && (
($blockName === $this->context->generatedBlockName('live-clock') && $element->hasAttribute('data-blocks-engine-live-clock'))
|| ($blockName === $this->context->generatedBlockName('motion-sequence') && $element->hasAttribute('data-blocks-engine-motion-steps'))
)) return true;
if ( ! in_array($blockName, array('core/group', 'core/paragraph', 'core/heading'), true) ) {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ public function runtimeSelectors(): RuntimeSelectorState
return $this->session->runtimeSelectorState();
}

public function generatedBlockName(string $localName): string
{
return $this->session->generatedBlockRegistry()?->blockName($localName) ?? '';
}

/**
* @return iterable<DOMElement>
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,18 @@ public function convert(DOMElement $canvas): array
$value = trim($canvas->getAttribute($name));
if (preg_match('/^[1-9][0-9]{0,4}$/', $value)) $attrs[$name] = $value;
}
// Authored, bounded portable effects are the same native editor settings
// as a manually configured canvas. Preserve them so source/runtime DOM
// dependencies still point at the generated editable block markup.
if ('particle-ripple' === $canvas->getAttribute('data-blocks-engine-canvas-effect')) {
$attrs['effect'] = 'particle-ripple';
foreach (array('data-blocks-engine-canvas-spacing' => array('particleSpacing', 10, 300), 'data-blocks-engine-canvas-size' => array('particleSize', 0.5, 10)) as $name => $config) {
$value = trim($canvas->getAttribute($name));
if (is_numeric($value) && (float) $value >= $config[1] && (float) $value <= $config[2]) $attrs[$config[0]] = $value;
}
$color = trim($canvas->getAttribute('data-blocks-engine-canvas-color'));
if (preg_match('/^#[0-9a-f]{6}$/i', $color)) $attrs['particleColor'] = $color;
}
$fallback = trim($canvas->textContent ?? '');
if ('' !== $fallback) $attrs['fallbackText'] = mb_substr($fallback, 0, 1024);
$block = $this->createBlock->createBlock($registry->blockName(self::LOCAL_NAME), $attrs, array(), $canvas);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ function select( selector ) {
if ( typeof selector !== 'string' || selector.length > 120 ) return null;
try { return document.querySelector( selector ); } catch ( error ) { return null; }
}
function textTarget( selector ) {
var element = select( selector );
if ( ! element ) return null;
if ( ! element.children.length ) return element;
// A native paragraph may carry the saved editable text inside the
// source-addressable wrapper. Animate that leaf, never the wrapper.
var child = element.firstElementChild;
return element.children.length === 1 && child.tagName === 'P' && ! child.children.length ? child : null;
}
function bounded( value, fallback, max ) {
if ( value === undefined || value === null || value === '' ) return fallback;
var number = Number( value );
Expand All @@ -78,7 +87,7 @@ function mount( marker ) {
var timezone = select( config.timezoneSelector );
if ( ! hours || ! minutes || ! timezone || hours.children.length || minutes.children.length || timezone.children.length ) return;
var ampm = select( config.ampmSelector );
var date = select( config.dateSelector );
var date = textTarget( config.dateSelector );
var colon = select( config.colonSelector );
var trigger = select( config.triggerSelector );
var ripple = select( config.rippleSelector );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ function select( selector ) {
if ( typeof selector !== 'string' || selector.length > 120 ) return null;
try { return document.querySelector( selector ); } catch ( error ) { return null; }
}
function textTarget( selector ) {
var element = select( selector );
if ( ! element ) return null;
if ( ! element.children.length ) return element;
var child = element.firstElementChild;
return element.children.length === 1 && child.tagName === 'P' && ! child.children.length ? child : null;
}
function bounded( value, fallback, min, max ) {
var number = Number( value );
return Number.isFinite( number ) && number >= min && number <= max ? number : fallback;
Expand All @@ -84,8 +91,8 @@ function mount( marker ) {
if ( ! Array.isArray( input ) ) return;
var steps = input.slice( 0, 8 ).map( function( item ) {
if ( ! item || typeof item !== 'object' ) return null;
var target = select( item.selector );
if ( ! target || target.children.length > 0 ) return null;
var target = textTarget( item.selector );
if ( ! target ) return null;
var text = target.textContent || '';
if ( text.length > 500 ) return null;
return {
Expand Down
4 changes: 4 additions & 0 deletions php-transformer/src/HtmlToBlocks/HtmlCompilation.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements\ButtonLinkDispatcher;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements\ButtonLinkLeftovers;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements\AuthoredFormControlBlockConverter;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements\AuthoredMotionMarkerConverter;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements\FormControlMetadataBuilder;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements\FormCompositionPlanner;
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Elements\FormDispatchContext;
Expand Down Expand Up @@ -2993,6 +2994,9 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca
{
$tagName = strtolower($element->tagName);

$authoredMotion = (new AuthoredMotionMarkerConverter($this->session, $this))->convert($element);
if (null !== $authoredMotion) return $authoredMotion;

$prelude = $this->elementPrelude->convert($element, $tagName, $fallbacks, $captureUnsupported);
if ( $prelude->handled ) {
return $prelude->block;
Expand Down
22 changes: 22 additions & 0 deletions php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -5011,6 +5011,28 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter
$assert('Drawing Surface' === ($drawingBlock['block_json']['title'] ?? '') && 'file:./view.js' === ($drawingBlock['block_json']['viewScript'] ?? '') && str_contains((string) ($drawingBlock['view_js'] ?? ''), 'pointermove'), 'native drawing surface ships its own frontend effect via the existing companion view-script contract');
$assert(!str_contains($decorativeCanvasMarkup, 'data-blocks-engine-canvas-effect'), 'unconfigured source canvases remain inert when converted');

$authoredCanvasSite = $compiler->compile(array(
'entrypoint' => 'index.html',
'files' => array(
'index.html' => '<main><canvas id="stage" data-blocks-engine-canvas-effect="particle-ripple" data-blocks-engine-canvas-spacing="90" data-blocks-engine-canvas-size="1.2" data-blocks-engine-canvas-color="#000000"></canvas><script src="motion.js"></script></main>',
'motion.js' => 'document.querySelectorAll(\'canvas[data-blocks-engine-canvas-effect="particle-ripple"]\').forEach(function(canvas){canvas.getContext(\'2d\');});',
),
))->toArray();
$authoredCanvasMarkup = (string) ($authoredCanvasSite['serialized_blocks'] ?? '');
$assert(str_contains($authoredCanvasMarkup, 'data-blocks-engine-canvas-effect="particle-ripple"') && str_contains($authoredCanvasMarkup, 'data-blocks-engine-canvas-spacing="90"'), 'authored portable ripple settings survive as native editable canvas block attributes');
$assert(array() === array_values(array_filter($authoredCanvasSite['diagnostics'] ?? array(), static fn (array $diagnostic): bool => 'runtime_dependency_contract_failed' === ($diagnostic['code'] ?? ''))), 'authored runtime canvas selector remains present in generated block markup');

$portableMarkerSite = $compiler->compile(array(
'entrypoint' => 'index.html',
'files' => array(
'index.html' => '<main><p id="message">Editable</p></main><span hidden data-blocks-engine-motion-steps="[{&quot;selector&quot;:&quot;#message&quot;}] "></span><span hidden data-blocks-engine-live-clock="{&quot;hourSelector&quot;:&quot;#hour&quot;}"></span><script src="motion.js"></script>',
'motion.js' => 'document.querySelectorAll("[data-blocks-engine-motion-steps]"); document.querySelectorAll("[data-blocks-engine-live-clock]");',
),
))->toArray();
$portableMarkup = (string) ($portableMarkerSite['serialized_blocks'] ?? '');
$assert(str_contains($portableMarkup, '/motion-sequence') && str_contains($portableMarkup, '/live-clock'), 'author-provided inert motion markers lower to native editable companion blocks');
$assert(array() === ($portableMarkerSite['source_reports']['runtime_islands'] ?? array()), 'native marker save markup fulfills authored script targets without runtime islands');

$motionFixture = array(
'site' => array('name' => 'Motion Fixture', 'slug' => 'motion-fixture'),
'entrypoint' => 'index.html',
Expand Down
Loading