diff --git a/composer.json b/composer.json index 3b8f8dc..c5bb537 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "ekino/phpstan-banned-code": "^1.0", "ergebnis/phpunit-slow-test-detector": "^2.14", "infection/infection": "^0.28", + "matthiasnoback/symfony-config-test": "^5.1", "php-parallel-lint/php-parallel-lint": "^1.4", "phpstan/extension-installer": "^1.1", "phpstan/phpdoc-parser": "^1.28", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e252646..6db0444 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -180,6 +180,11 @@ parameters: count: 1 path: src/Dto/BackgroundSync.php + - + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Favicons has an uninitialized property \\$src\\. Give it default value or assign it in the constructor\\.$#" + count: 1 + path: src/Dto/Favicons.php + - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\File has an uninitialized property \\$accept\\. Give it default value or assign it in the constructor\\.$#" count: 1 @@ -440,6 +445,16 @@ parameters: count: 1 path: src/ImageProcessor/GDImageProcessor.php + - + message: "#^Parameter \\#1 \\$dst_image of function imagecopyresampled expects GdImage, GdImage\\|false given\\.$#" + count: 1 + path: src/ImageProcessor/GDImageProcessor.php + + - + message: "#^Parameter \\#1 \\$image of function imagealphablending expects GdImage, GdImage\\|false given\\.$#" + count: 1 + path: src/ImageProcessor/GDImageProcessor.php + - message: "#^Parameter \\#1 \\$image of function imagepng expects GdImage, GdImage\\|false given\\.$#" count: 1 @@ -447,7 +462,7 @@ parameters: - message: "#^Parameter \\#1 \\$image of function imagesavealpha expects GdImage, GdImage\\|false given\\.$#" - count: 1 + count: 2 path: src/ImageProcessor/GDImageProcessor.php - @@ -485,6 +500,11 @@ parameters: count: 1 path: src/Normalizer/ServiceWorkerNormalizer.php + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/favicons.php + - message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" count: 1 @@ -623,14 +643,4 @@ parameters: - message: "#^Method SpomkyLabs\\\\PwaBundle\\\\SpomkyLabsPwaBundle\\:\\:loadExtension\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" count: 1 - path: src/SpomkyLabsPwaBundle.php - - - - message: "#^Property SpomkyLabs\\\\PwaBundle\\\\Subscriber\\\\ManifestCompileEventListener\\:\\:\\$jsonOptions type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Subscriber/ManifestCompileEventListener.php - - - - message: "#^Property SpomkyLabs\\\\PwaBundle\\\\Subscriber\\\\PwaDevServerSubscriber\\:\\:\\$jsonOptions type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Subscriber/PwaDevServerSubscriber.php \ No newline at end of file + path: src/SpomkyLabsPwaBundle.php \ No newline at end of file diff --git a/src/Dto/Favicons.php b/src/Dto/Favicons.php new file mode 100644 index 0000000..17b3658 --- /dev/null +++ b/src/Dto/Favicons.php @@ -0,0 +1,41 @@ +|null + */ + #[SerializedName('border_radius')] + public null|int $borderRadius = null; + + /** + * @var int<1, 100>|null + */ + #[SerializedName('image_scale')] + public null|int $imageScale = null; + + #[SerializedName('only_high_resolution')] + public null|bool $onlyHighResolution = null; + + #[SerializedName('only_tile_silhouette')] + public null|bool $onlyTileSilhouette = null; +} diff --git a/src/ImageProcessor/GDImageProcessor.php b/src/ImageProcessor/GDImageProcessor.php index 4e4fd2a..a108a0e 100644 --- a/src/ImageProcessor/GDImageProcessor.php +++ b/src/ImageProcessor/GDImageProcessor.php @@ -17,7 +17,15 @@ public function process(string $image, ?int $width, ?int $height, ?string $forma assert($image !== false); imagealphablending($image, true); if ($width !== null && $height !== null) { - $image = imagescale($image, $width, $height); + if ($width === $height) { + $image = imagescale($image, $width, $height); + } else { + $newImage = imagecreatetruecolor($width, $height); + imagealphablending($newImage, false); + imagesavealpha($newImage, true); + imagecopyresampled($newImage, $image, 0, 0, 0, 0, $width, $height, imagesx($image), imagesy($image)); + $image = $newImage; + } } ob_start(); imagesavealpha($image, true); diff --git a/src/ImageProcessor/ImagickImageProcessor.php b/src/ImageProcessor/ImagickImageProcessor.php index ee7e40f..34730bc 100644 --- a/src/ImageProcessor/ImagickImageProcessor.php +++ b/src/ImageProcessor/ImagickImageProcessor.php @@ -9,12 +9,6 @@ final readonly class ImagickImageProcessor implements ImageProcessorInterface { - public function __construct( - private int $filters = Imagick::FILTER_LANCZOS2, - private float $blur = 1, - ) { - } - public function process(string $image, ?int $width, ?int $height, ?string $format): string { if ($width === null && $height === null) { @@ -23,7 +17,17 @@ public function process(string $image, ?int $width, ?int $height, ?string $forma $imagick = new Imagick(); $imagick->readImageBlob($image); if ($width !== null && $height !== null) { - $imagick->resizeImage($width, $height, $this->filters, $this->blur, true); + if ($width === $height) { + $imagick->scaleImage($width, $height); + } else { + $imagick->scaleImage(min($width, $height), min($width, $height)); + $imagick->extentImage( + $width, + $height, + -($width - min($width, $height)) / 2, + -($height - min($width, $height)) / 2 + ); + } } $imagick->setImageBackgroundColor(new ImagickPixel('transparent')); if ($format !== null) { diff --git a/src/Resources/config/definition/favicons.php b/src/Resources/config/definition/favicons.php new file mode 100644 index 0000000..dff6b7a --- /dev/null +++ b/src/Resources/config/definition/favicons.php @@ -0,0 +1,71 @@ +rootNode() + ->beforeNormalization() + ->ifTrue( + static fn (null|array $v): bool => $v !== null && isset($v['manifest']) && $v['manifest']['enabled'] === true && isset($v['favicons']) && $v['favicons']['enabled'] === true && isset($v['manifest']['theme_color']) + ) + ->then(static function (array $v): array { + $v['favicons']['background_color'] = $v['manifest']['theme_color']; + return $v; + }) + ->end() + ->children() + ->arrayNode('favicons') + ->canBeEnabled() + ->children() + ->scalarNode('src') + ->isRequired() + ->info('The source of the favicon. Shall be a SVG or large PNG.') + ->end() + ->scalarNode('background_color') + ->defaultNull() + ->info( + 'The background color of the application. If this value is not defined and that of the Manifest section is, the value of the latter will be used.' + ) + ->example(['red', '#f5ef06']) + ->end() + ->scalarNode('safari_pinned_tab_color') + ->defaultNull() + ->info('The color of the Safari pinned tab.') + ->example(['red', '#f5ef06']) + ->end() + ->scalarNode('tile_color') + ->defaultNull() + ->info('The color of the tile for Windows 8+.') + ->example(['red', '#f5ef06']) + ->end() + ->integerNode('border_radius') + ->defaultNull() + ->min(1) + ->max(50) + ->info('The border radius of the icon.') + ->end() + ->integerNode('image_scale') + ->defaultNull() + ->min(1) + ->max(100) + ->info('The scale of the icon.') + ->end() + ->booleanNode('generate_precomposed') + ->defaultFalse() + ->info('Generate precomposed icons. Useful for old iOS devices.') + ->end() + ->booleanNode('only_high_resolution') + ->defaultTrue() + ->info('Only high resolution icons.') + ->end() + ->booleanNode('only_tile_silhouette') + ->defaultTrue() + ->info('Only tile silhouette for Windows 8+.') + ->end() + ->end() + ->end() + ->end() + ->end(); +}; diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 919c933..fb9d830 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -10,12 +10,15 @@ use SpomkyLabs\PwaBundle\Command\CreateScreenshotCommand; use SpomkyLabs\PwaBundle\Command\ListCacheStrategiesCommand; use SpomkyLabs\PwaBundle\DataCollector\PwaCollector; +use SpomkyLabs\PwaBundle\Dto\Favicons; use SpomkyLabs\PwaBundle\Dto\Manifest; use SpomkyLabs\PwaBundle\Dto\ServiceWorker; use SpomkyLabs\PwaBundle\EventSubscriber\ScreenshotSubscriber; use SpomkyLabs\PwaBundle\ImageProcessor\GDImageProcessor; use SpomkyLabs\PwaBundle\ImageProcessor\ImagickImageProcessor; use SpomkyLabs\PwaBundle\MatchCallbackHandler\MatchCallbackHandlerInterface; +use SpomkyLabs\PwaBundle\Service\FaviconsBuilder; +use SpomkyLabs\PwaBundle\Service\FaviconsCompiler; use SpomkyLabs\PwaBundle\Service\FileCompilerInterface; use SpomkyLabs\PwaBundle\Service\ManifestBuilder; use SpomkyLabs\PwaBundle\Service\ManifestCompiler; @@ -56,6 +59,17 @@ ; $container->set(ManifestCompiler::class); + /*** Favicons ***/ + $container->set(FaviconsBuilder::class) + ->args([ + '$config' => param('spomky_labs_pwa.favicons.config'), + ]) + ; + $container->set(Favicons::class) + ->factory([service(FaviconsBuilder::class), 'create']) + ; + $container->set(FaviconsCompiler::class); + /*** Service Worker ***/ $container->set(ServiceWorkerBuilder::class) ->args([ diff --git a/src/Service/Data.php b/src/Service/Data.php index be7c577..59276a9 100644 --- a/src/Service/Data.php +++ b/src/Service/Data.php @@ -10,17 +10,17 @@ final readonly class Data { /** - * @param string[] $headers + * @param array $headers */ public function __construct( public string $url, public string $data, public array $headers - ){ + ) { } /** - * @param array $headers + * @param array $headers */ public static function create(string $url, string $data, array $headers = []): self { diff --git a/src/Service/FaviconsBuilder.php b/src/Service/FaviconsBuilder.php new file mode 100644 index 0000000..49f55f6 --- /dev/null +++ b/src/Service/FaviconsBuilder.php @@ -0,0 +1,34 @@ + $config + */ + public function __construct( + private readonly DenormalizerInterface $denormalizer, + private readonly array $config, + ) { + } + + public function create(): Favicons + { + if ($this->favicons === null) { + $result = $this->denormalizer->denormalize($this->config, Favicons::class); + assert($result instanceof Favicons); + $this->favicons = $result; + } + + return $this->favicons; + } +} diff --git a/src/Service/FaviconsCompiler.php b/src/Service/FaviconsCompiler.php new file mode 100644 index 0000000..13d139f --- /dev/null +++ b/src/Service/FaviconsCompiler.php @@ -0,0 +1,151 @@ + + */ + private null|array $files = null; + + public function __construct( + private readonly null|ImageProcessorInterface $imageProcessor, + private readonly Favicons $favicons, + private readonly AssetMapperInterface $assetMapper, + #[Autowire('%kernel.debug%')] + public readonly bool $debug, + ) { + } + + /** + * @return array + */ + public function getFiles(): array + { + if ($this->files !== null) { + return $this->files; + } + if ($this->imageProcessor === null || $this->favicons->enabled === false) { + return []; + } + $asset = $this->assetMapper->getAsset($this->favicons->src->src); + assert($asset !== null, 'The asset does not exist.'); + $this->files = [ + '/favicon.ico' => $this->processIcon($asset, '/favicon.ico', 16, 16, 'ico', 'image/x-icon'), + ]; + $sizes = [16, 32, 36, 48, 57, 60, 70, 72, 76, 96, 114, 120, 144, 150, 152, 180, 192, 194, 256, 310, 384, 512]; + foreach ($sizes as $size) { + $this->files[sprintf('/favicons/icon-%dx%d.png', $size, $size)] = $this->processIcon( + $asset, + sprintf('/favicons/icon-%dx%d.{hash}.png', $size, $size), + $size, + $size, + 'png', + 'image/png' + ); + } + if ($this->favicons->tileColor !== null) { + $this->files['/favicons/icon-310x150.png'] = $this->processIcon( + $asset, + '/favicons/icon-310x150.{hash}.png', + 310, + 150, + 'png', + 'image/png' + ); + $this->files['/favicons/browserconfig.xml'] = $this->processBrowserConfig(); + } + + return $this->files; + } + + private function processIcon( + MappedAsset $asset, + string $publicUrl, + int $width, + int $height, + string $format, + string $mimeType + ): Data { + $content = file_get_contents($asset->sourcePath); + assert($content !== false); + if ($this->debug === true) { + $hash = hash('xxh128', $content); + return Data::create( + str_replace(['{hash}', '.png'], [$hash, '.svg'], $publicUrl), + $content, + [ + 'Cache-Control' => 'public, max-age=604800, immutable', + 'Content-Type' => 'image/svg+xml', + 'X-Favicons-Dev' => true, + 'Etag' => $hash, + ] + ); + } + assert($this->imageProcessor !== null); + $data = $this->imageProcessor->process($content, $width, $height, $format); + return Data::create( + str_replace('{hash}', hash('xxh128', $data), $publicUrl), + $data, + [ + 'Cache-Control' => 'public, max-age=604800, immutable', + 'Content-Type' => $mimeType, + 'X-Favicons-Dev' => true, + 'Etag' => hash('xxh128', $data), + ] + ); + } + + private function processBrowserConfig(): Data + { + $icon310x150 = $this->files['/favicons/icon-310x150.png'] ?? null; + $icon70x70 = $this->files['/favicons/icon-70x70.png'] ?? null; + $icon150x150 = $this->files['/favicons/icon-150x150.png'] ?? null; + $icon310x310 = $this->files['/favicons/icon-310x310.png'] ?? null; + assert($icon310x150 !== null); + assert($icon70x70 !== null); + assert($icon150x150 !== null); + assert($icon310x310 !== null); + if ($this->favicons->tileColor === null) { + $tileColor = ''; + } else { + $tileColor = sprintf(PHP_EOL . ' %s', $this->favicons->tileColor); + } + + $content = << + + + + + + + {$tileColor} + + + +XML; + $hash = hash('xxh128', $content); + return Data::create( + sprintf('/favicons/browserconfig.%s.xml', $hash), + $content, + [ + 'Cache-Control' => 'public, max-age=604800, immutable', + 'Content-Type' => 'application/xml', + 'X-Favicons-Dev' => true, + 'Etag' => $hash, + ] + ); + } +} diff --git a/src/Service/FileCompilerInterface.php b/src/Service/FileCompilerInterface.php index 8bb7372..bd88c1b 100644 --- a/src/Service/FileCompilerInterface.php +++ b/src/Service/FileCompilerInterface.php @@ -7,9 +7,7 @@ interface FileCompilerInterface { /** - * @return array + * @return iterable */ - public function supportedPublicUrls(): array; - - public function get(string $publicUrl): null|Data; + public function getFiles(): iterable; } diff --git a/src/Service/ManifestCompiler.php b/src/Service/ManifestCompiler.php index 6eaa641..a088053 100644 --- a/src/Service/ManifestCompiler.php +++ b/src/Service/ManifestCompiler.php @@ -61,40 +61,21 @@ public function __construct( } /** - * @return array + * @return iterable */ - public function supportedPublicUrls(): array + public function getFiles(): iterable { if ($this->manifest->enabled === false) { return []; } if ($this->locales === []) { - return [$this->manifestPublicUrl]; - } - - return array_map( - fn (string $locale) => str_replace('{locale}', $locale, $this->manifestPublicUrl), - $this->locales - ); - } - - public function get(string $publicUrl): null|Data - { - if ($this->manifest->enabled === false) { - return null; - } - if ($this->locales === []) { - return $this->compileManifest(null); + yield $this->manifestPublicUrl => $this->compileManifest(null); } foreach ($this->locales as $locale) { - if ($publicUrl === str_replace('{locale}', $locale, $this->manifestPublicUrl)) { - return $this->compileManifest($locale); - } + yield str_replace('{locale}', $locale, $this->manifestPublicUrl) => $this->compileManifest($locale); } - - return null; } private function compileManifest(null|string $locale): Data diff --git a/src/Service/ServiceWorkerCompiler.php b/src/Service/ServiceWorkerCompiler.php index 9969270..38eb36b 100644 --- a/src/Service/ServiceWorkerCompiler.php +++ b/src/Service/ServiceWorkerCompiler.php @@ -47,24 +47,13 @@ public function __construct( } } - public function supportedPublicUrls(): array - { - return [$this->serviceWorkerPublicUrl, ...$this->listWorkboxFiles()]; - } - - public function get(string $publicUrl): null|Data + /** + * @return iterable + */ + public function getFiles(): iterable { - if ($publicUrl === $this->serviceWorkerPublicUrl) { - return $this->compileSW(); - } - if ($this->workboxPublicUrl === null) { - return null; - } - if (! str_starts_with($publicUrl, $this->workboxPublicUrl)) { - return null; - } - - return $this->getWorkboxFile($publicUrl); + yield $this->serviceWorkerPublicUrl => $this->compileSW(); + yield from $this->getWorkboxFiles(); } private function compileSW(): Data @@ -107,9 +96,9 @@ private function includeRootSW(): string } /** - * @return array + * @return iterable */ - private function listWorkboxFiles(): array + private function getWorkboxFiles(): iterable { if ($this->serviceWorker->workbox->enabled === false) { return []; @@ -120,7 +109,6 @@ private function listWorkboxFiles(): array $fileLocator = new FileLocator(__DIR__ . '/../Resources'); $resourcePath = $fileLocator->locate(sprintf('workbox-v%s', $this->workboxVersion)); - $publicUrls = []; $files = scandir($resourcePath); assert(is_array($files), 'Unable to list the files.'); foreach ($files as $file) { @@ -135,10 +123,13 @@ private function listWorkboxFiles(): array if (! is_file($path) || ! is_readable($path)) { continue; } - $publicUrls[] = sprintf('%s/%s', $this->workboxPublicUrl, $file); + $publicUrl = sprintf('%s/%s', $this->workboxPublicUrl, $file); + $data = $this->getWorkboxFile($publicUrl); + if ($data === null) { + continue; + } + yield $publicUrl => $data; } - - return $publicUrls; } private function getWorkboxFile(string $publicUrl): null|Data diff --git a/src/Subscriber/FileCompileEventListener.php b/src/Subscriber/FileCompileEventListener.php index 14cbec8..bef7d9c 100644 --- a/src/Subscriber/FileCompileEventListener.php +++ b/src/Subscriber/FileCompileEventListener.php @@ -28,11 +28,7 @@ public function __construct( public function __invoke(PreAssetsCompileEvent $event): void { foreach ($this->fileCompilers as $fileCompiler) { - foreach ($fileCompiler->supportedPublicUrls() as $publicUrl) { - $data = $fileCompiler->get($publicUrl); - if ($data === null) { - continue; - } + foreach ($fileCompiler->getFiles() as $data) { $this->assetsFilesystem->write($data->url, $data->data); } } diff --git a/src/Subscriber/PwaDevServerSubscriber.php b/src/Subscriber/PwaDevServerSubscriber.php index f5ea1d4..1c79acc 100644 --- a/src/Subscriber/PwaDevServerSubscriber.php +++ b/src/Subscriber/PwaDevServerSubscriber.php @@ -13,7 +13,6 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\HttpKernel\Profiler\Profiler; -use function in_array; final readonly class PwaDevServerSubscriber implements EventSubscriberInterface { @@ -36,10 +35,13 @@ public function onKernelRequest(RequestEvent $event): void $request = $event->getRequest(); $pathInfo = $request->getPathInfo(); foreach ($this->fileCompilers as $fileCompiler) { - if (in_array($pathInfo, $fileCompiler->supportedPublicUrls(), true)) { - $data = $fileCompiler->get($pathInfo); - assert($data !== null); + $files = iterator_to_array($fileCompiler->getFiles()); + foreach ($files as $data) { + if ($data->url !== $pathInfo) { + continue; + } $this->serveFile($event, $data); + return; } } } diff --git a/src/Twig/PwaRuntime.php b/src/Twig/PwaRuntime.php index 71d0f6d..1388ed9 100644 --- a/src/Twig/PwaRuntime.php +++ b/src/Twig/PwaRuntime.php @@ -5,8 +5,10 @@ namespace SpomkyLabs\PwaBundle\Twig; use InvalidArgumentException; +use SpomkyLabs\PwaBundle\Dto\Favicons; use SpomkyLabs\PwaBundle\Dto\Icon; use SpomkyLabs\PwaBundle\Dto\Manifest; +use SpomkyLabs\PwaBundle\Service\FaviconsCompiler; use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\AssetMapper\MappedAsset; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -22,6 +24,8 @@ public function __construct( private AssetMapperInterface $assetMapper, private Manifest $manifest, + private Favicons $favicons, + private FaviconsCompiler $faviconsCompiler, #[Autowire('%spomky_labs_pwa.manifest.public_url%')] string $manifestPublicUrl, ) { @@ -34,6 +38,7 @@ public function __construct( public function load( bool $injectThemeColor = true, bool $injectIcons = true, + bool $injectFavicons = true, bool $injectSW = true, array $swAttributes = [], null|string $locale = null, @@ -46,6 +51,7 @@ public function load( $output = $this->injectServiceWorker($output, $injectSW, $swAttributes); } $output = $this->injectIcons($output, $injectIcons); + $output = $this->injectFavicons($output, $injectFavicons); return $this->injectThemeColor($output, $injectThemeColor); } @@ -211,4 +217,46 @@ private function createAttributesString(array $attributes): string return $attributeString; } + + private function injectFavicons(string $output, bool $injectFavicons): string + { + if ($this->favicons->enabled === false || $injectFavicons === false) { + return $output; + } + + $files = $this->faviconsCompiler->getFiles(); + + $output .= PHP_EOL . ''; + foreach ([57, 60, 72, 76, 114, 120, 144, 152, 180] as $size) { + $output .= PHP_EOL . sprintf( + '', + $size, + $size, + $files[sprintf('/favicons/icon-%dx%d.png', $size, $size)]->url + ); + } + foreach ([16, 32, 48, 96, 192, 256, 384, 512] as $size) { + $output .= PHP_EOL . sprintf( + '', + $size, + $size, + $files[sprintf('/favicons/icon-%dx%d.png', $size, $size)]->url + ); + } + if ($this->favicons->tileColor !== null) { + $output .= PHP_EOL . sprintf( + '', + $files['/favicons/browserconfig.xml']->url + ); + $output .= PHP_EOL . sprintf( + '', + $this->favicons->tileColor + ); + } + + return $output . (PHP_EOL . sprintf( + '', + $files['/favicons/icon-144x144.png']->url + )); + } } diff --git a/tests/Functional/ConfigurationTest.php b/tests/Functional/ConfigurationTest.php new file mode 100644 index 0000000..ff10b01 --- /dev/null +++ b/tests/Functional/ConfigurationTest.php @@ -0,0 +1,325 @@ + $configuration + */ + #[Test] + #[DataProvider('dataConfigurationIsValid')] + public function configurationIsValid(array $configuration): void + { + $this->assertConfigurationIsValid($configuration); + } + + /** + * @return array[] + */ + public static function dataConfigurationIsValid(): iterable + { + yield 'No configuration values' => [[]]; + yield 'Empty configuration' => [[ + 'pwa' => null, + ]]; + yield 'Image processor is defined' => [[ + 'pwa' => [ + 'image_processor' => ImagickImageProcessor::class, + 'web_client' => 'id_web_client', + 'user_agent' => 'user-agent/1.0', + ], + ]]; + yield 'Favicons only' => [[ + 'pwa' => [ + 'favicons' => [ + 'enabled' => true, + 'src' => 'pwa/1920x1920.svg', + ], + ], + ]]; + yield 'Manifest only' => [[ + 'pwa' => [ + 'manifest' => [ + 'enabled' => true, + ], + ], + ]]; + yield 'Service Worker only' => [[ + 'pwa' => [ + 'serviceworker' => [ + 'enabled' => true, + 'src' => __DIR__ . '/sw.js', + ], + ], + ]]; + yield 'Complete configuration' => [[ + 'pwa' => [ + 'image_processor' => DummyImageProcessor::class, + 'favicons' => [ + 'enabled' => true, + 'src' => 'pwa/1920x1920.svg', + ], + 'manifest' => [ + 'enabled' => true, + 'background_color' => 'red', + 'categories' => ['pwa.categories.0', 'pwa.categories.1', 'pwa.categories.2'], + 'description' => 'pwa.description', + 'display' => 'standalone', + 'display_override' => ['fullscreen', 'minimal-ui'], + 'file_handlers' => [ + [ + 'action' => [ + 'path' => 'audio_file_handler', + 'params' => [ + 'param1' => 'audio', + ], + ], + 'accept' => [ + 'audio/wav' => ['.wav'], + 'audio/x-wav' => ['.wav'], + 'audio/mpeg' => ['.mp3'], + 'audio/mp4' => ['.mp4'], + 'audio/aac' => ['.adts'], + 'audio/ogg' => ['.ogg'], + 'application/ogg' => ['.ogg'], + 'audio/webm' => ['.webm'], + 'audio/flac' => ['.flac'], + 'audio/mid' => ['.rmi', '.mid'], + ], + ], + ], + 'icons' => [ + [ + 'src' => 'pwa/1920x1920.svg', + 'sizes' => [48, 72, 96, 128, 256], + 'type' => 'webp', + ], + [ + 'src' => 'pwa/1920x1920.svg', + 'sizes' => [48, 72, 96, 128, 256], + 'type' => 'png', + 'purpose' => 'maskable', + ], + [ + 'src' => 'pwa/1920x1920.svg', + 'sizes' => [0], + ], + ], + 'id' => '/?homescreen=1', + 'launch_handler' => [ + 'client_mode' => ['focus-existing', 'auto'], + ], + 'orientation' => 'portrait-primary', + 'prefer_related_applications' => true, + 'dir' => 'rtl', + 'lang' => 'ar', + 'name' => 'pwa.name', + 'short_name' => 'pwa.short_name', + 'protocol_handlers' => [ + [ + 'protocol' => 'web+jngl', + 'url' => '/lookup?type=%s', + ], + [ + 'protocol' => 'web+jnglstore', + 'url' => '/shop?for=%s', + ], + ], + 'related_applications' => [ + [ + 'platform' => 'play', + 'url' => 'https://play.google.com/store/apps/details?id=com.example.app1', + 'id' => 'com.example.app1', + ], + [ + 'platform' => 'itunes', + 'url' => 'https://itunes.apple.com/app/example-app1/id123456789', + ], + [ + 'platform' => 'windows', + 'url' => 'https://apps.microsoft.com/store/detail/example-app1/id123456789', + ], + ], + 'scope' => '/', + 'start_url' => 'pwa.start_url', + 'theme_color' => 'red', + 'screenshots' => [ + [ + 'src' => 'pwa/screenshots/360x800.svg', + 'label' => 'pwa.screenshots.0', + ], + ], + 'share_target' => [ + 'action' => [ + 'path' => 'shared_content_receiver', + 'params' => [ + 'param1' => 'value1', + 'param2' => 'value2', + ], + ], + 'method' => 'GET', + 'params' => [ + 'title' => 'name', + 'text' => 'description', + 'url' => 'link', + ], + ], + 'shortcuts' => [ + [ + 'name' => "Today's agenda", + 'url' => [ + 'path' => 'agenda', + 'params' => [ + 'date' => 'today', + ], + ], + 'description' => 'List of events planned for today', + ], + [ + 'name' => 'New event', + 'url' => '/create/event', + ], + [ + 'name' => 'New reminder', + 'url' => '/create/reminder', + 'icons' => [ + 'pwa/1920x1920.svg', + [ + 'src' => 'pwa/1920x1920.svg', + 'purpose' => 'maskable', + ], + ], + ], + ], + 'edge_side_panel' => [ + 'preferred_width' => 480, + ], + 'iarc_rating_id' => '123456', + 'scope_extensions' => [ + [ + 'origin' => '*.foo.com', + ], + [ + 'origin' => 'https://*.bar.com', + ], + [ + 'origin' => 'https://*.baz.com', + ], + ], + 'widgets' => [ + [ + 'name' => 'PWAmp mini player', + 'description' => 'widget to control the PWAmp music player', + 'tag' => 'pwamp', + 'template' => 'pwamp-template', + 'ms_ac_template' => 'app_widget_template', + 'data' => 'app_widget_data', + 'type' => 'application/json', + 'screenshots' => [ + [ + 'src' => 'pwa/1920x1920.svg', + 'label' => 'The PWAmp mini-player widget', + ], + ], + 'icons' => [ + [ + 'src' => 'pwa/1920x1920.svg', + 'sizes' => [16, 48], + 'type' => 'webp', + ], + ], + 'auth' => false, + 'update' => 86400, + ], + ], + 'handle_links' => 'auto', + ], + 'serviceworker' => [ + 'enabled' => true, + 'src' => __DIR__ . '/sw.js', + 'scope' => '/', + 'use_cache' => true, + 'workbox' => [ + 'resource_caches' => [ + [ + 'match_callback' => 'regex:.*', + 'strategy' => 'StaleWhileRevalidate', + 'cache_name' => 'page-cache', + 'broadcast' => true, + 'preload_urls' => ['privacy_policy', 'terms_of_service', '@static-pages', '@widgets'], + ], + ], + 'offline_fallback' => [ + 'page' => '/offline.html', + ], + ], + ], + ], + ]]; + } + + /** + * @param array $configuration + */ + #[Test] + #[DataProvider('dataConfigurationIsInvalid')] + public function configurationIsInvalid(array $configuration, string $message): void + { + $this->assertConfigurationIsInvalid($configuration, $message); + } + + /** + * @return array[] + */ + public static function dataConfigurationIsInvalid(): iterable + { + yield 'No configuration values' => [ + [ + 'pwa' => [ + 'favicons' => 10, + ], + ], + 'Invalid type for path "pwa.favicons". Expected "array", but got "int"', + ]; + yield 'No favicon source' => [ + [ + 'pwa' => [ + 'favicons' => [ + 'enabled' => true, + ], + ], + ], + 'The child config "src" under "pwa.favicons" must be configured: The source of the favicon. Shall be a SVG or large PNG.', + ]; + yield 'No service worker source' => [ + [ + 'pwa' => [ + 'serviceworker' => [ + 'enabled' => true, + ], + ], + ], + 'The child config "src" under "pwa.serviceworker" must be configured: The path to the service worker source file. Can be served by Asset Mapper.', + ]; + } + + protected function getConfiguration(): ConfigurationInterface + { + return new Configuration(new SpomkyLabsPwaBundle(), null, 'pwa'); + } +} diff --git a/tests/config.php b/tests/config.php index b811592..e562821 100644 --- a/tests/config.php +++ b/tests/config.php @@ -55,6 +55,10 @@ ]); $container->extension('pwa', [ 'image_processor' => DummyImageProcessor::class, + 'favicons' => [ + 'enabled' => true, + 'src' => 'pwa/1920x1920.svg', + ], 'manifest' => [ 'enabled' => true, 'background_color' => 'red',