diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..9670e95
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,9 @@
+.gitattributes export-ignore
+.gitignore export-ignore
+.github export-ignore
+ncs.* export-ignore
+phpstan.neon export-ignore
+tests/ export-ignore
+
+*.sh eol=lf
+*.php* diff=php linguist-language=PHP
diff --git a/.github/workflows/coding-style.yml b/.github/workflows/coding-style.yml
new file mode 100644
index 0000000..5b796f6
--- /dev/null
+++ b/.github/workflows/coding-style.yml
@@ -0,0 +1,31 @@
+name: Coding Style
+
+on: [push, pull_request]
+
+jobs:
+ nette_cc:
+ name: Nette Code Checker
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.1
+ coverage: none
+
+ - run: composer create-project nette/code-checker temp/code-checker ^3 --no-progress
+ - run: php temp/code-checker/code-checker --strict-types --no-progress --ignore expected
+
+
+ nette_cs:
+ name: Nette Coding Standard
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.1
+ coverage: none
+
+ - run: composer create-project nette/coding-standard temp/coding-standard ^3 --no-progress
+ - run: php temp/coding-standard/ecs check
diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml
new file mode 100644
index 0000000..396244b
--- /dev/null
+++ b/.github/workflows/static-analysis.yml
@@ -0,0 +1,18 @@
+name: Static Analysis (only informative)
+
+on: [push, pull_request]
+
+jobs:
+ phpstan:
+ name: PHPStan
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.1
+ coverage: none
+
+ - run: composer install --no-progress --prefer-dist
+ - run: composer phpstan -- --no-progress
+ continue-on-error: true # is only informative
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..8e47791
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,65 @@
+name: Tests
+
+on: [push, pull_request]
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php: ['8.1', '8.2', '8.3', '8.4']
+
+ fail-fast: false
+
+ name: PHP ${{ matrix.php }} tests
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: none
+
+ - run: composer install --no-progress --prefer-dist
+ - run: vendor/bin/tester tests -s -C
+ - if: failure()
+ uses: actions/upload-artifact@v3
+ with:
+ name: output
+ path: tests/**/output
+
+
+ lowest_dependencies:
+ name: Lowest Dependencies
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.1
+ coverage: none
+
+ - run: composer update --no-progress --prefer-dist --prefer-lowest --prefer-stable
+ - run: vendor/bin/tester tests -s -C
+ - if: failure()
+ uses: actions/upload-artifact@v3
+ with:
+ name: output
+ path: tests/**/output
+
+
+ code_coverage:
+ name: Code Coverage
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.3
+ coverage: none
+
+ - run: composer install --no-progress --prefer-dist
+ - run: vendor/bin/tester -p phpdbg tests -s -C --coverage ./coverage.xml --coverage-src ./src
+ - run: wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.4.3/php-coveralls.phar
+ - env:
+ COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: php php-coveralls.phar --verbose --config tests/.coveralls.yml
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..8207e67
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,42 @@
+{
+ "name": "nette/assets",
+ "description": "🎨 Nette Assets: elegant asset management for PHP with versioning, caching and providers for various storage backends.",
+ "keywords": ["nette", "assets", "asset management", "versioning", "resources", "static files"],
+ "homepage": "https://nette.org",
+ "license": ["BSD-3-Clause", "GPL-2.0-only", "GPL-3.0-only"],
+ "authors": [
+ {
+ "name": "David Grudl",
+ "homepage": "https://davidgrudl.com"
+ },
+ {
+ "name": "Nette Community",
+ "homepage": "https://nette.org/contributors"
+ }
+ ],
+ "require": {
+ "php": "8.1 - 8.4",
+ "nette/utils": "^4.0"
+ },
+ "suggest": {
+ "latte/latte": "Allows using Assets in templates"
+ },
+ "require-dev": {
+ "nette/tester": "^2.5",
+ "nette/di": "^3.2",
+ "latte/latte": "^3.0.18",
+ "tracy/tracy": "^2.9",
+ "phpstan/phpstan-nette": "^1.0"
+ },
+ "conflict": {
+ "nette/http": "<3.3.1"
+ },
+ "autoload": {
+ "classmap": ["src/"]
+ },
+ "minimum-stability": "dev",
+ "scripts": {
+ "phpstan": "phpstan analyse",
+ "tester": "tester tests -s"
+ }
+}
diff --git a/license.md b/license.md
new file mode 100644
index 0000000..cf741bd
--- /dev/null
+++ b/license.md
@@ -0,0 +1,60 @@
+Licenses
+========
+
+Good news! You may use Nette Framework under the terms of either
+the New BSD License or the GNU General Public License (GPL) version 2 or 3.
+
+The BSD License is recommended for most projects. It is easy to understand and it
+places almost no restrictions on what you can do with the framework. If the GPL
+fits better to your project, you can use the framework under this license.
+
+You don't have to notify anyone which license you are using. You can freely
+use Nette Framework in commercial projects as long as the copyright header
+remains intact.
+
+Please be advised that the name "Nette Framework" is a protected trademark and its
+usage has some limitations. So please do not use word "Nette" in the name of your
+project or top-level domain, and choose a name that stands on its own merits.
+If your stuff is good, it will not take long to establish a reputation for yourselves.
+
+
+New BSD License
+---------------
+
+Copyright (c) 2004, 2014 David Grudl (https://davidgrudl.com)
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+ * Neither the name of "Nette Framework" nor the names of its contributors
+ may be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+This software is provided by the copyright holders and contributors "as is" and
+any express or implied warranties, including, but not limited to, the implied
+warranties of merchantability and fitness for a particular purpose are
+disclaimed. In no event shall the copyright owner or contributors be liable for
+any direct, indirect, incidental, special, exemplary, or consequential damages
+(including, but not limited to, procurement of substitute goods or services;
+loss of use, data, or profits; or business interruption) however caused and on
+any theory of liability, whether in contract, strict liability, or tort
+(including negligence or otherwise) arising in any way out of the use of this
+software, even if advised of the possibility of such damage.
+
+
+GNU General Public License
+--------------------------
+
+GPL licenses are very very long, so instead of including them here we offer
+you URLs with full text:
+
+- [GPL version 2](http://www.gnu.org/licenses/gpl-2.0.html)
+- [GPL version 3](http://www.gnu.org/licenses/gpl-3.0.html)
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..8a634ad
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,8 @@
+parameters:
+ level: 5
+
+ paths:
+ - src
+
+includes:
+ - vendor/phpstan/phpstan-nette/extension.neon
diff --git a/readme.md b/readme.md
new file mode 100644
index 0000000..3cbd1f6
--- /dev/null
+++ b/readme.md
@@ -0,0 +1,104 @@
+Nette Assets
+============
+
+[![Downloads this Month](https://img.shields.io/packagist/dm/nette/assets.svg)](https://packagist.org/packages/nette/assets)
+[![Tests](https://github.com/nette/assets/workflows/Tests/badge.svg?branch=master)](https://github.com/nette/assets/actions)
+[![Latest Stable Version](https://poser.pugx.org/nette/assets/v/stable)](https://github.com/nette/assets/releases)
+[![License](https://img.shields.io/badge/license-New%20BSD-blue.svg)](https://github.com/nette/assets/blob/master/license.md)
+
+
+
+Introduction
+------------
+
+Nette Assets is a powerful asset management library for PHP that helps you:
+
+✅ organize and serve your static assets (images, CSS, JavaScript, audio, etc.)
+✅ handle asset versioning automatically
+✅ get image dimensions without hassle
+✅ verify asset existence in development mode
+✅ support multiple storage backends
+
+The library provides a clean and intuitive API to manage static assets in your web applications with focus on developer experience and performance.
+
+
+
+Installation and Requirements
+-----------------------------
+
+The recommended way to install is via Composer:
+
+```shell
+composer require nette/assets
+```
+
+Nette Assets requires PHP 8.1 or higher.
+
+
+
+Usage
+-----
+
+First, configure your assets in your application's configuration file:
+
+```neon
+assets:
+ mapping:
+ default: assets # maps 'default:' prefix to /assets directory
+ audio: media/audio # maps 'audio:' prefix to /media/audio directory
+```
+
+Then use assets in your Latte templates:
+
+```latte
+
+```
+
+You can also use provider-specific prefixes:
+
+```latte
+
+```
+
+
+
+Asset Versioning
+----------------
+
+The library automatically appends version query string to asset URLs based on file modification time:
+
+```latte
+{asset('app.js')}
+```
+
+generates for example:
+
+```html
+/assets/app.js?v=1699944800
+```
+
+This ensures proper cache invalidation when assets change.
+
+
+
+Image Dimensions
+----------------
+
+Get image dimensions easily in templates:
+
+```latte
+
+```
+
+
+
+Multiple Storage Backends
+-------------------------
+
+The library supports multiple storage providers, which can be configured independently:
+
+```neon
+assets:
+ mapping:
+ product: App\UI\Accessory\ProductProvider(https://img.example.com, %rootDir%/www.img)
+```
diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php
new file mode 100644
index 0000000..ac85e44
--- /dev/null
+++ b/src/Assets/Asset.php
@@ -0,0 +1,28 @@
+url;
+ }
+
+
+ /**
+ * Returns the filesystem path to the source asset file.
+ */
+ public function getSourcePath(): string
+ {
+ return $this->sourcePath;
+ }
+
+
+ /**
+ * Shortcut for getUrl()
+ */
+ public function __toString(): string
+ {
+ return $this->url;
+ }
+
+
+ /**
+ * Checks if the asset file exists in the filesystem.
+ */
+ public function exists(): bool
+ {
+ return is_file($this->sourcePath);
+ }
+
+
+ /**
+ * Returns duration in seconds for MP3 audio file.
+ */
+ public function getDuration(): int
+ {
+ return $this->duration ??= Helpers::getMP3Duration($this->getSourcePath());
+ }
+
+
+ /**
+ * Returns width in pixels for image files.
+ */
+ public function getWidth(): int
+ {
+ return $this->getSize()[0];
+ }
+
+
+ /**
+ * Returns height in pixels for image files.
+ */
+ public function getHeight(): int
+ {
+ return $this->getSize()[1];
+ }
+
+
+ /**
+ * Returns the dimensions [width, height] of an image file.
+ * @throws \RuntimeException if file is not an image or doesn't exist
+ */
+ private function getSize(): array
+ {
+ return $this->size ??= @getimagesize($this->getSourcePath()) // @ - file may not exist or is not an image
+ ?: throw new \RuntimeException(sprintf(
+ "Cannot get size of image '%s'. %s",
+ $this->getSourcePath(),
+ Nette\Utils\Helpers::getLastError(),
+ ));
+ }
+}
diff --git a/src/Assets/FilesystemProvider.php b/src/Assets/FilesystemProvider.php
new file mode 100644
index 0000000..1a091bb
--- /dev/null
+++ b/src/Assets/FilesystemProvider.php
@@ -0,0 +1,99 @@
+baseUrl = rtrim($baseUrl, '/');
+ $this->basePath = rtrim($basePath, '\\/');
+ $this->extensions = $extensions;
+ }
+
+
+ /**
+ * Returns asset instance for given path.
+ */
+ public function getAsset(string $path, array $options = []): FileAsset
+ {
+ Helpers::checkOptions($options);
+ $sourcePath = $this->getSourcePath($path);
+ $ext = $this->extensions && !file_exists($sourcePath)
+ ? $this->findExtension($sourcePath)
+ : '';
+ return new FileAsset($this->buildUrl($path . $ext, $options), $sourcePath . $ext);
+ }
+
+
+ /**
+ * Builds public URL for the asset including optional version parameter.
+ */
+ protected function buildUrl(string $path, array $options): string
+ {
+ if ($version = $this->getVersion($path)) {
+ $path = $this->applyVersion($path, $version);
+ }
+ return $this->baseUrl . '/' . $path;
+ }
+
+
+ /**
+ * Returns filesystem path for the asset.
+ */
+ protected function getSourcePath(string $path): string
+ {
+ return $this->basePath . '/' . $path;
+ }
+
+
+ /**
+ * Returns version string for the asset based on file modification time.
+ */
+ protected function getVersion(string $path): ?string
+ {
+ $sourcePath = $this->getSourcePath($path);
+ return is_file($sourcePath) ? (string) filemtime($sourcePath) : null;
+ }
+
+
+ /**
+ * Applies version to asset URL as query parameter.
+ */
+ protected function applyVersion(string $path, string $version): string
+ {
+ return $path . '?v=' . $version;
+ }
+
+
+ /**
+ * Finds extension for the asset file based on the list of possible extensions.
+ */
+ private function findExtension(string $sourcePath): string
+ {
+ foreach ($this->extensions as $ext) {
+ if ($ext === '') {
+ $default = '';
+ } else {
+ $default ??= ($ext = '.' . $ext);
+ }
+ if (is_file($sourcePath . $ext)) {
+ return $ext;
+ }
+ }
+
+ return $default;
+ }
+}
diff --git a/src/Assets/Helpers.php b/src/Assets/Helpers.php
new file mode 100644
index 0000000..bb70772
--- /dev/null
+++ b/src/Assets/Helpers.php
@@ -0,0 +1,60 @@
+> 12) & 0xF;
+ $bitrate = [null, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320][$bitrateIndex] ?? null;
+ if ($bitrate === null) {
+ throw new \RuntimeException('Invalid or unsupported bitrate index.');
+ }
+
+ return (int) round($fileSize * 8 / $bitrate / 1000);
+ }
+}
diff --git a/src/Assets/Provider.php b/src/Assets/Provider.php
new file mode 100644
index 0000000..7dedca2
--- /dev/null
+++ b/src/Assets/Provider.php
@@ -0,0 +1,18 @@
+ */
+ private array $providers = [];
+
+ /** @var array */
+ private array $cache = [];
+
+
+ /**
+ * Adds new asset provider to the registry.
+ * @throws \InvalidArgumentException if provider with same ID already exists
+ */
+ public function addProvider(string $id, Provider $provider): void
+ {
+ if (isset($this->providers[$id])) {
+ throw new \InvalidArgumentException("Asset provider '$id' is already registered");
+ }
+ $this->providers[$id] = $provider;
+ }
+
+
+ /**
+ * Returns asset provider by its ID.
+ * @throws \InvalidArgumentException if provider doesn't exist
+ */
+ public function getProvider(string $id): Provider
+ {
+ return $this->providers[$id] ?? throw new \InvalidArgumentException("Unknown asset provider '$id'.");
+ }
+
+
+ /**
+ * Returns asset instance for given provider-qualified path.
+ */
+ public function getAsset(string $qualifiedPath, array $options = []): Asset
+ {
+ $cacheKey = $qualifiedPath . ($options ? '?' . http_build_query($options) : '');
+ if (isset($this->cache[$cacheKey])) {
+ return $this->cache[$cacheKey];
+ }
+
+ [$provider, $path] = $this->parsePath($qualifiedPath);
+ $asset = $this->getProvider($provider)->getAsset($path, $options);
+
+ if (count($this->cache) >= self::MaxCacheSize) {
+ array_shift($this->cache);
+ }
+
+ return $this->cache[$cacheKey] = $asset;
+ }
+
+
+ /**
+ * Parses provider-qualified path into [providerId, path] parts.
+ * @return array{string, string}
+ */
+ private function parsePath(string $qualifiedPath): array
+ {
+ $parts = explode(self::ProviderSeparator, $qualifiedPath, 2);
+ return count($parts) === 1 ? [self::DefaultScope, $parts[0]] : $parts;
+ }
+}
diff --git a/src/Bridges/Assets/DIExtension.php b/src/Bridges/Assets/DIExtension.php
new file mode 100644
index 0000000..60f620b
--- /dev/null
+++ b/src/Bridges/Assets/DIExtension.php
@@ -0,0 +1,82 @@
+ Expect::string(),
+ 'source' => Expect::string(),
+ 'mapping' => Expect::arrayOf(
+ Expect::anyOf(
+ Expect::string(),
+ Expect::structure([
+ 'url' => Expect::string()->required()->dynamic(),
+ 'source' => Expect::string()->required()->dynamic(),
+ 'extensions' => Expect::arrayOf('string'),
+ ]),
+ Expect::type(Statement::class),
+ ),
+ ),
+ ]);
+ }
+
+
+ public function loadConfiguration()
+ {
+ $builder = $this->getContainerBuilder();
+
+ $baseUrl = $this->config->url
+ ? new Statement(UrlImmutable::class, [rtrim($this->config->url, '/') . '/'])
+ : new Statement('@Nette\Http\Request::getUrl');
+ $sourceDir = $this->config->source ?? $builder->parameters['wwwDir'];
+
+ $mapping = $this->config->mapping ?? ['default' => 'assets'];
+ $mapping[Registry::DefaultScope] = $mapping['default'] ?? null;
+ unset($mapping['default']);
+
+ $registry = $builder->addDefinition($this->prefix('registry'))
+ ->setFactory(Registry::class);
+
+ foreach ($mapping as $scope => $item) {
+ if (is_string($item)) {
+ $url = new Statement([$baseUrl, 'resolve'], [$item]);
+ $url = new Statement([$url, 'getAbsoluteUrl']);
+ $source = new Statement(['', 'join'], [[$sourceDir, '/' . ltrim($item, '/')]]);
+ $item = new Statement(FilesystemProvider::class, [$url, $source]);
+ } elseif ($item instanceof \stdClass) {
+ $url = new Statement([$baseUrl, 'resolve'], [$item->url]);
+ $url = new Statement([$url, 'getAbsoluteUrl']);
+ $item = new Statement(FilesystemProvider::class, [$url, $item->source, $item->extensions]);
+ }
+ $registry->addSetup('addProvider', [$scope, $item]);
+ }
+ }
+
+
+ public function beforeCompile()
+ {
+ $builder = $this->getContainerBuilder();
+ if ($name = $builder->getByType(Nette\Bridges\ApplicationLatte\LatteFactory::class)) {
+ $builder->getDefinition($name)
+ ->getResultDefinition()
+ ->addSetup('addExtension', [new Statement(LatteExtension::class)]);
+ }
+ }
+}
diff --git a/src/Bridges/Assets/LatteExtension.php b/src/Bridges/Assets/LatteExtension.php
new file mode 100644
index 0000000..7e7a4c1
--- /dev/null
+++ b/src/Bridges/Assets/LatteExtension.php
@@ -0,0 +1,37 @@
+ $this->registry->getAsset(...),
+ 'assetWidth' => fn(string $qualifiedPath, array $options = []): ?int => $this->registry
+ ->getAsset($qualifiedPath, $options)
+ ->getWidth(),
+ 'assetHeight' => fn(string $qualifiedPath, array $options = []): ?int => $this->registry
+ ->getAsset($qualifiedPath, $options)
+ ->getHeight(),
+ ];
+ }
+}
diff --git a/tests/.gitignore b/tests/.gitignore
new file mode 100644
index 0000000..a7ffcfd
--- /dev/null
+++ b/tests/.gitignore
@@ -0,0 +1,3 @@
+/*/output
+/test.log
+/tmp
diff --git a/tests/Assets/FileAsset.phpt b/tests/Assets/FileAsset.phpt
new file mode 100644
index 0000000..fcb696d
--- /dev/null
+++ b/tests/Assets/FileAsset.phpt
@@ -0,0 +1,56 @@
+getUrl());
+ Assert::same(__DIR__ . '/fixtures/image.gif', $asset->getSourcePath());
+ Assert::same('http://example.com/image.gif', (string) $asset);
+ Assert::true($asset->exists());
+});
+
+test('Non-existent file', function () {
+ $asset = new FileAsset('http://example.com/missing.jpg', '/non/existent/path');
+ Assert::false($asset->exists());
+});
+
+test('Image dimensions', function () {
+ $asset = new FileAsset('http://example.com/image.gif', __DIR__ . '/fixtures/image.gif');
+
+ Assert::same(176, $asset->getWidth());
+ Assert::same(104, $asset->getHeight());
+});
+
+test('Invalid image dimensions throws', function () {
+ $asset = new FileAsset('http://example.com/audio.mp3', __DIR__ . '/fixtures/audio.mp3');
+
+ Assert::exception(
+ fn() => $asset->getWidth(),
+ RuntimeException::class,
+ sprintf("Cannot get size of image '%s'. %s", $asset->getSourcePath(), Nette\Utils\Helpers::getLastError()),
+ );
+});
+
+test('MP3 duration', function () {
+ $asset = new FileAsset('http://example.com/audio.mp3', __DIR__ . '/fixtures/audio.mp3');
+
+ Assert::same(149, $asset->getDuration());
+});
+
+test('Invalid MP3 throws', function () {
+ $asset = new FileAsset('http://example.com/image.gif', __DIR__ . '/fixtures/image.gif');
+
+ Assert::exception(
+ fn() => $asset->getDuration(),
+ RuntimeException::class,
+ 'Failed to find MP3 frame sync bits.',
+ );
+});
diff --git a/tests/Assets/FilesystemProvider.phpt b/tests/Assets/FilesystemProvider.phpt
new file mode 100644
index 0000000..563937d
--- /dev/null
+++ b/tests/Assets/FilesystemProvider.phpt
@@ -0,0 +1,85 @@
+getAsset('test.txt');
+
+ Assert::same('http://example.com/assets/test.txt?v=2700000000', $asset->getUrl());
+ Assert::same(__DIR__ . '/fixtures/test.txt', $asset->getSourcePath());
+ Assert::true($asset->exists());
+});
+
+test('URL without trailing slash', function () {
+ $provider = new FilesystemProvider('http://example.com/assets/', __DIR__ . '/fixtures/');
+ $asset = $provider->getAsset('test.txt');
+
+ Assert::same('http://example.com/assets/test.txt?v=2700000000', $asset->getUrl());
+});
+
+test('Non-existent file version handling', function () {
+ $provider = new FilesystemProvider('http://example.com/assets', __DIR__ . '/fixtures');
+ $asset = $provider->getAsset('missing.txt');
+
+ Assert::same('http://example.com/assets/missing.txt', $asset->getUrl());
+ Assert::false($asset->exists());
+});
+
+test('Mandatory extension autodetection', function () {
+ $provider = new FilesystemProvider(
+ 'http://example.com/assets',
+ __DIR__ . '/fixtures',
+ ['gif', 'jpg'],
+ );
+
+ $exact = $provider->getAsset('image.gif');
+ Assert::match('http://example.com/assets/image.gif?v=%d%', $exact->getUrl());
+ Assert::true($exact->exists());
+
+ $gif = $provider->getAsset('image');
+ Assert::match('http://example.com/assets/image.gif?v=%d%', $gif->getUrl());
+ Assert::true($gif->exists());
+
+ $notFound = $provider->getAsset('missing');
+ Assert::same('http://example.com/assets/missing.gif', $notFound->getUrl());
+ Assert::false($notFound->exists());
+
+ $subdir = $provider->getAsset('subdir');
+ Assert::same('http://example.com/assets/subdir', $subdir->getUrl());
+ Assert::false($subdir->exists());
+});
+
+test('Optional extension autodetection', function () {
+ $provider = new FilesystemProvider(
+ 'http://example.com/assets',
+ __DIR__ . '/fixtures',
+ ['gif', 'jpg', ''],
+ );
+
+ $gif = $provider->getAsset('image');
+ Assert::match('http://example.com/assets/image.gif?v=%d%', $gif->getUrl());
+ Assert::true($gif->exists());
+
+ $notFound = $provider->getAsset('missing');
+ Assert::same('http://example.com/assets/missing', $notFound->getUrl());
+ Assert::false($notFound->exists());
+});
+
+test('Option validation', function () {
+ $provider = new FilesystemProvider('http://example.com/assets', __DIR__ . '/fixtures');
+
+ Assert::exception(
+ fn() => $provider->getAsset('test.txt', ['invalid' => true]),
+ InvalidArgumentException::class,
+ 'Unsupported asset options: invalid',
+ );
+});
diff --git a/tests/Assets/Provider.basic.phpt b/tests/Assets/Provider.basic.phpt
new file mode 100644
index 0000000..39d285d
--- /dev/null
+++ b/tests/Assets/Provider.basic.phpt
@@ -0,0 +1,91 @@
+getUrl();
+ }
+
+
+ public function exists(): bool
+ {
+ return true;
+ }
+}
+
+class MockProvider implements Provider
+{
+ public function __construct(
+ private Asset $asset,
+ ) {
+ }
+
+
+ public function getAsset(string $path, array $options = []): Asset
+ {
+ return $this->asset;
+ }
+}
+
+
+test('Adding and getting provider', function () {
+ $registry = new Registry;
+ $provider = new MockProvider(new MockAsset);
+
+ $registry->addProvider('test', $provider);
+ Assert::same($provider, $registry->getProvider('test'));
+});
+
+test('Adding duplicate provider throws', function () {
+ $registry = new Registry;
+ $provider = new MockProvider(new MockAsset);
+
+ $registry->addProvider('test', $provider);
+ Assert::exception(
+ fn() => $registry->addProvider('test', $provider),
+ InvalidArgumentException::class,
+ "Asset provider 'test' is already registered",
+ );
+});
+
+test('Getting unknown provider throws', function () {
+ $registry = new Registry;
+ Assert::exception(
+ fn() => $registry->getProvider('unknown'),
+ InvalidArgumentException::class,
+ "Unknown asset provider 'unknown'.",
+ );
+});
+
+test('Getting asset without provider prefix uses default scope', function () {
+ $registry = new Registry;
+ $asset = new MockAsset;
+ $registry->addProvider('', new MockProvider($asset));
+
+ Assert::same($asset, $registry->getAsset('test.jpg'));
+});
+
+test('Getting asset with provider prefix', function () {
+ $registry = new Registry;
+ $asset = new MockAsset;
+ $registry->addProvider('images', new MockProvider($asset));
+
+ Assert::same($asset, $registry->getAsset('images:test.jpg'));
+});
diff --git a/tests/Assets/Provider.cache.phpt b/tests/Assets/Provider.cache.phpt
new file mode 100644
index 0000000..11f1821
--- /dev/null
+++ b/tests/Assets/Provider.cache.phpt
@@ -0,0 +1,76 @@
+getUrl();
+ }
+
+
+ public function exists(): bool
+ {
+ return true;
+ }
+}
+
+class MockProvider implements Provider
+{
+ public function getAsset(string $path, array $options = []): MockAsset
+ {
+ return new MockAsset;
+ }
+}
+
+
+test('Asset caching works', function () {
+ $registry = new Registry;
+ $registry->addProvider('test', new MockProvider);
+
+ $first = $registry->getAsset('test:asset.jpg');
+ $second = $registry->getAsset('test:asset.jpg');
+
+ Assert::same($first, $second);
+});
+
+test('Cache respects options', function () {
+ $registry = new Registry;
+ $registry->addProvider('test', new MockProvider);
+
+ $withoutOptions = $registry->getAsset('test:asset.jpg');
+ $withOptions = $registry->getAsset('test:asset.jpg', ['version' => 1]);
+
+ Assert::notSame($withoutOptions, $withOptions);
+});
+
+test('Cache has limited size', function () {
+ $registry = new Registry;
+ $registry->addProvider('test', new MockProvider);
+ $assets = [];
+
+ for ($i = 0; $i < 12; $i++) { // current cache size is 10
+ $assets[$i] = $registry->getAsset("test:asset$i.jpg");
+ }
+
+ $first = $registry->getAsset('test:asset0.jpg');
+ $last = $registry->getAsset('test:asset11.jpg');
+
+ Assert::notSame(reset($assets), $first); // First asset should be removed from cache
+ Assert::same(end($assets), $last); // Last asset should still be in cache
+});
diff --git a/tests/Assets/fixtures/audio.mp3 b/tests/Assets/fixtures/audio.mp3
new file mode 100644
index 0000000..0867b59
Binary files /dev/null and b/tests/Assets/fixtures/audio.mp3 differ
diff --git a/tests/Assets/fixtures/image.gif b/tests/Assets/fixtures/image.gif
new file mode 100644
index 0000000..4c2fd1b
Binary files /dev/null and b/tests/Assets/fixtures/image.gif differ
diff --git a/tests/Assets/fixtures/subdir/file.txt b/tests/Assets/fixtures/subdir/file.txt
new file mode 100644
index 0000000..e69de29
diff --git a/tests/Assets/fixtures/test.txt b/tests/Assets/fixtures/test.txt
new file mode 100644
index 0000000..d95f3ad
--- /dev/null
+++ b/tests/Assets/fixtures/test.txt
@@ -0,0 +1 @@
+content
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..e17aca1
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,16 @@
+