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
21 changes: 21 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
* text=auto eol=lf

/.github export-ignore
/tests export-ignore
/docs export-ignore
/AGENTS.md export-ignore
/CONTRIBUTING.md export-ignore
/qa-adoption.json export-ignore
/phpcs.xml export-ignore
/phpcs.xml.dist export-ignore
/phpstan.neon export-ignore
/phpstan.neon.dist export-ignore
/phpunit*.xml* export-ignore
/.php-cs-fixer* export-ignore
/.gitignore export-ignore
/.gitattributes export-ignore
/.env* export-ignore
/auth.json export-ignore
/.npmrc export-ignore
/composer.review*.json export-ignore
/composer.review*.lock export-ignore
18 changes: 18 additions & 0 deletions .github/workflows/dependency-canary.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Dependency compatibility

on:
schedule:
- cron: '14 4 * * 3'
workflow_dispatch:

permissions:
contents: read

jobs:
compatibility:
permissions:
contents: read
issues: write
uses: sympress/workflows/.github/workflows/dependency-canary.yml@177fa0d727b278d2103052ec77c102b4a4c492a0
with:
php_version: '8.5'
4 changes: 2 additions & 2 deletions .github/workflows/qa.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ permissions:

jobs:
workflows:
uses: sympress/workflows/.github/workflows/lint-workflows.yml@v1
uses: sympress/workflows/.github/workflows/lint-workflows.yml@177fa0d727b278d2103052ec77c102b4a4c492a0

qa:
uses: sympress/workflows/.github/workflows/sympress-qa.yml@v1
uses: sympress/workflows/.github/workflows/sympress-qa.yml@177fa0d727b278d2103052ec77c102b4a4c492a0
with:
php_version: '8.5'
secrets:
Expand Down
109 changes: 65 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,61 +1,82 @@
# SymPress Assets
---
nav_order: 5
---

[![Checks](https://img.shields.io/github/actions/workflow/status/SymPress/assets/qa.yml?branch=main&label=checks)](https://github.com/SymPress/assets/actions/workflows/qa.yml) [![Release](https://img.shields.io/github/v/release/SymPress/assets?label=release)](https://github.com/SymPress/assets/releases) [![PHP](https://img.shields.io/packagist/dependency-v/sympress/assets/php.svg?label=php)](https://packagist.org/packages/sympress/assets) [![Downloads](https://img.shields.io/packagist/dt/sympress/assets.svg?label=downloads)](https://packagist.org/packages/sympress/assets/stats) [![License: GPL-2.0-or-later](https://img.shields.io/badge/license-GPL--2.0--or--later-blue.svg)](LICENSE)
# Output Filters

Asset management for SymPress WordPress packages, plugins, and themes.
Output filters modify the final `script` or `link` tag.

The package registers classic scripts, script modules, and styles through a
Composer library. It can run standalone in WordPress or through the optional
Symfony kernel integration.
```php
<?php

## Installation
use SymPress\Assets\Script;

```bash
composer require sympress/assets
$script = (new Script('site', plugin_dir_url(__FILE__) . 'assets/site.js'))
->withAttributes(['crossorigin' => 'anonymous']);
```

## Features
## Included Filters

- Scripts, script modules, and styles with one shared asset API
- Webpack manifest, Encore, array, and PHP file loaders
- Automatic versions from file paths or dependency extraction files
- Native script loading strategies: `defer`, `async`, `blocking`
- Style preload for non-critical CSS
- Resource hints for `preload`, `preconnect`, and `dns-prefetch`
- Protection from cache, minify, combine, defer, and delay optimizations
- Optional Symfony kernel integration with asset providers
- `AttributesOutputFilter` sets additional attributes.
- `InlineAssetOutputFilter` emits small, allowed CSS or JS files inline.
- `AsyncStyleOutputFilter` loads non-critical CSS through `preload`.

## Example
`Style::preload()` requests `AsyncStyleOutputFilter`. Supply the page's intentional
per-request CSP nonce through `sympress_assets_csp_nonce` (value, asset). When a
nonempty nonce and WP_HTML_Tag_Processor are available, output contains a preload
link plus a nonce-bearing script using addEventListener, with no onload attribute.
The nonce must match the page's script-src policy; putting a nonce on a link does
not authorize inline event handlers. The script handles already completed/cache-hit
loads, and a noscript copy keeps the original stylesheet. Existing media, integrity,
crossorigin and fetchpriority are preserved. Without a nonce the original blocking
stylesheet is returned, so strict script-src-attr 'none' remains supported.

```php
<?php
Inline files must satisfy the allowed canonical path/extensions and 32KB limit,
including the actual read bytes. Raw JavaScript containing closing/opening script
prefixes, HTML comment openings, or U+2028/U+2029 falls back to its external tag.
There is no semantics-preserving universal rewrite for strings, regexps and tagged
templates; external fallback keeps the exact program bytes. CSS closing style
prefixes are escaped case-insensitively. Inline output retains nonce and removes
external-only integrity/src/loading attributes.

use SymPress\Assets\AssetManager;
use SymPress\Assets\Script;
use SymPress\Assets\Style;

add_action(
AssetManager::ACTION_SETUP,
static function (AssetManager $assets): void {
$assets->register(
new Script('site', plugin_dir_url(__FILE__) . 'assets/site.js'),
new Style('site', plugin_dir_url(__FILE__) . 'assets/site.css'),
);
}
);
```
For scripts, `AsyncScriptOutputFilter` and `DeferScriptOutputFilter` are
deprecated. Use `Script::async()` or `Script::defer()` instead.

Classic scripts use WordPress' native `defer` strategy by default. Styles remain
blocking unless `Style::preload()` is enabled for non-critical CSS.
## Custom Filter

## Standalone WordPress
```php
<?php

When the Symfony kernel is not used, load `inc/bootstrap.php` once after the
Composer autoloader.
use SymPress\Assets\Asset;
use SymPress\Assets\Script;

## License
$filter = static function (string $html, Asset $asset): string {
return str_replace('></script>', ' data-handle="' . esc_attr($asset->handle()) . '"></script>', $html);
};

This package is licensed under `GPL-2.0-or-later`.
$script = (new Script('site', plugin_dir_url(__FILE__) . 'assets/site.js'))
->withFilters($filter);
```

It contains code derived from `inpsyde/assets`. See [NOTICE.md](./NOTICE.md) for
details.
## Supplied SRI and request snapshots

Loader configuration and dependency JSON may provide integrity (space-separated
sha256/384/512 base64 digests) and crossorigin (anonymous/use-credentials). Digest
format/decoded length is validated and anonymous is the default. Hashes must come
from a trusted build for the exact bytes served. The library does not synthesize
integrity from URLs/versions or fetch remote files; no remote integrity guarantee
is possible without supplied build metadata. Inline tags omit external integrity.

RequestFiles shares canonical path/stat/content/JSON snapshots across assets and
loaders. Repeated reads and missing paths reuse a request snapshot, while allowed
root policy decisions remain local to their policy context. Call
SymPress\Assets\IO\RequestFiles::reset() at **every** request boundary in a
long-running worker, and recreate per-request asset objects. Normal PHP request
teardown already clears this memory. New/changed manifests and symlink targets
are observed after reset; do not modify build files partway through a request.
PHP dependency execution remains separately opt-in and size-bounded.

CSP preload requires an intentional page nonce via sympress_assets_csp_nonce;
without it CSS stays blocking. Supplied build SRI and request-scoped file snapshots
are documented in [output filters](docs/output-filter.md). Long-running workers
must reset RequestFiles and recreate request asset objects at each boundary.
15 changes: 3 additions & 12 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
"php-stubs/wordpress-stubs": ">=7.0@stable",
"phpstan/phpstan-mockery": "^2.0",
"symfony/var-dumper": "^8.1",
"sympress/kernel": "dev-main",
"sympress/qa": "dev-main"
"sympress/kernel": "^1.1.1",
"sympress/qa": "^0.1.2"
},
"autoload": {
"psr-4": {
Expand Down Expand Up @@ -92,18 +92,9 @@
"phpstan/extension-installer": true
}
},
"repositories": [
{
"type": "path",
"url": "./../*",
"options": {
"symlink": true
}
}
],
"suggest": {
"sympress/kernel": "Enables the optional Symfony-kernel bundle integration."
},
"minimum-stability": "dev",
"minimum-stability": "stable",
"prefer-stable": true
}
38 changes: 35 additions & 3 deletions docs/output-filter.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,23 @@ $script = (new Script('site', plugin_dir_url(__FILE__) . 'assets/site.js'))
- `InlineAssetOutputFilter` emits small, allowed CSS or JS files inline.
- `AsyncStyleOutputFilter` loads non-critical CSS through `preload`.

`Style::preload()` sets `AsyncStyleOutputFilter` automatically and creates a
`noscript` fallback. Existing attributes such as `media`, `integrity`,
`crossorigin`, and `fetchpriority` are preserved.
`Style::preload()` requests `AsyncStyleOutputFilter`. Supply the page's intentional
per-request CSP nonce through `sympress_assets_csp_nonce` (value, asset). When a
nonempty nonce and WP_HTML_Tag_Processor are available, output contains a preload
link plus a nonce-bearing script using addEventListener, with no onload attribute.
The nonce must match the page's script-src policy; putting a nonce on a link does
not authorize inline event handlers. The script handles already completed/cache-hit
loads, and a noscript copy keeps the original stylesheet. Existing media, integrity,
crossorigin and fetchpriority are preserved. Without a nonce the original blocking
stylesheet is returned, so strict script-src-attr 'none' remains supported.

Inline files must satisfy the allowed canonical path/extensions and 32KB limit,
including the actual read bytes. Raw JavaScript containing closing/opening script
prefixes, HTML comment openings, or U+2028/U+2029 falls back to its external tag.
There is no semantics-preserving universal rewrite for strings, regexps and tagged
templates; external fallback keeps the exact program bytes. CSS closing style
prefixes are escaped case-insensitively. Inline output retains nonce and removes
external-only integrity/src/loading attributes.

For scripts, `AsyncScriptOutputFilter` and `DeferScriptOutputFilter` are
deprecated. Use `Script::async()` or `Script::defer()` instead.
Expand All @@ -43,3 +57,21 @@ $filter = static function (string $html, Asset $asset): string {
$script = (new Script('site', plugin_dir_url(__FILE__) . 'assets/site.js'))
->withFilters($filter);
```

## Supplied SRI and request snapshots

Loader configuration and dependency JSON may provide integrity (space-separated
sha256/384/512 base64 digests) and crossorigin (anonymous/use-credentials). Digest
format/decoded length is validated and anonymous is the default. Hashes must come
from a trusted build for the exact bytes served. The library does not synthesize
integrity from URLs/versions or fetch remote files; no remote integrity guarantee
is possible without supplied build metadata. Inline tags omit external integrity.

RequestFiles shares canonical path/stat/content/JSON snapshots across assets and
loaders. Repeated reads and missing paths reuse a request snapshot, while allowed
root policy decisions remain local to their policy context. Call
SymPress\Assets\IO\RequestFiles::reset() at **every** request boundary in a
long-running worker, and recreate per-request asset objects. Normal PHP request
teardown already clears this memory. New/changed manifests and symlink targets
are observed after reset; do not modify build files partway through a request.
PHP dependency execution remains separately opt-in and size-bounded.
9 changes: 9 additions & 0 deletions docs/releases/1.2.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# 1.2.1 release candidate

This candidate contains the 2026-10-01 review repairs and requires the stable
SymPress dependency train. It has not been published yet. CI consumers use the
reviewed workflows commit 177fa0d727b278d2103052ec77c102b4a4c492a0.

Local exact-commit Composer archives verify dependency resolution and distribution
contents before publication. They do not imply that prospective tags exist on
GitHub or Packagist. Historical tags remain unchanged.
4 changes: 4 additions & 0 deletions src/AssetConfigurationApplier.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use SymPress\Assets\CacheOptimization\CacheOptimizationExclusion;
use SymPress\Assets\Performance\ResourceHint;
use SymPress\Assets\Performance\ResourceHintAwareAsset;
use SymPress\Assets\Security\IntegrityMetadata;

final readonly class AssetConfigurationApplier
{
Expand Down Expand Up @@ -47,6 +48,9 @@ public function apply(Asset $asset, array $config): Asset
}

$this->applyMappedConfig($asset, $config, $propertyMap);
if ($asset instanceof FilterAwareAsset) {
IntegrityMetadata::apply($asset, $config['integrity'] ?? null, $config['crossorigin'] ?? null);
}
$this->applyDependencies($asset, $config['dependencies'] ?? null);
$this->applyCacheOptimizationConfig($asset, $config['cacheOptimization'] ?? null);
$this->applyResourceHintsConfig($asset, $config['resourceHints'] ?? null);
Expand Down
2 changes: 2 additions & 0 deletions src/AssetFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
* location?: AssetLocation,
* condition?: string,
* attributes?: array<string, string|bool>,
* integrity?: string,
* crossorigin?: string,
* cacheOptimization?: bool|CacheOptimizationExclusion|null,
* resourceHints?: array<int, ResourceHint|array<string, mixed>>,
* dependencyExtractionEnabled?: bool,
Expand Down
6 changes: 4 additions & 2 deletions src/BaseAsset.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use SymPress\Assets\CacheOptimization\CacheOptimizationAwareAsset;
use SymPress\Assets\CacheOptimization\CacheOptimizationAwareTrait;
use SymPress\Assets\Handler\AssetHandler;
use SymPress\Assets\IO\RequestFiles;
use SymPress\Assets\Performance\ResourceHintAwareAsset;
use SymPress\Assets\Performance\ResourceHintAwareTrait;
use SymPress\Assets\Util\AssetPathResolver;
Expand Down Expand Up @@ -118,11 +119,12 @@ public function version(): ?string
if ($version === null && $this->autodiscoverVersion) {
$filePath = $this->filePath();

if ($filePath === '' || !is_file($filePath)) {
$info = RequestFiles::shared()->info($filePath);
if ($filePath === '' || !$info['file']) {
return null;
}

$version = (string) filemtime($filePath);
$version = (string) $info['modified'];
$this->withVersion($version);

return $version;
Expand Down
22 changes: 18 additions & 4 deletions src/DependencyExtractionTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use SymPress\Assets\Exception\InvalidResourceException;
use SymPress\Assets\Loader\JsonFileReader;
use SymPress\Assets\IO\RequestFiles;
use SymPress\Assets\Security\DependencyFilePolicy;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\Finder\Finder;
Expand Down Expand Up @@ -56,9 +57,7 @@ protected function resolveDependencyExtractionPlugin(): bool
if ($this->resolvedDependencyExtractionPlugin) {
return false;
}
$depsFile = $this->findDependencyFile(
new DependencyFilePolicy($this->phpDependencyFilesAllowed, $this->dependencyFileMaxBytes),
);
$depsFile = $this->findDependencyFile();
$this->resolvedDependencyExtractionPlugin = true;

if (!$depsFile) {
Expand All @@ -70,6 +69,9 @@ protected function resolveDependencyExtractionPlugin(): bool
? (new JsonFileReader())->read($depsFilePath)
: $this->loadPhpDependencyFile($depsFilePath);

if ($this instanceof FilterAwareAsset) {
\SymPress\Assets\Security\IntegrityMetadata::apply($this, $data['integrity'] ?? null, $data['crossorigin'] ?? null);
}
$dependencies = $this->normalizeDependencyHandles($data['dependencies'] ?? []);
$version = $this->normalizeDependencyVersion($data['version'] ?? null);

Expand Down Expand Up @@ -97,6 +99,18 @@ protected function findDepdendencyFile(): ?\SplFileInfo
}

protected function findDependencyFile(?DependencyFilePolicy $policy = null): ?\SplFileInfo
{
// Explicit policy objects may have a different trust/size boundary; do not reuse their result.
if ($policy !== null) {
return $this->scanDependencyFile($policy);
}
return RequestFiles::shared()->remember(
'dependency:' . $this->filePath() . ':' . (int) $this->phpDependencyFilesAllowed . ':' . $this->dependencyFileMaxBytes,
fn (): ?\SplFileInfo => $this->scanDependencyFile(),
);
}

private function scanDependencyFile(?DependencyFilePolicy $policy = null): ?\SplFileInfo
{
$policy ??= new DependencyFilePolicy($this->phpDependencyFilesAllowed, $this->dependencyFileMaxBytes);

Expand Down Expand Up @@ -169,7 +183,7 @@ private function dependencyFileFromPath(
?DependencyFilePolicy $policy = null,
): ?\SplFileInfo {

if (!is_file($filePath)) {
if (!RequestFiles::shared()->info($filePath)['file']) {
return null;
}

Expand Down
Loading
Loading