Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 77 additions & 8 deletions php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -352,30 +352,64 @@ private function urlsFromAttributeValue(string $attribute, string $value): array
}

/**
* @return array<int, array{source_path: string, selector: string, element: string, attribute: string, value: string, url: string}>
* @return array<int, array{source_path: string, selector: string, element: string, attribute: string, value: string, url: string, context?: string}>
*/
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<int, array<string, mixed>> $files
Expand All @@ -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
Expand Down Expand Up @@ -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
),
Expand Down Expand Up @@ -952,7 +988,7 @@ private function sanitizeKey(string $key): string
* @param array<int, array<string, mixed>> $files
* @return array<int, array<string, mixed>>
*/
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 ) {
Expand Down Expand Up @@ -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<int, array<string, mixed>> $assetReferences
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>> $files
* @return array<int, array<string, mixed>>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '<main><link rel="stylesheet" href="theme/site.css"><h1>Fonts</h1></main>',
'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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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": "<main><link rel=\"stylesheet\" href=\"styles/site.css\"><h1>CSS refs</h1></main>",
"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 }
]
}
Loading