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