diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c82b426 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c749ca7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +/.github export-ignore +/tests export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.php-cs-fixer.dist.php export-ignore +phpstan.neon export-ignore +phpunit.xml.dist export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..83694d3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: Continuous Integration +on: push + +jobs: + code-quality: + name: Run code quality checks on PHP 8.0 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + dependency-version: [ '', '--prefer-lowest' ] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.0' + + - name: Install dependencies + run: composer update ${{ matrix.dependency-version }} --no-ansi --no-interaction --no-scripts --no-suggest --prefer-dist + + - name: Run PHPStan + run: vendor/bin/phpstan analyze --error-format=github + + - name: Run PHP CS Fixer + run: vendor/bin/php-cs-fixer fix --allow-risky=yes --dry-run --diff + + test: + runs-on: ${{ matrix.os }} + needs: code-quality + timeout-minutes: 5 + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, windows-latest] + php: [8.0, 8.1, 8.2, 8.3] + laravel: [9.*, 10.*, 11.*] + stability: [prefer-stable] + include: + - laravel: 9.* + testbench: 7.* + carbon: ^2.72.2 + - laravel: 10.* + testbench: 8.* + carbon: ^2.72.2 + - laravel: 11.* + testbench: 9.* + carbon: ^2.72.2 + exclude: + - laravel: 10.* + php: 8.0 + - laravel: 11.* + php: 8.0 + - laravel: 11.* + php: 8.1 + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: Execute tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eee1503 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/composer.lock +/vendor +.phpunit.result.cache +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..9e57149 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,86 @@ + + +For the full copyright and license information, please view the LICENSE +file that was distributed with this source code. +EOF; + +$finder = PhpCsFixer\Finder::create() + ->in([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) +; + +$config = new PhpCsFixer\Config(); +$config + ->setRiskyAllowed(true) + ->setRules([ + '@Symfony' => true, + '@Symfony:risky' => true, + 'align_multiline_comment' => true, + 'array_syntax' => ['syntax' => 'short'], + 'blank_line_before_statement' => true, + 'combine_consecutive_issets' => true, + 'combine_consecutive_unsets' => true, + 'declare_strict_types' => true, + // one should use PHPUnit methods to set up expected exception instead of annotations + 'general_phpdoc_annotation_remove' => ['annotations' => ['expectedException', 'expectedExceptionMessage', 'expectedExceptionMessageRegExp']], + 'explicit_string_variable' => true, + 'header_comment' => ['header' => $header], + 'heredoc_to_nowdoc' => true, + 'list_syntax' => ['syntax' => 'long'], + 'method_chaining_indentation' => false, + 'native_function_invocation' => false, + 'native_constant_invocation' => false, + 'no_extra_blank_lines' => ['tokens' => ['break', 'continue', 'extra', 'return', 'throw', 'use', 'parenthesis_brace_block', 'square_brace_block', 'curly_brace_block']], + 'no_null_property_initialization' => true, + 'echo_tag_syntax' => ['format' => 'long'], + 'no_superfluous_phpdoc_tags' => ['allow_mixed' => false], + 'no_unneeded_curly_braces' => true, + 'no_unneeded_final_method' => true, + 'no_unreachable_default_argument_value' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'ordered_class_elements' => [ + 'order' => [ + 'use_trait', + 'constant_public', + 'constant_protected', + 'constant_private', + 'property_public', + 'property_protected', + 'property_private', + 'construct', + 'destruct', + 'magic', + 'phpunit', + 'method_public_static', + 'method_protected_static', + 'method_private_static', + 'method_public', + 'method_public_abstract', + 'method_protected', + 'method_protected_abstract', + 'method_private', + ], + 'sort_algorithm' => 'alpha' + ], + 'ordered_imports' => true, + 'php_unit_construct' => true, + 'php_unit_method_casing' => ['case' => 'camel_case'], + 'php_unit_dedicate_assert' => true, + 'phpdoc_order' => true, + 'phpdoc_types_order' => ['null_adjustment' => 'always_last'], + 'semicolon_after_instruction' => true, + 'single_line_comment_style' => true, + 'yoda_style' => true, + ]) + ->setFinder($finder) +; + +return $config; diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a32c0ff --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +phpunit.xml.distCopyright (c) Optimole Team + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7db8558 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Optimole Laravel Package + +[![Actions Status](https://github.com/Codeinwp/optimole-laravel/workflows/Continuous%20Integration/badge.svg)](https://github.com/Codeinwp/optimole-laravel/actions) + +## Requirements + +* Laravel >= 9.0 + +## Installation + +Install the Optimole Laravel Package in your project using composer: + +``` +$ composer require codeinwp/optimole-laravel +``` + +If you want, you can publish Optimole's configuration file using the `vendor:publish` command: + +``` +$ php artisan vendor:publish --tag="optimole-config" +``` + +## Configuration + +The Optimole package will remain inactive until you provide an Optimole API key. You can do this by adding the +`OPTIMOLE_KEY` variable to your `.env` file: + +``` +OPTIMOLE_KEY=your-optmole-api-key +``` + +If you don't have an API key, you can create an account on [Optimole][1] and get your API key. + +## Usage + +By default, the Optimole Laravel Package will optimize all images and assets in your Laravel application if they use +the [`asset`][2] helper function. The package also provides two helper functions that you can use in your blade +templates. The `optimole_asset` function will return the URL of the optimized CSS or JS files, while the +`optimole_image` function will return the URL of the optimized image. + +[1]: https://optimole.com +[2]: https://laravel.com/docs/master/helpers#method-asset diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1bde178 --- /dev/null +++ b/composer.json @@ -0,0 +1,52 @@ +{ + "name": "codeinwp/optimole-laravel", + "description": "Integrate Optimole cloud-based image optimization service with your Laravel application", + "homepage": "https://optimole.com", + "license": "MIT", + "authors": [ + { + "name": "Optimole Team", + "email": "friends@optimole.com", + "homepage": "https://optimole.com" + } + ], + "support": { + "issues": "https://github.com/Codeinwp/codeinwp/optimole-laravel/issues", + "source": "https://github.com/Codeinwp/optimole-laravel" + }, + "require": { + "illuminate/support": "^9.0|^10.0|^11.0", + "codeinwp/optimole-sdk": "^1.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "laravel/framework": "^9.0|^10.0|^11.0", + "orchestra/testbench": "^7.0|^8.0|^9.0", + "phpstan/phpstan": "^1.0" + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + }, + "autoload": { + "psr-4": { + "Optimole\\Laravel\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Optimole\\Laravel\\Tests\\": "tests" + } + }, + "extra": { + "laravel": { + "providers": [ + "Optimole\\Laravel\\ServiceProvider" + ], + "aliases": { + "Optimole": "Optimole\\Laravel\\Facade" + } + } + } +} diff --git a/config/optimole.php b/config/optimole.php new file mode 100644 index 0000000..ad34788 --- /dev/null +++ b/config/optimole.php @@ -0,0 +1,14 @@ + env('OPTIMOLE_KEY'), + + 'base_domain' => env('OPTIMOLE_BASE_DOMAIN', 'i.optimole.com'), + + 'cache_buster' => env('OPTIMOLE_CACHE_BUSTER', ''), + + 'override_asset_helper' => env('OPTIMOLE_OVERRIDE_ASSET_HELPER', true), +]; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..13569a5 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 5 + paths: + - src/ diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..a7ee360 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + + + + ./tests/Integration + + + diff --git a/src/Facade.php b/src/Facade.php new file mode 100644 index 0000000..4f1cc60 --- /dev/null +++ b/src/Facade.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Laravel; + +use Illuminate\Support\Facades\Facade as LaravelFacade; +use Optimole\Sdk\Offload\Manager; +use Optimole\Sdk\Optimole; +use Optimole\Sdk\Resource\Asset; +use Optimole\Sdk\Resource\Image; + +/** + * @method static Asset asset(string $assetUrl, string $cacheBuster = '') + * @method static Image image(string $imageUrl, string $cacheBuster = '') + * @method static Manager offload() + */ +class Facade extends LaravelFacade +{ + /** + * Get the registered name of the component. + * + * @return string + */ + protected static function getFacadeAccessor() + { + return Optimole::class; + } +} diff --git a/src/Routing/UrlGenerator.php b/src/Routing/UrlGenerator.php new file mode 100644 index 0000000..e8b6be8 --- /dev/null +++ b/src/Routing/UrlGenerator.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Laravel\Routing; + +use Illuminate\Routing\UrlGenerator as LaravelUrlGenerator; +use Optimole\Sdk\Optimole; + +class UrlGenerator extends LaravelUrlGenerator +{ + /** + * {@inheritdoc} + */ + public function asset($path, $secure = null) + { + $asset = parent::asset($path, $secure); + + if (!$this->isOptimoleSdkInitialized() || !config('optimole.override_asset_helper')) { + return $asset; + } + + $extension = pathinfo($asset, PATHINFO_EXTENSION); + + if (in_array($extension, ['css', 'js'])) { + $asset = $this->optimoleAsset($asset); + } elseif (in_array($extension, ['gif', 'jpeg', 'jpe', 'jpg', 'png', 'webp'])) { + $asset = $this->optimoleImage($asset); + } + + return $asset; + } + + /** + * Generate an Optimole asset URL. + */ + public function optimoleAsset($url, $cacheBuster = ''): string + { + return $this->isOptimoleSdkInitialized() ? Optimole::asset($url, $cacheBuster)->getUrl() : $url; + } + + /** + * Generate an Optimole image URL. + */ + public function optimoleImage($url, $cacheBuster = ''): string + { + return $this->isOptimoleSdkInitialized() ? Optimole::image($url, $cacheBuster)->getUrl() : $url; + } + + /** + * Check if the Optimole SDK is initialized. + */ + private function isOptimoleSdkInitialized(): bool + { + return config('optimole.key') && Optimole::initialized(); + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php new file mode 100644 index 0000000..ba8e3f5 --- /dev/null +++ b/src/ServiceProvider.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Laravel; + +use Illuminate\Contracts\Routing\UrlGenerator as UrlGeneratorContract; +use Illuminate\Foundation\Application as Laravel; +use Illuminate\Routing\UrlGenerator as LaravelUrlGenerator; +use Illuminate\Support\ServiceProvider as LaravelServiceProvider; +use Optimole\Laravel\Routing\UrlGenerator; +use Optimole\Sdk\Optimole; + +class ServiceProvider extends LaravelServiceProvider +{ + /** + * Bootstrap package services. + */ + public function boot() + { + $this->registerHelpers(); + $this->registerPublishing(); + } + + /** + * Register package services. + */ + public function register() + { + $this->bindUrlGenerator(); + $this->configure(); + } + + private function bindUrlGenerator() + { + $this->app->singleton(UrlGenerator::class, function (Laravel $app) { + return new UrlGenerator($app['router']->getRoutes(), $app->make('request')); + }); + + $this->app->singleton(LaravelUrlGenerator::class, function (Laravel $app) { + return $app->make(UrlGenerator::class); + }); + + $this->app->singleton('url', function (Laravel $app) { + return $app->make(UrlGenerator::class); + }); + + $this->app->bind(UrlGeneratorContract::class, UrlGenerator::class); + } + + /** + * Configure the package. + */ + private function configure() + { + $this->mergeConfigFrom(__DIR__.'/../config/optimole.php', 'optimole'); + + $key = config('optimole.key'); + + if (!is_string($key)) { + return; + } + + Optimole::init($key, [ + 'base_domain' => config('optimole.base_domain'), + 'cache_buster' => config('optimole.cache_buster'), + ]); + } + + /** + * Register the package's helper functions. + */ + private function registerHelpers() + { + require_once __DIR__.'/helpers.php'; + } + + /** + * Register the package's publishable resources. + */ + private function registerPublishing() + { + if (!$this->app->runningInConsole()) { + return; + } + + $this->publishes([ + __DIR__.'/../config/optimole.php' => config_path('optimole.php'), + ], 'optimole-config'); + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..b0faccc --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +if (!function_exists('optimole_asset')) { + function optimole_asset($url, $cacheBuster = ''): string + { + return app('url')->optimoleAsset($url, $cacheBuster); + } +} + +if (!function_exists('optimole_image')) { + function optimole_image($url, $cacheBuster = ''): string + { + return app('url')->optimoleImage($url, $cacheBuster); + } +} diff --git a/testbench.yaml b/testbench.yaml new file mode 100644 index 0000000..1a99346 --- /dev/null +++ b/testbench.yaml @@ -0,0 +1,2 @@ +providers: + - Optimole\Laravel\ServiceProvider diff --git a/tests/Integration/Routing/UrlGeneratorTest.php b/tests/Integration/Routing/UrlGeneratorTest.php new file mode 100644 index 0000000..603626b --- /dev/null +++ b/tests/Integration/Routing/UrlGeneratorTest.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Laravel\Tests\Integration\Routing; + +use Illuminate\Routing\RouteCollection; +use Optimole\Laravel\Routing\UrlGenerator; +use Optimole\Laravel\Tests\TestCase; + +class UrlGeneratorTest extends TestCase +{ + public static function provideAssetPaths() + { + return [ + ['/foo/bar.css'], + ['/foo/bar.js'], + ]; + } + + public static function provideImagePaths() + { + return [ + ['/foo/bar.gif'], + ['/foo/bar.jpeg'], + ['/foo/bar.jpe'], + ['/foo/bar.jpg'], + ['/foo/bar.png'], + ['/foo/bar.webp'], + ]; + } + + /** + * @dataProvider provideAssetPaths + */ + public function testAssetReturnsOptimoleAssetUrl($path) + { + $extension = pathinfo($path, PATHINFO_EXTENSION); + + $this->assertSame( + sprintf('https://optimole_key.i.optimole.com/f:%s/http://localhost%s', $extension, $path), + (new UrlGenerator(new RouteCollection(), $this->app->make('request')))->asset($path) + ); + } + + /** + * @dataProvider provideImagePaths + */ + public function testAssetReturnsOptimoleImageUrl($path) + { + $this->assertSame( + sprintf('https://optimole_key.i.optimole.com/http://localhost%s', $path), + (new UrlGenerator(new RouteCollection(), $this->app->make('request')))->asset($path) + ); + } + + /** + * @dataProvider provideAssetPaths + * @dataProvider provideImagePaths + */ + public function testAssetReturnsUnmodifiedUrlWhenOptimoleSdkIsNotInitialized($path) + { + config(['optimole.key' => null]); + + $this->assertSame( + sprintf('http://localhost%s', $path), + (new UrlGenerator(new RouteCollection(), $this->app->make('request')))->asset($path) + ); + } + + /** + * @dataProvider provideAssetPaths + * @dataProvider provideImagePaths + */ + public function testAssetReturnsUnmodifiedUrlWhenOverrideIsDisabled($path) + { + config(['optimole.override_asset_helper' => false]); + + $this->assertSame( + sprintf('http://localhost%s', $path), + (new UrlGenerator(new RouteCollection(), $this->app->make('request')))->asset($path) + ); + } + + public function testOptimoleAssetWhenOptimoleSdkIsInitialized() + { + $this->assertSame( + 'https://optimole_key.i.optimole.com/cb:cache_buster/f:css/http://localhost/foo/bar.css', + (new UrlGenerator(new RouteCollection(), $this->app->make('request')))->optimoleAsset('http://localhost/foo/bar.css', 'cache_buster') + ); + } + + public function testOptimoleAssetWhenOptimoleSdkIsNotInitialized() + { + config(['optimole.key' => null]); + + $this->assertSame( + 'http://localhost/foo/bar.css', + (new UrlGenerator(new RouteCollection(), $this->app->make('request')))->optimoleAsset('http://localhost/foo/bar.css') + ); + } + + public function testOptimoleImageWhenOptimoleSdkIsInitialized() + { + $this->assertSame( + 'https://optimole_key.i.optimole.com/cb:cache_buster/http://localhost/foo/bar.png', + (new UrlGenerator(new RouteCollection(), $this->app->make('request')))->optimoleImage('http://localhost/foo/bar.png', 'cache_buster') + ); + } + + public function testOptimoleImageWhenOptimoleSdkIsNotInitialized() + { + config(['optimole.key' => null]); + + $this->assertSame( + 'http://localhost/foo/bar.png', + (new UrlGenerator(new RouteCollection(), $this->app->make('request')))->optimoleImage('http://localhost/foo/bar.png') + ); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..ddd0b33 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Optimole\Laravel\Tests; + +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase as BaseTestCase; + +class TestCase extends BaseTestCase +{ + use WithWorkbench; +}