diff --git a/.github/workflows/solved-fixture-regression.yml b/.github/workflows/solved-fixture-regression.yml index 95a387ed..60958d54 100644 --- a/.github/workflows/solved-fixture-regression.yml +++ b/.github/workflows/solved-fixture-regression.yml @@ -18,8 +18,8 @@ permissions: jobs: solved-site-promotion: - uses: Automattic/static-site-importer/.github/workflows/solved-site-promotion.yml@dbdf5413d34d8c55c034ee28a271c3ec8f53adf8 + uses: Automattic/static-site-importer/.github/workflows/solved-site-promotion.yml@2edd49e6e03696f8a147dbac0db17d871d9a5687 with: - static-site-importer-sha: dbdf5413d34d8c55c034ee28a271c3ec8f53adf8 + static-site-importer-sha: 2edd49e6e03696f8a147dbac0db17d871d9a5687 blocks-engine-sha: ${{ github.event.pull_request.head.sha || github.sha }} blocks-engine-repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} diff --git a/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php b/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php index 1d19d2ef..389291c3 100644 --- a/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php +++ b/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php @@ -4,16 +4,10 @@ namespace Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler; /** - * Producer for the companion-plugin payload consumed by Static Site Importer. + * Producer for the WordPress companion-plugin payload. * - * This is the producer half of the companion-plugin / plugin-materialization - * keystone (issue #491). Slice 1 (SSI #492) built the consumer: - * Static_Site_Importer_Companion_Plugin::scaffold() turns a payload into an - * installable, theme-independent plugin that houses generated custom blocks - * (registered from their own block.json) and preserved island JS. This class is - * the producer seam: it packages the generated block definitions the artifact - * already carries (block.json + render + view JS + assets) into a payload whose - * shape exactly matches what scaffold() consumes. + * Packages generated block definitions into a product-neutral payload that a + * WordPress materializer can turn into a theme-independent plugin. * * Contract (consumed by scaffold(), keys it reads): * - site_slug (string) per-site naming; SSI may override at install time. @@ -36,12 +30,9 @@ final class CompanionPluginPayload { /** - * Shared contract identifier. Mirrors the consumer schema declared by - * Static_Site_Importer_Companion_Plugin::PAYLOAD_SCHEMA so SSI can assert - * conformance. scaffold() does not require it, but stamping it makes the - * producer<->consumer contract explicit and greppable across repos. + * Product-neutral contract identifier owned by Blocks Engine. */ - public const SCHEMA = 'static-site-importer/companion-plugin/v1'; + public const SCHEMA = 'blocks-engine/wordpress-companion-plugin/v1'; /** * Build the companion-plugin payload from detected generated blocks. diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index fb8a0715..8b7d20be 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -15400,29 +15400,115 @@ private function responsiveMediaBlock(DOMElement $element): array /** @return array|null */ private function capturedMediaLayoutBoundaryBlock(DOMElement $element): ?array { - if ( 'main' !== strtolower($element->tagName) + if ( ! in_array(strtolower($element->tagName), array('main', 'article', 'section', 'div', 'figure'), true) || $this->isRuntimeDomTarget($element) || $this->hasRuntimeTargetInSubtree($element) || $this->hasLayoutGeometryProofInSubtree($element) || $this->sourceElementNestingDepth($element) <= self::MAX_CAPTURED_LAYOUT_SOURCE_NESTING || ! $this->hasCapturedMediaContent($element) + || ('main' !== strtolower($element->tagName) && '' === trim((string) $element->textContent)) ) { return null; } - if ( ! $this->responsiveMediaBlockGenerated ) { - $this->generatedBlocks[] = ( new ResponsiveMediaBlockGenerator() )->definition($this->generatedBlockNamespace); - $this->responsiveMediaBlockGenerated = true; + if ( ! $this->isStaticLayoutV1($element) ) { + return null; + } + + if ( ! $this->responsiveLayoutBlockGenerated ) { + $this->generatedBlocks[] = ( new ResponsiveLayoutBlockGenerator() )->definition($this->generatedBlockNamespace); + $this->responsiveLayoutBlockGenerated = true; } return $this->createBlock( - $this->generatedBlockNamespace . '/' . ResponsiveMediaBlockGenerator::LOCAL_NAME, - array( 'content' => $this->safeFallbackHtml($element), 'kind' => 'layout' ), + $this->generatedBlockNamespace . '/' . ResponsiveLayoutBlockGenerator::LOCAL_NAME, + array( 'content' => $this->staticLayoutHtml($element) ), array(), $element ); } + /** + * The responsive-layout renderer accepts this fixed static-layout-v1 + * subset. Keep admission narrower than fallback sanitizing + * so a captured layout never depends on stripped source semantics. + */ + private function isStaticLayoutV1(DOMElement $element): bool + { + $tags = array( + 'main', 'article', 'aside', 'section', 'header', 'footer', 'nav', 'div', 'figure', + 'figcaption', 'p', 'span', 'strong', 'em', 'b', 'i', 'small', 'br', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'button', 'ul', 'ol', 'li', + 'dl', 'dt', 'dd', 'picture', 'source', 'img', 'video', 'audio', + 'svg', 'defs', 'symbol', 'lineargradient', 'radialgradient', 'stop', 'clippath', + 'mask', 'use', 'g', 'path', 'circle', 'ellipse', 'line', 'polyline', 'polygon', + 'rect', 'text', 'tspan', 'title', 'desc', 'link', + ); + $globalAttributes = array( + 'class', 'id', 'role', 'title', 'style', 'tabindex', 'dir', 'lang', 'hidden', 'xml:lang', + 'aria-controls', 'aria-current', 'aria-describedby', 'aria-details', 'aria-expanded', + 'aria-hidden', 'aria-label', 'aria-labelledby', 'aria-live', + ); + $tagAttributes = array( + 'a' => array('download', 'href', 'target', 'rel'), + 'button' => array('disabled', 'name', 'type', 'value'), + 'img' => array('src', 'alt', 'width', 'height', 'loading', 'decoding', 'fetchpriority', 'longdesc', 'srcset', 'sizes', 'usemap'), + 'source' => array('src', 'srcset', 'sizes', 'media', 'type'), + 'video' => array('autoplay', 'controls', 'height', 'loop', 'muted', 'playsinline', 'poster', 'preload', 'src', 'width'), + 'audio' => array('autoplay', 'controls', 'loop', 'muted', 'preload', 'src'), + 'svg' => array('fill', 'stroke', 'viewbox', 'width', 'height', 'focusable', 'preserveaspectratio', 'xmlns', 'xmlns:xlink'), + 'symbol' => array('viewbox'), + 'lineargradient' => array('gradientunits', 'x1', 'x2', 'y1', 'y2'), + 'radialgradient' => array('cx', 'cy', 'r'), + 'stop' => array('offset', 'stop-color', 'stop-opacity'), + 'use' => array('href', 'xlink:href'), + 'g' => array('clip-path', 'fill', 'fill-opacity', 'opacity', 'stroke', 'stroke-width', 'transform'), + 'path' => array('d', 'fill', 'fill-rule', 'opacity', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'transform'), + 'circle' => array('cx', 'cy', 'r', 'fill', 'opacity', 'stroke', 'stroke-width'), + 'ellipse' => array('cx', 'cy', 'rx', 'ry', 'fill', 'opacity', 'stroke', 'stroke-width'), + 'line' => array('x1', 'x2', 'y1', 'y2', 'fill', 'stroke', 'stroke-width', 'stroke-linecap'), + 'polyline' => array('points', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin'), + 'polygon' => array('points', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin'), + 'rect' => array('x', 'y', 'width', 'height', 'rx', 'ry', 'fill', 'opacity', 'stroke', 'stroke-width'), + 'text' => array('fill', 'font-family', 'font-size', 'font-weight', 'text-anchor', 'x', 'y'), + 'tspan' => array('dx', 'dy', 'fill', 'x', 'y'), + 'link' => array('href', 'rel'), + ); + + foreach (array_merge(array($element), $this->descendantElements($element)) as $candidate) { + $tag = strtolower($candidate->tagName); + $customElement = (bool) preg_match('/^[a-z][a-z0-9]*-[a-z0-9-]+$/D', $tag); + if ( (! $customElement && ! in_array($tag, $tags, true)) || $this->isDeclaredRuntimeDomTarget($candidate) || array() !== $this->eventMetadata($candidate) ) { + return false; + } + if ('svg' === $tag && ! $this->isSafeSvgContent($this->outerHtml($candidate))) { + return false; + } + if ('link' === $tag && ('stylesheet' !== strtolower($this->attr($candidate, 'rel')) || ! $this->hasAncestorTag($candidate, array('defs')) || ! $this->hasAncestorTag($candidate, array('svg')) || ! $this->safeFallbackUrl($this->attr($candidate, 'href'), 'href'))) { + return false; + } + + $allowed = array_merge($globalAttributes, $tagAttributes[$tag] ?? array()); + foreach ($this->htmlAttributes($candidate) as $attribute => $value) { + $attribute = strtolower($attribute); + if ( (! str_starts_with($attribute, 'data-') && ! str_starts_with($attribute, 'aria-') && ! in_array($attribute, $allowed, true)) + || str_starts_with($attribute, 'data-wp-') + || ('srcset' === $attribute && $this->safeFallbackSrcset($value) !== $value) + || (in_array($attribute, array('href', 'src'), true) && ! $this->safeFallbackUrl($value, $attribute)) + ) { + return false; + } + } + } + + return true; + } + + private function staticLayoutHtml(DOMElement $element): string + { + return preg_replace('/]*\/?\s*>/i', '', $this->safeFallbackHtml($element)) ?? ''; + } + private function hasLayoutGeometryProofInSubtree(DOMElement $element): bool { $prefix = $this->elementSelector($element) . ' > '; @@ -15444,13 +15530,30 @@ private function hasCapturedMediaContent(DOMElement $element): bool private function hasRuntimeTargetInSubtree(DOMElement $element): bool { foreach ( $element->getElementsByTagName('*') as $descendant ) { - if ( $descendant instanceof DOMElement && $this->isRuntimeDomTarget($descendant) ) { + if ( $descendant instanceof DOMElement && $this->isDeclaredRuntimeDomTarget($descendant) ) { return true; } } return false; } + private function isDeclaredRuntimeDomTarget(DOMElement $element): bool + { + foreach (array_keys($this->runtimeDomSelectors) as $selector) { + if (str_starts_with((string) $selector, '#') && substr((string) $selector, 1) === $this->attr($element, 'id')) { + return true; + } + if (str_starts_with((string) $selector, '.') && in_array(substr((string) $selector, 1), $this->classNames($element), true)) { + return true; + } + if ($this->elementMatchesRuntimeSelector($element, (string) $selector)) { + return true; + } + } + + return false; + } + private function sourceElementNestingDepth(DOMElement $element): int { $depth = 0; diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformerSession.php b/php-transformer/src/HtmlToBlocks/HtmlTransformerSession.php index adb3b5be..ee5e60e7 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformerSession.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformerSession.php @@ -43,6 +43,7 @@ final class HtmlTransformerSession public bool $formSelectBlockGenerated = false; public bool $formInputBlockGenerated = false; public bool $responsiveMediaBlockGenerated = false; + public bool $responsiveLayoutBlockGenerated = false; public bool $authoredMarqueeBlockGenerated = false; public bool $emptyRuntimeTargetGenerated = false; public bool $capturedDialogBlockGenerated = false; diff --git a/php-transformer/src/HtmlToBlocks/ResponsiveLayoutBlockGenerator.php b/php-transformer/src/HtmlToBlocks/ResponsiveLayoutBlockGenerator.php new file mode 100644 index 00000000..435a9c36 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/ResponsiveLayoutBlockGenerator.php @@ -0,0 +1,65 @@ + */ + public function blockJson(string $namespace): array + { + return array( + 'apiVersion' => 3, + 'name' => $namespace . '/' . self::LOCAL_NAME, + 'title' => 'Responsive Layout', + 'category' => 'design', + 'description' => 'An editable captured static layout boundary.', + 'editorScript' => 'file:./index.js', + 'attributes' => array( + 'content' => array( 'type' => 'string', 'default' => '', 'role' => 'content' ), + ), + 'supports' => array( 'html' => false ), + ); + } + + /** @return array */ + public function assets(string $blockName): array + { + $script = <<<'JS' +( function( blocks, blockEditor, components, element ) { + var createElement = element.createElement; + function edit( props ) { + return createElement( 'div', blockEditor.useBlockProps(), createElement( components.TextareaControl, { + label: 'Captured layout HTML', + value: props.attributes.content || '', + onChange: function( content ) { props.setAttributes( { content: content } ); } + } ) ); + } + blocks.registerBlockType( '__BLOCK_NAME__', { + attributes: { content: { type: 'string', default: '', role: 'content' } }, + supports: { html: false }, + edit: edit, + save: function() { return null; } + } ); +} )( window.wp.blocks, window.wp.blockEditor, window.wp.components, window.wp.element ); +JS; + + return array( 'index.js' => str_replace('__BLOCK_NAME__', $blockName, $script) ); + } + + /** @return array */ + public function definition(string $namespace): array + { + return array( + 'name' => self::LOCAL_NAME, + 'block_json' => $this->blockJson($namespace), + 'renderer' => self::RENDERER, + 'assets' => $this->assets($namespace . '/' . self::LOCAL_NAME), + 'script_dependencies' => array( 'index.js' => array( 'wp-blocks', 'wp-block-editor', 'wp-components', 'wp-element' ) ), + ); + } +} diff --git a/php-transformer/src/HtmlToBlocks/ResponsiveMediaBlockGenerator.php b/php-transformer/src/HtmlToBlocks/ResponsiveMediaBlockGenerator.php index e88b40c1..03576a43 100644 --- a/php-transformer/src/HtmlToBlocks/ResponsiveMediaBlockGenerator.php +++ b/php-transformer/src/HtmlToBlocks/ResponsiveMediaBlockGenerator.php @@ -7,7 +7,7 @@ final class ResponsiveMediaBlockGenerator { public const LOCAL_NAME = 'responsive-media'; - public const RENDERER = 'static-site-importer/responsive-media/v1'; + public const RENDERER = 'blocks-engine/responsive-media/v1'; /** @return array */ public function blockJson(string $namespace): array diff --git a/php-transformer/src/HtmlToBlocks/Support/DomHelpersTrait.php b/php-transformer/src/HtmlToBlocks/Support/DomHelpersTrait.php index 39d3363e..4a24714f 100644 --- a/php-transformer/src/HtmlToBlocks/Support/DomHelpersTrait.php +++ b/php-transformer/src/HtmlToBlocks/Support/DomHelpersTrait.php @@ -251,6 +251,7 @@ private function materializeFallbackSourceTagMarker(DOMElement $element): void private function safeFallbackHtmlString(string $html): string { $html = preg_replace('@<(script|style)[^>]*?>.*?@si', '', $html) ?? ''; + $html = preg_replace('@]*\/?>@si', '', $html) ?? ''; $html = preg_replace('/\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $html) ?? ''; $html = preg_replace_callback( '/\s+([a-zA-Z_:][\w:.-]*)\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>]+))/i', diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 6a9938bd..ccdae85f 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -4270,7 +4270,7 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter )->toArray(); $companionPayload = $companion['source_reports']['companion_plugin_payload'] ?? null; $assert(is_array($companionPayload), 'companion_plugin_payload is emitted when a generated block is present'); -$assert('static-site-importer/companion-plugin/v1' === ($companionPayload['schema'] ?? ''), 'companion payload stamps the shared consumer schema'); +$assert('blocks-engine/wordpress-companion-plugin/v1' === ($companionPayload['schema'] ?? ''), 'companion payload stamps the producer-owned WordPress contract'); $assert('acme' === ($companionPayload['site_slug'] ?? ''), 'companion payload derives site_slug from the artifact'); $assert('Acme Co' === ($companionPayload['site_name'] ?? ''), 'companion payload derives site_name from the artifact'); $assert(array() === ($companionPayload['preserved_js'] ?? null), 'companion payload exposes an empty preserved_js slot'); diff --git a/php-transformer/tests/contract/staged-artifact-compilation.php b/php-transformer/tests/contract/staged-artifact-compilation.php index e2ab87be..fcb35e74 100644 --- a/php-transformer/tests/contract/staged-artifact-compilation.php +++ b/php-transformer/tests/contract/staged-artifact-compilation.php @@ -446,7 +446,7 @@ public function read(array $reference): string { $this->reads[] = $reference['id $layoutShared = $layoutCompiler->prepareShared($layoutArtifact); $layoutStaged = $layoutCompiler->compose($layoutShared, array($layoutCompiler->compilePage($layoutArtifact, $layoutShared, 'index.html')))->toArray(); $layoutPage = $layoutWhole['source_reports']['compiled_site']['pages'][0] ?? array(); -$assert('passed' === ($layoutWhole['source_reports']['editability_policy']['status'] ?? null) && str_contains((string) ($layoutPage['block_markup'] ?? ''), '"kind":"layout"'), 'A deep semantic media main compiles as one typed layout boundary under the unchanged editability policy.'); +$assert('passed' === ($layoutWhole['source_reports']['editability_policy']['status'] ?? null) && str_contains((string) ($layoutPage['block_markup'] ?? ''), '