From d87bbd39c136c1ff9bc847fca2965a09bab39422 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 18 Jul 2026 08:33:36 -0400 Subject: [PATCH] Preserve author selector semantics --- php-transformer/composer.json | 4 + .../src/ArtifactCompiler/ArtifactCompiler.php | 254 ++++++++- .../ArtifactCompiler/ArtifactNormalizer.php | 82 ++- .../src/HtmlToBlocks/HtmlTransformer.php | 356 ++++++++++++- .../HtmlToBlocks/Patterns/ButtonsPattern.php | 10 +- .../HtmlToBlocks/Style/CssSelectorMatcher.php | 491 ++++++++++++++++++ .../Style/CssSelectorTokenizer.php | 105 ++++ .../Style/CssStylesheetTransformer.php | 233 +++++++++ .../HtmlToBlocks/Style/CssSyntaxScanner.php | 117 +++++ .../Support/ButtonLinkDispatchTrait.php | 4 +- .../StaticSite/MaterializationPlanBuilder.php | 1 + ...act-author-selector-inline-projection.json | 29 ++ ...act-author-selector-linked-projection.json | 32 ++ ...act-stylesheet-media-type-occurrences.json | 35 ++ .../artifact-stylesheet-occurrence-order.json | 32 ++ .../html-author-selector-provenance.json | 29 ++ ...-preserves-source-classes-and-metrics.json | 4 +- ...button-style-fidelity-filled-from-css.json | 4 +- ...utton-style-fidelity-outline-from-css.json | 4 +- ...tton-style-fidelity-outline-split-css.json | 6 +- ...ainer-static-css-button-style-signals.json | 8 +- ...seudo-state-rules-not-inlined-resting.json | 2 +- ...ml-wrapper-button-selector-projection.json | 28 + .../artifact-author-stylesheet-projection.php | 56 ++ .../tests/unit/author-selector-semantics.php | 77 +++ .../tests/unit/css-selector-matcher.php | 86 +++ .../tests/unit/css-stylesheet-transformer.php | 98 ++++ 27 files changed, 2116 insertions(+), 71 deletions(-) create mode 100644 php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php create mode 100644 php-transformer/src/HtmlToBlocks/Style/CssSelectorTokenizer.php create mode 100644 php-transformer/src/HtmlToBlocks/Style/CssStylesheetTransformer.php create mode 100644 php-transformer/src/HtmlToBlocks/Style/CssSyntaxScanner.php create mode 100644 php-transformer/tests/fixtures/parity/artifact-author-selector-inline-projection.json create mode 100644 php-transformer/tests/fixtures/parity/artifact-author-selector-linked-projection.json create mode 100644 php-transformer/tests/fixtures/parity/artifact-stylesheet-media-type-occurrences.json create mode 100644 php-transformer/tests/fixtures/parity/artifact-stylesheet-occurrence-order.json create mode 100644 php-transformer/tests/fixtures/parity/html-author-selector-provenance.json create mode 100644 php-transformer/tests/fixtures/parity/html-wrapper-button-selector-projection.json create mode 100644 php-transformer/tests/unit/artifact-author-stylesheet-projection.php create mode 100644 php-transformer/tests/unit/author-selector-semantics.php create mode 100644 php-transformer/tests/unit/css-selector-matcher.php create mode 100644 php-transformer/tests/unit/css-stylesheet-transformer.php diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 965888e8..0639fdaf 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -57,6 +57,10 @@ "php tests/unit/subtree-classifier.php", "php tests/unit/custom-block-generator.php", "php tests/unit/css-value-splitter.php", + "php tests/unit/css-stylesheet-transformer.php", + "php tests/unit/css-selector-matcher.php", + "php tests/unit/author-selector-semantics.php", + "php tests/unit/artifact-author-stylesheet-projection.php", "php tests/unit/fallback-finding-normalizer.php", "php tests/unit/navigation-underline-color-resolver.php", "php tests/unit/button-signal-classifier.php", diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index f801fb34..01458ec4 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -42,12 +42,14 @@ public function compile(array $artifact): TransformerResult $entryPath = is_array($entry) ? (string) $entry['path'] : ''; $html = is_array($entry) ? (string) $entry['content'] : ''; - $referenceReports = $this->referenceReports($normalized['files']); $components = $this->detectComponents($normalized['files'], $entryPath, $documents['components']); $blockTypes = $this->detectBlockTypes($normalized['files'], $diagnostics); $companionPluginPayloadBuilder = new CompanionPluginPayload(); + $normalized['files'] = $this->withStylesheetOccurrenceAssets($html, $entryPath, $normalized['files']); $entryBlocks = $this->compileEntryBlocks($html, $entryPath, $normalized['files'], $companionPluginPayloadBuilder->blockNamespace($artifact)); - $manifestAssets = $this->assetManifest($normalized['files'], $entryPath, $referenceReports['asset_references']); + $normalized['files'] = $this->applyAuthorStylesheetProjections($normalized['files'], $entryBlocks['author_stylesheet_projections']); + $referenceReports = $this->referenceReports($normalized['files']); + $manifestAssets = $this->assetManifest($normalized['files'], $entryPath, $referenceReports['asset_references'], $html); $geometryAssets = array_values(array_filter($entryBlocks['assets'], static fn (array $asset): bool => 'css' === ($asset['kind'] ?? '') && str_contains((string) ($asset['content'] ?? ''), '.be-inline-geometry-'))); $otherGeneratedAssets = array_values(array_filter($entryBlocks['assets'], static fn (array $asset): bool => ! in_array($asset, $geometryAssets, true))); // Runtime loads the manifest in array order. Put carrier CSS before @@ -154,7 +156,7 @@ public function compileFragment(string $content, string $source = 'fragment', st /** * @param array> $files - * @return array{blocks: array>, serialized_blocks: string, diagnostics: array>, fallbacks: array>, assets: array>, runtime_islands: array>, generated_blocks: array>, interaction_candidates: array>, superseded_selectors: array} + * @return array{blocks: array>, serialized_blocks: string, diagnostics: array>, fallbacks: array>, assets: array>, runtime_islands: array>, generated_blocks: array>, interaction_candidates: array>, superseded_selectors: array, author_stylesheet_projections: array>} */ private function compileEntryBlocks(string $html, string $entryPath, array $files, string $generatedBlockNamespace = ''): array { @@ -170,12 +172,13 @@ private function compileEntryBlocks(string $html, string $entryPath, array $file 'generated_blocks' => $result['generated_blocks'], 'interaction_candidates' => $result['interaction_candidates'], 'superseded_selectors' => $result['superseded_selectors'], + 'author_stylesheet_projections' => $result['author_stylesheet_projections'], ); } /** * @param array> $files - * @return array{blocks: array>, serialized_blocks: string, diagnostics: array>, fallbacks: array>, assets: array>, runtime_islands: array>, generated_blocks: array>, interaction_candidates: array>, superseded_selectors: array} + * @return array{blocks: array>, serialized_blocks: string, diagnostics: array>, fallbacks: array>, assets: array>, runtime_islands: array>, generated_blocks: array>, interaction_candidates: array>, superseded_selectors: array, author_stylesheet_projections: array>} */ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, array $files, string $sourceScope, string $generatedBlockNamespace = ''): array { @@ -190,6 +193,7 @@ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, arr 'generated_blocks' => array(), 'interaction_candidates' => array(), 'superseded_selectors' => array(), + 'author_stylesheet_projections' => array(), ); } @@ -204,6 +208,7 @@ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, arr 'generated_blocks' => array(), 'interaction_candidates' => array(), 'superseded_selectors' => array(), + 'author_stylesheet_projections' => array(), ); } @@ -211,6 +216,7 @@ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, arr 'source' => $sourcePath, 'source_scope' => $sourceScope, 'static_css' => $this->linkedStylesheetCss($html, $sourcePath, $files), + 'author_stylesheet_assets' => $this->stylesheetAssetsForSource($html, $sourcePath, $files), 'skip_author_stylesheet_materialization' => true, 'asset_metadata' => $this->assetMetadataForSource($sourcePath, $files), 'runtime_script_metadata' => $this->runtimeScriptMetadataForSource($html, $sourcePath, $files), @@ -236,6 +242,7 @@ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, arr is_array($result['source_reports']['superseded_selectors'] ?? null) ? $result['source_reports']['superseded_selectors'] : array(), static fn (mixed $selector): bool => is_string($selector) && '' !== $selector )), + 'author_stylesheet_projections' => is_array($result['source_reports']['author_stylesheet_projections'] ?? null) ? $result['source_reports']['author_stylesheet_projections'] : array(), ); } @@ -569,11 +576,11 @@ private function linkedStylesheetCss(string $html, string $sourcePath, array $fi foreach ( $matches[0] as $tag ) { $rel = $this->htmlAttribute((string) $tag, 'rel'); $href = $this->htmlAttribute((string) $tag, 'href'); - if ( '' === $href || ! preg_match('/(?:^|\s)stylesheet(?:\s|$)/i', $rel) ) { + if ( '' === $href || ! preg_match('/(?:^|\s)stylesheet(?:\s|$)/i', $rel) || ! $this->isCssStylesheetType($this->htmlAttribute((string) $tag, 'type')) ) { continue; } - $path = ArtifactPath::resolveRelativePath($href, $sourcePath); + $path = $this->stylesheetPathFromHref($href, $sourcePath); if ( '' === $path ) { continue; } @@ -589,6 +596,178 @@ private function linkedStylesheetCss(string $html, string $sourcePath, array $fi return trim(implode("\n", $css)); } + /** + * Preserve authored stylesheet boundaries and document order for selector + * projection. Inline CSS is normalized as its own asset by ArtifactNormalizer. + * + * @param array> $files + * @return list + */ + private function stylesheetAssetsForSource(string $html, string $sourcePath, array $files): array + { + $byPath = array(); + $inline = array(); + $occurrencePaths = array(); + foreach ( $files as $file ) { + if ( 'css' !== ($file['kind'] ?? '') || ! is_string($file['path'] ?? null) || ! is_string($file['content'] ?? null) ) { + continue; + } + $byPath[$file['path']] = $file; + if ( 'inline-style' === ($file['source'] ?? '') && $sourcePath === ($file['source_path'] ?? '') ) { + $inline[(int) ($file['stylesheet_index'] ?? 0)] = $file; + } + if ( is_string($file['stylesheet_source_path'] ?? null) && isset($file['stylesheet_occurrence']) ) { + $occurrencePaths[$file['stylesheet_source_path']][(int) $file['stylesheet_occurrence']] = $file['path']; + } + } + $assets = array(); + $seenPaths = array(); + $inlineIndex = 0; + $linkOccurrences = array(); + if ( preg_match_all('/]*>.*?<\/style>|]*>/is', $html, $matches) ) { + foreach ( $matches[0] as $tag ) { + if ( preg_match('/^]*)>/i', $tag, $styleMatch); + $attributes = (string) ($styleMatch[1] ?? ''); + if ( ! $this->isCssStylesheetType($this->htmlAttribute($attributes, 'type')) ) { + continue; + } + if ( '' === trim((string) preg_replace('@^]*>|$@is', '', $tag)) ) { + continue; + } + ++$inlineIndex; + $file = $inline[$inlineIndex] ?? null; + if ( is_array($file) && ! isset($seenPaths[$file['path']]) ) { + $assets[] = array( 'path' => $file['path'], 'content' => $file['content'], 'source_hash' => (string) ($file['provenance']['hash'] ?? hash('sha256', $file['content']) ), 'media' => (string) ($file['media'] ?? ''), 'type' => (string) ($file['type'] ?? '') ); + $seenPaths[$file['path']] = true; + } + continue; + } + if ( ! preg_match('/^htmlAttribute((string) $tag, 'rel') ) || ! $this->isCssStylesheetType($this->htmlAttribute((string) $tag, 'type')) ) { + continue; + } + $sourcePathForLink = $this->stylesheetPathFromHref($this->htmlAttribute((string) $tag, 'href'), $sourcePath); + $linkOccurrences[$sourcePathForLink] = ($linkOccurrences[$sourcePathForLink] ?? 0) + 1; + $path = $occurrencePaths[$sourcePathForLink][$linkOccurrences[$sourcePathForLink]] ?? ''; + $file = $byPath[$path] ?? null; + if ( is_array($file) && ! isset($seenPaths[$path]) ) { + $assets[] = array( 'path' => $path, 'content' => $file['content'], 'source_hash' => (string) ($file['provenance']['hash'] ?? hash('sha256', $file['content']) ), 'media' => $this->htmlAttribute((string) $tag, 'media'), 'type' => $this->htmlAttribute((string) $tag, 'type') ); + $seenPaths[$path] = true; + } + } + } + return $assets; + } + + /** @param array> $files @return array> */ + private function withStylesheetOccurrenceAssets(string $html, string $sourcePath, array $files): array + { + $byPath = array(); + $reserved = array(); + foreach ( $files as $index => $file ) { + $path = (string) ($file['path'] ?? ''); + $byPath[$path] = $index; + $reserved[$path] = true; + } + $occurrences = array(); + if ( ! preg_match_all('/]*>/i', $html, $matches) ) { + return $files; + } + foreach ( $matches[0] as $tag ) { + if ( ! preg_match('/(?:^|\s)stylesheet(?:\s|$)/i', $this->htmlAttribute((string) $tag, 'rel')) || ! $this->isCssStylesheetType($this->htmlAttribute((string) $tag, 'type')) ) { + continue; + } + $originalPath = $this->stylesheetPathFromHref($this->htmlAttribute((string) $tag, 'href'), $sourcePath); + if ( '' === $originalPath || ! isset($byPath[$originalPath]) || 'css' !== ($files[$byPath[$originalPath]]['kind'] ?? '') ) { + continue; + } + $occurrences[$originalPath] = ($occurrences[$originalPath] ?? 0) + 1; + $occurrence = $occurrences[$originalPath]; + $media = $this->htmlAttribute((string) $tag, 'media'); + $type = $this->htmlAttribute((string) $tag, 'type'); + if ( 1 === $occurrence ) { + $files[$byPath[$originalPath]]['media'] = $media; + $files[$byPath[$originalPath]]['type'] = $type; + $files[$byPath[$originalPath]]['stylesheet_source_path'] = $originalPath; + $files[$byPath[$originalPath]]['stylesheet_occurrence'] = 1; + continue; + } + $alias = $this->allocateStylesheetOccurrencePath($this->stylesheetOccurrencePath($originalPath, $occurrence), $reserved); + $aliasFile = $files[$byPath[$originalPath]]; + $aliasFile['path'] = $alias; + $aliasFile['source'] = 'stylesheet-occurrence'; + $aliasFile['source_path'] = $originalPath; + $aliasFile['stylesheet_source_path'] = $originalPath; + $aliasFile['stylesheet_occurrence'] = $occurrence; + $aliasFile['media'] = $media; + $aliasFile['type'] = $type; + $aliasFile['provenance']['source_path'] = $originalPath; + $files[] = $aliasFile; + $byPath[$alias] = count($files) - 1; + } + return $files; + } + + private function stylesheetPathFromHref(string $href, string $sourcePath): string + { + return ArtifactPath::resolveRelativePath((string) preg_replace('/[?#].*$/', '', $href), $sourcePath); + } + + private function stylesheetOccurrencePath(string $path, int $occurrence): string + { + $extension = pathinfo($path, PATHINFO_EXTENSION); + $base = '' === $extension ? $path : substr($path, 0, -strlen($extension) - 1); + return $base . '.occurrence-' . $occurrence . ('' === $extension ? '' : '.' . $extension); + } + + /** @param array $reserved */ + private function allocateStylesheetOccurrencePath(string $candidate, array &$reserved): string + { + $path = $candidate; + $index = 1; + while ( isset($reserved[$path]) ) { + $extension = pathinfo($candidate, PATHINFO_EXTENSION); + $base = '' === $extension ? $candidate : substr($candidate, 0, -strlen($extension) - 1); + $path = $base . '-generated-' . $index++ . ('' === $extension ? '' : '.' . $extension); + } + $reserved[$path] = true; + return $path; + } + + private function isCssStylesheetType(string $type): bool + { + $type = strtolower(trim($type)); + return '' === $type || 1 === preg_match("/^text\\/css(?:\\s*;\\s*[!#$%&'*+\\-.^_`|~0-9a-z]+(?:\\s*=\\s*(?:[!#$%&'*+\\-.^_`|~0-9a-z]+|\"(?:[^\"\\\\]|\\\\.)*\"))?)*\\s*$/i", $type); + } + + /** + * @param array> $files + * @param array> $projections + * @return array> + */ + private function applyAuthorStylesheetProjections(array $files, array $projections): array + { + $byPath = array(); + foreach ( $projections as $projection ) { + if ( is_string($projection['path'] ?? null) && is_string($projection['content'] ?? null) ) { + $byPath[$projection['path']] = $projection; + } + } + foreach ( $files as &$file ) { + $projection = $byPath[$file['path'] ?? ''] ?? null; + if ( ! is_array($projection) || 'css' !== ($file['kind'] ?? '') ) { + continue; + } + $file['content'] = $projection['content']; + $file['bytes'] = $projection['bytes']; + $file['provenance']['projected_from_hash'] = $file['provenance']['hash'] ?? ''; + $file['provenance']['hash'] = $projection['hash']; + } + unset($file); + return $files; + } + /** * Collect the `` tags declared across the artifact's HTML sources so * downstream font materialization can detect linked web-font stylesheets @@ -1801,6 +1980,7 @@ private function compiledSiteAssets(array $assets): array 'role' => $asset['role'] ?? '', 'intent' => $asset['intent'] ?? '', 'media_type' => $asset['media_type'] ?? $asset['mime_type'] ?? '', + 'media' => $asset['media'] ?? '', 'mime_type' => $asset['mime_type'] ?? '', 'bytes' => $asset['bytes'] ?? 0, 'binary' => $asset['binary'] ?? false, @@ -2035,11 +2215,12 @@ private function sanitizeKey(string $key): string * @param array> $files * @return array> */ - private function assetManifest(array $files, string $entryPath, array $assetReferences = array()): array + private function assetManifest(array $files, string $entryPath, array $assetReferences = array(), string $entryHtml = ''): array { $assets = array(); + $unsupportedStylesheets = $this->unsupportedStylesheetPaths($entryHtml, $entryPath); foreach ( $files as $file ) { - if ( $entryPath === $file['path'] || $this->isMaterializedHtmlDocument($file) ) { + if ( $entryPath === $file['path'] || $this->isMaterializedHtmlDocument($file) || isset($unsupportedStylesheets[$file['path'] ?? '']) ) { continue; } $asset = array( @@ -2055,6 +2236,7 @@ private function assetManifest(array $files, string $entryPath, array $assetRefe 'content_encoding' => $file['encoding'], 'binary' => $file['binary'], 'hash' => $file['provenance']['hash'] ?? '', + 'source_hash' => $file['provenance']['projected_from_hash'] ?? ($file['provenance']['hash'] ?? ''), 'provenance' => $file['provenance'], ); if ( ! empty($file['content_base64']) ) { @@ -2071,6 +2253,9 @@ private function assetManifest(array $files, string $entryPath, array $assetRefe $asset[$field] = (string) $file[$field]; } } + if ( isset($file['media']) && is_scalar($file['media']) && '' !== trim((string) $file['media']) ) { + $asset['media'] = (string) $file['media']; + } foreach ( array('defer', 'async') as $field ) { if ( isset($file[$field]) ) { $asset[$field] = (bool) $file[$field]; @@ -2082,8 +2267,59 @@ private function assetManifest(array $files, string $entryPath, array $assetRefe } $assets[] = $asset; } + if ( '' === $entryHtml ) { + return $assets; + } + $orderedPaths = array_column($this->stylesheetAssetsForSource($entryHtml, $entryPath, $files), 'path'); + $ordered = array(); + $consumed = array(); + foreach ( $orderedPaths as $path ) { + if ( isset($consumed[$path]) ) { + continue; + } + foreach ( $assets as $asset ) { + if ( $path === ($asset['path'] ?? '') ) { + $ordered[] = $asset; + $consumed[$path] = true; + break; + } + } + } + foreach ( $assets as $asset ) { + if ( isset($consumed[$asset['path'] ?? '']) ) { + continue; + } + $ordered[] = $asset; + } + return $ordered; + } - return $assets; + /** @return array */ + private function unsupportedStylesheetPaths(string $html, string $sourcePath): array + { + $unsupported = array(); + $supported = array(); + if ( ! preg_match_all('/]*>/i', $html, $matches) ) { + return $unsupported; + } + foreach ( $matches[0] as $tag ) { + if ( ! preg_match('/(?:^|\s)stylesheet(?:\s|$)/i', $this->htmlAttribute((string) $tag, 'rel')) ) { + continue; + } + $path = $this->stylesheetPathFromHref($this->htmlAttribute((string) $tag, 'href'), $sourcePath); + if ( '' === $path ) { + continue; + } + if ( $this->isCssStylesheetType($this->htmlAttribute((string) $tag, 'type')) ) { + $supported[$path] = true; + } else { + $unsupported[$path] = true; + } + } + foreach ( $supported as $path => $_true ) { + unset($unsupported[$path]); + } + return $unsupported; } /** diff --git a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php index 18fc0dd7..02b9577c 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php @@ -42,7 +42,15 @@ public function normalize(array $artifact): array } } - $rawFiles = $this->withInlineScriptFiles($this->withInlineStyleFiles($this->rawFiles($artifact))); + $rawFiles = $this->rawFiles($artifact); + $reservedPaths = array(); + foreach ( $rawFiles as $file ) { + $path = ArtifactPath::safeRelativePath((string) ($file['path'] ?? '')); + if ( '' !== $path ) { + $reservedPaths[$path] = true; + } + } + $rawFiles = $this->withInlineScriptFiles($this->withInlineStyleFiles($rawFiles, $reservedPaths)); $safeEntrypoints = array(); foreach ( array_unique($entrypoints) as $entrypoint ) { $path = ArtifactPath::safeRelativePath($entrypoint); @@ -125,7 +133,7 @@ public function normalize(array $artifact): array if ( '' !== $intent ) { $normalized['intent'] = $intent; } - foreach ( array('placement', 'type', 'source_path', 'selector') as $field ) { + foreach ( array('placement', 'type', 'media', 'source_path', 'selector', 'stylesheet_index') as $field ) { if ( isset($file[$field]) && is_scalar($file[$field]) && '' !== trim((string) $file[$field]) ) { $normalized[$field] = (string) $file[$field]; } @@ -217,34 +225,42 @@ private function rawFiles(array $artifact): array * @param array> $files * @return array> */ - private function withInlineStyleFiles(array $files): array + private function withInlineStyleFiles(array $files, array &$reservedPaths): array { $expanded = array(); foreach ( $files as $file ) { $expanded[] = $file; $content = $this->payload($file, (string) ($file['path'] ?? ''))['content']; - if ( '' === trim($content) || ! $this->isHtmlLikeFile($file) || ! preg_match_all('@]*>(.*?)@is', $content, $matches) ) { + if ( '' === trim($content) || ! $this->isHtmlLikeFile($file) || ! preg_match_all('@]*)>(.*?)@is', $content, $matches, PREG_SET_ORDER) ) { continue; } - $css = trim(implode("\n", array_filter(array_map( - static fn (string $style): string => trim($style), - $matches[1] - ), static fn (string $style): bool => '' !== $style))); - if ( '' === $css ) { - continue; + $styles = array(); + foreach ( $matches as $match ) { + $attributes = (string) $match[1]; + $css = trim((string) $match[2]); + if ( '' === $css || ! $this->isCssType($this->htmlAttribute($attributes, 'type')) ) { + continue; + } + $styles[] = array( 'content' => $css, 'media' => $this->htmlAttribute($attributes, 'media'), 'type' => $this->htmlAttribute($attributes, 'type') ); + } + foreach ( $styles as $index => $style ) { + $path = $this->allocateGeneratedPath($this->inlineStylePath((string) ($file['path'] ?? 'index.html'), count($styles), $index + 1), $reservedPaths); + $expanded[] = array( + 'path' => $path, + 'content' => $style['content'], + 'kind' => 'css', + 'mime_type' => 'text/css', + 'role' => 'stylesheet', + 'intent' => 'style', + 'source' => 'inline-style', + 'source_path' => (string) ($file['path'] ?? 'index.html'), + 'stylesheet_index' => $index + 1, + 'media' => $style['media'], + 'type' => $style['type'], + ); } - - $expanded[] = array( - 'path' => $this->inlineStylePath((string) ($file['path'] ?? 'index.html')), - 'content' => $css, - 'kind' => 'css', - 'mime_type' => 'text/css', - 'role' => 'stylesheet', - 'intent' => 'style', - 'source' => 'inline-style', - ); } return $expanded; @@ -264,20 +280,40 @@ private function isHtmlLikeFile(array $file): bool && ( '' === $path || preg_match('/\.html?$/', $path) || 'index.html' === $path ); } - private function inlineStylePath(string $htmlPath): string + private function inlineStylePath(string $htmlPath, int $count = 1, int $index = 1): string { $path = ArtifactPath::safeRelativePath($htmlPath); if ( '' === $path ) { - return 'inline-styles.css'; + return 1 === $count ? 'inline-styles.css' : 'inline-styles-' . $index . '.css'; } $directory = trim((string) pathinfo($path, PATHINFO_DIRNAME), '.'); $filename = pathinfo($path, PATHINFO_FILENAME); - $stylePath = ('' === $filename ? 'inline' : $filename) . '.inline.css'; + $stylePath = ('' === $filename ? 'inline' : $filename) . '.inline' . (1 === $count ? '' : '-' . $index) . '.css'; return '' === $directory ? $stylePath : $directory . '/' . $stylePath; } + /** @param array $reservedPaths */ + private function allocateGeneratedPath(string $candidate, array &$reservedPaths): string + { + $path = $candidate; + $index = 1; + while ( isset($reservedPaths[$path]) ) { + $extension = pathinfo($candidate, PATHINFO_EXTENSION); + $base = '' === $extension ? $candidate : substr($candidate, 0, -strlen($extension) - 1); + $path = $base . '-generated-' . $index++ . ('' === $extension ? '' : '.' . $extension); + } + $reservedPaths[$path] = true; + return $path; + } + + private function isCssType(string $type): bool + { + $type = strtolower(trim($type)); + return '' === $type || 1 === preg_match("/^text\\/css(?:\\s*;\\s*[!#$%&'*+\\-.^_`|~0-9a-z]+(?:\\s*=\\s*(?:[!#$%&'*+\\-.^_`|~0-9a-z]+|\"(?:[^\"\\\\]|\\\\.)*\"))?)*\\s*$/i", $type); + } + /** * @param array> $files * @return array> diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index be17d2a2..b3ce1bbd 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -28,6 +28,8 @@ use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\QuotePattern; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Patterns\SpacerPattern; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\StyleResolutionTrait; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssSelectorMatcher; +use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Style\CssStylesheetTransformer; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\BackgroundImageExtractor; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\ButtonLinkDispatchTrait; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Support\DomHelpersTrait; @@ -332,6 +334,35 @@ final class HtmlTransformer */ private array $supersededRuntimeSelectors = array(); + /** @var array Source tag names whose serialized blocks need provenance classes. */ + private array $sourceTagMarkers = array(); + + /** @var array Source control DOM paths mapped to core/button wrapper classes. */ + private array $sourceControlMarkers = array(); + + /** @var array Source controls that need selector projection. */ + private array $sourceControlPaths = array(); + + private string $combinedAuthorCss = ''; + + private ?DOMElement $authorStyleSourceBody = null; + + private string $authorMarkerSeed = ''; + + private int $authorMarkerCounter = 0; + + private string $authorMarkerCollisionText = ''; + + /** @var list */ + private array $authorStylesheetAssets = array(); + + /** A collision-checked custom element used solely to retain type specificity. */ + private string $authorSpecificityShim = ''; + + private string $authorClassSpecificityShim = ''; + + private string $authorIdSpecificityShim = ''; + private int $nextSourceProvenanceId = 1; public function __construct(private readonly Runtime $runtime = new Runtime()) @@ -388,6 +419,18 @@ public function transform(string $html, array $options = array()): TransformerRe $this->assetMetadata = $this->assetMetadataFromOptions($options); $this->generatedAssets = array(); $this->gutenbergIncompatibilities = array(); + $this->sourceTagMarkers = array(); + $this->sourceControlMarkers = array(); + $this->sourceControlPaths = array(); + $this->combinedAuthorCss = ''; + $this->authorStyleSourceBody = null; + $this->authorMarkerSeed = ''; + $this->authorMarkerCounter = 0; + $this->authorMarkerCollisionText = ''; + $this->authorStylesheetAssets = array(); + $this->authorSpecificityShim = ''; + $this->authorClassSpecificityShim = ''; + $this->authorIdSpecificityShim = ''; $this->staticClassPromotions = $this->detectStaticClassPromotions($html); $this->staticStyleRules = $this->staticStyleRules($html, (string) ($options['static_css'] ?? '')); $this->staticPseudoElementStyleRules = $this->staticPseudoElementStyleRules($html, (string) ($options['static_css'] ?? '')); @@ -461,6 +504,8 @@ public function transform(string $html, array $options = array()): TransformerRe ); } + $this->prepareAuthorSelectorSemantics($html, (string) ($options['static_css'] ?? ''), $normalizedHtml, $options); + $fallbacks = array(); $interactionCandidates = $this->interactionCandidates($body); $this->collectSupersededNavToggleSelectors($body); @@ -471,6 +516,7 @@ public function transform(string $html, array $options = array()): TransformerRe $this->appendCommerceControlsFallbacks($body, $fallbacks); $sourceProvenance = $this->sourceProvenanceForBlocks($blocks); $serializedBlocks = $this->runtime->serializeBlocks($blocks); + $authorStylesheetProjections = $this->authorStylesheetProjections(); $this->materializeAuthorStylesheet( $html, (string) ($options['static_css'] ?? ''), @@ -513,6 +559,9 @@ public function transform(string $html, array $options = array()): TransformerRe 'runtime_islands' => $this->runtimeIslands, ), ); + if ( array() !== $authorStylesheetProjections ) { + $sourceReports['author_stylesheet_projections'] = $authorStylesheetProjections; + } $sourceReports['conversion_report'] = ConversionReportProjection::fromResultParts('html', $blocks, $fallbacks, $sourceReports, array(), $provenance, $metrics); return new TransformerResult( @@ -567,20 +616,8 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo // remain able to override them. $cssParts[] = $geometryCss; } - if ( $includeAuthorStyles && preg_match_all('@]*>(.*?)@is', $html, $matches) ) { - foreach ( $matches[1] as $styleBlock ) { - $styleBlock = trim(html_entity_decode((string) $styleBlock, ENT_QUOTES | ENT_HTML5, 'UTF-8')); - if ( '' !== $styleBlock ) { - $cssParts[] = $styleBlock; - } - } - } - - if ( $includeAuthorStyles ) { - $staticCss = trim($staticCss); - if ( '' !== $staticCss ) { - $cssParts[] = $staticCss; - } + if ( $includeAuthorStyles && '' !== $this->combinedAuthorCss ) { + $cssParts[] = $this->rewriteAuthorStylesheet($this->combinedAuthorCss); } $css = trim(implode("\n\n", $cssParts)); @@ -609,6 +646,276 @@ private function materializeAuthorStylesheet(string $html, string $staticCss, bo ); } + /** @param array $options */ + private function prepareAuthorSelectorSemantics(string $html, string $staticCss, string $normalizedHtml, array $options): void + { + $this->authorStylesheetAssets = $this->authorStylesheetAssetsFromOptions($options); + $this->combinedAuthorCss = array() === $this->authorStylesheetAssets + ? $this->combinedAuthorStylesheet($html, $staticCss) + : implode("\n\n", array_column($this->authorStylesheetAssets, 'content')); + // Ignore already-generated-looking markers when seeding so collision + // avoidance remains deterministic even when source CSS contains one. + $seedInput = preg_replace('/blocks-engine-(?:source-p|control|specificity(?:-(?:class|id))?)-[a-f0-9]+-\d+/', '', $html . "\0" . $this->combinedAuthorCss) ?? ''; + $this->authorMarkerSeed = substr(hash('sha256', $seedInput), 0, 12); + $this->authorMarkerCollisionText = $html . "\0" . $this->combinedAuthorCss; + $this->authorSpecificityShim = $this->allocateAuthorMarker('specificity'); + $this->authorClassSpecificityShim = $this->allocateAuthorMarker('specificity-class'); + $this->authorIdSpecificityShim = $this->allocateAuthorMarker('specificity-id'); + + if ( '' === $this->combinedAuthorCss ) { + return; + } + + $document = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + $loaded = $document->loadHTML('' . $normalizedHtml . '', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + if ( ! $loaded || ! ($document->getElementsByTagName('body')->item(0) instanceof DOMElement) ) { + return; + } + $this->authorStyleSourceBody = $document->getElementsByTagName('body')->item(0); + + $hasParagraphTypeSelector = false; + ( new CssStylesheetTransformer() )->transform($this->combinedAuthorCss, static function (string $prelude) use (&$hasParagraphTypeSelector): string { + foreach ( CssStylesheetTransformer::splitSelectorList($prelude) ?? array() as $selector ) { + $parsed = CssSelectorMatcher::parse($selector); + foreach ( $parsed['type_spans'] ?? array() as $typeSpan ) { + if ( 'p' === strtolower($typeSpan['name']) ) { + $hasParagraphTypeSelector = true; + break 2; + } + } + } + return $prelude; + }); + if ( $hasParagraphTypeSelector ) { + $this->sourceTagMarkers['p'] = $this->allocateAuthorMarker('source-p'); + } + $this->discoverAuthorControlPaths(); + } + + private function discoverAuthorControlPaths(): void + { + ( new CssStylesheetTransformer() )->transform($this->combinedAuthorCss, function (string $prelude): string { + foreach ( CssStylesheetTransformer::splitSelectorList($prelude) ?? array() as $selector ) { + $parsed = CssSelectorMatcher::parse($selector); + if ( ! $parsed['supported'] ) { + continue; + } + $matches = $this->matchingAuthorSourceElements($parsed); + $controls = array_filter($matches, static fn (DOMElement $element): bool => in_array(strtolower($element->tagName), array( 'a', 'button' ), true)); + if ( array() === $controls ) { + continue; + } + foreach ( $controls as $control ) { + $path = $control->getNodePath() ?? ''; + if ( '' !== $path ) { + $this->sourceControlPaths[$path] = true; + } + } + } + return $prelude; + }); + } + + private function combinedAuthorStylesheet(string $html, string $staticCss): string + { + $cssParts = array(); + if ( preg_match_all('@]*>(.*?)@is', $html, $matches) ) { + foreach ( $matches[1] as $styleBlock ) { + $styleBlock = trim(html_entity_decode((string) $styleBlock, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + if ( '' !== $styleBlock ) { + $cssParts[] = $styleBlock; + } + } + } + $staticCss = trim($staticCss); + if ( '' !== $staticCss ) { + $cssParts[] = $staticCss; + } + return trim(implode("\n\n", $cssParts)); + } + + /** @param array $options @return list */ + private function authorStylesheetAssetsFromOptions(array $options): array + { + if ( ! is_array($options['author_stylesheet_assets'] ?? null) ) { + return array(); + } + $assets = array(); + foreach ( $options['author_stylesheet_assets'] as $asset ) { + if ( ! is_array($asset) || ! is_string($asset['path'] ?? null) || '' === $asset['path'] || ! is_string($asset['content'] ?? null) ) { + continue; + } + $assets[] = array( 'path' => $asset['path'], 'content' => $asset['content'], 'source_hash' => is_string($asset['source_hash'] ?? null) ? $asset['source_hash'] : hash('sha256', $asset['content']) ); + } + return $assets; + } + + /** @return list */ + private function authorStylesheetProjections(): array + { + $projections = array(); + foreach ( $this->authorStylesheetAssets as $asset ) { + $content = $this->rewriteAuthorStylesheet($asset['content']); + $hash = hash('sha256', $content); + $projections[] = array( + 'path' => $asset['path'], + 'content' => $content, + 'bytes' => strlen($content), + 'hash' => $hash, + 'source_hash' => $asset['source_hash'], + ); + } + return $projections; + } + + private function allocateAuthorMarker(string $kind): string + { + do { + $marker = 'blocks-engine-' . $kind . '-' . $this->authorMarkerSeed . '-' . $this->authorMarkerCounter++; + } while ( str_contains($this->authorMarkerCollisionText, $marker) ); + return $marker; + } + + private function rewriteAuthorStylesheet(string $stylesheet): string + { + return ( new CssStylesheetTransformer() )->transform($stylesheet, fn (string $prelude): string => $this->rewriteAuthorSelectorPrelude($prelude)); + } + + private function rewriteAuthorSelectorPrelude(string $prelude): string + { + $selectors = CssStylesheetTransformer::splitSelectorList($prelude); + if ( null === $selectors || ! $this->authorStyleSourceBody instanceof DOMElement ) { + return $prelude; + } + + $rewritten = array(); + foreach ( $selectors as $selector ) { + $parsed = CssSelectorMatcher::parse($selector); + if ( ! $parsed['supported'] ) { + $rewritten[] = $selector; + continue; + } + $matches = $this->matchingAuthorSourceElements($parsed); + if ( array() === $matches ) { + $rewritten[] = $selector; + continue; + } + + $controls = array(); + $hasNonControl = false; + foreach ( $matches as $element ) { + $path = $element->getNodePath() ?? ''; + if ( isset($this->sourceControlMarkers[$path]) ) { + $controls[] = $this->sourceControlMarkers[$path]; + } else { + $hasNonControl = true; + } + } + $controls = array_values(array_unique($controls)); + if ( array() === $controls ) { + $rewritten[] = $this->rewriteSourceTagTypes($selector, $parsed); + continue; + } + + if ( $hasNonControl ) { + $rewritten[] = $this->rewriteSourceTagTypes($selector, $parsed, ':not(:where(.' . implode(',.', $controls) . '))'); + } + foreach ( $controls as $marker ) { + $rewritten[] = $this->projectControlSelector($selector, $parsed, $marker); + } + } + return implode(',', $rewritten); + } + + /** @param array $parsed @return list */ + private function matchingAuthorSourceElements(array $parsed): array + { + $matches = array(); + foreach ($this->authorStyleSourceBody?->getElementsByTagName('*') ?? array() as $element) { + if ( $element instanceof DOMElement && CssSelectorMatcher::matches($element, $parsed, true)['matches'] ) { + $matches[] = $element; + } + } + return $matches; + } + + /** @param array $parsed */ + private function rewriteSourceTagTypes(string $selector, array $parsed, string $rightmostInsertion = ''): string + { + $replacements = array(); + foreach ( $parsed['type_spans'] as $typeSpan ) { + if ( isset($this->sourceTagMarkers[strtolower($typeSpan['name'])]) ) { + $replacements[$typeSpan['start']] = array( 'end' => $typeSpan['end'], 'value' => ':where(.' . $this->sourceTagMarkers[strtolower($typeSpan['name'])] . ')' . $this->typeSpecificityShim() ); + } + } + if ( '' !== $rightmostInsertion ) { + $replacements[(int) $parsed['rightmost_rewrite_end']] = array( 'end' => (int) $parsed['rightmost_rewrite_end'], 'value' => $rightmostInsertion ); + } + return $this->replaceSelectorSpans($selector, $replacements); + } + + /** @param array $parsed */ + private function projectControlSelector(string $selector, array $parsed, string $marker): string + { + $suffix = null === $parsed['pseudo_state_suffix_span'] ? '' : substr($selector, $parsed['pseudo_state_suffix_span']['start']); + // Source matching is complete before mutation and the marker is unique to + // this control. Project through it rather than assuming source attributes + // or ancestors survive canonical core/button serialization. + return ':where(.' . $marker . ')' . $this->selectorSpecificityShims($parsed) . '> :where(.wp-block-button__link)' . $suffix; + } + + /** @param array $parsed */ + private function rightmostTypeIsControl(array $parsed): bool + { + $type = $parsed['compounds'][count($parsed['compounds']) - 1]['type'] ?? null; + return is_string($type) && in_array(strtolower($type), array( 'a', 'button' ), true); + } + + private function typeSpecificityShim(): string + { + return '' === $this->authorSpecificityShim ? '' : ':not(' . $this->authorSpecificityShim . ')'; + } + + /** @param array $parsed */ + private function selectorSpecificityShims(array $parsed): string + { + // A wrapper-driven button can collapse selector ancestors onto its one + // canonical wrapper. Collision-checked impossible sentinels preserve the + // source selector's specificity without coupling to Gutenberg classes. + $shims = ''; + foreach ( $parsed['compounds'] as $compound ) { + if ( null !== $compound['type'] ) { + $shims .= $this->typeSpecificityShim(); + } + foreach ( $compound['classes'] as $_class ) { + $shims .= ':not(.' . $this->authorClassSpecificityShim . ')'; + } + foreach ( $compound['attributes'] as $_attribute ) { + $shims .= ':not(.' . $this->authorClassSpecificityShim . ')'; + } + foreach ( $compound['ids'] as $_id ) { + $shims .= ':not(#' . $this->authorIdSpecificityShim . ')'; + } + } + return $shims; + } + + /** @param array $replacements */ + private function replaceSelectorSpans(string $selector, array $replacements): string + { + ksort($replacements, SORT_NUMERIC); + $output = ''; + $offset = 0; + foreach ( $replacements as $start => $replacement ) { + $output .= substr($selector, $offset, $start - $offset) . $replacement['value']; + $offset = $replacement['end']; + } + return $output . substr($selector, $offset); + } + /** * @param array> $blocks */ @@ -1089,7 +1396,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca fn (DOMElement $sourceElement, array $excludedGeometryProperties = array()): array => $this->presentationAttributes($sourceElement, $excludedGeometryProperties), fn (DOMElement $sourceElement, array &$sourceFallbacks, array $excludedTags): array => $this->convertChildrenWithoutTags($sourceElement, $sourceFallbacks, $excludedTags), fn (string $inlineTagName): bool => $this->isInlineContentElement($inlineTagName), - fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement) + fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null, ?DOMElement $logicalSourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement, $logicalSourceElement) ); } @@ -1495,7 +1802,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca fn (DOMElement $sourceElement): string => $this->resolveCssVariablesInValue($this->mergedPresentationStyle($sourceElement)), fn (DOMElement $sourceElement): string => $this->innerHtml($sourceElement), fn (DOMElement $sourceElement, string $name): string => $this->attr($sourceElement, $name), - fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement) + fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null, ?DOMElement $logicalSourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement, $logicalSourceElement) ); if ( null !== $buttons ) { return $buttons; @@ -1651,7 +1958,7 @@ private function convertText(string $text): array * @param array> $innerBlocks * @return array */ - private function createBlock(string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array + private function createBlock(string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null, ?DOMElement $logicalSourceElement = null): array { $attrs = $this->hoistContentWrappingSpans($name, $attrs); @@ -1663,10 +1970,23 @@ private function createBlock(string $name, array $attrs = array(), array $innerB } if ( $sourceElement instanceof DOMElement ) { + $sourceTagName = strtolower($sourceElement->tagName); + if ( isset($this->sourceTagMarkers[$sourceTagName]) ) { + $attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), $this->sourceTagMarkers[$sourceTagName]); + } + $logicalControl = $logicalSourceElement ?? $sourceElement; + if ( 'core/button' === $name && in_array(strtolower($logicalControl->tagName), array( 'a', 'button' ), true) && isset($this->sourceControlPaths[$logicalControl->getNodePath() ?? '']) ) { + $path = $logicalControl->getNodePath() ?? ''; + if ( '' !== $path && ! isset($this->sourceControlMarkers[$path]) ) { + $this->sourceControlMarkers[$path] = $this->allocateAuthorMarker('control'); + } + if ( isset($this->sourceControlMarkers[$path]) ) { + $attrs['className'] = $this->mergeClassNames((string) ($attrs['className'] ?? ''), $this->sourceControlMarkers[$path]); + } + } $provenanceId = $this->nextSourceProvenanceId++; $this->recordPresentationProvenance($name, $attrs, $sourceElement); $this->recordStructureProvenance($name, $attrs, $sourceElement); - $sourceTagName = strtolower($sourceElement->tagName); if ( $this->isRuntimeDomTarget($sourceElement) && ! $this->isFormControlElement($sourceElement) && ! in_array($sourceTagName, array( 'canvas', 'form', 'script' ), true) ) { $this->recordRuntimeIsland($sourceElement, 'dom', 'runtime_dom_target', 'client_script_execution', array( 'events' => $this->eventMetadata($sourceElement), diff --git a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php index fad6d713..dd05f293 100644 --- a/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php +++ b/php-transformer/src/HtmlToBlocks/Patterns/ButtonsPattern.php @@ -24,7 +24,7 @@ public function __construct() * @param callable(DOMElement): string $resolvedStyle * @param callable(DOMElement): string $innerHtml * @param callable(DOMElement, string): string $attr - * @param callable(string, array, array>, DOMElement|null): array $createBlock + * @param callable(string, array, array>, DOMElement|null, DOMElement|null): array $createBlock * @return array|null */ public function matchAnchor(DOMElement $anchor, callable $fileBlockFromAnchor, callable $presentationAttributes, callable $resolvedStyle, callable $innerHtml, callable $attr, callable $createBlock): ?array @@ -45,7 +45,7 @@ public function matchAnchor(DOMElement $anchor, callable $fileBlockFromAnchor, c * @param callable(DOMElement): array $presentationAttributes * @param callable(DOMElement): string $resolvedStyle * @param callable(DOMElement): string $innerHtml - * @param callable(string, array, array>, DOMElement|null): array $createBlock + * @param callable(string, array, array>, DOMElement|null, DOMElement|null): array $createBlock * @return array */ public function matchButton(DOMElement $button, callable $presentationAttributes, callable $resolvedStyle, callable $innerHtml, callable $createBlock): array @@ -67,7 +67,7 @@ public function matchButton(DOMElement $button, callable $presentationAttributes * @param callable(DOMElement): string $resolvedStyle * @param callable(DOMElement): string $innerHtml * @param callable(DOMElement, string): string $attr - * @param callable(string, array, array>, DOMElement|null): array $createBlock + * @param callable(string, array, array>, DOMElement|null, DOMElement|null): array $createBlock * @return array|null */ public function matchContainer(DOMElement $element, callable $presentationAttributes, callable $resolvedStyle, callable $innerHtml, callable $attr, callable $createBlock): ?array @@ -97,7 +97,7 @@ public function matchContainer(DOMElement $element, callable $presentationAttrib * @param callable(DOMElement): string $resolvedStyle * @param callable(DOMElement): string $innerHtml * @param callable(DOMElement, string): string $attr - * @param callable(string, array, array>, DOMElement|null): array $createBlock + * @param callable(string, array, array>, DOMElement|null, DOMElement|null): array $createBlock * @return array */ private function buttonBlockFromAnchor(DOMElement $anchor, callable $presentationAttributes, callable $resolvedStyle, callable $innerHtml, callable $attr, callable $createBlock, ?DOMElement $presentationElement = null): array @@ -107,7 +107,7 @@ private function buttonBlockFromAnchor(DOMElement $anchor, callable $presentatio return $createBlock('core/button', array_filter(array_merge($this->buttonPresentationAttributes($presentationElement, $presentationAttributes, $resolvedStyle), array( 'text' => $this->buttonText($anchor, $innerHtml($anchor)), 'url' => $attr($anchor, 'href'), - )), static fn ($value): bool => is_array($value) ? array() !== $value : '' !== $value), array(), $presentationElement); + )), static fn ($value): bool => is_array($value) ? array() !== $value : '' !== $value), array(), $presentationElement, $anchor); } private function buttonText(DOMElement $element, string $html): string diff --git a/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php b/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php new file mode 100644 index 00000000..dd41c9f3 --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Style/CssSelectorMatcher.php @@ -0,0 +1,491 @@ +>, combinators: list, type_spans: list, 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 + { + if ( 1 !== preg_match('//u', $selector) ) { + return self::unsupported('invalid-utf8'); + } + + $tokens = CssSelectorTokenizer::tokenize($selector); + if ( ! $tokens['supported'] ) { + return self::unsupported('tokenization'); + } + + $compounds = array(); + $typeSpans = array(); + $suffix = null; + $lastCompound = count($tokens['compounds']) - 1; + foreach ( $tokens['compounds'] as $index => $compound ) { + $parsed = self::parseCompound($compound, $tokens['compound_spans'][ $index ]['start'], $index === $lastCompound); + if ( null === $parsed ) { + return self::unsupported('unsupported-selector'); + } + $compounds[] = $parsed['compound']; + if ( null !== $parsed['type_span'] ) { + $typeSpans[] = array_merge($parsed['type_span'], array( 'compound' => $index )); + } + if ( null !== $parsed['suffix'] ) { + $suffix = $parsed['suffix']; + } + } + if ( in_array('||', $tokens['combinators'], true) ) { + return self::unsupported('column-combinator'); + } + + $rightmost = $tokens['compound_spans'][ $lastCompound ]; + return array( + 'supported' => true, + 'reason' => null, + 'compounds' => $compounds, + 'combinators' => $tokens['combinators'], + 'type_spans' => $typeSpans, + 'rightmost_compound_span' => $rightmost, + 'pseudo_state_suffix_span' => $suffix, + 'rightmost_rewrite_end' => $suffix['start'] ?? $rightmost['end'], + ); + } + + /** + * Match from the rightmost compound. Only hover, focus, active, and visited + * are detachable dynamic suffixes; callers must explicitly account for them. + * + * @param array $selector Result of parse(). + * @return array{supported: bool, matches: bool} + */ + public static function matches(DOMElement $element, array $selector, bool $accountForPseudoStateSuffix = false): array + { + if ( ! ($selector['supported'] ?? false) ) { + return array( 'supported' => false, 'matches' => false ); + } + if ( null !== ($selector['pseudo_state_suffix_span'] ?? null) && ! $accountForPseudoStateSuffix ) { + return array( 'supported' => false, 'matches' => false ); + } + if ( self::hasUnmodeledHtmlAttributeValueSemantics($selector['compounds']) ) { + return array( 'supported' => false, 'matches' => false ); + } + + return array( + 'supported' => true, + 'matches' => self::matchesAt($element, $selector['compounds'], $selector['combinators'], count($selector['compounds']) - 1), + ); + } + + /** @return array{compound: array, suffix: array{start: int, end: int}|null, type_span: array{start: int, end: int, name: string}|null}|null */ + private static function parseCompound(string $source, int $sourceStart, bool $isRightmost): ?array + { + $compound = array( 'type' => null, 'universal' => false, 'classes' => array(), 'ids' => array(), 'attributes' => array() ); + $offset = 0; + $suffix = null; + $typeSpan = null; + $hasSimple = false; + $length = strlen($source); + + while ( $offset < $length ) { + self::skipIgnorable($source, $offset); + if ( $offset >= $length ) { + break; + } + + $character = $source[ $offset ]; + if ( ':' === $character ) { + $start = $offset; + ++$offset; + if ( ':' === ($source[ $offset ] ?? '') ) { + return null; + } + $name = self::identifier($source, $offset); + if ( null === $name || '(' === ($source[ $offset ] ?? '') || ! $isRightmost ) { + return null; + } + if ( ! in_array(strtolower($name), array( 'hover', 'focus', 'active', 'visited' ), true) ) { + return null; + } + if ( null === $suffix ) { + $suffix = array( 'start' => $sourceStart + $start, 'end' => $sourceStart + $offset ); + } else { + $suffix['end'] = $sourceStart + $offset; + } + continue; + } + if ( null !== $suffix ) { + return null; + } + + if ( '.' === $character || '#' === $character ) { + ++$offset; + $name = self::identifier($source, $offset); + if ( null === $name ) { + return null; + } + $key = '.' === $character ? 'classes' : 'ids'; + $compound[ $key ][] = $name; + $hasSimple = true; + continue; + } + if ( '[' === $character ) { + $attribute = self::attribute($source, $offset); + if ( null === $attribute ) { + return null; + } + $compound['attributes'][] = $attribute; + $hasSimple = true; + continue; + } + if ( '*' === $character ) { + if ( $hasSimple || $compound['universal'] ) { + return null; + } + ++$offset; + $compound['universal'] = true; + $hasSimple = true; + continue; + } + if ( $hasSimple || '|' === $character ) { + return null; + } + $start = $offset; + $name = self::identifier($source, $offset); + if ( null === $name ) { + return null; + } + $compound['type'] = $name; + $typeSpan = array( 'start' => $sourceStart + $start, 'end' => $sourceStart + $offset, 'name' => $name ); + $hasSimple = true; + } + + return $hasSimple ? array( 'compound' => $compound, 'suffix' => $suffix, 'type_span' => $typeSpan ) : null; + } + + /** @return array{name: string, operator: string|null, value: string|null, flag: string|null}|null */ + private static function attribute(string $source, int &$offset): ?array + { + ++$offset; + self::skipIgnorable($source, $offset); + $name = self::identifier($source, $offset); + if ( null === $name ) { + return null; + } + $name = strtolower($name); // HTML attribute names are ASCII-case-insensitive. + self::skipIgnorable($source, $offset); + if ( ']' === ($source[ $offset ] ?? '') ) { + ++$offset; + return array( 'name' => $name, 'operator' => null, 'value' => null, 'flag' => null ); + } + + $operator = self::attributeOperator($source, $offset); + if ( null === $operator ) { + return null; + } + self::skipIgnorable($source, $offset); + $value = self::attributeValue($source, $offset); + if ( null === $value ) { + return null; + } + self::skipIgnorable($source, $offset); + + $flag = null; + if ( ']' !== ($source[ $offset ] ?? '') ) { + $flag = self::identifier($source, $offset); + if ( null !== $flag ) { + $flag = strtolower($flag); + } + if ( ! in_array($flag, array( 'i', 's' ), true) ) { + return null; + } + self::skipIgnorable($source, $offset); + } + if ( ']' !== ($source[ $offset ] ?? '') ) { + return null; + } + ++$offset; + + return array( 'name' => $name, 'operator' => $operator, 'value' => $value, 'flag' => $flag ); + } + + private static function attributeOperator(string $source, int &$offset): ?string + { + foreach ( array( '~=', '|=', '^=', '$=', '*=', '=' ) as $candidate ) { + if ( substr($source, $offset, strlen($candidate)) === $candidate ) { + $offset += strlen($candidate); + return $candidate; + } + } + return null; + } + + private static function attributeValue(string $source, int &$offset): ?string + { + $quote = $source[ $offset ] ?? ''; + if ( '"' !== $quote && "'" !== $quote ) { + return self::identifier($source, $offset); + } + + ++$offset; + $value = ''; + while ( $offset < strlen($source) && $source[ $offset ] !== $quote ) { + if ( "\n" === $source[ $offset ] || "\r" === $source[ $offset ] || "\f" === $source[ $offset ] ) { + return null; + } + $escape = self::escape($source, $offset); + if ( null === $escape ) { + $value .= $source[ $offset ]; + ++$offset; + continue; + } + $value .= $escape; + } + if ( $quote !== ($source[ $offset ] ?? '') ) { + return null; + } + ++$offset; + return $value; + } + + private static function identifier(string $source, int &$offset): ?string + { + $start = $offset; + $first = self::identifierFirstCharacter($source, $offset); + if ( null === $first ) { + $offset = $start; + return null; + } + $value = $first; + while ( $offset < strlen($source) ) { + $escape = self::escape($source, $offset); + if ( null !== $escape ) { + $value .= $escape; + continue; + } + if ( ! self::isIdentifierCharacter($source[ $offset ]) ) { + break; + } + $value .= $source[ $offset ]; + ++$offset; + } + return $value; + } + + private static function identifierFirstCharacter(string $source, int &$offset): ?string + { + $escape = self::escape($source, $offset); + if ( null !== $escape ) { + return $escape; + } + $character = $source[ $offset ] ?? ''; + if ( '-' === $character ) { + ++$offset; + $next = self::escape($source, $offset); + if ( null !== $next ) { + return '-' . $next; + } + if ( '-' !== ($source[ $offset ] ?? '') && ! self::isIdentifierStartCharacter($source[ $offset ] ?? '') ) { + return null; + } + return '-'; + } + if ( ! self::isIdentifierStartCharacter($character) ) { + return null; + } + ++$offset; + return $character; + } + + private static function escape(string $source, int &$offset): ?string + { + if ( '\\' !== ($source[ $offset ] ?? '') ) { + return null; + } + $escaped = $source[ $offset + 1 ] ?? ''; + if ( '' === $escaped || "\n" === $escaped || "\r" === $escaped || "\f" === $escaped ) { + return null; + } + $end = CssSyntaxScanner::escapeEnd($source, $offset); + if ( null === $end ) { + return null; + } + $raw = substr($source, $offset + 1, $end - $offset - 1); + $offset = $end; + $hex = preg_replace('/[\x09\x0A\x0C\x0D\x20].*$/', '', $raw); + if ( '' === $hex || ! ctype_xdigit($hex) ) { + return $raw; + } + $codepoint = hexdec($hex); + if ( 0 === $codepoint || $codepoint > 0x10ffff || ($codepoint >= 0xd800 && $codepoint <= 0xdfff) ) { + return "\xef\xbf\xbd"; + } + return self::utf8($codepoint); + } + + private static function skipIgnorable(string $source, int &$offset): void + { + $length = strlen($source); + while ( $offset < $length ) { + if ( CssSyntaxScanner::isCssWhitespace($source[ $offset ]) ) { + ++$offset; + continue; + } + if ( '/*' !== substr($source, $offset, 2) ) { + return; + } + $end = strpos($source, '*/', $offset + 2); + if ( false === $end ) { + $offset = $length; + return; + } + $offset = $end + 2; + } + } + + /** @param list> $compounds @param list $combinators */ + private static function matchesAt(DOMElement $element, array $compounds, array $combinators, int $index): bool + { + if ( ! self::matchesCompound($element, $compounds[ $index ]) ) { + return false; + } + if ( 0 === $index ) { + return true; + } + + $combinator = $combinators[ $index - 1 ]; + if ( '>' === $combinator ) { + return $element->parentNode instanceof DOMElement && self::matchesAt($element->parentNode, $compounds, $combinators, $index - 1); + } + if ( '+' === $combinator ) { + $previous = self::previousElementSibling($element); + return null !== $previous && self::matchesAt($previous, $compounds, $combinators, $index - 1); + } + if ( '~' === $combinator ) { + for ( $previous = self::previousElementSibling($element); null !== $previous; $previous = self::previousElementSibling($previous) ) { + if ( self::matchesAt($previous, $compounds, $combinators, $index - 1) ) { + return true; + } + } + return false; + } + + for ( $parent = $element->parentNode; $parent instanceof DOMElement; $parent = $parent->parentNode ) { + if ( self::matchesAt($parent, $compounds, $combinators, $index - 1) ) { + return true; + } + } + return false; + } + + /** @param array $compound */ + private static function matchesCompound(DOMElement $element, array $compound): bool + { + if ( null !== $compound['type'] && 0 !== strcasecmp($element->tagName, $compound['type']) ) { + return false; + } + foreach ( $compound['ids'] as $id ) { + if ( $element->getAttribute('id') !== $id ) { + return false; + } + } + $classes = preg_split('/[\x09\x0A\x0C\x0D\x20]+/', trim($element->getAttribute('class'))) ?: array(); + foreach ( $compound['classes'] as $class ) { + if ( ! in_array($class, $classes, true) ) { + return false; + } + } + foreach ( $compound['attributes'] as $attribute ) { + if ( ! self::matchesAttribute($element, $attribute) ) { + return false; + } + } + return true; + } + + /** @param array{name: string, operator: string|null, value: string|null, flag: string|null} $attribute */ + private static function matchesAttribute(DOMElement $element, array $attribute): bool + { + if ( ! $element->hasAttribute($attribute['name']) ) { + return false; + } + if ( null === $attribute['operator'] ) { + return true; + } + + $actual = $element->getAttribute($attribute['name']); + $expected = (string) $attribute['value']; + if ( 'i' === $attribute['flag'] ) { + $actual = strtolower($actual); + $expected = strtolower($expected); + } + return match ( $attribute['operator'] ) { + '=' => $actual === $expected, + '~=' => in_array($expected, preg_split('/[\x09\x0A\x0C\x0D\x20]+/', trim($actual)) ?: array(), true), + '|=' => $actual === $expected || str_starts_with($actual, $expected . '-'), + '^=' => '' !== $expected && str_starts_with($actual, $expected), + '$=' => '' !== $expected && str_ends_with($actual, $expected), + '*=' => '' !== $expected && str_contains($actual, $expected), + }; + } + + /** @param list> $compounds */ + private static function hasUnmodeledHtmlAttributeValueSemantics(array $compounds): bool + { + // HTML defines these enumerated values as ASCII-case-insensitive by default. + $enumerated = array( 'autocomplete', 'contenteditable', 'dir', 'draggable', 'enterkeyhint', 'hidden', 'inputmode', 'kind', 'method', 'rel', 'spellcheck', 'translate', 'type' ); + foreach ( $compounds as $compound ) { + foreach ( $compound['attributes'] as $attribute ) { + if ( null !== $attribute['operator'] && null === $attribute['flag'] && in_array($attribute['name'], $enumerated, true) ) { + return true; + } + } + } + return false; + } + + private static function previousElementSibling(DOMElement $element): ?DOMElement + { + for ( $node = $element->previousSibling; $node instanceof DOMNode; $node = $node->previousSibling ) { + if ( $node instanceof DOMElement ) { + return $node; + } + } + return null; + } + + private static function isIdentifierStartCharacter(string $character): bool + { + return ctype_alpha($character) || '_' === $character || ('' !== $character && ord($character) >= 0x80); + } + + private static function isIdentifierCharacter(string $character): bool + { + return self::isIdentifierStartCharacter($character) || ctype_digit($character) || '-' === $character; + } + + private static function utf8(int $codepoint): string + { + if ( $codepoint <= 0x7f ) { + return chr($codepoint); + } + if ( $codepoint <= 0x7ff ) { + return chr(0xc0 | ($codepoint >> 6)) . chr(0x80 | ($codepoint & 0x3f)); + } + if ( $codepoint <= 0xffff ) { + return chr(0xe0 | ($codepoint >> 12)) . chr(0x80 | (($codepoint >> 6) & 0x3f)) . chr(0x80 | ($codepoint & 0x3f)); + } + return chr(0xf0 | ($codepoint >> 18)) . chr(0x80 | (($codepoint >> 12) & 0x3f)) . chr(0x80 | (($codepoint >> 6) & 0x3f)) . chr(0x80 | ($codepoint & 0x3f)); + } + + /** @return array{supported: false, reason: string, compounds: list>, combinators: list, type_spans: list, rightmost_compound_span: null, pseudo_state_suffix_span: null, rightmost_rewrite_end: null} */ + private static function unsupported(string $reason): array + { + return array( 'supported' => false, 'reason' => $reason, 'compounds' => array(), 'combinators' => array(), 'type_spans' => array(), 'rightmost_compound_span' => null, 'pseudo_state_suffix_span' => null, 'rightmost_rewrite_end' => null ); + } +} diff --git a/php-transformer/src/HtmlToBlocks/Style/CssSelectorTokenizer.php b/php-transformer/src/HtmlToBlocks/Style/CssSelectorTokenizer.php new file mode 100644 index 00000000..ac0e368d --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Style/CssSelectorTokenizer.php @@ -0,0 +1,105 @@ +, combinators: list, rightmost_compound: string, compound_spans: list, combinator_spans: list} + */ + public static function tokenize(string $selector): array + { + $unsupported = static fn (): array => array( 'supported' => false, 'compounds' => array(), 'combinators' => array(), 'rightmost_compound' => '', 'compound_spans' => array(), 'combinator_spans' => array() ); + $compounds = array(); + $combinators = array(); + $compoundSpans = array(); + $combinatorSpans = array(); + $state = CssSyntaxScanner::state(); + $compoundStart = null; + $whitespaceStart = null; + $length = strlen($selector); + + for ( $offset = 0; $offset < $length; ) { + $character = $selector[ $offset ]; + $topLevel = CssSyntaxScanner::isTopLevel($state); + $next = CssSyntaxScanner::consume($selector, $offset, $state); + if ( null === $next ) { + return $unsupported(); + } + if ( ! $topLevel || $next !== $offset + 1 ) { + // An escape is one lexical unit, but it can also be the first + // byte of a compound (including after descendant whitespace). + if ( $topLevel && '\\' === $character ) { + if ( null === $compoundStart ) { + $compoundStart = $offset; + } elseif ( null !== $whitespaceStart ) { + self::appendCompound($selector, $compoundStart, $whitespaceStart, $compounds, $compoundSpans); + $combinators[] = ' '; + $combinatorSpans[] = array( 'start' => $whitespaceStart, 'end' => $offset ); + $compoundStart = $offset; + } + $whitespaceStart = null; + } + $offset = $next; + continue; + } + if ( CssSyntaxScanner::isCssWhitespace($character) ) { + if ( null !== $compoundStart && null === $whitespaceStart ) { + $whitespaceStart = $offset; + } + $offset = $next; + continue; + } + if ( ',' === $character || '{' === $character || '}' === $character || ';' === $character ) { + return $unsupported(); + } + $combinatorLength = '|' === $character && '|' === ($selector[ $offset + 1 ] ?? '') ? 2 : (in_array($character, array( '>', '+', '~' ), true) ? 1 : 0); + if ( $combinatorLength > 0 ) { + if ( null === $compoundStart ) { + return $unsupported(); + } + $end = $whitespaceStart ?? $offset; + self::appendCompound($selector, $compoundStart, $end, $compounds, $compoundSpans); + $combinators[] = substr($selector, $offset, $combinatorLength); + $combinatorSpans[] = array( 'start' => $offset, 'end' => $offset + $combinatorLength ); + $compoundStart = null; + $whitespaceStart = null; + $offset += $combinatorLength; + continue; + } + if ( null === $compoundStart ) { + $compoundStart = $offset; + } elseif ( null !== $whitespaceStart ) { + self::appendCompound($selector, $compoundStart, $whitespaceStart, $compounds, $compoundSpans); + $combinators[] = ' '; + $combinatorSpans[] = array( 'start' => $whitespaceStart, 'end' => $offset ); + $compoundStart = $offset; + } + $whitespaceStart = null; + $offset = $next; + } + + if ( ! CssSyntaxScanner::isComplete($state) || null === $compoundStart ) { + return $unsupported(); + } + self::appendCompound($selector, $compoundStart, $whitespaceStart ?? $length, $compounds, $compoundSpans); + if ( count($combinators) !== count($compounds) - 1 ) { + return $unsupported(); + } + return array( 'supported' => true, 'compounds' => $compounds, 'combinators' => $combinators, 'rightmost_compound' => $compounds[ count($compounds) - 1 ], 'compound_spans' => $compoundSpans, 'combinator_spans' => $combinatorSpans ); + } + + /** @param list $compounds @param list $spans */ + private static function appendCompound(string $selector, int $start, int $end, array &$compounds, array &$spans): void + { + if ( $end <= $start ) { + return; + } + $compounds[] = substr($selector, $start, $end - $start); + $spans[] = array( 'start' => $start, 'end' => $end ); + } +} diff --git a/php-transformer/src/HtmlToBlocks/Style/CssStylesheetTransformer.php b/php-transformer/src/HtmlToBlocks/Style/CssStylesheetTransformer.php new file mode 100644 index 00000000..8772e40e --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Style/CssStylesheetTransformer.php @@ -0,0 +1,233 @@ +isWellFormedStylesheet($stylesheet) ) { + return $stylesheet; + } + return $this->transformRules($stylesheet, $transformSelectorPrelude); + } + + /** + * Split a selector list only at top-level commas. Null indicates malformed CSS. + * + * @return list|null + */ + public static function splitSelectorList(string $prelude): ?array + { + $parts = array(); + $start = 0; + $state = CssSyntaxScanner::state(); + $length = strlen($prelude); + + for ( $index = 0; $index < $length; ++$index ) { + $topLevel = CssSyntaxScanner::isTopLevel($state); + $next = CssSyntaxScanner::consume($prelude, $index, $state); + if ( null === $next ) { + return null; + } + if ( ',' === $prelude[ $index ] && $topLevel && $next === $index + 1 ) { + $parts[] = substr($prelude, $start, $index - $start); + $start = $index + 1; + } + $index = $next - 1; + } + + if ( ! CssSyntaxScanner::isComplete($state) ) { + return null; + } + + $parts[] = substr($prelude, $start); + return $parts; + } + + /** + * @param callable(string): string $transformSelectorPrelude + */ + private function transformRules(string $css, callable $transformSelectorPrelude): string + { + $output = ''; + $offset = 0; + $length = strlen($css); + + while ( $offset < $length ) { + $boundary = $this->nextRuleBoundary($css, $offset); + if ( null === $boundary ) { + return $output . substr($css, $offset); + } + + $token = $css[ $boundary ]; + if ( ';' === $token ) { + $output .= substr($css, $offset, $boundary - $offset + 1); + $offset = $boundary + 1; + continue; + } + + $blockEnd = $this->matchingBrace($css, $boundary); + if ( null === $blockEnd ) { + return $output . substr($css, $offset); + } + + $prelude = substr($css, $offset, $boundary - $offset); + if ( $this->isAtRule($prelude) ) { + $output .= $prelude . '{'; + $body = substr($css, $boundary + 1, $blockEnd - $boundary - 1); + $output .= $this->walksNestedRules($prelude) ? $this->transformRules($body, $transformSelectorPrelude) : $body; + $output .= '}'; + } elseif ( $this->isStylePrelude($prelude) ) { + $output .= $transformSelectorPrelude($prelude) . '{'; + $output .= substr($css, $boundary + 1, $blockEnd - $boundary - 1) . '}'; + } else { + $output .= substr($css, $offset, $blockEnd - $offset + 1); + } + + $offset = $blockEnd + 1; + } + + return $output; + } + + private function nextRuleBoundary(string $css, int $offset): ?int + { + $state = CssSyntaxScanner::state(); + $length = strlen($css); + for ( $index = $offset; $index < $length; ++$index ) { + $topLevel = CssSyntaxScanner::isTopLevel($state); + $next = CssSyntaxScanner::consume($css, $index, $state); + if ( null === $next ) { + return null; + } + if ( $topLevel && $next === $index + 1 && ('{' === $css[ $index ] || ';' === $css[ $index ]) ) { + return $index; + } + $index = $next - 1; + } + + return null; + } + + private function matchingBrace(string $css, int $openingBrace): ?int + { + $state = CssSyntaxScanner::state(); + $depth = 0; + $length = strlen($css); + for ( $index = $openingBrace; $index < $length; ++$index ) { + $topLevel = CssSyntaxScanner::isTopLevel($state); + $next = CssSyntaxScanner::consume($css, $index, $state); + if ( null === $next ) { + return null; + } + if ( $topLevel && $next === $index + 1 && '{' === $css[ $index ] ) { + ++$depth; + } elseif ( $topLevel && $next === $index + 1 && '}' === $css[ $index ] && 0 === --$depth ) { + return $index; + } + $index = $next - 1; + } + + return null; + } + + private function isAtRule(string $prelude): bool + { + return '@' === self::firstSignificantCharacter($prelude); + } + + private function isStylePrelude(string $prelude): bool + { + return '' !== self::firstSignificantCharacter($prelude) && null !== self::splitSelectorList($prelude); + } + + private function walksNestedRules(string $prelude): bool + { + return in_array(self::atRuleName($prelude), array( 'container', 'layer', 'media', 'scope', 'starting-style', 'supports' ), true); + } + + private function isWellFormedStylesheet(string $css): bool + { + $state = CssSyntaxScanner::state(); + $braces = 0; + $length = strlen($css); + for ( $offset = 0; $offset < $length; ) { + $topLevel = CssSyntaxScanner::isTopLevel($state); + $next = CssSyntaxScanner::consume($css, $offset, $state); + if ( null === $next ) { + return false; + } + if ( $topLevel && $next === $offset + 1 ) { + if ( '{' === $css[ $offset ] ) { + ++$braces; + } elseif ( '}' === $css[ $offset ] ) { + if ( --$braces < 0 ) { + return false; + } + } + } + $offset = $next; + } + return 0 === $braces && CssSyntaxScanner::isComplete($state); + } + + private static function firstSignificantCharacter(string $value): string + { + $state = CssSyntaxScanner::state(); + $length = strlen($value); + for ( $offset = 0; $offset < $length; ) { + $next = CssSyntaxScanner::consume($value, $offset, $state); + if ( null === $next ) { + return ''; + } + if ( $next === $offset + 1 && CssSyntaxScanner::isTopLevel($state) && ! CssSyntaxScanner::isCssWhitespace($value[ $offset ]) ) { + return $value[ $offset ]; + } + $offset = $next; + } + return ''; + } + + private static function atRuleName(string $prelude): string + { + $state = CssSyntaxScanner::state(); + $length = strlen($prelude); + $seenAt = false; + $name = ''; + for ( $offset = 0; $offset < $length; ) { + $next = CssSyntaxScanner::consume($prelude, $offset, $state); + if ( null === $next ) { + return ''; + } + if ( $next !== $offset + 1 || ! CssSyntaxScanner::isTopLevel($state) ) { + $offset = $next; + continue; + } + $character = $prelude[ $offset ]; + if ( ! $seenAt ) { + if ( CssSyntaxScanner::isCssWhitespace($character) ) { $offset = $next; continue; } + if ( '@' !== $character ) { return ''; } + $seenAt = true; + } elseif ( ctype_alpha($character) || '-' === $character ) { + $name .= strtolower($character); + } else { + return $name; + } + $offset = $next; + } + return $name; + } +} diff --git a/php-transformer/src/HtmlToBlocks/Style/CssSyntaxScanner.php b/php-transformer/src/HtmlToBlocks/Style/CssSyntaxScanner.php new file mode 100644 index 00000000..25d3c86d --- /dev/null +++ b/php-transformer/src/HtmlToBlocks/Style/CssSyntaxScanner.php @@ -0,0 +1,117 @@ + '', 'comment' => false, 'parens' => 0, 'brackets' => 0 ); + } + + /** + * Consume one CSS lexical unit and return the next byte offset. + * + * @param array{quote: string, comment: bool, parens: int, brackets: int} $state + */ + public static function consume(string $value, int $offset, array &$state): ?int + { + $character = $value[ $offset ]; + $next = $value[ $offset + 1 ] ?? ''; + if ( $state['comment'] ) { + if ( '*' === $character && '/' === $next ) { + $state['comment'] = false; + return $offset + 2; + } + return $offset + 1; + } + if ( '' !== $state['quote'] ) { + if ( '\\' === $character ) { + return self::escapeEnd($value, $offset); + } + if ( $character === $state['quote'] ) { + $state['quote'] = ''; + } + return $offset + 1; + } + if ( '/' === $character && '*' === $next ) { + $state['comment'] = true; + return $offset + 2; + } + if ( '"' === $character || "'" === $character ) { + $state['quote'] = $character; + return $offset + 1; + } + if ( '\\' === $character ) { + return self::escapeEnd($value, $offset); + } + if ( '(' === $character ) { + ++$state['parens']; + } elseif ( ')' === $character ) { + if ( 0 === $state['parens'] ) { + return null; + } + --$state['parens']; + } elseif ( '[' === $character ) { + ++$state['brackets']; + } elseif ( ']' === $character ) { + if ( 0 === $state['brackets'] ) { + return null; + } + --$state['brackets']; + } + return $offset + 1; + } + + public static function isCssWhitespace(string $character): bool + { + return " " === $character || "\t" === $character || "\n" === $character || "\r" === $character || "\f" === $character; + } + + /** @param array{quote: string, comment: bool, parens: int, brackets: int} $state */ + public static function isTopLevel(array $state): bool + { + return '' === $state['quote'] && ! $state['comment'] && 0 === $state['parens'] && 0 === $state['brackets']; + } + + /** @param array{quote: string, comment: bool, parens: int, brackets: int} $state */ + public static function isComplete(array $state): bool + { + return self::isTopLevel($state); + } + + /** Consume a CSS escape beginning at the supplied backslash. */ + public static function escapeEnd(string $value, int $offset): ?int + { + if ( '\\' !== ($value[ $offset ] ?? '') ) { + return null; + } + return self::consumeEscape($value, $offset); + } + + private static function consumeEscape(string $value, int $offset): ?int + { + $length = strlen($value); + $offset++; + if ( $offset >= $length ) { + return null; + } + if ( ! ctype_xdigit($value[ $offset ]) ) { + if ( "\r" === $value[ $offset ] && "\n" === ($value[ $offset + 1 ] ?? '') ) { + return $offset + 2; + } + return $offset + 1; + } + $end = $offset; + while ( $end < $length && $end < $offset + 6 && ctype_xdigit($value[ $end ]) ) { + ++$end; + } + if ( $end < $length && self::isCssWhitespace($value[ $end ]) ) { + return "\r" === $value[ $end ] && "\n" === ($value[ $end + 1 ] ?? '') ? $end + 2 : $end + 1; + } + return $end; + } +} diff --git a/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php b/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php index 95f680c6..b36b757c 100644 --- a/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php +++ b/php-transformer/src/HtmlToBlocks/Support/ButtonLinkDispatchTrait.php @@ -23,7 +23,7 @@ private function convertAnchorDispatchElement(DOMElement $element, array &$fallb fn (DOMElement $sourceElement, array $excludedGeometryProperties = array()): array => $this->presentationAttributes($sourceElement, $excludedGeometryProperties), fn (DOMElement $sourceElement): string => $this->richTextContentWithMaterializedInlineStyles($sourceElement), fn (DOMElement $sourceElement): string => $this->restoreSvgCasing($this->outerHtml($sourceElement)), - fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement) + fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null, ?DOMElement $logicalSourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement, $logicalSourceElement) ); if ( null !== $logo ) { return $logo; @@ -36,7 +36,7 @@ private function convertAnchorDispatchElement(DOMElement $element, array &$fallb fn (DOMElement $sourceElement): string => $this->resolveCssVariablesInValue($this->mergedPresentationStyle($sourceElement)), fn (DOMElement $sourceElement): string => $this->innerHtml($sourceElement), fn (DOMElement $sourceElement, string $name): string => $this->attr($sourceElement, $name), - fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement) + fn (string $name, array $attrs = array(), array $innerBlocks = array(), ?DOMElement $sourceElement = null, ?DOMElement $logicalSourceElement = null): array => $this->createBlock($name, $attrs, $innerBlocks, $sourceElement, $logicalSourceElement) ); if ( null !== $button ) { return $button; diff --git a/php-transformer/src/StaticSite/MaterializationPlanBuilder.php b/php-transformer/src/StaticSite/MaterializationPlanBuilder.php index 1954127a..87e2fd3a 100644 --- a/php-transformer/src/StaticSite/MaterializationPlanBuilder.php +++ b/php-transformer/src/StaticSite/MaterializationPlanBuilder.php @@ -282,6 +282,7 @@ private function assets(array $assets): array 'role' => (string) ($asset['role'] ?? ''), 'intent' => (string) ($asset['intent'] ?? ''), 'media_type' => (string) ($asset['media_type'] ?? $asset['mime_type'] ?? ''), + 'media' => (string) ($asset['media'] ?? ''), 'mime_type' => (string) ($asset['mime_type'] ?? ''), 'bytes' => (int) ($asset['bytes'] ?? 0), 'binary' => ! empty($asset['binary']), diff --git a/php-transformer/tests/fixtures/parity/artifact-author-selector-inline-projection.json b/php-transformer/tests/fixtures/parity/artifact-author-selector-inline-projection.json new file mode 100644 index 00000000..b1fb3de2 --- /dev/null +++ b/php-transformer/tests/fixtures/parity/artifact-author-selector-inline-projection.json @@ -0,0 +1,29 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "artifact-author-selector-inline-projection", + "description": "Rewrites normalized inline stylesheet assets in place after canonical block conversion.", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/artifact-author-selector-inline-projection.json", + "notes": "The original stale p and button selectors must not remain in the materialized inline asset." + }, + "legacy_comparison": { "skip": true, "reason": "Author selector projection is a native artifact materialization behavior." }, + "operation": "artifact_compiler.compile", + "input": { + "artifact": { + "files": [ + { "path": "index.html", "kind": "html", "content": "

Copy

Go
" } + ] + } + }, + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "assets.0.path", "assert": "equals", "value": "index.inline.css" }, + { "path": "assets.0.content", "assert": "contains", "value": ":where(.blocks-engine-source-p-" }, + { "path": "assets.0.content", "assert": "contains", "value": "> :where(.wp-block-button__link):hover" }, + { "path": "assets.0.content", "assert": "not_contains", "value": ".hero-text p" }, + { "path": "assets.0.content", "assert": "not_contains", "value": "a.cta:hover" }, + { "path": "source_reports.materialization_plan.assets.0.target_path", "assert": "equals", "value": "index.inline.css" }, + { "path": "source_reports.materialization_plan.assets.0.content", "assert": "not_contains", "value": ".hero-text p" } + ] +} diff --git a/php-transformer/tests/fixtures/parity/artifact-author-selector-linked-projection.json b/php-transformer/tests/fixtures/parity/artifact-author-selector-linked-projection.json new file mode 100644 index 00000000..9672bf0f --- /dev/null +++ b/php-transformer/tests/fixtures/parity/artifact-author-selector-linked-projection.json @@ -0,0 +1,32 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "artifact-author-selector-linked-projection", + "description": "Retains separate linked stylesheet paths and order while projecting selectors in each asset independently.", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/artifact-author-selector-linked-projection.json", + "notes": "Multiple linked CSS assets remain independently materializable after selector rewriting." + }, + "legacy_comparison": { "skip": true, "reason": "Per-asset selector projection is a native artifact materialization behavior." }, + "operation": "artifact_compiler.compile", + "input": { + "artifact": { + "files": [ + { "path": "index.html", "kind": "html", "content": "
Go

Copy

" }, + { "path": "assets/buttons.css", "kind": "css", "content": "a.cta:hover{padding:1rem}" }, + { "path": "assets/type.css", "kind": "css", "content": "p{color:red}" } + ] + } + }, + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "assets.0.path", "assert": "equals", "value": "assets/buttons.css" }, + { "path": "assets.1.path", "assert": "equals", "value": "assets/type.css" }, + { "path": "assets.0.content", "assert": "contains", "value": "> :where(.wp-block-button__link):hover" }, + { "path": "assets.0.content", "assert": "not_contains", "value": "a.cta:hover" }, + { "path": "assets.1.content", "assert": "contains", "value": ":where(.blocks-engine-source-p-" }, + { "path": "assets.1.content", "assert": "not_contains", "value": "p{color:red}" }, + { "path": "source_reports.compiled_site.theme.stylesheets.0", "assert": "equals", "value": "assets/buttons.css" }, + { "path": "source_reports.compiled_site.theme.stylesheets.1", "assert": "equals", "value": "assets/type.css" } + ] +} diff --git a/php-transformer/tests/fixtures/parity/artifact-stylesheet-media-type-occurrences.json b/php-transformer/tests/fixtures/parity/artifact-stylesheet-media-type-occurrences.json new file mode 100644 index 00000000..327d62b5 --- /dev/null +++ b/php-transformer/tests/fixtures/parity/artifact-stylesheet-media-type-occurrences.json @@ -0,0 +1,35 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "artifact-stylesheet-media-type-occurrences", + "description": "Carries supported CSS media/type metadata through ordered stylesheet occurrences while leaving unsupported types out of materialization.", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/artifact-stylesheet-media-type-occurrences.json", + "notes": "Nonce attributes are intentionally absent from static asset metadata; media/type survive for runtime link emission." + }, + "legacy_comparison": { "skip": true, "reason": "Stylesheet occurrence metadata is an artifact materialization behavior." }, + "operation": "artifact_compiler.compile", + "input": { + "artifact": { + "files": [ + { "path": "assets/print.css", "kind": "css", "content": ".print{color:black}" }, + { "path": "index.inline.css", "kind": "css", "content": ".authored{color:purple}" }, + { "path": "index.html", "kind": "html", "content": "

Copy

" } + ] + } + }, + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "assets.0.path", "assert": "equals", "value": "index.inline-generated-1.css" }, + { "path": "assets.0.media", "assert": "equals", "value": "print" }, + { "path": "assets.0.type", "assert": "equals", "value": "text/css" }, + { "path": "assets.1.path", "assert": "equals", "value": "assets/print.css" }, + { "path": "assets.1.media", "assert": "equals", "value": "print" }, + { "path": "assets.1.type", "assert": "equals", "value": "text/css" }, + { "path": "assets.2.path", "assert": "equals", "value": "index.inline.css" }, + { "path": "source_reports.compiled_site.assets.0.media", "assert": "equals", "value": "print" }, + { "path": "source_reports.materialization_plan.assets.0.media", "assert": "equals", "value": "print" }, + { "path": "assets.0.content", "assert": "not_contains", "value": "secret" }, + { "path": "assets.0.content", "assert": "not_contains", "value": ".unsupported" } + ] +} diff --git a/php-transformer/tests/fixtures/parity/artifact-stylesheet-occurrence-order.json b/php-transformer/tests/fixtures/parity/artifact-stylesheet-occurrence-order.json new file mode 100644 index 00000000..565f87ab --- /dev/null +++ b/php-transformer/tests/fixtures/parity/artifact-stylesheet-occurrence-order.json @@ -0,0 +1,32 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "artifact-stylesheet-occurrence-order", + "description": "Preserves entry HTML stylesheet occurrence order across interleaved inline and linked CSS regardless of artifact file order.", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/artifact-stylesheet-occurrence-order.json", + "notes": "Single inline styles keep index.inline.css; multiple inline occurrences receive deterministic indexed paths." + }, + "legacy_comparison": { "skip": true, "reason": "Stylesheet occurrence ordering is an artifact materialization behavior." }, + "operation": "artifact_compiler.compile", + "input": { + "artifact": { + "files": [ + { "path": "assets/theme.css", "kind": "css", "content": ".blue{color:blue}" }, + { "path": "index.html", "kind": "html", "content": "

Copy

" } + ] + } + }, + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "assets.0.path", "assert": "equals", "value": "index.inline-1.css" }, + { "path": "assets.0.content", "assert": "contains", "value": ".red{color:red}" }, + { "path": "assets.1.path", "assert": "equals", "value": "assets/theme.css" }, + { "path": "assets.1.content", "assert": "contains", "value": ".blue{color:blue}" }, + { "path": "assets.2.path", "assert": "equals", "value": "index.inline-2.css" }, + { "path": "assets.2.content", "assert": "contains", "value": ".green{color:green}" }, + { "path": "source_reports.compiled_site.theme.stylesheets.0", "assert": "equals", "value": "index.inline-1.css" }, + { "path": "source_reports.compiled_site.theme.stylesheets.1", "assert": "equals", "value": "assets/theme.css" }, + { "path": "source_reports.compiled_site.theme.stylesheets.2", "assert": "equals", "value": "index.inline-2.css" } + ] +} diff --git a/php-transformer/tests/fixtures/parity/html-author-selector-provenance.json b/php-transformer/tests/fixtures/parity/html-author-selector-provenance.json new file mode 100644 index 00000000..0e801b6f --- /dev/null +++ b/php-transformer/tests/fixtures/parity/html-author-selector-provenance.json @@ -0,0 +1,29 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "html-author-selector-provenance", + "description": "Projects supported author selectors through canonical block serialization without falling back to opaque HTML.", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/html-author-selector-provenance.json", + "notes": "Source p type selectors use a zero-specificity provenance marker; standalone spans remain ordinary canonical blocks." + }, + "legacy_comparison": { + "skip": true, + "reason": "Selector provenance is a native-block materialization behavior." + }, + "operation": "html_transformer.transform", + "input": { + "content": "
Standalone

Paragraph

" + }, + "expected_blocks": [ + { "path": "blocks.0", "name": "core/group" }, + { "path": "blocks.0.innerBlocks.0", "name": "core/paragraph" }, + { "path": "blocks.0.innerBlocks.1", "name": "core/paragraph" } + ], + "expected_fallbacks": [], + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "assets.0.content", "assert": "contains", "value": ":where(.blocks-engine-source-p-" }, + { "path": "serialized_blocks", "assert": "not_contains", "value": "wp:html" } + ] +} diff --git a/php-transformer/tests/fixtures/parity/html-button-outline-preserves-source-classes-and-metrics.json b/php-transformer/tests/fixtures/parity/html-button-outline-preserves-source-classes-and-metrics.json index 9d8895a2..983a936d 100644 --- a/php-transformer/tests/fixtures/parity/html-button-outline-preserves-source-classes-and-metrics.json +++ b/php-transformer/tests/fixtures/parity/html-button-outline-preserves-source-classes-and-metrics.json @@ -18,12 +18,12 @@ }, "expected_blocks": [ { "path": "blocks.0", "name": "core/buttons" }, - { "path": "blocks.0.innerBlocks.0", "name": "core/button", "attrs": { "className": "is-style-outline", "text": "Tour Dates", "url": "/tour", "style": { "color": { "text": "#e6d8c5", "background": "transparent" }, "border": { "width": "1px", "style": "solid", "color": "#e6d8c5", "radius": "0" }, "spacing": { "padding": { "top": "14px", "right": "28px", "bottom": "14px", "left": "28px" } } } } } + { "path": "blocks.0.innerBlocks.0", "name": "core/button", "attrs": { "text": "Tour Dates", "url": "/tour", "style": { "color": { "text": "#e6d8c5", "background": "transparent" }, "border": { "width": "1px", "style": "solid", "color": "#e6d8c5", "radius": "0" }, "spacing": { "padding": { "top": "14px", "right": "28px", "bottom": "14px", "left": "28px" } } } } } ], "expected_fallbacks": [], "expect": [ { "path": "status", "assert": "equals", "value": "success" }, - { "path": "serialized_blocks", "assert": "contains", "value": "" }, + { "path": "serialized_blocks", "assert": "contains", "value": "
" }, + { "path": "serialized_blocks", "assert": "contains", "value": "
" }, + { "path": "serialized_blocks", "assert": "contains", "value": "" }, + { "path": "serialized_blocks", "assert": "contains", "value": "
" }, + { "path": "serialized_blocks", "assert": "contains", "value": "
" }, + { "path": "serialized_blocks", "assert": "contains", "value": "
" + }, + "expected_blocks": [ + { "path": "blocks.0", "name": "core/buttons" }, + { "path": "blocks.0.innerBlocks.0", "name": "core/button", "attrs": { "text": "Go", "url": "/go" } } + ], + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "assets.0.content", "assert": "contains", "value": "> :where(.wp-block-button__link):hover" }, + { "path": "assets.0.content", "assert": "contains", "value": "> :where(.wp-block-button__link):focus" }, + { "path": "source_reports.wp_block_validity.status", "assert": "equals", "value": "pass" } + ] +} diff --git a/php-transformer/tests/unit/artifact-author-stylesheet-projection.php b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php new file mode 100644 index 00000000..12c7e11e --- /dev/null +++ b/php-transformer/tests/unit/artifact-author-stylesheet-projection.php @@ -0,0 +1,56 @@ +compile(array( + 'files' => array( + array( 'path' => 'index.html', 'kind' => 'html', 'content' => 'Go

Copy

' ), + array( 'path' => 'a.css', 'kind' => 'css', 'content' => 'a.cta:hover{padding:1rem}' ), + array( 'path' => 'b.css', 'kind' => 'css', 'content' => '[href="/go"]{color:blue}' ), + array( 'path' => 'a.occurrence-2.css', 'kind' => 'css', 'content' => '.authored-collision{color:purple}' ), + ), +) )->toArray(); + +$failures = 0; +$assert = static function (bool $condition, string $message) use (&$failures): void { + if ( ! $condition ) { + ++$failures; + fwrite(STDERR, "FAIL: {$message}\n"); + } +}; +$assets = $result['assets'] ?? array(); +$assert(array( 'index.inline-1.css', 'a.css', 'index.inline-2.css', 'b.css', 'a.occurrence-2-generated-1.css', 'a.occurrence-2.css' ) === array_column($assets, 'path'), 'allocated repeated-link alias avoids authored path collisions while preserving source occurrence order'); +foreach ( $assets as $asset ) { + $content = (string) ($asset['content'] ?? ''); + $hash = hash('sha256', $content); + $assert(strlen($content) === ($asset['bytes'] ?? null) && $hash === ($asset['hash'] ?? null), 'rewritten asset bytes and hashes describe emitted content'); +} +$planAssets = $result['source_reports']['materialization_plan']['assets'] ?? array(); +foreach ( $planAssets as $asset ) { + $content = (string) ($asset['content'] ?? ''); + $hash = hash('sha256', $content); + $assert(strlen($content) === ($asset['bytes'] ?? null) && $hash === ($asset['hash'] ?? null), 'materialization plan payload hashes describe rewritten content'); +} +$assert(hash('sha256', 'a.cta:hover{padding:1rem}') === ($assets[1]['source_hash'] ?? null) && ($assets[1]['hash'] ?? '') !== ($assets[1]['source_hash'] ?? ''), 'source hash retains linked pre-projection provenance'); +$assert(! str_contains((string) ($assets[1]['content'] ?? ''), 'a.cta:hover') && str_contains((string) ($assets[1]['content'] ?? ''), '> :where(.wp-block-button__link):hover'), 'linked button CSS is rewritten in place'); +$assert(hash('sha256', '.hero p{color:green}') === ($assets[2]['source_hash'] ?? null) && ! str_contains((string) ($assets[2]['content'] ?? ''), '.hero p') && str_contains((string) ($assets[2]['content'] ?? ''), ':where(.blocks-engine-source-p-'), 'inline CSS is rewritten in place with original source provenance'); +$assert(str_contains((string) ($assets[4]['content'] ?? ''), '> :where(.wp-block-button__link):hover') && '.authored-collision{color:purple}' === ($assets[5]['content'] ?? ''), 'allocated occurrence alias is referenced while authored collision CSS remains a deterministic orphan asset'); + +$types = ( new ArtifactCompiler() )->compile(array( + 'files' => array( + array( 'path' => 'index.html', 'kind' => 'html', 'content' => '' ), + array( 'path' => 'ok.css', 'kind' => 'css', 'content' => '.link-ok{color:green}' ), + array( 'path' => 'bad.css', 'kind' => 'css', 'content' => '.link-bad{color:blue}' ), + ), +) )->toArray(); +$typeAssets = $types['assets'] ?? array(); +$typeContents = implode("\n", array_map(static fn (array $asset): string => (string) ($asset['content'] ?? ''), $typeAssets)); +$assert(str_contains($typeContents, '.style-ok{color:red}') && str_contains($typeContents, '.link-ok{color:green}') && ! str_contains($typeContents, '.style-bad{color:red}') && ! str_contains($typeContents, '.link-bad{color:blue}'), 'CSS MIME parsing accepts case-insensitive text/css parameters and rejects non-MIME prefixes for style and link occurrences'); + +if ( $failures > 0 ) { + exit(1); +} +fwrite(STDOUT, "Artifact author stylesheet projection unit tests passed\n"); diff --git a/php-transformer/tests/unit/author-selector-semantics.php b/php-transformer/tests/unit/author-selector-semantics.php new file mode 100644 index 00000000..fa29504c --- /dev/null +++ b/php-transformer/tests/unit/author-selector-semantics.php @@ -0,0 +1,77 @@ + ( new HtmlTransformer() )->transform($html)->toArray(); +$css = static fn (array $result): string => (string) ($result['assets'][0]['content'] ?? ''); + +$paragraph = $transform('Loose text

Paragraph

'); +$paragraphClass = (string) ($paragraph['blocks'][1]['attrs']['className'] ?? ''); +$assert('' !== $paragraphClass && str_contains($css($paragraph), ':where(.' . $paragraphClass . '):not(blocks-engine-specificity-') && ! str_contains($paragraph['serialized_blocks'], 'core/html'), 'p type selectors retain provenance and type specificity only on canonical p serialization'); + +$controls = $transform('Go'); +$controlCss = $css($controls); +$assert(2 === substr_count($controlCss, '> :where(.wp-block-button__link)') && str_contains($controlCss, ':hover') && str_contains($controlCss, ':focus'), 'promoted anchors and native buttons project dynamic selectors onto their links once'); + +$order = $transform('Go'); +$orderCss = $css($order); +$assert(strpos($orderCss, 'color:red') < strpos($orderCss, 'color:blue'), 'projected selectors preserve authored rule order for cascade precedence'); + +$specificity = $transform('Go

Paragraph

'); +$specificityCss = $css($specificity); +$assert(str_contains($specificityCss, ':not(blocks-engine-specificity-') && strpos($specificityCss, 'color:red') < strpos($specificityCss, 'color:blue') && strpos($specificityCss, 'color:blue') < strpos($specificityCss, 'color:green'), 'type-specificity shims preserve the authored a.cta and p cascade ordering against later class and universal rules'); + +$important = $transform('Go'); +$assert(str_contains($css($important), '> :where(.wp-block-button__link):hover{padding:1rem!important}') && strpos($css($important), 'padding:1rem!important') < strpos($css($important), 'padding:2rem'), 'projected selectors preserve !important declarations and authored cascade order'); + +$shared = $transform('Go

Other

'); +$sharedCss = $css($shared); +$assert(! str_contains($sharedCss, ':not(.wp-block-button)') && str_contains($sharedCss, '.shared:not(:where(.blocks-engine-control-') && 1 === substr_count($sharedCss, '{margin:1px}') && str_contains($sharedCss, ':not(blocks-engine-specificity-') && str_contains($sharedCss, '*:not(:where('), 'shared selectors exclude only matched control markers without changing specificity or excluding authored wp-block-button classes'); + +$relations = $transform('

Before

Go
'); +$relationCss = $css($relations); +$assert(str_contains($relationCss, ':where(.blocks-engine-source-p-') && 3 === substr_count($relationCss, '> :where(.wp-block-button__link)') && ! str_contains($relationCss, 'p + a.cta'), 'child and sibling source matches project through exact controls while independent p selectors retain provenance'); + +$base = '

Collision

'; +$baseCss = $css($transform($base)); +preg_match_all('/blocks-engine-source-p-([a-f0-9]+)-(\d+)/', $baseCss, $matches); +$match = array( end($matches[0]) ?: '', end($matches[1]) ?: '', (int) (end($matches[2]) ?: -1) ); +$collision = '

Collision

'; +$collisionResult = $transform($collision); +$assert('' !== $match[0] && str_contains($css($collisionResult), 'blocks-engine-source-p-' . $match[1] . '-' . ($match[2] + 1)), 'marker collisions select the deterministic next candidate'); + +$nested = $transform('

Before

Go'); +$assert(str_contains($css($nested), '@media') && str_contains($css($nested), '@supports') && str_contains($css($nested), '> :where(.wp-block-button__link):hover'), 'nested media and supports rules preserve declarations while projecting selectors'); + +$attributes = $transform('Go'); +$attributeCss = $css($attributes); +$assert(4 === substr_count($attributeCss, '> :where(.wp-block-button__link)') && str_contains($attributeCss, ':focus') && ! str_contains($attributeCss, '[data-cta]') && ! str_contains($attributeCss, '[aria-label]') && ! str_contains($attributeCss, '[data-kind') && ! str_contains($attributeCss, '#cta-id') && ! str_contains($attributes['serialized_blocks'], 'core/html') && 'pass' === ($attributes['source_reports']['wp_block_validity']['status'] ?? ''), 'data, aria, attribute-operator, ID, and class selectors project through exact control markers with canonical validity'); + +$wrapper = $transform(''); +$wrapperCss = $css($wrapper); +$wrapperButton = $wrapper['blocks'][0]['innerBlocks'][0] ?? array(); +$assert('core/button' === ($wrapperButton['blockName'] ?? '') && str_contains((string) ($wrapperButton['attrs']['className'] ?? ''), 'blocks-engine-control-') && 2 === substr_count($wrapperCss, '> :where(.wp-block-button__link)') && str_contains($wrapperCss, ':hover') && str_contains($wrapperCss, ':focus') && 'pass' === ($wrapper['source_reports']['wp_block_validity']['status'] ?? ''), 'wrapper-driven role=button promotion retains logical anchor selectors, presentation wrapper attributes, and valid blocks'); + +$instance = new HtmlTransformer(); +$first = $instance->transform('

First

')->toArray(); +$second = $instance->transform('Go')->toArray(); +$assert(! str_contains($css($second), 'blocks-engine-source-p-') && 1 === substr_count($css($second), '> :where(.wp-block-button__link)') && ! str_contains($second['serialized_blocks'], 'core/html'), 'repeated transformer instances reset selector marker state and remain canonical'); + +if ( $failures > 0 ) { + fwrite(STDERR, "Author selector semantics unit tests: {$failures} failed, {$passes} passed\n"); + exit(1); +} +fwrite(STDOUT, "Author selector semantics unit tests: {$passes} passed\n"); diff --git a/php-transformer/tests/unit/css-selector-matcher.php b/php-transformer/tests/unit/css-selector-matcher.php new file mode 100644 index 00000000..e000fa01 --- /dev/null +++ b/php-transformer/tests/unit/css-selector-matcher.php @@ -0,0 +1,86 @@ +loadHTML('

one

two

target
'); +libxml_clear_errors(); +$byId = static fn (string $id): DOMElement => $dom->getElementById($id); +$match = static fn (string $selector, DOMElement $element, bool $suffix = false): array => CssSelectorMatcher::matches($element, CssSelectorMatcher::parse($selector), $suffix); + +foreach ( array( 'p.item#one', '*#one' ) as $selector ) { + $result = $match($selector, $byId('one')); + $assert($result['supported'] && $result['matches'], "matches compound {$selector}"); +} +$result = $match('P.outer', $byId('one')); +$assert($result['supported'] && ! $result['matches'], 'matches HTML tag names case-insensitively'); + +foreach ( array( '[data-value]', '[DATA-VALUE="alpha beta"]', '[DaTa-VaLuE~=beta]', '[DATA-LANG|=en]', '[data-start^=prefix]', '[DATA-END$=suffix]', '[data-contains*=mid]', '[DATA-QUOTED="a, b c"]', '[data-lang="EN-us" i]' ) as $selector ) { + $result = $match($selector, $byId('one')); + $assert($result['supported'] && $result['matches'], "matches case-insensitive HTML attribute name {$selector}"); +} +$result = $match('[\\44 ATA-VALUE="alpha beta"]', $byId('one')); +$assert($result['supported'] && $result['matches'], 'decodes and normalizes escaped HTML attribute names'); + +$result = $match('div section > p + p', $byId('two')); +$assert($result['supported'] && $result['matches'], 'matches adjacent sibling through a comment'); +foreach ( array( 'div>section>p~span', 'div section .final' ) as $selector ) { + $result = $match($selector, $byId('target')); + $assert($result['supported'] && $result['matches'], "matches combinators {$selector}"); +} + +foreach ( array( '.\\31 0', '#\\74 wo', '\\64 iv', '--name', '-name', '.\\31 0', '[data-quoted="a\\2c b c"]' ) as $selector ) { + $element = '#\\74 wo' === $selector || '.\\31 0' === $selector ? $byId('two') : ('\\64 iv' === $selector ? $byId('root') : $byId('one')); + $result = $match($selector, $element); + $assert($result['supported'], "accepts valid identifier grammar {$selector}"); +} +$result = $match('\\64 iv', $byId('root')); +$assert($result['supported'] && $result['matches'], 'decodes an escaped type-selector start'); +$result = $match('.\\31 0', $byId('two')); +$assert($result['matches'], 'decodes escaped class identifier'); +$result = $match('[data-quoted="a\\2c b c"]', $byId('one')); +$assert($result['matches'], 'decodes escaped attribute value'); + +$parsed = CssSelectorMatcher::parse('.final:hover:focus:active'); +$assert($parsed['supported'] && array( 'start' => 6, 'end' => 25 ) === $parsed['pseudo_state_suffix_span'] && 6 === $parsed['rightmost_rewrite_end'], 'exposes dynamic pseudo suffix span and rewrite boundary'); +$assert(! CssSelectorMatcher::matches($byId('target'), $parsed)['supported'], 'dynamic suffix requires caller acknowledgement'); +$assert(CssSelectorMatcher::matches($byId('target'), $parsed, true)['matches'], 'caller acknowledgement permits structural dynamic-pseudo match'); + +foreach ( array( ':disabled', ':not(.x)', ':is(.x)', ':where(.x)', ':has(.x)', ':nth-child(2)', 'p::before', 'svg|a', '.a||.b', '.a, .b', '.a[', '.a >', '-', '.-', '#-', '.10', '\\', ".a\\\n", ".a\\\r", ".a\\\r\n", "[data-value=\"a\nb\"]", "[data-value=\"a\\\nb\"]", "[data-value=\"a\\\r\nb\"]", "[data-value=\"a\\\"]" ) as $selector ) { + $assert(! CssSelectorMatcher::parse($selector)['supported'], "rejects unsupported or malformed {$selector}"); +} + +foreach ( array( '[type=text]', '[TYPE="TEXT"]' ) as $selector ) { + $result = $match($selector, $byId('control')); + $assert(! $result['supported'], "declines unmodeled HTML enumerated value semantics {$selector}"); +} +$result = $match('[TYPE="TEXT" I]', $byId('control')); +$assert($result['supported'] && $result['matches'], 'explicit ASCII-insensitive attribute flag is matched'); +$result = $match('[TYPE="TEXT" s]', $byId('control')); +$assert($result['supported'] && ! $result['matches'], 'explicit case-sensitive attribute flag is matched'); + +$invalidUtf8 = ".\xff"; +$assert(! CssSelectorMatcher::parse($invalidUtf8)['supported'], 'rejects malformed UTF-8'); +$parsed = CssSelectorMatcher::parse('/*x*/ div > .final:hover'); +$assert($parsed['supported'] && array( 'start' => 12, 'end' => 24 ) === $parsed['rightmost_compound_span'] && array( 'start' => 18, 'end' => 24 ) === $parsed['pseudo_state_suffix_span'], 'preserves original source spans around comments and whitespace'); + +if ( $failures > 0 ) { + fwrite(STDERR, "CssSelectorMatcher unit tests: {$failures} failed, {$passes} passed\n"); + exit(1); +} +fwrite(STDOUT, "CssSelectorMatcher unit tests: {$passes} passed\n"); diff --git a/php-transformer/tests/unit/css-stylesheet-transformer.php b/php-transformer/tests/unit/css-stylesheet-transformer.php new file mode 100644 index 00000000..a5823d0b --- /dev/null +++ b/php-transformer/tests/unit/css-stylesheet-transformer.php @@ -0,0 +1,98 @@ + str_replace('.before', '.after', $prelude); + +// 1, 2, and 8: comments and declaration syntax remain source-identical. +$css = "/* leading */ .before, /* list */ [data-label=\"a,b\"] { content: '\\'{};'; --x: \";}\"; }"; +$assert("/* leading */ .after, /* list */ [data-label=\"a,b\"] { content: '\\'{};'; --x: \";}\"; }" === $transformer->transform($css, $rename), 'comments, strings, escapes, and declarations are preserved'); +$assert($css === $transformer->transform($css, static fn (string $prelude): string => $prelude), 'no-op callback is byte-identical'); + +// 3 and 4: only safe block at-rules recurse. +$css = '@media screen { @supports (display: grid) { .before { color:red } } } @font-face { font-family:"x"; src:url("x;{}.woff2"); } @keyframes spin { from { opacity:0 } }'; +$expected = '@media screen { @supports (display: grid) { .after { color:red } } } @font-face { font-family:"x"; src:url("x;{}.woff2"); } @keyframes spin { from { opacity:0 } }'; +$assert($expected === $transformer->transform($css, $rename), 'nested media/supports rules transform while font-face and keyframes remain opaque'); + +// 5: commas inside nested syntax are not selector-list separators. +$parts = CssStylesheetTransformer::splitSelectorList(':is(.a,.b), :not([data-x="a,b"]), [title="x,y"]'); +$assert(array( ':is(.a,.b)', ' :not([data-x="a,b"])', ' [title="x,y"]' ) === $parts, 'selector lists split only on top-level commas'); + +// 6: tokenization identifies each combinator and the rightmost compound. +foreach ( array( '.a .b' => ' ', '.a>.b' => '>', '.a + .b' => '+', '.a~.b' => '~' ) as $selector => $combinator ) { + $tokens = CssSelectorTokenizer::tokenize($selector); + $assert($tokens['supported'] && array( '.a', '.b' ) === $tokens['compounds'] && array( $combinator ) === $tokens['combinators'] && '.b' === $tokens['rightmost_compound'], "tokenizes {$selector}"); +} +$assert(! CssSelectorTokenizer::tokenize('.a, .b')['supported'], 'selector lists are surfaced as unsupported by the single-selector tokenizer'); + +// Escapes consume CSS hex digits and an optional CSS whitespace continuation. +foreach ( array( '.\\31 0', ".\\31\r\n0" ) as $selector ) { + $tokens = CssSelectorTokenizer::tokenize($selector); + $assert($tokens['supported'] && array( $selector ) === $tokens['compounds'], 'escaped identifier stays one compound'); +} +$escapedCss = '.before\\{x\\;y\\,z { color:red }'; +$assert('.after\\{x\\;y\\,z { color:red }' === $transformer->transform($escapedCss, $rename), 'escaped structural bytes do not split stylesheet rules'); + +// CSS whitespace is exactly space, tab, LF, CR, and FF; comments only separate +// descendants when surrounding whitespace supplies the combinator. +$tokens = CssSelectorTokenizer::tokenize(".a\f>\f.b"); +$assert($tokens['supported'] && array( '.a', '.b' ) === $tokens['compounds'] && array( '>' ) === $tokens['combinators'], 'form-feed around child combinator has no phantom compound'); +$tokens = CssSelectorTokenizer::tokenize('.a/* note */ .b'); +$assert($tokens['supported'] && array( ' ' ) === $tokens['combinators'], 'comments with CSS whitespace separate descendants'); +$tokens = CssSelectorTokenizer::tokenize('.a/* note */.b'); +$assert($tokens['supported'] && array( '.a/* note */.b' ) === $tokens['compounds'], 'comments alone stay inside a compound'); + +// Selectors 4 column combinator and namespace separator remain distinct. +foreach ( array( '.a||.b', '.a || .b' ) as $selector ) { + $tokens = CssSelectorTokenizer::tokenize($selector); + $assert($tokens['supported'] && array( '||' ) === $tokens['combinators'], "tokenizes column combinator {$selector}"); +} +$tokens = CssSelectorTokenizer::tokenize('svg|a > *|button'); +$assert($tokens['supported'] && array( 'svg|a', '*|button' ) === $tokens['compounds'] && array( '>' ) === $tokens['combinators'], 'single namespace separator remains in compounds'); +$assert(array( 'start' => 0, 'end' => 2 ) === CssSelectorTokenizer::tokenize('.a > .b')['compound_spans'][0], 'compound spans reference original selector bytes'); +$assert(array( 'start' => 3, 'end' => 4 ) === CssSelectorTokenizer::tokenize('.a > .b')['combinator_spans'][0], 'combinator spans reference original selector bytes'); + +// 7: malformed/truncated constructs are retained instead of partially transformed. +$malformed = '.before { content: "unterminated'; +$assert($malformed === $transformer->transform($malformed, $rename), 'truncated stylesheet is preserved exactly'); +$malformedSelector = '.before:is(.x { color:red }'; +$assert($malformedSelector === $transformer->transform($malformedSelector, $rename), 'unbalanced selector prelude is preserved exactly'); +$unmatchedClose = '} .before { color:red }'; +$assert($unmatchedClose === $transformer->transform($unmatchedClose, $rename), 'top-level unmatched close preserves the entire stylesheet'); +$unmatchedCloseAfterRule = '.before { color:red } } .before { color:blue }'; +$assert($unmatchedCloseAfterRule === $transformer->transform($unmatchedCloseAfterRule, $rename), 'later unmatched close prevents earlier callback transformations'); + +// Block at-rules recurse only when rules are safe to walk. Declaration at-rules +// and declaration bodies, including custom-property braces and URL data, stay raw. +$css = '@scope (.root) { .before { color:red } } @property --x { syntax:""; initial-value:red; } @font-face { src:url(data:font/woff2;base64,abc==); } .before { --json: { braces: ";}"; }; background:url("data:image/svg+xml,{}"); }'; +$expected = '@scope (.root) { .after { color:red } } @property --x { syntax:""; initial-value:red; } @font-face { src:url(data:font/woff2;base64,abc==); } .after { --json: { braces: ";}"; }; background:url("data:image/svg+xml,{}"); }'; +$assert($expected === $transformer->transform($css, $rename), 'scope recurses while property, custom properties, and data URLs remain byte-preserved'); + +$identityCases = array( + "/* comment */ .a\r\n{ content:';}'; }", + ".a\f>\f.b { background:url(data:image/svg+xml,{}); }", + '@scope (.x) { .a/*x*/ .b { --x:{ y:z; }; } }', + '@property --x { syntax:"*"; inherits:false; initial-value:0; }', +); +foreach ( $identityCases as $identityCase ) { + $assert($identityCase === $transformer->transform($identityCase, static fn (string $prelude): string => $prelude), 'no-op is byte-identical across scanner edge cases'); +} + +if ( $failures > 0 ) { + fwrite(STDERR, "CssStylesheetTransformer unit tests: {$failures} failed, {$passes} passed\n"); + exit(1); +} +fwrite(STDOUT, "CssStylesheetTransformer unit tests: {$passes} passed\n");