diff --git a/php-transformer/README.md b/php-transformer/README.md index 5a2ec8d0..62e08269 100644 --- a/php-transformer/README.md +++ b/php-transformer/README.md @@ -145,6 +145,10 @@ The artifact compiler accepts loose generated-site bundles and normalizes them i Unsupported or unsafe artifact inputs are reported through diagnostics instead of hidden best-effort behavior. Empty, absolute, or root-escaping paths are rejected; oversized files are ignored according to the source report limits; and a bundle with neither an HTML entry nor source documents fails with `missing_entry_html`. +## WordPress Site Plans + +`WordPressSitePlan` projects a self-contained `blocks-engine/wordpress-site-plan/v2` materialization contract from a compiler result. Its normalized document metadata and generic reporting summaries let a consumer project reports from a resolved plan plus its own receipt, without reading the compiler envelope or source files. See [WordPress Site Plan v2](docs/wordpress-site-plan.md) for the metadata shape, ordering, supported attributes, and resolver boundary. + ## Parity Checks Run the package contract, parity fixtures, and clean package-install proof with `composer test`. The checked-in fixtures assert current transformer behavior, and the install proof verifies that Composer can install `automattic/blocks-engine-php-transformer` from the `php-transformer/` package root without symlinking back to the working tree. diff --git a/php-transformer/docs/wordpress-site-plan.md b/php-transformer/docs/wordpress-site-plan.md new file mode 100644 index 00000000..3b8eafa0 --- /dev/null +++ b/php-transformer/docs/wordpress-site-plan.md @@ -0,0 +1,53 @@ +# WordPress Site Plan v2 + +`blocks-engine/wordpress-site-plan/v2` is a destination-independent materialization contract. A consumer resolves it with `WordPressSitePlanResolver` and combines the resolved plan with its own materialization receipt. Destination IDs, paths outside declared writes, and product report formats remain consumer-owned. + +## Document Metadata + +Every page and template part has `document_metadata`. It is normalized compiler output, not source HTML: + +```php +array( + 'source_context' => array('source_path' => 'nested/about.html', 'kind' => 'html'), + 'title' => 'About', + 'title_declaration' => array('order' => 0, 'placement' => 'head'), + 'meta' => array(), + 'links' => array(), + 'scripts' => array(), +) +``` + +`meta`, `links`, and `scripts` are ordered source rows. Their zero-based `order` equals their array index. `placement` is `head` or `body`; `title_declaration` always has `order: 0` and `placement: head`. `source_context` identifies the compiler document that supplied the declarations. + +Meta rows preserve `charset`, `name`, `property`, `http_equiv`, and `content`. Link rows preserve `rel`, `type`, `media`, `integrity`, `crossorigin`, `referrerpolicy`, `as`, `fetchpriority`, and `sizes`. Script rows preserve `type`, `integrity`, `crossorigin`, `referrerpolicy`, and `fetchpriority`, plus independent booleans for `async`, `defer`, `module`, and `nomodule`. Explicitly present empty values are retained as `''` so consumers can distinguish them from absent attributes, except `crossorigin`: its empty or boolean HTML state is normalized to `anonymous`, matching browser CORS semantics. `effective_loading` records browser loading semantics: `async` wins over `defer`; non-async module scripts are `defer`; other scripts are `blocking`. Inline scripts carry `source_kind: inline` and `body_hash`, not their source body. + +URL-bearing link and external-script declarations contain either an explicit absolute or protocol-relative `url`, or an `asset_reference` token. Local artifact URLs must use `asset_reference`; undeclared local URLs are invalid. Resolver output adds `resolved_url` for each `asset_reference`. That URL is exactly the URL of a declared resolved theme-asset write. Explicit external URLs remain unchanged. + +Document metadata is reporting-only. It preserves source declaration facts for consumers that need reports or manifests; it does not alter generated theme bootstrap behavior or claim runtime execution parity. + +## Reporting + +`reporting` is a compiler-output summary: + +```php +array( + 'source_documents' => array( + array( + 'source_path' => 'index.html', + 'kind' => 'html', + 'body_format' => 'blocks', + 'block_document' => true, + 'provenance' => array(), + ), + ), + 'metrics' => array( + 'source_document_count' => 1, + 'block_document_count' => 1, + 'native_block_count' => 0, + 'fallback_count' => 0, + ), + 'diagnostic_codes' => array(), +) +``` + +It provides generic source-document identity, native/block and fallback metrics, provenance, and diagnostic linkage. It intentionally excludes destination IDs, filesystem locations, and consumer report paths. A consumer can project its own stable report from the resolved plan and a receipt that confirms every declared write and page reconciliation identity. diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 08e13f9c..25b5df7b 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -1572,11 +1572,58 @@ private function scriptAppendedRootSelectors(string $script): array private function htmlAttribute(string $tag, string $name): string { - if ( preg_match('/\s' . preg_quote($name, '/') . '\s*=\s*(["\'])(.*?)\1/is', $tag, $match) ) { - return html_entity_decode((string) $match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'); - } + return $this->htmlAttributes($tag)[strtolower($name)] ?? ''; + } - return ''; + private function hasHtmlAttribute(string $tag, string $name): bool + { + return array_key_exists(strtolower($name), $this->htmlAttributes($tag)); + } + + /** @return array */ + private function htmlAttributes(string $tag): array + { + $length = strlen($tag); + $offset = strpos($tag, '<'); + if (false === $offset) { + $offset = 0; + } else { + ++$offset; + while ($offset < $length && ctype_space($tag[$offset])) ++$offset; + if ($offset < $length && '/' === $tag[$offset]) ++$offset; + while ($offset < $length && !ctype_space($tag[$offset]) && !in_array($tag[$offset], array('>', '/'), true)) ++$offset; + } + $attributes = array(); + while ($offset < $length) { + while ($offset < $length && ctype_space($tag[$offset])) ++$offset; + if ($offset >= $length || '>' === $tag[$offset] || '/' === $tag[$offset]) break; + $start = $offset; + while ($offset < $length && !ctype_space($tag[$offset]) && !in_array($tag[$offset], array('=', '>', '/', '"', "'", '<'), true)) ++$offset; + if ($start === $offset) break; + $name = strtolower(substr($tag, $start, $offset - $start)); + while ($offset < $length && ctype_space($tag[$offset])) ++$offset; + $value = ''; + if ($offset < $length && '=' === $tag[$offset]) { + ++$offset; + while ($offset < $length && ctype_space($tag[$offset])) ++$offset; + if ($offset >= $length) break; + if (in_array($tag[$offset], array('"', "'"), true)) { + $quote = $tag[$offset++]; $start = $offset; + while ($offset < $length && $tag[$offset] !== $quote) ++$offset; + if ($offset >= $length) break; + $value = substr($tag, $start, $offset - $start); ++$offset; + } else { + $start = $offset; + while ($offset < $length && !ctype_space($tag[$offset]) && '>' !== $tag[$offset]) { + if (in_array($tag[$offset], array('"', "'", '<'), true)) break 2; + ++$offset; + } + $value = substr($tag, $start, $offset - $start); + } + } + if (!isset($attributes[$name])) $attributes[$name] = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + return $attributes; } /** @@ -1648,6 +1695,7 @@ private function compiledSiteReport(array $artifact, string $entryPath, array $d 'slug' => $slug, 'title' => $title, 'metadata' => $this->documentMetadata($path, 'html', (string) ($file['role'] ?? 'document'), $slug, $title, $bodyFormat), + 'document_metadata' => $this->fullDocumentMetadata($content, $path, $artifact['files']), 'html' => $file['content'] ?? '', 'body_format' => $bodyFormat, 'block_markup' => $blockMarkup, @@ -1904,6 +1952,45 @@ private function documentMetadata(string $sourcePath, string $kind, string $role ); } + /** @param array> $files @return array */ + private function fullDocumentMetadata(string $html, string $sourcePath, array $files): array + { + $headEnd = preg_match('/]*>.*?<\/head\s*>/is', $html, $head) ? (int) strpos($html, $head[0]) + strlen($head[0]) : 0; + $reference = function (string $value) use ($sourcePath, $files): array { + $asset = $this->findAssetByHtmlReference($value, $sourcePath, $files); + return is_array($asset) ? array('asset_source_path' => (string) $asset['path']) : array('url' => $value); + }; + $attributes = function (string $tag, array $names): array { + $values = array(); + foreach ($names as $name) { + if (!$this->hasHtmlAttribute($tag, $name)) continue; + $value = $this->htmlAttribute($tag, $name); + // HTML's empty and invalid-value CORS states both select anonymous. + $values[str_replace('-', '_', $name)] = 'crossorigin' === $name && '' === $value ? 'anonymous' : $value; + } + return $values; + }; + $placement = static fn(int $offset): string => $offset < $headEnd ? 'head' : 'body'; + $meta = array(); $links = array(); $scripts = array(); + if (preg_match_all('/]*>/i', $html, $matches, PREG_OFFSET_CAPTURE)) foreach ($matches[0] as $match) { + $tag = (string) $match[0]; + $row = $attributes($tag, array('charset', 'name', 'property', 'http-equiv', 'content')); + if (array() !== $row) { $row = array_merge(array('order' => count($meta), 'placement' => $placement((int) $match[1])), $row); $meta[] = $row; } + } + if (preg_match_all('/]*>/i', $html, $matches, PREG_OFFSET_CAPTURE)) foreach ($matches[0] as $match) { + $tag = (string) $match[0]; $href = $this->htmlAttribute($tag, 'href'); + if ('' === $href) continue; + $links[] = array_merge(array('order' => count($links), 'placement' => $placement((int) $match[1])), $attributes($tag, array('rel', 'type', 'media', 'integrity', 'crossorigin', 'referrerpolicy', 'as', 'fetchpriority', 'sizes')), $reference($href)); + } + if (preg_match_all('/]*>(?:.*?)<\/script\s*>/is', $html, $matches, PREG_OFFSET_CAPTURE)) foreach ($matches[0] as $match) { + $tag = (string) $match[0]; $open = strstr($tag, '>', true) . '>'; $src = $this->htmlAttribute($open, 'src'); + $async = $this->hasHtmlAttribute($open, 'async'); $defer = $this->hasHtmlAttribute($open, 'defer'); $module = 'module' === strtolower($this->htmlAttribute($open, 'type')); + $scripts[] = array_merge(array('order' => count($scripts), 'placement' => $placement((int) $match[1]), 'async' => $async, 'defer' => $defer, 'module' => $module, 'nomodule' => $this->hasHtmlAttribute($open, 'nomodule'), 'effective_loading' => $async ? 'async' : (($defer || $module) ? 'defer' : 'blocking')), $attributes($open, array('type', 'integrity', 'crossorigin', 'referrerpolicy', 'fetchpriority')), '' !== $src ? $reference($src) : array('source_kind' => 'inline', 'body_hash' => hash('sha256', (string) preg_replace('/^.*?>|<\/script\s*>$/is', '', $tag)))); + } + $title = preg_match('/]*>(.*?)<\/title\s*>/is', $html, $match) ? trim(html_entity_decode(strip_tags((string) $match[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8')) : $this->titleFromHtml($html, $sourcePath); + return array('source_context' => array('source_path' => $sourcePath, 'kind' => 'html'), 'title' => $title, 'title_declaration' => array('order' => 0, 'placement' => 'head'), 'meta' => $meta, 'links' => $links, 'scripts' => $scripts); + } + /** * @param array> $files * @return array> diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index 12a7a7ff..5f7b4ef9 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php @@ -48,6 +48,7 @@ public function fromResult(TransformerResult|array $result): array 'visual_repair' => $compiled['visual_repair'] ?? array(), 'diagnostics' => $data['diagnostics'], 'quality' => array('status' => $data['status'], 'metrics' => array_diff_key($data['metrics'], array('transform_duration_ms' => true)), 'fallbacks' => $data['fallbacks']), + 'reporting' => $this->reporting($compiled, $data), ); self::assertValid($plan); return $plan; @@ -59,7 +60,7 @@ public static function assertValid(array $plan): void if ( self::SCHEMA !== ($plan['schema'] ?? null) ) { throw new InvalidArgumentException('WordPress site plan has an unsupported schema.'); } - foreach ( array('source', 'pages', 'templates', 'template_parts', 'assets', 'reference_tokens', 'reference_semantics', 'writes', 'operations', 'routes', 'navigation_links', 'menus', 'theme', 'visual_repair', 'diagnostics', 'quality') as $key ) { + foreach ( array('source', 'pages', 'templates', 'template_parts', 'assets', 'reference_tokens', 'reference_semantics', 'writes', 'operations', 'routes', 'navigation_links', 'menus', 'theme', 'visual_repair', 'diagnostics', 'quality', 'reporting') as $key ) { if ( ! is_array($plan[$key] ?? null) ) { throw new InvalidArgumentException(sprintf('WordPress site plan %s must be an array.', $key)); } @@ -98,6 +99,7 @@ public static function assertValid(array $plan): void self::assertDocument($page, 'page', false, $tokens); self::unique($pagePaths, $page['source_path'], 'page source'); } + self::assertReporting($plan['reporting'], $pagePaths, $tokens); self::assertOperations($plan['operations'], $plan['pages']); $templateTargets = array(); foreach ( $plan['templates'] as $template ) { @@ -161,7 +163,7 @@ private function documents(mixed $documents, bool $part, array $tokens, array $r throw new InvalidArgumentException('Compiled site document lacks a safe identity or block markup.'); } $markup = $this->tokenize($document['block_markup'], $tokens, $document['source_path']); - $rows[] = array('source_path' => $document['source_path'], 'slug' => self::value($document, 'slug'), 'title' => self::value($document, 'title'), 'post_type' => self::value((array) ($document['metadata'] ?? array()), 'post_type', 'page'), 'parent_source_path' => self::value((array) ($document['metadata'] ?? array()), 'parent_source_path'), 'entrypoint' => ! empty($document['entrypoint']), 'area' => $part ? self::value($document, 'area', 'uncategorized') : null, 'placement' => $part && is_array($document['placement'] ?? null) ? $document['placement'] : ($part ? array('kind' => 'unbound') : null), 'canonical_block_markup' => $this->routeLinks($markup, $document['source_path'], $routes), 'metadata' => is_array($document['metadata'] ?? null) ? $document['metadata'] : array(), 'provenance' => is_array($document['provenance'] ?? null) ? $document['provenance'] : array(), 'reconciliation_identity' => hash('sha256', $document['source_path'] . "\n" . $document['block_markup'])); + $rows[] = array('source_path' => $document['source_path'], 'slug' => self::value($document, 'slug'), 'title' => self::value($document, 'title'), 'post_type' => self::value((array) ($document['metadata'] ?? array()), 'post_type', 'page'), 'parent_source_path' => self::value((array) ($document['metadata'] ?? array()), 'parent_source_path'), 'entrypoint' => ! empty($document['entrypoint']), 'area' => $part ? self::value($document, 'area', 'uncategorized') : null, 'placement' => $part && is_array($document['placement'] ?? null) ? $document['placement'] : ($part ? array('kind' => 'unbound') : null), 'canonical_block_markup' => $this->routeLinks($markup, $document['source_path'], $routes), 'metadata' => is_array($document['metadata'] ?? null) ? $document['metadata'] : array(), 'document_metadata' => $this->documentMetadata($document, $tokens), 'provenance' => is_array($document['provenance'] ?? null) ? $document['provenance'] : array(), 'reconciliation_identity' => hash('sha256', $document['source_path'] . "\n" . $document['block_markup'])); } return $rows; } @@ -187,6 +189,10 @@ private function assets(mixed $assets): array /** @param array> $assets @return array> */ private function tokens(array $assets): array { return array_map(static fn(array $asset): array => array('token' => $asset['token'], 'source_path' => $asset['source_path'], 'target_path' => $asset['target_path']), $assets); } + /** @param array $document @param array> $tokens @return array */ + private function documentMetadata(array $document, array $tokens): array { $metadata = is_array($document['document_metadata'] ?? null) ? $document['document_metadata'] : array('source_context' => array('source_path' => self::value($document, 'source_path'), 'kind' => 'document'), 'title' => self::value($document, 'title'), 'title_declaration' => array('order' => 0, 'placement' => 'head'), 'meta' => array(), 'links' => array(), 'scripts' => array()); $bySource = array(); foreach ($tokens as $token) $bySource[$token['source_path']] = self::TOKEN_PREFIX . $token['token'] . '}}'; foreach (array('links', 'scripts') as $kind) { if (!is_array($metadata[$kind] ?? null)) $metadata[$kind] = array(); foreach ($metadata[$kind] as &$row) if (is_array($row) && is_string($row['asset_source_path'] ?? null)) { if (!isset($bySource[$row['asset_source_path']])) throw new InvalidArgumentException('Document metadata references an undeclared asset.'); $row['asset_reference'] = $bySource[$row['asset_source_path']]; unset($row['asset_source_path']); } unset($row); } return $metadata; } + /** @param array $compiled @param array $data @return array */ + private function reporting(array $compiled, array $data): array { $documents = array(); foreach ($compiled['pages'] ?? array() as $page) if (is_array($page)) $documents[] = array('source_path' => $page['source_path'] ?? '', 'kind' => $page['kind'] ?? '', 'body_format' => $page['body_format'] ?? '', 'block_document' => 'blocks' === ($page['body_format'] ?? ''), 'provenance' => $page['provenance'] ?? array()); return array('source_documents' => $documents, 'metrics' => array('source_document_count' => count($documents), 'block_document_count' => count(array_filter($documents, static fn(array $document): bool => !empty($document['block_document']))), 'native_block_count' => $data['metrics']['block_count'] ?? 0, 'fallback_count' => $data['metrics']['fallback_count'] ?? 0), 'diagnostic_codes' => array_values(array_map(static fn(array $diagnostic): string => (string) ($diagnostic['code'] ?? ''), $data['diagnostics']))); } /** @param array> $pages @return array> */ private function templates(array $pages, array $parts): array @@ -321,7 +327,11 @@ private static function assertNoLocalBrowserReferences(string $content): void } } /** @param array $tokens */ - private static function assertDocument(mixed $document, string $kind, bool $part, array $tokens): void { if(!is_array($document)||!self::safePath($document['source_path']??null)||!is_string($document['slug']??null)||!is_string($document['title']??null)||!is_string($document['post_type']??null)||!is_string($document['parent_source_path']??null)||!is_bool($document['entrypoint']??null)||!is_string($document['canonical_block_markup']??null)||''===trim($document['canonical_block_markup'])||!is_array($document['metadata']??null)||!is_array($document['provenance']??null)||!is_string($document['reconciliation_identity']??null)||($part&&(!is_string($document['area']??null)||''===$document['area']||!is_array($document['placement']??null)))||(!$part&&(null!==($document['area']??null)||null!==($document['placement']??null))))throw new InvalidArgumentException("WordPress site plan {$kind} is structurally invalid.");if($part&&'entry_shell'===($document['placement']['kind']??null)&&(!is_string($document['placement']['source_path']??null)||!is_array($document['placement']['template_slugs']??null)||array()=== $document['placement']['template_slugs']))throw new InvalidArgumentException('WordPress site plan template part placement is invalid.');self::assertTokens($document['canonical_block_markup'],$tokens);self::assertNoLocalBrowserReferences($document['canonical_block_markup']); } + private static function assertDocument(mixed $document, string $kind, bool $part, array $tokens): void { if(!is_array($document)||!self::safePath($document['source_path']??null)||!is_string($document['slug']??null)||!is_string($document['title']??null)||!is_string($document['post_type']??null)||!is_string($document['parent_source_path']??null)||!is_bool($document['entrypoint']??null)||!is_string($document['canonical_block_markup']??null)||''===trim($document['canonical_block_markup'])||!is_array($document['metadata']??null)||!is_array($document['document_metadata']??null)||!is_array($document['provenance']??null)||!is_string($document['reconciliation_identity']??null)||($part&&(!is_string($document['area']??null)||''===$document['area']||!is_array($document['placement']??null)))||(!$part&&(null!==($document['area']??null)||null!==($document['placement']??null))))throw new InvalidArgumentException("WordPress site plan {$kind} is structurally invalid.");if($part&&'entry_shell'===($document['placement']['kind']??null)&&(!is_string($document['placement']['source_path']??null)||!is_array($document['placement']['template_slugs']??null)||array()=== $document['placement']['template_slugs']))throw new InvalidArgumentException('WordPress site plan template part placement is invalid.');self::assertDocumentMetadata($document['document_metadata'],$tokens);self::assertTokens($document['canonical_block_markup'],$tokens);self::assertNoLocalBrowserReferences($document['canonical_block_markup']); } + /** @param array $metadata @param array $tokens */ + private static function assertDocumentMetadata(array $metadata, array $tokens): void { if(!is_array($metadata['source_context']??null)||!self::safePath($metadata['source_context']['source_path']??null)||!is_string($metadata['source_context']['kind']??null)||!is_string($metadata['title']??null)||!is_array($metadata['title_declaration']??null)||0!==($metadata['title_declaration']['order']??null)||'head'!==($metadata['title_declaration']['placement']??null)||!is_array($metadata['meta']??null)||!is_array($metadata['links']??null)||!is_array($metadata['scripts']??null))throw new InvalidArgumentException('WordPress site plan document metadata is structurally invalid.');foreach($metadata['meta'] as $index=>$row)if(!is_array($row)||$index!==($row['order']??null)||!in_array($row['placement']??null,array('head','body'),true)||array_diff(array_keys($row),array('order','placement','charset','name','property','http_equiv','content')))throw new InvalidArgumentException('WordPress site plan meta declaration is invalid.');foreach($metadata['links'] as $index=>$row){if(!is_array($row)||$index!==($row['order']??null)||!in_array($row['placement']??null,array('head','body'),true)||(!is_string($row['asset_reference']??null)&&!self::explicitUrl($row['url']??null))||array_diff(array_keys($row),array('order','placement','rel','type','media','integrity','crossorigin','referrerpolicy','as','fetchpriority','sizes','asset_reference','url','resolved_url')))throw new InvalidArgumentException('WordPress site plan link declaration is invalid.');if(is_string($row['asset_reference']??null))self::assertTokens($row['asset_reference'],$tokens);}foreach($metadata['scripts'] as $index=>$row){if(!is_array($row)||$index!==($row['order']??null)||!in_array($row['placement']??null,array('head','body'),true)||!is_bool($row['defer']??null)||!is_bool($row['async']??null)||!is_bool($row['module']??null)||!is_bool($row['nomodule']??null)||!in_array($row['effective_loading']??null,array('blocking','defer','async'),true)||($row['async']&&'async'!==$row['effective_loading'])||(!$row['async']&&($row['defer']||$row['module'])&&'defer'!==$row['effective_loading'])||(!$row['async']&&!$row['defer']&&!$row['module']&&'blocking'!==$row['effective_loading'])||(!is_string($row['asset_reference']??null)&&!self::explicitUrl($row['url']??null)&&'inline'!==($row['source_kind']??null))||array_diff(array_keys($row),array('order','placement','async','defer','module','nomodule','effective_loading','type','integrity','crossorigin','referrerpolicy','fetchpriority','asset_reference','url','resolved_url','source_kind','body_hash')))throw new InvalidArgumentException('WordPress site plan script declaration is invalid.');if(is_string($row['asset_reference']??null))self::assertTokens($row['asset_reference'],$tokens);}} + /** @param array $reporting @param array $pagePaths @param array $tokens */ + private static function assertReporting(array $reporting, array $pagePaths, array $tokens): void { if(!is_array($reporting['source_documents']??null)||!is_array($reporting['metrics']??null)||!is_array($reporting['diagnostic_codes']??null))throw new InvalidArgumentException('WordPress site plan reporting summary is invalid.');$sources=array();foreach($reporting['source_documents'] as $document){if(!is_array($document)||!self::safePath($document['source_path']??null)||!is_string($document['kind']??null)||!is_string($document['body_format']??null)||!is_bool($document['block_document']??null)||!is_array($document['provenance']??null))throw new InvalidArgumentException('WordPress site plan source document summary is invalid.');self::unique($sources,$document['source_path'],'source document');}if(count($sources)!==count($pagePaths)||array_keys($sources)!==array_keys($pagePaths))throw new InvalidArgumentException('WordPress site plan source document summaries do not match pages.');foreach(array('source_document_count','block_document_count','native_block_count','fallback_count') as $key)if(!is_int($reporting['metrics'][$key]??null))throw new InvalidArgumentException('WordPress site plan reporting metric is invalid.');foreach($reporting['diagnostic_codes'] as $code)if(!is_string($code)||''===$code)throw new InvalidArgumentException('WordPress site plan diagnostic linkage is invalid.');} /** @param array $tokens */ private static function assertWrite(mixed $write, array $tokens): void { if (!is_array($write) || !is_string($write['kind'] ?? null) || !self::safePath($write['source_path'] ?? null) || !self::safePath($write['target_path'] ?? null) || !is_array($write['payload'] ?? null) || !in_array($write['payload']['encoding'] ?? null, array('utf8','base64'), true) || !is_string($write['payload']['data'] ?? null)) throw new InvalidArgumentException('WordPress site plan write is structurally invalid.'); if ('base64' === $write['payload']['encoding'] && false === base64_decode($write['payload']['data'], true)) throw new InvalidArgumentException('WordPress site plan write has invalid base64 payload.'); if ('utf8' === $write['payload']['encoding']) { self::assertTokens($write['payload']['data'], $tokens); self::assertNoLocalBrowserReferences($write['payload']['data']); } } /** @param array $tokens */ @@ -334,5 +344,6 @@ private static function assertSource(array $source): void { if ('blocks-engine/p private static function assertRows(array $rows, string $kind, array $fields, array $optional = array()): void { foreach ($rows as $row) { if (!is_array($row)) throw new InvalidArgumentException("WordPress site plan {$kind} must be an array."); foreach ($fields as $field) if (!array_key_exists($field, $row) || (!is_string($row[$field]) && !is_int($row[$field]))) throw new InvalidArgumentException("WordPress site plan {$kind} lacks {$field}."); foreach ($optional as $field) if (array_key_exists($field, $row) && !is_string($row[$field])) throw new InvalidArgumentException("WordPress site plan {$kind} has invalid {$field}."); } } /** @param array $data */ private static function value(array $data, string $key, string $default = ''): string { return is_string($data[$key] ?? null) ? $data[$key] : $default; } + private static function explicitUrl(mixed $url): bool { return is_string($url) && preg_match('~^(?:[a-z][a-z0-9+.-]*:|//)~i', $url) === 1; } private static function safePath(mixed $path): bool { if (!is_string($path) || '' === $path || str_contains($path, "\0") || str_starts_with($path, '/') || str_starts_with($path, '\\') || preg_match('/^[A-Za-z]:/', $path)) return false; foreach (explode('/', str_replace('\\', '/', $path)) as $segment) if ('' === $segment || '.' === $segment || '..' === $segment) return false; return true; } } diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlanResolver.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlanResolver.php index c53e2422..34763957 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlanResolver.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlanResolver.php @@ -24,7 +24,10 @@ public function resolve(array $plan, array $context): array unset($template); foreach ($plan['writes'] as &$write) if ('utf8' === $write['payload']['encoding']) $write['payload']['data'] = self::replace($write['payload']['data'], $references); unset($write); + foreach (array('pages', 'template_parts') as $documents) foreach ($plan[$documents] as &$document) foreach (array('links', 'scripts') as $kind) { if (!is_array($document['document_metadata'][$kind] ?? null)) continue; foreach ($document['document_metadata'][$kind] as &$declaration) if (is_string($declaration['asset_reference'] ?? null)) $declaration['resolved_url'] = self::replace($declaration['asset_reference'], $references); } + unset($declaration, $document); $plan['resolution'] = array('theme_uri' => $themeUri); + self::assertResolvedMetadata($plan, $references); return $plan; } @@ -46,4 +49,6 @@ private static function themeUri(mixed $value): string $authority = strtolower($parts['host']) . (isset($parts['port']) ? ':' . $parts['port'] : ''); return strtolower($parts['scheme']) . '://' . $authority . rtrim($path, '/'); } + /** @param array $plan @param array $references */ + private static function assertResolvedMetadata(array $plan, array $references): void { foreach(array('pages','template_parts') as $documents)foreach($plan[$documents] as $document)foreach(array('links','scripts') as $kind)foreach($document['document_metadata'][$kind]??array() as $declaration)if(is_string($declaration['asset_reference']??null)){if(!isset($references[$declaration['asset_reference']])||$references[$declaration['asset_reference']]!==($declaration['resolved_url']??null))throw new InvalidArgumentException('WordPress site plan metadata URL does not correspond to a declared resolved write.');} } } diff --git a/php-transformer/tests/contract/production-acceptance-matrix.php b/php-transformer/tests/contract/production-acceptance-matrix.php index c3c69f5d..2870b3b2 100644 --- a/php-transformer/tests/contract/production-acceptance-matrix.php +++ b/php-transformer/tests/contract/production-acceptance-matrix.php @@ -127,6 +127,9 @@ $plan['pages'] = array(); $plan['routes'] = array(); $plan['operations'] = array(); +$plan['reporting']['source_documents'] = array(); +$plan['reporting']['metrics']['source_document_count'] = 0; +$plan['reporting']['metrics']['block_document_count'] = 0; file_put_contents($fixtures[0]['site_plan'], json_encode($plan)); $runFailure($fixtures, 'import', 'import_empty_site_plan'); diff --git a/php-transformer/tests/contract/support/ResolvedPlanProjection.php b/php-transformer/tests/contract/support/ResolvedPlanProjection.php new file mode 100644 index 00000000..d1771d49 --- /dev/null +++ b/php-transformer/tests/contract/support/ResolvedPlanProjection.php @@ -0,0 +1,31 @@ + $plan @param array $receipt @return array */ + public static function fromPlanAndReceipt(array $plan, array $receipt): array + { + $writes = array(); + foreach ($receipt['writes'] ?? array() as $write) { + if (!is_array($write) || 'written' !== ($write['status'] ?? null) || !is_string($write['target_path'] ?? null)) throw new InvalidArgumentException('Receipt write is invalid.'); + if (isset($writes[$write['target_path']])) throw new InvalidArgumentException('Receipt has colliding writes.'); + $writes[$write['target_path']] = true; + } + $declaredWrites = array(); + foreach ($plan['writes'] ?? array() as $write) $declaredWrites[$write['target_path'] ?? ''] = true; + if ($writes !== $declaredWrites) throw new InvalidArgumentException('Receipt writes do not match declared writes.'); + $receiptPages = array(); + foreach ($receipt['pages'] ?? array() as $page) if (is_array($page) && is_string($page['reconciliation_identity'] ?? null)) { if (isset($receiptPages[$page['reconciliation_identity']])) throw new InvalidArgumentException('Receipt has colliding pages.'); $receiptPages[$page['reconciliation_identity']] = true; } + $documents = array(); + $declaredPages = array(); + foreach ($plan['pages'] ?? array() as $page) { + if (!is_array($page) || !isset($receiptPages[$page['reconciliation_identity'] ?? ''])) throw new InvalidArgumentException('Receipt omits a resolved page.'); + $declaredPages[$page['reconciliation_identity']] = true; + $documents[] = array('source_path' => $page['source_path'], 'title' => $page['document_metadata']['title'], 'metadata' => $page['document_metadata']); + } + if ($receiptPages !== $declaredPages) throw new InvalidArgumentException('Receipt pages do not match resolved pages.'); + return array('documents' => $documents, 'reporting' => $plan['reporting'], 'write_count' => count($writes)); + } +} diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index 9a215efb..1b78c6dd 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -2,6 +2,7 @@ declare(strict_types=1); require dirname(__DIR__, 2) . '/vendor/autoload.php'; +require __DIR__ . '/support/ResolvedPlanProjection.php'; use Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler\ArtifactCompiler; use Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlan; @@ -14,11 +15,15 @@ $artifact = array( 'entrypoint' => 'index.html', 'files' => array( - 'index.html' => '

Entry Header

Home

Entry Footer

', - 'about.html' => '

About Chrome

About

', + 'index.html' => 'Home title

Entry Header

Home

Entry Footer

', + 'nested/about.html' => 'About title

About Chrome

About

', 'parts/sidebar.html' => '', 'assets/site.css' => '@font-face{font-family:test;src:url(assets/font.woff2)}main{background:url("assets/logo.svg")}', - 'assets/site.js' => 'window.siteAsset="assets/logo.svg";', + 'assets/async.js' => 'window.asyncAsset=true;', + 'assets/defer.js' => 'window.deferAsset=true;', + 'assets/both.js' => 'window.bothAsset=true;', + 'assets/module.js' => 'window.moduleAsset=true;', + 'assets/legacy.js' => 'window.legacyAsset=true;', 'assets/logo.svg' => '', 'assets/font.woff2' => 'font-data', ), @@ -36,13 +41,24 @@ $assert(str_contains((string) $writes['templates/front-page.html']['payload']['data'], '"slug":"header"') && str_contains((string) $writes['templates/front-page.html']['payload']['data'], '"slug":"footer"'), 'Front-page template references extracted header and footer parts.'); $assert(!str_contains((string) $writes['templates/page.html']['payload']['data'], '"slug":"header"') && !str_contains((string) $writes['templates/front-page.html']['payload']['data'], '"slug":"sidebar"'), 'Templates do not bind unproven or unbound parts.'); $pagesBySource = array(); foreach ($plan['pages'] as $page) $pagesBySource[$page['source_path']] = $page; -$assert(!str_contains((string) ($pagesBySource['index.html']['canonical_block_markup'] ?? ''), 'Entry Header') && str_contains((string) ($pagesBySource['about.html']['canonical_block_markup'] ?? ''), 'About Chrome'), 'Only extracted entry shell content is removed from page markup: ' . json_encode($pagesBySource)); +$assert(!str_contains((string) ($pagesBySource['index.html']['canonical_block_markup'] ?? ''), 'Entry Header') && str_contains((string) ($pagesBySource['nested/about.html']['canonical_block_markup'] ?? ''), 'About Chrome'), 'Only extracted entry shell content is removed from page markup: ' . json_encode($pagesBySource)); $assert('site_reading' === ($plan['operations'][0]['kind'] ?? null) && 'index.html' === ($plan['operations'][0]['front_page_source_path'] ?? null), 'Plan declares deterministic front-page desired state.'); $assert(str_contains((string) ($plan['pages'][0]['canonical_block_markup'] ?? ''), '{{wordpress-site-plan:asset:'), 'Canonical page markup uses declared destination-independent references.'); $assert(!isset($plan['pages'][0]['resolved_block_markup']), 'Canonical markup is explicitly distinct from resolved markup.'); $assert(count($plan['reference_tokens']) === count($plan['assets']), 'Every asset has one deterministic resolver token.'); $assert(true === ($plan['reference_semantics']['dynamic_client_assets']['materializer_may_reject'] ?? null), 'Plan exposes dynamic client asset capability limits.'); $assert($plan === ($second['source_reports']['wordpress_site_plan'] ?? null), 'Canonical WordPress site plans are deterministic.'); +$home = $pagesBySource['index.html']; +$assert('Home title' === ($home['document_metadata']['title'] ?? null) && 'head' === ($home['document_metadata']['title_declaration']['placement'] ?? null) && 'utf-8' === ($home['document_metadata']['meta'][0]['charset'] ?? null) && 'viewport' === ($home['document_metadata']['meta'][1]['name'] ?? null), 'Plan projects title, source context, charset, and viewport metadata from the compiler document report.'); +$assert(str_starts_with((string) ($home['document_metadata']['links'][0]['asset_reference'] ?? ''), WordPressSitePlan::TOKEN_PREFIX) && 'sha256-test' === ($home['document_metadata']['links'][0]['integrity'] ?? null) && 'anonymous' === ($home['document_metadata']['links'][0]['crossorigin'] ?? null) && 'https://cdn.example.test/path?x=1&y=2' === ($home['document_metadata']['links'][1]['url'] ?? null), 'Plan preserves unquoted, mixed-case local and external link declarations with safe URL punctuation and anonymous CORS semantics.'); +$scripts = $home['document_metadata']['scripts']; +$assert(true === ($scripts[0]['async'] ?? null) && false === ($scripts[0]['defer'] ?? null) && 'async' === ($scripts[0]['effective_loading'] ?? null) && 'anonymous' === ($scripts[0]['crossorigin'] ?? null) && '' === ($scripts[0]['referrerpolicy'] ?? null) && '' === ($scripts[0]['fetchpriority'] ?? null) && false === ($scripts[1]['async'] ?? null) && true === ($scripts[1]['defer'] ?? null) && 'defer' === ($scripts[1]['effective_loading'] ?? null) && true === ($scripts[2]['async'] ?? null) && true === ($scripts[2]['defer'] ?? null) && 'async' === ($scripts[2]['effective_loading'] ?? null) && true === ($scripts[3]['module'] ?? null) && 'defer' === ($scripts[3]['effective_loading'] ?? null) && true === ($scripts[4]['nomodule'] ?? null) && 'inline' === ($scripts[5]['source_kind'] ?? null), 'Plan preserves async, defer, async plus defer, module, nomodule, inline, and empty-valued standard attribute semantics independently.'); +$assert(2 === ($plan['reporting']['metrics']['source_document_count'] ?? null) && 2 === ($plan['reporting']['metrics']['block_document_count'] ?? null) && is_array($plan['reporting']['diagnostic_codes'] ?? null), 'Plan carries generic compiler reporting summaries and diagnostic linkage.'); + +$malformed = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '

Malformed

', 'assets/site.css' => 'body{}', 'assets/app.js' => 'window.app=true;')))->toArray(); +$malformedPlan = $malformed['source_reports']['wordpress_site_plan'] ?? array(); +$malformedPage = $malformedPlan['pages'][0] ?? array(); +$assert(2 === count($malformedPage['document_metadata']['links'] ?? array()) && str_starts_with((string) ($malformedPage['document_metadata']['links'][1]['asset_reference'] ?? ''), WordPressSitePlan::TOKEN_PREFIX) && true === ($malformedPage['document_metadata']['scripts'][0]['module'] ?? null) && true === ($malformedPage['document_metadata']['scripts'][0]['defer'] ?? null), 'Malformed attributes retain bounded declarations while later unquoted declarations and module defer semantics remain intact.'); $resolver = new WordPressSitePlanResolver(); $resolved = $resolver->resolve($plan, array('theme_uri' => 'https://example.test/wp-content/themes/site')); @@ -52,7 +68,17 @@ $assert(str_contains($about, 'https://example.test/wp-content/themes/site/assets/assets/logo.svg'), 'Nested page markup resolves declared assets to the explicit theme URI.'); $resolvedWrites = $writeMap($resolved['writes']); $assert(str_contains((string) $resolvedWrites['assets/assets/site.css']['payload']['data'], 'https://example.test/wp-content/themes/site/assets/assets/logo.svg'), 'Stylesheet references resolve through the same declared token.'); -$assert(str_contains((string) $resolvedWrites['assets/assets/site.js']['payload']['data'], 'https://example.test/wp-content/themes/site/assets/assets/logo.svg'), 'Script metadata references resolve through the same declared token.'); +$resolvedPages = array(); foreach ($resolved['pages'] as $page) $resolvedPages[$page['source_path']] = $page; +$assert('https://example.test/wp-content/themes/site/assets/assets/site.css' === ($resolvedPages['index.html']['document_metadata']['links'][0]['resolved_url'] ?? null) && 'https://example.test/wp-content/themes/site/assets/assets/async.js' === ($resolvedPages['index.html']['document_metadata']['scripts'][0]['resolved_url'] ?? null) && 'https://cdn.example.test/external.js' === ($resolvedPages['nested/about.html']['document_metadata']['scripts'][0]['url'] ?? null), 'Resolver resolves local document metadata references only through declared writes and preserves external URLs.'); + +// A downstream consumer needs only this public plan and its own receipt to project a stable report. +$receipt = array('writes' => array_map(static fn(array $write): array => array('target_path' => $write['target_path'], 'status' => 'written'), $resolved['writes']), 'pages' => array_map(static fn(array $page): array => array('reconciliation_identity' => $page['reconciliation_identity'], 'status' => 'written'), $resolved['pages'])); +$projection = ResolvedPlanProjection::fromPlanAndReceipt($resolved, $receipt); +$assert('Home title' === ($projection['documents'][0]['title'] ?? null) && 2 === ($projection['reporting']['metrics']['source_document_count'] ?? null) && count($resolved['writes']) === $projection['write_count'], 'An independent consumer derives document/report content from only the resolved plan and synthetic receipt.'); +$missingReceiptPage = $receipt; array_pop($missingReceiptPage['pages']); +$throws(static fn() => ResolvedPlanProjection::fromPlanAndReceipt($resolved, $missingReceiptPage), 'Independent projection rejects receipts that omit resolved pages.'); +$extraReceiptWrite = $receipt; $extraReceiptWrite['writes'][] = array('target_path' => 'outside-plan.json', 'status' => 'written'); +$throws(static fn() => ResolvedPlanProjection::fromPlanAndReceipt($resolved, $extraReceiptWrite), 'Independent projection rejects receipts that add undeclared writes.'); $destination = sys_get_temp_dir() . '/blocks-engine-site-plan-' . bin2hex(random_bytes(6)); foreach ($resolved['writes'] as $write) { @@ -60,7 +86,7 @@ if (!is_dir(dirname($path))) mkdir(dirname($path), 0777, true); file_put_contents($path, 'base64' === $write['payload']['encoding'] ? base64_decode($write['payload']['data'], true) : $write['payload']['data']); } -foreach (array('style.css', 'theme.json', 'functions.php', 'templates/index.html', 'templates/page.html', 'templates/front-page.html', 'parts/header.html', 'parts/footer.html', 'parts/sidebar.html', 'assets/assets/site.css', 'assets/assets/site.js', 'assets/assets/logo.svg', 'assets/assets/font.woff2') as $required) $assert(is_file($destination . '/' . $required), "Materialization writes {$required}."); +foreach (array('style.css', 'theme.json', 'functions.php', 'templates/index.html', 'templates/page.html', 'templates/front-page.html', 'parts/header.html', 'parts/footer.html', 'parts/sidebar.html', 'assets/assets/site.css', 'assets/assets/async.js', 'assets/assets/module.js', 'assets/assets/logo.svg', 'assets/assets/font.woff2') as $required) $assert(is_file($destination . '/' . $required), "Materialization writes {$required}."); $assert(false === str_contains((string) file_get_contents($destination . '/assets/assets/site.css'), WordPressSitePlan::TOKEN_PREFIX), 'Materialized assets contain no unresolved resolver tokens.'); $runtime = array('pages' => array(), 'front_page' => null); foreach ($resolved['pages'] as $page) $runtime['pages'][$page['reconciliation_identity']] = $page; @@ -83,6 +109,14 @@ $throws(static fn() => WordPressSitePlan::assertValid($invalidScaffold), 'Validation rejects malformed scaffold writes.'); $unresolvedLocal = $plan; $unresolvedLocal['pages'][0]['canonical_block_markup'] .= ''; $throws(static fn() => WordPressSitePlan::assertValid($unresolvedLocal), 'Validation rejects unresolved local browser references.'); +$invalidMetadata = $plan; $invalidMetadata['pages'][0]['document_metadata']['scripts'][0]['asset_reference'] = '{{wordpress-site-plan:asset:asset-0000000000000000}}'; +$throws(static fn() => WordPressSitePlan::assertValid($invalidMetadata), 'Validation rejects undeclared document metadata references.'); +$invalidLoad = $plan; $invalidLoad['pages'][0]['document_metadata']['scripts'][0]['load'] = 'later'; +$throws(static fn() => WordPressSitePlan::assertValid($invalidLoad), 'Validation rejects invalid document script load semantics.'); +$invalidOrder = $plan; $invalidOrder['pages'][0]['document_metadata']['links'][0]['order'] = 1; +$throws(static fn() => WordPressSitePlan::assertValid($invalidOrder), 'Validation rejects non-deterministic document metadata ordering.'); +$localMetadataUrl = $plan; $localMetadataUrl['pages'][0]['document_metadata']['links'][0]['asset_reference'] = null; $localMetadataUrl['pages'][0]['document_metadata']['links'][0]['url'] = 'assets/site.css'; +$throws(static fn() => WordPressSitePlan::assertValid($localMetadataUrl), 'Validation rejects local metadata URLs without canonical references.'); $invalidCompiledAsset = $first; $invalidCompiledAsset['source_reports']['compiled_site']['assets'][0]['target_path'] = 'C:\\theme\\site.css'; $throws(static fn() => (new WordPressSitePlan())->fromResult($invalidCompiledAsset), 'Projection rejects unsafe compiled asset targets.');