From ddf41a9c546b449b7755b5d3432f0267a18d7cd2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 22 Jun 2026 23:01:42 -0400 Subject: [PATCH] Add CSS asset reference reporting --- .../src/ArtifactCompiler/ArtifactCompiler.php | 85 +++++++++++++++++-- .../StaticSite/MaterializationPlanBuilder.php | 1 + php-transformer/tests/contract/run.php | 43 ++++++++++ .../artifact-css-import-font-references.json | 65 ++++++++++++++ 4 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 php-transformer/tests/fixtures/parity/artifact-css-import-font-references.json diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 2b2fa2b..c17e02e 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -30,8 +30,8 @@ public function compile(array $artifact): TransformerResult $entryPath = is_array($entry) ? (string) $entry['path'] : ''; $html = is_array($entry) ? (string) $entry['content'] : ''; - $assets = $this->assetManifest($normalized['files'], $entryPath); $referenceReports = $this->referenceReports($normalized['files']); + $assets = $this->assetManifest($normalized['files'], $entryPath, $referenceReports['asset_references']); $components = $this->detectComponents($normalized['files'], $entryPath, $documents['components']); $blockTypes = $this->detectBlockTypes($normalized['files'], $diagnostics); $entryBlocks = $this->compileEntryBlocks($html, $entryPath, $normalized['files']); @@ -235,7 +235,7 @@ private function referenceReports(array $files): array } if ( 'css' === ($file['kind'] ?? '') ) { - foreach ( $this->cssUrlReferenceCandidates((string) ($file['content'] ?? ''), (string) ($file['path'] ?? '')) as $candidate ) { + foreach ( $this->cssReferenceCandidates((string) ($file['content'] ?? ''), (string) ($file['path'] ?? '')) as $candidate ) { if ( '' === $candidate['url'] || ! $this->isArtifactLocalReference($candidate['url']) ) { continue; } @@ -352,30 +352,64 @@ private function urlsFromAttributeValue(string $attribute, string $value): array } /** - * @return array + * @return array */ - private function cssUrlReferenceCandidates(string $css, string $sourcePath): array + private function cssReferenceCandidates(string $css, string $sourcePath): array { - if ( '' === trim($css) || ! preg_match_all('/url\(\s*(["\']?)([^"\')]+)\1\s*\)/i', $css, $matches, PREG_SET_ORDER) ) { + if ( '' === trim($css) ) { return array(); } $candidates = array(); + + if ( preg_match_all('/@import\s+(?:url\(\s*)?(["\']?)([^"\'\)\s;]+)\1\s*\)?[^;]*;/i', $css, $matches, PREG_SET_ORDER) ) { + foreach ( $matches as $index => $match ) { + $url = html_entity_decode(trim((string) $match[2]), ENT_QUOTES | ENT_HTML5); + $candidates[] = array( + 'source_path' => $sourcePath, + 'selector' => 'css:@import(' . ($index + 1) . ')', + 'element' => 'style', + 'attribute' => '@import', + 'value' => $url, + 'url' => $url, + 'context' => 'css-import', + ); + } + } + + if ( ! preg_match_all('/url\(\s*(["\']?)([^"\')]+)\1\s*\)/i', $css, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE) ) { + return $candidates; + } + foreach ( $matches as $index => $match ) { - $url = html_entity_decode(trim((string) $match[2]), ENT_QUOTES | ENT_HTML5); + $url = html_entity_decode(trim((string) $match[2][0]), ENT_QUOTES | ENT_HTML5); + $ruleContext = $this->cssRuleContext($css, (int) $match[0][1]); $candidates[] = array( 'source_path' => $sourcePath, - 'selector' => 'css:url(' . ($index + 1) . ')', + 'selector' => ('font-face' === $ruleContext ? 'css:@font-face:url(' : 'css:url(') . ($index + 1) . ')', 'element' => 'style', 'attribute' => 'url', 'value' => $url, 'url' => $url, + 'context' => 'font-face' === $ruleContext ? 'css-font-face' : 'css-url', ); } return $candidates; } + private function cssRuleContext(string $css, int $offset): string + { + $before = substr($css, 0, $offset); + $ruleStart = strrpos($before, '{'); + if ( false === $ruleStart ) { + return ''; + } + + $prefix = substr($css, max(0, $ruleStart - 256), $ruleStart - max(0, $ruleStart - 256)); + return preg_match('/@font-face\s*$/i', $prefix) ? 'font-face' : ''; + } + /** * @param array{source_path: string, selector: string, element: string, attribute: string, value: string, url: string} $candidate * @param array> $files @@ -393,6 +427,7 @@ private function normalizeReferenceCandidate(array $candidate, array $files): ar 'attribute' => $candidate['attribute'], 'value' => $candidate['value'], 'url' => $candidate['url'], + 'context' => $candidate['context'] ?? '', 'resolved_path' => $resolvedPath, ), static fn (mixed $value): bool => '' !== $value @@ -736,6 +771,7 @@ private function compiledSiteAssets(array $assets): array 'content' => $asset['content'] ?? null, 'content_base64' => $asset['content_base64'] ?? null, 'hash' => $asset['hash'] ?? $asset['provenance']['hash'] ?? '', + 'references' => $asset['references'] ?? array(), ), static fn (mixed $value): bool => null !== $value && '' !== $value ), @@ -952,7 +988,7 @@ private function sanitizeKey(string $key): string * @param array> $files * @return array> */ - private function assetManifest(array $files, string $entryPath): array + private function assetManifest(array $files, string $entryPath, array $assetReferences = array()): array { $assets = array(); foreach ( $files as $file ) { @@ -983,12 +1019,45 @@ private function assetManifest(array $files, string $entryPath): array if ( ! empty($file['intent']) ) { $asset['intent'] = $file['intent']; } + $references = $this->referencesForAsset((string) $file['path'], $assetReferences); + if ( array() !== $references ) { + $asset['references'] = $references; + } $assets[] = $asset; } return $assets; } + /** + * @param array> $assetReferences + * @return array> + */ + private function referencesForAsset(string $path, array $assetReferences): array + { + $references = array(); + foreach ( $assetReferences as $reference ) { + if ( $path !== ($reference['asset_path'] ?? '') ) { + continue; + } + + $references[] = array_filter( + array( + 'source_path' => $reference['source_path'] ?? '', + 'selector' => $reference['selector'] ?? '', + 'element' => $reference['element'] ?? '', + 'attribute' => $reference['attribute'] ?? '', + 'value' => $reference['value'] ?? '', + 'url' => $reference['url'] ?? '', + 'context' => $reference['context'] ?? '', + ), + static fn (mixed $value): bool => '' !== $value + ); + } + + return $references; + } + /** * @param array> $files * @return array> diff --git a/php-transformer/src/StaticSite/MaterializationPlanBuilder.php b/php-transformer/src/StaticSite/MaterializationPlanBuilder.php index 702bc35..b883766 100644 --- a/php-transformer/src/StaticSite/MaterializationPlanBuilder.php +++ b/php-transformer/src/StaticSite/MaterializationPlanBuilder.php @@ -288,6 +288,7 @@ private function assets(array $assets): array 'content' => $asset['content'] ?? null, 'content_base64' => $asset['content_base64'] ?? null, 'hash' => (string) ($asset['hash'] ?? $asset['provenance']['hash'] ?? ''), + 'references' => is_array($asset['references'] ?? null) ? $asset['references'] : array(), ), static fn (mixed $value): bool => null !== $value && '' !== $value && 0 !== $value && false !== $value); } return $planned; diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index ba9e98c..0a2be6f 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -238,6 +238,49 @@ function serialize_blocks(array $blocks): string $assert('text' === ($cssAssetPlanRow['content_encoding'] ?? ''), 'materialization plan asset rows expose text content encoding'); $assert('.wp-site-blocks{min-height:100vh}' === ($cssAssetPlanRow['content'] ?? ''), 'materialization plan asset rows expose text payloads for writable assets'); +$cssReferences = $compiler->compile( + array( + 'entrypoint' => 'index.html', + 'files' => array( + 'index.html' => '

Fonts

', + 'theme/site.css' => '@import "fonts/fonts.css"; body{background:url("../assets/paper.png")}', + 'theme/fonts/fonts.css' => '@font-face{font-family:"Fixture Sans";src:url("FixtureSans.woff2") format("woff2");font-weight:400}', + 'theme/fonts/FixtureSans.woff2' => array( + 'content_base64' => base64_encode('fixture-font'), + 'mime_type' => 'font/woff2', + ), + 'assets/paper.png' => array( + 'content_base64' => base64_encode("\x89PNG\r\n\x1a\n"), + 'mime_type' => 'image/png', + ), + ), + ) +)->toArray(); +$cssAssetReferences = $cssReferences['source_reports']['artifact']['asset_references'] ?? array(); +$assert(4 === count($cssAssetReferences), 'CSS asset analysis reports linked stylesheet, @import, url(), and @font-face url references'); +$assert('css-import' === ($cssAssetReferences[1]['context'] ?? ''), 'CSS @import references expose a neutral context'); +$assert('theme/fonts/fonts.css' === ($cssAssetReferences[1]['asset_path'] ?? ''), 'CSS @import references resolve relative to the source stylesheet'); +$assert('css:@import(1)' === ($cssAssetReferences[1]['selector'] ?? ''), 'CSS @import references expose a stable selector'); +$assert('css-url' === ($cssAssetReferences[2]['context'] ?? ''), 'CSS url() references expose a neutral context'); +$assert('assets/paper.png' === ($cssAssetReferences[2]['asset_path'] ?? ''), 'CSS url() references continue resolving asset paths'); +$assert('css-font-face' === ($cssAssetReferences[3]['context'] ?? ''), 'CSS @font-face url references expose a neutral context'); +$assert('theme/fonts/FixtureSans.woff2' === ($cssAssetReferences[3]['asset_path'] ?? ''), 'CSS @font-face url references resolve local font assets'); +$fontCompiledAsset = null; +$fontPlanAsset = null; +foreach ( $cssReferences['source_reports']['compiled_site']['assets'] ?? array() as $asset ) { + if ( 'theme/fonts/FixtureSans.woff2' === ($asset['path'] ?? '') ) { + $fontCompiledAsset = $asset; + } +} +foreach ( $cssReferences['source_reports']['materialization_plan']['assets'] ?? array() as $asset ) { + if ( 'theme/fonts/FixtureSans.woff2' === ($asset['path'] ?? '') ) { + $fontPlanAsset = $asset; + } +} +$assert('font/woff2' === ($fontCompiledAsset['media_type'] ?? ''), 'compiled site assets preserve local font media type'); +$assert('css-font-face' === ($fontCompiledAsset['references'][0]['context'] ?? ''), 'compiled site assets expose structured reference metadata'); +$assert('css-font-face' === ($fontPlanAsset['references'][0]['context'] ?? ''), 'materialization plan assets preserve structured reference metadata'); + $neutralPlan = ( new MaterializationPlanBuilder() )->fromCompiledSite( array( 'products' => array( diff --git a/php-transformer/tests/fixtures/parity/artifact-css-import-font-references.json b/php-transformer/tests/fixtures/parity/artifact-css-import-font-references.json new file mode 100644 index 0000000..9aad590 --- /dev/null +++ b/php-transformer/tests/fixtures/parity/artifact-css-import-font-references.json @@ -0,0 +1,65 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "artifact-css-import-font-references", + "description": "Reports CSS @import and @font-face URL references to local artifact assets.", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/artifact-css-import-font-references.json", + "notes": "Covers generic CSS reference reporting without browser/runtime behavior." + }, + "legacy_comparison": { + "skip": true, + "reason": "This generic artifact reference report has no legacy comparison." + }, + "operation": "artifact_compiler.compile", + "input": { + "artifact": { + "entrypoint": "index.html", + "files": [ + { + "path": "index.html", + "content": "

CSS refs

", + "mime_type": "text/html", + "role": "entry" + }, + { + "path": "styles/site.css", + "content": "@import \"fonts.css\"; .hero{background:url('../images/paper.png')}", + "mime_type": "text/css" + }, + { + "path": "styles/fonts.css", + "content": "@font-face{font-family:'Fixture';src:url('./fonts/Fixture.woff2') format('woff2')}", + "mime_type": "text/css" + }, + { + "path": "styles/fonts/Fixture.woff2", + "content_base64": "Zml4dHVyZS1mb250", + "mime_type": "font/woff2" + }, + { + "path": "images/paper.png", + "content_base64": "iVBORw0KGgo=", + "mime_type": "image/png" + } + ] + } + }, + "expect": [ + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "source_reports.artifact.asset_references", "assert": "count", "count": 4 }, + { "path": "source_reports.artifact.asset_references.1.selector", "assert": "equals", "value": "css:@import(1)" }, + { "path": "source_reports.artifact.asset_references.1.context", "assert": "equals", "value": "css-import" }, + { "path": "source_reports.artifact.asset_references.1.asset_path", "assert": "equals", "value": "styles/fonts.css" }, + { "path": "source_reports.artifact.asset_references.2.context", "assert": "equals", "value": "css-url" }, + { "path": "source_reports.artifact.asset_references.2.asset_path", "assert": "equals", "value": "images/paper.png" }, + { "path": "source_reports.artifact.asset_references.3.selector", "assert": "equals", "value": "css:@font-face:url(1)" }, + { "path": "source_reports.artifact.asset_references.3.context", "assert": "equals", "value": "css-font-face" }, + { "path": "source_reports.artifact.asset_references.3.asset_path", "assert": "equals", "value": "styles/fonts/Fixture.woff2" }, + { "path": "source_reports.compiled_site.assets.1.references.0.context", "assert": "equals", "value": "css-import" }, + { "path": "source_reports.compiled_site.assets.2.media_type", "assert": "equals", "value": "font/woff2" }, + { "path": "source_reports.compiled_site.assets.2.references.0.context", "assert": "equals", "value": "css-font-face" }, + { "path": "source_reports.materialization_plan.assets.2.references.0.context", "assert": "equals", "value": "css-font-face" }, + { "path": "source_reports.conversion_report.asset_refs", "assert": "count", "count": 4 } + ] +}