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',