From 0504a55a417bec10c2576445062a4fc7f7dfe75a Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Mon, 15 Jan 2024 12:47:22 +0100 Subject: [PATCH] Leverage on Asset Mapper (#33) DTOs, Denormalizer and AssetMapper --- composer.json | 10 +- rector.php | 3 + src/Command/GenerateIconsCommand.php | 84 ++++++++++ src/Command/GenerateManifestCommand.php | 74 --------- src/Command/GenerateServiceWorkerCommand.php | 31 +--- .../ActionsSectionProcessor.php | 40 ----- .../ApplicationIconsSectionProcessor.php | 59 ------- ...ApplicationScreenshotsSectionProcessor.php | 73 --------- .../SectionProcessor/FileProcessorTrait.php | 72 -------- .../IconsSectionProcessorTrait.php | 62 ------- .../ImageSectionProcessorTrait.php | 55 ------- .../ScreenshotsProcessorTrait.php | 154 ------------------ .../SectionProcessor/SectionProcessor.php | 17 -- .../ServiceWorkerSectionProcessor.php | 20 --- .../ShortcutsSectionProcessor.php | 75 --------- .../Windows10WidgetsSectionProcessor.php | 90 ---------- src/Command/TakeScreenshotCommand.php | 81 +++++++++ src/DependencyInjection/Configuration.php | 121 +++++++------- .../SpomkyLabsPwaExtension.php | 17 +- src/Dto/EdgeSidePanel.php | 13 ++ src/Dto/File.php | 12 ++ src/Dto/FileHandler.php | 23 +++ src/Dto/Icon.php | 32 ++++ src/Dto/LaunchHandler.php | 16 ++ src/Dto/Manifest.php | 111 +++++++++++++ src/Dto/ProtocolHandler.php | 20 +++ src/Dto/RelatedApplication.php | 14 ++ src/Dto/ScopeExtension.php | 10 ++ src/Dto/Screenshot.php | 25 +++ src/Dto/ServiceWorker.php | 17 ++ src/Dto/ShareTarget.php | 24 +++ src/Dto/ShareTargetParameters.php | 19 +++ src/Dto/Shortcut.php | 30 ++++ src/Dto/Widget.php | 44 +++++ src/ImageProcessor/GDImageProcessor.php | 7 +- src/ImageProcessor/ImagickImageProcessor.php | 4 +- src/Normalizer/FileHandlerNormalizer.php | 51 ++++++ src/Normalizer/IconNormalizer.php | 58 +++++++ src/Normalizer/ProtocolHandlerNormalizer.php | 51 ++++++ src/Normalizer/ScreenshotNormalizer.php | 65 ++++++++ src/Normalizer/ServiceWorkerNormalizer.php | 57 +++++++ src/Normalizer/ShareTargetNormalizer.php | 59 +++++++ src/Normalizer/ShortcutNormalizer.php | 60 +++++++ src/Resources/config/services.php | 40 ++--- src/Service/Builder.php | 34 ++++ src/Twig/PwaExtension.php | 20 +++ src/Twig/PwaRuntime.php | 30 ++++ tests/AppKernel.php | 3 + tests/Controller/DummyController.php | 29 ++++ tests/DummyImageProcessor.php | 3 + tests/Functional/AbstractPwaTestCase.php | 36 ++++ tests/Functional/CommandTest.php | 115 ------------- tests/Functional/GenerateIconsCommandTest.php | 36 ++++ tests/Functional/ServiceWorkerCommandTest.php | 35 ++++ .../Functional/TakeScreenshotCommandTest.php | 35 ++++ tests/config.php | 58 +++++-- tests/routes.php | 15 ++ 57 files changed, 1395 insertions(+), 1054 deletions(-) create mode 100644 src/Command/GenerateIconsCommand.php delete mode 100644 src/Command/GenerateManifestCommand.php delete mode 100644 src/Command/SectionProcessor/ActionsSectionProcessor.php delete mode 100644 src/Command/SectionProcessor/ApplicationIconsSectionProcessor.php delete mode 100644 src/Command/SectionProcessor/ApplicationScreenshotsSectionProcessor.php delete mode 100644 src/Command/SectionProcessor/FileProcessorTrait.php delete mode 100644 src/Command/SectionProcessor/IconsSectionProcessorTrait.php delete mode 100644 src/Command/SectionProcessor/ImageSectionProcessorTrait.php delete mode 100644 src/Command/SectionProcessor/ScreenshotsProcessorTrait.php delete mode 100644 src/Command/SectionProcessor/SectionProcessor.php delete mode 100644 src/Command/SectionProcessor/ServiceWorkerSectionProcessor.php delete mode 100644 src/Command/SectionProcessor/ShortcutsSectionProcessor.php delete mode 100644 src/Command/SectionProcessor/Windows10WidgetsSectionProcessor.php create mode 100644 src/Command/TakeScreenshotCommand.php create mode 100644 src/Dto/EdgeSidePanel.php create mode 100644 src/Dto/File.php create mode 100644 src/Dto/FileHandler.php create mode 100644 src/Dto/Icon.php create mode 100644 src/Dto/LaunchHandler.php create mode 100644 src/Dto/Manifest.php create mode 100644 src/Dto/ProtocolHandler.php create mode 100644 src/Dto/RelatedApplication.php create mode 100644 src/Dto/ScopeExtension.php create mode 100644 src/Dto/Screenshot.php create mode 100644 src/Dto/ServiceWorker.php create mode 100644 src/Dto/ShareTarget.php create mode 100644 src/Dto/ShareTargetParameters.php create mode 100644 src/Dto/Shortcut.php create mode 100644 src/Dto/Widget.php create mode 100644 src/Normalizer/FileHandlerNormalizer.php create mode 100644 src/Normalizer/IconNormalizer.php create mode 100644 src/Normalizer/ProtocolHandlerNormalizer.php create mode 100644 src/Normalizer/ScreenshotNormalizer.php create mode 100644 src/Normalizer/ServiceWorkerNormalizer.php create mode 100644 src/Normalizer/ShareTargetNormalizer.php create mode 100644 src/Normalizer/ShortcutNormalizer.php create mode 100644 src/Service/Builder.php create mode 100644 src/Twig/PwaExtension.php create mode 100644 src/Twig/PwaRuntime.php create mode 100644 tests/Controller/DummyController.php create mode 100644 tests/Functional/AbstractPwaTestCase.php delete mode 100644 tests/Functional/CommandTest.php create mode 100644 tests/Functional/GenerateIconsCommandTest.php create mode 100644 tests/Functional/ServiceWorkerCommandTest.php create mode 100644 tests/Functional/TakeScreenshotCommandTest.php create mode 100644 tests/routes.php diff --git a/composer.json b/composer.json index f9259c7..25535d4 100644 --- a/composer.json +++ b/composer.json @@ -26,15 +26,22 @@ }, "require": { "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3", + "symfony/asset-mapper": "^6.4|^7.0", "symfony/config": "^6.4|^7.0", "symfony/dependency-injection": "^6.4|^7.0", "symfony/filesystem": "^6.4|^7.0", "symfony/finder": "^6.4|^7.0", "symfony/http-kernel": "^6.4|^7.0", "symfony/mime": "^6.4|^7.0", - "symfony/routing": "^6.4|^7.0" + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^7.0", + "symfony/routing": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "twig/twig": "^3.8" }, "require-dev": { + "dbrekelmans/bdi": "^1.1", "infection/infection": "^0.27", "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "^1.0", @@ -47,6 +54,7 @@ "symfony/framework-bundle": "^6.4|^7.0", "symfony/panther": "^2.1", "symfony/phpunit-bridge": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0", "symplify/easy-coding-standard": "^12.0" }, "config": { diff --git a/rector.php b/rector.php index 6dd23d5..82ecc9f 100644 --- a/rector.php +++ b/rector.php @@ -28,6 +28,9 @@ ]); $config->phpVersion(PhpVersion::PHP_82); $config->paths([__DIR__ . '/src', __DIR__ . '/tests']); + $config->skip([ + __DIR__ . '/tests/Controller/DummyController.php', + ]); $config->parallel(); $config->importNames(); $config->importShortClasses(); diff --git a/src/Command/GenerateIconsCommand.php b/src/Command/GenerateIconsCommand.php new file mode 100644 index 0000000..3130c3e --- /dev/null +++ b/src/Command/GenerateIconsCommand.php @@ -0,0 +1,84 @@ +addArgument('source', InputArgument::REQUIRED, 'The source image'); + $this->addArgument('output', InputArgument::REQUIRED, 'The output directory'); + $this->addOption('format', null, InputOption::VALUE_OPTIONAL, 'The format of the icons'); + $this->addArgument( + 'sizes', + InputArgument::OPTIONAL | InputArgument::IS_ARRAY, + 'The sizes of the icons', + ['192', '512'] + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('PWA Icons Generator'); + + $source = $input->getArgument('source'); + $dest = $input->getArgument('output'); + $format = $input->getOption('format'); + $sizes = $input->getArgument('sizes'); + + if (! $this->filesystem->exists($source)) { + $io->info('The source image does not exist.'); + return self::FAILURE; + } + + if (! $this->filesystem->exists($dest)) { + $io->info('The output directory does not exist. It will be created.'); + $this->filesystem->mkdir($dest); + } + + $mime = MimeTypes::getDefault(); + if ($format === null) { + $mimeType = $mime->guessMimeType($source); + $extensions = $mime->getExtensions($mimeType); + if (count($extensions) === 0) { + $io->error(sprintf('Unable to guess the extension for the mime type "%s".', $mimeType)); + return self::FAILURE; + } + $format = current($extensions); + } + + foreach ($sizes as $size) { + $io->info('Generating icon ' . $size . 'x' . $size . '...'); + $tmp = $this->imageProcessor->process(file_get_contents($source), (int) $size, (int) $size, $format); + $filename = sprintf('%s/icon-%sx%s.%s', $dest, $size, $size, $format); + $this->filesystem->dumpFile($filename, $tmp); + $io->info('Icon ' . $size . 'x' . $size . ' generated.'); + } + $io->info('Done.'); + + return self::SUCCESS; + } +} diff --git a/src/Command/GenerateManifestCommand.php b/src/Command/GenerateManifestCommand.php deleted file mode 100644 index 2724c90..0000000 --- a/src/Command/GenerateManifestCommand.php +++ /dev/null @@ -1,74 +0,0 @@ - $processors - */ - public function __construct( - #[TaggedIterator('pwa.section-processor')] - private readonly iterable $processors, - #[Autowire('%spomky_labs_pwa.config%')] - private readonly array $config, - #[Autowire('%spomky_labs_pwa.dest%')] - private readonly array $dest, - private readonly Filesystem $filesystem, - ) { - parent::__construct(); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - $io->title('PWA Manifest Generator'); - $manifest = $this->config; - $manifest = array_filter($manifest, static fn ($value) => ($value !== null && $value !== [])); - - foreach ($this->processors as $processor) { - $result = $processor->process($io, $this->config, $manifest); - if (is_int($result)) { - return $result; - } - $manifest = $result; - } - - try { - if (! $this->filesystem->exists(dirname($this->dest['manifest_filepath']))) { - $this->filesystem->mkdir(dirname($this->dest['manifest_filepath'])); - } - file_put_contents( - (string) $this->dest['manifest_filepath'], - json_encode( - $manifest, - JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR - ) - ); - } catch (JsonException $exception) { - $io->error(sprintf('Unable to generate the manifest file: %s', $exception->getMessage())); - return self::FAILURE; - } - - return self::SUCCESS; - } -} diff --git a/src/Command/GenerateServiceWorkerCommand.php b/src/Command/GenerateServiceWorkerCommand.php index e0dfa61..13e3643 100644 --- a/src/Command/GenerateServiceWorkerCommand.php +++ b/src/Command/GenerateServiceWorkerCommand.php @@ -6,22 +6,19 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpKernel\Config\FileLocator; use function count; -use function dirname; #[AsCommand(name: 'pwa:sw', description: 'Generate a basic Service Worker')] final class GenerateServiceWorkerCommand extends Command { public function __construct( - #[Autowire('%spomky_labs_pwa.config%')] - private readonly array $config, private readonly Filesystem $filesystem, private readonly FileLocator $fileLocator, ) { @@ -30,6 +27,7 @@ public function __construct( protected function configure(): void { + $this->addArgument('output', InputArgument::REQUIRED, 'The output file'); $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Force the generation of the service worker'); } @@ -38,20 +36,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); $io->title('PWA Service Worker Generator'); - if (! isset($this->config['serviceworker'])) { - $io->info('Service worker section is missing.'); - return self::SUCCESS; - } - + $dest = $input->getArgument('output'); $force = $input->getOption('force'); - $dest = $this->config['serviceworker']['filepath']; - $scope = $this->config['serviceworker']['scope']; - $src = $this->config['serviceworker']['src']; - - if (! $this->filesystem->exists(dirname((string) $dest))) { - $this->filesystem->mkdir(dirname((string) $dest)); - } if ($this->filesystem->exists($dest) && ! $force) { $io->info('Service worker already exists. Skipping.'); return self::SUCCESS; @@ -65,18 +52,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $resourcePath = $resourcePath[0]; $this->filesystem->copy($resourcePath, $dest); $io->info('Service worker generated.'); - $io->comment('You can now configure your web server to serve the service worker file.'); - $io->section('# assets/app.js (or any other entrypoint)'); - $jsTemplate = << { - navigator.serviceWorker.register("{$src}", {scope: '{$scope}'}); - }) - } - JS; - - $io->writeln($jsTemplate); - $io->section('# End of file'); return self::SUCCESS; } diff --git a/src/Command/SectionProcessor/ActionsSectionProcessor.php b/src/Command/SectionProcessor/ActionsSectionProcessor.php deleted file mode 100644 index 9019bb7..0000000 --- a/src/Command/SectionProcessor/ActionsSectionProcessor.php +++ /dev/null @@ -1,40 +0,0 @@ - $handler) { - if (str_starts_with((string) $handler['action'], '/')) { - continue; - } - if ($this->router === null) { - $io->error('The router is not available. Unable to generate the file handler action URL.'); - return Command::FAILURE; - } - $manifest['file_handlers'][$id]['action'] = $this->router->generate( - $handler['action'], - [], - RouterInterface::RELATIVE_PATH - ); - } - - return $manifest; - } -} diff --git a/src/Command/SectionProcessor/ApplicationIconsSectionProcessor.php b/src/Command/SectionProcessor/ApplicationIconsSectionProcessor.php deleted file mode 100644 index 887bab7..0000000 --- a/src/Command/SectionProcessor/ApplicationIconsSectionProcessor.php +++ /dev/null @@ -1,59 +0,0 @@ -processIcons($io, $config['icons']); - if (is_int($result)) { - return $result; - } - $manifest['icons'] = $result; - $io->info('Icons are built'); - - return $manifest; - } - - protected function getFilesystem(): Filesystem - { - return $this->filesystem; - } - - protected function getIconPrefixUrl(): string - { - return $this->dest['icon_prefix_url']; - } - - protected function getIconFolder(): string - { - return $this->dest['icon_folder']; - } - - protected function getImageProcessor(): ?ImageProcessor - { - return $this->imageProcessor; - } -} diff --git a/src/Command/SectionProcessor/ApplicationScreenshotsSectionProcessor.php b/src/Command/SectionProcessor/ApplicationScreenshotsSectionProcessor.php deleted file mode 100644 index 8c92c74..0000000 --- a/src/Command/SectionProcessor/ApplicationScreenshotsSectionProcessor.php +++ /dev/null @@ -1,73 +0,0 @@ -webClient = $webClient; - } - - public function process(SymfonyStyle $io, array $config, array $manifest): array|int - { - if ($config['screenshots'] === []) { - return $manifest; - } - $result = $this->processScreenshots($io, $config['screenshots']); - if (is_int($result)) { - return $result; - } - $manifest['screenshots'] = $result; - - return $manifest; - } - - protected function getFilesystem(): Filesystem - { - return $this->filesystem; - } - - protected function getImageProcessor(): ?ImageProcessor - { - return $this->imageProcessor; - } - - protected function getWebClient(): ?Client - { - return $this->webClient; - } - - protected function getScreenshotPrefixUrl(): string - { - return $this->dest['screenshot_prefix_url']; - } - - protected function getScreenshotFolder(): string - { - return $this->dest['screenshot_folder']; - } -} diff --git a/src/Command/SectionProcessor/FileProcessorTrait.php b/src/Command/SectionProcessor/FileProcessorTrait.php deleted file mode 100644 index 9d97159..0000000 --- a/src/Command/SectionProcessor/FileProcessorTrait.php +++ /dev/null @@ -1,72 +0,0 @@ -mime)) { - $this->mime = MimeTypes::getDefault(); - } - return $this->mime; - } - - protected function createDirectoryIfNotExists(string $folder): bool - { - try { - if (! $this->getFilesystem()->exists($folder)) { - $this->getFilesystem() - ->mkdir($folder); - } - } catch (IOExceptionInterface) { - return false; - } - - return true; - } - - /** - * @param array $components - * @return array{src: string, type: string} - */ - protected function storeFile(string $data, string $prefixUrl, string $storageFolder, array $components): array - { - $tempFilename = $this->getFilesystem() - ->tempnam($storageFolder, 'pwa-'); - $hash = mb_substr(hash('sha256', $data), 0, 8); - file_put_contents($tempFilename, $data); - $mime = $this->getMime() - ->guessMimeType($tempFilename); - $extension = $this->getMime() - ->getExtensions($mime); - - if (empty($extension)) { - throw new RuntimeException(sprintf('Unable to guess the extension for the mime type "%s"', $mime)); - } - - $components[] = $hash; - $filename = sprintf('%s.%s', implode('-', $components), $extension[0]); - $localFilename = sprintf('%s/%s', rtrim($storageFolder, '/'), $filename); - - file_put_contents($localFilename, $data); - $this->getFilesystem() - ->remove($tempFilename); - - return [ - 'src' => sprintf('%s/%s', $prefixUrl, $filename), - 'type' => $mime, - ]; - } -} diff --git a/src/Command/SectionProcessor/IconsSectionProcessorTrait.php b/src/Command/SectionProcessor/IconsSectionProcessorTrait.php deleted file mode 100644 index c330eff..0000000 --- a/src/Command/SectionProcessor/IconsSectionProcessorTrait.php +++ /dev/null @@ -1,62 +0,0 @@ -, format: ?string, purpose: ?string} $icons - */ - protected function processIcons(SymfonyStyle $io, array $icons): array|int - { - if (! $this->createDirectoryIfNotExists($this->getIconFolder()) || ! $this->checkImageProcessor($io)) { - return Command::FAILURE; - } - $result = []; - foreach ($icons as $icon) { - foreach ($icon['sizes'] as $size) { - if (! is_int($size) || $size < 0) { - $io->error('The icon size must be a positive integer'); - return Command::FAILURE; - } - $data = $this->loadFileAndConvert($icon['src'], $size, $icon['format'] ?? null); - if ($data === null) { - $io->error(sprintf('Unable to read the icon "%s"', $icon['src'])); - return Command::FAILURE; - } - - $iconManifest = $this->storeIcon($data, $size, $icon['purpose'] ?? null); - $result[] = $iconManifest; - } - } - - return $result; - } - - /** - * @return array{src: string, sizes: string, type: string, purpose: ?string} - */ - private function storeIcon(string $data, int $size, ?string $purpose): array - { - $fileData = $this->storeFile( - $data, - $this->getIconPrefixUrl(), - $this->getIconFolder(), - ['icon', $purpose, $size === 0 ? 'any' : $size . 'x' . $size] - ); - - return $this->handleSizeAndPurpose($purpose, $size, $fileData); - } -} diff --git a/src/Command/SectionProcessor/ImageSectionProcessorTrait.php b/src/Command/SectionProcessor/ImageSectionProcessorTrait.php deleted file mode 100644 index b35e470..0000000 --- a/src/Command/SectionProcessor/ImageSectionProcessorTrait.php +++ /dev/null @@ -1,55 +0,0 @@ - $sizes, - ]; - - if ($purpose !== null) { - $fileData += [ - 'purpose' => $purpose, - ]; - } - - return $fileData; - } - - protected function loadFileAndConvert(string $src, ?int $size, ?string $format): ?string - { - $data = file_get_contents($src); - if ($data === false) { - return null; - } - if ($size !== 0 && $size !== null) { - $data = $this->getImageProcessor() - ->process($data, $size, $size, $format); - } - - return $data; - } - - protected function checkImageProcessor(SymfonyStyle $io): bool - { - if ($this->getImageProcessor() === null) { - $io->error('Image processor not found'); - return false; - } - - return true; - } -} diff --git a/src/Command/SectionProcessor/ScreenshotsProcessorTrait.php b/src/Command/SectionProcessor/ScreenshotsProcessorTrait.php deleted file mode 100644 index 781856d..0000000 --- a/src/Command/SectionProcessor/ScreenshotsProcessorTrait.php +++ /dev/null @@ -1,154 +0,0 @@ -createDirectoryIfNotExists($this->getScreenshotFolder()) || ! $this->checkImageProcessor( - $io - )) { - return Command::FAILURE; - } - $config = []; - foreach ($screenshots as $screenshot) { - if (isset($screenshot['src'])) { - $src = $screenshot['src']; - if (! $this->getFilesystem()->exists($src)) { - continue; - } - foreach ($this->findImages($src) as $image) { - $data = $screenshot; - $data['src'] = $image; - $config[] = $data; - } - } - if (isset($screenshot['path'])) { - if ($this->getWebClient() === null) { - $io->error( - 'The web client is not available. Unable to take a screenshot. Please install "symfony/panther" and a web driver.' - ); - return Command::FAILURE; - } - $path = $screenshot['path']; - $height = $screenshot['height']; - $width = $screenshot['width']; - unset($screenshot['path'], $screenshot['height'], $screenshot['width']); - - $client = clone $this->getWebClient(); - $client->request('GET', $path); - $tmpName = $this->getFilesystem() - ->tempnam('', 'pwa-'); - $client->manage() - ->window() - ->setSize(new WebDriverDimension($width, $height)); - $client->manage() - ->window() - ->fullscreen(); - $client->takeScreenshot($tmpName); - $data = $screenshot; - $data['src'] = $tmpName; - $data['delete'] = true; - $config[] = $data; - } - } - - $result = []; - foreach ($config as $screenshot) { - $data = $this->loadFileAndConvert($screenshot['src'], null, $screenshot['format'] ?? null); - if ($data === null) { - $io->error(sprintf('Unable to read the icon "%s"', $screenshot['src'])); - return Command::FAILURE; - } - $delete = $screenshot['delete'] ?? false; - unset($screenshot['delete']); - $screenshotManifest = $this->storeScreenshot( - $data, - $screenshot['format'] ?? null, - $screenshot['form_factor'] ?? null - ); - if (isset($screenshot['label'])) { - $screenshotManifest['label'] = $screenshot['label']; - } - if (isset($screenshot['platform'])) { - $screenshotManifest['platform'] = $screenshot['platform']; - } - $result[] = $screenshotManifest; - if ($delete) { - $this->getFilesystem() - ->remove($screenshot['src']); - } - } - - return $result; - } - - /** - * @return array{src: string, type: string, sizes: string, form_factor: ?string} - */ - private function storeScreenshot(string $data, ?string $format, ?string $formFactor): array - { - if ($format !== null) { - $data = $this->getImageProcessor() - ->process($data, null, null, $format); - } - - ['width' => $width, 'height' => $height] = $this->getImageProcessor()->getSizes($data); - $size = sprintf('%sx%s', $width, $height); - - $fileData = $this->storeFile( - $data, - $this->getScreenshotPrefixUrl(), - $this->getScreenshotFolder(), - ['screenshot', $formFactor, $size] - ); - if ($formFactor !== null) { - $fileData += [ - 'form_factor' => $formFactor, - ]; - } - - return $fileData + [ - 'sizes' => $size, - ]; - } - - /** - * @return iterable - */ - private function findImages(string $src): iterable - { - $finder = new Finder(); - if (is_file($src)) { - yield $src; - return; - } - $files = $finder->in($src) - ->files() - ->name('/\.(png|jpg|jpeg|gif|webp|svg)$/i'); - foreach ($files as $file) { - if ($file->isFile()) { - yield $file->getRealPath(); - } else { - yield from $this->findImages($file->getRealPath()); - } - } - } -} diff --git a/src/Command/SectionProcessor/SectionProcessor.php b/src/Command/SectionProcessor/SectionProcessor.php deleted file mode 100644 index 8d00f3f..0000000 --- a/src/Command/SectionProcessor/SectionProcessor.php +++ /dev/null @@ -1,17 +0,0 @@ -router === null) { - $io->error('The router is not available'); - return Command::FAILURE; - } - $shortcut['url'] = $this->router->generate($shortcut['url'], [], RouterInterface::RELATIVE_PATH); - } - - if (isset($shortcutConfig['icons'])) { - $shortcut['icons'] = $this->processIcons($io, $shortcutConfig['icons']); - } - $manifest['shortcuts'][] = $shortcut; - } - $manifest['shortcuts'] = array_values($manifest['shortcuts']); - - return $manifest; - } - - protected function getFilesystem(): Filesystem - { - return $this->filesystem; - } - - protected function getIconPrefixUrl(): string - { - return $this->dest['icon_prefix_url']; - } - - protected function getIconFolder(): string - { - return $this->dest['icon_folder']; - } - - protected function getImageProcessor(): ?ImageProcessor - { - return $this->imageProcessor; - } -} diff --git a/src/Command/SectionProcessor/Windows10WidgetsSectionProcessor.php b/src/Command/SectionProcessor/Windows10WidgetsSectionProcessor.php deleted file mode 100644 index a516dbc..0000000 --- a/src/Command/SectionProcessor/Windows10WidgetsSectionProcessor.php +++ /dev/null @@ -1,90 +0,0 @@ -webClient = $webClient; - } - - public function process(SymfonyStyle $io, array $config, array $manifest): array|int - { - if ($config['widgets'] === []) { - return $manifest; - } - $manifest['widgets'] = []; - foreach ($config['widgets'] as $widget) { - if (isset($widget['icons'])) { - $widget['icons'] = $this->processIcons($io, $widget['icons']); - } - if (isset($widget['screenshots'])) { - $widget['screenshots'] = $this->processScreenshots($io, $widget['screenshots']); - } - $manifest['widgets'][] = $widget; - } - - return $manifest; - } - - protected function getFilesystem(): Filesystem - { - return $this->filesystem; - } - - protected function getIconPrefixUrl(): string - { - return $this->dest['icon_prefix_url']; - } - - protected function getIconFolder(): string - { - return $this->dest['icon_folder']; - } - - protected function getImageProcessor(): ?ImageProcessor - { - return $this->imageProcessor; - } - - protected function getWebClient(): ?Client - { - return $this->webClient; - } - - protected function getScreenshotPrefixUrl(): string - { - return $this->dest['screenshot_prefix_url']; - } - - protected function getScreenshotFolder(): string - { - return $this->dest['screenshot_folder']; - } -} diff --git a/src/Command/TakeScreenshotCommand.php b/src/Command/TakeScreenshotCommand.php new file mode 100644 index 0000000..38053ca --- /dev/null +++ b/src/Command/TakeScreenshotCommand.php @@ -0,0 +1,81 @@ +webClient = $webClient; + } + + public function isEnabled(): bool + { + return class_exists(Client::class) && class_exists(WebDriverDimension::class); + } + + protected function configure(): void + { + $this->addArgument('url', InputArgument::REQUIRED, 'The URL to take a screenshot from'); + $this->addArgument('output', InputArgument::REQUIRED, 'The output file'); + $this->addOption('width', null, InputOption::VALUE_OPTIONAL, 'The width of the screenshot'); + $this->addOption('height', null, InputOption::VALUE_OPTIONAL, 'The height of the screenshot'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('PWA - Take a screenshot'); + + $url = $input->getArgument('url'); + $height = $input->getOption('height'); + $width = $input->getOption('width'); + + $client = clone $this->webClient; + $client->request('GET', $url); + $tmpName = $this->filesystem + ->tempnam('', 'pwa-'); + if ($width !== null && $height !== null) { + $client->manage() + ->window() + ->setSize(new WebDriverDimension((int) $width, (int) $height)); + } + $client->manage() + ->window() + ->fullscreen(); + $client->takeScreenshot($tmpName); + + $this->filesystem->copy($tmpName, $input->getArgument('output'), true); + $this->filesystem->remove($tmpName); + $io->success('Screenshot saved'); + + return self::SUCCESS; + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 0a4dd77..f38342e 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -8,6 +8,7 @@ use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use function assert; final readonly class Configuration implements ConfigurationInterface @@ -67,6 +68,13 @@ private function setupShortcuts(ArrayNodeDefinition $node): void ->info('The URL of the shortcut.') ->example('https://example.com') ->end() + ->arrayNode('url_params') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->prototype('variable')->end() + ->info('The parameters of the action. Only used if the action is a route to a controller.') + ->end() ->append($this->getIconsNode('The icons of the shortcut.')) ->end() ->end() @@ -100,6 +108,13 @@ private function setupFileHandlers(ArrayNodeDefinition $node): void ->info('The action to take.') ->example('/handle-audio-file') ->end() + ->arrayNode('action_params') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->prototype('variable')->end() + ->info('The parameters of the action. Only used if the action is a route to a controller.') + ->end() ->arrayNode('accept') ->requiresAtLeastOneElement() ->useAttributeAsKey('name') @@ -130,6 +145,13 @@ private function setupSharedTarget(ArrayNodeDefinition $node): void ->info('The action of the share target.') ->example('/shared-content-receiver/') ->end() + ->arrayNode('action_params') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->prototype('variable')->end() + ->info('The parameters of the action. Only used if the action is a route to a controller.') + ->end() ->scalarNode('method') ->info('The method of the share target.') ->example('GET') @@ -194,6 +216,13 @@ private function setupProtocolHandlers(ArrayNodeDefinition $node): void ->info('The URL of the handler.') ->example('/lookup?type=%s') ->end() + ->arrayNode('url_params') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->prototype('variable')->end() + ->info('The parameters of the action. Only used if the action is a route to a controller.') + ->end() ->end() ->end() ->end() @@ -259,6 +288,31 @@ private function setupRelatedApplications(ArrayNodeDefinition $node): void private function setupSimpleOptions(ArrayNodeDefinition $node): void { $node->children() + ->integerNode('path_type_reference') + ->defaultValue(UrlGeneratorInterface::ABSOLUTE_PATH) + ->info( + 'The path type reference to generate paths/URLs. See https://symfony.com/doc/current/routing.html#generating-urls-in-controllers for more information.' + ) + ->example( + [ + UrlGeneratorInterface::ABSOLUTE_PATH, + UrlGeneratorInterface::ABSOLUTE_URL, + UrlGeneratorInterface::NETWORK_PATH, + UrlGeneratorInterface::RELATIVE_PATH, + ] + ) + ->validate() + ->ifNotInArray( + [ + UrlGeneratorInterface::ABSOLUTE_PATH, + UrlGeneratorInterface::ABSOLUTE_URL, + UrlGeneratorInterface::NETWORK_PATH, + UrlGeneratorInterface::RELATIVE_PATH, + ] + ) + ->thenInvalid('Invalid path type reference "%s".') + ->end() + ->end() ->scalarNode('image_processor') ->defaultNull() ->info('The image processor to use to generate the icons of different sizes.') @@ -268,26 +322,6 @@ private function setupSimpleOptions(ArrayNodeDefinition $node): void ->defaultNull() ->info('The Panther Client for generating screenshots. If not set, the default client will be used.') ->end() - ->scalarNode('icon_folder') - ->defaultValue('%kernel.project_dir%/public/pwa') - ->info('The folder where the icons will be generated.') - ->end() - ->scalarNode('icon_prefix_url') - ->defaultValue('/pwa') - ->info('The URL prefix to use to generate the icons.') - ->end() - ->scalarNode('screenshot_folder') - ->defaultValue('%kernel.project_dir%/public/pwa') - ->info('The folder where the screenshots will be generated.') - ->end() - ->scalarNode('screenshot_prefix_url') - ->defaultValue('/pwa') - ->info('The URL prefix to use to generate the icons.') - ->end() - ->scalarNode('manifest_filepath') - ->defaultValue('%kernel.project_dir%/public/site.webmanifest') - ->info('The filename where the manifest will be generated.') - ->end() ->scalarNode('background_color') ->info( 'The background color of the application. It should match the background-color CSS property in the sites stylesheet for a smooth transition between launching the web application and loading the site\'s content.' @@ -397,7 +431,7 @@ private function getIconsNode(string $info): ArrayNodeDefinition ->children() ->scalarNode('src') ->isRequired() - ->info('The path to the icon.') + ->info('The path to the icon. Can be served by Asset Mapper.') ->example('icon/logo.svg') ->end() ->arrayNode('sizes') @@ -428,25 +462,12 @@ private function setupServiceWorker(ArrayNodeDefinition $node): void ->arrayNode('serviceworker') ->info('EXPERIMENTAL. Specifies a serviceworker that is registered.') ->treatFalseLike([]) - ->treatTrueLike([ - 'generate' => true, - ]) + ->treatTrueLike([]) ->treatNullLike([]) - ->validate() - ->ifTrue( - static fn (array $v): bool => basename((string) $v['filepath']) !== basename((string) $v['src']) - ) - ->thenInvalid('The filename from the "filepath" and the "src" must be the same.') - ->end() ->children() - ->scalarNode('filepath') - ->defaultValue('%kernel.project_dir%/public/sw.js') - ->info('The filename where the service worker will be generated.') - ->end() ->scalarNode('src') - ->cannotBeEmpty() - ->defaultValue('/sw.js') - ->info('The path to the service worker.') + ->isRequired() + ->info('The path to the service worker. Can be served by Asset Mapper.') ->example('/sw.js') ->end() ->scalarNode('scope') @@ -475,41 +496,17 @@ private function getScreenshotsNode(string $info): ArrayNodeDefinition ->treatTrueLike([]) ->treatNullLike([]) ->arrayPrototype() - ->validate() - ->ifTrue(static fn (array $v): bool => ! (isset($v['src']) xor isset($v['path']))) - ->thenInvalid('Either "src", "route" or "path" must be set.') - ->end() - ->validate() - ->ifTrue(static function (array $v): bool { - if (isset($v['src'])) { - return false; - } - - if (! isset($v['height']) || ! isset($v['width'])) { - return true; - } - - return false; - }) - ->thenInvalid('When using "path", "height" and "width" must be set.') - ->end() ->children() ->scalarNode('src') - ->info('The path to the screenshot.') + ->info('The path to the screenshot. Can be served by Asset Mapper.') ->example('screenshot/lowres.webp') ->end() - ->scalarNode('path') - ->info('The path to an application page. The screenshot will be generated.') - ->example('https://example.com') - ->end() ->scalarNode('height') ->defaultNull() - ->info('When using "route" or "path", the height of the screenshot.') ->example('1080') ->end() ->scalarNode('width') ->defaultNull() - ->info('When using "route" or "path", the height of the screenshot.') ->example('1080') ->end() ->scalarNode('form_factor') diff --git a/src/DependencyInjection/SpomkyLabsPwaExtension.php b/src/DependencyInjection/SpomkyLabsPwaExtension.php index 0405a6d..c9a685a 100644 --- a/src/DependencyInjection/SpomkyLabsPwaExtension.php +++ b/src/DependencyInjection/SpomkyLabsPwaExtension.php @@ -34,21 +34,8 @@ public function load(array $configs, ContainerBuilder $container): void if ($config['web_client'] !== null) { $container->setAlias('pwa.web_client', $config['web_client']); } - unset($config['image_processor'], $config['web_client']); - $params = [ - 'icon_folder', - 'icon_prefix_url', - 'shortcut_icon_folder', - 'shortcut_icon_prefix_url', - 'screenshot_folder', - 'screenshot_prefix_url', - 'manifest_filepath', - 'serviceworker_filepath', - ]; - $container->setParameter('spomky_labs_pwa.dest', array_intersect_key($config, array_flip($params))); - foreach ($params as $param) { - unset($config[$param]); - } + $container->setParameter('spomky_labs_pwa.routes.reference_type', $config['path_type_reference']); + unset($config['image_processor'], $config['web_client'], $config['path_type_reference']); $container->setParameter('spomky_labs_pwa.config', $config); } diff --git a/src/Dto/EdgeSidePanel.php b/src/Dto/EdgeSidePanel.php new file mode 100644 index 0000000..6de065c --- /dev/null +++ b/src/Dto/EdgeSidePanel.php @@ -0,0 +1,13 @@ + + */ + #[SerializedName('action_params')] + public array $actionParameters = []; + + /** + * @var array + */ + public array $accept; +} diff --git a/src/Dto/Icon.php b/src/Dto/Icon.php new file mode 100644 index 0000000..04854c7 --- /dev/null +++ b/src/Dto/Icon.php @@ -0,0 +1,32 @@ + + */ + public array $sizeList; + + public null|string $format = null; + + public null|string $purpose = null; + + #[SerializedName('sizes')] + public function getSizeList(): string + { + $result = []; + foreach ($this->sizeList as $size) { + $result[] = $size === 0 ? 'any' : $size . 'x' . $size; + } + return implode(' ', $result); + } +} diff --git a/src/Dto/LaunchHandler.php b/src/Dto/LaunchHandler.php new file mode 100644 index 0000000..09f53ec --- /dev/null +++ b/src/Dto/LaunchHandler.php @@ -0,0 +1,16 @@ + + */ + public array $clientMode = []; +} diff --git a/src/Dto/Manifest.php b/src/Dto/Manifest.php new file mode 100644 index 0000000..49b980e --- /dev/null +++ b/src/Dto/Manifest.php @@ -0,0 +1,111 @@ + + */ + public array $categories = []; + + public null|string $description = null; + + public null|string $display = null; + + /** + * @var array + */ + #[SerializedName('display_override')] + public array $displayOverride = []; + + public null|string $id = null; + + public null|string $orientation = null; + + public null|string $dir = null; + + public null|string $lang = null; + + public null|string $name = null; + + #[SerializedName('short_name')] + public null|string $shortName = null; + + public null|string $scope = null; + + #[SerializedName('start_url')] + public null|string $startUrl = null; + + #[SerializedName('theme_color')] + public null|string $themeColor = null; + + #[SerializedName('edge_side_panel')] + public null|EdgeSidePanel $edgeSidePanel = null; + + #[SerializedName('iarc_rating_id')] + public null|string $iarcRatingId = null; + + /** + * @var array + */ + #[SerializedName('scope_extensions')] + public array $scopeExtensions = []; + + #[SerializedName('handle_links')] + public null|string $handleLinks = null; + + /** + * @var array + */ + public array $icons = []; + + /** + * @var array + */ + public array $screenshots = []; + + #[SerializedName('file_handlers')] + /** + * @var array + */ + public array $fileHandlers = []; + + #[SerializedName('launch_handler')] + public null|LaunchHandler $launchHandler = null; + + /** + * @var array + */ + #[SerializedName('protocol_handlers')] + public array $protocolHandlers = []; + + /** + * @var array + */ + #[SerializedName('related_applications')] + public array $relatedApplications = []; + + /** + * @var array + */ + public array $shortcuts = []; + + #[SerializedName('share_target')] + public null|ShareTarget $shareTarget = null; + + /** + * @var array + */ + public array $widgets = []; + + #[SerializedName('serviceworker')] + public null|ServiceWorker $serviceWorker = null; +} diff --git a/src/Dto/ProtocolHandler.php b/src/Dto/ProtocolHandler.php new file mode 100644 index 0000000..1d9ac7d --- /dev/null +++ b/src/Dto/ProtocolHandler.php @@ -0,0 +1,20 @@ + + */ + #[SerializedName('url_params')] + public array $urlParameters = []; + + public string $url; +} diff --git a/src/Dto/RelatedApplication.php b/src/Dto/RelatedApplication.php new file mode 100644 index 0000000..ba5ade4 --- /dev/null +++ b/src/Dto/RelatedApplication.php @@ -0,0 +1,14 @@ + + */ + #[SerializedName('action_params')] + public array $actionParameters = []; + + public null|string $method = null; + + public null|string $enctype = null; + + public null|ShareTargetParameters $params = null; +} diff --git a/src/Dto/ShareTargetParameters.php b/src/Dto/ShareTargetParameters.php new file mode 100644 index 0000000..3d117d7 --- /dev/null +++ b/src/Dto/ShareTargetParameters.php @@ -0,0 +1,19 @@ + + */ + public array $files = []; +} diff --git a/src/Dto/Shortcut.php b/src/Dto/Shortcut.php new file mode 100644 index 0000000..cd9eaad --- /dev/null +++ b/src/Dto/Shortcut.php @@ -0,0 +1,30 @@ + + */ + #[SerializedName('url_params')] + public array $urlParameters = []; + + /** + * @var array + */ + public array $icons = []; +} diff --git a/src/Dto/Widget.php b/src/Dto/Widget.php new file mode 100644 index 0000000..7ed39f9 --- /dev/null +++ b/src/Dto/Widget.php @@ -0,0 +1,44 @@ + + */ + public array $icons = []; + + /** + * @var array + */ + public array $screenshots = []; + + public null|string $tag = null; + + public null|string $template = null; + + #[SerializedName('ms_ac_template')] + public string $adaptativeCardTemplate; + + public null|string $data = null; + + public null|string $type = null; + + public null|bool $auth = null; + + public null|int $update = null; + + public bool $multiple = true; +} diff --git a/src/ImageProcessor/GDImageProcessor.php b/src/ImageProcessor/GDImageProcessor.php index 19cd604..137d97c 100644 --- a/src/ImageProcessor/GDImageProcessor.php +++ b/src/ImageProcessor/GDImageProcessor.php @@ -4,6 +4,8 @@ namespace SpomkyLabs\PwaBundle\ImageProcessor; +use function assert; + final readonly class GDImageProcessor implements ImageProcessor { public function process(string $image, ?int $width, ?int $height, ?string $format): string @@ -12,8 +14,11 @@ public function process(string $image, ?int $width, ?int $height, ?string $forma ['width' => $width, 'height' => $height] = $this->getSizes($image); } $image = imagecreatefromstring($image); + assert($image !== false); imagealphablending($image, true); - $image = imagescale($image, $width, $height); + if ($width !== null && $height !== null) { + $image = imagescale($image, $width, $height); + } ob_start(); imagesavealpha($image, true); imagepng($image); diff --git a/src/ImageProcessor/ImagickImageProcessor.php b/src/ImageProcessor/ImagickImageProcessor.php index 81e1284..a1d44b9 100644 --- a/src/ImageProcessor/ImagickImageProcessor.php +++ b/src/ImageProcessor/ImagickImageProcessor.php @@ -22,7 +22,9 @@ public function process(string $image, ?int $width, ?int $height, ?string $forma } $imagick = new Imagick(); $imagick->readImageBlob($image); - $imagick->resizeImage($width, $height, $this->filters, $this->blur, true); + if ($width !== null && $height !== null) { + $imagick->resizeImage($width, $height, $this->filters, $this->blur, true); + } $imagick->setImageBackgroundColor(new ImagickPixel('transparent')); if ($format !== null) { $imagick->setImageFormat($format); diff --git a/src/Normalizer/FileHandlerNormalizer.php b/src/Normalizer/FileHandlerNormalizer.php new file mode 100644 index 0000000..c4ec167 --- /dev/null +++ b/src/Normalizer/FileHandlerNormalizer.php @@ -0,0 +1,51 @@ +action; + if (! str_starts_with($url, '/') && ! filter_var($url, FILTER_VALIDATE_URL)) { + $url = $this->router->generate($url, $object->actionParameters, $this->referenceType); + } + + return [ + 'action' => $url, + 'accept' => $object->accept, + ]; + } + + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return $data instanceof FileHandler; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [ + FileHandler::class => true, + ]; + } +} diff --git a/src/Normalizer/IconNormalizer.php b/src/Normalizer/IconNormalizer.php new file mode 100644 index 0000000..2c6066c --- /dev/null +++ b/src/Normalizer/IconNormalizer.php @@ -0,0 +1,58 @@ +src, '/')) { + $url = $this->assetMapper->getAsset($object->src)?->publicPath; + } + if ($url === null) { + $url = $object->src; + } + + $result = [ + 'src' => $url, + 'sizes' => $object->getSizeList(), + 'type' => $object->format, + 'purpose' => $object->purpose, + ]; + + $cleanup = static fn (array $data): array => array_filter( + $data, + static fn ($value) => ($value !== null && $value !== []) + ); + return $cleanup($result); + } + + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return $data instanceof Icon; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [ + Icon::class => true, + ]; + } +} diff --git a/src/Normalizer/ProtocolHandlerNormalizer.php b/src/Normalizer/ProtocolHandlerNormalizer.php new file mode 100644 index 0000000..6db5c83 --- /dev/null +++ b/src/Normalizer/ProtocolHandlerNormalizer.php @@ -0,0 +1,51 @@ +url; + if (! str_starts_with($url, '/') && ! filter_var($url, FILTER_VALIDATE_URL)) { + $url = $this->router->generate($url, $object->urlParameters, $this->referenceType); + } + + return [ + 'url' => $url, + 'protocol' => $object->protocol, + ]; + } + + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return $data instanceof ProtocolHandler; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [ + ProtocolHandler::class => true, + ]; + } +} diff --git a/src/Normalizer/ScreenshotNormalizer.php b/src/Normalizer/ScreenshotNormalizer.php new file mode 100644 index 0000000..a58d44c --- /dev/null +++ b/src/Normalizer/ScreenshotNormalizer.php @@ -0,0 +1,65 @@ +src, '/')) { + $url = $this->assetMapper->getAsset($object->src)?->publicPath; + } + if ($url === null) { + $url = $object->src; + } + $sizes = null; + if ($object->width !== null && $object->height !== null) { + $sizes = sprintf('%dx%d', $object->width, $object->height); + } + + $result = [ + 'src' => $url, + 'sizes' => $sizes, + 'width' => $object->width, + 'form_factor' => $object->formFactor, + 'label' => $object->label, + 'platform' => $object->platform, + 'format' => $object->format, + ]; + + $cleanup = static fn (array $data): array => array_filter( + $data, + static fn ($value) => ($value !== null && $value !== []) + ); + return $cleanup($result); + } + + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return $data instanceof Screenshot; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [ + Screenshot::class => true, + ]; + } +} diff --git a/src/Normalizer/ServiceWorkerNormalizer.php b/src/Normalizer/ServiceWorkerNormalizer.php new file mode 100644 index 0000000..13d2e49 --- /dev/null +++ b/src/Normalizer/ServiceWorkerNormalizer.php @@ -0,0 +1,57 @@ +src, '/')) { + $url = $this->assetMapper->getAsset($object->src)?->publicPath; + } + if ($url === null) { + $url = $object->src; + } + + $result = [ + 'src' => $url, + 'scope' => $object->scope, + 'use_cache' => $object->useCache, + ]; + + $cleanup = static fn (array $data): array => array_filter( + $data, + static fn ($value) => ($value !== null && $value !== []) + ); + return $cleanup($result); + } + + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return $data instanceof ServiceWorker; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [ + ServiceWorker::class => true, + ]; + } +} diff --git a/src/Normalizer/ShareTargetNormalizer.php b/src/Normalizer/ShareTargetNormalizer.php new file mode 100644 index 0000000..b593529 --- /dev/null +++ b/src/Normalizer/ShareTargetNormalizer.php @@ -0,0 +1,59 @@ +action; + if (! str_starts_with($url, '/') && ! filter_var($url, FILTER_VALIDATE_URL)) { + $url = $this->router->generate($url, $object->actionParameters, $this->referenceType); + } + + $result = [ + 'action' => $url, + 'method' => $object->method, + 'enctype' => $object->enctype, + 'params' => $object->params, + ]; + + $cleanup = static fn (array $data): array => array_filter( + $data, + static fn ($value) => ($value !== null && $value !== []) + ); + return $cleanup($result); + } + + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return $data instanceof ShareTarget; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [ + ShareTarget::class => true, + ]; + } +} diff --git a/src/Normalizer/ShortcutNormalizer.php b/src/Normalizer/ShortcutNormalizer.php new file mode 100644 index 0000000..b5a36e8 --- /dev/null +++ b/src/Normalizer/ShortcutNormalizer.php @@ -0,0 +1,60 @@ +url; + if (! str_starts_with($url, '/') && ! filter_var($url, FILTER_VALIDATE_URL)) { + $url = $this->router->generate($url, $object->urlParameters, $this->referenceType); + } + + $result = [ + 'name' => $object->name, + 'short_name' => $object->shortName, + 'description' => $object->description, + 'url' => $url, + 'icons' => $object->icons, + ]; + + $cleanup = static fn (array $data): array => array_filter( + $data, + static fn ($value) => ($value !== null && $value !== []) + ); + return $cleanup($result); + } + + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return $data instanceof Shortcut; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [ + Shortcut::class => true, + ]; + } +} diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 25ea090..51de71c 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -2,17 +2,12 @@ declare(strict_types=1); -use SpomkyLabs\PwaBundle\Command\GenerateManifestCommand; -use SpomkyLabs\PwaBundle\Command\GenerateServiceWorkerCommand; -use SpomkyLabs\PwaBundle\Command\SectionProcessor\ActionsSectionProcessor; -use SpomkyLabs\PwaBundle\Command\SectionProcessor\ApplicationIconsSectionProcessor; -use SpomkyLabs\PwaBundle\Command\SectionProcessor\ApplicationScreenshotsSectionProcessor; -use SpomkyLabs\PwaBundle\Command\SectionProcessor\ServiceWorkerSectionProcessor; -use SpomkyLabs\PwaBundle\Command\SectionProcessor\ShortcutsSectionProcessor; -use SpomkyLabs\PwaBundle\Command\SectionProcessor\Windows10WidgetsSectionProcessor; +use SpomkyLabs\PwaBundle\Dto\Manifest; use SpomkyLabs\PwaBundle\ImageProcessor\GDImageProcessor; use SpomkyLabs\PwaBundle\ImageProcessor\ImagickImageProcessor; +use SpomkyLabs\PwaBundle\Service\Builder; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use function Symfony\Component\DependencyInjection\Loader\Configurator\service; return static function (ContainerConfigurator $container): void { $container = $container->services() @@ -22,21 +17,22 @@ ->autowire() ; - $container->set(ApplicationIconsSectionProcessor::class) - ->tag('pwa.section-processor'); - $container->set(ApplicationScreenshotsSectionProcessor::class) - ->tag('pwa.section-processor'); - $container->set(ShortcutsSectionProcessor::class) - ->tag('pwa.section-processor'); - $container->set(ActionsSectionProcessor::class) - ->tag('pwa.section-processor'); - $container->set(Windows10WidgetsSectionProcessor::class) - ->tag('pwa.section-processor'); - $container->set(ServiceWorkerSectionProcessor::class) - ->tag('pwa.section-processor'); + $container->set(Builder::class) + ->args([ + '$config' => '%spomky_labs_pwa.config%', + ]) + ; + $container->set(Manifest::class) + ->factory([service(Builder::class), 'createManifest']) + ; - $container->set(GenerateManifestCommand::class); - $container->set(GenerateServiceWorkerCommand::class); + $container->load('SpomkyLabs\\PwaBundle\\Command\\', '../../Command/*'); + + $container->load('SpomkyLabs\\PwaBundle\\Normalizer\\', '../../Normalizer/*') + ->tag('serializer.normalizer', [ + 'priority' => 1024, + ]) + ; if (extension_loaded('imagick')) { $container diff --git a/src/Service/Builder.php b/src/Service/Builder.php new file mode 100644 index 0000000..8496532 --- /dev/null +++ b/src/Service/Builder.php @@ -0,0 +1,34 @@ + $config + */ + public function __construct( + private readonly DenormalizerInterface $denormalizer, + private readonly array $config, + ) { + } + + public function createManifest(): Manifest + { + if ($this->manifest === null) { + $result = $this->denormalizer->denormalize($this->config, Manifest::class); + assert($result instanceof Manifest); + $this->manifest = $result; + } + + return $this->manifest; + } +} diff --git a/src/Twig/PwaExtension.php b/src/Twig/PwaExtension.php new file mode 100644 index 0000000..904ab3a --- /dev/null +++ b/src/Twig/PwaExtension.php @@ -0,0 +1,20 @@ + ['html'], + ]), + ]; + } +} diff --git a/src/Twig/PwaRuntime.php b/src/Twig/PwaRuntime.php new file mode 100644 index 0000000..514d89c --- /dev/null +++ b/src/Twig/PwaRuntime.php @@ -0,0 +1,30 @@ +assetMapper + ->getAsset($filename) + ?->publicPath + ; + if ($url === null) { + throw new RuntimeException(sprintf('The asset "%s" is missing.', $filename)); + } + + return sprintf('', $url); + } +} diff --git a/tests/AppKernel.php b/tests/AppKernel.php index 53a6b23..fac97c4 100644 --- a/tests/AppKernel.php +++ b/tests/AppKernel.php @@ -10,6 +10,9 @@ use Symfony\Component\HttpKernel\Bundle\BundleInterface; use Symfony\Component\HttpKernel\Kernel; +/** + * @internal + */ final class AppKernel extends Kernel { public function __construct(string $environment) diff --git a/tests/Controller/DummyController.php b/tests/Controller/DummyController.php new file mode 100644 index 0000000..f5df3ad --- /dev/null +++ b/tests/Controller/DummyController.php @@ -0,0 +1,29 @@ +get(Filesystem::class); + $filesystem->remove(sprintf('%s/samples', self::$kernel->getCacheDir())); + } +} diff --git a/tests/Functional/CommandTest.php b/tests/Functional/CommandTest.php deleted file mode 100644 index fe6a890..0000000 --- a/tests/Functional/CommandTest.php +++ /dev/null @@ -1,115 +0,0 @@ -find('pwa:build'); - $commandTester = new CommandTester($command); - - // When - $commandTester->execute([]); - - // Then - $commandTester->assertCommandIsSuccessful(); - - static::assertStringContainsString('PWA Manifest Generator', $commandTester->getDisplay()); - static::assertDirectoryExists(sprintf('%s/samples/icons', self::$kernel->getCacheDir())); - static::assertDirectoryExists(sprintf('%s/samples/screenshots', self::$kernel->getCacheDir())); - foreach (self::expectedFiles() as $name => $file) { - static::assertFileExists($file, sprintf('File "%s" does not exist.', $name)); - } - } - - #[Test] - public static function theCommandCanGenerateTheServiceWorker(): void - { - // Given - $command = self::$application->find('pwa:sw'); - $commandTester = new CommandTester($command); - - // When - $commandTester->execute([]); - - // Then - $commandTester->assertCommandIsSuccessful(); - - static::assertStringContainsString('PWA Service Worker Generator', $commandTester->getDisplay()); - static::assertDirectoryExists(sprintf('%s/samples/sw', self::$kernel->getCacheDir())); - static::assertFileExists(sprintf('%s/samples/sw/my-sw.js', self::$kernel->getCacheDir())); - } - - private static function cleanupFolder(): void - { - $filesystem = self::getContainer()->get(Filesystem::class); - $filesystem->remove(sprintf('%s/samples', self::$kernel->getCacheDir())); - } - - /** - * @return iterable - */ - private static function expectedFiles(): iterable - { - yield 'manifest' => sprintf('%s/samples/manifest/my-pwa.json', self::$kernel->getCacheDir()); - yield 'icon 48' => sprintf('%s/samples/icons/icon--48x48-1dc988f5.json', self::$kernel->getCacheDir()); - yield 'icon 72' => sprintf('%s/samples/icons/icon--72x72-5446402b.json', self::$kernel->getCacheDir()); - yield 'icon 96' => sprintf('%s/samples/icons/icon--96x96-d6d73d91.json', self::$kernel->getCacheDir()); - yield 'icon 128' => sprintf('%s/samples/icons/icon--128x128-d7e6af19.json', self::$kernel->getCacheDir()); - yield 'icon 256' => sprintf('%s/samples/icons/icon--256x256-0091eae5.json', self::$kernel->getCacheDir()); - yield 'icon any' => sprintf('%s/samples/icons/icon--any-2a9c5120.svg', self::$kernel->getCacheDir()); - yield 'icon 48 maskable' => sprintf( - '%s/samples/icons/icon-maskable-48x48-bda4c927.json', - self::$kernel->getCacheDir() - ); - yield 'icon 72 maskable' => sprintf( - '%s/samples/icons/icon-maskable-72x72-6019b5fd.json', - self::$kernel->getCacheDir() - ); - yield 'icon 96 maskable' => sprintf( - '%s/samples/icons/icon-maskable-96x96-b4c4250c.json', - self::$kernel->getCacheDir() - ); - yield 'icon 128 maskable' => sprintf( - '%s/samples/icons/icon-maskable-128x128-9be87901.json', - self::$kernel->getCacheDir() - ); - yield 'icon 256 maskable' => sprintf( - '%s/samples/icons/icon-maskable-256x256-8f61caf3.json', - self::$kernel->getCacheDir() - ); - yield 'screenshot' => sprintf( - '%s/samples/screenshots/screenshot--1024x1920-a8c03e1d.json', - self::$kernel->getCacheDir() - ); - } -} diff --git a/tests/Functional/GenerateIconsCommandTest.php b/tests/Functional/GenerateIconsCommandTest.php new file mode 100644 index 0000000..59e3f84 --- /dev/null +++ b/tests/Functional/GenerateIconsCommandTest.php @@ -0,0 +1,36 @@ +find('pwa:generate-icons'); + $commandTester = new CommandTester($command); + $output = sprintf('%s/samples/icons', self::$kernel->getCacheDir()); + + // When + $commandTester->execute([ + 'source' => __DIR__ . '/../images/1920x1920.svg', + 'output' => $output, + '--format' => 'png', + 'sizes' => [192, 512], + ]); + + // Then + $commandTester->assertCommandIsSuccessful(); + static::assertFileExists(sprintf('%s/icon-192x192.png', $output)); + static::assertFileExists(sprintf('%s/icon-512x512.png', $output)); + } +} diff --git a/tests/Functional/ServiceWorkerCommandTest.php b/tests/Functional/ServiceWorkerCommandTest.php new file mode 100644 index 0000000..4100caa --- /dev/null +++ b/tests/Functional/ServiceWorkerCommandTest.php @@ -0,0 +1,35 @@ +find('pwa:sw'); + $commandTester = new CommandTester($command); + $output = sprintf('%s/samples/my-sw.js', self::$kernel->getCacheDir()); + + // When + $commandTester->execute([ + 'output' => $output, + '--force' => true, + ]); + + // Then + $commandTester->assertCommandIsSuccessful(); + + static::assertStringContainsString('PWA Service Worker Generator', $commandTester->getDisplay()); + static::assertFileExists($output); + } +} diff --git a/tests/Functional/TakeScreenshotCommandTest.php b/tests/Functional/TakeScreenshotCommandTest.php new file mode 100644 index 0000000..9f02f53 --- /dev/null +++ b/tests/Functional/TakeScreenshotCommandTest.php @@ -0,0 +1,35 @@ +find('pwa:take-screenshot'); + $commandTester = new CommandTester($command); + $output = sprintf('%s/samples/screenshots/screenshot-1024x1920.png', self::$kernel->getCacheDir()); + + // When + $commandTester->execute([ + 'url' => 'https://localhost', + 'output' => $output, + '--width' => '1024', + '--height' => '1920', + ]); + + // Then + $commandTester->assertCommandIsSuccessful(); + static::assertFileExists($output); + } +} diff --git a/tests/config.php b/tests/config.php index 9df004e..ae83477 100644 --- a/tests/config.php +++ b/tests/config.php @@ -9,6 +9,12 @@ return static function (ContainerConfigurator $container) { $container->services() ->set(DummyImageProcessor::class); + + $container->services() + ->load('SpomkyLabs\\PwaBundle\\Tests\\Controller\\', __DIR__ . '/Controller/') + ->tag('controller.service_arguments') + ; + $container->extension('framework', [ 'test' => true, 'secret' => 'test', @@ -16,14 +22,18 @@ 'session' => [ 'storage_factory_id' => 'session.storage.factory.mock_file', ], + 'asset_mapper' => [ + 'paths' => [ + 'tests/images' => 'pwa', + ], + ], + 'router' => [ + 'utf8' => true, + 'resource' => '%kernel.project_dir%/tests/routes.php', + ], ]); $container->extension('pwa', [ 'image_processor' => DummyImageProcessor::class, - 'icon_folder' => '%kernel.cache_dir%/samples/icons', - 'icon_prefix_url' => '/icons', - 'screenshot_folder' => '%kernel.cache_dir%/samples/screenshots', - 'screenshot_prefix_url' => '/screenshots', - 'manifest_filepath' => '%kernel.cache_dir%/samples/manifest/my-pwa.json', 'background_color' => 'red', 'categories' => ['books', 'education', 'medical'], 'description' => 'Awesome application that will help you achieve your dreams.', @@ -31,7 +41,10 @@ 'display_override' => ['fullscreen', 'minimal-ui'], 'file_handlers' => [ [ - 'action' => '/handle-audio-file', + 'action' => 'audio_file_handler', + 'action_params' => [ + 'param1' => 'audio', + ], 'accept' => [ 'audio/wav' => ['.wav'], 'audio/x-wav' => ['.wav'], @@ -48,18 +61,18 @@ ], 'icons' => [ [ - 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'src' => 'pwa/1920x1920.svg', 'sizes' => [48, 72, 96, 128, 256], 'format' => 'webp', ], [ - 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'src' => 'pwa/1920x1920.svg', 'sizes' => [48, 72, 96, 128, 256], 'format' => 'png', 'purpose' => 'maskable', ], [ - 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'src' => 'pwa/1920x1920.svg', 'sizes' => [0], ], ], @@ -103,13 +116,20 @@ 'theme_color' => 'red', 'screenshots' => [ [ - 'src' => sprintf('%s/images/screenshots', __DIR__), + 'src' => 'pwa/screenshots/360x800.svg', 'platform' => 'android', 'format' => 'png', + 'label' => '360x800', + 'width' => 360, + 'height' => 800, ], ], 'share_target' => [ - 'action' => '/shared-content-receiver/', + 'action' => 'shared_content_receiver', + 'action_params' => [ + 'param1' => 'value1', + 'param2' => 'value2', + ], 'method' => 'GET', 'params' => [ 'title' => 'name', @@ -120,7 +140,10 @@ 'shortcuts' => [ [ 'name' => "Today's agenda", - 'url' => '/today', + 'url' => 'agenda', + 'url_params' => [ + 'date' => 'today', + ], 'description' => 'List of events planned for today', ], [ @@ -132,18 +155,18 @@ 'url' => '/create/reminder', 'icons' => [ [ - 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'src' => 'pwa/1920x1920.svg', 'sizes' => [48, 72, 96, 128, 256], 'format' => 'webp', ], [ - 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'src' => 'pwa/1920x1920.svg', 'sizes' => [48, 72, 96, 128, 256], 'format' => 'png', 'purpose' => 'maskable', ], [ - 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'src' => 'pwa/1920x1920.svg', 'sizes' => [0], ], ], @@ -175,13 +198,13 @@ 'type' => 'application/json', 'screenshots' => [ [ - 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'src' => 'pwa/1920x1920.svg', 'label' => 'The PWAmp mini-player widget', ], ], 'icons' => [ [ - 'src' => sprintf('%s/images/1920x1920.svg', __DIR__), + 'src' => 'pwa/1920x1920.svg', 'sizes' => [16, 48], 'format' => 'webp', ], @@ -193,7 +216,6 @@ 'handle_links' => 'auto', 'serviceworker' => [ 'src' => '/sw/my-sw.js', - 'filepath' => '%kernel.cache_dir%/samples/sw/my-sw.js', 'scope' => '/', 'use_cache' => true, ], diff --git a/tests/routes.php b/tests/routes.php new file mode 100644 index 0000000..e4665cc --- /dev/null +++ b/tests/routes.php @@ -0,0 +1,15 @@ +import( + resource: [ + 'path' => __DIR__ . '/Controller/', + 'namespace' => 'SpomkyLabs\\PwaBundle\\Tests\\Controller\\', + ], + type: 'attribute', + ); +};