diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlanView.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlanView.php index 1e783a71b..5605e4144 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlanView.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlanView.php @@ -4,11 +4,13 @@ namespace Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan; use Automattic\BlocksEngine\PhpTransformer\Contract\TransformerResult; +use InvalidArgumentException; /** Bounded importer-facing projection of a canonical WordPress site plan result. */ final class WordPressSitePlanView { public const SCHEMA = 'blocks-engine/wordpress-site-plan-view/v1'; + public const COMPACT_SCHEMA = 'blocks-engine/wordpress-site-plan-view/v2'; public const MAX_FAILURE_DIAGNOSTICS = 100; /** @return array */ @@ -52,6 +54,86 @@ public function fromResult(TransformerResult|array $result): array ); } + /** + * Compact only the view-owned duplicate asset transports. The canonical plan + * remains unchanged at every external boundary; materialize() restores it. + * + * @param array $view + * @return array + */ + public function compact(array $view): array + { + if (self::SCHEMA !== ($view['schema'] ?? null) || !is_array($view['wordpress_site_plan'] ?? null)) { + throw new InvalidArgumentException('WordPress site plan view compaction requires the canonical v1 view.'); + } + $plan = $view['wordpress_site_plan']; + if (!is_array($plan['assets'] ?? null) || !is_array($plan['writes'] ?? null)) { + throw new InvalidArgumentException('WordPress site plan view has an invalid canonical plan.'); + } + $payloads = array(); + foreach ($plan['assets'] as &$asset) { + $field = is_string($asset['content_base64'] ?? null) ? 'content_base64' : 'content'; + $data = $asset[$field] ?? null; + if (!is_string($data)) continue; + $key = hash('sha256', $data); + $payloads[$key] = array('sha256' => $key, 'data' => $data); + $compactAsset = array(); + foreach ($asset as $assetKey => $assetValue) $compactAsset[$field === $assetKey ? 'view_payload' : $assetKey] = $field === $assetKey ? $key : $assetValue; + $asset = $compactAsset; + $asset['view_payload_field'] = $field; + } + unset($asset); + foreach ($plan['writes'] as &$write) { + $payload = $write['payload'] ?? null; + if (!is_array($payload) || !is_string($payload['data'] ?? null) || !in_array($payload['encoding'] ?? null, array('utf8', 'base64'), true)) continue; + $key = hash('sha256', $payload['data']); + $payloads[$key] = array('sha256' => $key, 'data' => $payload['data']); + $compactPayload = array(); + foreach ($payload as $payloadKey => $payloadValue) $compactPayload['data' === $payloadKey ? 'view_payload' : $payloadKey] = 'data' === $payloadKey ? $key : $payloadValue; + $write['payload'] = $compactPayload; + } + unset($write); + $view['schema'] = self::COMPACT_SCHEMA; + $view['wordpress_site_plan'] = $plan; + $view['view_payloads'] = $payloads; + return $view; + } + + /** @param array $view @return array */ + public static function materialize(array $view): array + { + if (self::COMPACT_SCHEMA !== ($view['schema'] ?? null) || !is_array($view['wordpress_site_plan'] ?? null) || !is_array($view['view_payloads'] ?? null)) throw new InvalidArgumentException('WordPress site plan view materialization requires the compact v2 view.'); + $payloads = $view['view_payloads']; + $payload = static function (mixed $key) use ($payloads): string { + $row = is_string($key) ? ($payloads[$key] ?? null) : null; + if (!is_array($row) || !is_string($row['data'] ?? null) || !is_string($row['sha256'] ?? null) || $key !== $row['sha256'] || !hash_equals($key, hash('sha256', $row['data']))) throw new InvalidArgumentException('WordPress site plan view payload is invalid.'); + return $row['data']; + }; + $plan = $view['wordpress_site_plan']; + foreach ($plan['assets'] as &$asset) { + if (!isset($asset['view_payload'])) continue; + $data = $payload($asset['view_payload']); + $field = $asset['view_payload_field'] ?? null; + if (!in_array($field, array('content', 'content_base64'), true)) throw new InvalidArgumentException('WordPress site plan view payload transport is invalid.'); + $restoredAsset = array(); + foreach ($asset as $assetKey => $assetValue) if ('view_payload_field' !== $assetKey) $restoredAsset['view_payload' === $assetKey ? $field : $assetKey] = 'view_payload' === $assetKey ? $data : $assetValue; + $asset = $restoredAsset; + } + unset($asset); + foreach ($plan['writes'] as &$write) { + if (!isset($write['payload']['view_payload'])) continue; + $data = $payload($write['payload']['view_payload']); + $restoredPayload = array(); + foreach ($write['payload'] as $payloadKey => $payloadValue) $restoredPayload['view_payload' === $payloadKey ? 'data' : $payloadKey] = 'view_payload' === $payloadKey ? $data : $payloadValue; + $write['payload'] = $restoredPayload; + } + unset($write); + unset($view['view_payloads']); + $view['schema'] = self::SCHEMA; + $view['wordpress_site_plan'] = $plan; + return $view; + } + /** @param array> $diagnostics @return array> */ private function boundedFailureDiagnostics(array $diagnostics): array { diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 24f2708d8..c37b264c0 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -24,6 +24,7 @@ use Automattic\BlocksEngine\PhpTransformer\Path\ArtifactPath; use Automattic\BlocksEngine\PhpTransformer\StaticSite\FontMaterialization\FontMaterializationPlanBuilder; use Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlanView; +use Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlan; use Automattic\BlocksEngine\PhpTransformer\StaticSite\MaterializationPlanBuilder; use Automattic\BlocksEngine\PhpTransformer\VisualParity\TypographyVisualProbe; use Automattic\BlocksEngine\PhpTransformer\VisualParity\TypographyVisualProbeComparator; @@ -3695,6 +3696,34 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter $assert(WordPressSitePlanView::SCHEMA === ($simpleObjectPlanView['schema'] ?? ''), 'Transformer result exposes the bounded WordPress site plan view directly'); $assert(($simple['source_reports']['wordpress_site_plan'] ?? array()) === ($simplePlanView['wordpress_site_plan'] ?? null), 'WordPress site plan view preserves the exact canonical plan'); $assert(($boundedHandoffResult->toArray()['source_reports']['wordpress_site_plan'] ?? array()) === ($simpleObjectPlanView['wordpress_site_plan'] ?? null), 'TransformerResult handoff preserves the exact canonical plan without a compatibility projection'); +$payloadPlanView = $compiler->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
Payload view
', 'assets/site.css' => '.payload{color:#123}')))->toWordPressSitePlanView(); +$payloadPlanView['wordpress_site_plan']['assets'] = array( + array('path' => 'assets/authored-base64.css', 'content_base64' => base64_encode('.base64-css{color:#1354}'), 'transport_sha256' => 'authored-base64-transport'), + array('path' => 'assets/authored-base64.txt', 'content_base64' => base64_encode("base64 text\n"), 'transport_sha256' => 'authored-text-transport'), + array('path' => 'assets/binary.bin', 'content_base64' => base64_encode("\x00\xffbinary\x00"), 'binary' => true, 'transport_sha256' => 'binary-transport'), + array('path' => 'assets/utf8.css', 'content' => '.utf8{content:"cafe"}', 'transport_sha256' => 'utf8-transport'), +); +$payloadPlanView['wordpress_site_plan']['writes'] = array( + array('payload' => array('encoding' => 'base64', 'data' => base64_encode('write bytes'), 'transport' => 'authored')), + array('payload' => array('encoding' => 'utf8', 'data' => 'write text', 'transport' => 'authored')), +); +$compactPlanView = (new WordPressSitePlanView())->compact($payloadPlanView); +$materializedCompactPlanView = WordPressSitePlanView::materialize(json_decode(json_encode($compactPlanView, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR)); +$serializedPayloadPlanView = json_decode(json_encode($payloadPlanView, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); +$assert(WordPressSitePlanView::COMPACT_SCHEMA === ($compactPlanView['schema'] ?? null) && !isset($compactPlanView['wordpress_site_plan']['assets'][0]['content_base64']), 'WordPress site plan compact view removes base64 asset transports'); +$assert($payloadPlanView['wordpress_site_plan']['assets'] === $materializedCompactPlanView['wordpress_site_plan']['assets'], 'WordPress site plan compact view exactly restores asset payload transports'); +$assert($payloadPlanView['wordpress_site_plan']['writes'] === $materializedCompactPlanView['wordpress_site_plan']['writes'], 'WordPress site plan compact view exactly restores write payload transports'); +$assert($serializedPayloadPlanView === $materializedCompactPlanView, 'WordPress site plan compact view exactly restores the complete persisted v1 view'); +$assert("\x00\xffbinary\x00" === base64_decode($materializedCompactPlanView['wordpress_site_plan']['assets'][2]['content_base64'], true) && '.base64-css{color:#1354}' === base64_decode($materializedCompactPlanView['wordpress_site_plan']['assets'][0]['content_base64'], true), 'WordPress site plan compact view preserves binary and base64 payload bytes'); +$assert(WordPressSitePlan::canonicalHash($payloadPlanView['wordpress_site_plan']) === WordPressSitePlan::canonicalHash($materializedCompactPlanView['wordpress_site_plan']), 'WordPress site plan compact view preserves the canonical plan hash'); +$tamperedCompactPlanView = $compactPlanView; +$tamperedCompactPlanView['wordpress_site_plan']['assets'][0]['view_payload_field'] = 'binary'; +try { WordPressSitePlanView::materialize($tamperedCompactPlanView); $tamperedCompactRejected = false; } catch (InvalidArgumentException) { $tamperedCompactRejected = true; } +$assert($tamperedCompactRejected, 'WordPress site plan compact view rejects tampered payload transport metadata'); +try { WordPressSitePlanView::materialize($payloadPlanView); $canonicalViewRejected = false; } catch (InvalidArgumentException) { $canonicalViewRejected = true; } +$assert($canonicalViewRejected, 'WordPress site plan materialization rejects canonical v1 views'); +try { (new WordPressSitePlanView())->compact($compactPlanView); $compactViewRejected = false; } catch (InvalidArgumentException) { $compactViewRejected = true; } +$assert($compactViewRejected, 'WordPress site plan compaction rejects compact v2 views'); $assert(($simple['source_reports']['editability_report'] ?? null) === ($simplePlanView['editability_report'] ?? null), 'WordPress site plan view preserves the producer-owned editability report exactly'); $assert(array('schema', 'metrics', 'block_types', 'documents', 'signals', 'signal_totals') === array_keys($simplePlanView['editability_report'] ?? array()) && EditabilityReport::SCHEMA === ($simplePlanView['editability_report']['schema'] ?? null), 'WordPress site plan view exposes the current versioned editability report shape'); $boundedEditabilityReport = (new EditabilityReport())->fromDocuments(array('large.html' => array('blocks' => array_fill(0, 101, array('blockName' => 'core/group', 'attrs' => array(), 'innerBlocks' => array(), 'innerHTML' => '')))));