Skip to content
Merged
41 changes: 30 additions & 11 deletions php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Automattic\BlocksEngine\PhpTransformer\StaticSite\MaterializationPlanBuilder;
use Automattic\BlocksEngine\PhpTransformer\Support\DeterministicRowDeduplicator;
use Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\WordPressSitePlan;
use Automattic\BlocksEngine\PhpTransformer\WordPressSitePlan\ValidationException;
use DOMDocument;
use DOMElement;

Expand All @@ -30,6 +31,12 @@ final class ArtifactCompiler
*/
private const RUNTIME_TAG_SELECTORS = array( 'button', 'input', 'select', 'textarea', 'ul', 'ol', 'li' );

/** @var array<string, string> */
private array $themeStaticCssCache = array();

/** @var array<string, string> */
private array $wordpressCompatCssCache = array();

/**
* Resolve the runtime selector context used when a caller converts one
* source document or landmark separately from full artifact compilation.
Expand Down Expand Up @@ -57,6 +64,8 @@ public function runtimeContextForSource(string $html, string $sourcePath, array
public function compile(array $artifact): TransformerResult
{
$startedAt = hrtime(true);
$this->themeStaticCssCache = array();
$this->wordpressCompatCssCache = array();
$normalized = ( new ArtifactNormalizer() )->normalize($artifact);
$entry = $this->entryFile($normalized['files'], $normalized['entrypoints']);
$documents = $this->compileSourceDocuments($normalized);
Expand Down Expand Up @@ -122,9 +131,9 @@ public function compile(array $artifact): TransformerResult
'files_by_source' => $this->countBy($normalized['files'], 'source'),
'files_by_intent' => $this->countBy($normalized['files'], 'intent'),
'limits' => array(
'max_files' => ArtifactNormalizer::DEFAULT_MAX_FILES,
'max_file_bytes' => ArtifactNormalizer::DEFAULT_MAX_FILE_BYTES,
'max_total_bytes' => ArtifactNormalizer::DEFAULT_MAX_TOTAL_BYTES,
'max_files' => $normalized['limits']['max_files'],
'max_file_bytes' => $normalized['limits']['max_file_bytes'],
'max_total_bytes' => $normalized['limits']['max_total_bytes'],
),
'source_hash' => hash('sha256', $normalized['hash_payload']),
'html' => array(
Expand Down Expand Up @@ -195,14 +204,16 @@ public function compile(array $artifact): TransformerResult
'metrics' => $metrics,
));
} catch (\InvalidArgumentException $exception) {
$diagnostics[] = $this->diagnostic('wordpress_site_plan_not_self_contained', 'error', $exception->getMessage());
$diagnostics[] = $exception instanceof ValidationException
? array_merge($exception->diagnostic(), array('severity' => 'error', 'source' => self::class))
: $this->diagnostic('wordpress_site_plan_not_self_contained', 'error', $exception->getMessage());
}
}

$metrics['diagnostic_count'] = count($diagnostics);
$metrics['transform_duration_ms'] = (hrtime(true) - $startedAt) / 1000000;
$sourceReports['conversion_report'] = ConversionReportProjection::fromResultParts('artifact', $entryBlocks['blocks'], $allFallbacks, $sourceReports, $assets, $provenance, $metrics);
$sourceReports['wordpress_site_plan_diagnostics'] = array_values(array_filter($diagnostics, static fn (array $diagnostic): bool => 'wordpress_site_plan_not_self_contained' === ($diagnostic['code'] ?? '')));
$sourceReports['wordpress_site_plan_diagnostics'] = array_values(array_filter($diagnostics, static fn (array $diagnostic): bool => str_starts_with((string) ($diagnostic['code'] ?? ''), 'wordpress_site_plan_')));
if ( array() === $sourceReports['wordpress_site_plan_diagnostics'] ) {
unset($sourceReports['wordpress_site_plan_diagnostics']);
}
Expand Down Expand Up @@ -1119,6 +1130,10 @@ private function themeFontLinkHtml(array $files): string
*/
private function themeStaticCss(array $files, bool $includeNavigationCompat = true): string
{
$cacheKey = $includeNavigationCompat ? 'with-compat' : 'without-compat';
if ( array_key_exists($cacheKey, $this->themeStaticCssCache) ) {
return $this->themeStaticCssCache[$cacheKey];
}
$blocks = array();
foreach ( $files as $file ) {
$content = is_string($file['content'] ?? null) ? (string) $file['content'] : '';
Expand All @@ -1144,11 +1159,10 @@ private function themeStaticCss(array $files, bool $includeNavigationCompat = tr
$css = implode("\n", array_keys($blocks));

if ( ! $includeNavigationCompat ) {
return $css;
return $this->themeStaticCssCache[$cacheKey] = $css;
}

return $css
. $this->wordpressCompatCss($css, $files);
return $this->themeStaticCssCache[$cacheKey] = $css . $this->wordpressCompatCss($css, $files);
}

/** @return array<int,array{path:string,content:string,source_hash:string}> */
Expand Down Expand Up @@ -1368,7 +1382,11 @@ private function rootStartupClassNames(array $files): array
/** @param array<int, array<string, mixed>> $files */
private function wordpressCompatCss(string $css, array $files): string
{
return $this->navigationContainerCompatCss($css)
$cacheKey = hash('sha256', $css);
if ( array_key_exists($cacheKey, $this->wordpressCompatCssCache) ) {
return $this->wordpressCompatCssCache[$cacheKey];
}
return $this->wordpressCompatCssCache[$cacheKey] = $this->navigationContainerCompatCss($css)
. $this->navigationStructureCompatCss($css)
. $this->navigationAnchorCompatCss($css)
. $this->rootStartupClassCompatCss($css, $files)
Expand Down Expand Up @@ -2450,7 +2468,7 @@ private function compiledSiteReport(array $artifact, string $entryPath, array $d
'entrypoint' => $path === $entryPath || ! empty($file['entrypoint']),
'slug' => $slug,
'title' => $title,
'metadata' => $this->documentMetadata($path, 'html', (string) ($file['role'] ?? 'document'), $slug, $title, $bodyFormat),
'metadata' => array_merge($this->documentMetadata($path, 'html', (string) ($file['role'] ?? 'document'), $slug, $title, $bodyFormat), is_string($file['metadata']['route_path'] ?? null) ? array('route_path' => $file['metadata']['route_path']) : array()),
'document_metadata' => $this->fullDocumentMetadata($content, $path, $artifact['files'], $path === $entryPath ? $assets : ($compiledBlocks['assets'] ?? array())),
'html' => $file['content'] ?? '',
'body_format' => $bodyFormat,
Expand Down Expand Up @@ -2904,7 +2922,8 @@ private function compiledSiteAssets(array $assets): array
'selector' => $asset['selector'] ?? '',
'references' => $asset['references'] ?? array(),
),
static fn (mixed $value): bool => null !== $value && '' !== $value
static fn (mixed $value, string $key): bool => ('content' === $key && is_string($value)) || (null !== $value && '' !== $value),
ARRAY_FILTER_USE_BOTH
),
$assets
));
Expand Down
39 changes: 31 additions & 8 deletions php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ final class ArtifactNormalizer
public const DEFAULT_MAX_FILES = 500;
public const DEFAULT_MAX_FILE_BYTES = 5242880;
public const DEFAULT_MAX_TOTAL_BYTES = 52428800;
public const MAX_FILES = 5000;
public const MAX_FILE_BYTES = 10485760;
public const MAX_TOTAL_BYTES = 335544320;

/**
* @param array<string, mixed> $artifact
* @return array{files: array<int, array<string, mixed>>, diagnostics: array<int, array<string, mixed>>, rejected_count: int, bytes: int, entrypoints: array<int, string>, hash_payload: string, runtime_declarations: array<int,array<string,mixed>>}
* @return array{files: array<int, array<string, mixed>>, diagnostics: array<int, array<string, mixed>>, rejected_count: int, bytes: int, limits: array{max_files:int,max_file_bytes:int,max_total_bytes:int}, entrypoints: array<int, string>, hash_payload: string, runtime_declarations: array<int,array<string,mixed>>}
*/
public function normalize(array $artifact): array
{
Expand All @@ -29,6 +32,7 @@ public function normalize(array $artifact): array
$rejected = 0;
$bytes = 0;
$seenPaths = array();
$limits = $this->limits($artifact);

foreach ( array('entrypoint', 'entry', 'main') as $key ) {
if ( is_string($artifact[$key] ?? null) ) {
Expand Down Expand Up @@ -63,9 +67,9 @@ public function normalize(array $artifact): array
}

foreach ( $rawFiles as $index => $file ) {
if ( count($files) >= self::DEFAULT_MAX_FILES ) {
if ( count($files) >= $limits['max_files'] ) {
++$rejected;
$diagnostics[] = $this->diagnostic('file_limit_exceeded', 'warning', 'Additional artifact files were ignored because the file limit was reached.', array('max_files' => self::DEFAULT_MAX_FILES));
$diagnostics[] = $this->diagnostic('file_limit_exceeded', 'warning', 'Additional artifact files were ignored because the file limit was reached.', array('max_files' => $limits['max_files']));
break;
}

Expand All @@ -83,15 +87,15 @@ public function normalize(array $artifact): array
continue;
}

if ( $payload['bytes'] > self::DEFAULT_MAX_FILE_BYTES ) {
if ( $payload['bytes'] > $limits['max_file_bytes'] ) {
++$rejected;
$diagnostics[] = $this->diagnostic('artifact_file_too_large', 'warning', 'An artifact file was ignored because it exceeds the per-file byte limit.', array('path' => $path, 'bytes' => $payload['bytes'], 'max_file_bytes' => self::DEFAULT_MAX_FILE_BYTES));
$diagnostics[] = $this->diagnostic('artifact_file_too_large', 'warning', 'An artifact file was ignored because it exceeds the per-file byte limit.', array('path' => $path, 'bytes' => $payload['bytes'], 'max_file_bytes' => $limits['max_file_bytes']));
continue;
}

if ( $bytes + $payload['bytes'] > self::DEFAULT_MAX_TOTAL_BYTES ) {
if ( $bytes + $payload['bytes'] > $limits['max_total_bytes'] ) {
++$rejected;
$diagnostics[] = $this->diagnostic('artifact_total_too_large', 'warning', 'An artifact file was ignored because the bundle byte limit was reached.', array('path' => $path, 'bytes' => $payload['bytes'], 'max_total_bytes' => self::DEFAULT_MAX_TOTAL_BYTES));
$diagnostics[] = $this->diagnostic('artifact_total_too_large', 'warning', 'An artifact file was ignored because the bundle byte limit was reached.', array('path' => $path, 'bytes' => $payload['bytes'], 'max_total_bytes' => $limits['max_total_bytes']));
continue;
}

Expand Down Expand Up @@ -135,6 +139,9 @@ public function normalize(array $artifact): array
if ( '' !== $intent ) {
$normalized['intent'] = $intent;
}
if ( is_array($file['metadata'] ?? null) && is_string($file['metadata']['route_path'] ?? null) && '' !== trim($file['metadata']['route_path']) ) {
$normalized['metadata'] = array('route_path' => trim($file['metadata']['route_path']));
}
foreach ( array('placement', 'type', 'media', 'source_path', 'selector', 'stylesheet_index', 'superseded_by') as $field ) {
if ( isset($file[$field]) && is_scalar($file[$field]) && '' !== trim((string) $file[$field]) ) {
$normalized[$field] = (string) $file[$field];
Expand All @@ -160,12 +167,24 @@ public function normalize(array $artifact): array
'diagnostics' => $this->dedupeDiagnostics($diagnostics),
'rejected_count' => $rejected,
'bytes' => $bytes,
'limits' => $limits,
'entrypoints' => array_values(array_unique($safeEntrypoints)),
'hash_payload' => $this->fileHashPayload($files) . "\n" . RuntimeDeclarations::canonicalJson($runtimeDeclarations),
'runtime_declarations' => $runtimeDeclarations,
);
}

/** @param array<string,mixed> $artifact @return array{max_files:int,max_file_bytes:int,max_total_bytes:int} */
private function limits(array $artifact): array
{
$requested = is_array($artifact['compiler_limits'] ?? null) ? $artifact['compiler_limits'] : array();
return array(
'max_files' => min(self::MAX_FILES, max(1, (int) ($requested['max_files'] ?? self::DEFAULT_MAX_FILES))),
'max_file_bytes' => min(self::MAX_FILE_BYTES, max(1, (int) ($requested['max_file_bytes'] ?? self::DEFAULT_MAX_FILE_BYTES))),
'max_total_bytes' => min(self::MAX_TOTAL_BYTES, max(1, (int) ($requested['max_total_bytes'] ?? self::DEFAULT_MAX_TOTAL_BYTES))),
);
}

/**
* @param array<string, mixed> $artifact
* @return array<int, array<string, mixed>>
Expand Down Expand Up @@ -428,7 +447,11 @@ private function payload(array $file, string $path): array
return array('accepted' => true, 'content' => $binary ? '' : $decoded, 'content_base64' => $base64, 'encoding' => 'base64', 'binary' => $binary, 'bytes' => strlen($decoded), 'diagnostics' => $diagnostics);
}

$content = $this->normalizeContent($file['content'] ?? $file['body'] ?? $file['text'] ?? '');
$contentKey = array_key_exists('content', $file) ? 'content' : (array_key_exists('body', $file) ? 'body' : (array_key_exists('text', $file) ? 'text' : null));
if (null === $contentKey || !is_string($file[$contentKey])) {
return array('accepted' => false, 'content' => '', 'content_base64' => '', 'encoding' => 'text', 'binary' => false, 'bytes' => 0, 'diagnostics' => array($this->diagnostic('missing_file_payload', 'warning', 'An artifact file was ignored because it has no explicit text or base64 payload.', array('path' => $path))));
}
$content = $this->normalizeContent($file[$contentKey]);
return array('accepted' => true, 'content' => $content, 'content_base64' => '', 'encoding' => 'text', 'binary' => false, 'bytes' => strlen($content), 'diagnostics' => array());
}

Expand Down
14 changes: 10 additions & 4 deletions php-transformer/src/AssetAnalysis/ReferenceAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ public function referenceReports(array $files, ?callable $isLinkableDocument = n
$internalLinks = array();
$assetReferences = array();
$imageReferences = array();
$filesByPath = array();
foreach ( $files as $file ) {
if ( is_string($file['path'] ?? null) ) {
$filesByPath[$file['path']] = $file;
}
}

foreach ( $files as $file ) {
if ( ! empty($file['binary']) ) {
Expand All @@ -30,7 +36,7 @@ public function referenceReports(array $files, ?callable $isLinkableDocument = n
continue;
}

$reference = $this->normalizeReferenceCandidate($candidate, $files, $isLinkableDocument, $isSafeImageAsset);
$reference = $this->normalizeReferenceCandidate($candidate, $files, $isLinkableDocument, $isSafeImageAsset, $filesByPath);
$target = $reference['target'] ?? null;
if ( is_array($target) && $this->isLinkableDocument($target, $isLinkableDocument) && 'a' === $candidate['element'] ) {
unset($reference['target']);
Expand All @@ -54,7 +60,7 @@ public function referenceReports(array $files, ?callable $isLinkableDocument = n
continue;
}

$reference = $this->normalizeReferenceCandidate($candidate, $files, $isLinkableDocument, $isSafeImageAsset);
$reference = $this->normalizeReferenceCandidate($candidate, $files, $isLinkableDocument, $isSafeImageAsset, $filesByPath);
$target = $reference['target'] ?? null;
if ( is_array($target) && ! $this->isLinkableDocument($target, $isLinkableDocument) ) {
unset($reference['target']);
Expand Down Expand Up @@ -178,10 +184,10 @@ public function cssReferenceCandidates(string $css, string $sourcePath): array
* @param callable(array<string, mixed>): bool|null $isSafeImageAsset
* @return array<string, mixed>
*/
public function normalizeReferenceCandidate(array $candidate, array $files, ?callable $isLinkableDocument = null, ?callable $isSafeImageAsset = null): array
public function normalizeReferenceCandidate(array $candidate, array $files, ?callable $isLinkableDocument = null, ?callable $isSafeImageAsset = null, ?array $filesByPath = null): array
{
$resolvedPath = ArtifactPath::resolveRelativePath($candidate['url'], $candidate['source_path']);
$target = '' === $resolvedPath ? null : $this->findFileByPath($resolvedPath, $files);
$target = '' === $resolvedPath ? null : (null === $filesByPath ? $this->findFileByPath($resolvedPath, $files) : ($filesByPath[$resolvedPath] ?? null));
$reference = array_filter(
array(
'source_path' => $candidate['source_path'],
Expand Down
Loading
Loading