diff --git a/composer.json b/composer.json
index 25535d4..6726da3 100644
--- a/composer.json
+++ b/composer.json
@@ -30,10 +30,8 @@
"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/property-access": "^6.4|^7.0",
"symfony/property-info": "^7.0",
"symfony/routing": "^6.4|^7.0",
@@ -51,7 +49,9 @@
"phpstan/phpstan-strict-rules": "^1.0",
"phpunit/phpunit": "^10.0",
"rector/rector": "^0.19",
+ "symfony/filesystem": "^6.4|^7.0",
"symfony/framework-bundle": "^6.4|^7.0",
+ "symfony/mime": "^6.4|^7.0",
"symfony/panther": "^2.1",
"symfony/phpunit-bridge": "^6.4|^7.0",
"symfony/yaml": "^6.4|^7.0",
@@ -67,6 +67,8 @@
"suggest": {
"ext-gd": "Required to generate icons (or Imagick).",
"ext-imagick": "Required to generate icons (or GD).",
+ "symfony/mime": "For generating and manipulating icons or screenshots",
+ "symfony/filesystem": "For generating and manipulating icons or screenshots",
"symfony/panther": "For generating screenshots directly from your application"
}
}
diff --git a/src/Command/GenerateIconsCommand.php b/src/Command/GenerateIconsCommand.php
index 3130c3e..11312da 100644
--- a/src/Command/GenerateIconsCommand.php
+++ b/src/Command/GenerateIconsCommand.php
@@ -30,6 +30,7 @@ protected function configure(): void
{
$this->addArgument('source', InputArgument::REQUIRED, 'The source image');
$this->addArgument('output', InputArgument::REQUIRED, 'The output directory');
+ $this->addArgument('filename', InputArgument::OPTIONAL, 'The output directory', 'icon');
$this->addOption('format', null, InputOption::VALUE_OPTIONAL, 'The format of the icons');
$this->addArgument(
'sizes',
@@ -46,6 +47,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$source = $input->getArgument('source');
$dest = $input->getArgument('output');
+ $filename = $input->getArgument('filename');
$format = $input->getOption('format');
$sizes = $input->getArgument('sizes');
@@ -73,8 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
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);
+ $this->filesystem->dumpFile(sprintf('%s/%s-%sx%s.%s', $dest, $filename, $size, $size, $format), $tmp);
$io->info('Icon ' . $size . 'x' . $size . ' generated.');
}
$io->info('Done.');
diff --git a/src/Command/TakeScreenshotCommand.php b/src/Command/TakeScreenshotCommand.php
index 38053ca..f7c76a0 100644
--- a/src/Command/TakeScreenshotCommand.php
+++ b/src/Command/TakeScreenshotCommand.php
@@ -14,7 +14,9 @@
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\Mime\MimeTypes;
use Symfony\Component\Panther\Client;
+use function count;
#[AsCommand(
name: 'pwa:take-screenshot',
@@ -44,7 +46,14 @@ public function isEnabled(): bool
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->addArgument('output', InputArgument::REQUIRED, 'The output directory of the screenshot');
+ $this->addArgument(
+ 'filename',
+ InputArgument::OPTIONAL,
+ 'The output name of the screenshot',
+ 'screenshot',
+ ['homeage-android', 'feature1']
+ );
$this->addOption('width', null, InputOption::VALUE_OPTIONAL, 'The width of the screenshot');
$this->addOption('height', null, InputOption::VALUE_OPTIONAL, 'The height of the screenshot');
}
@@ -72,7 +81,28 @@ protected function execute(InputInterface $input, OutputInterface $output): int
->fullscreen();
$client->takeScreenshot($tmpName);
- $this->filesystem->copy($tmpName, $input->getArgument('output'), true);
+ $mime = MimeTypes::getDefault();
+ $mimeType = $mime->guessMimeType($tmpName);
+ $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;
+ }
+ $sizes = '';
+ if ($width !== null && $height !== null) {
+ $sizes = sprintf('-%dx%d', (int) $width, (int) $height);
+ }
+
+ $format = current($extensions);
+ $filename = sprintf(
+ '%s/%s%s.%s',
+ $input->getArgument('output'),
+ $input->getArgument('filename'),
+ $sizes,
+ $format
+ );
+
+ $this->filesystem->copy($tmpName, $filename, true);
$this->filesystem->remove($tmpName);
$io->success('Screenshot saved');
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index f38342e..3fdc6ce 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -313,6 +313,12 @@ private function setupSimpleOptions(ArrayNodeDefinition $node): void
->thenInvalid('Invalid path type reference "%s".')
->end()
->end()
+ ->scalarNode('manifest_public_url')
+ ->defaultValue('/site.manifest')
+ ->cannotBeEmpty()
+ ->info('The public URL of the manifest file.')
+ ->example('/site.manifest')
+ ->end()
->scalarNode('image_processor')
->defaultNull()
->info('The image processor to use to generate the icons of different sizes.')
diff --git a/src/DependencyInjection/SpomkyLabsPwaExtension.php b/src/DependencyInjection/SpomkyLabsPwaExtension.php
index c9a685a..403e267 100644
--- a/src/DependencyInjection/SpomkyLabsPwaExtension.php
+++ b/src/DependencyInjection/SpomkyLabsPwaExtension.php
@@ -5,12 +5,14 @@
namespace SpomkyLabs\PwaBundle\DependencyInjection;
use SpomkyLabs\PwaBundle\ImageProcessor\ImageProcessor;
+use SpomkyLabs\PwaBundle\Subscriber\PwaDevServerSubscriber;
use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\Definition\Processor;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
+use function in_array;
final class SpomkyLabsPwaExtension extends Extension
{
@@ -35,9 +37,18 @@ public function load(array $configs, ContainerBuilder $container): void
$container->setAlias('pwa.web_client', $config['web_client']);
}
$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.manifest_public_url', $config['manifest_public_url']);
+
+ unset(
+ $config['image_processor'],
+ $config['web_client'],
+ $config['path_type_reference'],
+ $config['manifest_public_url'],
+ );
$container->setParameter('spomky_labs_pwa.config', $config);
+ if (! in_array($container->getParameter('kernel.environment'), ['dev', 'test'], true)) {
+ $container->removeDefinition(PwaDevServerSubscriber::class);
+ }
}
public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface
diff --git a/src/Normalizer/IconNormalizer.php b/src/Normalizer/IconNormalizer.php
index 2c6066c..77bc4af 100644
--- a/src/Normalizer/IconNormalizer.php
+++ b/src/Normalizer/IconNormalizer.php
@@ -38,6 +38,7 @@ public function normalize(mixed $object, string $format = null, array $context =
$data,
static fn ($value) => ($value !== null && $value !== [])
);
+
return $cleanup($result);
}
diff --git a/src/Normalizer/ScreenshotNormalizer.php b/src/Normalizer/ScreenshotNormalizer.php
index a58d44c..1ab2172 100644
--- a/src/Normalizer/ScreenshotNormalizer.php
+++ b/src/Normalizer/ScreenshotNormalizer.php
@@ -5,14 +5,18 @@
namespace SpomkyLabs\PwaBundle\Normalizer;
use SpomkyLabs\PwaBundle\Dto\Screenshot;
+use SpomkyLabs\PwaBundle\ImageProcessor\ImageProcessor;
use Symfony\Component\AssetMapper\AssetMapperInterface;
+use Symfony\Component\AssetMapper\MappedAsset;
+use Symfony\Component\Mime\MimeTypes;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use function assert;
final readonly class ScreenshotNormalizer implements NormalizerInterface
{
public function __construct(
- private AssetMapperInterface $assetMapper
+ private AssetMapperInterface $assetMapper,
+ private null|ImageProcessor $imageProcessor,
) {
}
@@ -20,25 +24,24 @@ public function normalize(mixed $object, string $format = null, array $context =
{
assert($object instanceof Screenshot);
$url = null;
+ $asset = null;
if (! str_starts_with($object->src, '/')) {
- $url = $this->assetMapper->getAsset($object->src)?->publicPath;
+ $asset = $this->assetMapper->getAsset($object->src);
+ $url = $asset?->publicPath;
}
if ($url === null) {
$url = $object->src;
}
- $sizes = null;
- if ($object->width !== null && $object->height !== null) {
- $sizes = sprintf('%dx%d', $object->width, $object->height);
- }
+ ['sizes' => $sizes, 'formFactor' => $formFactor] = $this->getSizes($object, $asset);
+ $format = $this->getFormat($object, $asset);
$result = [
'src' => $url,
'sizes' => $sizes,
- 'width' => $object->width,
- 'form_factor' => $object->formFactor,
+ 'form_factor' => $formFactor,
'label' => $object->label,
'platform' => $object->platform,
- 'format' => $object->format,
+ 'format' => $format,
];
$cleanup = static fn (array $data): array => array_filter(
@@ -62,4 +65,56 @@ public function getSupportedTypes(?string $format): array
Screenshot::class => true,
];
}
+
+ /**
+ * @return array{sizes: string|null, formFactor: string|null}
+ */
+ private function getSizes(Screenshot $object, null|MappedAsset $asset): array
+ {
+ if ($object->width !== null && $object->height !== null) {
+ return [
+ 'sizes' => sprintf('%dx%d', $object->width, $object->height),
+ 'formFactor' => $object->formFactor ?? $this->getFormFactor($object->width, $object->height),
+ ];
+ }
+
+ if ($this->imageProcessor === null || $asset === null) {
+ return [
+ 'sizes' => null,
+ 'formFactor' => $object->formFactor,
+ ];
+ }
+
+ ['width' => $width, 'height' => $height] = $this->imageProcessor->getSizes(
+ file_get_contents($asset->sourcePath)
+ );
+
+ return [
+ 'sizes' => sprintf('%dx%d', $width, $height),
+ 'formFactor' => $object->formFactor ?? $this->getFormFactor($width, $height),
+ ];
+ }
+
+ private function getFormat(Screenshot $object, ?MappedAsset $asset): ?string
+ {
+ if ($object->format !== null) {
+ return $object->format;
+ }
+
+ if ($this->imageProcessor === null || $asset === null) {
+ return null;
+ }
+
+ $mime = MimeTypes::getDefault();
+ return $mime->guessMimeType($asset->sourcePath);
+ }
+
+ private function getFormFactor(?int $width, ?int $height): ?string
+ {
+ if ($width === null || $height === null) {
+ return null;
+ }
+
+ return $width > $height ? 'wide' : 'narrow';
+ }
}
diff --git a/src/Normalizer/ShortcutNormalizer.php b/src/Normalizer/ShortcutNormalizer.php
index b5a36e8..89b6913 100644
--- a/src/Normalizer/ShortcutNormalizer.php
+++ b/src/Normalizer/ShortcutNormalizer.php
@@ -7,16 +7,20 @@
use SpomkyLabs\PwaBundle\Dto\Shortcut;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Routing\RouterInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use function assert;
use const FILTER_VALIDATE_URL;
-final readonly class ShortcutNormalizer implements NormalizerInterface
+final class ShortcutNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
+ use NormalizerAwareTrait;
+
public function __construct(
- private RouterInterface $router,
+ private readonly RouterInterface $router,
#[Autowire('%spomky_labs_pwa.routes.reference_type%')]
- private int $referenceType,
+ private readonly int $referenceType,
) {
}
@@ -33,7 +37,7 @@ public function normalize(mixed $object, string $format = null, array $context =
'short_name' => $object->shortName,
'description' => $object->description,
'url' => $url,
- 'icons' => $object->icons,
+ 'icons' => $this->normalizer->normalize($object->icons, $format, $context),
];
$cleanup = static fn (array $data): array => array_filter(
diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php
index 51de71c..26a6c7b 100644
--- a/src/Resources/config/services.php
+++ b/src/Resources/config/services.php
@@ -6,6 +6,9 @@
use SpomkyLabs\PwaBundle\ImageProcessor\GDImageProcessor;
use SpomkyLabs\PwaBundle\ImageProcessor\ImagickImageProcessor;
use SpomkyLabs\PwaBundle\Service\Builder;
+use SpomkyLabs\PwaBundle\Subscriber\PwaDevServerSubscriber;
+use SpomkyLabs\PwaBundle\Twig\PwaExtension;
+use SpomkyLabs\PwaBundle\Twig\PwaRuntime;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
@@ -46,4 +49,19 @@
->alias('pwa.image_processor.gd', GDImageProcessor::class)
;
}
+
+ $container->set(PwaDevServerSubscriber::class)
+ ->args([
+ '$profiler' => service('profiler')
+ ->nullOnInvalid(),
+ ])
+ ->tag('kernel.event_subscriber')
+ ;
+
+ $container->set(PwaExtension::class)
+ ->tag('twig.extension')
+ ;
+ $container->set(PwaRuntime::class)
+ ->tag('twig.runtime')
+ ;
};
diff --git a/src/Subscriber/PwaDevServerSubscriber.php b/src/Subscriber/PwaDevServerSubscriber.php
new file mode 100644
index 0000000..860bd6c
--- /dev/null
+++ b/src/Subscriber/PwaDevServerSubscriber.php
@@ -0,0 +1,83 @@
+manifestPublicUrl = '/' . trim($manifestPublicUrl, '/');
+ }
+
+ public function onKernelRequest(RequestEvent $event): void
+ {
+ if (! $event->isMainRequest()) {
+ return;
+ }
+
+ $pathInfo = $event->getRequest()
+ ->getPathInfo();
+ if ($pathInfo !== $this->manifestPublicUrl) {
+ return;
+ }
+
+ $body = $this->serializer->serialize($this->manifest, 'json', [
+ AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true,
+ AbstractObjectNormalizer::SKIP_NULL_VALUES => true,
+ 'json_encode_options' => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
+ ]);
+
+ $this->profiler?->disable();
+
+ $response = new Response($body, Response::HTTP_OK, [
+ 'Cache-Control' => 'public, max-age=604800, immutable',
+ 'Content-Type' => 'application/manifest+json',
+ 'X-Manifest-Dev' => true,
+ 'Etag' => hash('xxh128', $body),
+ ]);
+
+ $event->setResponse($response);
+ $event->stopPropagation();
+ }
+
+ public function onKernelResponse(ResponseEvent $event): void
+ {
+ if ($event->getResponse()->headers->get('X-Manifest-Dev')) {
+ $event->stopPropagation();
+ }
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ // priority higher than RouterListener
+ KernelEvents::REQUEST => [['onKernelRequest', 35]],
+ // Highest priority possible to bypass all other listeners
+ KernelEvents::RESPONSE => [['onKernelResponse', 2048]],
+ ];
+ }
+}
diff --git a/src/Twig/PwaRuntime.php b/src/Twig/PwaRuntime.php
index 514d89c..6d38a51 100644
--- a/src/Twig/PwaRuntime.php
+++ b/src/Twig/PwaRuntime.php
@@ -4,27 +4,32 @@
namespace SpomkyLabs\PwaBundle\Twig;
-use RuntimeException;
+use SpomkyLabs\PwaBundle\Dto\Manifest;
use Symfony\Component\AssetMapper\AssetMapperInterface;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+use const PHP_EOL;
final readonly class PwaRuntime
{
+ private string $manifestPublicUrl;
+
public function __construct(
- private AssetMapperInterface $assetMapper
+ private AssetMapperInterface $assetMapper,
+ private Manifest $manifest,
+ #[Autowire('%spomky_labs_pwa.manifest_public_url%')]
+ string $manifestPublicUrl,
) {
+ $this->manifestPublicUrl = '/' . trim($manifestPublicUrl, '/');
}
- public function load(string $filename = 'site.webmanifest'): string
+ public function load(): string
{
- $url = $this
- ->assetMapper
- ->getAsset($filename)
- ?->publicPath
- ;
- if ($url === null) {
- throw new RuntimeException(sprintf('The asset "%s" is missing.', $filename));
+ $url = $this->assetMapper->getPublicPath($this->manifestPublicUrl) ?? $this->manifestPublicUrl;
+ $output = sprintf('', $url);
+ if ($this->manifest->themeColor !== null) {
+ $output .= sprintf('%s', PHP_EOL, $this->manifest->themeColor);
}
- return sprintf('', $url);
+ return $output;
}
}
diff --git a/tests/Functional/ManifestFileDevServerTest.php b/tests/Functional/ManifestFileDevServerTest.php
new file mode 100644
index 0000000..2479ebc
--- /dev/null
+++ b/tests/Functional/ManifestFileDevServerTest.php
@@ -0,0 +1,28 @@
+request('GET', '/site.manifest');
+
+ // Then
+ static::assertResponseIsSuccessful();
+ static::assertResponseHeaderSame('Content-Type', 'application/manifest+json');
+ }
+}
diff --git a/tests/Functional/TakeScreenshotCommandTest.php b/tests/Functional/TakeScreenshotCommandTest.php
index 9f02f53..7569418 100644
--- a/tests/Functional/TakeScreenshotCommandTest.php
+++ b/tests/Functional/TakeScreenshotCommandTest.php
@@ -18,12 +18,13 @@ public static function aScreenshotIsCorrectlyTake(): void
// Given
$command = self::$application->find('pwa:take-screenshot');
$commandTester = new CommandTester($command);
- $output = sprintf('%s/samples/screenshots/screenshot-1024x1920.png', self::$kernel->getCacheDir());
+ $output = sprintf('%s/samples/screenshots/', self::$kernel->getCacheDir());
// When
$commandTester->execute([
- 'url' => 'https://localhost',
+ 'url' => 'https://symfony.com',
'output' => $output,
+ 'filename' => 'screenshot',
'--width' => '1024',
'--height' => '1920',
]);