diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index e7acff61..3bad2069 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -77,14 +77,20 @@ public function compile(array $artifact): TransformerResult ); $sourceReports['compiled_site'] = $this->compiledSiteReport($normalized, $entryPath, $documents['documents'], $assets, $blockTypes, $serializedBlocks); $sourceReports['materialization_plan'] = ( new MaterializationPlanBuilder() )->fromCompiledSite($sourceReports['compiled_site']); - $companionPluginPayload = $companionPluginPayloadBuilder->fromBlockTypes($blockTypes, $normalized['files'], $artifact, $entryBlocks['generated_blocks']); + // Build the generic runtime-island package first so the product-named + // companion-plugin payload can map preserved island JS into its + // preserved_js slot (issue #488). The package is the engine-neutral feed; + // CompanionPluginPayload owns the SSI-named projection. + $runtimeIslandPackage = array() !== $entryBlocks['runtime_islands'] + ? ( new RuntimeIslandPackageBuilder() )->fromRuntimeIslands($entryBlocks['runtime_islands'], $normalized['files'], $entryPath) + : array(); + $companionPluginPayload = $companionPluginPayloadBuilder->fromBlockTypes($blockTypes, $normalized['files'], $artifact, $entryBlocks['generated_blocks'], $runtimeIslandPackage); if ( array() !== $companionPluginPayload ) { $sourceReports['companion_plugin_payload'] = $companionPluginPayload; } $sourceReports['runtime_dependency_parity'] = ( new RuntimeDependencyParityReport() )->fromArtifact($normalized['files'], $html, $serializedBlocks, $entryPath, $entryBlocks['runtime_islands'], $referenceReports['asset_references'], $entryBlocks['interaction_candidates']); if ( array() !== $entryBlocks['runtime_islands'] ) { $sourceReports['runtime_islands'] = $entryBlocks['runtime_islands']; - $runtimeIslandPackage = ( new RuntimeIslandPackageBuilder() )->fromRuntimeIslands($entryBlocks['runtime_islands'], $normalized['files'], $entryPath); if ( array() !== $runtimeIslandPackage ) { $sourceReports['runtime_island_package'] = $runtimeIslandPackage; } diff --git a/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php b/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php index 18420a30..8861e1ce 100644 --- a/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php +++ b/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php @@ -20,8 +20,19 @@ * - site_name (string) optional human-readable name; defaults to slug. * - mu_plugin (bool) optional; emit as a must-use loader. * - blocks[] (array) each: name, block_json, render, view_js, assets{}. - * - preserved_js[] (array) each: content, handle, src, block. Slot for the - * JS->plugin wire-up (SSI #488); empty for now. + * - preserved_js[] (array) verbatim island JS projected from the generic + * runtime-island package (SSI #488). Two shapes: + * block-scoped entries carry content, handle, src, block + * and are enqueued via render_block when that block renders; + * site-wide entries carry content, handle, src, scope='site', + * order (no `block` key) and are enqueued for the whole site. + * Free-standing behavior islands with no generated-block + * owner take the site-wide shape so their JS lands instead + * of being parked. + * - preserved_js_deferred[] (array) each: content, handle, src, reason. Island JS + * whose owning block exists but was not packaged into this + * payload (owner_block_not_packaged) — a genuine anomaly, + * surfaced rather than silently dropped or guessed. * * When the artifact carries no custom blocks (mapping still prefers * core/Automattic blocks with a core/html fallback, and no subtree qualified for @@ -48,9 +59,10 @@ final class CompanionPluginPayload * @param array> $files Normalized artifact files (carry content). * @param array $artifact Raw artifact envelope (for site identity). * @param array> $generatedBlocks Dynamic blocks generated at core/html fallbacks (issue #497). + * @param array $runtimeIslandPackage Generic runtime-island package feed (issue #488). * @return array Empty array when there are no generated blocks. */ - public function fromBlockTypes(array $blockTypes, array $files, array $artifact, array $generatedBlocks = array()): array + public function fromBlockTypes(array $blockTypes, array $files, array $artifact, array $generatedBlocks = array(), array $runtimeIslandPackage = array()): array { $blocks = array(); $seenNames = array(); @@ -88,13 +100,19 @@ public function fromBlockTypes(array $blockTypes, array $files, array $artifact, return array(); } + $preserved = $this->preservedJs($runtimeIslandPackage, $blocks, $this->blockNamespace($artifact)); + $payload = array( 'schema' => self::SCHEMA, 'blocks' => $blocks, - // Slot for preserved island JS. Populated by the JS->plugin wire-up - // (SSI #488); empty here so the producer seam exists today. - 'preserved_js' => array(), + // Preserved island JS projected from the generic runtime-island package: + // block-scoped to its owning generated block, or site-wide for free-standing + // behavior islands with no generated-block owner (SSI #488). + 'preserved_js' => $preserved['preserved_js'], ); + if ( array() !== $preserved['deferred'] ) { + $payload['preserved_js_deferred'] = $preserved['deferred']; + } $siteSlug = $this->siteSlug($artifact); if ( '' !== $siteSlug ) { @@ -111,6 +129,126 @@ public function fromBlockTypes(array $blockTypes, array $files, array $artifact, return $payload; } + /** + * Project preserved island JS from the generic runtime-island package into the + * scaffold's preserved_js contract (issue #488), gated by the + * preserve-vs-rebuild signal (issue #224). + * + * An island captured at custom-block generation carries the owning block's + * fully-qualified name (`owner_block`). When that block ships in this payload the + * island is emitted block-scoped, and the consumer enqueues its JS via render_block + * only when that block renders. Free-standing islands (canvas, form, and standalone + * scripts elsewhere in the DOM) have no generated-block owner; they are promoted to + * site-wide entries (`scope='site'`, no `block`, deterministic `order`) so the + * consumer enqueues them for the whole site instead of parking the JS. The narrow + * anomaly where an owner_block is named but was not packaged is still deferred + * (owner_block_not_packaged) rather than guessed into a site-wide scope. + * Telemetry/droppable and external-unmaterialized scripts carry no verbatim JS body, + * so they contribute neither an entry nor a deferral. + * + * @param array $runtimeIslandPackage Generic runtime-island package. + * @param array> $blocks Packaged companion blocks. + * @param string $blockNamespace Per-site block namespace (`ssi-`). + * @return array{preserved_js: array>, deferred: array>} + */ + private function preservedJs(array $runtimeIslandPackage, array $blocks, string $blockNamespace): array + { + $islands = is_array($runtimeIslandPackage['islands'] ?? null) ? $runtimeIslandPackage['islands'] : array(); + if ( array() === $islands ) { + return array( 'preserved_js' => array(), 'deferred' => array() ); + } + + // Fully-qualified names of the generated blocks this payload actually + // packages, so an island scope is only honored when its owning block ships. + $ownedBlocks = array(); + if ( '' !== $blockNamespace ) { + foreach ( $blocks as $block ) { + $name = (string) ($block['name'] ?? ''); + if ( '' !== $name ) { + $ownedBlocks[$blockNamespace . '/' . $name] = true; + } + } + } + + $preserved = array(); + $deferred = array(); + foreach ( $islands as $index => $island ) { + if ( ! is_array($island) ) { + continue; + } + // Preserve-vs-rebuild gate (#224): only verbatim-preserve islands carry JS. + if ( 'preserve' !== ($island['disposition'] ?? '') || 'preserve_verbatim' !== ($island['js_handling'] ?? '') ) { + continue; + } + $content = $this->islandScriptContent($island); + $handle = is_scalar($island['handle_hint'] ?? null) ? (string) $island['handle_hint'] : ''; + if ( '' === $content || '' === $handle ) { + // Nothing carryable (telemetry-only or external-unmaterialized) or + // no stable handle: the consumer requires both, so emit neither an + // entry nor a deferral. + continue; + } + + $entry = array( + 'content' => $content, + 'handle' => $handle, + 'src' => 'islands/' . $handle . '.js', + ); + + $owner = is_scalar($island['owner_block'] ?? null) ? (string) $island['owner_block'] : ''; + if ( '' !== $owner && isset($ownedBlocks[$owner]) ) { + // Scoped to the owning generated block: the consumer enqueues it via + // render_block only when that block renders (UNCHANGED, #488). + $entry['block'] = $owner; + $preserved[] = $entry; + continue; + } + + if ( '' === $owner ) { + // Free-standing behavior island with no generated-block owner: promote + // to a site-wide preserved_js entry. The consumer enqueues these for + // the whole site rather than gating on a block, so the JS lands instead + // of being parked. Islands arrive ordered, so the package index gives a + // deterministic enqueue order. No `block` key by contract. + $entry['scope'] = 'site'; + $entry['order'] = (int) $index; + $preserved[] = $entry; + continue; + } + + // The owning block exists but was not packaged into this payload — a + // genuine anomaly. Keep deferring (rather than guessing a site-wide scope) + // so the loss is visible for follow-up. + $entry['reason'] = 'owner_block_not_packaged'; + $deferred[] = $entry; + } + + return array( 'preserved_js' => $preserved, 'deferred' => $deferred ); + } + + /** + * The first preserve-worthy verbatim JS body carried by an island's scripts: + * an inline body or materialized external content. Telemetry/droppable scripts + * and external-but-unmaterialized scripts contribute no carryable content. + * + * @param array $island One runtime-island-package island. + */ + private function islandScriptContent(array $island): string + { + $scripts = is_array($island['scripts'] ?? null) ? $island['scripts'] : array(); + foreach ( $scripts as $script ) { + if ( ! is_array($script) || ! empty($script['droppable']) || 'telemetry' === ($script['role'] ?? '') ) { + continue; + } + $content = is_scalar($script['content'] ?? null) ? (string) $script['content'] : ''; + if ( '' !== trim($content) ) { + return $content; + } + } + + return ''; + } + /** * Per-site companion-plugin block namespace (`ssi-`), or '' when * the artifact carries no resolvable site identity. The producer emits diff --git a/php-transformer/src/ArtifactCompiler/RuntimeIslandPackageBuilder.php b/php-transformer/src/ArtifactCompiler/RuntimeIslandPackageBuilder.php index b613bde5..3612d258 100644 --- a/php-transformer/src/ArtifactCompiler/RuntimeIslandPackageBuilder.php +++ b/php-transformer/src/ArtifactCompiler/RuntimeIslandPackageBuilder.php @@ -136,6 +136,11 @@ private function buildIsland(array $runtimeIsland, array $files, string $sourceP 'disposition' => $disposition, 'js_handling' => 'drop' === $disposition ? 'drop' : 'preserve_verbatim', 'handle_hint' => $this->handleHint($kind, $selector, $markup), + // Generic association: the generated block this island lives inside, + // when the transformer recorded one (a script captured at custom-block + // generation, issue #488). Product-neutral — it is a generated-block + // reference, not a host-product name. Absent for free-standing islands. + 'owner_block' => is_scalar($runtimeIsland['owner_block'] ?? null) ? (string) $runtimeIsland['owner_block'] : '', 'attributes' => is_array($runtimeIsland['attributes'] ?? null) ? $runtimeIsland['attributes'] : array(), 'scripts' => $scripts, ); diff --git a/php-transformer/src/HtmlToBlocks/Diagnostics/FallbackEmitter.php b/php-transformer/src/HtmlToBlocks/Diagnostics/FallbackEmitter.php index 1fa685f0..72721d99 100644 --- a/php-transformer/src/HtmlToBlocks/Diagnostics/FallbackEmitter.php +++ b/php-transformer/src/HtmlToBlocks/Diagnostics/FallbackEmitter.php @@ -126,9 +126,10 @@ public function resetGeneratedBlocks(): void * existing fallback behavior whenever the conservative gate is not met. * * @param array> $generatedBlocks Accumulator of generated block-type definitions. + * @param array> $runtimeIslands Accumulator of preserved runtime islands. * @return array{blockName: string, attrs: array}|null */ - public function maybeGenerateCustomBlock(DOMElement $element, array &$generatedBlocks, string $namespace): ?array + public function maybeGenerateCustomBlock(DOMElement $element, array &$generatedBlocks, array &$runtimeIslands, string $namespace): ?array { $result = $this->classifier->classify($element, $this->classificationContext($element)); if ( ! $result->is(SubtreeClassifier::BUCKET_CUSTOM_BLOCK) ) { @@ -168,12 +169,74 @@ public function maybeGenerateCustomBlock(DOMElement $element, array &$generatedB ); } + $ownerBlock = $namespace . '/' . $localName; + $this->recordGeneratedBlockScriptIslands($element, $ownerBlock, $runtimeIslands); + return array( - 'blockName' => $namespace . '/' . $localName, + 'blockName' => $ownerBlock, 'attrs' => $this->blockGenerator->referenceAttributes($content), ); } + /** + * Record the runtime behavior scripts that live inside a subtree being turned + * into a generated custom block, each scoped to that block via `owner_block`. + * + * The generated block consumes its source subtree wholesale — the caller + * emits a single self-closing reference and never recurses — and + * {@see sanitizeHtmlString()} strips `' + . '' + . '' + . '' + . '', + ), + ) +)->toArray(); +$companionJsPayload = $companionJs['source_reports']['companion_plugin_payload'] ?? array(); +$preservedJs = $companionJsPayload['preserved_js'] ?? array(); +$generatedName = (string) ($companionJsPayload['blocks'][0]['name'] ?? ''); +$assert('' !== $generatedName, 'companion JS fixture generates a custom block to scope island JS against'); +$assert(2 === count($preservedJs), 'preserved_js carries the scoped owner island and the promoted site-wide island'); + +// The owned island stays block-scoped (UNCHANGED, #488): carries a `block` key and +// no `scope` key. +$scopedIsland = null; +$siteWideIsland = null; +foreach ( $preservedJs as $islandEntry ) { + if ( isset($islandEntry['block']) ) { + $scopedIsland = $islandEntry; + } elseif ( 'site' === ($islandEntry['scope'] ?? '') ) { + $siteWideIsland = $islandEntry; + } +} +$assert(is_array($scopedIsland), 'preserved_js still carries a block-scoped island entry'); +$assert(! array_key_exists('scope', $scopedIsland), 'block-scoped island carries no scope key (scoped path unchanged)'); +$assert('ssi-acme/' . $generatedName === ($scopedIsland['block'] ?? ''), 'preserved island JS is scoped to its owning generated block'); +$assert(str_contains((string) ($scopedIsland['content'] ?? ''), 'window.__pricing'), 'preserved island entry carries the verbatim inline JS body'); +$assert(str_starts_with((string) ($scopedIsland['handle'] ?? ''), 'runtime-island-'), 'preserved island entry derives a stable generic handle'); +$assert('islands/' . ($scopedIsland['handle'] ?? '') . '.js' === ($scopedIsland['src'] ?? ''), 'preserved island entry points at a per-handle island file'); + +// The free-standing standalone island is now promoted to a site-wide preserved_js +// entry (the producer half of site-wide companion JS): scope='site', no `block` +// key, carries the verbatim JS, a stable handle/src, and a deterministic order. +$assert(is_array($siteWideIsland), 'free-standing standalone island is promoted to a site-wide preserved_js entry'); +$assert('site' === ($siteWideIsland['scope'] ?? ''), 'promoted free-standing island declares site scope'); +$assert(! array_key_exists('block', $siteWideIsland), 'site-wide island carries no block key by contract'); +$assert(str_contains((string) ($siteWideIsland['content'] ?? ''), '__standalone'), 'site-wide island carries its verbatim standalone JS body'); +$assert(str_starts_with((string) ($siteWideIsland['handle'] ?? ''), 'runtime-island-'), 'site-wide island derives a stable generic handle'); +$assert('islands/' . ($siteWideIsland['handle'] ?? '') . '.js' === ($siteWideIsland['src'] ?? ''), 'site-wide island points at a per-handle island file'); +$assert(is_int($siteWideIsland['order'] ?? null), 'site-wide island carries a deterministic integer order'); + +// With the standalone island promoted and no owner_block_not_packaged anomaly, the +// payload defers nothing. +$assert(! array_key_exists('preserved_js_deferred', $companionJsPayload), 'no deferral list when every preserve-worthy island is emitted'); +$companionJsBlob = json_encode($companionJsPayload, JSON_UNESCAPED_SLASHES); +$assert(is_string($companionJsBlob) && ! str_contains($companionJsBlob, 'gtag'), 'telemetry island JS is dropped, never carried into the payload'); + +// owner_block_not_packaged anomaly (a named owner block that did not ship in this +// payload) is still deferred — never promoted to a site-wide scope by guess. Built +// directly against the producer since the compiler path does not naturally orphan a +// named owner. A sibling no-owner island in the same package proves the site-wide +// promotion and the deferral coexist. +$companionDirectPayload = ( new \Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler\CompanionPluginPayload() )->fromBlockTypes( + array(), + array(), + array( 'site' => array( 'slug' => 'acme', 'name' => 'Acme Co' ) ), + array( + array( + 'name' => 'pricing', + 'block_json' => array( 'apiVersion' => 3, 'name' => 'ssi-acme/pricing', 'render' => 'file:./render.php' ), + 'render' => ' array( + array( + 'disposition' => 'preserve', + 'js_handling' => 'preserve_verbatim', + 'handle_hint' => 'runtime-island-orphan', + 'owner_block' => 'ssi-acme/missing', + 'scripts' => array( array( 'content' => 'window.__orphan=1;' ) ), + ), + array( + 'disposition' => 'preserve', + 'js_handling' => 'preserve_verbatim', + 'handle_hint' => 'runtime-island-floating', + 'owner_block' => '', + 'scripts' => array( array( 'content' => 'window.__floating=1;' ) ), + ), + ), + ) +); +$directPreservedJs = $companionDirectPayload['preserved_js'] ?? array(); +$directDeferred = $companionDirectPayload['preserved_js_deferred'] ?? array(); +$assert(1 === count($directPreservedJs), 'no-owner island promotes to a single site-wide entry alongside the deferred anomaly'); +$assert('site' === ($directPreservedJs[0]['scope'] ?? ''), 'no-owner island promotes to a site-wide entry'); +$assert(! array_key_exists('block', $directPreservedJs[0]), 'promoted site-wide entry carries no block key'); +$assert(str_contains((string) ($directPreservedJs[0]['content'] ?? ''), '__floating'), 'promoted site-wide entry carries the no-owner JS'); +$assert(1 === count($directDeferred), 'owner_block_not_packaged island is still deferred, not promoted'); +$assert('owner_block_not_packaged' === ($directDeferred[0]['reason'] ?? ''), 'orphaned owner island defers with the owner_block_not_packaged reason'); +$assert(! array_key_exists('scope', $directDeferred[0]), 'owner_block_not_packaged deferral is not given a site-wide scope'); +$assert(str_contains((string) ($directDeferred[0]['content'] ?? ''), '__orphan'), 'owner_block_not_packaged deferral still carries its verbatim JS'); + $companionNoSite = $compiler->compile( array( 'files' => array(