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
4 changes: 2 additions & 2 deletions .github/workflows/solved-fixture-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
19 changes: 5 additions & 14 deletions php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
117 changes: 110 additions & 7 deletions php-transformer/src/HtmlToBlocks/HtmlTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15400,29 +15400,115 @@ private function responsiveMediaBlock(DOMElement $element): array
/** @return array<string, mixed>|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('/<link\b[^>]*\/?\s*>/i', '', $this->safeFallbackHtml($element)) ?? '';
}

private function hasLayoutGeometryProofInSubtree(DOMElement $element): bool
{
$prefix = $this->elementSelector($element) . ' > ';
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);

namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks;

/** Builds the bounded companion block for captured static layout markup. */
final class ResponsiveLayoutBlockGenerator
{
public const LOCAL_NAME = 'responsive-layout';
public const RENDERER = 'blocks-engine/responsive-layout/v1';

/** @return array<string, mixed> */
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<string, string> */
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<string, mixed> */
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' ) ),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> */
public function blockJson(string $namespace): array
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ private function materializeFallbackSourceTagMarker(DOMElement $element): void
private function safeFallbackHtmlString(string $html): string
{
$html = preg_replace('@<(script|style)[^>]*?>.*?</\\1>@si', '', $html) ?? '';
$html = preg_replace('@<link\b[^>]*\/?>@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',
Expand Down
2 changes: 1 addition & 1 deletion php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? ''), '<!-- wp:custom/responsive-layout {"content":'), 'A deep semantic media main compiles as one dedicated typed layout boundary under the unchanged editability policy.');
$assert($canonical($layoutWhole) === $canonical($layoutStaged), 'Typed captured layout boundaries preserve direct and staged canonical equivalence.');

fwrite(STDOUT, "Staged artifact compilation contract passed\n");
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
},
"expect": [
{ "path": "status", "assert": "equals", "value": "success" },
{ "path": "source_reports.companion_plugin_payload.schema", "assert": "equals", "value": "static-site-importer/companion-plugin/v1" },
{ "path": "source_reports.companion_plugin_payload.schema", "assert": "equals", "value": "blocks-engine/wordpress-companion-plugin/v1" },
{ "path": "source_reports.companion_plugin_payload.site_slug", "assert": "equals", "value": "acme-co" },
{ "path": "source_reports.companion_plugin_payload.blocks", "assert": "count", "count": 1 },
{ "path": "source_reports.companion_plugin_payload.blocks.0.name", "assert": "equals", "value": "collection-42ba83f51ffea3a7df69ca6f8c29d50848fd6a809917172303a2c79f3802c9bd" },
Expand Down
Loading
Loading