From 2faf7b7c423164e457f71d2a0b2ffeb31085e2bf Mon Sep 17 00:00:00 2001
From: Florent Morselli <florent.morselli@spomky-labs.com>
Date: Tue, 23 Apr 2024 10:53:04 +0200
Subject: [PATCH] Implement localization support in Progressive Web App bundle

Updated various modules to support localization, primarily adjusting how manifest files are generated and served. Added new "locales" configuration parameter, made necessary adjustments in manifest and service worker compilers. Additionally, refactored the twig runtime to properly handle localized manifest URLs.
---
 phpstan-baseline.neon                         |  5 ++
 src/Dto/TranslatableTrait.php                 |  7 ++-
 src/SpomkyLabsPwaBundle.php                   |  1 +
 .../ManifestCompileEventListener.php          | 41 ++++++++++++++--
 src/Subscriber/PwaDevServerSubscriber.php     | 47 ++++++++++++++-----
 src/Twig/PwaRuntime.php                       | 20 ++++++--
 6 files changed, 98 insertions(+), 23 deletions(-)

diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index b640631..0e73f87 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -665,6 +665,11 @@ parameters:
 			count: 3
 			path: src/Resources/config/definition/service_worker.php
 
+		-
+			message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#"
+			count: 1
+			path: src/Resources/config/definition/translation.php
+
 		-
 			message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:end\\(\\)\\.$#"
 			count: 1
diff --git a/src/Dto/TranslatableTrait.php b/src/Dto/TranslatableTrait.php
index a4abf34..5d67ddd 100644
--- a/src/Dto/TranslatableTrait.php
+++ b/src/Dto/TranslatableTrait.php
@@ -21,9 +21,12 @@ public function provideTranslation(null|string|array $data): null|string|Transla
             return $data;
         }
         if (is_array($data)) {
-            return array_map(fn (string $value): TranslatableInterface => new TranslatableMessage($value), $data);
+            return array_map(
+                fn (string $value): TranslatableInterface => new TranslatableMessage($value, [], 'pwa'),
+                $data
+            );
         }
 
-        return new TranslatableMessage($data);
+        return new TranslatableMessage($data, [], 'pwa');
     }
 }
diff --git a/src/SpomkyLabsPwaBundle.php b/src/SpomkyLabsPwaBundle.php
index 6dd5843..ef7571d 100644
--- a/src/SpomkyLabsPwaBundle.php
+++ b/src/SpomkyLabsPwaBundle.php
@@ -38,6 +38,7 @@ public function loadExtension(array $config, ContainerConfigurator $container, C
             $builder->setAlias('pwa.web_client', $config['web_client']);
         }
         $builder->setParameter('spomky_labs_pwa.screenshot_user_agent', $config['user_agent']);
+        $builder->setParameter('spomky_labs_pwa.locales', $config['locales']);
 
         $serviceWorkerConfig = $config['serviceworker'];
         $manifestConfig = $config['manifest'];
diff --git a/src/Subscriber/ManifestCompileEventListener.php b/src/Subscriber/ManifestCompileEventListener.php
index 4d746de..8c74380 100644
--- a/src/Subscriber/ManifestCompileEventListener.php
+++ b/src/Subscriber/ManifestCompileEventListener.php
@@ -16,7 +16,10 @@
 use Symfony\Component\Serializer\Encoder\JsonEncode;
 use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
 use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
+use Symfony\Component\Serializer\Normalizer\TranslatableNormalizer;
 use Symfony\Component\Serializer\SerializerInterface;
+use function assert;
+use function count;
 use const JSON_PRETTY_PRINT;
 use const JSON_THROW_ON_ERROR;
 use const JSON_UNESCAPED_SLASHES;
@@ -31,6 +34,9 @@
 
     private array $jsonOptions;
 
+    /**
+     * @param string[] $locales
+     */
     public function __construct(
         private SerializerInterface $serializer,
         private Manifest $manifest,
@@ -40,7 +46,9 @@ public function __construct(
         private PublicAssetsFilesystemInterface $assetsFilesystem,
         #[Autowire('%kernel.debug%')]
         bool $debug,
-        null|EventDispatcherInterface $dispatcher = null,
+        null|EventDispatcherInterface $dispatcher,
+        #[Autowire('%spomky_labs_pwa.locales%')]
+        private array $locales,
     ) {
         $this->dispatcher = $dispatcher ?? new NullEventDispatcher();
         $this->manifestPublicUrl = '/' . trim($manifestPublicUrl, '/');
@@ -62,10 +70,33 @@ public function __invoke(PreAssetsCompileEvent $event): void
             return;
         }
         $manifest = clone $this->manifest;
-        $this->dispatcher->dispatch(new PreManifestCompileEvent($manifest));
-        $data = $this->serializer->serialize($manifest, 'json', $this->jsonOptions);
+        if (count($this->locales) === 0) {
+            $this->compileManifest($manifest, null);
+        } else {
+            foreach ($this->locales as $locale) {
+                $this->compileManifest($manifest, $locale);
+            }
+        }
+    }
+
+    private function compileManifest(Manifest $manifest, null|string $locale): void
+    {
+        $preEvent = new PreManifestCompileEvent($manifest);
+        $preEvent = $this->dispatcher->dispatch($preEvent);
+        assert($preEvent instanceof PreManifestCompileEvent);
+
+        $options = $this->jsonOptions;
+        $manifestPublicUrl = $this->manifestPublicUrl;
+        if ($locale !== null) {
+            $options[TranslatableNormalizer::NORMALIZATION_LOCALE_KEY] = $locale;
+            $manifestPublicUrl = str_replace('{locale}', $locale, $this->manifestPublicUrl);
+        }
+        $data = $this->serializer->serialize($preEvent->manifest, 'json', $options);
+
         $postEvent = new PostManifestCompileEvent($manifest, $data);
-        $this->dispatcher->dispatch($postEvent);
-        $this->assetsFilesystem->write($this->manifestPublicUrl, $postEvent->data);
+        $postEvent = $this->dispatcher->dispatch($postEvent);
+        assert($postEvent instanceof PostManifestCompileEvent);
+
+        $this->assetsFilesystem->write($manifestPublicUrl, $postEvent->data);
     }
 }
diff --git a/src/Subscriber/PwaDevServerSubscriber.php b/src/Subscriber/PwaDevServerSubscriber.php
index 93d635c..183ced4 100644
--- a/src/Subscriber/PwaDevServerSubscriber.php
+++ b/src/Subscriber/PwaDevServerSubscriber.php
@@ -22,9 +22,11 @@
 use Symfony\Component\Serializer\Encoder\JsonEncode;
 use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
 use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
+use Symfony\Component\Serializer\Normalizer\TranslatableNormalizer;
 use Symfony\Component\Serializer\SerializerInterface;
 use function assert;
 use function count;
+use function in_array;
 use function is_array;
 use function is_string;
 use function mb_strlen;
@@ -47,6 +49,9 @@
 
     private array $jsonOptions;
 
+    /**
+     * @param string[] $locales
+     */
     public function __construct(
         private ServiceWorkerCompiler $serviceWorkerBuilder,
         private SerializerInterface $serializer,
@@ -57,7 +62,9 @@ public function __construct(
         private null|Profiler $profiler,
         #[Autowire('%kernel.debug%')]
         bool $debug,
-        null|EventDispatcherInterface $dispatcher = null,
+        null|EventDispatcherInterface $dispatcher,
+        #[Autowire('%spomky_labs_pwa.locales%')]
+        private array $locales,
     ) {
         $this->dispatcher = $dispatcher ?? new NullEventDispatcher();
         $this->manifestPublicUrl = '/' . trim($manifestPublicUrl, '/');
@@ -89,17 +96,28 @@ public function onKernelRequest(RequestEvent $event): void
             return;
         }
 
-        $pathInfo = $event->getRequest()
-            ->getPathInfo();
+        $request = $event->getRequest();
+        $pathInfo = $request->getPathInfo();
+        $localizedManifestPublicUrls = [];
+        foreach ($this->locales as $locale) {
+            $localizedManifestPublicUrls[$locale] = str_replace('{locale}', $locale, $this->manifestPublicUrl);
+        }
 
         switch (true) {
-            case $this->manifest->enabled === true && $pathInfo === $this->manifestPublicUrl:
+            case $this->manifest->enabled === true && in_array($pathInfo, $localizedManifestPublicUrls, true):
+                $locale = array_search($pathInfo, $localizedManifestPublicUrls, true);
+                assert(is_string($locale), 'Locale not found.');
+                $this->serveManifest($event, $locale);
+                break;
+            case $this->manifest->enabled === true && count(
+                $localizedManifestPublicUrls
+            ) === 0 && $pathInfo === $this->manifestPublicUrl:
                 $this->serveManifest($event);
                 break;
-            case $this->serviceWorker->enabled === true && $pathInfo === $this->serviceWorkerPublicUrl:
+            case $this->serviceWorkerEnabled === true && $pathInfo === $this->serviceWorkerPublicUrl:
                 $this->serveServiceWorker($event);
                 break;
-            case $this->serviceWorker->enabled === true && $this->workboxVersion !== null && $this->workboxPublicUrl !== null && str_starts_with(
+            case $this->serviceWorkerEnabled === true && $this->workboxVersion !== null && $this->workboxPublicUrl !== null && str_starts_with(
                 $pathInfo,
                 $this->workboxPublicUrl
             ):
@@ -129,16 +147,23 @@ public static function getSubscribedEvents(): array
         ];
     }
 
-    private function serveManifest(RequestEvent $event): void
+    private function serveManifest(RequestEvent $event, null|string $locale = null): void
     {
         $this->profiler?->disable();
         $manifest = clone $this->manifest;
-        $this->dispatcher->dispatch(new PreManifestCompileEvent($manifest));
-        $data = $this->serializer->serialize($manifest, 'json', $this->jsonOptions);
+        $options = $this->jsonOptions;
+        if ($locale !== null) {
+            $options[TranslatableNormalizer::NORMALIZATION_LOCALE_KEY] = $locale;
+        }
+        $preEvent = new PreManifestCompileEvent($manifest);
+        $preEvent = $this->dispatcher->dispatch($preEvent);
+        assert($preEvent instanceof PreManifestCompileEvent);
+        $data = $this->serializer->serialize($preEvent->manifest, 'json', $options);
         $postEvent = new PostManifestCompileEvent($manifest, $data);
-        $this->dispatcher->dispatch($postEvent);
+        $postEvent = $this->dispatcher->dispatch($postEvent);
+        assert($postEvent instanceof PostManifestCompileEvent);
 
-        $response = new Response($data, Response::HTTP_OK, [
+        $response = new Response($postEvent->data, Response::HTTP_OK, [
             'Cache-Control' => 'public, max-age=604800, immutable',
             'Content-Type' => 'application/manifest+json',
             'X-Manifest-Dev' => true,
diff --git a/src/Twig/PwaRuntime.php b/src/Twig/PwaRuntime.php
index 77256ae..67bc202 100644
--- a/src/Twig/PwaRuntime.php
+++ b/src/Twig/PwaRuntime.php
@@ -35,11 +35,12 @@ public function load(
         bool $injectThemeColor = true,
         bool $injectIcons = true,
         bool $injectSW = true,
-        array $swAttributes = []
+        array $swAttributes = [],
+        null|string $locale = null,
     ): string {
         $output = '';
         if ($this->manifest->enabled === true) {
-            $output = $this->injectManifestFile($output);
+            $output = $this->injectManifestFile($output, $locale);
         }
         if ($this->manifest->serviceWorker?->enabled === true) {
             $output = $this->injectServiceWorker($output, $injectSW, $swAttributes);
@@ -49,15 +50,24 @@ public function load(
         return $this->injectThemeColor($output, $injectThemeColor);
     }
 
-    private function injectManifestFile(string $output): string
+    private function injectManifestFile(string $output, null|string $locale): string
     {
-        $url = $this->assetMapper->getPublicPath($this->manifestPublicUrl) ?? $this->manifestPublicUrl;
+        $manifestPublicUrl = $locale === null ? $this->manifestPublicUrl : str_replace(
+            '{locale}',
+            $locale,
+            $this->manifestPublicUrl
+        );
+        $url = $this->assetMapper->getPublicPath($manifestPublicUrl) ?? $manifestPublicUrl;
         $useCredentials = '';
         if ($this->manifest->useCredentials === true) {
             $useCredentials = ' crossorigin="use-credentials"';
         }
+        $hreflang = '';
+        if ($locale !== null) {
+            $hreflang = sprintf(' hreflang="%s"', mb_strtolower(str_replace('_', '-', $locale)));
+        }
 
-        return $output . sprintf('%s<link rel="manifest"%s href="%s">', PHP_EOL, $useCredentials, $url);
+        return $output . sprintf('%s<link rel="manifest" href="%s"%s%s>', PHP_EOL, $url, $useCredentials, $hreflang);
     }
 
     private function injectThemeColor(string $output, bool $themeColor): string