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
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
where applicable.

## Unreleased
## 1.2.0 - 2026-10-01

### Added

- `EncoreEntrypointsLoader::fromFile()` validates local manifests, including
extensions, traversal and symlink confinement, while preserving valid entries
when optional entries are broken.
- `EncoreEntrypointsLoader::editorStyles()` and `SmallStyleConfigurator` share
editor stylesheet discovery and opt-in inlining of small hashed CSS files.

### Changed

Expand Down
32 changes: 32 additions & 0 deletions docs/theme-entrypoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Local theme entrypoints

`EncoreEntrypointsLoader::fromFile($manifest)` reads and validates an Encore
manifest before building assets. Each entry is validated independently: invalid
optional editor files do not discard a valid frontend entry. Missing/broken
manifests return an empty asset list. CSS and JS entries must have their matching
extension; files must be readable and remain inside the manifest directory after
canonicalization. Absolute paths, remote URLs, traversal and escaping symlinks
are rejected. The existing general-purpose `load()` API remains unchanged.

```php
$loader = (new EncoreEntrypointsLoader())->withDirectoryUrl($themeUrl . '/build/');
$inline = new SmallStyleConfigurator(new FilesystemPathPolicy([$themePath . '/build']));
foreach ($loader->fromFile($themePath . '/build/entrypoints.json') as $asset) {
$inline->configure($asset);
$manager->register($asset);
}
foreach ($loader->editorStyles($themePath . '/build/entrypoints.json', 'theme-editor') as $css) {
add_editor_style($css);
}
```

`editorStyles()` returns relative CSS paths, prefixed with `build/` by default;
the prefix is configurable. Select only the frontend entry handles before
registering assets if the manifest also contains editor entries.

`SmallStyleConfigurator` is opt-in and implements `AssetConfiguratorInterface`.
It only configures CSS filenames containing a hexadecimal hash of at least eight
characters. Files must be within the supplied path policy, no larger than 16 KiB
(configurable), and contain neither `url()` nor `@import`. Existing inline-content
and filesystem policies still apply when WordPress outputs the asset. The helper
has no kernel dependency and works in standalone WordPress.
24 changes: 23 additions & 1 deletion src/Loader/EncoreEntrypointsLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,26 @@
*/
class EncoreEntrypointsLoader extends AbstractWebpackLoader implements LoaderInterface
{
/**
* Load local files only, confined to the manifest directory.
*
* @return array<Asset>
*/
public function fromFile(string $file): array
{
$entries = EncoreManifest::read($file);
return $entries === null ? [] : $this->loadFromArray(['entrypoints' => $entries], $file);
}

/**
* @return list<string>
*/
public function editorStyles(string $file, string $entry, string $prefix = 'build/'): array
{
$entries = EncoreManifest::read($file);
return array_map(static fn (string $css): string => $prefix . preg_replace('~^\\./~', '', $css), $entries[$entry]['css'] ?? []);
}

/**
* Load an already decoded and validated Encore manifest without reading it again.
* The resource path anchors relative asset paths; the manifest need not exist.
Expand All @@ -33,7 +53,9 @@ public function loadFromArray(array $data, string $resource): array
protected function parseData(array $data, string $resource): array
{
$directory = Path::getDirectory($resource);
/** @var array{entrypoints:array{css?:array<string>, js?:array<string>}} $data */
/**
* @var array{entrypoints:array{css?:array<string>, js?:array<string>}} $data
*/
$data = $data['entrypoints'] ?? [];

$assets = [];
Expand Down
74 changes: 74 additions & 0 deletions src/Loader/EncoreManifest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace SymPress\Assets\Loader;

use SymPress\Assets\Security\FilesystemPathPolicy;

final class EncoreManifest
{
/**
* @return array<string, array<string, list<string>>>|null
*/
public static function read(string $file): ?array
{
if (!is_readable($file)) {
return null;
}
$contents = file_get_contents($file);
if ($contents === false) {
return null;
}
try {
$manifest = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
$entries = is_array($manifest) ? ($manifest['entrypoints'] ?? null) : null;
if (!is_array($entries)) {
return null;
}
$valid = [];
foreach ($entries as $name => $entry) {
if (!is_string($name) || !is_array($entry)) {
continue;
}
$assets = [];
foreach (['css', 'js'] as $type) {
if (!isset($entry[$type])) {
continue;
}
if (!self::validFiles($entry[$type], dirname($file), $type)) {
continue 2;
}
$assets[$type] = $entry[$type];
}
if (array_filter($assets) === []) {
continue;
}

$valid[$name] = $assets;
}
return $valid === [] ? null : $valid;
}

/** @phpstan-assert-if-true list<string> $files */
private static function validFiles(mixed $files, string $directory, string $type): bool
{
if (!is_array($files) || !array_is_list($files)) {
return false;
}
foreach ($files as $asset) {
if (
!is_string($asset) || !preg_match('~^(?:\./)?(?:[a-zA-Z0-9_-][a-zA-Z0-9_.-]*/)*[a-zA-Z0-9_-][a-zA-Z0-9_.-]*\.(?:css|js)$~', $asset)
|| pathinfo($asset, PATHINFO_EXTENSION) !== $type
|| !is_readable($directory . '/' . $asset)
|| !(new FilesystemPathPolicy([$directory]))->allowsPath($directory . '/' . $asset)
) {
return false;
}
}
return true;
}
}
36 changes: 36 additions & 0 deletions src/SmallStyleConfigurator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace SymPress\Assets;

use SymPress\Assets\Security\FilesystemPathPolicy;

/** Opt-in standalone configurator; the existing inline policy remains authoritative at output time. */
final readonly class SmallStyleConfigurator implements AssetConfiguratorInterface
{
public function __construct(private FilesystemPathPolicy $paths, private int $maxBytes = 16_384)
{
}

public function supports(Asset $asset): bool
{
return $asset instanceof Style;
}

public function configure(Asset $asset): Asset
{
if (!$asset instanceof Style) {
return $asset;
}
$path = $asset->filePath();
if (!$this->paths->allowsPath($path) || !preg_match('/\.[a-f0-9]{8,}\.css$/', $path) || !is_readable($path) || filesize($path) > $this->maxBytes) {
return $asset;
}
$css = file_get_contents($path);
if ($css !== false && !preg_match('/\burl\s*\(|@import\b/i', $css)) {
$asset->useInlineFilter();
}
return $asset;
}
}
88 changes: 88 additions & 0 deletions tests/phpunit/Unit/Loader/EncoreManifestTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace SymPress\Assets\Tests\Unit\Loader;

use SymPress\Assets\Loader\EncoreEntrypointsLoader;
use SymPress\Assets\Loader\EncoreManifest;
use SymPress\Assets\Security\FilesystemPathPolicy;
use SymPress\Assets\SmallStyleConfigurator;
use SymPress\Assets\Style;
use SymPress\Assets\Tests\Unit\AbstractTestCase;

final class EncoreManifestTest extends AbstractTestCase
{
private string $directory;

protected function setUp(): void
{
parent::setUp();
$this->directory = sys_get_temp_dir() . '/sympress-manifest-' . bin2hex(random_bytes(6));
mkdir($this->directory);
mkdir($this->directory . '/build');
file_put_contents($this->directory . '/build/app.12345678.css', 'body{color:red}');
file_put_contents($this->directory . '/outside.css', 'body{color:blue}');
}

protected function tearDown(): void
{
foreach (glob($this->directory . '/build/*') ?: [] as $file) {
unlink($file);
}
rmdir($this->directory . '/build');
unlink($this->directory . '/outside.css');
rmdir($this->directory);
parent::tearDown();
}

public function testUnsafeOptionalEntriesDoNotDisableValidFrontendAssets(): void
{
symlink($this->directory . '/outside.css', $this->directory . '/build/link.css');
$file = $this->directory . '/build/entrypoints.json';
file_put_contents($file, json_encode([
'entrypoints' => [
'app' => ['css' => ['app.12345678.css']],
'traversal' => ['css' => ['../outside.css']],
'symlink' => ['css' => ['link.css']],
'wrong-type' => ['js' => ['app.12345678.css']],
'missing-editor' => ['css' => ['missing.css']],
'remote' => ['css' => ['https://example.test/outside.css']],
],
], JSON_THROW_ON_ERROR));
self::assertSame(['app'], array_keys(EncoreManifest::read($file) ?? []));
$loader = (new EncoreEntrypointsLoader())->withDirectoryUrl('https://example.test/build/');
$assets = $loader->fromFile($file);
self::assertCount(1, $assets);
self::assertSame('https://example.test/build/app.12345678.css', $assets[0]->url());
self::assertSame(['build/app.12345678.css'], $loader->editorStyles($file, 'app'));
self::assertSame([], $loader->editorStyles($file, 'missing-editor'));
}

public function testBrokenManifestReturnsNoAssets(): void
{
$file = $this->directory . '/build/entrypoints.json';
$loader = new EncoreEntrypointsLoader();
self::assertSame([], $loader->fromFile($file));
foreach (['null', '{}', '{broken', '{"entrypoints":false}'] as $json) {
file_put_contents($file, $json);
self::assertSame([], $loader->fromFile($file));
}
}

public function testInliningRequiresHashSmallFileAndSafeDirectory(): void
{
$file = $this->directory . '/build/app.12345678.css';
$configurator = new SmallStyleConfigurator(new FilesystemPathPolicy([$this->directory . '/build']));
$style = (new Style('app', '/app.css'))->withFilePath($file);
$configurator->configure($style);
self::assertNotEmpty($style->filters());
file_put_contents($file, 'body{background:url(image.png)}');
$external = (new Style('external', '/app.css'))->withFilePath($file);
$configurator->configure($external);
self::assertSame([], $external->filters());
$outside = (new Style('outside', '/outside.css'))->withFilePath($this->directory . '/outside.css');
$configurator->configure($outside);
self::assertSame([], $outside->filters());
}
}
Loading