From 7d0cf2298d340a2c1b915c28d8d676dbd891b08d Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Tue, 23 Apr 2024 17:20:08 +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. --- src/Dto/Manifest.php | 5 ++ src/Dto/TranslatableTrait.php | 7 ++- src/Resources/config/definition/manifest.php | 16 ++++++- .../ManifestCompileEventListener.php | 38 ++++++++++++--- src/Subscriber/PwaDevServerSubscriber.php | 46 +++++++++++++------ src/Twig/PwaRuntime.php | 20 ++++++-- 6 files changed, 105 insertions(+), 27 deletions(-) diff --git a/src/Dto/Manifest.php b/src/Dto/Manifest.php index 775c594..a947bcb 100644 --- a/src/Dto/Manifest.php +++ b/src/Dto/Manifest.php @@ -61,6 +61,11 @@ final class Manifest #[SerializedName('iarc_rating_id')] public null|string $iarcRatingId = null; + /** + * @var array + */ + public array $locales = []; + /** * @var array */ 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/Resources/config/definition/manifest.php b/src/Resources/config/definition/manifest.php index b448f49..abf866c 100644 --- a/src/Resources/config/definition/manifest.php +++ b/src/Resources/config/definition/manifest.php @@ -20,17 +20,31 @@ ->children() ->arrayNode('manifest') ->canBeEnabled() + ->validate() + ->ifTrue( + static fn (array $v) => count( + $v['locales'] + ) !== 0 && ! str_contains((string) $v['public_url'], '{locale}') + ) + ->thenInvalid( + 'When setting locales, the public URL "public_url" must contain the "{locale}" placeholder.' + ) + ->end() ->children() ->scalarNode('public_url') ->defaultValue('/site.webmanifest') ->cannotBeEmpty() ->info('The public URL of the manifest file.') - ->example('/site.manifest') + ->example(['/site.manifest', '/site.{locale}.webmanifest']) ->end() ->booleanNode('use_credentials') ->defaultTrue() ->info('Indicates whether the manifest should be fetched with credentials.') ->end() + ->arrayNode('locales') + ->defaultValue([]) + ->scalarPrototype()->end() + ->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.' diff --git a/src/Subscriber/ManifestCompileEventListener.php b/src/Subscriber/ManifestCompileEventListener.php index 4d746de..8fa182e 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; @@ -40,14 +43,14 @@ public function __construct( private PublicAssetsFilesystemInterface $assetsFilesystem, #[Autowire('%kernel.debug%')] bool $debug, - null|EventDispatcherInterface $dispatcher = null, + null|EventDispatcherInterface $dispatcher, ) { $this->dispatcher = $dispatcher ?? new NullEventDispatcher(); $this->manifestPublicUrl = '/' . trim($manifestPublicUrl, '/'); $options = [ AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true, AbstractObjectNormalizer::SKIP_NULL_VALUES => true, - AbstractNormalizer::IGNORED_ATTRIBUTES => ['useCredentials'], + AbstractNormalizer::IGNORED_ATTRIBUTES => ['useCredentials', 'locales'], JsonEncode::OPTIONS => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, ]; if ($debug === true) { @@ -62,10 +65,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->manifest->locales) === 0) { + $this->compileManifest($manifest, null); + } else { + foreach ($this->manifest->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..4ec65a7 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; @@ -51,13 +53,13 @@ public function __construct( private ServiceWorkerCompiler $serviceWorkerBuilder, private SerializerInterface $serializer, private Manifest $manifest, - private ServiceWorker $serviceWorker, + ServiceWorker $serviceWorker, #[Autowire('%spomky_labs_pwa.manifest.public_url%')] string $manifestPublicUrl, private null|Profiler $profiler, #[Autowire('%kernel.debug%')] bool $debug, - null|EventDispatcherInterface $dispatcher = null, + null|EventDispatcherInterface $dispatcher, ) { $this->dispatcher = $dispatcher ?? new NullEventDispatcher(); $this->manifestPublicUrl = '/' . trim($manifestPublicUrl, '/'); @@ -74,7 +76,7 @@ public function __construct( $options = [ AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => true, AbstractObjectNormalizer::SKIP_NULL_VALUES => true, - AbstractNormalizer::IGNORED_ATTRIBUTES => ['useCredentials'], + AbstractNormalizer::IGNORED_ATTRIBUTES => ['useCredentials', 'locales'], JsonEncode::OPTIONS => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, ]; if ($debug === true) { @@ -89,17 +91,28 @@ public function onKernelRequest(RequestEvent $event): void return; } - $pathInfo = $event->getRequest() - ->getPathInfo(); + $request = $event->getRequest(); + $pathInfo = $request->getPathInfo(); + $localizedManifestPublicUrls = []; + foreach ($this->manifest->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->manifest->serviceWorker?->enabled === true && $pathInfo === $this->serviceWorkerPublicUrl: $this->serveServiceWorker($event); break; - case $this->serviceWorker->enabled === true && $this->workboxVersion !== null && $this->workboxPublicUrl !== null && str_starts_with( + case $this->manifest->serviceWorker?->enabled === true && $this->workboxVersion !== null && $this->workboxPublicUrl !== null && str_starts_with( $pathInfo, $this->workboxPublicUrl ): @@ -129,16 +142,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', PHP_EOL, $useCredentials, $url); + return $output . sprintf('%s', PHP_EOL, $url, $useCredentials, $hreflang); } private function injectThemeColor(string $output, bool $themeColor): string