From f79767718bdb4653a70d4855928b69ce6bd2f71c Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 31 Jul 2026 18:50:02 -0400 Subject: [PATCH 1/9] Negotiate bounded artifact compiler limits --- .../src/ArtifactCompiler/ArtifactCompiler.php | 6 ++-- .../ArtifactCompiler/ArtifactNormalizer.php | 30 ++++++++++++++----- .../WordPressSitePlan/WordPressSitePlan.php | 2 +- php-transformer/tests/contract/run.php | 18 +++++++++++ .../tests/contract/wordpress-site-plan.php | 2 ++ 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 7ab6aaa58..46019bbc0 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -121,9 +121,9 @@ public function compile(array $artifact): TransformerResult 'files_by_source' => $this->countBy($normalized['files'], 'source'), 'files_by_intent' => $this->countBy($normalized['files'], 'intent'), 'limits' => array( - 'max_files' => ArtifactNormalizer::DEFAULT_MAX_FILES, - 'max_file_bytes' => ArtifactNormalizer::DEFAULT_MAX_FILE_BYTES, - 'max_total_bytes' => ArtifactNormalizer::DEFAULT_MAX_TOTAL_BYTES, + 'max_files' => $normalized['limits']['max_files'], + 'max_file_bytes' => $normalized['limits']['max_file_bytes'], + 'max_total_bytes' => $normalized['limits']['max_total_bytes'], ), 'source_hash' => hash('sha256', $normalized['hash_payload']), 'html' => array( diff --git a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php index 559ca15f8..768b63dcf 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php @@ -15,10 +15,13 @@ final class ArtifactNormalizer public const DEFAULT_MAX_FILES = 500; public const DEFAULT_MAX_FILE_BYTES = 5242880; public const DEFAULT_MAX_TOTAL_BYTES = 52428800; + public const MAX_FILES = 5000; + public const MAX_FILE_BYTES = 10485760; + public const MAX_TOTAL_BYTES = 335544320; /** * @param array $artifact - * @return array{files: array>, diagnostics: array>, rejected_count: int, bytes: int, entrypoints: array, hash_payload: string, runtime_declarations: array>} + * @return array{files: array>, diagnostics: array>, rejected_count: int, bytes: int, limits: array{max_files:int,max_file_bytes:int,max_total_bytes:int}, entrypoints: array, hash_payload: string, runtime_declarations: array>} */ public function normalize(array $artifact): array { @@ -29,6 +32,7 @@ public function normalize(array $artifact): array $rejected = 0; $bytes = 0; $seenPaths = array(); + $limits = $this->limits($artifact); foreach ( array('entrypoint', 'entry', 'main') as $key ) { if ( is_string($artifact[$key] ?? null) ) { @@ -63,9 +67,9 @@ public function normalize(array $artifact): array } foreach ( $rawFiles as $index => $file ) { - if ( count($files) >= self::DEFAULT_MAX_FILES ) { + if ( count($files) >= $limits['max_files'] ) { ++$rejected; - $diagnostics[] = $this->diagnostic('file_limit_exceeded', 'warning', 'Additional artifact files were ignored because the file limit was reached.', array('max_files' => self::DEFAULT_MAX_FILES)); + $diagnostics[] = $this->diagnostic('file_limit_exceeded', 'warning', 'Additional artifact files were ignored because the file limit was reached.', array('max_files' => $limits['max_files'])); break; } @@ -83,15 +87,15 @@ public function normalize(array $artifact): array continue; } - if ( $payload['bytes'] > self::DEFAULT_MAX_FILE_BYTES ) { + if ( $payload['bytes'] > $limits['max_file_bytes'] ) { ++$rejected; - $diagnostics[] = $this->diagnostic('artifact_file_too_large', 'warning', 'An artifact file was ignored because it exceeds the per-file byte limit.', array('path' => $path, 'bytes' => $payload['bytes'], 'max_file_bytes' => self::DEFAULT_MAX_FILE_BYTES)); + $diagnostics[] = $this->diagnostic('artifact_file_too_large', 'warning', 'An artifact file was ignored because it exceeds the per-file byte limit.', array('path' => $path, 'bytes' => $payload['bytes'], 'max_file_bytes' => $limits['max_file_bytes'])); continue; } - if ( $bytes + $payload['bytes'] > self::DEFAULT_MAX_TOTAL_BYTES ) { + if ( $bytes + $payload['bytes'] > $limits['max_total_bytes'] ) { ++$rejected; - $diagnostics[] = $this->diagnostic('artifact_total_too_large', 'warning', 'An artifact file was ignored because the bundle byte limit was reached.', array('path' => $path, 'bytes' => $payload['bytes'], 'max_total_bytes' => self::DEFAULT_MAX_TOTAL_BYTES)); + $diagnostics[] = $this->diagnostic('artifact_total_too_large', 'warning', 'An artifact file was ignored because the bundle byte limit was reached.', array('path' => $path, 'bytes' => $payload['bytes'], 'max_total_bytes' => $limits['max_total_bytes'])); continue; } @@ -160,12 +164,24 @@ public function normalize(array $artifact): array 'diagnostics' => $this->dedupeDiagnostics($diagnostics), 'rejected_count' => $rejected, 'bytes' => $bytes, + 'limits' => $limits, 'entrypoints' => array_values(array_unique($safeEntrypoints)), 'hash_payload' => $this->fileHashPayload($files) . "\n" . RuntimeDeclarations::canonicalJson($runtimeDeclarations), 'runtime_declarations' => $runtimeDeclarations, ); } + /** @param array $artifact @return array{max_files:int,max_file_bytes:int,max_total_bytes:int} */ + private function limits(array $artifact): array + { + $requested = is_array($artifact['compiler_limits'] ?? null) ? $artifact['compiler_limits'] : array(); + return array( + 'max_files' => min(self::MAX_FILES, max(1, (int) ($requested['max_files'] ?? self::DEFAULT_MAX_FILES))), + 'max_file_bytes' => min(self::MAX_FILE_BYTES, max(1, (int) ($requested['max_file_bytes'] ?? self::DEFAULT_MAX_FILE_BYTES))), + 'max_total_bytes' => min(self::MAX_TOTAL_BYTES, max(1, (int) ($requested['max_total_bytes'] ?? self::DEFAULT_MAX_TOTAL_BYTES))), + ); + } + /** * @param array $artifact * @return array> diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index f73a8cfcd..fa600b726 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php @@ -791,7 +791,7 @@ private static function assertNoLocalBrowserReferences(string $content): void $patterns = array( array('/\b(?:src|href|poster|action)\s*=\s*["\']([^"\']+)["\']/i', false), array('/\bsrcset\s*=\s*["\']([^"\']+)["\']/i', true), - array('/["\'](?:url|src|href|poster|action)["\']\s*:\s*["\']([^"\']+)["\']/i', false), + array('/["\'](?:url|src|href|poster)["\']\s*:\s*["\']([^"\']+)["\']/i', false), array('/["\']srcset["\']\s*:\s*["\']([^"\']+)["\']/i', true), ); foreach ($patterns as [$pattern, $commaSeparated]) if (preg_match_all($pattern, $content, $matches)) foreach ($matches[1] as $value) foreach ($commaSeparated ? explode(',', (string) $value) : array((string) $value) as $candidate) { diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index a4e6fe881..e150c67da 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -3505,6 +3505,24 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(1 === ($tooLarge['source_reports']['artifact']['rejected_count'] ?? null), 'oversized file increments rejected count'); $assert('artifact_file_too_large' === ($tooLarge['diagnostics'][0]['code'] ?? ''), 'oversized file diagnostic is exposed'); +$negotiatedLimits = (new ArtifactNormalizer())->normalize(array( + 'compiler_limits' => array( + 'max_files' => PHP_INT_MAX, + 'max_file_bytes' => ArtifactNormalizer::DEFAULT_MAX_FILE_BYTES + 1, + 'max_total_bytes' => PHP_INT_MAX, + ), + 'files' => array( + 'index.html' => '
OK
', + 'large.txt' => str_repeat('x', ArtifactNormalizer::DEFAULT_MAX_FILE_BYTES + 1), + ), +)); +$assert(2 === count($negotiatedLimits['files']), 'artifact compiler accepts files within explicitly negotiated limits'); +$assert(array( + 'max_files' => ArtifactNormalizer::MAX_FILES, + 'max_file_bytes' => ArtifactNormalizer::DEFAULT_MAX_FILE_BYTES + 1, + 'max_total_bytes' => ArtifactNormalizer::MAX_TOTAL_BYTES, +) === ($negotiatedLimits['limits'] ?? null), 'artifact compiler clamps negotiated limits to hard resource ceilings'); + assertSame('core/group', $result['blocks'][0]['blockName'], 'main wrapper should preserve multiple supported child blocks in a group.'); assertSame('core/heading', $result['blocks'][0]['innerBlocks'][0]['blockName'], 'h1 should convert to a heading block.'); assertSame(1, $result['blocks'][0]['innerBlocks'][0]['attrs']['level'], 'h1 level should be preserved.'); diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index ef1035938..5b5eae3b1 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -419,6 +419,8 @@ $throws(static fn() => WordPressSitePlan::assertValid($missingCreate), 'Validation rejects plans that omit a declared page creation operation.'); $unresolvedLocal = $plan; $unresolvedLocal['pages'][0]['canonical_block_markup'] .= ''; $unresolvedLocal['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($unresolvedLocal['pages'][0]['canonical_block_markup']); $throws(static fn() => WordPressSitePlan::assertValid($unresolvedLocal), 'Validation rejects unresolved local browser references.'); +$semanticAction = $plan; $semanticAction['pages'][0]['canonical_block_markup'] .= '
'; $semanticAction['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($semanticAction['pages'][0]['canonical_block_markup']); +WordPressSitePlan::assertValid($semanticAction); $assert(true, 'Validation does not classify semantic JSON action values as browser references.'); $dataSvg = $plan; $dataSvg['pages'][0]['canonical_block_markup'] .= '
'; $dataSvg['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($dataSvg['pages'][0]['canonical_block_markup']); WordPressSitePlan::assertValid($dataSvg); $assert(true, 'Validation preserves complete data URLs instead of treating the payload after its comma as a local reference.'); $encodedQuotedDataSvg = $plan; $encodedQuotedDataSvg['pages'][0]['canonical_block_markup'] .= '
'; $encodedQuotedDataSvg['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($encodedQuotedDataSvg['pages'][0]['canonical_block_markup']); From 17acb1134288562d4c080c9acc1812b2a4d3f0d9 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 31 Jul 2026 23:34:15 -0400 Subject: [PATCH 2/9] Report precise site plan validation failures --- .../src/ArtifactCompiler/ArtifactCompiler.php | 6 +- .../ArtifactCompiler/ArtifactNormalizer.php | 6 +- .../AssetReferenceCanonicalizer.php | 75 ++++++++++++++++--- .../WordPressSitePlan/ValidationException.php | 36 +++++++++ .../WordPressSitePlan/WordPressSitePlan.php | 70 +++++++++++------ .../tests/contract/wordpress-site-plan.php | 47 ++++++++++++ 6 files changed, 202 insertions(+), 38 deletions(-) create mode 100644 php-transformer/src/WordPressSitePlan/ValidationException.php diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 46019bbc0..0a0e79be1 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -15,6 +15,7 @@ use Automattic\BlocksEngine\PhpTransformer\Path\ArtifactPath; use Automattic\BlocksEngine\PhpTransformer\StaticSite\MaterializationPlanBuilder; use Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlan; +use Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\ValidationException; use DOMDocument; use DOMElement; @@ -193,7 +194,7 @@ public function compile(array $artifact): TransformerResult 'metrics' => $metrics, )); } catch (\InvalidArgumentException $exception) { - $sourceReports['wordpress_site_plan_diagnostics'] = array(array('code' => 'wordpress_site_plan_not_self_contained', 'message' => $exception->getMessage())); + $sourceReports['wordpress_site_plan_diagnostics'] = array($exception instanceof ValidationException ? $exception->diagnostic() : array('code' => 'wordpress_site_plan_not_self_contained', 'message' => $exception->getMessage())); } } @@ -2926,7 +2927,8 @@ private function compiledSiteAssets(array $assets): array 'selector' => $asset['selector'] ?? '', 'references' => $asset['references'] ?? array(), ), - static fn (mixed $value): bool => null !== $value && '' !== $value + static fn (mixed $value, string $key): bool => ('content' === $key && is_string($value)) || (null !== $value && '' !== $value), + ARRAY_FILTER_USE_BOTH ), $assets )); diff --git a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php index 768b63dcf..ff9c6435f 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php @@ -444,7 +444,11 @@ private function payload(array $file, string $path): array return array('accepted' => true, 'content' => $binary ? '' : $decoded, 'content_base64' => $base64, 'encoding' => 'base64', 'binary' => $binary, 'bytes' => strlen($decoded), 'diagnostics' => $diagnostics); } - $content = $this->normalizeContent($file['content'] ?? $file['body'] ?? $file['text'] ?? ''); + $contentKey = array_key_exists('content', $file) ? 'content' : (array_key_exists('body', $file) ? 'body' : (array_key_exists('text', $file) ? 'text' : null)); + if (null === $contentKey || !is_string($file[$contentKey])) { + return array('accepted' => false, 'content' => '', 'content_base64' => '', 'encoding' => 'text', 'binary' => false, 'bytes' => 0, 'diagnostics' => array($this->diagnostic('missing_file_payload', 'warning', 'An artifact file was ignored because it has no explicit text or base64 payload.', array('path' => $path)))); + } + $content = $this->normalizeContent($file[$contentKey]); return array('accepted' => true, 'content' => $content, 'content_base64' => '', 'encoding' => 'text', 'binary' => false, 'bytes' => strlen($content), 'diagnostics' => array()); } diff --git a/php-transformer/src/WordPressSitePlan/AssetReferenceCanonicalizer.php b/php-transformer/src/WordPressSitePlan/AssetReferenceCanonicalizer.php index 0305e8b78..61acb4b82 100644 --- a/php-transformer/src/WordPressSitePlan/AssetReferenceCanonicalizer.php +++ b/php-transformer/src/WordPressSitePlan/AssetReferenceCanonicalizer.php @@ -35,29 +35,80 @@ public function reference(string $reference, string $origin): ?string if ('' === $path || preg_match('~%2f|%5c|%2e~i', $path)) { return null; } - if (str_starts_with(str_replace('\\', '/', $path), '/')) { + $normalizedPath = str_replace('\\', '/', $path); + $externalPath = ltrim($normalizedPath, '/'); + $external = str_starts_with($externalPath, '_external/'); + if (str_starts_with($normalizedPath, '/')) { $identity = self::identity(ltrim($path, '/')); + } elseif ($external) { + // Downloaded remote assets retain this artifact-root staging namespace. + $identity = self::identity($path); + if (!isset($this->tokensBySource[$identity])) { + $relativeIdentity = self::relativeIdentity($path, $origin); + if (isset($this->tokensBySource[$relativeIdentity])) { + $identity = $relativeIdentity; + } + } } else { $identity = self::relativeIdentity($path, $origin); // Compiler block markup may already carry an artifact-relative identity. if (!isset($this->tokensBySource[$identity])) $identity = self::identity($path); } - return '' !== $identity && isset($this->tokensBySource[$identity]) ? $this->tokensBySource[$identity] . $suffix : null; + if ('' !== $identity && isset($this->tokensBySource[$identity])) { + return $this->tokensBySource[$identity] . $suffix; + } + // `_external/` is a transport-only prefix used by downloaded remote + // assets; match its declared artifact-relative identity when present. + if ($external) { + $relativeIdentity = self::relativeIdentity($externalPath, $origin); + if (isset($this->tokensBySource[$relativeIdentity])) { + return $this->tokensBySource[$relativeIdentity] . $suffix; + } + $stagedIdentity = substr($externalPath, strlen('_external/')); + if (isset($this->tokensBySource[$stagedIdentity])) { + return $this->tokensBySource[$stagedIdentity] . $suffix; + } + } + return null; } public function content(string $content, string $origin): string { $replace = fn(string $reference): string => $this->reference($reference, $origin) ?? $reference; - $content = preg_replace_callback('~(\b(?:src|href|poster)\s*=\s*\\\\")([^"\\\\]*)(\\\\")~is', static fn(array $match): string => $match[1] . $replace($match[2]) . $match[3], $content) ?? $content; - $content = preg_replace_callback('~(\bsrcset\s*=\s*\\\\")([^"\\\\]*)(\\\\")~is', static fn(array $match): string => $match[1] . self::srcset($match[2], $replace) . $match[3], $content) ?? $content; - $content = preg_replace_callback('~(\b(?:src|href|poster)\s*=\s*)(["\'])(.*?)\2~is', static fn(array $match): string => $match[1] . $match[2] . $replace($match[3]) . $match[2], $content) ?? $content; - $content = preg_replace_callback('~(\b(?:src|href|poster)\s*=\s*)([^\s>]+)~i', static fn(array $match): string => $match[1] . $replace($match[2]), $content) ?? $content; - $content = preg_replace_callback('~(\bsrcset\s*=\s*)(["\'])(.*?)\2~is', static fn(array $match): string => $match[1] . $match[2] . self::srcset($match[3], $replace) . $match[2], $content) ?? $content; - $content = preg_replace_callback('~(\bsrcset\s*=\s*)([^\s>]+)~i', static fn(array $match): string => $match[1] . self::srcset($match[2], $replace), $content) ?? $content; - $content = CssUrlRewriter::rewrite($content, $replace); - $content = preg_replace_callback('~(@import\s+)(["\'])([^"\']+)\2~i', static fn(array $match): string => $match[1] . $match[2] . $replace($match[3]) . $match[2], $content) ?? $content; - $content = preg_replace_callback('~(["\']srcset["\']\s*:\s*["\'])([^"\']*)(["\'])~i', static fn(array $match): string => $match[1] . self::srcset($match[2], $replace) . $match[3], $content) ?? $content; - return preg_replace_callback('~(["\'](?:url|src|href|srcset|poster)["\']\s*:\s*["\'])([^"\']*)(["\'])~i', static fn(array $match): string => $match[1] . $replace($match[2]) . $match[3], $content) ?? $content; + if (str_ends_with(strtolower($origin), '.css')) return self::css($content, $replace); + $content = preg_replace_callback('~<\s*[A-Za-z][A-Za-z0-9:-]*(?:\s+(?:"[^"]*"|\'[^\']*\'|[^\'"<>])*)?/?>~s', static fn(array $match): string => self::tag($match[0], $replace), $content) ?? $content; + $content = preg_replace_callback('~]*>(.*?)~is', static function (array $match) use ($replace): string { + return str_replace($match[1], self::css($match[1], $replace), $match[0]); + }, $content) ?? $content; + // Block comments are the supported serialized JSON transport. Restricting + // rewrites to them avoids treating arbitrary text or SVG data as markup. + return preg_replace_callback('~~is', static fn(array $match): string => self::json($match[0], $replace), $content) ?? $content; + } + + /** @param callable(string):string $replace */ + private static function tag(string $tag, callable $replace): string + { + return preg_replace_callback('~(? $match[1] . $match[2] . $replace($match[3]) . $match[2], $css) ?? $css; + } + + /** @param callable(string):string $replace */ + private static function json(string $comment, callable $replace): string + { + return preg_replace_callback('~((?:"|\\\\u0022)(url|src|href|poster|action|srcset)(?:"|\\\\u0022)\s*:\s*(?:"|\\\\u0022))(.*?)(?:"|\\\\u0022)~is', static function (array $match) use ($replace): string { + $value = 'srcset' === strtolower($match[2]) ? self::srcset($match[3], $replace) : $replace($match[3]); + return $match[1] . $value . (str_contains($match[0], '\\u0022') ? '\\u0022' : '"'); + }, $comment) ?? $comment; } /** @param callable(string):string $replace */ diff --git a/php-transformer/src/WordPressSitePlan/ValidationException.php b/php-transformer/src/WordPressSitePlan/ValidationException.php new file mode 100644 index 000000000..779020573 --- /dev/null +++ b/php-transformer/src/WordPressSitePlan/ValidationException.php @@ -0,0 +1,36 @@ + $context */ + public function __construct(string $message, private array $context) + { + parent::__construct($message); + } + + /** @return array */ + public function diagnostic(): array + { + $fields = is_array($this->context['fields'] ?? null) ? $this->context['fields'] : array(); + ksort($fields); + $boundedFields = array(); + $truncated = (int) ($this->context['fields_truncated'] ?? 0); + foreach ($fields as $key => $value) { + if (!is_string($key) || (!is_scalar($value) && null !== $value) || 20 === count($boundedFields)) { ++$truncated; continue; } + $key = substr($key, 0, 64); + if (isset($boundedFields[$key])) { ++$truncated; continue; } + $boundedFields[$key] = is_string($value) ? substr($value, 0, 256) : $value; + } + $index = $this->context['declaration_index'] ?? 0; + $index = is_int($index) ? $index : (is_string($index) && ctype_digit($index) ? (int) $index : 0); + $diagnostic = array('code' => 'wordpress_site_plan_invalid_declaration', 'message' => substr($this->getMessage(), 0, 256), 'source_path' => substr((string) ($this->context['source_path'] ?? ''), 0, 256), 'document_kind' => substr((string) ($this->context['document_kind'] ?? ''), 0, 64), 'declaration_kind' => substr((string) ($this->context['declaration_kind'] ?? ''), 0, 64), 'declaration_index' => max(0, $index), 'reason' => substr((string) ($this->context['reason'] ?? ''), 0, 64), 'fields' => $boundedFields); + if (0 < $truncated) $diagnostic['fields_truncated'] = $truncated; + return $diagnostic; + } +} diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index fa600b726..c821efe3a 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php @@ -781,24 +781,16 @@ private static function assertOperations(array $operations, array $pages): void } if (count($created) !== count($pages) || $reading !== (array() === array_filter($pages, static fn(array $page): bool => !empty($page['entrypoint'])) ? 0 : 1)) throw new InvalidArgumentException('WordPress site plan operations are incomplete.'); } - private static function assertNoLocalBrowserReferences(string $content): void + private static function assertNoLocalBrowserReferences(string $content, string $sourcePath = '', string $context = 'markup'): void { - $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8'); - $assertReference = static function (string $candidate): void { - $url = trim(preg_split('/\s+/', trim($candidate))[0] ?? ''); - if ('' !== $url && !str_starts_with($url, self::TOKEN_PREFIX) && !preg_match('~^(?:[a-z][a-z0-9+.-]*:|//|/|#|\?)~i', $url)) throw new InvalidArgumentException(sprintf('WordPress site plan contains unresolved local browser reference %s.', $url)); - }; + $assertReference = static function (string $candidate, string $attribute) use ($sourcePath, $context): void { $url = trim(preg_split('/\s+/', trim(html_entity_decode($candidate, ENT_QUOTES | ENT_HTML5, 'UTF-8')))[0] ?? ''); if ('' !== $url && !str_starts_with($url, self::TOKEN_PREFIX) && !preg_match('~^(?:[a-z][a-z0-9+.-]*:|//|/|#|\?)~i', $url)) throw new ValidationException(sprintf('WordPress site plan contains unresolved local browser reference %s.', $url), array('source_path' => $sourcePath, 'document_kind' => $context, 'declaration_kind' => 'browser_reference', 'declaration_index' => 0, 'reason' => 'unresolved_local_browser_reference', 'fields' => array('context' => $context, 'attribute' => $attribute, 'value' => $url))); }; + $assertCss = static function (string $css, string $cssContext) use ($assertReference): void { \Automattic\BlocksEngine\PhpTransformer\AssetAnalysis\CssUrlRewriter::rewrite(html_entity_decode($css, ENT_QUOTES | ENT_HTML5, 'UTF-8'), static function (string $url) use ($assertReference, $cssContext): string { $assertReference($url, $cssContext . ':url'); return $url; }); if (preg_match_all('/@import\s+(?:url\(\s*)?(?:"([^"]*)"|\'([^\']*)\'|([^\s\)"\';]+))/i', html_entity_decode($css, ENT_QUOTES | ENT_HTML5, 'UTF-8'), $matches, PREG_SET_ORDER)) foreach ($matches as $match) $assertReference((string) (($match[1] ?? '') ?: ($match[2] ?? '') ?: ($match[3] ?? '')), $cssContext . ':@import'); }; $patterns = array( - array('/\b(?:src|href|poster|action)\s*=\s*["\']([^"\']+)["\']/i', false), - array('/\bsrcset\s*=\s*["\']([^"\']+)["\']/i', true), - array('/["\'](?:url|src|href|poster)["\']\s*:\s*["\']([^"\']+)["\']/i', false), - array('/["\']srcset["\']\s*:\s*["\']([^"\']+)["\']/i', true), + array('~<\s*[A-Za-z][A-Za-z0-9:-]*(?:\s+(?:"[^"]*"|\'[^\']*\'|[^\'"<>])*)?/?>~s', false), ); - foreach ($patterns as [$pattern, $commaSeparated]) if (preg_match_all($pattern, $content, $matches)) foreach ($matches[1] as $value) foreach ($commaSeparated ? explode(',', (string) $value) : array((string) $value) as $candidate) { - $assertReference($candidate); - } - if (preg_match_all('/url\(\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s\)"\']+))\s*\)/i', $content, $matches, PREG_SET_ORDER)) foreach ($matches as $match) $assertReference((string) (($match[1] ?? '') ?: ($match[2] ?? '') ?: ($match[3] ?? ''))); - if (preg_match_all('/@import\s+(?:url\(\s*)?(?:"([^"]*)"|\'([^\']*)\'|([^\s\)"\';]+))/i', $content, $matches, PREG_SET_ORDER)) foreach ($matches as $match) $assertReference((string) (($match[1] ?? '') ?: ($match[2] ?? '') ?: ($match[3] ?? ''))); + foreach ($patterns as [$pattern]) if (preg_match_all($pattern, $content, $tags)) foreach ($tags[0] as $tag) if (preg_match_all('~(?`]+))~is', $tag, $attributes, PREG_SET_ORDER)) foreach ($attributes as $attribute) { $name = strtolower($attribute[1]); $value = (string) (($attribute[2] ?? '') ?: ($attribute[3] ?? '') ?: ($attribute[4] ?? '')); if ('style' === $name) { $assertCss($value, 'style_attribute'); continue; } foreach ('srcset' === $name ? explode(',', $value) : array($value) as $candidate) $assertReference($candidate, $name); } + if (preg_match_all('~]*>(.*?)~is', $content, $styles)) foreach ($styles[1] as $css) $assertCss($css, 'style_block'); + if (preg_match_all('~~is', $content, $comments)) foreach ($comments[0] as $comment) if (preg_match_all('~(?:"|\\\\u0022)(url|src|href|poster|action|srcset)(?:"|\\\\u0022)\s*:\s*(?:"|\\\\u0022)(.*?)(?:"|\\\\u0022)~is', $comment, $fields, PREG_SET_ORDER)) foreach ($fields as $field) foreach ('srcset' === strtolower($field[1]) ? explode(',', $field[2]) : array($field[2]) as $candidate) $assertReference((string) $candidate, 'json:' . strtolower($field[1])); } /** @param array $tokens @param array> $writes */ private static function assertResolution(array $plan, array $tokens, array $writes): void @@ -833,26 +825,58 @@ private static function assertResolvedMetadata(array $plan, array $references): } private static function assertRoute(array $page, string $entryRoot = ''): void { $route = $page['route'] ?? null; $expected = is_string($page['metadata']['route_path'] ?? null) && '' !== $page['metadata']['route_path'] ? self::canonicalRoutePath($page['metadata']['route_path']) : self::pageRoutePath($page['source_path'], $entryRoot); if (!is_array($route) || !is_string($route['path'] ?? null) || !preg_match('~^/(?:[a-z0-9-]+(?:/[a-z0-9-]+)*)?$~', $route['path']) || !is_string($route['parent_path'] ?? null) || !is_string($route['slug'] ?? null) || self::parentRoutePath($route['path']) !== $route['parent_path'] || self::routeSlug($route['path']) !== $route['slug'] || (!isset($page['synthetic']) && $route['path'] !== $expected) || (isset($page['synthetic']) && (true !== $page['synthetic'] || !str_starts_with((string) ($page['source_path'] ?? ''), 'wordpress-site-plan/routes/')))) throw new InvalidArgumentException('WordPress site plan page route is invalid.'); } /** @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['document_metadata']??null)||!is_array($document['provenance']??null)||!self::hash($document['reconciliation_identity']??null)||!self::hash($document['content_hash']??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&&$document['reconciliation_identity']!==self::identity('template-part',$document['source_path'],'parts/'.$document['slug'].'.html'))throw new InvalidArgumentException('WordPress site plan template part identity is invalid.');if($part&&in_array($document['placement']['kind']??null,array('entry_shell','shared_shell'),true)&&(!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']); } + 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)||!self::hash($document['reconciliation_identity']??null)||!self::hash($document['content_hash']??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&&$document['reconciliation_identity']!==self::identity('template-part',$document['source_path'],'parts/'.$document['slug'].'.html'))throw new InvalidArgumentException('WordPress site plan template part identity is invalid.');if($part&&in_array($document['placement']['kind']??null,array('entry_shell','shared_shell'),true)&&(!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,$document['source_path'],$kind);self::assertTokens($document['canonical_block_markup'],$tokens);self::assertNoLocalBrowserReferences($document['canonical_block_markup'],$document['source_path'],$kind); } /** @param array $metadata @param array $tokens */ - private static function assertDocumentMetadata(array $metadata, array $tokens): void + private static function assertDocumentMetadata(array $metadata, array $tokens, string $sourcePath, string $documentKind): 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['meta'] as $index => $row) { + if (!is_array($row)) self::invalidDeclaration('meta declaration', 'meta', $index, $sourcePath, $documentKind, 'invalid_structure', $row); + if ($index !== ($row['order'] ?? null)) self::invalidDeclaration('meta declaration', 'meta', $index, $sourcePath, $documentKind, 'invalid_order', $row); + if (!in_array($row['placement'] ?? null, array('head', 'body'), true)) self::invalidDeclaration('meta declaration', 'meta', $index, $sourcePath, $documentKind, 'invalid_placement', $row); + if (array_diff(array_keys($row), array('order', 'placement', 'charset', 'name', 'property', 'http_equiv', 'content'))) self::invalidDeclaration('meta declaration', 'meta', $index, $sourcePath, $documentKind, 'unsupported_field', $row); + } 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_array($row)) self::invalidDeclaration('link declaration', 'link', $index, $sourcePath, $documentKind, 'invalid_structure', $row); + if ($index !== ($row['order'] ?? null)) self::invalidDeclaration('link declaration', 'link', $index, $sourcePath, $documentKind, 'invalid_order', $row); + if (!in_array($row['placement'] ?? null, array('head', 'body'), true)) self::invalidDeclaration('link declaration', 'link', $index, $sourcePath, $documentKind, 'invalid_placement', $row); + if (!is_string($row['asset_reference'] ?? null) && !self::explicitUrl($row['url'] ?? null)) self::invalidDeclaration('link declaration', 'link', $index, $sourcePath, $documentKind, 'unresolved_local_url', $row); + if (array_diff(array_keys($row), array('order', 'placement', 'rel', 'type', 'media', 'integrity', 'crossorigin', 'referrerpolicy', 'as', 'fetchpriority', 'sizes', 'asset_reference', 'url', 'resolved_url'))) self::invalidDeclaration('link declaration', 'link', $index, $sourcePath, $documentKind, 'unsupported_field', $row); 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', 'selector', 'superseded_by'))) throw new InvalidArgumentException('WordPress site plan script declaration is invalid.'); - if (isset($row['superseded_by']) && (!is_string($row['selector'] ?? null) || !preg_match('/^script:nth-of-type\([1-9][0-9]*\)$/', $row['selector']) || !is_string($row['superseded_by']) || !preg_match('/^#[A-Za-z][A-Za-z0-9_-]*$/', $row['superseded_by']) || !self::hash($row['body_hash'] ?? null))) throw new InvalidArgumentException('WordPress site plan script supersession metadata is invalid.'); - if (is_string($row['asset_reference'] ?? null)) self::assertTokens($row['asset_reference'], $tokens); + if (!is_array($row)) self::invalidDeclaration('script declaration', 'script', $index, $sourcePath, $documentKind, 'invalid_structure', $row); + if ($index !== ($row['order'] ?? null)) self::invalidDeclaration('script declaration', 'script', $index, $sourcePath, $documentKind, 'invalid_order', $row); + if (!in_array($row['placement'] ?? null, array('head', 'body'), true)) self::invalidDeclaration('script declaration', 'script', $index, $sourcePath, $documentKind, 'invalid_placement', $row); + if (!is_string($row['asset_reference'] ?? null) && !self::explicitUrl($row['url'] ?? null) && 'inline' !== ($row['source_kind'] ?? null)) self::invalidDeclaration('script declaration', 'script', $index, $sourcePath, $documentKind, 'unresolved_local_url', $row); + if (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', 'selector', 'superseded_by'))) self::invalidDeclaration('script declaration', 'script', $index, $sourcePath, $documentKind, 'unsupported_field', $row); + if (!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'])) self::invalidDeclaration('script declaration', 'script', $index, $sourcePath, $documentKind, 'invalid_loading_semantics', $row); + if (isset($row['superseded_by']) && (!is_string($row['selector'] ?? null) || !preg_match('/^script:nth-of-type\([1-9][0-9]*\)$/', $row['selector']) || !is_string($row['superseded_by']) || !preg_match('/^#[A-Za-z][A-Za-z0-9_-]*$/', $row['superseded_by']) || !self::hash($row['body_hash'] ?? null))) self::invalidDeclaration('script declaration', 'script', $index, $sourcePath, $documentKind, 'invalid_supersession_metadata', $row); + if (is_string($row['asset_reference'] ?? null) && preg_match_all('/\{\{wordpress-site-plan:asset:([^}]+)\}\}/', $row['asset_reference'], $matches)) foreach ($matches[1] as $token) if (!isset($tokens[$token])) self::invalidDeclaration('script declaration', 'script', $index, $sourcePath, $documentKind, 'undeclared_asset_token', $row); + } + } + /** @param mixed $row */ + private static function invalidDeclaration(string $label, string $declarationKind, int|string $index, string $sourcePath, string $documentKind, string $reason, mixed $row): never + { + $fields = array(); + $truncated = 0; + if (is_array($row)) { + ksort($row); + foreach ($row as $key => $value) { + if (!is_string($key) || (!is_scalar($value) && null !== $value) || 20 === count($fields)) { ++$truncated; continue; } + $key = substr($key, 0, 64); + if (isset($fields[$key])) { ++$truncated; continue; } + $fields[$key] = is_string($value) ? substr($value, 0, 256) : $value; + } } + $context = array('source_path' => $sourcePath, 'document_kind' => $documentKind, 'declaration_kind' => $declarationKind, 'declaration_index' => $index, 'reason' => $reason, 'fields' => $fields); + if (0 < $truncated) $context['fields_truncated'] = $truncated; + throw new ValidationException("WordPress site plan {$label} is invalid: {$reason}.", $context); } /** @param array $reporting @param array $pagePaths @param array $tokens */ private static function assertReporting(array $reporting, array $pagePaths, array $tokens, array $diagnostics): 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.');$linked=array_fill_keys($reporting['diagnostic_codes'],true);foreach($reporting['diagnostic_codes'] as $code)if(!is_string($code)||''===$code)throw new InvalidArgumentException('WordPress site plan diagnostic linkage is invalid.');foreach($diagnostics as $diagnostic)if(is_array($diagnostic)&&is_string($diagnostic['code']??null)&&!isset($linked[$diagnostic['code']]))throw new InvalidArgumentException('WordPress site plan diagnostics are not linked to reporting.');} /** @param array $tokens */ - private static function assertWrite(mixed $write, array $tokens, bool $browserReferences): void { if (!is_array($write) || !is_string($write['kind'] ?? null) || !self::safePath($write['source_path'] ?? null) || !self::safePath($write['target_path'] ?? null) || !self::hash($write['reconciliation_identity'] ?? null) || !self::hash($write['payload_hash'] ?? null) || !is_array($write['payload'] ?? null) || !in_array($write['payload']['encoding'] ?? null, array('utf8','base64'), true) || !is_string($write['payload']['data'] ?? null) || $write['reconciliation_identity'] !== self::identity('write', $write['source_path'], $write['target_path']) || $write['payload_hash'] !== self::contentHash($write['payload']['data'])) throw new InvalidArgumentException('WordPress site plan write has a stale payload hash or invalid structure.'); 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); if ($browserReferences) self::assertNoLocalBrowserReferences($write['payload']['data']); } } + private static function assertWrite(mixed $write, array $tokens, bool $browserReferences): void { if (!is_array($write) || !is_string($write['kind'] ?? null) || !self::safePath($write['source_path'] ?? null) || !self::safePath($write['target_path'] ?? null) || !self::hash($write['reconciliation_identity'] ?? null) || !self::hash($write['payload_hash'] ?? null) || !is_array($write['payload'] ?? null) || !in_array($write['payload']['encoding'] ?? null, array('utf8','base64'), true) || !is_string($write['payload']['data'] ?? null) || $write['reconciliation_identity'] !== self::identity('write', $write['source_path'], $write['target_path']) || $write['payload_hash'] !== self::contentHash($write['payload']['data'])) throw new InvalidArgumentException('WordPress site plan write has a stale payload hash or invalid structure.'); 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); if ($browserReferences) self::assertNoLocalBrowserReferences(str_ends_with(strtolower($write['target_path']), '.css') ? '' : $write['payload']['data'], $write['source_path'], 'write'); } } /** @param array $tokens */ private static function assertTokens(string $content, array $tokens): void { if (preg_match_all('/\{\{wordpress-site-plan:asset:([^}]+)\}\}/', $content, $matches)) foreach ($matches[1] as $token) if (!isset($tokens[$token])) throw new InvalidArgumentException('WordPress site plan contains an undeclared reference token.'); } /** @param array $values */ diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index 5b5eae3b1..a86ffdb51 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -8,9 +8,12 @@ use Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler\RuntimeDeclarations; use Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlan; use Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlanResolver; +use Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\ValidationException; +use Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\AssetReferenceCanonicalizer; $assert = static function (bool $condition, string $message): void { if (! $condition) throw new RuntimeException($message); }; $throws = static function (callable $callback, string $message) use ($assert): void { try { $callback(); } catch (InvalidArgumentException) { return; } $assert(false, $message); }; +$validationFailure = static function (callable $callback) use ($assert): ValidationException { try { $callback(); } catch (ValidationException $exception) { return $exception; } $assert(false, 'Expected a contextual WordPress site plan validation failure.'); }; $writeMap = static function (array $writes): array { $map = array(); foreach ($writes as $write) $map[$write['target_path']] = $write; return $map; }; $artifact = array( @@ -52,6 +55,21 @@ $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.'); $assert(true === ($plan['quality']['pass'] ?? null) && ($plan['quality']['pass'] ?? null) === ('failed' !== ($plan['quality']['status'] ?? null)), 'Quality exposes one canonical pass predicate consistent with status.'); +$stagedFavicon = (new ArtifactCompiler())->compile(array('entrypoint' => 'website/index.html', 'files' => array('website/index.html' => '
Home
', 'images.squarespace-cdn.com/favicon.ico' => 'favicon')))->toArray(); +$stagedFaviconLink = $stagedFavicon['source_reports']['wordpress_site_plan']['pages'][0]['document_metadata']['links'][0] ?? array(); +$assert(str_starts_with((string) ($stagedFaviconLink['asset_reference'] ?? ''), WordPressSitePlan::TOKEN_PREFIX) && !isset($stagedFavicon['source_reports']['wordpress_site_plan_diagnostics']), 'Transport-prefixed external favicon URLs canonicalize to their matching artifact asset tokens.'); +$stagedRootFavicon = (new ArtifactCompiler())->compile(array('entrypoint' => 'website/index.html', 'files' => array('website/index.html' => '
Home
', 'images.squarespace-cdn.com/favicon.ico' => 'favicon')))->toArray(); +$assert(str_starts_with((string) ($stagedRootFavicon['source_reports']['wordpress_site_plan']['pages'][0]['document_metadata']['links'][0]['asset_reference'] ?? ''), WordPressSitePlan::TOKEN_PREFIX) && !isset($stagedRootFavicon['source_reports']['wordpress_site_plan_diagnostics']), 'Root-prefixed external favicon URLs canonicalize to their matching artifact asset tokens.'); +$stagedImage = (new ArtifactCompiler())->compile(array('entrypoint' => 'website/index.html', 'files' => array('website/index.html' => '
', 'website/_external/images.squarespace-cdn.com/hero.png' => array('content_base64' => base64_encode('image'), 'mime_type' => 'image/png'))))->toArray(); +$assert(str_contains((string) ($stagedImage['source_reports']['wordpress_site_plan']['pages'][0]['canonical_block_markup'] ?? ''), WordPressSitePlan::TOKEN_PREFIX) && !isset($stagedImage['source_reports']['wordpress_site_plan_diagnostics']), 'Artifact fallback markup canonicalizes escaped JSON image asset URLs.'); +$stagedCss = (new AssetReferenceCanonicalizer(array(array('source_path' => 'images.squarespace-cdn.com/hero.svg', 'token' => 'asset-0123456789abcdef'))))->content('main { a:url(_external/images.squarespace-cdn.com/hero.svg); b:url(/_external/images.squarespace-cdn.com/hero.svg); }', 'website/style.css'); +$assert(2 === substr_count($stagedCss, WordPressSitePlan::TOKEN_PREFIX . 'asset-0123456789abcdef}}'), 'Transport-prefixed and root-prefixed external CSS URLs canonicalize to matching artifact asset tokens.'); +$emptyStylesheet = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '
Home
', 'assets/empty.css' => array('content' => '', 'mime_type' => 'text/css'))))->toArray(); +$emptyPlan = $emptyStylesheet['source_reports']['wordpress_site_plan'] ?? array(); $emptyWrites = $writeMap($emptyPlan['writes'] ?? array()); +$assert(array() !== $emptyPlan && !isset($emptyStylesheet['source_reports']['wordpress_site_plan_diagnostics']) && '' === ($emptyWrites['assets/assets/empty.css']['payload']['data'] ?? null) && true === (static function () use ($emptyPlan): bool { WordPressSitePlan::assertValid($emptyPlan); return true; })(), 'Explicit empty CSS artifacts retain a self-contained token and zero-byte write.'); +$unresolvedFavicon = (new ArtifactCompiler())->compile(array('entrypoint' => 'website/index.html', 'files' => array('website/index.html' => '
Home
')))->toArray(); +$faviconDiagnostic = $unresolvedFavicon['source_reports']['wordpress_site_plan_diagnostics'][0] ?? array(); +$assert('wordpress_site_plan_invalid_declaration' === ($faviconDiagnostic['code'] ?? null) && 'website/index.html' === ($faviconDiagnostic['source_path'] ?? null) && 'page' === ($faviconDiagnostic['document_kind'] ?? null) && 'link' === ($faviconDiagnostic['declaration_kind'] ?? null) && 0 === ($faviconDiagnostic['declaration_index'] ?? null) && 'unresolved_local_url' === ($faviconDiagnostic['reason'] ?? null) && 'icon' === ($faviconDiagnostic['fields']['rel'] ?? null) && '_external/images.squarespace-cdn.com/favicon.ico' === ($faviconDiagnostic['fields']['url'] ?? null) && strlen(json_encode($faviconDiagnostic['fields'])) < 512, 'Compiler emits bounded SSI-facing diagnostics for unresolved favicon declarations.'); $changedContent = $artifact; $changedContent['files']['index.html'] = str_replace('

Home

', '

Updated Home

', $changedContent['files']['index.html']); $changedPlan = (new ArtifactCompiler())->compile($changedContent)->toArray()['source_reports']['wordpress_site_plan']; $changedPages = array(); foreach ($changedPlan['pages'] as $page) $changedPages[$page['source_path']] = $page; $assert(($pagesBySource['index.html']['reconciliation_identity'] ?? null) === ($changedPages['index.html']['reconciliation_identity'] ?? null) && ($pagesBySource['index.html']['content_hash'] ?? null) !== ($changedPages['index.html']['content_hash'] ?? null), 'Page reconciliation identity is stable across changed content while content_hash detects the change.'); @@ -419,6 +437,10 @@ $throws(static fn() => WordPressSitePlan::assertValid($missingCreate), 'Validation rejects plans that omit a declared page creation operation.'); $unresolvedLocal = $plan; $unresolvedLocal['pages'][0]['canonical_block_markup'] .= ''; $unresolvedLocal['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($unresolvedLocal['pages'][0]['canonical_block_markup']); $throws(static fn() => WordPressSitePlan::assertValid($unresolvedLocal), 'Validation rejects unresolved local browser references.'); +$unresolvedLocalDiagnostic = $validationFailure(static fn() => WordPressSitePlan::assertValid($unresolvedLocal))->diagnostic(); +$assert('index.html' === ($unresolvedLocalDiagnostic['source_path'] ?? null) && 'page' === ($unresolvedLocalDiagnostic['document_kind'] ?? null) && 'src' === ($unresolvedLocalDiagnostic['fields']['attribute'] ?? null) && 'images/missing.svg' === ($unresolvedLocalDiagnostic['fields']['value'] ?? null), 'Unresolved browser references expose bounded source, context, attribute, and value diagnostics.'); +$formAction = $plan; $formAction['pages'][0]['canonical_block_markup'] .= '
'; $formAction['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($formAction['pages'][0]['canonical_block_markup']); +$throws(static fn() => WordPressSitePlan::assertValid($formAction), 'Validation rejects unresolved local form action references.'); $semanticAction = $plan; $semanticAction['pages'][0]['canonical_block_markup'] .= '
'; $semanticAction['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($semanticAction['pages'][0]['canonical_block_markup']); WordPressSitePlan::assertValid($semanticAction); $assert(true, 'Validation does not classify semantic JSON action values as browser references.'); $dataSvg = $plan; $dataSvg['pages'][0]['canonical_block_markup'] .= '
'; $dataSvg['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($dataSvg['pages'][0]['canonical_block_markup']); @@ -427,12 +449,37 @@ WordPressSitePlan::assertValid($encodedQuotedDataSvg); $assert(true, 'Validation preserves data URLs with HTML-encoded CSS quotes.'); $unresolvedSrcset = $plan; $unresolvedSrcset['pages'][0]['canonical_block_markup'] .= ''; $unresolvedSrcset['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($unresolvedSrcset['pages'][0]['canonical_block_markup']); $throws(static fn() => WordPressSitePlan::assertValid($unresolvedSrcset), 'Validation still rejects unresolved members of comma-separated srcset references.'); +$inlineSvgPath = $plan; $inlineSvgPath['pages'][0]['canonical_block_markup'] .= ''; $inlineSvgPath['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($inlineSvgPath['pages'][0]['canonical_block_markup']); +WordPressSitePlan::assertValid($inlineSvgPath); $assert(true, 'Validation ignores inline SVG geometry data.'); +$unresolvedSvgHref = $plan; $unresolvedSvgHref['pages'][0]['canonical_block_markup'] .= ''; $unresolvedSvgHref['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($unresolvedSvgHref['pages'][0]['canonical_block_markup']); +$throws(static fn() => WordPressSitePlan::assertValid($unresolvedSvgHref), 'Validation rejects unresolved SVG href values while preserving fragment semantics.'); +$svgFragment = $plan; $svgFragment['pages'][0]['canonical_block_markup'] .= ''; $svgFragment['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($svgFragment['pages'][0]['canonical_block_markup']); +WordPressSitePlan::assertValid($svgFragment); $assert(true, 'Validation preserves SVG fragment href values.'); +$unresolvedCss = $plan; $unresolvedCss['pages'][0]['canonical_block_markup'] .= '
'; $unresolvedCss['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($unresolvedCss['pages'][0]['canonical_block_markup']); +$throws(static fn() => WordPressSitePlan::assertValid($unresolvedCss), 'Validation rejects unresolved CSS URLs in style attributes.'); $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.'); +$invalidOrderDiagnostic = $validationFailure(static fn() => WordPressSitePlan::assertValid($invalidOrder))->diagnostic(); +$assert('wordpress_site_plan_invalid_declaration' === ($invalidOrderDiagnostic['code'] ?? null) && 'index.html' === ($invalidOrderDiagnostic['source_path'] ?? null) && 'page' === ($invalidOrderDiagnostic['document_kind'] ?? null) && 'link' === ($invalidOrderDiagnostic['declaration_kind'] ?? null) && 0 === ($invalidOrderDiagnostic['declaration_index'] ?? null) && 'invalid_order' === ($invalidOrderDiagnostic['reason'] ?? null), 'Public validation preserves InvalidArgumentException compatibility while exposing exact invalid link ordering context.'); +$invalidField = $plan; $invalidField['pages'][0]['document_metadata']['links'][0][str_repeat('k', 100)] = str_repeat('x', 1024); for ($field = 0; $field < 25; ++$field) $invalidField['pages'][0]['document_metadata']['links'][0]['unexpected-' . $field] = $field; +$invalidFieldDiagnostic = $validationFailure(static fn() => WordPressSitePlan::assertValid($invalidField))->diagnostic(); +$invalidFields = $invalidFieldDiagnostic['fields'] ?? array(); $invalidFieldKeys = array_keys($invalidFields); $fieldLengths = true; foreach ($invalidFields as $key => $value) if (strlen((string) $key) > 64 || (is_string($value) && strlen($value) > 256)) $fieldLengths = false; +$assert('unsupported_field' === ($invalidFieldDiagnostic['reason'] ?? null) && count($invalidFields) === 20 && $invalidFieldKeys === array_values(array_unique($invalidFieldKeys)) && $invalidFieldKeys === (static function (array $keys): array { $sorted = $keys; sort($sorted, SORT_STRING); return $sorted; })($invalidFieldKeys) && $fieldLengths && is_int($invalidFieldDiagnostic['fields_truncated'] ?? null) && 0 < $invalidFieldDiagnostic['fields_truncated'], 'Unsupported declaration fields are deterministically bounded by count and key/value length.'); +$invalidScript = $plan; $invalidScript['pages'][0]['document_metadata']['scripts'][0]['order'] = 1; +$invalidScriptDiagnostic = $validationFailure(static fn() => WordPressSitePlan::assertValid($invalidScript))->diagnostic(); +$assert('script' === ($invalidScriptDiagnostic['declaration_kind'] ?? null) && 'invalid_order' === ($invalidScriptDiagnostic['reason'] ?? null) && 'index.html' === ($invalidScriptDiagnostic['source_path'] ?? null), 'Script declaration failures expose the same exact structured context.'); +$invalidScriptLoading = $plan; $invalidScriptLoading['pages'][0]['document_metadata']['scripts'][0]['effective_loading'] = 'blocking'; +$assert('invalid_loading_semantics' === ($validationFailure(static fn() => WordPressSitePlan::assertValid($invalidScriptLoading))->diagnostic()['reason'] ?? null), 'Invalid script loading semantics expose structured context.'); +$invalidScriptSupersession = $plan; $invalidScriptSupersession['pages'][0]['document_metadata']['scripts'][0]['superseded_by'] = '#invalid'; +$assert('invalid_supersession_metadata' === ($validationFailure(static fn() => WordPressSitePlan::assertValid($invalidScriptSupersession))->diagnostic()['reason'] ?? null), 'Invalid script supersession metadata exposes structured context.'); +$invalidScriptToken = $plan; $invalidScriptToken['pages'][0]['document_metadata']['scripts'][0]['asset_reference'] = '{{wordpress-site-plan:asset:asset-0000000000000000}}'; +$assert('undeclared_asset_token' === ($validationFailure(static fn() => WordPressSitePlan::assertValid($invalidScriptToken))->diagnostic()['reason'] ?? null), 'Undeclared script asset tokens expose structured context.'); +$invalidMeta = $plan; $invalidMeta['pages'][0]['source_path'] = str_repeat('source/', 100) . 'index.html'; $invalidMetaIndex = count($invalidMeta['pages'][0]['document_metadata']['meta']); $invalidMeta['pages'][0]['document_metadata']['meta'][] = array('order' => $invalidMetaIndex, 'placement' => 'head', str_repeat('k', 100) => str_repeat('x', 1024)); $invalidMetaDiagnostic = $validationFailure(static fn() => WordPressSitePlan::assertValid($invalidMeta))->diagnostic(); +$assert('meta' === ($invalidMetaDiagnostic['declaration_kind'] ?? null) && 'unsupported_field' === ($invalidMetaDiagnostic['reason'] ?? null) && 256 === strlen((string) ($invalidMetaDiagnostic['source_path'] ?? '')) && 64 === strlen((string) array_key_first($invalidMetaDiagnostic['fields'] ?? array())) && 256 === strlen((string) ($invalidMetaDiagnostic['fields'][str_repeat('k', 64)] ?? '')), 'Meta diagnostics bound source context and field key/value lengths.'); $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'; From 28cc03cc776d24a0e83ab2d760d8946b5213e635 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 1 Aug 2026 02:29:41 -0400 Subject: [PATCH 3/9] Scan browser references by HTML context --- .../WordPressSitePlan/WordPressSitePlan.php | 64 +++++++++++++++++-- .../tests/contract/wordpress-site-plan.php | 33 ++++++++++ 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index c821efe3a..1f52eff2c 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php @@ -785,12 +785,64 @@ private static function assertNoLocalBrowserReferences(string $content, string $ { $assertReference = static function (string $candidate, string $attribute) use ($sourcePath, $context): void { $url = trim(preg_split('/\s+/', trim(html_entity_decode($candidate, ENT_QUOTES | ENT_HTML5, 'UTF-8')))[0] ?? ''); if ('' !== $url && !str_starts_with($url, self::TOKEN_PREFIX) && !preg_match('~^(?:[a-z][a-z0-9+.-]*:|//|/|#|\?)~i', $url)) throw new ValidationException(sprintf('WordPress site plan contains unresolved local browser reference %s.', $url), array('source_path' => $sourcePath, 'document_kind' => $context, 'declaration_kind' => 'browser_reference', 'declaration_index' => 0, 'reason' => 'unresolved_local_browser_reference', 'fields' => array('context' => $context, 'attribute' => $attribute, 'value' => $url))); }; $assertCss = static function (string $css, string $cssContext) use ($assertReference): void { \Automattic\BlocksEngine\PhpTransformer\AssetAnalysis\CssUrlRewriter::rewrite(html_entity_decode($css, ENT_QUOTES | ENT_HTML5, 'UTF-8'), static function (string $url) use ($assertReference, $cssContext): string { $assertReference($url, $cssContext . ':url'); return $url; }); if (preg_match_all('/@import\s+(?:url\(\s*)?(?:"([^"]*)"|\'([^\']*)\'|([^\s\)"\';]+))/i', html_entity_decode($css, ENT_QUOTES | ENT_HTML5, 'UTF-8'), $matches, PREG_SET_ORDER)) foreach ($matches as $match) $assertReference((string) (($match[1] ?? '') ?: ($match[2] ?? '') ?: ($match[3] ?? '')), $cssContext . ':@import'); }; - $patterns = array( - array('~<\s*[A-Za-z][A-Za-z0-9:-]*(?:\s+(?:"[^"]*"|\'[^\']*\'|[^\'"<>])*)?/?>~s', false), - ); - foreach ($patterns as [$pattern]) if (preg_match_all($pattern, $content, $tags)) foreach ($tags[0] as $tag) if (preg_match_all('~(?`]+))~is', $tag, $attributes, PREG_SET_ORDER)) foreach ($attributes as $attribute) { $name = strtolower($attribute[1]); $value = (string) (($attribute[2] ?? '') ?: ($attribute[3] ?? '') ?: ($attribute[4] ?? '')); if ('style' === $name) { $assertCss($value, 'style_attribute'); continue; } foreach ('srcset' === $name ? explode(',', $value) : array($value) as $candidate) $assertReference($candidate, $name); } - if (preg_match_all('~]*>(.*?)~is', $content, $styles)) foreach ($styles[1] as $css) $assertCss($css, 'style_block'); - if (preg_match_all('~~is', $content, $comments)) foreach ($comments[0] as $comment) if (preg_match_all('~(?:"|\\\\u0022)(url|src|href|poster|action|srcset)(?:"|\\\\u0022)\s*:\s*(?:"|\\\\u0022)(.*?)(?:"|\\\\u0022)~is', $comment, $fields, PREG_SET_ORDER)) foreach ($fields as $field) foreach ('srcset' === strtolower($field[1]) ? explode(',', $field[2]) : array($field[2]) as $candidate) $assertReference((string) $candidate, 'json:' . strtolower($field[1])); + foreach (self::htmlMarkupNodes($content) as $node) { + if ('tag' === $node['kind']) foreach ($node['attributes'] as $name => $value) { + if (!in_array($name, array('xlink:href', 'srcset', 'src', 'href', 'poster', 'action', 'style'), true)) continue; + if ('style' === $name) { $assertCss($value, 'style_attribute'); continue; } + foreach ('srcset' === $name ? self::srcsetCandidates($value) : array($value) as $candidate) $assertReference($candidate, $name); + } + if ('style' === $node['kind']) $assertCss($node['css'], 'style_block'); + if ('comment' === $node['kind'] && preg_match('~^\s*wp:~i', $node['content']) && preg_match_all('~(?:"|\\\\u0022)(url|src|href|poster|action|srcset)(?:"|\\\\u0022)\s*:\s*(?:"|\\\\u0022)(.*?)(?:"|\\\\u0022)~is', $node['content'], $fields, PREG_SET_ORDER)) foreach ($fields as $field) foreach ('srcset' === strtolower($field[1]) ? self::srcsetCandidates($field[2]) : array($field[2]) as $candidate) $assertReference((string) $candidate, 'json:' . strtolower($field[1])); + } + } + /** @return array */ + private static function srcsetCandidates(string $srcset): array + { + $candidates = array(); $length = strlen($srcset); $offset = 0; + while ($offset < $length) { + while ($offset < $length && (ctype_space($srcset[$offset]) || ',' === $srcset[$offset])) ++$offset; + if ($offset >= $length) break; + $start = $offset; $data = str_starts_with(strtolower(substr($srcset, $offset)), 'data:'); + while ($offset < $length && !ctype_space($srcset[$offset]) && ($data || ',' !== $srcset[$offset])) ++$offset; + $url = substr($srcset, $start, $offset - $start); if ('' !== $url) $candidates[] = $url; + while ($offset < $length && ',' !== $srcset[$offset]) ++$offset; + if ($offset < $length) ++$offset; + } + return $candidates; + } + /** @return array> */ + private static function htmlMarkupNodes(string $content): array + { + $nodes = array(); $length = strlen($content); $offset = 0; + while ($offset < $length) { + $start = strpos($content, '<', $offset); if (false === $start) break; + if (str_starts_with(substr($content, $start), '', $start + 4); if (false === $end) break; $nodes[] = array('kind' => 'comment', 'content' => substr($content, $start + 4, $end - $start - 4)); $offset = $end + 3; continue; } + if ($start + 1 < $length && '!' === $content[$start + 1]) { if (str_starts_with(substr($content, $start), '', $start + 9); $offset = false === $end ? $length : $end + 3; continue; } $cursor = $start + 2; $quote = ''; while ($cursor < $length) { if ('' !== $quote) { if ($quote === $content[$cursor]) $quote = ''; ++$cursor; continue; } if ('"' === $content[$cursor] || "'" === $content[$cursor]) { $quote = $content[$cursor++]; continue; } if ('>' === $content[$cursor++]) break; } $offset = $cursor; continue; } + $cursor = $start + 1; if ($cursor >= $length || !ctype_alpha($content[$cursor])) { $offset = $cursor; continue; } + $nameStart = $cursor; while ($cursor < $length && preg_match('/[A-Za-z0-9:-]/', $content[$cursor])) ++$cursor; + $name = strtolower(substr($content, $nameStart, $cursor - $nameStart)); $attributes = array(); + while ($cursor < $length) { + while ($cursor < $length && ctype_space($content[$cursor])) ++$cursor; + if ($cursor >= $length) break; + if ('>' === $content[$cursor] || ('/' === $content[$cursor] && $cursor + 1 < $length && '>' === $content[$cursor + 1])) { $cursor += '>' === $content[$cursor] ? 1 : 2; $nodes[] = array('kind' => 'tag', 'name' => $name, 'attributes' => $attributes); if ('style' === $name) { $closing = self::rawTextEnd($content, $name, $cursor); if (null !== $closing) { $nodes[] = array('kind' => 'style', 'css' => substr($content, $cursor, $closing[0] - $cursor)); $offset = $closing[1]; } else { $nodes[] = array('kind' => 'style', 'css' => substr($content, $cursor)); $offset = $length; } continue 2; } if ('plaintext' === $name) { $offset = $length; continue 2; } if (in_array($name, array('script', 'textarea', 'title', 'xmp', 'iframe', 'noembed', 'noframes', 'noscript'), true)) { $closing = self::rawTextEnd($content, $name, $cursor); $offset = null === $closing ? $length : $closing[1]; continue 2; } $offset = $cursor; continue 2; } + $attributeStart = $cursor; while ($cursor < $length && !ctype_space($content[$cursor]) && !str_contains('=/>', $content[$cursor])) ++$cursor; + if ($attributeStart === $cursor) { ++$cursor; continue; } + $attribute = strtolower(substr($content, $attributeStart, $cursor - $attributeStart)); while ($cursor < $length && ctype_space($content[$cursor])) ++$cursor; + if ($cursor >= $length || '=' !== $content[$cursor]) { if (!array_key_exists($attribute, $attributes)) $attributes[$attribute] = ''; continue; } + ++$cursor; while ($cursor < $length && ctype_space($content[$cursor])) ++$cursor; + if ($cursor >= $length) { if (!array_key_exists($attribute, $attributes)) $attributes[$attribute] = ''; break; } + if ('"' === $content[$cursor] || "'" === $content[$cursor]) { $quote = $content[$cursor++]; $valueStart = $cursor; while ($cursor < $length && $quote !== $content[$cursor]) ++$cursor; if (!array_key_exists($attribute, $attributes)) $attributes[$attribute] = substr($content, $valueStart, $cursor - $valueStart); if ($cursor < $length) ++$cursor; continue; } + $valueStart = $cursor; while ($cursor < $length && !ctype_space($content[$cursor]) && '>' !== $content[$cursor]) ++$cursor; if (!array_key_exists($attribute, $attributes)) $attributes[$attribute] = substr($content, $valueStart, $cursor - $valueStart); + } + $nodes[] = array('kind' => 'tag', 'name' => $name, 'attributes' => $attributes); $offset = $cursor; + } + return $nodes; + } + /** @return array{0:int,1:int}|null */ + private static function rawTextEnd(string $content, string $name, int $offset): ?array + { + if (!preg_match('~])[^>]*>~i', $content, $match, PREG_OFFSET_CAPTURE, $offset)) return null; + return array($match[0][1], $match[0][1] + strlen($match[0][0])); } /** @param array $tokens @param array> $writes */ private static function assertResolution(array $plan, array $tokens, array $writes): void diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index a86ffdb51..fbaae0eee 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -439,16 +439,49 @@ $throws(static fn() => WordPressSitePlan::assertValid($unresolvedLocal), 'Validation rejects unresolved local browser references.'); $unresolvedLocalDiagnostic = $validationFailure(static fn() => WordPressSitePlan::assertValid($unresolvedLocal))->diagnostic(); $assert('index.html' === ($unresolvedLocalDiagnostic['source_path'] ?? null) && 'page' === ($unresolvedLocalDiagnostic['document_kind'] ?? null) && 'src' === ($unresolvedLocalDiagnostic['fields']['attribute'] ?? null) && 'images/missing.svg' === ($unresolvedLocalDiagnostic['fields']['value'] ?? null), 'Unresolved browser references expose bounded source, context, attribute, and value diagnostics.'); +$duplicateLocalFirst = $plan; $duplicateLocalFirst['pages'][0]['canonical_block_markup'] .= ''; $duplicateLocalFirst['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($duplicateLocalFirst['pages'][0]['canonical_block_markup']); +$throws(static fn() => WordPressSitePlan::assertValid($duplicateLocalFirst), 'Validation honors the first duplicate browser attribute value.'); +$duplicateExternalFirst = $plan; $duplicateExternalFirst['pages'][0]['canonical_block_markup'] .= ''; $duplicateExternalFirst['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($duplicateExternalFirst['pages'][0]['canonical_block_markup']); +WordPressSitePlan::assertValid($duplicateExternalFirst); $assert(true, 'Validation ignores later duplicate browser attribute values.'); $formAction = $plan; $formAction['pages'][0]['canonical_block_markup'] .= '
'; $formAction['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($formAction['pages'][0]['canonical_block_markup']); $throws(static fn() => WordPressSitePlan::assertValid($formAction), 'Validation rejects unresolved local form action references.'); $semanticAction = $plan; $semanticAction['pages'][0]['canonical_block_markup'] .= '
'; $semanticAction['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($semanticAction['pages'][0]['canonical_block_markup']); WordPressSitePlan::assertValid($semanticAction); $assert(true, 'Validation does not classify semantic JSON action values as browser references.'); +$embeddedIframe = $plan; $embeddedIframe['pages'][0]['canonical_block_markup'] .= '
'; $embeddedIframe['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($embeddedIframe['pages'][0]['canonical_block_markup']); +WordPressSitePlan::assertValid($embeddedIframe); $assert(true, 'Validation does not scan entity-escaped iframe attributes embedded in data-html.'); +$srcdocMarkup = $plan; $srcdocMarkup['pages'][0]['canonical_block_markup'] .= ''; $srcdocMarkup['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($srcdocMarkup['pages'][0]['canonical_block_markup']); +WordPressSitePlan::assertValid($srcdocMarkup); $assert(true, 'Validation does not scan markup embedded in srcdoc.'); +$deceptiveAttribute = $plan; $deceptiveAttribute['pages'][0]['canonical_block_markup'] .= '
'; $deceptiveAttribute['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($deceptiveAttribute['pages'][0]['canonical_block_markup']); +WordPressSitePlan::assertValid($deceptiveAttribute); $assert(true, 'Validation does not scan attribute-looking text inside another attribute value.'); +$rawTextMarkup = $plan; $rawTextMarkup['pages'][0]['canonical_block_markup'] .= '<img src=images/missing.svg><img src=images/missing.svg><img src=images/missing.svg><img src=images/missing.svg><img src=images/missing.svg>'; $rawTextMarkup['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($rawTextMarkup['pages'][0]['canonical_block_markup']); +WordPressSitePlan::assertValid($rawTextMarkup); $assert(true, 'Validation does not scan tag-like strings inside raw-text and RCDATA elements.'); +$afterRawText = $plan; $afterRawText['pages'][0]['canonical_block_markup'] .= '<script>"<img src=images/missing.svg>"</script><img src="images/missing.svg">'; $afterRawText['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($afterRawText['pages'][0]['canonical_block_markup']); +$throws(static fn() => WordPressSitePlan::assertValid($afterRawText), 'Validation resumes scanning after a raw-text element closes.'); +$rawTextCloseShapes = array('</script/>', '</script data-x>', '</ScRiPt / >'); foreach ($rawTextCloseShapes as $close) { $rawTextClose = $plan; $rawTextClose['pages'][0]['canonical_block_markup'] .= '<script>"<img src=images/missing.svg>"' . $close . '<img src="images/missing.svg">'; $rawTextClose['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($rawTextClose['pages'][0]['canonical_block_markup']); $throws(static fn() => WordPressSitePlan::assertValid($rawTextClose), 'Validation recognizes an appropriate raw-text end tag and resumes after its closing bracket.'); } +$rawTextPrefix = $plan; $rawTextPrefix['pages'][0]['canonical_block_markup'] .= '<script></scripture><img src="images/missing.svg"></script>'; $rawTextPrefix['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($rawTextPrefix['pages'][0]['canonical_block_markup']); +WordPressSitePlan::assertValid($rawTextPrefix); $assert(true, 'Validation does not treat a raw-text end-tag prefix as an appropriate end tag.'); +$eofStartTag = $plan; $eofStartTag['pages'][0]['canonical_block_markup'] .= '<img src=images/missing.svg'; $eofStartTag['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($eofStartTag['pages'][0]['canonical_block_markup']); +$throws(static fn() => WordPressSitePlan::assertValid($eofStartTag), 'Validation emits an unterminated start tag at EOF.'); +$selfClosingRawText = $plan; $selfClosingRawText['pages'][0]['canonical_block_markup'] .= '<script/><img src="images/missing.svg">'; $selfClosingRawText['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($selfClosingRawText['pages'][0]['canonical_block_markup']); +WordPressSitePlan::assertValid($selfClosingRawText); $assert(true, 'Validation ignores the self-closing flag on non-void raw-text elements.'); +$selfClosingRawTextClose = $plan; $selfClosingRawTextClose['pages'][0]['canonical_block_markup'] .= '<script/><img src="images/missing.svg"></script><img src="images/missing.svg">'; $selfClosingRawTextClose['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($selfClosingRawTextClose['pages'][0]['canonical_block_markup']); +$throws(static fn() => WordPressSitePlan::assertValid($selfClosingRawTextClose), 'Validation resumes after an actual end tag for a self-closing raw-text start tag.'); +$svgCdata = $plan; $svgCdata['pages'][0]['canonical_block_markup'] .= '<svg><![CDATA[<img src="images/missing.svg">]]></svg>'; $svgCdata['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($svgCdata['pages'][0]['canonical_block_markup']); +WordPressSitePlan::assertValid($svgCdata); $assert(true, 'Validation does not scan tag-like character data inside SVG CDATA declarations.'); +$declarationThenImage = $plan; $declarationThenImage['pages'][0]['canonical_block_markup'] .= '<!DOCTYPE svg PUBLIC "example>quoted"><img src="images/missing.svg">'; $declarationThenImage['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($declarationThenImage['pages'][0]['canonical_block_markup']); +$throws(static fn() => WordPressSitePlan::assertValid($declarationThenImage), 'Validation resumes after quote-aware markup declarations.'); +$noscriptMarkup = $plan; $noscriptMarkup['pages'][0]['canonical_block_markup'] .= '<noscript><img src="images/missing.svg"></noscript>'; $noscriptMarkup['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($noscriptMarkup['pages'][0]['canonical_block_markup']); +WordPressSitePlan::assertValid($noscriptMarkup); $assert(true, 'Validation treats noscript contents as opaque for scripting-enabled browser validation.'); +$afterNoscript = $plan; $afterNoscript['pages'][0]['canonical_block_markup'] .= '<noscript><img src="images/missing.svg"></noscript><img src="images/missing.svg">'; $afterNoscript['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($afterNoscript['pages'][0]['canonical_block_markup']); +$throws(static fn() => WordPressSitePlan::assertValid($afterNoscript), 'Validation resumes scanning after noscript closes.'); $dataSvg = $plan; $dataSvg['pages'][0]['canonical_block_markup'] .= '<div style="background-image:url(data:image/svg+xml,%3Csvg%3E%3C/svg%3E)"></div>'; $dataSvg['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($dataSvg['pages'][0]['canonical_block_markup']); WordPressSitePlan::assertValid($dataSvg); $assert(true, 'Validation preserves complete data URLs instead of treating the payload after its comma as a local reference.'); $encodedQuotedDataSvg = $plan; $encodedQuotedDataSvg['pages'][0]['canonical_block_markup'] .= '<div style="background-image:url(&quot;data:image/svg+xml,%3Csvg%3E%3C/svg%3E&quot;)"></div>'; $encodedQuotedDataSvg['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($encodedQuotedDataSvg['pages'][0]['canonical_block_markup']); WordPressSitePlan::assertValid($encodedQuotedDataSvg); $assert(true, 'Validation preserves data URLs with HTML-encoded CSS quotes.'); $unresolvedSrcset = $plan; $unresolvedSrcset['pages'][0]['canonical_block_markup'] .= '<img srcset="/images/first.svg 1x, images/missing.svg 2x">'; $unresolvedSrcset['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($unresolvedSrcset['pages'][0]['canonical_block_markup']); $throws(static fn() => WordPressSitePlan::assertValid($unresolvedSrcset), 'Validation still rejects unresolved members of comma-separated srcset references.'); +$dataSrcset = $plan; $dataSrcset['pages'][0]['canonical_block_markup'] .= '<img srcset="data:image/svg+xml,%3Csvg%3E%3C/svg%3E 1x, https://cdn.example.test/logo.svg 2x">'; $dataSrcset['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($dataSrcset['pages'][0]['canonical_block_markup']); +WordPressSitePlan::assertValid($dataSrcset); $assert(true, 'Validation preserves commas in data URL srcset candidates while validating external candidates.'); $inlineSvgPath = $plan; $inlineSvgPath['pages'][0]['canonical_block_markup'] .= '<svg viewBox="0 0 10 10"><path d="M0 0 v5 h5"></path></svg>'; $inlineSvgPath['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($inlineSvgPath['pages'][0]['canonical_block_markup']); WordPressSitePlan::assertValid($inlineSvgPath); $assert(true, 'Validation ignores inline SVG geometry data.'); $unresolvedSvgHref = $plan; $unresolvedSvgHref['pages'][0]['canonical_block_markup'] .= '<svg><use href="icons/sprite.svg#mark"></use><use href="#local-mark"></use></svg>'; $unresolvedSvgHref['pages'][0]['content_hash'] = WordPressSitePlan::contentHash($unresolvedSvgHref['pages'][0]['canonical_block_markup']); From c542a60ee85920d60a39f34f0e7ad7e9c6621a32 Mon Sep 17 00:00:00 2001 From: Chris Huber <chris.huber@automattic.com> Date: Sat, 1 Aug 2026 03:51:17 -0400 Subject: [PATCH 4/9] Canonicalize safe link URLs --- .../src/HtmlToBlocks/HtmlTransformer.php | 8 +--- .../src/HtmlToBlocks/Patterns/LogoPattern.php | 8 +--- .../Patterns/NavigationPattern.php | 8 +--- .../HtmlToBlocks/Support/DomHelpersTrait.php | 33 ++++++++++++--- .../HtmlToBlocks/Support/LinkUrlSanitizer.php | 42 +++++++++++++++++++ php-transformer/tests/contract/run.php | 14 +++++++ 6 files changed, 89 insertions(+), 24 deletions(-) create mode 100644 php-transformer/src/HtmlToBlocks/Support/LinkUrlSanitizer.php diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 401203f8c..7116e1d39 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -37,6 +37,7 @@ use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\ButtonLinkDispatchTrait; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\DomHelpersTrait; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\FormDispatchTrait; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\LinkUrlSanitizer; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\NavigationToggleSuppressionTrait; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\SvgMaterializationTrait; use Automattic\BlocksEngine\PhpTransformer\WordPress\Runtime; @@ -9075,12 +9076,7 @@ private function imageLinkAttributes(DOMElement $link): array private function safeLinkUrl(string $url): string { - $url = trim($url); - if ( '' === $url || preg_match('/[\x00-\x1f\x7f]|javascript\s*:/i', $url) ) { - return ''; - } - - return $url; + return LinkUrlSanitizer::sanitize($url); } /** diff --git a/php-transformer/src/HtmlToBlocks/Patterns/LogoPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/LogoPattern.php index c44a1859e..06f0f3269 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/LogoPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/LogoPattern.php @@ -3,6 +3,7 @@ namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\LinkUrlSanitizer; use DOMDocument; use DOMElement; @@ -220,12 +221,7 @@ private function accessibleFallbackLabel(DOMElement $element): string private function safeNavigationUrl(string $url): string { - $url = trim($url); - if ( '' === $url || preg_match('/[\x00-\x1f\x7f]|javascript\s*:/i', $url) ) { - return ''; - } - - return $url; + return LinkUrlSanitizer::sanitize($url); } /** diff --git a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php index dbedb73ae..414d06215 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/NavigationPattern.php @@ -3,6 +3,7 @@ namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\LinkUrlSanitizer; use DOMElement; final class NavigationPattern implements PatternRecognizerInterface @@ -168,12 +169,7 @@ private function allNavigationLinksShare(array $links, callable $value, mixed $e private function safeNavigationUrl(string $url): string { - $url = trim($url); - if ( '' === $url || preg_match('/[\x00-\x1f\x7f]|javascript\s*:/i', $url) ) { - return ''; - } - - return $url; + return LinkUrlSanitizer::sanitize($url); } private function hasDirectBrandingAnchorBesideListNavigation(DOMElement $element, callable $innerHtml): bool diff --git a/php-transformer/src/HtmlToBlocks/Support/DomHelpersTrait.php b/php-transformer/src/HtmlToBlocks/Support/DomHelpersTrait.php index cc83fe494..a58e93a66 100644 --- a/php-transformer/src/HtmlToBlocks/Support/DomHelpersTrait.php +++ b/php-transformer/src/HtmlToBlocks/Support/DomHelpersTrait.php @@ -23,6 +23,7 @@ private function normalizedNavigationLabel(string $label): string private function innerHtml(DOMElement $element): string { + $this->canonicalizeLinkUrls($element); $html = ''; foreach ( $element->childNodes as $child ) { $html .= $element->ownerDocument->saveHTML($child); @@ -33,6 +34,7 @@ private function innerHtml(DOMElement $element): string private function innerHtmlPreservingWhitespace(DOMElement $element): string { + $this->canonicalizeLinkUrls($element); $html = ''; foreach ( $element->childNodes as $child ) { $html .= $element->ownerDocument->saveHTML($child); @@ -43,9 +45,33 @@ private function innerHtmlPreservingWhitespace(DOMElement $element): string private function outerHtml(DOMElement $element): string { + $this->canonicalizeLinkUrls($element); return trim($element->ownerDocument->saveHTML($element) ?: ''); } + private function canonicalizeLinkUrls(DOMElement $element): void + { + $anchors = 'a' === strtolower($element->tagName) ? array( $element ) : array(); + foreach ( $element->getElementsByTagName('a') as $anchor ) { + if ( $anchor instanceof DOMElement ) { + $anchors[] = $anchor; + } + } + + foreach ( $anchors as $anchor ) { + if ( ! $anchor->hasAttribute('href') ) { + continue; + } + + $href = LinkUrlSanitizer::sanitize($anchor->getAttribute('href')); + if ( '' === $href ) { + $anchor->removeAttribute('href'); + continue; + } + $anchor->setAttribute('href', $href); + } + } + private function attr(DOMElement $element, string $name): string { return $element->hasAttribute($name) ? $element->getAttribute($name) : ''; @@ -303,12 +329,7 @@ private function hasSourceNavigationSignal(DOMElement $element): bool */ private function safeNavigationUrl(string $url): string { - $url = trim($url); - if ( '' === $url || preg_match('/[\x00-\x1f\x7f]|javascript\s*:/i', $url) ) { - return ''; - } - - return $url; + return LinkUrlSanitizer::sanitize($url); } private function runtimeIslandSelector(DOMElement $element): string diff --git a/php-transformer/src/HtmlToBlocks/Support/LinkUrlSanitizer.php b/php-transformer/src/HtmlToBlocks/Support/LinkUrlSanitizer.php new file mode 100644 index 000000000..5205138fd --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Support/LinkUrlSanitizer.php @@ -0,0 +1,42 @@ +<?php +declare(strict_types=1); + +namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support; + +final class LinkUrlSanitizer +{ + private const ALLOWED_PROTOCOLS = array( + 'http', 'https', 'ftp', 'ftps', 'mailto', 'news', 'irc', 'ircs', 'gopher', + 'nntp', 'feed', 'telnet', 'mms', 'rtsp', 'svn', 'tel', 'fax', 'xmpp', + 'webcal', 'urn', + ); + + public static function sanitize(string $url): string + { + $trimmed = preg_replace('/^[\s\p{Z}\x{FEFF}]+|[\s\p{Z}\x{FEFF}]+$/u', '', $url); + $url = is_string($trimmed) ? $trimmed : trim($url); + + if ( '' === $url || 1 !== preg_match('//u', $url) || 1 === preg_match('/[\x00-\x20\x7f-\x9f\p{Z}\x{FEFF}]/u', $url) ) { + return ''; + } + + if ( self::isBareEmail($url) ) { + return 'mailto:' . $url; + } + + if ( 1 === preg_match('/^([a-z][a-z0-9+.-]*):/i', $url, $matches) && ! in_array(strtolower($matches[1]), self::ALLOWED_PROTOCOLS, true) ) { + return ''; + } + + return $url; + } + + private static function isBareEmail(string $url): bool + { + $atom = '[\p{L}\p{N}!#$%&\'*+=?^_`{|}~-]+'; + $quoted = '"(?:\\\\[\x21-\x7e]|[\x21\x23-\x5b\x5d-\x7e])+"'; + $label = '[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?'; + + return 1 === preg_match('/^(?:' . $atom . '(?:\.' . $atom . ')*|' . $quoted . ')@' . $label . '(?:\.' . $label . ')+$/u', $url); + } +} diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index e150c67da..846feac8e 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -12,6 +12,7 @@ use Automattic\BlocksEngine\PhpTransformer\FormatBridge\FormatBridge; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\BlockFactory; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\LinkUrlSanitizer; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\TableClassificationPolicy; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\PatternContext; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\PatternRecognizerInterface; @@ -639,6 +640,19 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(array() === ($contactLayout['fallbacks'] ?? array()), 'static contact layout decomposes without fallback diagnostics'); $assert(0 === substr_count((string) ($contactLayout['serialized_blocks'] ?? ''), '<!-- wp:html'), 'static contact layout emits native blocks only'); +$canonicalLinkUrls = ( new HtmlTransformer() )->transform( + '<main><p><a href="hello@richlynngroup.com&nbsp;">Entity whitespace</a><a href="hello@richlynngroup.com' . "\xC2\xA0" . '">Literal whitespace</a><a href="mailto:hello@richlynngroup.com?subject=Hello">Mail query</a><a href="https://example.test/?x=&amp;copy;">Literal entity query</a><a href="&quot;quoted.local&quot;@example.test">Quoted mailbox</a><a href="δοκιμή@παράδειγμα.δοκιμή">Unicode mailbox</a><a href="members/hello@richlynngroup.com/profile">Relative path</a><a href="java&#x0A;script&#58;alert(1)">Obfuscated script</a><a href="data&#58;text/plain,unsafe">Data</a><a href="vbscript&#58;msgbox(1)">VBScript</a></p><nav><a href="hello@richlynngroup.com">Email</a></nav></main>' +)->toArray(); +$canonicalLinkMarkup = (string) ($canonicalLinkUrls['serialized_blocks'] ?? ''); +$canonicalNavigation = $canonicalLinkUrls['blocks'][0]['innerBlocks'][1]['innerBlocks'][0]['attrs']['url'] ?? null; +$assert(2 === substr_count($canonicalLinkMarkup, 'href="mailto:hello@richlynngroup.com"') && str_contains($canonicalLinkMarkup, 'href="mailto:hello@richlynngroup.com?subject=Hello"') && str_contains($canonicalLinkMarkup, 'href="https://example.test/?x=&amp;copy;"') && str_contains($canonicalLinkMarkup, 'href="mailto:%22quoted.local%22@example.test"') && str_contains($canonicalLinkMarkup, 'href="mailto:%CE%B4%CE%BF%CE%BA%CE%B9%CE%BC%CE%AE@%CF%80%CE%B1%CF%81%CE%AC%CE%B4%CE%B5%CE%B9%CE%B3%CE%BC%CE%B1.%CE%B4%CE%BF%CE%BA%CE%B9%CE%BC%CE%AE"') && str_contains($canonicalLinkMarkup, 'href="members/hello@richlynngroup.com/profile"') && ! str_contains($canonicalLinkMarkup, 'script:') && ! str_contains($canonicalLinkMarkup, 'data:') && ! str_contains($canonicalLinkMarkup, 'vbscript:'), 'link sanitization canonicalizes DOM-decoded NBSP-trimmed bare emails, preserves literal entity query text and relative @ paths, supports quoted and Unicode mailboxes, and rejects unsafe schemes'); +$assert('mailto:hello@richlynngroup.com' === $canonicalNavigation, 'native navigation conversion shares bare-email link canonicalization'); +$assert('https://example.test/?x=&copy;' === LinkUrlSanitizer::sanitize('https://example.test/?x=&copy;') && 'mailto:"quoted.local"@example.test' === LinkUrlSanitizer::sanitize('"quoted.local"@example.test') && 'mailto:δοκιμή@παράδειγμα.δοκιμή' === LinkUrlSanitizer::sanitize('δοκιμή@παράδειγμα.δοκιμή'), 'link sanitization leaves already-DOM-decoded literal entity query text intact and recognizes quoted and Unicode mailboxes without IDN conversion'); +$safeLinkProtocols = array( 'http', 'https', 'ftp', 'ftps', 'mailto', 'news', 'irc', 'ircs', 'gopher', 'nntp', 'feed', 'telnet', 'mms', 'rtsp', 'svn', 'tel', 'fax', 'xmpp', 'webcal', 'urn' ); +foreach ( $safeLinkProtocols as $protocol ) $assert($protocol . ':value' === LinkUrlSanitizer::sanitize($protocol . ':value'), 'link sanitization permits WordPress-safe explicit scheme ' . $protocol); +foreach ( array( '/relative/path', '../relative', '//example.test/path', '#fragment', '?query=value' ) as $relativeUrl ) $assert($relativeUrl === LinkUrlSanitizer::sanitize($relativeUrl), 'link sanitization preserves relative, protocol-relative, fragment, and query URLs'); +foreach ( array( 'data:text/plain,unsafe', 'vbscript:msgbox(1)', 'javascript:alert(1)', 'unknown:value', "java\nscript:alert(1)" ) as $unsafeUrl ) $assert('' === LinkUrlSanitizer::sanitize($unsafeUrl), 'link sanitization rejects unsafe or unknown explicit schemes and scheme obfuscation'); + $inlineSvgArtwork = ( new HtmlTransformer() )->transform( '<main><svg class="album-art" viewBox="0 0 100 100" role="img" aria-label="Album art"><rect width="100" height="100" fill="#111"/><circle cx="50" cy="50" r="30" fill="#c4581a"/></svg></main>' )->toArray(); From d318420b4aaa315e3eca531066b50a2a84d644f4 Mon Sep 17 00:00:00 2001 From: Chris Huber <chris.huber@automattic.com> Date: Sat, 1 Aug 2026 10:39:33 -0400 Subject: [PATCH 5/9] Canonicalize bare web host links --- .../HtmlToBlocks/Support/LinkUrlSanitizer.php | 16 ++++++++++++++++ php-transformer/tests/contract/run.php | 6 +++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Support/LinkUrlSanitizer.php b/php-transformer/src/HtmlToBlocks/Support/LinkUrlSanitizer.php index 5205138fd..c0dd98b62 100644 --- a/php-transformer/src/HtmlToBlocks/Support/LinkUrlSanitizer.php +++ b/php-transformer/src/HtmlToBlocks/Support/LinkUrlSanitizer.php @@ -24,6 +24,10 @@ public static function sanitize(string $url): string return 'mailto:' . $url; } + if ( self::isBareWebHost($url) ) { + return 'https://' . $url; + } + if ( 1 === preg_match('/^([a-z][a-z0-9+.-]*):/i', $url, $matches) && ! in_array(strtolower($matches[1]), self::ALLOWED_PROTOCOLS, true) ) { return ''; } @@ -39,4 +43,16 @@ private static function isBareEmail(string $url): bool return 1 === preg_match('/^(?:' . $atom . '(?:\.' . $atom . ')*|' . $quoted . ')@' . $label . '(?:\.' . $label . ')+$/u', $url); } + + private static function isBareWebHost(string $url): bool + { + $label = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?'; + $fileExtensions = array('asp', 'aspx', 'avif', 'css', 'gif', 'htm', 'html', 'jpeg', 'jpg', 'js', 'json', 'markdown', 'md', 'mdown', 'mkd', 'pdf', 'php', 'png', 'svg', 'txt', 'webp', 'xml', 'zip'); + + if (1 !== preg_match('/^(?:' . $label . '\.)+([a-z]{2,63})$/i', $url, $matches)) { + return false; + } + + return ! in_array(strtolower($matches[1]), $fileExtensions, true); + } } diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 846feac8e..4f786f352 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -641,13 +641,13 @@ public function match(DOMElement $element, PatternContext $context): ?array $assert(0 === substr_count((string) ($contactLayout['serialized_blocks'] ?? ''), '<!-- wp:html'), 'static contact layout emits native blocks only'); $canonicalLinkUrls = ( new HtmlTransformer() )->transform( - '<main><p><a href="hello@richlynngroup.com&nbsp;">Entity whitespace</a><a href="hello@richlynngroup.com' . "\xC2\xA0" . '">Literal whitespace</a><a href="mailto:hello@richlynngroup.com?subject=Hello">Mail query</a><a href="https://example.test/?x=&amp;copy;">Literal entity query</a><a href="&quot;quoted.local&quot;@example.test">Quoted mailbox</a><a href="δοκιμή@παράδειγμα.δοκιμή">Unicode mailbox</a><a href="members/hello@richlynngroup.com/profile">Relative path</a><a href="java&#x0A;script&#58;alert(1)">Obfuscated script</a><a href="data&#58;text/plain,unsafe">Data</a><a href="vbscript&#58;msgbox(1)">VBScript</a></p><nav><a href="hello@richlynngroup.com">Email</a></nav></main>' + '<main><p><a href="hello@richlynngroup.com&nbsp;">Entity whitespace</a><a href="hello@richlynngroup.com' . "\xC2\xA0" . '">Literal whitespace</a><a href="mailto:hello@richlynngroup.com?subject=Hello">Mail query</a><a href="martinguitar.com">Bare domain</a><a href="https://example.test/?x=&amp;copy;">Literal entity query</a><a href="&quot;quoted.local&quot;@example.test">Quoted mailbox</a><a href="δοκιμή@παράδειγμα.δοκιμή">Unicode mailbox</a><a href="members/hello@richlynngroup.com/profile">Relative path</a><a href="java&#x0A;script&#58;alert(1)">Obfuscated script</a><a href="data&#58;text/plain,unsafe">Data</a><a href="vbscript&#58;msgbox(1)">VBScript</a></p><nav><a href="hello@richlynngroup.com">Email</a></nav></main>' )->toArray(); $canonicalLinkMarkup = (string) ($canonicalLinkUrls['serialized_blocks'] ?? ''); $canonicalNavigation = $canonicalLinkUrls['blocks'][0]['innerBlocks'][1]['innerBlocks'][0]['attrs']['url'] ?? null; -$assert(2 === substr_count($canonicalLinkMarkup, 'href="mailto:hello@richlynngroup.com"') && str_contains($canonicalLinkMarkup, 'href="mailto:hello@richlynngroup.com?subject=Hello"') && str_contains($canonicalLinkMarkup, 'href="https://example.test/?x=&amp;copy;"') && str_contains($canonicalLinkMarkup, 'href="mailto:%22quoted.local%22@example.test"') && str_contains($canonicalLinkMarkup, 'href="mailto:%CE%B4%CE%BF%CE%BA%CE%B9%CE%BC%CE%AE@%CF%80%CE%B1%CF%81%CE%AC%CE%B4%CE%B5%CE%B9%CE%B3%CE%BC%CE%B1.%CE%B4%CE%BF%CE%BA%CE%B9%CE%BC%CE%AE"') && str_contains($canonicalLinkMarkup, 'href="members/hello@richlynngroup.com/profile"') && ! str_contains($canonicalLinkMarkup, 'script:') && ! str_contains($canonicalLinkMarkup, 'data:') && ! str_contains($canonicalLinkMarkup, 'vbscript:'), 'link sanitization canonicalizes DOM-decoded NBSP-trimmed bare emails, preserves literal entity query text and relative @ paths, supports quoted and Unicode mailboxes, and rejects unsafe schemes'); +$assert(2 === substr_count($canonicalLinkMarkup, 'href="mailto:hello@richlynngroup.com"') && str_contains($canonicalLinkMarkup, 'href="mailto:hello@richlynngroup.com?subject=Hello"') && str_contains($canonicalLinkMarkup, 'href="https://martinguitar.com"') && str_contains($canonicalLinkMarkup, 'href="https://example.test/?x=&amp;copy;"') && str_contains($canonicalLinkMarkup, 'href="mailto:%22quoted.local%22@example.test"') && str_contains($canonicalLinkMarkup, 'href="mailto:%CE%B4%CE%BF%CE%BA%CE%B9%CE%BC%CE%AE@%CF%80%CE%B1%CF%81%CE%AC%CE%B4%CE%B5%CE%B9%CE%B3%CE%BC%CE%B1.%CE%B4%CE%BF%CE%BA%CE%B9%CE%BC%CE%AE"') && str_contains($canonicalLinkMarkup, 'href="members/hello@richlynngroup.com/profile"') && ! str_contains($canonicalLinkMarkup, 'script:') && ! str_contains($canonicalLinkMarkup, 'data:') && ! str_contains($canonicalLinkMarkup, 'vbscript:'), 'link sanitization canonicalizes DOM-decoded NBSP-trimmed bare emails and web hosts, preserves literal entity query text and relative @ paths, supports quoted and Unicode mailboxes, and rejects unsafe schemes'); $assert('mailto:hello@richlynngroup.com' === $canonicalNavigation, 'native navigation conversion shares bare-email link canonicalization'); -$assert('https://example.test/?x=&copy;' === LinkUrlSanitizer::sanitize('https://example.test/?x=&copy;') && 'mailto:"quoted.local"@example.test' === LinkUrlSanitizer::sanitize('"quoted.local"@example.test') && 'mailto:δοκιμή@παράδειγμα.δοκιμή' === LinkUrlSanitizer::sanitize('δοκιμή@παράδειγμα.δοκιμή'), 'link sanitization leaves already-DOM-decoded literal entity query text intact and recognizes quoted and Unicode mailboxes without IDN conversion'); +$assert('https://example.test/?x=&copy;' === LinkUrlSanitizer::sanitize('https://example.test/?x=&copy;') && 'https://martinguitar.com' === LinkUrlSanitizer::sanitize('martinguitar.com') && 'guide.html' === LinkUrlSanitizer::sanitize('guide.html') && 'mailto:"quoted.local"@example.test' === LinkUrlSanitizer::sanitize('"quoted.local"@example.test') && 'mailto:δοκιμή@παράδειγμα.δοκιμή' === LinkUrlSanitizer::sanitize('δοκιμή@παράδειγμα.δοκιμή'), 'link sanitization recognizes bare web hosts without converting common relative file links and recognizes quoted and Unicode mailboxes without IDN conversion'); $safeLinkProtocols = array( 'http', 'https', 'ftp', 'ftps', 'mailto', 'news', 'irc', 'ircs', 'gopher', 'nntp', 'feed', 'telnet', 'mms', 'rtsp', 'svn', 'tel', 'fax', 'xmpp', 'webcal', 'urn' ); foreach ( $safeLinkProtocols as $protocol ) $assert($protocol . ':value' === LinkUrlSanitizer::sanitize($protocol . ':value'), 'link sanitization permits WordPress-safe explicit scheme ' . $protocol); foreach ( array( '/relative/path', '../relative', '//example.test/path', '#fragment', '?query=value' ) as $relativeUrl ) $assert($relativeUrl === LinkUrlSanitizer::sanitize($relativeUrl), 'link sanitization preserves relative, protocol-relative, fragment, and query URLs'); From 14b043e763d9cab7ee84c50669e2dcdc079aae5d Mon Sep 17 00:00:00 2001 From: Chris Huber <chris.huber@automattic.com> Date: Sat, 1 Aug 2026 11:49:58 -0400 Subject: [PATCH 6/9] Honor explicit sibling page routes --- php-transformer/src/ArtifactCompiler/ArtifactCompiler.php | 2 +- php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php | 3 +++ php-transformer/src/WordPressSitePlan/WordPressSitePlan.php | 2 +- php-transformer/tests/contract/wordpress-site-plan.php | 3 +++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 0a0e79be1..41f10dde8 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -2462,7 +2462,7 @@ private function compiledSiteReport(array $artifact, string $entryPath, array $d 'entrypoint' => $path === $entryPath || ! empty($file['entrypoint']), 'slug' => $slug, 'title' => $title, - 'metadata' => $this->documentMetadata($path, 'html', (string) ($file['role'] ?? 'document'), $slug, $title, $bodyFormat), + 'metadata' => array_merge($this->documentMetadata($path, 'html', (string) ($file['role'] ?? 'document'), $slug, $title, $bodyFormat), is_string($file['metadata']['route_path'] ?? null) ? array('route_path' => $file['metadata']['route_path']) : array()), 'document_metadata' => $this->fullDocumentMetadata($content, $path, $artifact['files'], $path === $entryPath ? $assets : ($compiledBlocks['assets'] ?? array())), 'html' => $file['content'] ?? '', 'body_format' => $bodyFormat, diff --git a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php index ff9c6435f..811c1f8bc 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php @@ -139,6 +139,9 @@ public function normalize(array $artifact): array if ( '' !== $intent ) { $normalized['intent'] = $intent; } + if ( is_array($file['metadata'] ?? null) && is_string($file['metadata']['route_path'] ?? null) && '' !== trim($file['metadata']['route_path']) ) { + $normalized['metadata'] = array('route_path' => trim($file['metadata']['route_path'])); + } foreach ( array('placement', 'type', 'media', 'source_path', 'selector', 'stylesheet_index', 'superseded_by') as $field ) { if ( isset($file[$field]) && is_scalar($file[$field]) && '' !== trim((string) $file[$field]) ) { $normalized[$field] = (string) $file[$field]; diff --git a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php index 1f52eff2c..6f411e347 100644 --- a/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php +++ b/php-transformer/src/WordPressSitePlan/WordPressSitePlan.php @@ -454,7 +454,7 @@ private function documentMetadata(array $document, AssetReferenceCanonicalizer $ private function reporting(array $pages, array $data, array $scriptDiagnostics = array()): array { $documents = array(); foreach ($pages as $page) if (is_array($page)) $documents[] = array('source_path' => $page['source_path'] ?? '', 'kind' => 'page', 'body_format' => 'blocks', 'block_document' => true, 'provenance' => $page['provenance'] ?? array()); return array('source_documents' => $documents, 'metrics' => array('source_document_count' => count($documents), 'block_document_count' => count($documents), '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'] ?? ''), array_merge($data['diagnostics'], $scriptDiagnostics)))); } /** @param mixed $documents @param array<int,array<string,mixed>> $legacyRoutes @return array<int,array<string,mixed>> */ - private function canonicalRoutes(mixed $documents, array $legacyRoutes): array { if (!is_array($documents)) throw new InvalidArgumentException('Compiled site documents must be an array.'); $legacy = array(); foreach ($legacyRoutes as $route) if (is_array($route) && is_string($route['source_path'] ?? null)) $legacy[$route['source_path']] = $route; $entryRoot = self::entryRootFromDocuments($documents); $routes = array(); $paths = array(); foreach ($documents as $order => $document) { if (!is_array($document) || !self::safePath($document['source_path'] ?? null)) throw new InvalidArgumentException('Compiled site route source is invalid.'); if ('' !== $entryRoot && ! str_starts_with((string) $document['source_path'], $entryRoot . '/')) throw new InvalidArgumentException('Compiled site document is outside the entrypoint content root.'); $metadata = is_array($document['metadata'] ?? null) ? $document['metadata'] : array(); $path = is_string($metadata['route_path'] ?? null) && '' !== $metadata['route_path'] ? self::canonicalRoutePath($metadata['route_path']) : self::pageRoutePath($document['source_path'], $entryRoot); if (isset($paths[$path])) throw new InvalidArgumentException('WordPress site plan has colliding page routes.'); $paths[$path] = true; $previous = $legacy[$document['source_path']] ?? array(); $routes[] = array('kind' => 'route', 'source_path' => $document['source_path'], 'target_path' => $path, 'target_slug' => self::value($document, 'slug', self::routeSlug($path)), 'title' => self::value($document, 'title'), 'parent_source_path' => self::value($metadata, 'parent_source_path'), 'source_relation' => !empty($document['entrypoint']) ? 'entrypoint' : ($previous['source_relation'] ?? 'document'), 'order' => $order); } return $routes; } + private function canonicalRoutes(mixed $documents, array $legacyRoutes): array { if (!is_array($documents)) throw new InvalidArgumentException('Compiled site documents must be an array.'); $legacy = array(); foreach ($legacyRoutes as $route) if (is_array($route) && is_string($route['source_path'] ?? null)) $legacy[$route['source_path']] = $route; $entryRoot = self::entryRootFromDocuments($documents); $routes = array(); $paths = array(); foreach ($documents as $order => $document) { if (!is_array($document) || !self::safePath($document['source_path'] ?? null)) throw new InvalidArgumentException('Compiled site route source is invalid.'); $metadata = is_array($document['metadata'] ?? null) ? $document['metadata'] : array(); $explicitRoute = is_string($metadata['route_path'] ?? null) && '' !== $metadata['route_path']; if ('' !== $entryRoot && ! str_starts_with((string) $document['source_path'], $entryRoot . '/') && !$explicitRoute) throw new InvalidArgumentException('Compiled site document is outside the entrypoint content root.'); $path = $explicitRoute ? self::canonicalRoutePath($metadata['route_path']) : self::pageRoutePath($document['source_path'], $entryRoot); if (isset($paths[$path])) throw new InvalidArgumentException('WordPress site plan has colliding page routes.'); $paths[$path] = true; $previous = $legacy[$document['source_path']] ?? array(); $routes[] = array('kind' => 'route', 'source_path' => $document['source_path'], 'target_path' => $path, 'target_slug' => self::value($document, 'slug', self::routeSlug($path)), 'title' => self::value($document, 'title'), 'parent_source_path' => self::value($metadata, 'parent_source_path'), 'source_relation' => !empty($document['entrypoint']) ? 'entrypoint' : ($previous['source_relation'] ?? 'document'), 'order' => $order); } return $routes; } /** @param array<int,array<string,mixed>> $pages @param array<int,array<string,mixed>> $routes @return array<int,array<string,mixed>> */ private function pageHierarchy(array $pages, array $routes): array { diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index fbaae0eee..b6cbe8283 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -293,6 +293,9 @@ $assert(array('website/admin.html', 'website/preview.html') === ($explicitFileEntrypoint['source_reports']['artifact']['entrypoints'] ?? null), 'Explicit per-file entrypoint flags and entry roles remain supported without an artifact-level declaration.'); $outsidePackagedRoot = (new ArtifactCompiler())->compile(array('entrypoint' => 'website/index.html', 'files' => array('website/index.html' => '<main>Home</main>', 'other/contact.html' => '<main>Outside</main>')))->toArray(); $assert(isset($outsidePackagedRoot['source_reports']['wordpress_site_plan_diagnostics']), 'HTML documents outside a nested entrypoint content root fail closed instead of leaking into public routes.'); +$explicitSiblingRoutes = (new ArtifactCompiler())->compile(array('entrypoint' => 'website/news/one/index.html', 'files' => array(array('path' => 'website/news/one/index.html', 'content' => '<main>One</main>', 'metadata' => array('route_path' => '/news/one')), array('path' => 'website/news/two/index.html', 'content' => '<main>Two</main>', 'metadata' => array('route_path' => '/news/two')))))->toArray(); +$explicitSiblingPages = array(); foreach ($explicitSiblingRoutes['source_reports']['wordpress_site_plan']['pages'] ?? array() as $page) $explicitSiblingPages[$page['source_path']] = $page; +$assert('/news/one' === ($explicitSiblingPages['website/news/one/index.html']['route']['path'] ?? null) && '/news/two' === ($explicitSiblingPages['website/news/two/index.html']['route']['path'] ?? null), 'Explicit canonical route metadata admits sibling pages outside a nested entrypoint directory without weakening unannotated content-root isolation.'); $forgedSyntheticPage = $packagedRoutes; foreach ($forgedSyntheticPage['pages'] as &$page) if ('website/contact.html' === ($page['source_path'] ?? null)) $page['synthetic'] = true; unset($page); $throws(static fn() => WordPressSitePlan::assertValid($forgedSyntheticPage), 'Public validation rejects regular pages relabeled as compiler-owned synthetic route parents.'); $dynamicScripts = (new ArtifactCompiler())->compile(array('entrypoint' => 'index.html', 'files' => array('index.html' => '<!doctype html><html><body><main>Dynamic script</main><script src="assets/dynamic.js"></script></body></html>', 'assets/dynamic.js' => 'import("./chunk.js");')))->toArray(); From 7ad8b624fc1dfe5d2dff1d8d16a50942b8a4cbfd Mon Sep 17 00:00:00 2001 From: Chris Huber <chris.huber@automattic.com> Date: Sat, 1 Aug 2026 17:15:36 -0400 Subject: [PATCH 7/9] Index repeated artifact compilation work --- .../src/ArtifactCompiler/ArtifactCompiler.php | 23 ++- .../src/AssetAnalysis/ReferenceAnalyzer.php | 14 +- .../src/HtmlToBlocks/HtmlTransformer.php | 194 +++++++++++++----- .../HtmlToBlocks/Style/CssSelectorMatcher.php | 14 ++ 4 files changed, 188 insertions(+), 57 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 41f10dde8..44581bf7e 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -30,6 +30,12 @@ final class ArtifactCompiler */ private const RUNTIME_TAG_SELECTORS = array( 'button', 'input', 'select', 'textarea', 'ul', 'ol', 'li' ); + /** @var array<string, string> */ + private array $themeStaticCssCache = array(); + + /** @var array<string, string> */ + private array $wordpressCompatCssCache = array(); + /** * Resolve the runtime selector context used when a caller converts one * source document or landmark separately from full artifact compilation. @@ -57,6 +63,8 @@ public function runtimeContextForSource(string $html, string $sourcePath, array public function compile(array $artifact): TransformerResult { $startedAt = hrtime(true); + $this->themeStaticCssCache = array(); + $this->wordpressCompatCssCache = array(); $normalized = ( new ArtifactNormalizer() )->normalize($artifact); $entry = $this->entryFile($normalized['files'], $normalized['entrypoints']); $documents = $this->compileSourceDocuments($normalized); @@ -1120,6 +1128,10 @@ private function themeFontLinkHtml(array $files): string */ private function themeStaticCss(array $files, bool $includeNavigationCompat = true): string { + $cacheKey = $includeNavigationCompat ? 'with-compat' : 'without-compat'; + if ( array_key_exists($cacheKey, $this->themeStaticCssCache) ) { + return $this->themeStaticCssCache[$cacheKey]; + } $blocks = array(); foreach ( $files as $file ) { $content = is_string($file['content'] ?? null) ? (string) $file['content'] : ''; @@ -1145,11 +1157,10 @@ private function themeStaticCss(array $files, bool $includeNavigationCompat = tr $css = implode("\n", array_keys($blocks)); if ( ! $includeNavigationCompat ) { - return $css; + return $this->themeStaticCssCache[$cacheKey] = $css; } - return $css - . $this->wordpressCompatCss($css, $files); + return $this->themeStaticCssCache[$cacheKey] = $css . $this->wordpressCompatCss($css, $files); } /** @return array<int,array{path:string,content:string,source_hash:string}> */ @@ -1369,7 +1380,11 @@ private function rootStartupClassNames(array $files): array /** @param array<int, array<string, mixed>> $files */ private function wordpressCompatCss(string $css, array $files): string { - return $this->navigationContainerCompatCss($css) + $cacheKey = hash('sha256', $css); + if ( array_key_exists($cacheKey, $this->wordpressCompatCssCache) ) { + return $this->wordpressCompatCssCache[$cacheKey]; + } + return $this->wordpressCompatCssCache[$cacheKey] = $this->navigationContainerCompatCss($css) . $this->navigationStructureCompatCss($css) . $this->navigationAnchorCompatCss($css) . $this->rootStartupClassCompatCss($css, $files) diff --git a/php-transformer/src/AssetAnalysis/ReferenceAnalyzer.php b/php-transformer/src/AssetAnalysis/ReferenceAnalyzer.php index 1662f5cdb..0579c982d 100644 --- a/php-transformer/src/AssetAnalysis/ReferenceAnalyzer.php +++ b/php-transformer/src/AssetAnalysis/ReferenceAnalyzer.php @@ -18,6 +18,12 @@ public function referenceReports(array $files, ?callable $isLinkableDocument = n $internalLinks = array(); $assetReferences = array(); $imageReferences = array(); + $filesByPath = array(); + foreach ( $files as $file ) { + if ( is_string($file['path'] ?? null) ) { + $filesByPath[$file['path']] = $file; + } + } foreach ( $files as $file ) { if ( ! empty($file['binary']) ) { @@ -30,7 +36,7 @@ public function referenceReports(array $files, ?callable $isLinkableDocument = n continue; } - $reference = $this->normalizeReferenceCandidate($candidate, $files, $isLinkableDocument, $isSafeImageAsset); + $reference = $this->normalizeReferenceCandidate($candidate, $files, $isLinkableDocument, $isSafeImageAsset, $filesByPath); $target = $reference['target'] ?? null; if ( is_array($target) && $this->isLinkableDocument($target, $isLinkableDocument) && 'a' === $candidate['element'] ) { unset($reference['target']); @@ -54,7 +60,7 @@ public function referenceReports(array $files, ?callable $isLinkableDocument = n continue; } - $reference = $this->normalizeReferenceCandidate($candidate, $files, $isLinkableDocument, $isSafeImageAsset); + $reference = $this->normalizeReferenceCandidate($candidate, $files, $isLinkableDocument, $isSafeImageAsset, $filesByPath); $target = $reference['target'] ?? null; if ( is_array($target) && ! $this->isLinkableDocument($target, $isLinkableDocument) ) { unset($reference['target']); @@ -178,10 +184,10 @@ public function cssReferenceCandidates(string $css, string $sourcePath): array * @param callable(array<string, mixed>): bool|null $isSafeImageAsset * @return array<string, mixed> */ - public function normalizeReferenceCandidate(array $candidate, array $files, ?callable $isLinkableDocument = null, ?callable $isSafeImageAsset = null): array + public function normalizeReferenceCandidate(array $candidate, array $files, ?callable $isLinkableDocument = null, ?callable $isSafeImageAsset = null, ?array $filesByPath = null): array { $resolvedPath = ArtifactPath::resolveRelativePath($candidate['url'], $candidate['source_path']); - $target = '' === $resolvedPath ? null : $this->findFileByPath($resolvedPath, $files); + $target = '' === $resolvedPath ? null : (null === $filesByPath ? $this->findFileByPath($resolvedPath, $files) : ($filesByPath[$resolvedPath] ?? null)); $reference = array_filter( array( 'source_path' => $candidate['source_path'], diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index 7116e1d39..20ee15c3c 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -411,6 +411,24 @@ final class HtmlTransformer /** @var list<DOMElement> */ private array $authorStyleSourceElements = array(); + /** @var array<string, list<DOMElement>> */ + private array $authorStyleSourceElementsByTag = array(); + + /** @var array<string, list<DOMElement>> */ + private array $authorStyleSourceElementsById = array(); + + /** @var array<string, list<DOMElement>> */ + private array $authorStyleSourceElementsByClass = array(); + + /** @var array<string, true> */ + private array $authorStyleSourceTags = array(); + + /** @var array<string, true> */ + private array $authorStyleSourceIds = array(); + + /** @var array<string, true> */ + private array $authorStyleSourceClasses = array(); + /** @var array<string, list<DOMElement>> */ private array $authorSourceSelectorMatches = array(); @@ -513,6 +531,12 @@ public function transform(string $html, array $options = array()): TransformerRe $this->combinedAuthorCss = ''; $this->authorStyleSourceBody = null; $this->authorStyleSourceElements = array(); + $this->authorStyleSourceElementsByTag = array(); + $this->authorStyleSourceElementsById = array(); + $this->authorStyleSourceElementsByClass = array(); + $this->authorStyleSourceTags = array(); + $this->authorStyleSourceIds = array(); + $this->authorStyleSourceClasses = array(); $this->authorSourceSelectorMatches = array(); $this->parsedCssSelectors = array(); $this->authorMarkerSeed = ''; @@ -900,32 +924,48 @@ private function prepareAuthorSelectorSemantics(string $html, string $staticCss, } $this->authorStyleSourceBody = $sourceBody; + for ( $ancestor = $sourceBody; $ancestor instanceof DOMElement; $ancestor = $ancestor->parentNode ) { + $this->recordAuthorSelectorSignals($ancestor); + } foreach ( $sourceBody->getElementsByTagName('*') as $element ) { if ( $element instanceof DOMElement ) { $this->authorStyleSourceElements[] = $element; - } - } - - $sourceTagSelectorNames = array(); - ( new CssStylesheetTransformer() )->transform($this->combinedAuthorCss, function (string $prelude) use (&$sourceTagSelectorNames): string { - foreach ( CssStylesheetTransformer::splitSelectorList($prelude) ?? array() as $selector ) { - $parsed = $this->parsedCssSelector($selector); - foreach ( $parsed['type_spans'] ?? array() as $typeSpan ) { - $tagName = strtolower($typeSpan['name']); - if ( in_array($tagName, array( 'li', 'nav', 'p' ), true) ) { - $sourceTagSelectorNames[ $tagName ] = true; - } - } - } - return $prelude; - }); + $this->recordAuthorSelectorSignals($element); + $this->authorStyleSourceElementsByTag[strtolower($element->tagName)][] = $element; + $id = $this->attr($element, 'id'); + if ( '' !== $id ) { + $this->authorStyleSourceElementsById[$id][] = $element; + } + foreach ( preg_split('/\s+/', trim($this->attr($element, 'class'))) ?: array() as $class ) { + if ( '' !== $class ) { + $this->authorStyleSourceElementsByClass[$class][] = $element; + } + } + } + } + + $sourceTagSelectorNames = array(); + $authorSelectors = array(); + ( new CssStylesheetTransformer() )->transform($this->combinedAuthorCss, function (string $prelude) use (&$sourceTagSelectorNames, &$authorSelectors): string { + foreach ( CssStylesheetTransformer::splitSelectorList($prelude) ?? array() as $selector ) { + $parsed = $this->parsedCssSelector($selector); + $authorSelectors[] = array('selector' => $selector, 'parsed' => $parsed); + foreach ( $parsed['type_spans'] ?? array() as $typeSpan ) { + $tagName = strtolower($typeSpan['name']); + if ( in_array($tagName, array( 'li', 'nav', 'p' ), true) ) { + $sourceTagSelectorNames[$tagName] = true; + } + } + } + return $prelude; + }); foreach ( array_keys($sourceTagSelectorNames) as $tagName ) { $this->sourceTagMarkers[ $tagName ] = $this->allocateAuthorMarker('source-' . $tagName); } - $this->discoverAuthorControlPaths(); - $this->discoverAuthorInlineSemanticPaths(); - $this->discoverAuthorRootChildPaths(); - $this->discoverAuthorTablePaths(); + $this->discoverAuthorControlPaths($authorSelectors); + $this->discoverAuthorInlineSemanticPaths($authorSelectors); + $this->discoverAuthorRootChildPaths($authorSelectors); + $this->discoverAuthorTablePaths($authorSelectors); $this->sourceBodyProjectionClasses = $this->referencedSourceBodyClasses($sourceBody); } @@ -938,11 +978,12 @@ private function referencedSourceBodyClasses(DOMElement $sourceBody): array })); } - private function discoverAuthorControlPaths(): void + /** @param list<array{selector:string,parsed:array<string,mixed>}> $authorSelectors */ + private function discoverAuthorControlPaths(array $authorSelectors): void { - ( new CssStylesheetTransformer() )->transform($this->combinedAuthorCss, function (string $prelude): string { - foreach ( CssStylesheetTransformer::splitSelectorList($prelude) ?? array() as $selector ) { - $parsed = $this->parsedCssSelector($selector); + foreach ( $authorSelectors as $authorSelector ) { + $selector = $authorSelector['selector']; + $parsed = $authorSelector['parsed']; if ( ! $parsed['supported'] ) { continue; } @@ -957,16 +998,15 @@ private function discoverAuthorControlPaths(): void $this->sourceControlPaths[$path] = true; } } - } - return $prelude; - }); + } } - private function discoverAuthorInlineSemanticPaths(): void + /** @param list<array{selector:string,parsed:array<string,mixed>}> $authorSelectors */ + private function discoverAuthorInlineSemanticPaths(array $authorSelectors): void { - ( new CssStylesheetTransformer() )->transform($this->combinedAuthorCss, function (string $prelude): string { - foreach ( CssStylesheetTransformer::splitSelectorList($prelude) ?? array() as $selector ) { - $parsed = $this->parsedCssSelector($selector); + foreach ( $authorSelectors as $authorSelector ) { + $selector = $authorSelector['selector']; + $parsed = $authorSelector['parsed']; if ( ! $parsed['supported'] ) { continue; } @@ -989,16 +1029,15 @@ private function discoverAuthorInlineSemanticPaths(): void $element->setAttribute('data-blocks-engine-richtext-marker', $marker); } } - } - return $prelude; - }); + } } - private function discoverAuthorRootChildPaths(): void + /** @param list<array{selector:string,parsed:array<string,mixed>}> $authorSelectors */ + private function discoverAuthorRootChildPaths(array $authorSelectors): void { - ( new CssStylesheetTransformer() )->transform($this->combinedAuthorCss, function (string $prelude): string { - foreach ( CssStylesheetTransformer::splitSelectorList($prelude) ?? array() as $selector ) { - $parsed = $this->parsedCssSelector($selector); + foreach ( $authorSelectors as $authorSelector ) { + $selector = $authorSelector['selector']; + $parsed = $authorSelector['parsed']; if ( ! $parsed['supported'] || ! $this->isRootChildSelector($parsed) ) { continue; } @@ -1011,16 +1050,15 @@ private function discoverAuthorRootChildPaths(): void $this->sourceRootChildMarkers[$path] ??= $this->allocateAuthorMarker('root-child'); } } - } - return $prelude; - }); + } } - private function discoverAuthorTablePaths(): void + /** @param list<array{selector:string,parsed:array<string,mixed>}> $authorSelectors */ + private function discoverAuthorTablePaths(array $authorSelectors): void { - ( new CssStylesheetTransformer() )->transform($this->combinedAuthorCss, function (string $prelude): string { - foreach ( CssStylesheetTransformer::splitSelectorList($prelude) ?? array() as $selector ) { - $parsed = $this->parsedCssSelector($selector); + foreach ( $authorSelectors as $authorSelector ) { + $selector = $authorSelector['selector']; + $parsed = $authorSelector['parsed']; if ( ! $parsed['supported'] ) { continue; } @@ -1040,9 +1078,7 @@ private function discoverAuthorTablePaths(): void $this->sourceTableMarkers[$path] ??= $this->allocateAuthorMarker('table'); } } - } - return $prelude; - }); + } } /** @param array<string, mixed> $parsed */ @@ -1428,8 +1464,11 @@ private function matchingAuthorSourceElements(string $selector, array $parsed): if ( array_key_exists($selector, $this->authorSourceSelectorMatches) ) { return $this->authorSourceSelectorMatches[$selector]; } + if ( ! $this->authorSelectorCanMatch($parsed) ) { + return $this->authorSourceSelectorMatches[$selector] = array(); + } $matches = array(); - foreach ( $this->authorStyleSourceElements as $element ) { + foreach ( $this->authorSelectorCandidates($parsed) as $element ) { if ( CssSelectorMatcher::matches($element, $parsed, true)['matches'] ) { $matches[] = $element; } @@ -1437,6 +1476,63 @@ private function matchingAuthorSourceElements(string $selector, array $parsed): return $this->authorSourceSelectorMatches[$selector] = $matches; } + /** @param array<string, mixed> $parsed @return list<DOMElement> */ + private function authorSelectorCandidates(array $parsed): array + { + $compounds = $parsed['compounds'] ?? array(); + $rightmost = $compounds[array_key_last($compounds)] ?? array(); + $candidates = array(); + foreach ( $rightmost['ids'] ?? array() as $id ) { + $candidates[] = $this->authorStyleSourceElementsById[$id] ?? array(); + } + foreach ( $rightmost['classes'] ?? array() as $class ) { + $candidates[] = $this->authorStyleSourceElementsByClass[$class] ?? array(); + } + if ( is_string($rightmost['type'] ?? null) && '' !== $rightmost['type'] ) { + $candidates[] = $this->authorStyleSourceElementsByTag[strtolower($rightmost['type'])] ?? array(); + } + if ( array() === $candidates ) { + return $this->authorStyleSourceElements; + } + usort($candidates, static fn (array $left, array $right): int => count($left) <=> count($right)); + return $candidates[0]; + } + + /** @param array<string, mixed> $parsed */ + private function authorSelectorCanMatch(array $parsed): bool + { + foreach ( $parsed['compounds'] ?? array() as $compound ) { + if ( is_string($compound['type'] ?? null) && '' !== $compound['type'] && ! isset($this->authorStyleSourceTags[strtolower($compound['type'])]) ) { + return false; + } + foreach ( $compound['ids'] ?? array() as $id ) { + if ( ! isset($this->authorStyleSourceIds[$id]) ) { + return false; + } + } + foreach ( $compound['classes'] ?? array() as $class ) { + if ( ! isset($this->authorStyleSourceClasses[$class]) ) { + return false; + } + } + } + return true; + } + + private function recordAuthorSelectorSignals(DOMElement $element): void + { + $this->authorStyleSourceTags[strtolower($element->tagName)] = true; + $id = $this->attr($element, 'id'); + if ( '' !== $id ) { + $this->authorStyleSourceIds[$id] = true; + } + foreach ( preg_split('/\s+/', trim($this->attr($element, 'class'))) ?: array() as $class ) { + if ( '' !== $class ) { + $this->authorStyleSourceClasses[$class] = true; + } + } + } + /** @param array<string, mixed> $parsed */ private function rewriteSourceTagTypes(string $selector, array $parsed, string $rightmostInsertion = ''): string { diff --git a/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php b/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php index 5c0b8b56a..7ee0be521 100644 --- a/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php +++ b/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php @@ -13,6 +13,20 @@ final class CssSelectorMatcher * @return array{supported: bool, reason: string|null, compounds: list<array<string, mixed>>, combinators: list<string>, type_spans: list<array{start: int, end: int, name: string, compound: int}>, rightmost_compound_span: array{start: int, end: int}|null, pseudo_state_suffix_span: array{start: int, end: int}|null, rightmost_rewrite_end: int|null} */ public static function parse(string $selector): array + { + static $cache = array(); + if ( array_key_exists($selector, $cache) ) { + return $cache[$selector]; + } + $parsed = self::parseUncached($selector); + if ( count($cache) < 10000 ) { + $cache[$selector] = $parsed; + } + return $parsed; + } + + /** @return array<string, mixed> */ + private static function parseUncached(string $selector): array { if ( 1 !== preg_match('//u', $selector) ) { return self::unsupported('invalid-utf8'); From 05f2646a9a12628249620d0c95f28ae67c9e10fe Mon Sep 17 00:00:00 2001 From: Chris Huber <chris.huber@automattic.com> Date: Sat, 1 Aug 2026 17:35:50 -0400 Subject: [PATCH 8/9] Resolve encoded artifact path segments --- php-transformer/src/Path/ArtifactPath.php | 4 ++++ php-transformer/tests/contract/run.php | 2 ++ 2 files changed, 6 insertions(+) diff --git a/php-transformer/src/Path/ArtifactPath.php b/php-transformer/src/Path/ArtifactPath.php index 16ddaaeda..b6fd08cd2 100644 --- a/php-transformer/src/Path/ArtifactPath.php +++ b/php-transformer/src/Path/ArtifactPath.php @@ -37,6 +37,10 @@ public static function resolveRelativePath(string $reference, string $sourcePath $base = '' === $sourcePath || ! str_contains($sourcePath, '/') ? '' : dirname($sourcePath) . '/'; $parts = array(); foreach ( explode('/', $base . $reference) as $part ) { + $part = rawurldecode($part); + if ( str_contains($part, '/') || str_contains($part, '\\') ) { + return ''; + } if ( '' === $part || '.' === $part ) { continue; } diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 4f786f352..6325b0e7c 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -255,6 +255,8 @@ function serialize_blocks(array $blocks): string $assert('' === ArtifactPath::safeRelativePath('C:\\assets\\logo.png'), 'artifact paths reject drive-absolute paths'); $assert('' === ArtifactPath::safeRelativePath('../secrets/logo.png'), 'artifact paths reject traversal paths'); $assert('assets/logo.png' === ArtifactPath::resolveRelativePath('../assets/logo.png?version=1#hash', 'pages/home.html'), 'artifact references resolve relative paths without query or fragment'); +$assert('assets/JOHN-OATES-‘ARKANSAS.jpg' === ArtifactPath::resolveRelativePath('../assets/JOHN-OATES-%E2%80%98ARKANSAS.jpg', 'pages/home.html'), 'artifact references resolve percent-encoded Unicode path segments to canonical artifact paths'); +$assert('' === ArtifactPath::resolveRelativePath('../assets%2flogo.png', 'pages/home.html'), 'artifact references reject encoded path separators'); $assert('' === ArtifactPath::resolveRelativePath('https://example.com/logo.png', 'pages/home.html'), 'artifact references reject URL references'); $assert('' === ArtifactPath::resolveRelativePath('../../logo.png', 'pages/home.html'), 'artifact references reject traversal above the artifact root'); From 0862999047701799df51292b89c60fe5a68530d3 Mon Sep 17 00:00:00 2001 From: Chris Huber <chris.huber@automattic.com> Date: Sat, 1 Aug 2026 17:45:40 -0400 Subject: [PATCH 9/9] Canonicalize encoded asset references --- .../src/WordPressSitePlan/AssetReferenceCanonicalizer.php | 2 ++ php-transformer/tests/contract/wordpress-site-plan.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/php-transformer/src/WordPressSitePlan/AssetReferenceCanonicalizer.php b/php-transformer/src/WordPressSitePlan/AssetReferenceCanonicalizer.php index 61acb4b82..be6b24d3d 100644 --- a/php-transformer/src/WordPressSitePlan/AssetReferenceCanonicalizer.php +++ b/php-transformer/src/WordPressSitePlan/AssetReferenceCanonicalizer.php @@ -136,6 +136,8 @@ private static function segments(array $segments): string { $normalized = array(); foreach ($segments as $segment) { + $segment = rawurldecode($segment); + if (str_contains($segment, '/') || str_contains($segment, '\\')) return ''; if ('' === $segment || '.' === $segment) continue; if ('..' === $segment) { if (array() === $normalized) return ''; diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index b6cbe8283..73edc75fe 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -377,6 +377,8 @@ $rootLogo = $canonicalizer->reference('/assets/logo.svg?width=40#hero', 'nested/index.html'); $markupReferences = $canonicalizer->content('<img src="/assets/logo.svg?width=40#hero" srcset="/assets/logo.svg?one=1#one 1x, /assets/logo.svg?two=2#two 2x" poster="/assets/poster.jpg"><a href="/application-route#anchor">Route</a>', 'nested/index.html'); $assert(is_string($rootLogo) && str_ends_with($rootLogo, '?width=40#hero') && str_contains($markupReferences, WordPressSitePlan::TOKEN_PREFIX) && str_contains($markupReferences, '?two=2#two') && str_contains($markupReferences, '/application-route#anchor') && null !== $canonicalizer->reference('../assets/logo.svg', 'nested/index.html') && null === $canonicalizer->reference('/application-route#anchor', 'nested/index.html') && null === $canonicalizer->reference('#local', 'nested/index.html') && null === $canonicalizer->reference('https://example.test/external', 'nested/index.html') && null === $canonicalizer->reference('//cdn.example.test/library.js', 'nested/index.html') && null === $canonicalizer->reference('data:image/svg+xml,svg', 'nested/index.html') && null === $canonicalizer->reference('blob:https://example.test/blob', 'nested/index.html') && null === $canonicalizer->reference('mailto:test@example.test', 'nested/index.html') && null === $canonicalizer->reference('tel:+15551212', 'nested/index.html') && null === $canonicalizer->reference('/assets%2flogo.svg', 'nested/index.html') && null === $canonicalizer->reference('/../assets/logo.svg', 'nested/index.html'), 'Canonical matching resolves root-relative and nested markup asset identities only, preserving browser routes, anchors, external schemes, encoded separators, and traversal references.'); +$unicodeCanonicalizer = new AssetReferenceCanonicalizer(array(array('source_path' => 'assets/JOHN-OATES-‘ARKANSAS.jpg', 'token' => 'asset-0123456789abcdef'))); +$assert(WordPressSitePlan::TOKEN_PREFIX . 'asset-0123456789abcdef}}' === $unicodeCanonicalizer->reference('../assets/JOHN-OATES-%E2%80%98ARKANSAS.jpg', 'pages/home.html'), 'Canonical matching resolves percent-encoded Unicode references to declared artifact paths.'); $throws(static fn() => new \Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\AssetReferenceCanonicalizer(array(array('source_path' => 'assets/logo.svg', 'token' => 'asset-0000000000000000'), array('source_path' => 'assets\\logo.svg', 'token' => 'asset-1111111111111111'))), 'Canonical asset source identities reject normalized-separator collisions.'); $resolver = new WordPressSitePlanResolver();