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
2 changes: 1 addition & 1 deletion .github/workflows/solved-site-promotion-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,4 @@ jobs:
with:
static-site-importer-sha: ${{ github.event.pull_request.head.sha || github.sha }}
static-site-importer-repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
blocks-engine-sha: c47e806f9e758528b6d19cfda0bd3c20d46a6309
blocks-engine-sha: ae9716efb388ffa338ed0aa6d1b423f6eca3082c
7 changes: 4 additions & 3 deletions docs/companion-block-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Static Site Importer should use generated companion blocks only for generic
runtime/editor gaps that WordPress core blocks cannot model with stable,
editable Gutenberg markup. The Blocks Engine producer stays product-neutral: it
emits typed facts, diagnostics, and `source_reports.companion_plugin_payload`.
emits typed facts, diagnostics, and a `blocks-engine/wordpress-companion-plugin/v1`
payload under `source_reports.companion_plugin_payload`.
SSI owns WordPress runtime materialization: generated metadata blocks, provider
dependency checks, activation, and import diagnostics.

Expand All @@ -14,10 +15,10 @@ functional after Static Site Importer and Blocks Engine are removed.

The existing seam is enough for first implementations:

- Blocks Engine emits `static-site-importer/companion-plugin/v1` payloads under `source_reports.companion_plugin_payload`.
- Blocks Engine emits `blocks-engine/wordpress-companion-plugin/v1` payloads under `source_reports.companion_plugin_payload`.
- SSI materializes that payload as a generated plugin dependency under `companion_plugins.dependencies`.
- Typed payload blocks write `block.json`, render files, and declared assets; WordPress registers them from their block directories so `editorScript`, `style`, `editorStyle`, and `viewScript` file references resolve normally.
- Typed payload blocks may select an explicitly audited SSI-owned renderer by versioned identifier; producer-authored PHP and unknown renderers remain invalid.
- Typed payload blocks select producer-owned renderer capabilities by versioned identifier. SSI maps trusted capabilities to WordPress render templates through `static_site_importer_companion_renderers`; producer-authored PHP, unknown capabilities, and malformed registry entries remain invalid.
- Provider-backed features should continue to use SSI entity materializer adapters before falling back to a companion block.

## Candidate Blocks
Expand Down
1 change: 1 addition & 0 deletions homeboy-test-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"tests/smoke-materialized-block-content-validation.php": { "environment": "standalone-php" },
"tests/smoke-managed-classic-theme-materialization.php": { "environment": "standalone-php" },
"tests/smoke-playground-import-primitive.php": { "environment": "standalone-php" },
"tests/smoke-post-meta-rollback.php": { "environment": "standalone-php" },
"tests/smoke-plugin-materializer-lifecycle.php": { "environment": "standalone-php" },
"tests/smoke-product-handoff-contract.php": { "environment": "standalone-php" },
"tests/smoke-quality-budget-admission.php": { "environment": "standalone-php" },
Expand Down
222 changes: 214 additions & 8 deletions includes/class-static-site-importer-companion-plugin.php

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@
}

final class Static_Site_Importer_WordPress_Site_Plan_Materializer {
public const RECEIPT_SCHEMA = 'static-site-importer/materialization-receipt/v2';
private const RECONCILIATION_META_KEY = '_static_site_importer_reconciliation_identity';
private const BLOCK_PROVENANCE_LIMIT = 50;
public const RECEIPT_SCHEMA = 'static-site-importer/materialization-receipt/v2';
private const RECONCILIATION_META_KEY = '_static_site_importer_reconciliation_identity';
private const PRODUCER_RECONCILIATION_META_KEY = '_blocks_engine_reconciliation_identity';
private const BLOCK_PROVENANCE_LIMIT = 50;

/**
* Materialize a fully canonical v2 plan. Compilation and plan validation belong to Blocks Engine.
Expand Down Expand Up @@ -71,7 +72,7 @@ public static function materialize_prepared_lifecycle( array $prepared, array $l
)
);
}
$companion = self::materialize_companion_dependency( $companion_payload, $args, $page_ready );
$companion = self::materialize_companion_dependency( $companion_payload, $prepared, $page_ready );
if ( is_wp_error( $companion ) ) {
return $companion;
}
Expand Down Expand Up @@ -162,7 +163,8 @@ public static function materialize_prepared_lifecycle( array $prepared, array $l
}

/** Materialize the compiler-declared companion before provider dependencies. */
private static function materialize_companion_dependency( $payload, array $args, bool $page_ready ) {
private static function materialize_companion_dependency( $payload, array $prepared, bool $page_ready ) {
$args = is_array( $prepared['args'] ?? null ) ? $prepared['args'] : array();
if ( null === $payload ) {
return array(
'status' => 'skipped',
Expand All @@ -175,6 +177,7 @@ private static function materialize_companion_dependency( $payload, array $args,
'reason' => $page_ready ? 'page_ready_scope' : 'dependency_materialization_disabled',
);
}
$payload = self::resolve_companion_asset_references( $payload, $prepared['plan'] ?? array(), $prepared['resolved'] ?? array() );
$dependency = Static_Site_Importer_Entity_Materializer_Registry::companion_plugin_dependency( $payload );
$result = Static_Site_Importer_Entity_Materializer_Registry::materialize_companion_dependency( $dependency, ! empty( $args['overwrite'] ) );
if ( 'failed' === ( $result['status'] ?? '' ) ) {
Expand All @@ -184,6 +187,32 @@ private static function materialize_companion_dependency( $payload, array $args,
return $result;
}

/** Resolve browser-visible asset references carried by generated block renders. */
private static function resolve_companion_asset_references( array $payload, array $plan, array $resolved ): array {
$tokens = isset( $plan['reference_tokens'] ) && is_array( $plan['reference_tokens'] ) ? $plan['reference_tokens'] : array();
$theme_uri = isset( $resolved['resolution']['theme_uri'] ) && is_string( $resolved['resolution']['theme_uri'] ) ? $resolved['resolution']['theme_uri'] : '';
if ( empty( $tokens ) || '' === $theme_uri ) {
return $payload;
}

$entries = array_values( array_filter( $plan['pages'] ?? array(), static fn( mixed $page ): bool => is_array( $page ) && ! empty( $page['entrypoint'] ) ) );
$entry = $entries[0] ?? null;
$origin = is_array( $entry ) && is_string( $entry['source_path'] ?? null ) ? $entry['source_path'] : '';
$root = '' === $origin || '.' === dirname( $origin ) ? '' : trim( dirname( $origin ), '/' );
$canonicalizer = new \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\AssetReferenceCanonicalizer( $tokens, $root );
$references = WordPressSitePlanResolver::references( $tokens, $theme_uri );

foreach ( $payload['blocks'] ?? array() as $index => $block ) {
if ( ! is_array( $block ) || ! is_string( $block['render'] ?? null ) ) {
continue;
}
$canonical = $canonicalizer->content( $block['render'], $origin );
$payload['blocks'][ $index ]['render'] = WordPressSitePlanResolver::resolvePayload( $canonical, $references );
}

return $payload;
}

/** Public because checkpoint preparation provisions the same typed dependencies. */
public static function materialize_runtime_dependencies( array $lifecycle, array $args ) {
return Static_Site_Importer_Theme_Generator::materialize_prepared_dependencies( $lifecycle, $args );
Expand Down Expand Up @@ -525,9 +554,17 @@ public static function materialize_prepared( array $prepared ): array {
if ( empty( $page['planned_existing_id'] ) ) {
$state['rollback']['posts'][ $post ] = array( 'existing' => false );
}
$state['applied']['posts'][] = array(
'id' => $post,
'source_path' => $page['source_path'],
'reconciliation_identity' => $page['reconciliation_identity'],
);
$state['page_ids'][ $page['reconciliation_identity'] ] = $post;
$state['source_ids'][ $page['source_path'] ] = $post;
$materialized_markup = (string) ( $page['materialized_block_markup'] ?? $page['resolved_block_markup'] );
if ( ! self::write_post_meta( $post, self::RECONCILIATION_META_KEY, (string) $page['reconciliation_identity'] ) || ! self::write_post_meta( $post, self::PRODUCER_RECONCILIATION_META_KEY, (string) $page['reconciliation_identity'] ) ) {
return self::failed_receipt( $state, 'materialization_reconciliation_metadata_write_failed' );
}
$materialized_markup = (string) ( $page['materialized_block_markup'] ?? $page['resolved_block_markup'] );
update_post_meta(
$post,
'_static_site_importer_provenance',
Expand All @@ -541,11 +578,6 @@ public static function materialize_prepared( array $prepared ): array {
)
)
);
$state['applied']['posts'][] = array(
'id' => $post,
'source_path' => $page['source_path'],
'reconciliation_identity' => $page['reconciliation_identity'],
);
foreach ( $state['applied']['runtime_declarations']['entity_bindings'] as &$binding_report ) {
if ( ( $binding_report['source_path'] ?? '' ) === $page['source_path'] ) {
$fragment = (string) ( $binding_report['replacement_block_markup'] ?? '' );
Expand Down Expand Up @@ -969,10 +1001,15 @@ private static function materialize_page( array $page, array $source_ids, string
if ( is_wp_error( $id ) ) {
return $id;
}
update_post_meta( (int) $id, self::RECONCILIATION_META_KEY, $page['reconciliation_identity'] );
return (int) $id;
}

/** Persist and verify importer-owned post metadata. */
private static function write_post_meta( int $id, string $key, string $value ): bool {
update_post_meta( $id, $key, $value );
return metadata_exists( 'post', $id, $key ) && (string) get_post_meta( $id, $key, true ) === $value;
}

/** Resolve destination-independent route references after WordPress has assigned every permalink. */
private static function rewrite_materialized_route_links( array &$state ) {
$routes = array();
Expand Down Expand Up @@ -2049,10 +2086,12 @@ private static function journal_post( array &$state, array $page ): void {
$post = 0 < $id && function_exists( 'get_post' ) ? get_post( $id, ARRAY_A ) : null;
if ( $post ) {
$state['rollback']['posts'][ $id ] = array(
'existing' => true,
'post' => $post,
'provenance' => get_post_meta( $id, '_static_site_importer_provenance', true ),
'reconciliation_identity' => get_post_meta( $id, self::RECONCILIATION_META_KEY, true ),
'existing' => true,
'post' => $post,
'provenance' => get_post_meta( $id, '_static_site_importer_provenance', true ),
'reconciliation_identity' => get_post_meta( $id, self::RECONCILIATION_META_KEY, true ),
'producer_reconciliation_identity' => get_post_meta( $id, self::PRODUCER_RECONCILIATION_META_KEY, true ),
'producer_reconciliation_identity_exists' => metadata_exists( 'post', $id, self::PRODUCER_RECONCILIATION_META_KEY ),
);
return;
}
Expand Down Expand Up @@ -2134,10 +2173,12 @@ public static function journal_receipt_post( array &$receipt, int $id ): void {
$post = function_exists( 'get_post' ) ? get_post( $id, ARRAY_A ) : null;
if ( $post ) {
$receipt['transaction']->state['rollback']['posts'][ $id ] = array(
'existing' => true,
'post' => $post,
'provenance' => get_post_meta( $id, '_static_site_importer_provenance', true ),
'reconciliation_identity' => get_post_meta( $id, self::RECONCILIATION_META_KEY, true ),
'existing' => true,
'post' => $post,
'provenance' => get_post_meta( $id, '_static_site_importer_provenance', true ),
'reconciliation_identity' => get_post_meta( $id, self::RECONCILIATION_META_KEY, true ),
'producer_reconciliation_identity' => get_post_meta( $id, self::PRODUCER_RECONCILIATION_META_KEY, true ),
'producer_reconciliation_identity_exists' => metadata_exists( 'post', $id, self::PRODUCER_RECONCILIATION_META_KEY ),
);
}
}
Expand Down Expand Up @@ -2191,6 +2232,16 @@ private static function rollback( array &$state ): void {
wp_update_post( $before['post'] );
update_post_meta( $id, '_static_site_importer_provenance', $before['provenance'] );
update_post_meta( $id, self::RECONCILIATION_META_KEY, $before['reconciliation_identity'] );
if ( ! empty( $before['producer_reconciliation_identity_exists'] ) ) {
if ( ! self::write_post_meta( $id, self::PRODUCER_RECONCILIATION_META_KEY, (string) $before['producer_reconciliation_identity'] ) ) {
throw new RuntimeException( 'materialization_rollback_post_meta_restore_failed' );
}
} else {
delete_post_meta( $id, self::PRODUCER_RECONCILIATION_META_KEY );
if ( metadata_exists( 'post', $id, self::PRODUCER_RECONCILIATION_META_KEY ) ) {
throw new RuntimeException( 'materialization_rollback_post_meta_delete_failed' );
}
}
} elseif ( function_exists( 'wp_delete_post' ) && ! wp_delete_post( $id, true ) ) {
throw new RuntimeException( 'materialization_rollback_post_delete_failed' );
}
Expand Down
1 change: 1 addition & 0 deletions test-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
{ "path": "tests/smoke-materialized-block-content-validation.php", "environment": "standalone-php" },
{ "path": "tests/smoke-managed-classic-theme-materialization.php", "environment": "standalone-php" },
{ "path": "tests/smoke-playground-import-primitive.php", "environment": "standalone-php" },
{ "path": "tests/smoke-post-meta-rollback.php", "environment": "standalone-php" },
{ "path": "tests/smoke-plugin-materializer-lifecycle.php", "environment": "standalone-php" },
{ "path": "tests/smoke-product-handoff-contract.php", "environment": "standalone-php" },
{ "path": "tests/smoke-quality-budget-admission.php", "environment": "standalone-php" },
Expand Down
25 changes: 25 additions & 0 deletions tests/fixtures/busy-bears-responsive-layout-contract.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"schema": "static-site-importer/frozen-responsive-layout-contract/v1",
"source": "https://www.busybearscleaning.com/",
"captured_at": "2026-08-24",
"pages": [
{
"path": "website/contact/index.html",
"captured_layouts": [
{ "bytes": 14069, "sha256": "d39d2adaa5550e80d3d997239c4de20c8aadc0f570185f11d24049baa9274ffb" },
{ "bytes": 14091, "sha256": "317456fbc2718f8fc2230eba16cdb0f11bc603993109c0e0ebe1d90e775cc625" }
],
"content": "<section style=\"min-height:559.156px\"><p>If you have any inquiries, please fill out this form and our team will get back to you within 5 business days.</p><form aria-label=\"Contact\"><fieldset><label for=\"first-name\">First name*</label><input id=\"first-name\" name=\"first_name\" required><label for=\"details\">Details*</label><textarea id=\"details\" name=\"details\" required></textarea><button type=\"submit\">Submit</button></fieldset></form><svg viewBox=\"0 0 16 16\" role=\"img\" aria-label=\"Submit\"><path d=\"M2 8h12\"></path></svg></section>",
"expected_text": ["If you have any inquiries", "First name", "Details", "Submit"]
},
{
"path": "website/get-a-quote/index.html",
"captured_layouts": [
{ "bytes": 24873, "sha256": "141d79f79178bedf87ff50d28a416843cf7bbfea7bcfb37e5cfa35da6a311fba" },
{ "bytes": 24895, "sha256": "3b1b2d2cd4c8af7b29b8b9848343dc41235dfb4b8cf6dad4d56661b8a0d5e69e" }
],
"content": "<section style=\"min-height:1236.33px\"><h2>Request Your Custom Cleaning Quote</h2><p>Provide your home details below to receive a personalized estimate.</p><form><label for=\"address\">Address*</label><input id=\"address\" name=\"address\" required><label for=\"quote-details\">Details (please include the approximate square footage of the space you need cleaned) *</label><textarea id=\"quote-details\" name=\"details\" required></textarea><button type=\"submit\">Submit</button></form><h3>Why Choose Busy Bears</h3><svg viewBox=\"0 0 16 16\" aria-hidden=\"true\"><circle cx=\"8\" cy=\"8\" r=\"6\"></circle></svg></section>",
"expected_text": ["Request Your Custom Cleaning Quote", "Provide your home details", "Address", "approximate square footage", "Why Choose Busy Bears"]
}
]
}
Loading
Loading