Skip to content
Closed
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
10 changes: 8 additions & 2 deletions php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
150 changes: 144 additions & 6 deletions php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -48,9 +59,10 @@ final class CompanionPluginPayload
* @param array<int, array<string, mixed>> $files Normalized artifact files (carry content).
* @param array<string, mixed> $artifact Raw artifact envelope (for site identity).
* @param array<int, array<string, mixed>> $generatedBlocks Dynamic blocks generated at core/html fallbacks (issue #497).
* @param array<string, mixed> $runtimeIslandPackage Generic runtime-island package feed (issue #488).
* @return array<string, mixed> 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();
Expand Down Expand Up @@ -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 ) {
Expand All @@ -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<string, mixed> $runtimeIslandPackage Generic runtime-island package.
* @param array<int, array<string, mixed>> $blocks Packaged companion blocks.
* @param string $blockNamespace Per-site block namespace (`ssi-<slug>`).
* @return array{preserved_js: array<int, array<string, mixed>>, deferred: array<int, array<string, string>>}
*/
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<string, mixed> $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-<site_slug>`), or '' when
* the artifact carries no resolvable site identity. The producer emits
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
85 changes: 66 additions & 19 deletions php-transformer/src/HtmlToBlocks/Diagnostics/FallbackEmitter.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,10 @@ public function resetGeneratedBlocks(): void
* existing fallback behavior whenever the conservative gate is not met.
*
* @param array<int, array<string, mixed>> $generatedBlocks Accumulator of generated block-type definitions.
* @param array<int, array<string, mixed>> $runtimeIslands Accumulator of preserved runtime islands.
* @return array{blockName: string, attrs: array<string, mixed>}|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) ) {
Expand Down Expand Up @@ -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 `<script>` bodies from the captured
* content, so any behavior script inside the subtree would otherwise be lost.
* Recording it as a script runtime island carrying the owning block's
* fully-qualified name is the one sound island->generated-block association
* point (issue #488): the block name is known here and the script lives
* literally inside that block's subtree, so a downstream materializer can
* carry the verbatim JS forward and enqueue it only when the block renders.
* Telemetry/analytics scripts are still classified and dropped downstream by
* the runtime-island package builder (issue #224); only executable runtime
* scripts are captured here (data/JSON-LD scripts carry no behavior).
*
* @param array<int, array<string, mixed>> $runtimeIslands
*/
private function recordGeneratedBlockScriptIslands(DOMElement $element, string $ownerBlock, array &$runtimeIslands): void
{
foreach ( $this->subtreeElements($element) as $node ) {
if ( 'script' !== strtolower($node->tagName) || 'runtime' !== $this->scriptRole($node) ) {
continue;
}
$metadata = $this->scriptIslandMetadata($node);
$metadata['owner_block'] = $ownerBlock;
$this->recordRuntimeIsland($node, 'script', 'script_requires_runtime', 'client_script_execution', $metadata, $runtimeIslands);
}
}

/**
* Build the runtime-island metadata for a `<script>` element: its safe
* attributes (external src), role, source kind, and — for an inline script —
* the verbatim inline body (bounded). safeFallbackHtml() strips `<script>`
* bodies from the island source_snippet for safety, so the inline body is
* preserved here so a downstream consumer can carry the script forward verbatim
* (issue #224: verbatim JS on verbatim island markup).
*
* @return array<string, mixed>
*/
private function scriptIslandMetadata(DOMElement $element): array
{
$scriptSourceKind = '' !== trim($this->attr($element, 'src')) ? 'external' : 'inline';
$metadata = array(
'attributes' => $this->safeScriptAttributes($element),
'script_role' => $this->scriptRole($element),
'script_source_kind' => $scriptSourceKind,
);
if ( 'inline' === $scriptSourceKind ) {
$boundedBody = $this->boundedFallbackText(trim($element->textContent ?? ''));
$metadata['script_body'] = $boundedBody['text'];
$metadata['body_bytes'] = $boundedBody['bytes'];
$metadata['body_truncated'] = $boundedBody['truncated'];
}

return $metadata;
}

/**
* Canonical structural signature of a subtree: the tag-only DOM skeleton,
* ignoring text and attributes. Same shape => same signature => one block
Expand Down Expand Up @@ -367,23 +430,7 @@ public function captureScriptFallback(DOMElement $element, array &$fallbacks, ar
$boundedHtml = $this->boundedFallbackHtml($this->safeFallbackHtml($element));
$boundedBody = $this->boundedFallbackText(trim($element->textContent ?? ''));
$scriptRole = $this->scriptRole($element);
$scriptSourceKind = '' !== trim($this->attr($element, 'src')) ? 'external' : 'inline';
$scriptIslandMetadata = array(
'attributes' => $this->safeScriptAttributes($element),
'script_role' => $scriptRole,
'script_source_kind' => $scriptSourceKind,
);
if ( 'inline' === $scriptSourceKind ) {
// safeFallbackHtml() strips <script> bodies from source_snippet for
// safety, so an inline script island would otherwise carry no JS.
// Preserve the verbatim inline body (bounded) so a downstream
// consumer can carry the script forward (issue #224: verbatim JS on
// verbatim island markup).
$scriptIslandMetadata['script_body'] = $boundedBody['text'];
$scriptIslandMetadata['body_bytes'] = $boundedBody['bytes'];
$scriptIslandMetadata['body_truncated'] = $boundedBody['truncated'];
}
$this->recordRuntimeIsland($element, 'script', 'script_requires_runtime', 'client_script_execution', $scriptIslandMetadata, $runtimeIslands);
$this->recordRuntimeIsland($element, 'script', 'script_requires_runtime', 'client_script_execution', $this->scriptIslandMetadata($element), $runtimeIslands);
$fallbacks[] = FallbackDiagnostic::build(array(
'type' => 'html',
'reason' => 'script_requires_runtime',
Expand Down
Loading
Loading