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 @@ +