diff --git a/php-transformer/src/HtmlToBlocks/Elements/AuthoredMotionMarkerConverter.php b/php-transformer/src/HtmlToBlocks/Elements/AuthoredMotionMarkerConverter.php
new file mode 100644
index 000000000..293cab0b3
--- /dev/null
+++ b/php-transformer/src/HtmlToBlocks/Elements/AuthoredMotionMarkerConverter.php
@@ -0,0 +1,45 @@
+|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'] = '';
+ $block['innerContent'] = array($block['innerHTML']);
+ return $block;
+ }
+ return null;
+ }
+}
diff --git a/php-transformer/src/HtmlToBlocks/Elements/RuntimeIslandAnalyzer.php b/php-transformer/src/HtmlToBlocks/Elements/RuntimeIslandAnalyzer.php
index c96ffc6ec..a103a422b 100644
--- a/php-transformer/src/HtmlToBlocks/Elements/RuntimeIslandAnalyzer.php
+++ b/php-transformer/src/HtmlToBlocks/Elements/RuntimeIslandAnalyzer.php
@@ -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;
}
diff --git a/php-transformer/src/HtmlToBlocks/Elements/RuntimeIslandContext.php b/php-transformer/src/HtmlToBlocks/Elements/RuntimeIslandContext.php
index 15a775c6e..3101909e2 100644
--- a/php-transformer/src/HtmlToBlocks/Elements/RuntimeIslandContext.php
+++ b/php-transformer/src/HtmlToBlocks/Elements/RuntimeIslandContext.php
@@ -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
*/
diff --git a/php-transformer/src/HtmlToBlocks/Generators/CanvasBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/CanvasBlockGenerator.php
index 6a6afa434..a8d58eb36 100644
--- a/php-transformer/src/HtmlToBlocks/Generators/CanvasBlockGenerator.php
+++ b/php-transformer/src/HtmlToBlocks/Generators/CanvasBlockGenerator.php
@@ -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);
diff --git a/php-transformer/src/HtmlToBlocks/Generators/LiveClockBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/LiveClockBlockGenerator.php
index 6b651a796..ac5b2df37 100644
--- a/php-transformer/src/HtmlToBlocks/Generators/LiveClockBlockGenerator.php
+++ b/php-transformer/src/HtmlToBlocks/Generators/LiveClockBlockGenerator.php
@@ -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 );
@@ -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 );
diff --git a/php-transformer/src/HtmlToBlocks/Generators/MotionSequenceBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/MotionSequenceBlockGenerator.php
index a96c5ed27..5dd5448ef 100644
--- a/php-transformer/src/HtmlToBlocks/Generators/MotionSequenceBlockGenerator.php
+++ b/php-transformer/src/HtmlToBlocks/Generators/MotionSequenceBlockGenerator.php
@@ -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;
@@ -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 {
diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php
index f39e95ad8..7bbc4268f 100644
--- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php
+++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php
@@ -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;
@@ -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;
diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php
index a99cabaa8..158910bc4 100644
--- a/php-transformer/tests/contract/run.php
+++ b/php-transformer/tests/contract/run.php
@@ -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' => '',
+ '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' => 'Editable
',
+ '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',