From 59a52200ab30a797ba97543cd9babe08adb5f5a1 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Mon, 4 Mar 2024 13:42:22 +0100 Subject: [PATCH 01/12] Update SECURITY.md --- SECURITY.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index c85f447..1fb95e9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,13 +1,74 @@ -# Security Policy +# Security Release Process + +Spomky-Labs is devoted in providing the best experience for all developers. +We has adopted this security disclosure and response policy to ensure we responsibly handle critical issues. ## Supported Versions -| Version | Supported | -|---------|--------------------| -| 1.0.x | :white_check_mark: | -| < 1.0.x | :x: | +This project maintains release branches for the three most recent minor releases. +Applicable fixes, including security fixes, may be backported to those three release branches, depending on severity and feasibility. Please refer to [RELEASES.md](RELEASES.md) for details. + +## Reporting a Vulnerability - Private Disclosure Process + +Security is of the highest importance and all security vulnerabilities or suspected security vulnerabilities should be reported privately, to minimize attacks against current users before they are fixed. +Vulnerabilities will be investigated and patched on the next patch (or minor) release as soon as possible. +This information could be kept entirely internal to the project. + +If you know of a publicly disclosed security vulnerability, please **IMMEDIATELY** contact security@spomky-labs.com to inform the Security Team. + +**IMPORTANT: Do not file public issues on GitHub for security vulnerabilities** + +To report a vulnerability or a security-related issue, please email the private address security@spomky-labs.com with the details of the vulnerability. +The email will be fielded by the Security Team, which is made up of the maintainers and main contributors who have committer and release permissions. +Do not report non-security-impacting bugs through this channel. Use [GitHub issues](https://github.com/spomky-labs/phpwa/issues/new/choose) instead. + +Emails can be encrypted if you wish to share the vulnerability details securely. +The Security Team's PGP is key is available on the [PGP keyservers](https://keys.openpgp.org/search?q=security%40spomky-labs.com). + +### Proposed Email Content + +Provide a descriptive subject line and in the body of the email include the following information: + +- Basic identity information, such as your name and your affiliation or company. +- Detailed steps to reproduce the vulnerability (POC scripts, screenshots, and compressed packet captures are all helpful to us). +- Description of the effects of the vulnerability and the related hardware and software configurations, so that the Security Team can reproduce it. +- How the vulnerability affects Webauthn Framework usage and an estimation of the attack surface, if there is one. +- List other projects or dependencies that were used to produce the vulnerability. + +## When to report a vulnerability + +- When you think a potential security vulnerability exists. +- When you suspect a potential vulnerability, but you are unsure its impact. +- When you know of or suspect a potential vulnerability on another dependent project. + +## Patch, Release, and Disclosure + +The Security Team will respond to vulnerability reports as follows: + +1. The Security Team will investigate the vulnerability and determine its effects and criticality. +2. If the issue is not deemed to be a vulnerability, the Security Team will follow up with a detailed reason for rejection. +3. The Security Team will initiate a conversation with the reporter as soon as possible. +4. If a vulnerability is acknowledged and the timeline for a fix is determined, the Security Team will work on a plan to communicate with the appropriate community, including identifying mitigating steps that affected users can take to protect themselves until the fix is rolled out. +5. The Security Team will work on fixing the vulnerability and perform internal testing before preparing to roll out the fix. +6. A public disclosure date is negotiated by the Security Team and the bug submitter. We prefer to fully disclose the bug as soon as possible once a user mitigation or patch is available. It is reasonable to delay disclosure when the bug or the fix is not yet fully understood, the solution is not well-tested, or for distributor coordination. The timeframe for disclosure is from immediate (especially if it’s already publicly known) to a few weeks. For a critical vulnerability with a straightforward mitigation, we expect report date to public disclosure date to be on the order of 14 business days. The Security Team holds the final say when setting a public disclosure date. +7. Once the fix is confirmed, the Security Team will patch the vulnerability in the next patch or minor release, and backport a patch release into all earlier supported releases. Upon release of the patched version, we will follow the **Public Disclosure Process**. + +### Public Disclosure Process + +The Security Team publishes a public [advisory](https://github.com/spomky-labs/phpwa/security/advisories) to the community via GitHub. In most cases, additional communication via Twitter, blog and other channels will assist in educating users and rolling out the patched release to affected users. + +The Security Team will also publish any mitigating steps users can take until the fix can be applied to their instances. Distributors will handle creating and publishing their own security advisories. + +## Mailing lists + +- Use security@spomky-labs.com to report security concerns to the Security Team, who uses the list to privately discuss security issues and fixes prior to disclosure. + +## Early Disclosure to Distributors List + +This private list is intended to be used primarily to provide actionable information to multiple distributor projects at once. This list is not intended to inform individuals about security issues. -## Reporting a Vulnerability +## Confidentiality, integrity and availability -If you discover a security vulnerability within the project, please **don't use the bug tracker and don't publish it publicly**. -Instead, all security issues must be sent to security [at] spomky-labs.com. +We consider vulnerabilities leading to the compromise of data confidentiality, elevation of privilege, or integrity to be our highest priority concerns. +Availability, in particular in areas relating to DoS and resource exhaustion, is also a serious security concern. +The Security Team takes all vulnerabilities, potential vulnerabilities, and suspected vulnerabilities seriously and will investigate them in an urgent and expeditious manner. From 024aaf40f3a3efd90ae6f23bd99d4e412b65a183 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Mon, 4 Mar 2024 13:44:22 +0100 Subject: [PATCH 02/12] Create RELEASES.md (#87) --- RELEASES.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 RELEASES.md diff --git a/RELEASES.md b/RELEASES.md new file mode 100644 index 0000000..b3f2355 --- /dev/null +++ b/RELEASES.md @@ -0,0 +1,8 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|--------------------| +| 1.0.x | :white_check_mark: | +| < 1.0.x | :x: | From cfcf9e766a29945020fb6dd49c3f9e8d87d4f481 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Tue, 5 Mar 2024 12:09:03 +0100 Subject: [PATCH 03/12] Remove placeholders (#92) --- ecs.php | 11 +- phpstan-baseline.neon | 12 +- rector.php | 6 +- src/DependencyInjection/Configuration.php | 20 +++ src/Dto/Workbox.php | 5 + src/Resources/sw-skeleton.js | 11 +- src/Service/ServiceWorkerCompiler.php | 141 ++++++++++++---------- 7 files changed, 124 insertions(+), 82 deletions(-) diff --git a/ecs.php b/ecs.php index b3b5192..917d102 100644 --- a/ecs.php +++ b/ecs.php @@ -28,7 +28,7 @@ use PhpCsFixer\Fixer\Whitespace\ArrayIndentationFixer; use PhpCsFixer\Fixer\Whitespace\CompactNullableTypehintFixer; use PhpCsFixer\Fixer\Whitespace\MethodChainingIndentationFixer; -use Symplify\CodingStandard\Fixer\LineLength\LineLengthFixer; +use Symplify\CodingStandard\Fixer\Spacing\MethodChainingNewlineFixer; use Symplify\EasyCodingStandard\Config\ECSConfig; use Symplify\EasyCodingStandard\ValueObject\Set\SetList; @@ -92,13 +92,10 @@ $config->skip([ PhpUnitTestClassRequiresCoversFixer::class, - MethodChainingIndentationFixer::class => [__DIR__.'/src/DependencyInjection/Configuration.php'], - \Symplify\CodingStandard\Fixer\Spacing\MethodChainingNewlineFixer::class => [__DIR__.'/src/DependencyInjection/Configuration.php'], + MethodChainingIndentationFixer::class => [__DIR__ . '/src/DependencyInjection/Configuration.php'], + MethodChainingNewlineFixer::class => [__DIR__ . '/src/DependencyInjection/Configuration.php'], ]); $config->parallel(); - $config->paths([ - __DIR__.'/src', - __DIR__.'/tests', - ]); + $config->paths([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/ecs.php', __DIR__ . '/rector.php']); }; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a26fa3e..183af00 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -320,6 +320,11 @@ parameters: count: 1 path: src/Dto/Widget.php + - + message: "#^Attribute class JetBrains\\\\PhpStorm\\\\Deprecated does not exist\\.$#" + count: 4 + path: src/Dto/Workbox.php + - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$enabled\\. Give it default value or assign it in the constructor\\.$#" count: 1 @@ -468,4 +473,9 @@ parameters: - message: "#^Method SpomkyLabs\\\\PwaBundle\\\\Normalizer\\\\UrlNormalizer\\:\\:supportsNormalization\\(\\) has parameter \\$context with no value type specified in iterable type array\\.$#" count: 1 - path: src/Normalizer/UrlNormalizer.php \ No newline at end of file + path: src/Normalizer/UrlNormalizer.php + + - + message: "#^Property SpomkyLabs\\\\PwaBundle\\\\Service\\\\ServiceWorkerCompiler\\:\\:\\$jsonOptions type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Service/ServiceWorkerCompiler.php \ No newline at end of file diff --git a/rector.php b/rector.php index 9cc1e1b..fe8046d 100644 --- a/rector.php +++ b/rector.php @@ -25,10 +25,8 @@ PHPUnitSetList::ANNOTATIONS_TO_ATTRIBUTES, ]); $config->phpVersion(PhpVersion::PHP_82); - $config->paths([__DIR__ . '/src', __DIR__ . '/tests']); - $config->skip([ - __DIR__ . '/tests/Controller/DummyController.php', - ]); + $config->paths([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/ecs.php', __DIR__ . '/rector.php']); + $config->skip([__DIR__ . '/tests/Controller/DummyController.php']); $config->parallel(); $config->importNames(); $config->importShortClasses(); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 2201ad6..a4a867f 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -126,6 +126,11 @@ private function setupServiceWorker(ArrayNodeDefinition $node): void ->info('The public path to the local workbox. Only used if use_cdn is false.') ->end() ->scalarNode('workbox_import_placeholder') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. No replacement.' + ) ->defaultValue('//WORKBOX_IMPORT_PLACEHOLDER') ->info( 'The placeholder for the workbox import. Will be replaced by the workbox import.' @@ -133,6 +138,11 @@ private function setupServiceWorker(ArrayNodeDefinition $node): void ->example('//WORKBOX_IMPORT_PLACEHOLDER') ->end() ->scalarNode('standard_rules_placeholder') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. No replacement.' + ) ->defaultValue('//STANDARD_RULES_PLACEHOLDER') ->info( 'The placeholder for the standard rules. Will be replaced by caching strategies.' @@ -140,11 +150,21 @@ private function setupServiceWorker(ArrayNodeDefinition $node): void ->example('//STANDARD_RULES_PLACEHOLDER') ->end() ->scalarNode('offline_fallback_placeholder') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. No replacement.' + ) ->defaultValue('//OFFLINE_FALLBACK_PLACEHOLDER') ->info('The placeholder for the offline fallback. Will be replaced by the URL.') ->example('//OFFLINE_FALLBACK_PLACEHOLDER') ->end() ->scalarNode('widgets_placeholder') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. No replacement.' + ) ->defaultValue('//WIDGETS_PLACEHOLDER') ->info( 'The placeholder for the widgets. Will be replaced by the widgets management events.' diff --git a/src/Dto/Workbox.php b/src/Dto/Workbox.php index 62313a7..c034d78 100644 --- a/src/Dto/Workbox.php +++ b/src/Dto/Workbox.php @@ -4,6 +4,7 @@ namespace SpomkyLabs\PwaBundle\Dto; +use JetBrains\PhpStorm\Deprecated; use Symfony\Component\Serializer\Attribute\SerializedName; final class Workbox @@ -19,15 +20,19 @@ final class Workbox public string $workboxPublicUrl; #[SerializedName('workbox_import_placeholder')] + #[Deprecated('No longer used.')] public string $workboxImportPlaceholder; #[SerializedName('standard_rules_placeholder')] + #[Deprecated('No longer used.')] public string $standardRulesPlaceholder; #[SerializedName('offline_fallback_placeholder')] + #[Deprecated('No longer used.')] public string $offlineFallbackPlaceholder; #[SerializedName('widgets_placeholder')] + #[Deprecated('No longer used.')] public string $widgetsPlaceholder; #[SerializedName('page_fallback')] diff --git a/src/Resources/sw-skeleton.js b/src/Resources/sw-skeleton.js index 35ece5a..7eec157 100644 --- a/src/Resources/sw-skeleton.js +++ b/src/Resources/sw-skeleton.js @@ -1,5 +1,6 @@ -// *** Workbox Bundle rules *** -//WORKBOX_IMPORT_PLACEHOLDER -//STANDARD_RULES_PLACEHOLDER -//OFFLINE_FALLBACK_PLACEHOLDER -//WIDGETS_PLACEHOLDER +// *** Service Worker *** // +/* + This is the service worker file. It will be populated with the rules you define in the + configuration file. + You can define here custom rules depending on your application needs. + */ diff --git a/src/Service/ServiceWorkerCompiler.php b/src/Service/ServiceWorkerCompiler.php index 075bac0..08c8d9d 100644 --- a/src/Service/ServiceWorkerCompiler.php +++ b/src/Service/ServiceWorkerCompiler.php @@ -18,9 +18,12 @@ use const JSON_THROW_ON_ERROR; use const JSON_UNESCAPED_SLASHES; use const JSON_UNESCAPED_UNICODE; +use const PHP_EOL; final readonly class ServiceWorkerCompiler { + private array $jsonOptions; + public function __construct( private SerializerInterface $serializer, #[Autowire('%spomky_labs_pwa.asset_public_prefix%')] @@ -31,6 +34,9 @@ public function __construct( private ServiceWorker $serviceWorker, private AssetMapperInterface $assetMapper, ) { + $this->jsonOptions = [ + JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ]; } public function compile(): ?string @@ -52,6 +58,7 @@ public function compile(): ?string if ($workbox->enabled === true) { $body = $this->processWorkbox($workbox, $body); } + $body = $this->processWidgets($body); return $this->processSkipWaiting($body); } @@ -71,24 +78,23 @@ private function processSkipWaiting(string $body): string }); SKIP_WAITING; - return $body . trim($declaration); + return $body . PHP_EOL . PHP_EOL . trim($declaration); } private function processWorkbox(Workbox $workbox, string $body): string { $body = $this->processWorkboxImport($workbox, $body); $body = $this->processClearCache($workbox, $body); - $body = $this->processStandardRules($workbox, $body); - $body = $this->processWidgets($workbox, $body); + $body = $this->processAssetCacheRules($workbox, $body); + $body = $this->processFontCacheRules($workbox, $body); + $body = $this->processPageImageCacheRule($workbox, $body); + $body = $this->processImageCacheRule($workbox, $body); return $this->processOfflineFallback($workbox, $body); } private function processWorkboxImport(Workbox $workbox, string $body): string { - if (! str_contains($body, $workbox->workboxImportPlaceholder)) { - return $body; - } if ($workbox->useCDN === true) { $declaration = <<version}/workbox-sw.js'); @@ -101,7 +107,7 @@ private function processWorkboxImport(Workbox $workbox, string $body): string IMPORT_CDN_STRATEGY; } - return str_replace($workbox->workboxImportPlaceholder, trim($declaration), $body); + return trim($declaration) . PHP_EOL . PHP_EOL . $body; } private function processClearCache(Workbox $workbox, string $body): string @@ -123,60 +129,25 @@ private function processClearCache(Workbox $workbox, string $body): string }); CLEAR_CACHE; - return $body . trim($declaration); + return $body . PHP_EOL . PHP_EOL . trim($declaration); } - private function processStandardRules(Workbox $workbox, string $body): string + private function processAssetCacheRules(Workbox $workbox, string $body): string { - if (! str_contains($body, $workbox->standardRulesPlaceholder)) { - return $body; - } - $assets = []; - $fonts = []; foreach ($this->assetMapper->allAssets() as $asset) { if (preg_match($workbox->imageRegex, $asset->sourcePath) === 1 || preg_match( $workbox->staticRegex, $asset->sourcePath ) === 1) { $assets[] = $asset->publicPath; - } elseif (preg_match($workbox->fontRegex, $asset->sourcePath) === 1) { - $fonts[] = $asset->publicPath; } } - $jsonOptions = [ - JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, - ]; - $assetUrls = $this->serializer->serialize($assets, 'json', $jsonOptions); - $fontUrls = $this->serializer->serialize($fonts, 'json', $jsonOptions); + $assetUrls = $this->serializer->serialize($assets, 'json', $this->jsonOptions); $assetUrlsLength = count($assets) * 2; - $routes = $this->serializer->serialize($workbox->warmCacheUrls, 'json', $jsonOptions); - - $declaration = <<pageCacheName}', - networkTimeoutSeconds: {$workbox->networkTimeoutSeconds}, - warmCache: {$routes} -}); - -//Images cache -workbox.routing.registerRoute( - ({request, url}) => (request.destination === 'image' && !url.pathname.startsWith('{$this->assetPublicPrefix}')), - new workbox.strategies.CacheFirst({ - cacheName: '{$workbox->imageCacheName}', - plugins: [ - new workbox.cacheableResponse.CacheableResponsePlugin({statuses: [0, 200]}), - new workbox.expiration.ExpirationPlugin({ - maxEntries: {$workbox->maxImageCacheEntries}, - maxAgeSeconds: {$workbox->maxImageAge}, - }), - ], - }) -); + $declaration = <<assetCacheName}', plugins: [ @@ -187,7 +158,6 @@ private function processStandardRules(Workbox $workbox, string $body): string }), ], }); -// - Strategy: only the Asset Mapper public route workbox.routing.registerRoute( ({url}) => url.pathname.startsWith('{$this->assetPublicPrefix}'), assetCacheStrategy @@ -203,8 +173,23 @@ private function processStandardRules(Workbox $workbox, string $body): string event.waitUntil(Promise.all(done)); }); +ASSET_CACHE_RULE_STRATEGY; + return $body . PHP_EOL . PHP_EOL . trim($declaration); + } + + private function processFontCacheRules(Workbox $workbox, string $body): string + { + $fonts = []; + foreach ($this->assetMapper->allAssets() as $asset) { + if (preg_match($workbox->fontRegex, $asset->sourcePath) === 1) { + $fonts[] = $asset->publicPath; + } + } + $fontUrls = $this->serializer->serialize($fonts, 'json', $this->jsonOptions); + $declaration = <<fontCacheName}', plugins: [ @@ -232,39 +217,68 @@ private function processStandardRules(Workbox $workbox, string $body): string event.waitUntil(Promise.all(done)); }); +FONT_CACHE_RULE_STRATEGY; + return $body . PHP_EOL . PHP_EOL . trim($declaration); + } -STANDARD_RULE_STRATEGY; + private function processPageImageCacheRule(Workbox $workbox, string $body): string + { + $routes = $this->serializer->serialize($workbox->warmCacheUrls, 'json', $this->jsonOptions); - return str_replace($workbox->standardRulesPlaceholder, trim($declaration), $body); + $declaration = <<pageCacheName}', + networkTimeoutSeconds: {$workbox->networkTimeoutSeconds}, + warmCache: {$routes} +}); +PAGE_CACHE_RULE_STRATEGY; + + return $body . PHP_EOL . PHP_EOL . trim($declaration); + } + + private function processImageCacheRule(Workbox $workbox, string $body): string + { + $declaration = << (request.destination === 'image' && !url.pathname.startsWith('{$this->assetPublicPrefix}')), + new workbox.strategies.CacheFirst({ + cacheName: '{$workbox->imageCacheName}', + plugins: [ + new workbox.cacheableResponse.CacheableResponsePlugin({statuses: [0, 200]}), + new workbox.expiration.ExpirationPlugin({ + maxEntries: {$workbox->maxImageCacheEntries}, + maxAgeSeconds: {$workbox->maxImageAge}, + }), + ], + }) +); +IMAGE_CACHE_RULE_STRATEGY; + + return $body . PHP_EOL . PHP_EOL . trim($declaration); } private function processOfflineFallback(Workbox $workbox, string $body): string { - if (! str_contains($body, $workbox->offlineFallbackPlaceholder)) { - return $body; - } if ($workbox->pageFallback === null && $workbox->imageFallback === null && $workbox->fontFallback === null) { return $body; } - - $jsonOptions = [ - JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, - ]; $pageFallback = $workbox->pageFallback === null ? 'null' : $this->serializer->serialize( $workbox->pageFallback, 'json', - $jsonOptions + $this->jsonOptions ); $imageFallback = $workbox->imageFallback === null ? 'null' : $this->serializer->serialize( $workbox->imageFallback, 'json', - $jsonOptions + $this->jsonOptions ); $fontFallback = $workbox->fontFallback === null ? 'null' : $this->serializer->serialize( $workbox->fontFallback, 'json', - $jsonOptions + $this->jsonOptions ); $declaration = <<offlineFallbackPlaceholder, trim($declaration), $body); + return $body . PHP_EOL . PHP_EOL . trim($declaration); } - private function processWidgets(Workbox $workbox, string $body): string + private function processWidgets(string $body): string { - if (! str_contains($body, $workbox->widgetsPlaceholder)) { - return $body; - } $tags = []; foreach ($this->manifest->widgets as $widget) { if ($widget->tag !== null) { @@ -357,6 +368,6 @@ private function processWidgets(Workbox $workbox, string $body): string } OFFLINE_FALLBACK_STRATEGY; - return str_replace($workbox->widgetsPlaceholder, trim($declaration), $body); + return $body . PHP_EOL . PHP_EOL . trim($declaration); } } From 92f91c533c09031c796da95983d46c1162defc0f Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Tue, 5 Mar 2024 12:33:30 +0100 Subject: [PATCH 04/12] Refactoring ideas (#90) Refactoring ideas --- ecs.php | 4 +- phpstan-baseline.neon | 127 ++- src/Command/CreateServiceWorkerCommand.php | 6 +- src/DependencyInjection/Configuration.php | 828 ------------------ .../SpomkyLabsPwaExtension.php | 91 -- .../config/definition/asset_public_prefix.php | 16 + .../config/definition/image_processor.php | 17 + src/Resources/config/definition/manifest.php | 139 +++ .../config/definition/path_type_reference.php | 32 + .../config/definition/service_worker.php | 200 +++++ .../config/definition/utils/file_handlers.php | 38 + .../config/definition/utils/icons.php | 65 ++ .../definition/utils/launch_handler.php | 32 + .../definition/utils/protocol_handlers.php | 31 + .../definition/utils/related_applications.php | 39 + .../config/definition/utils/screenshots.php | 58 ++ .../config/definition/utils/shared_target.php | 56 ++ .../config/definition/utils/shortcuts.php | 40 + .../config/definition/utils/url_node.php | 42 + .../config/definition/utils/widgets.php | 78 ++ .../config/definition/web_client.php | 15 + src/SpomkyLabsPwaBundle.php | 74 +- src/Subscriber/PwaDevServerSubscriber.php | 10 +- .../WorkboxCompileEventListener.php | 8 +- 24 files changed, 1072 insertions(+), 974 deletions(-) delete mode 100644 src/DependencyInjection/Configuration.php delete mode 100644 src/DependencyInjection/SpomkyLabsPwaExtension.php create mode 100644 src/Resources/config/definition/asset_public_prefix.php create mode 100644 src/Resources/config/definition/image_processor.php create mode 100644 src/Resources/config/definition/manifest.php create mode 100644 src/Resources/config/definition/path_type_reference.php create mode 100644 src/Resources/config/definition/service_worker.php create mode 100644 src/Resources/config/definition/utils/file_handlers.php create mode 100644 src/Resources/config/definition/utils/icons.php create mode 100644 src/Resources/config/definition/utils/launch_handler.php create mode 100644 src/Resources/config/definition/utils/protocol_handlers.php create mode 100644 src/Resources/config/definition/utils/related_applications.php create mode 100644 src/Resources/config/definition/utils/screenshots.php create mode 100644 src/Resources/config/definition/utils/shared_target.php create mode 100644 src/Resources/config/definition/utils/shortcuts.php create mode 100644 src/Resources/config/definition/utils/url_node.php create mode 100644 src/Resources/config/definition/utils/widgets.php create mode 100644 src/Resources/config/definition/web_client.php diff --git a/ecs.php b/ecs.php index 917d102..62aa8e7 100644 --- a/ecs.php +++ b/ecs.php @@ -92,8 +92,8 @@ $config->skip([ PhpUnitTestClassRequiresCoversFixer::class, - MethodChainingIndentationFixer::class => [__DIR__ . '/src/DependencyInjection/Configuration.php'], - MethodChainingNewlineFixer::class => [__DIR__ . '/src/DependencyInjection/Configuration.php'], + MethodChainingIndentationFixer::class => [__DIR__ . '/src/Resources/config'], + MethodChainingNewlineFixer::class => [__DIR__ . '/src/Resources/config'], ]); $config->parallel(); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 183af00..f786269 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -100,41 +100,6 @@ parameters: count: 1 path: src/Command/CreateServiceWorkerCommand.php - - - message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" - count: 4 - path: src/DependencyInjection/Configuration.php - - - - message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:append\\(\\)\\.$#" - count: 2 - path: src/DependencyInjection/Configuration.php - - - - message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:beforeNormalization\\(\\)\\.$#" - count: 1 - path: src/DependencyInjection/Configuration.php - - - - message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:end\\(\\)\\.$#" - count: 1 - path: src/DependencyInjection/Configuration.php - - - - message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:scalarNode\\(\\)\\.$#" - count: 5 - path: src/DependencyInjection/Configuration.php - - - - message: "#^Cannot access offset 'public_prefix' on mixed\\.$#" - count: 1 - path: src/DependencyInjection/SpomkyLabsPwaExtension.php - - - - message: "#^Method SpomkyLabs\\\\PwaBundle\\\\DependencyInjection\\\\SpomkyLabsPwaExtension\\:\\:getConfiguration\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" - count: 1 - path: src/DependencyInjection/SpomkyLabsPwaExtension.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\File has an uninitialized property \\$accept\\. Give it default value or assign it in the constructor\\.$#" count: 1 @@ -475,7 +440,97 @@ parameters: count: 1 path: src/Normalizer/UrlNormalizer.php + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/asset_public_prefix.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/image_processor.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/manifest.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/path_type_reference.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:end\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/utils/file_handlers.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/utils/icons.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:beforeNormalization\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/utils/launch_handler.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:append\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/utils/protocol_handlers.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:append\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/utils/related_applications.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/utils/screenshots.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:scalarNode\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/utils/shared_target.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:scalarNode\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/utils/shortcuts.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/utils/url_node.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:scalarNode\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/utils/widgets.php + + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeDefinition\\:\\:children\\(\\)\\.$#" + count: 1 + path: src/Resources/config/definition/web_client.php + - message: "#^Property SpomkyLabs\\\\PwaBundle\\\\Service\\\\ServiceWorkerCompiler\\:\\:\\$jsonOptions type has no value type specified in iterable type array\\.$#" count: 1 - path: src/Service/ServiceWorkerCompiler.php \ No newline at end of file + path: src/Service/ServiceWorkerCompiler.php + + - + message: "#^Cannot access offset 'public_prefix' on mixed\\.$#" + count: 1 + path: src/SpomkyLabsPwaBundle.php + + - + message: "#^Method SpomkyLabs\\\\PwaBundle\\\\SpomkyLabsPwaBundle\\:\\:loadExtension\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#" + count: 1 + path: src/SpomkyLabsPwaBundle.php \ No newline at end of file diff --git a/src/Command/CreateServiceWorkerCommand.php b/src/Command/CreateServiceWorkerCommand.php index a5ac1d9..c5f3483 100644 --- a/src/Command/CreateServiceWorkerCommand.php +++ b/src/Command/CreateServiceWorkerCommand.php @@ -5,6 +5,7 @@ namespace SpomkyLabs\PwaBundle\Command; use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\Config\FileLocator; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -14,7 +15,6 @@ use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Filesystem\Filesystem; -use Symfony\Component\HttpKernel\Config\FileLocator; use Symfony\Component\Yaml\Yaml; use function count; @@ -24,7 +24,6 @@ final class CreateServiceWorkerCommand extends Command public function __construct( private readonly AssetMapperInterface $assetMapper, private readonly Filesystem $filesystem, - private readonly FileLocator $fileLocator, #[Autowire('%spomky_labs_pwa.asset_public_prefix%')] private readonly string $assetPublicPrefix, #[Autowire('%kernel.project_dir%')] @@ -57,7 +56,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::SUCCESS; } - $resourcePath = $this->fileLocator->locate('@SpomkyLabsPwaBundle/Resources/sw-skeleton.js', null, false); + $fileLocator = new FileLocator(__DIR__ . '/../Resources'); + $resourcePath = $fileLocator->locate('sw-skeleton.js', null, false); if (count($resourcePath) !== 1) { $io->error('Unable to find the Workbox resource.'); return Command::FAILURE; diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php deleted file mode 100644 index a4a867f..0000000 --- a/src/DependencyInjection/Configuration.php +++ /dev/null @@ -1,828 +0,0 @@ -alias); - $rootNode = $treeBuilder->getRootNode(); - assert($rootNode instanceof ArrayNodeDefinition); - $rootNode->addDefaultsIfNotSet(); - - $this->setupServices($rootNode); - $this->setupManifest($rootNode); - $this->setupServiceWorker($rootNode); - - return $treeBuilder; - } - - private function setupServices(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.') - ->example(GDImageProcessor::class) - ->end() - ->scalarNode('asset_public_prefix') - ->cannotBeOverwritten() - ->defaultNull() - ->info('The public prefix of the assets. Shall be the same as the one used in the asset mapper.') - ->end() - ->scalarNode('web_client') - ->defaultNull() - ->info('The Panther Client for generating screenshots. If not set, the default client will be used.') - ->end() - ->end(); - } - - private function setupServiceWorker(ArrayNodeDefinition $node): void - { - $node->children() - ->arrayNode('serviceworker') - ->canBeEnabled() - ->beforeNormalization() - ->ifString() - ->then(static fn (string $v): array => [ - 'enabled' => true, - 'src' => $v, - ]) - ->end() - ->children() - ->scalarNode('src') - ->isRequired() - ->info('The path to the service worker source file. Can be served by Asset Mapper.') - ->example('script/sw.js') - ->end() - ->scalarNode('dest') - ->cannotBeEmpty() - ->defaultValue('/sw.js') - ->info('The public URL to the service worker.') - ->example('/sw.js') - ->end() - ->booleanNode('skip_waiting') - ->defaultFalse() - ->info('Whether to skip waiting for the service worker to be activated.') - ->end() - ->arrayNode('workbox') - ->info('The configuration of the workbox.') - ->canBeDisabled() - ->children() - ->booleanNode('use_cdn') - ->defaultFalse() - ->info('Whether to use the local workbox or the CDN.') - ->end() - ->scalarNode('version') - ->defaultValue('7.0.0') - ->info( - 'The version of workbox. When using local files, the version shall be "7.0.0."' - ) - ->end() - ->scalarNode('workbox_public_url') - ->defaultValue('/workbox') - ->info('The public path to the local workbox. Only used if use_cdn is false.') - ->end() - ->scalarNode('workbox_import_placeholder') - ->setDeprecated( - 'spomky-labs/phpwa', - '1.1.0', - 'The "%node%" option is deprecated and will be removed in 2.0.0. No replacement.' - ) - ->defaultValue('//WORKBOX_IMPORT_PLACEHOLDER') - ->info( - 'The placeholder for the workbox import. Will be replaced by the workbox import.' - ) - ->example('//WORKBOX_IMPORT_PLACEHOLDER') - ->end() - ->scalarNode('standard_rules_placeholder') - ->setDeprecated( - 'spomky-labs/phpwa', - '1.1.0', - 'The "%node%" option is deprecated and will be removed in 2.0.0. No replacement.' - ) - ->defaultValue('//STANDARD_RULES_PLACEHOLDER') - ->info( - 'The placeholder for the standard rules. Will be replaced by caching strategies.' - ) - ->example('//STANDARD_RULES_PLACEHOLDER') - ->end() - ->scalarNode('offline_fallback_placeholder') - ->setDeprecated( - 'spomky-labs/phpwa', - '1.1.0', - 'The "%node%" option is deprecated and will be removed in 2.0.0. No replacement.' - ) - ->defaultValue('//OFFLINE_FALLBACK_PLACEHOLDER') - ->info('The placeholder for the offline fallback. Will be replaced by the URL.') - ->example('//OFFLINE_FALLBACK_PLACEHOLDER') - ->end() - ->scalarNode('widgets_placeholder') - ->setDeprecated( - 'spomky-labs/phpwa', - '1.1.0', - 'The "%node%" option is deprecated and will be removed in 2.0.0. No replacement.' - ) - ->defaultValue('//WIDGETS_PLACEHOLDER') - ->info( - 'The placeholder for the widgets. Will be replaced by the widgets management events.' - ) - ->example('//WIDGETS_PLACEHOLDER') - ->end() - ->booleanNode('clear_cache') - ->defaultTrue() - ->info('Whether to clear the cache during the service worker activation.') - ->end() - ->scalarNode('image_cache_name') - ->defaultValue('images') - ->info('The name of the image cache.') - ->end() - ->scalarNode('font_cache_name') - ->defaultValue('fonts') - ->info('The name of the font cache.') - ->end() - ->scalarNode('page_cache_name') - ->defaultValue('pages') - ->info('The name of the page cache.') - ->end() - ->scalarNode('asset_cache_name') - ->defaultValue('assets') - ->info('The name of the asset cache.') - ->end() - ->append($this->getUrlNode('page_fallback', 'The URL of the offline page fallback.')) - ->append($this->getUrlNode('image_fallback', 'The URL of the offline image fallback.')) - ->append($this->getUrlNode('font_fallback', 'The URL of the offline font fallback.')) - ->scalarNode('image_regex') - ->defaultValue('/\.(ico|png|jpe?g|gif|svg|webp|bmp)$/') - ->info('The regex to match the images.') - ->example('/\.(ico|png|jpe?g|gif|svg|webp|bmp)$/') - ->end() - ->scalarNode('static_regex') - ->defaultValue('/\.(css|js|json|xml|txt|map|webmanifest)$/') - ->info('The regex to match the static files.') - ->example('/\.(css|js|json|xml|txt|woff2|ttf|eot|otf|map|webmanifest)$/') - ->end() - ->scalarNode('font_regex') - ->defaultValue('/\.(ttf|eot|otf|woff2)$/') - ->info('The regex to match the static files.') - ->example('/\.(ttf|eot|otf|woff2)$/') - ->end() - ->integerNode('max_image_cache_entries') - ->defaultValue(60) - ->info('The maximum number of entries in the image cache.') - ->example([50, 100, 200]) - ->end() - ->integerNode('max_image_age') - ->defaultValue(60 * 60 * 24 * 365) - ->info('The maximum number of seconds before the image cache is invalidated.') - ->example([60 * 60 * 24 * 365, 60 * 60 * 24 * 30, 60 * 60 * 24 * 7]) - ->end() - ->integerNode('max_font_cache_entries') - ->defaultValue(30) - ->info('The maximum number of entries in the font cache.') - ->example([30, 50, 100]) - ->end() - ->integerNode('max_font_age') - ->defaultValue(60 * 60 * 24 * 365) - ->info('The maximum number of seconds before the font cache is invalidated.') - ->example([60 * 60 * 24 * 365, 60 * 60 * 24 * 30, 60 * 60 * 24 * 7]) - ->end() - ->integerNode('network_timeout_seconds') - ->defaultValue(3) - ->info( - 'The network timeout in seconds before cache is called (for warm cache URLs only).' - ) - ->example([1, 2, 5]) - ->end() - ->arrayNode('warm_cache_urls') - ->treatNullLike([]) - ->treatFalseLike([]) - ->treatTrueLike([]) - ->info('The URLs to warm the cache. The URLs shall be served by the application.') - ->arrayPrototype() - ->beforeNormalization() - ->ifString() - ->then(static fn (string $v): array => [ - 'path' => $v, - ]) - ->end() - ->children() - ->scalarNode('path') - ->isRequired() - ->info('The URL of the shortcut.') - ->example('app_homepage') - ->end() - ->arrayNode('params') - ->treatFalseLike([]) - ->treatTrueLike([]) - ->treatNullLike([]) - ->prototype('variable')->end() - ->info('The parameters of the action.') - ->end() - ->end() - ->end() - ->end() - ->end() - ->end() - ->scalarNode('scope') - ->cannotBeEmpty() - ->defaultValue('/') - ->info('The scope of the service worker.') - ->example('/app/') - ->end() - ->booleanNode('use_cache') - ->defaultTrue() - ->info('Whether the service worker should use the cache.') - ->end() - ->end() - ->end() - ->end() - ->end(); - } - - private function setupShortcuts(): ArrayNodeDefinition - { - $treeBuilder = new TreeBuilder('shortcuts'); - $node = $treeBuilder->getRootNode(); - assert($node instanceof ArrayNodeDefinition); - $node - ->treatFalseLike([]) - ->treatTrueLike([]) - ->treatNullLike([]) - ->info('The shortcuts of the application.') - ->arrayPrototype() - ->children() - ->scalarNode('name') - ->isRequired() - ->info('The name of the shortcut.') - ->example('Awesome shortcut') - ->end() - ->scalarNode('short_name') - ->info('The short name of the shortcut.') - ->example('shortcut') - ->end() - ->scalarNode('description') - ->info('The description of the shortcut.') - ->example('This is an awesome shortcut') - ->end() - ->append($this->getUrlNode('url', 'The URL of the shortcut.')) - ->append($this->getIconsNode('The icons of the shortcut.')) - ->end() - ->end() - ->end(); - - return $node; - } - - private function getFileHandlersNode(): ArrayNodeDefinition - { - $treeBuilder = new TreeBuilder('file_handlers'); - $node = $treeBuilder->getRootNode(); - assert($node instanceof ArrayNodeDefinition); - - $node->info( - 'It specifies an array of objects representing the types of files an installed progressive web app (PWA) can handle.' - ) - ->treatFalseLike([]) - ->treatTrueLike([]) - ->treatNullLike([]) - ->arrayPrototype() - ->children() - ->append($this->getUrlNode('action', 'The action to take.', ['/handle-audio-file'])) - ->arrayNode('accept') - ->requiresAtLeastOneElement() - ->useAttributeAsKey('name') - ->arrayPrototype() - ->scalarPrototype()->end() - ->end() - ->info('The file types that the action will be applied to.') - ->example('image/*') - ->end() - ->end() - ->end() - ->end(); - - return $node; - } - - private function setupSharedTarget(): ArrayNodeDefinition - { - $treeBuilder = new TreeBuilder('share_target'); - $node = $treeBuilder->getRootNode(); - assert($node instanceof ArrayNodeDefinition); - - $node - ->treatFalseLike([]) - ->treatTrueLike([]) - ->treatNullLike([]) - ->info('The share target of the application.') - ->children() - ->append( - $this->getUrlNode('action', 'The action of the share target.', ['/shared-content-receiver/']) - ) - ->scalarNode('method') - ->info('The method of the share target.') - ->example('GET') - ->end() - ->scalarNode('enctype') - ->info('The enctype of the share target. Ignored if method is GET.') - ->example('multipart/form-data') - ->end() - ->arrayNode('params') - ->isRequired() - ->info('The parameters of the share target.') - ->children() - ->scalarNode('title') - ->info('The title of the share target.') - ->example('name') - ->end() - ->scalarNode('text') - ->info('The text of the share target.') - ->example('description') - ->end() - ->scalarNode('url') - ->info('The URL of the share target.') - ->example('link') - ->end() - ->arrayNode('files') - ->info('The files of the share target.') - ->scalarPrototype()->end() - ->end() - ->end() - ->end() - ->end() - ->end(); - - return $node; - } - - private function getProtocolHandlersNode(): ArrayNodeDefinition - { - $treeBuilder = new TreeBuilder('protocol_handlers'); - $node = $treeBuilder->getRootNode(); - assert($node instanceof ArrayNodeDefinition); - - $node->info('The protocol handlers of the application.') - ->treatFalseLike([]) - ->treatTrueLike([]) - ->treatNullLike([]) - ->arrayPrototype() - ->children() - ->scalarNode('protocol') - ->isRequired() - ->info('The protocol of the handler.') - ->example('web+jngl') - ->end() - ->append($this->getUrlNode('url', 'The URL of the handler.')) - ->end() - ->end() - ->end(); - - return $node; - } - - private function getLaunchHandlerNode(): ArrayNodeDefinition - { - $treeBuilder = new TreeBuilder('launch_handler'); - $node = $treeBuilder->getRootNode(); - assert($node instanceof ArrayNodeDefinition); - - $node->info('The launch handler of the application.') - ->treatFalseLike([]) - ->treatTrueLike([]) - ->treatNullLike([]) - ->children() - ->arrayNode('client_mode') - ->info('The client mode of the application.') - ->example(['focus-existing', 'auto']) - ->scalarPrototype()->end() - ->beforeNormalization() - ->castToArray() - ->end() - ->end() - ->end() - ->end(); - - return $node; - } - - private function setupRelatedApplications(): ArrayNodeDefinition - { - $treeBuilder = new TreeBuilder('related_applications'); - $node = $treeBuilder->getRootNode(); - assert($node instanceof ArrayNodeDefinition); - $node - ->treatFalseLike([]) - ->treatTrueLike([]) - ->treatNullLike([]) - ->info('The related applications of the application.') - ->arrayPrototype() - ->children() - ->scalarNode('platform') - ->isRequired() - ->info('The platform of the application.') - ->example('play') - ->end() - ->append( - $this->getUrlNode('url', 'The URL of the application.', [ - 'https://play.google.com/store/apps/details?id=com.example.app1', - ]) - ) - ->scalarNode('id') - ->info('The ID of the application.') - ->example('com.example.app1') - ->end() - ->end() - ->end() - ->end(); - - return $node; - } - - private function setupManifest(ArrayNodeDefinition $node): void - { - $node->children() - ->arrayNode('manifest') - ->canBeEnabled() - ->children() - ->scalarNode('public_url') - ->defaultValue('/site.webmanifest') - ->cannotBeEmpty() - ->info('The public URL of the manifest file.') - ->example('/site.manifest') - ->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.' - ) - ->example('red') - ->end() - ->arrayNode('categories') - ->info('The categories of the application.') - ->example([['news', 'sports', 'lifestyle']]) - ->scalarPrototype()->end() - ->end() - ->scalarNode('description') - ->info('The description of the application.') - ->example('My awesome application') - ->end() - ->scalarNode('display') - ->info('The display mode of the application.') - ->example('standalone') - ->end() - ->arrayNode('display_override') - ->info( - 'A sequence of display modes that the browser will consider before using the display member.' - ) - ->example([['fullscreen', 'minimal-ui']]) - ->scalarPrototype()->end() - ->end() - ->scalarNode('id') - ->info('A string that represents the identity of the web application.') - ->example('?homescreen=1') - ->end() - ->scalarNode('orientation') - ->info('The orientation of the application.') - ->example('portrait-primary') - ->end() - ->scalarNode('dir') - ->info('The direction of the application.') - ->example('rtl') - ->end() - ->scalarNode('lang') - ->info('The language of the application.') - ->example('ar') - ->end() - ->scalarNode('name') - ->info('The name of the application.') - ->example('My awesome application') - ->end() - ->scalarNode('short_name') - ->info('The short name of the application.') - ->example('awesome_app') - ->end() - ->scalarNode('scope') - ->info('The scope of the application.') - ->example('/app/') - ->end() - ->scalarNode('start_url') - ->info('The start URL of the application.') - ->example('https://example.com') - ->end() - ->scalarNode('theme_color') - ->info('The theme color of the application.') - ->example('red') - ->end() - ->arrayNode('edge_side_panel') - ->info('Specifies whether or not your app supports the side panel view in Microsoft Edge.') - ->children() - ->integerNode('preferred_width') - ->info('Specifies the preferred width of the side panel view in Microsoft Edge.') - ->end() - ->end() - ->end() - ->scalarNode('iarc_rating_id') - ->info( - 'Specifies the International Age Rating Coalition (IARC) rating ID for the app. See https://www.globalratings.com/how-iarc-works.aspx for more information.' - ) - ->end() - ->arrayNode('scope_extensions') - ->info( - 'Specifies a list of origin patterns to associate with. This allows for your app to control multiple subdomains and top-level domains as a single entity.' - ) - ->arrayPrototype() - ->children() - ->scalarNode('origin') - ->isRequired() - ->info('Specifies the origin pattern to associate with.') - ->example('*.foo.com') - ->end() - ->end() - ->end() - ->end() - ->scalarNode('handle_links') - ->info('Specifies the default link handling for the web app.') - ->example(['auto', 'preferred', 'not-preferred']) - ->end() - ->append($this->getIconsNode('The icons of the application.')) - ->append($this->getScreenshotsNode('The screenshots of the application.')) - ->append($this->getFileHandlersNode()) - ->append($this->getLaunchHandlerNode()) - ->append($this->getProtocolHandlersNode()) - ->booleanNode('prefer_related_applications') - ->info('The prefer related native applications of the application.') - ->end() - ->append($this->setupRelatedApplications()) - ->append($this->setupShortcuts()) - ->append($this->setupSharedTarget()) - ->append($this->setupWidgets()) - ->end() - ->end() - ->end(); - } - - private function getIconsNode(string $info): ArrayNodeDefinition - { - $treeBuilder = new TreeBuilder('icons'); - $node = $treeBuilder->getRootNode(); - assert($node instanceof ArrayNodeDefinition); - $node->info($info) - ->treatFalseLike([]) - ->treatTrueLike([]) - ->treatNullLike([]) - ->arrayPrototype() - ->beforeNormalization() - ->ifString() - ->then(static fn (string $v): array => [ - 'src' => $v, - ]) - ->end() - ->children() - ->scalarNode('src') - ->isRequired() - ->info('The path to the icon. Can be served by Asset Mapper.') - ->example('icon/logo.svg') - ->end() - ->arrayNode('sizes') - ->beforeNormalization() - ->ifTrue(static fn (mixed $v): bool => is_int($v)) - ->then(static fn (int $v): array => [$v]) - ->end() - ->beforeNormalization() - ->ifTrue(static fn (mixed $v): bool => is_string($v)) - ->then(static function (string $v): array { - if ($v === 'any') { - return [0]; - } - - return [(int) $v]; - }) - ->end() - ->info( - 'The sizes of the icon. 16 means 16x16, 32 means 32x32, etc. 0 means "any" (i.e. it is a vector image).' - ) - ->example([['16', '32']]) - ->integerPrototype()->end() - ->end() - ->scalarNode('type') - ->info('The icon mime type.') - ->example(['image/webp', 'image/png']) - ->end() - ->scalarNode('purpose') - ->info('The purpose of the icon.') - ->example(['any', 'maskable', 'monochrome']) - ->end() - ->end() - ->end() - ; - - return $node; - } - - private function getScreenshotsNode(string $info): ArrayNodeDefinition - { - $treeBuilder = new TreeBuilder('screenshots'); - $node = $treeBuilder->getRootNode(); - assert($node instanceof ArrayNodeDefinition); - $node - ->info($info) - ->treatFalseLike([]) - ->treatTrueLike([]) - ->treatNullLike([]) - ->arrayPrototype() - ->beforeNormalization() - ->ifString() - ->then(static fn (string $v): array => [ - 'src' => $v, - ]) - ->end() - ->children() - ->scalarNode('src') - ->info('The path to the screenshot. Can be served by Asset Mapper.') - ->example('screenshot/lowres.webp') - ->end() - ->scalarNode('height') - ->defaultNull() - ->example('1080') - ->end() - ->scalarNode('width') - ->defaultNull() - ->example('1080') - ->end() - ->scalarNode('form_factor') - ->info('The form factor of the screenshot. Will guess the form factor if not set.') - ->example(['wide', 'narrow']) - ->end() - ->scalarNode('label') - ->info('The label of the screenshot.') - ->example('Homescreen of Awesome App') - ->end() - ->scalarNode('platform') - ->info('The platform of the screenshot.') - ->example( - ['android', 'windows', 'chromeos', 'ipados', 'ios', 'kaios', 'macos', 'windows', 'xbox'] - ) - ->end() - ->scalarNode('format') - ->info('The format of the screenshot. Will convert the file if set.') - ->example(['image/jpg', 'image/png', 'image/webp']) - ->end() - ->end() - ->end(); - - return $node; - } - - private function setupWidgets(): ArrayNodeDefinition - { - $treeBuilder = new TreeBuilder('widgets'); - $node = $treeBuilder->getRootNode(); - assert($node instanceof ArrayNodeDefinition); - $node - ->info( - 'EXPERIMENTAL. Specifies PWA-driven widgets. See https://learn.microsoft.com/en-us/microsoft-edge/progressive-web-apps-chromium/how-to/widgets for more information' - ) - ->arrayPrototype() - ->children() - ->scalarNode('name') - ->isRequired() - ->info('The title of the widget, presented to users.') - ->end() - ->scalarNode('short_name') - ->info('An alternative short version of the name.') - ->end() - ->scalarNode('description') - ->isRequired() - ->info('The description of the widget.') - ->example('My awesome widget') - ->end() - ->append( - $this->getIconsNode( - 'An array of icons to be used for the widget. If missing, the icons manifest member is used instead. Icons larger than 1024x1024 are ignored.' - ) - ) - ->append( - $this->getScreenshotsNode('The screenshots of the widget')->requiresAtLeastOneElement() - ) - ->scalarNode('tag') - ->isRequired() - ->info('A string used to reference the widget in the PWA service worker.') - ->end() - ->scalarNode('template') - ->info( - 'The template to use to display the widget in the operating system widgets dashboard. Note: this property is currently only informational and not used. See ms_ac_template below.' - ) - ->end() - ->append( - $this->getUrlNode( - 'ms_ac_template', - 'The URL of the custom Adaptive Cards template to use to display the widget in the operating system widgets dashboard.' - ) - ) - ->append( - $this->getUrlNode( - 'data', - 'The URL where the data to fill the template with can be found. If present, this URL is required to return valid JSON.' - ) - ) - ->scalarNode('type') - ->info('The MIME type for the widget data.') - ->end() - ->booleanNode('auth') - ->info('A boolean indicating if the widget requires authentication.') - ->end() - ->integerNode('update') - ->info( - 'The frequency, in seconds, at which the widget will be updated. Code in your service worker must perform the updating; the widget is not updated automatically. See Access widget instances at runtime.' - ) - ->end() - ->booleanNode('multiple') - ->defaultTrue() - ->info( - 'A boolean indicating whether to allow multiple instances of the widget. Defaults to true.' - ) - ->end() - ->end() - ->end() - ->end(); - - return $node; - } - - /** - * @param array $examples - */ - private function getUrlNode(string $name, string $info, null|array $examples = null): ArrayNodeDefinition - { - $treeBuilder = new TreeBuilder($name); - $node = $treeBuilder->getRootNode(); - assert($node instanceof ArrayNodeDefinition); - $node - ->info($info) - ->beforeNormalization() - ->ifString() - ->then(static fn (string $v): array => [ - 'path' => $v, - ]) - ->end() - ->children() - ->scalarNode('path') - ->isRequired() - ->info('The URL or route name.') - ->example($examples ?? ['https://example.com', 'app_action_route', '/do/action']) - ->end() - ->arrayNode('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(); - - return $node; - } -} diff --git a/src/DependencyInjection/SpomkyLabsPwaExtension.php b/src/DependencyInjection/SpomkyLabsPwaExtension.php deleted file mode 100644 index 6bddbec..0000000 --- a/src/DependencyInjection/SpomkyLabsPwaExtension.php +++ /dev/null @@ -1,91 +0,0 @@ -processConfiguration($this->getConfiguration($configs, $container), $configs); - $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); - $loader->load('services.php'); - - if ($config['image_processor'] !== null) { - $container->setAlias(ImageProcessor::class, $config['image_processor']); - } - if ($config['web_client'] !== null) { - $container->setAlias('pwa.web_client', $config['web_client']); - } - $container->setParameter( - 'spomky_labs_pwa.asset_public_prefix', - '/' . trim((string) $config['asset_public_prefix'], '/') - ); - $container->setParameter('spomky_labs_pwa.routes.reference_type', $config['path_type_reference']); - $serviceWorkerConfig = $config['serviceworker']; - $manifestConfig = $config['manifest']; - if ($serviceWorkerConfig['enabled'] === true && $manifestConfig['enabled'] === true) { - $manifestConfig['serviceworker'] = $serviceWorkerConfig; - } - - /*** Manifest ***/ - $container->setParameter('spomky_labs_pwa.manifest.enabled', $config['manifest']['enabled']); - $container->setParameter('spomky_labs_pwa.manifest.public_url', $config['manifest']['public_url'] ?? null); - $container->setParameter('spomky_labs_pwa.manifest.config', $manifestConfig); - - /*** Service Worker ***/ - $container->setParameter('spomky_labs_pwa.sw.enabled', $config['serviceworker']['enabled']); - $container->setParameter('spomky_labs_pwa.sw.public_url', $config['serviceworker']['dest'] ?? null); - $container->setParameter('spomky_labs_pwa.sw.config', $serviceWorkerConfig); - - if (! in_array($container->getParameter('kernel.environment'), ['dev', 'test'], true)) { - $container->removeDefinition(PwaDevServerSubscriber::class); - } - } - - public function getConfiguration(array $config, ContainerBuilder $container): ConfigurationInterface - { - return new Configuration(self::ALIAS); - } - - public function prepend(ContainerBuilder $container): void - { - $bundles = $container->getParameter('kernel.bundles'); - if (isset($bundles['FrameworkBundle'])) { - foreach ($container->getExtensions() as $name => $extension) { - if ($name !== 'framework') { - continue; - } - $config = $container->getExtensionConfig($name); - foreach ($config as $c) { - if (! isset($c['asset_mapper']['public_prefix'])) { - continue; - } - $container->prependExtensionConfig('pwa', [ - 'asset_public_prefix' => $c['asset_mapper']['public_prefix'], - ]); - } - } - } - } -} diff --git a/src/Resources/config/definition/asset_public_prefix.php b/src/Resources/config/definition/asset_public_prefix.php new file mode 100644 index 0000000..1ab6926 --- /dev/null +++ b/src/Resources/config/definition/asset_public_prefix.php @@ -0,0 +1,16 @@ +rootNode() + ->children() + ->scalarNode('asset_public_prefix') + ->cannotBeOverwritten() + ->defaultNull() + ->info('The public prefix of the assets. Shall be the same as the one used in the asset mapper.') + ->end() + ->end(); +}; diff --git a/src/Resources/config/definition/image_processor.php b/src/Resources/config/definition/image_processor.php new file mode 100644 index 0000000..8d1d442 --- /dev/null +++ b/src/Resources/config/definition/image_processor.php @@ -0,0 +1,17 @@ +rootNode() + ->children() + ->scalarNode('image_processor') + ->defaultNull() + ->info('The image processor to use to generate the icons of different sizes.') + ->example(GDImageProcessor::class) + ->end() + ->end(); +}; diff --git a/src/Resources/config/definition/manifest.php b/src/Resources/config/definition/manifest.php new file mode 100644 index 0000000..c86c111 --- /dev/null +++ b/src/Resources/config/definition/manifest.php @@ -0,0 +1,139 @@ +rootNode() + ->children() + ->arrayNode('manifest') + ->canBeEnabled() + ->children() + ->scalarNode('public_url') + ->defaultValue('/site.webmanifest') + ->cannotBeEmpty() + ->info('The public URL of the manifest file.') + ->example('/site.manifest') + ->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.' + ) + ->example('red') + ->end() + ->arrayNode('categories') + ->info('The categories of the application.') + ->example([['news', 'sports', 'lifestyle']]) + ->scalarPrototype() + ->end() + ->end() + ->scalarNode('description') + ->info('The description of the application.') + ->example('My awesome application') + ->end() + ->scalarNode('display') + ->info('The display mode of the application.') + ->example('standalone') + ->end() + ->arrayNode('display_override') + ->info( + 'A sequence of display modes that the browser will consider before using the display member.' + ) + ->example([['fullscreen', 'minimal-ui']]) + ->scalarPrototype()->end() + ->end() + ->scalarNode('id') + ->info('A string that represents the identity of the web application.') + ->example('?homescreen=1') + ->end() + ->scalarNode('orientation') + ->info('The orientation of the application.') + ->example('portrait-primary') + ->end() + ->scalarNode('dir') + ->info('The direction of the application.') + ->example('rtl') + ->end() + ->scalarNode('lang') + ->info('The language of the application.') + ->example('ar') + ->end() + ->scalarNode('name') + ->info('The name of the application.') + ->example('My awesome application') + ->end() + ->scalarNode('short_name') + ->info('The short name of the application.') + ->example('awesome_app') + ->end() + ->scalarNode('scope') + ->info('The scope of the application.') + ->example('/app/') + ->end() + ->scalarNode('start_url') + ->info('The start URL of the application.') + ->example('https://example.com') + ->end() + ->scalarNode('theme_color') + ->info('The theme color of the application.') + ->example('red') + ->end() + ->arrayNode('edge_side_panel') + ->info('Specifies whether or not your app supports the side panel view in Microsoft Edge.') + ->children() + ->integerNode('preferred_width') + ->info('Specifies the preferred width of the side panel view in Microsoft Edge.') + ->end() + ->end() + ->end() + ->scalarNode('iarc_rating_id') + ->info( + 'Specifies the International Age Rating Coalition (IARC) rating ID for the app. See https://www.globalratings.com/how-iarc-works.aspx for more information.' + ) + ->end() + ->arrayNode('scope_extensions') + ->info( + 'Specifies a list of origin patterns to associate with. This allows for your app to control multiple subdomains and top-level domains as a single entity.' + ) + ->arrayPrototype() + ->children() + ->scalarNode('origin') + ->isRequired() + ->info('Specifies the origin pattern to associate with.') + ->example('*.foo.com') + ->end() + ->end() + ->end() + ->end() + ->scalarNode('handle_links') + ->info('Specifies the default link handling for the web app.') + ->example(['auto', 'preferred', 'not-preferred']) + ->end() + ->append(getIconsNode('The icons of the application.')) + ->append(getScreenshotsNode('The screenshots of the application.')) + ->append(getFileHandlersNode()) + ->append(getLaunchHandlerNode()) + ->append(getProtocolHandlersNode()) + ->booleanNode('prefer_related_applications') + ->info('The prefer related native applications of the application.') + ->end() + ->append(setupRelatedApplications()) + ->append(setupShortcuts()) + ->append(setupSharedTarget()) + ->append(setupWidgets()) + ->end() + ->end() + ->end(); +}; diff --git a/src/Resources/config/definition/path_type_reference.php b/src/Resources/config/definition/path_type_reference.php new file mode 100644 index 0000000..317d7f9 --- /dev/null +++ b/src/Resources/config/definition/path_type_reference.php @@ -0,0 +1,32 @@ +rootNode() + ->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(); +}; diff --git a/src/Resources/config/definition/service_worker.php b/src/Resources/config/definition/service_worker.php new file mode 100644 index 0000000..c16e94a --- /dev/null +++ b/src/Resources/config/definition/service_worker.php @@ -0,0 +1,200 @@ +rootNode() + ->children() + ->arrayNode('serviceworker') + ->canBeEnabled() + ->beforeNormalization() + ->ifString() + ->then(static fn (string $v): array => [ + 'enabled' => true, + 'src' => $v, + ]) + ->end() + ->children() + ->scalarNode('src') + ->isRequired() + ->info('The path to the service worker source file. Can be served by Asset Mapper.') + ->example('script/sw.js') + ->end() + ->scalarNode('dest') + ->cannotBeEmpty() + ->defaultValue('/sw.js') + ->info('The public URL to the service worker.') + ->example('/sw.js') + ->end() + ->booleanNode('skip_waiting') + ->defaultFalse() + ->info('Whether to skip waiting for the service worker to be activated.') + ->end() + ->arrayNode('workbox') + ->info('The configuration of the workbox.') + ->canBeDisabled() + ->children() + ->booleanNode('use_cdn') + ->defaultFalse() + ->info('Whether to use the local workbox or the CDN.') + ->end() + ->scalarNode('version') + ->defaultValue('7.0.0') + ->info('The version of workbox. When using local files, the version shall be "7.0.0."') + ->end() + ->scalarNode('workbox_public_url') + ->defaultValue('/workbox') + ->info('The public path to the local workbox. Only used if use_cdn is false.') + ->end() + ->scalarNode('workbox_import_placeholder') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. No replacement.' + ) + ->defaultValue('//WORKBOX_IMPORT_PLACEHOLDER') + ->info('The placeholder for the workbox import. Will be replaced by the workbox import.') + ->example('//WORKBOX_IMPORT_PLACEHOLDER') + ->end() + ->scalarNode('standard_rules_placeholder') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. No replacement.' + ) + ->defaultValue('//STANDARD_RULES_PLACEHOLDER') + ->info('The placeholder for the standard rules. Will be replaced by caching strategies.') + ->example('//STANDARD_RULES_PLACEHOLDER') + ->end() + ->scalarNode('offline_fallback_placeholder') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. No replacement.' + ) + ->defaultValue('//OFFLINE_FALLBACK_PLACEHOLDER') + ->info('The placeholder for the offline fallback. Will be replaced by the URL.') + ->example('//OFFLINE_FALLBACK_PLACEHOLDER') + ->end() + ->scalarNode('widgets_placeholder') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. No replacement.' + ) + ->defaultValue('//WIDGETS_PLACEHOLDER') + ->info( + 'The placeholder for the widgets. Will be replaced by the widgets management events.' + ) + ->example('//WIDGETS_PLACEHOLDER') + ->end() + ->booleanNode('clear_cache') + ->defaultTrue() + ->info('Whether to clear the cache during the service worker activation.') + ->end() + ->scalarNode('image_cache_name') + ->defaultValue('images') + ->info('The name of the image cache.') + ->end() + ->scalarNode('font_cache_name') + ->defaultValue('fonts') + ->info('The name of the font cache.') + ->end() + ->scalarNode('page_cache_name') + ->defaultValue('pages') + ->info('The name of the page cache.') + ->end() + ->scalarNode('asset_cache_name') + ->defaultValue('assets') + ->info('The name of the asset cache.') + ->end() + ->append(getUrlNode('page_fallback', 'The URL of the offline page fallback.')) + ->append(getUrlNode('image_fallback', 'The URL of the offline image fallback.')) + ->append(getUrlNode('font_fallback', 'The URL of the offline font fallback.')) + ->scalarNode('image_regex') + ->defaultValue('/\.(ico|png|jpe?g|gif|svg|webp|bmp)$/') + ->info('The regex to match the images.') + ->example('/\.(ico|png|jpe?g|gif|svg|webp|bmp)$/') + ->end() + ->scalarNode('static_regex') + ->defaultValue('/\.(css|js|json|xml|txt|map|webmanifest)$/') + ->info('The regex to match the static files.') + ->example('/\.(css|js|json|xml|txt|woff2|ttf|eot|otf|map|webmanifest)$/') + ->end() + ->scalarNode('font_regex') + ->defaultValue('/\.(ttf|eot|otf|woff2)$/') + ->info('The regex to match the static files.') + ->example('/\.(ttf|eot|otf|woff2)$/') + ->end() + ->integerNode('max_image_cache_entries') + ->defaultValue(60) + ->info('The maximum number of entries in the image cache.') + ->example([50, 100, 200]) + ->end() + ->integerNode('max_image_age') + ->defaultValue(60 * 60 * 24 * 365) + ->info('The maximum number of seconds before the image cache is invalidated.') + ->example([60 * 60 * 24 * 365, 60 * 60 * 24 * 30, 60 * 60 * 24 * 7]) + ->end() + ->integerNode('max_font_cache_entries') + ->defaultValue(30) + ->info('The maximum number of entries in the font cache.') + ->example([30, 50, 100]) + ->end() + ->integerNode('max_font_age') + ->defaultValue(60 * 60 * 24 * 365) + ->info('The maximum number of seconds before the font cache is invalidated.') + ->example([60 * 60 * 24 * 365, 60 * 60 * 24 * 30, 60 * 60 * 24 * 7]) + ->end() + ->integerNode('network_timeout_seconds') + ->defaultValue(3) + ->info('The network timeout in seconds before cache is called (for warm cache URLs only).') + ->example([1, 2, 5]) + ->end() + ->arrayNode('warm_cache_urls') + ->treatNullLike([]) + ->treatFalseLike([]) + ->treatTrueLike([]) + ->info('The URLs to warm the cache. The URLs shall be served by the application.') + ->arrayPrototype() + ->beforeNormalization() + ->ifString() + ->then(static fn (string $v): array => [ + 'path' => $v, + ]) + ->end() + ->children() + ->scalarNode('path') + ->isRequired() + ->info('The URL of the shortcut.') + ->example('app_homepage') + ->end() + ->arrayNode('params') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->prototype('variable')->end() + ->info('The parameters of the action.') + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->scalarNode('scope') + ->cannotBeEmpty() + ->defaultValue('/') + ->info('The scope of the service worker.') + ->example('/app/') + ->end() + ->booleanNode('use_cache') + ->defaultTrue() + ->info('Whether the service worker should use the cache.') + ->end() + ->end() + ->end() +->end() + ->end(); +}; diff --git a/src/Resources/config/definition/utils/file_handlers.php b/src/Resources/config/definition/utils/file_handlers.php new file mode 100644 index 0000000..5d0c967 --- /dev/null +++ b/src/Resources/config/definition/utils/file_handlers.php @@ -0,0 +1,38 @@ +getRootNode(); + assert($node instanceof ArrayNodeDefinition); + + $node->info( + 'It specifies an array of objects representing the types of files an installed progressive web app (PWA) can handle.' + ) + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->arrayPrototype() + ->children() + ->append(getUrlNode('action', 'The action to take.', ['/handle-audio-file'])) + ->arrayNode('accept') + ->requiresAtLeastOneElement() + ->useAttributeAsKey('name') + ->arrayPrototype() + ->scalarPrototype() + ->end() + ->end() + ->info('The file types that the action will be applied to.') + ->example('image/*') + ->end() + ->end() + ->end() + ->end(); + + return $node; +} diff --git a/src/Resources/config/definition/utils/icons.php b/src/Resources/config/definition/utils/icons.php new file mode 100644 index 0000000..006ff5a --- /dev/null +++ b/src/Resources/config/definition/utils/icons.php @@ -0,0 +1,65 @@ +getRootNode(); + assert($node instanceof ArrayNodeDefinition); + $node->info($info) + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->arrayPrototype() + ->beforeNormalization() + ->ifString() + ->then(static fn (string $v): array => [ + 'src' => $v, + ]) + ->end() + ->children() + ->scalarNode('src') + ->isRequired() + ->info('The path to the icon. Can be served by Asset Mapper.') + ->example('icon/logo.svg') + ->end() + ->arrayNode('sizes') + ->beforeNormalization() + ->ifTrue(static fn (mixed $v): bool => is_int($v)) + ->then(static fn (int $v): array => [$v]) + ->end() + ->beforeNormalization() + ->ifTrue(static fn (mixed $v): bool => is_string($v)) + ->then(static function (string $v): array { + if ($v === 'any') { + return [0]; + } + + return [(int) $v]; + }) + ->end() + ->info( + 'The sizes of the icon. 16 means 16x16, 32 means 32x32, etc. 0 means "any" (i.e. it is a vector image).' + ) + ->example([['16', '32']]) + ->integerPrototype() + ->end() + ->end() + ->scalarNode('type') + ->info('The icon mime type.') + ->example(['image/webp', 'image/png']) + ->end() + ->scalarNode('purpose') + ->info('The purpose of the icon.') + ->example(['any', 'maskable', 'monochrome']) + ->end() + ->end() + ->end() + ; + + return $node; +} diff --git a/src/Resources/config/definition/utils/launch_handler.php b/src/Resources/config/definition/utils/launch_handler.php new file mode 100644 index 0000000..9cb524a --- /dev/null +++ b/src/Resources/config/definition/utils/launch_handler.php @@ -0,0 +1,32 @@ +getRootNode(); + assert($node instanceof ArrayNodeDefinition); + + $node->info('The launch handler of the application.') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->children() + ->arrayNode('client_mode') + ->info('The client mode of the application.') + ->example(['focus-existing', 'auto']) + ->scalarPrototype() + ->end() + ->beforeNormalization() + ->castToArray() + ->end() + ->end() + ->end() + ->end(); + + return $node; +} diff --git a/src/Resources/config/definition/utils/protocol_handlers.php b/src/Resources/config/definition/utils/protocol_handlers.php new file mode 100644 index 0000000..a035999 --- /dev/null +++ b/src/Resources/config/definition/utils/protocol_handlers.php @@ -0,0 +1,31 @@ +getRootNode(); + assert($node instanceof ArrayNodeDefinition); + + $node->info('The protocol handlers of the application.') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->arrayPrototype() + ->children() + ->scalarNode('protocol') + ->isRequired() + ->info('The protocol of the handler.') + ->example('web+jngl') + ->end() + ->append(getUrlNode('url', 'The URL of the handler.')) + ->end() + ->end() + ->end(); + + return $node; +} diff --git a/src/Resources/config/definition/utils/related_applications.php b/src/Resources/config/definition/utils/related_applications.php new file mode 100644 index 0000000..e920436 --- /dev/null +++ b/src/Resources/config/definition/utils/related_applications.php @@ -0,0 +1,39 @@ +getRootNode(); + assert($node instanceof ArrayNodeDefinition); + $node + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->info('The related applications of the application.') + ->arrayPrototype() + ->children() + ->scalarNode('platform') + ->isRequired() + ->info('The platform of the application.') + ->example('play') + ->end() + ->append( + getUrlNode('url', 'The URL of the application.', [ + 'https://play.google.com/store/apps/details?id=com.example.app1', + ]) + ) + ->scalarNode('id') + ->info('The ID of the application.') + ->example('com.example.app1') + ->end() + ->end() + ->end() + ->end(); + + return $node; +} diff --git a/src/Resources/config/definition/utils/screenshots.php b/src/Resources/config/definition/utils/screenshots.php new file mode 100644 index 0000000..7a27ad6 --- /dev/null +++ b/src/Resources/config/definition/utils/screenshots.php @@ -0,0 +1,58 @@ +getRootNode(); + assert($node instanceof ArrayNodeDefinition); + $node + ->info($info) + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->arrayPrototype() + ->beforeNormalization() + ->ifString() + ->then(static fn (string $v): array => [ + 'src' => $v, + ]) + ->end() + ->children() + ->scalarNode('src') + ->info('The path to the screenshot. Can be served by Asset Mapper.') + ->example('screenshot/lowres.webp') + ->end() + ->scalarNode('height') + ->defaultNull() + ->example('1080') + ->end() + ->scalarNode('width') + ->defaultNull() + ->example('1080') + ->end() + ->scalarNode('form_factor') + ->info('The form factor of the screenshot. Will guess the form factor if not set.') + ->example(['wide', 'narrow']) + ->end() + ->scalarNode('label') + ->info('The label of the screenshot.') + ->example('Homescreen of Awesome App') + ->end() + ->scalarNode('platform') + ->info('The platform of the screenshot.') + ->example(['android', 'windows', 'chromeos', 'ipados', 'ios', 'kaios', 'macos', 'windows', 'xbox']) + ->end() + ->scalarNode('format') + ->info('The format of the screenshot. Will convert the file if set.') + ->example(['image/jpg', 'image/png', 'image/webp']) + ->end() + ->end() + ->end(); + + return $node; +} diff --git a/src/Resources/config/definition/utils/shared_target.php b/src/Resources/config/definition/utils/shared_target.php new file mode 100644 index 0000000..fda0a03 --- /dev/null +++ b/src/Resources/config/definition/utils/shared_target.php @@ -0,0 +1,56 @@ +getRootNode(); + assert($node instanceof ArrayNodeDefinition); + + $node + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->info('The share target of the application.') + ->children() + ->append(getUrlNode('action', 'The action of the share target.', ['/shared-content-receiver/'])) + ->scalarNode('method') + ->info('The method of the share target.') + ->example('GET') + ->end() + ->scalarNode('enctype') + ->info('The enctype of the share target. Ignored if method is GET.') + ->example('multipart/form-data') + ->end() + ->arrayNode('params') + ->isRequired() + ->info('The parameters of the share target.') + ->children() + ->scalarNode('title') + ->info('The title of the share target.') + ->example('name') + ->end() + ->scalarNode('text') + ->info('The text of the share target.') + ->example('description') + ->end() + ->scalarNode('url') + ->info('The URL of the share target.') + ->example('link') + ->end() + ->arrayNode('files') + ->info('The files of the share target.') + ->scalarPrototype() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end(); + + return $node; +} diff --git a/src/Resources/config/definition/utils/shortcuts.php b/src/Resources/config/definition/utils/shortcuts.php new file mode 100644 index 0000000..96b2dc7 --- /dev/null +++ b/src/Resources/config/definition/utils/shortcuts.php @@ -0,0 +1,40 @@ +getRootNode(); + assert($node instanceof ArrayNodeDefinition); + $node + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->info('The shortcuts of the application.') + ->arrayPrototype() + ->children() + ->scalarNode('name') + ->isRequired() + ->info('The name of the shortcut.') + ->example('Awesome shortcut') + ->end() + ->scalarNode('short_name') + ->info('The short name of the shortcut.') + ->example('shortcut') + ->end() + ->scalarNode('description') + ->info('The description of the shortcut.') + ->example('This is an awesome shortcut') + ->end() + ->append(getUrlNode('url', 'The URL of the shortcut.')) + ->append(getIconsNode('The icons of the shortcut.')) + ->end() + ->end() + ->end(); + + return $node; +} diff --git a/src/Resources/config/definition/utils/url_node.php b/src/Resources/config/definition/utils/url_node.php new file mode 100644 index 0000000..0ba2464 --- /dev/null +++ b/src/Resources/config/definition/utils/url_node.php @@ -0,0 +1,42 @@ + $examples + */ +function getUrlNode(string $name, string $info, null|array $examples = null): ArrayNodeDefinition +{ + $treeBuilder = new TreeBuilder($name); + $node = $treeBuilder->getRootNode(); + assert($node instanceof ArrayNodeDefinition); + $node + ->info($info) + ->beforeNormalization() + ->ifString() + ->then(static fn (string $v): array => [ + 'path' => $v, + ]) + ->end() + ->children() + ->scalarNode('path') + ->isRequired() + ->info('The URL or route name.') + ->example($examples ?? ['https://example.com', 'app_action_route', '/do/action']) + ->end() + ->arrayNode('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(); + + return $node; +} diff --git a/src/Resources/config/definition/utils/widgets.php b/src/Resources/config/definition/utils/widgets.php new file mode 100644 index 0000000..641c3d1 --- /dev/null +++ b/src/Resources/config/definition/utils/widgets.php @@ -0,0 +1,78 @@ +getRootNode(); + assert($node instanceof ArrayNodeDefinition); + $node + ->info( + 'EXPERIMENTAL. Specifies PWA-driven widgets. See https://learn.microsoft.com/en-us/microsoft-edge/progressive-web-apps-chromium/how-to/widgets for more information' + ) + ->arrayPrototype() + ->children() + ->scalarNode('name') + ->isRequired() + ->info('The title of the widget, presented to users.') + ->end() + ->scalarNode('short_name') + ->info('An alternative short version of the name.') + ->end() + ->scalarNode('description') + ->isRequired() + ->info('The description of the widget.') + ->example('My awesome widget') + ->end() + ->append( + getIconsNode( + 'An array of icons to be used for the widget. If missing, the icons manifest member is used instead. Icons larger than 1024x1024 are ignored.' + ) + ) + ->append(getScreenshotsNode('The screenshots of the widget') ->requiresAtLeastOneElement()) + ->scalarNode('tag') + ->isRequired() + ->info('A string used to reference the widget in the PWA service worker.') + ->end() + ->scalarNode('template') + ->info( + 'The template to use to display the widget in the operating system widgets dashboard. Note: this property is currently only informational and not used. See ms_ac_template below.' + ) + ->end() + ->append( + getUrlNode( + 'ms_ac_template', + 'The URL of the custom Adaptive Cards template to use to display the widget in the operating system widgets dashboard.' + ) + ) + ->append( + getUrlNode( + 'data', + 'The URL where the data to fill the template with can be found. If present, this URL is required to return valid JSON.' + ) + ) + ->scalarNode('type') + ->info('The MIME type for the widget data.') + ->end() + ->booleanNode('auth') + ->info('A boolean indicating if the widget requires authentication.') + ->end() + ->integerNode('update') + ->info( + 'The frequency, in seconds, at which the widget will be updated. Code in your service worker must perform the updating; the widget is not updated automatically. See Access widget instances at runtime.' + ) + ->end() + ->booleanNode('multiple') + ->defaultTrue() + ->info('A boolean indicating whether to allow multiple instances of the widget. Defaults to true.') + ->end() + ->end() + ->end() + ->end(); + + return $node; +} diff --git a/src/Resources/config/definition/web_client.php b/src/Resources/config/definition/web_client.php new file mode 100644 index 0000000..fae787b --- /dev/null +++ b/src/Resources/config/definition/web_client.php @@ -0,0 +1,15 @@ +rootNode() + ->children() + ->scalarNode('web_client') + ->defaultNull() + ->info('The Panther Client for generating screenshots. If not set, the default client will be used.') + ->end() + ->end(); +}; diff --git a/src/SpomkyLabsPwaBundle.php b/src/SpomkyLabsPwaBundle.php index f1e545b..55cbf6d 100644 --- a/src/SpomkyLabsPwaBundle.php +++ b/src/SpomkyLabsPwaBundle.php @@ -4,13 +4,77 @@ namespace SpomkyLabs\PwaBundle; -use SpomkyLabs\PwaBundle\DependencyInjection\SpomkyLabsPwaExtension; -use Symfony\Component\HttpKernel\Bundle\Bundle; +use SpomkyLabs\PwaBundle\ImageProcessor\ImageProcessor; +use SpomkyLabs\PwaBundle\Subscriber\PwaDevServerSubscriber; +use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpKernel\Bundle\AbstractBundle; +use function in_array; -final class SpomkyLabsPwaBundle extends Bundle +final class SpomkyLabsPwaBundle extends AbstractBundle { - public function getContainerExtension(): SpomkyLabsPwaExtension + protected string $extensionAlias = 'pwa'; + + public function configure(DefinitionConfigurator $definition): void + { + $definition->import('Resources/config/definition/*.php'); + } + + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $container->import('Resources/config/services.php'); + + if ($config['image_processor'] !== null) { + $builder->setAlias(ImageProcessor::class, $config['image_processor']); + } + if ($config['web_client'] !== null) { + $builder->setAlias('pwa.web_client', $config['web_client']); + } + $builder->setParameter('spomky_labs_pwa.routes.reference_type', $config['path_type_reference']); + $serviceWorkerConfig = $config['serviceworker']; + $manifestConfig = $config['manifest']; + if ($serviceWorkerConfig['enabled'] === true && $manifestConfig['enabled'] === true) { + $manifestConfig['serviceworker'] = $serviceWorkerConfig; + } + $builder->setParameter( + 'spomky_labs_pwa.asset_public_prefix', + '/' . trim((string) $config['asset_public_prefix'], '/') + ); + + /*** Manifest ***/ + $builder->setParameter('spomky_labs_pwa.manifest.enabled', $config['manifest']['enabled']); + $builder->setParameter('spomky_labs_pwa.manifest.public_url', $config['manifest']['public_url'] ?? null); + $builder->setParameter('spomky_labs_pwa.manifest.config', $manifestConfig); + + /*** Service Worker ***/ + $builder->setParameter('spomky_labs_pwa.sw.enabled', $config['serviceworker']['enabled']); + $builder->setParameter('spomky_labs_pwa.sw.public_url', $config['serviceworker']['dest'] ?? null); + $builder->setParameter('spomky_labs_pwa.sw.config', $serviceWorkerConfig); + + if (! in_array($builder->getParameter('kernel.environment'), ['dev', 'test'], true)) { + $builder->removeDefinition(PwaDevServerSubscriber::class); + } + } + + public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void { - return new SpomkyLabsPwaExtension(); + $bundles = $builder->getParameter('kernel.bundles'); + if (isset($bundles['FrameworkBundle'])) { + foreach ($builder->getExtensions() as $name => $extension) { + if ($name !== 'framework') { + continue; + } + $config = $builder->getExtensionConfig($name); + foreach ($config as $c) { + if (! isset($c['asset_mapper']['public_prefix'])) { + continue; + } + $builder->prependExtensionConfig('pwa', [ + 'asset_public_prefix' => $c['asset_mapper']['public_prefix'], + ]); + } + } + } } } diff --git a/src/Subscriber/PwaDevServerSubscriber.php b/src/Subscriber/PwaDevServerSubscriber.php index e917913..814e55b 100644 --- a/src/Subscriber/PwaDevServerSubscriber.php +++ b/src/Subscriber/PwaDevServerSubscriber.php @@ -7,10 +7,10 @@ use SpomkyLabs\PwaBundle\Dto\Manifest; use SpomkyLabs\PwaBundle\Dto\ServiceWorker; use SpomkyLabs\PwaBundle\Service\ServiceWorkerCompiler; +use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Config\FileLocator; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; @@ -39,7 +39,6 @@ private null|string $workboxVersion; public function __construct( - private FileLocator $fileLocator, private ServiceWorkerCompiler $serviceWorkerBuilder, private SerializerInterface $serializer, private Manifest $manifest, @@ -87,6 +86,8 @@ public function onKernelRequest(RequestEvent $event): void ): $this->serveWorkboxFile($event, $pathInfo); break; + default: + // Do nothing } } @@ -152,8 +153,9 @@ private function serveWorkboxFile(RequestEvent $event, string $pathInfo): void return; } $asset = mb_substr($pathInfo, mb_strlen((string) $this->workboxPublicUrl)); - $resource = sprintf('@SpomkyLabsPwaBundle/Resources/workbox-v%s%s', $this->workboxVersion, $asset); - $resourcePath = $this->fileLocator->locate($resource, null, false); + $fileLocator = new FileLocator(__DIR__ . '/../Resources'); + $resource = sprintf('workbox-v%s%s', $this->workboxVersion, $asset); + $resourcePath = $fileLocator->locate($resource, null, false); if (is_array($resourcePath)) { if (count($resourcePath) === 1) { $resourcePath = $resourcePath[0]; diff --git a/src/Subscriber/WorkboxCompileEventListener.php b/src/Subscriber/WorkboxCompileEventListener.php index af9fe8e..2c56d76 100644 --- a/src/Subscriber/WorkboxCompileEventListener.php +++ b/src/Subscriber/WorkboxCompileEventListener.php @@ -7,9 +7,9 @@ use SpomkyLabs\PwaBundle\Dto\Manifest; use Symfony\Component\AssetMapper\Event\PreAssetsCompileEvent; use Symfony\Component\AssetMapper\Path\PublicAssetsFilesystemInterface; +use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; -use Symfony\Component\HttpKernel\Config\FileLocator; use function assert; use function in_array; use function is_array; @@ -24,7 +24,6 @@ public function __construct( #[Autowire('@asset_mapper.local_public_assets_filesystem')] private PublicAssetsFilesystemInterface $assetsFilesystem, private Manifest $manifest, - private FileLocator $fileLocator, ) { } @@ -40,9 +39,8 @@ public function __invoke(PreAssetsCompileEvent $event): void $workboxVersion = $serviceWorker->workbox->version; $workboxPublicUrl = '/' . trim($serviceWorker->workbox->workboxPublicUrl, '/'); - $resourcePath = $this->fileLocator->locate( - sprintf('@SpomkyLabsPwaBundle/Resources/workbox-v%s', $workboxVersion) - ); + $fileLocator = new FileLocator(__DIR__ . '/../Resources'); + $resourcePath = $fileLocator->locate(sprintf('workbox-v%s', $workboxVersion)); if (! is_string($resourcePath)) { return; } From 29abb72c7fdf6acd79a01d237327bd54ee84d64e Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Tue, 5 Mar 2024 13:01:53 +0100 Subject: [PATCH 05/12] Refactoring ideas (#93) --- src/Dto/Url.php | 6 ++ src/Normalizer/UrlNormalizer.php | 5 +- .../config/definition/path_type_reference.php | 5 ++ .../config/definition/utils/url_node.php | 55 +++++++++++++------ src/SpomkyLabsPwaBundle.php | 1 - 5 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/Dto/Url.php b/src/Dto/Url.php index 341cdf5..0e9110e 100644 --- a/src/Dto/Url.php +++ b/src/Dto/Url.php @@ -4,10 +4,16 @@ namespace SpomkyLabs\PwaBundle\Dto; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Serializer\Attribute\SerializedName; + final class Url { public string $path; + #[SerializedName('path_type_reference')] + public int $pathTypeReference = UrlGeneratorInterface::ABSOLUTE_PATH; + /** * @var array */ diff --git a/src/Normalizer/UrlNormalizer.php b/src/Normalizer/UrlNormalizer.php index cfb54b9..ea35626 100644 --- a/src/Normalizer/UrlNormalizer.php +++ b/src/Normalizer/UrlNormalizer.php @@ -5,7 +5,6 @@ namespace SpomkyLabs\PwaBundle\Normalizer; use SpomkyLabs\PwaBundle\Dto\Url; -use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; @@ -19,8 +18,6 @@ final class UrlNormalizer implements NormalizerInterface, NormalizerAwareInterfa public function __construct( private readonly RouterInterface $router, - #[Autowire('%spomky_labs_pwa.routes.reference_type%')] - private readonly int $referenceType, ) { } @@ -29,7 +26,7 @@ public function normalize(mixed $object, string $format = null, array $context = assert($object instanceof Url); if (! str_starts_with($object->path, '/') && filter_var($object->path, FILTER_VALIDATE_URL) === false) { - return $this->router->generate($object->path, $object->params, $this->referenceType); + return $this->router->generate($object->path, $object->params, $object->pathTypeReference); } return $object->path; diff --git a/src/Resources/config/definition/path_type_reference.php b/src/Resources/config/definition/path_type_reference.php index 317d7f9..8b66d7e 100644 --- a/src/Resources/config/definition/path_type_reference.php +++ b/src/Resources/config/definition/path_type_reference.php @@ -9,6 +9,11 @@ $definition->rootNode() ->children() ->integerNode('path_type_reference') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" configuration key is deprecated. Use the "path_type_reference" of URL nodes instead.' + ) ->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.' diff --git a/src/Resources/config/definition/utils/url_node.php b/src/Resources/config/definition/utils/url_node.php index 0ba2464..9ebacc0 100644 --- a/src/Resources/config/definition/utils/url_node.php +++ b/src/Resources/config/definition/utils/url_node.php @@ -4,6 +4,7 @@ use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** * @param array $examples @@ -17,26 +18,46 @@ function getUrlNode(string $name, string $info, null|array $examples = null): Ar ->info($info) ->beforeNormalization() ->ifString() - ->then(static fn (string $v): array => [ - 'path' => $v, - ]) + ->then(static fn (string $v): array => [ + 'path' => $v, + ]) ->end() ->children() - ->scalarNode('path') - ->isRequired() - ->info('The URL or route name.') - ->example($examples ?? ['https://example.com', 'app_action_route', '/do/action']) + ->scalarNode('path') + ->isRequired() + ->info('The URL or route name.') + ->example($examples ?? ['https://example.com', 'app_action_route', '/do/action']) + ->end() + ->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() + ->arrayNode('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() - ->arrayNode('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(); return $node; } diff --git a/src/SpomkyLabsPwaBundle.php b/src/SpomkyLabsPwaBundle.php index 55cbf6d..7e1bb91 100644 --- a/src/SpomkyLabsPwaBundle.php +++ b/src/SpomkyLabsPwaBundle.php @@ -31,7 +31,6 @@ public function loadExtension(array $config, ContainerConfigurator $container, C if ($config['web_client'] !== null) { $builder->setAlias('pwa.web_client', $config['web_client']); } - $builder->setParameter('spomky_labs_pwa.routes.reference_type', $config['path_type_reference']); $serviceWorkerConfig = $config['serviceworker']; $manifestConfig = $config['manifest']; if ($serviceWorkerConfig['enabled'] === true && $manifestConfig['enabled'] === true) { From 273cb82e6d20e5ffe8e0519496d08a37d43675ef Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Tue, 5 Mar 2024 13:20:01 +0100 Subject: [PATCH 06/12] Package name changed (#94) --- README.md | 26 +++++++++++++------------- composer.json | 4 ++-- link | 10 +++++----- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index aae5458..7c57fb0 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ Progressive Web App for Symfony =============================== -![Build Status](https://github.com/Spomky-Labs/phpwa/workflows/Coding%20Standards/badge.svg) -![Build Status](https://github.com/Spomky-Labs/phpwa/workflows/Static%20Analyze/badge.svg) +![Build Status](https://github.com/Spomky-Labs/pwa-bundle/workflows/Coding%20Standards/badge.svg) +![Build Status](https://github.com/Spomky-Labs/pwa-bundle/workflows/Static%20Analyze/badge.svg) -![Build Status](https://github.com/Spomky-Labs/phpwa/workflows/Unit%20and%20Functional%20Tests/badge.svg) -![Build Status](https://github.com/Spomky-Labs/phpwa/workflows/Rector%20Checkstyle/badge.svg) +![Build Status](https://github.com/Spomky-Labs/pwa-bundle/workflows/Unit%20and%20Functional%20Tests/badge.svg) +![Build Status](https://github.com/Spomky-Labs/pwa-bundle/workflows/Rector%20Checkstyle/badge.svg) -[![Latest Stable Version](https://poser.pugx.org/Spomky-Labs/phpwa/v/stable.png)](https://packagist.org/packages/Spomky-Labs/phpwa) -[![Total Downloads](https://poser.pugx.org/Spomky-Labs/phpwa/downloads.png)](https://packagist.org/packages/Spomky-Labs/phpwa) -[![Latest Unstable Version](https://poser.pugx.org/Spomky-Labs/phpwa/v/unstable.png)](https://packagist.org/packages/Spomky-Labs/phpwa) -[![License](https://poser.pugx.org/Spomky-Labs/phpwa/license.png)](https://packagist.org/packages/Spomky-Labs/phpwa) +[![Latest Stable Version](https://poser.pugx.org/Spomky-Labs/pwa-bundle/v/stable.png)](https://packagist.org/packages/Spomky-Labs/pwa-bundle) +[![Total Downloads](https://poser.pugx.org/Spomky-Labs/pwa-bundle/downloads.png)](https://packagist.org/packages/Spomky-Labs/pwa-bundle) +[![Latest Unstable Version](https://poser.pugx.org/Spomky-Labs/pwa-bundle/v/unstable.png)](https://packagist.org/packages/Spomky-Labs/pwa-bundle) +[![License](https://poser.pugx.org/Spomky-Labs/pwa-bundle/license.png)](https://packagist.org/packages/Spomky-Labs/pwa-bundle) -[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Spomky-Labs/phpwa/badge)](https://api.securityscorecards.dev/projects/github.com/Spomky-Labs/phpwa) +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Spomky-Labs/pwa-bundle/badge)](https://api.securityscorecards.dev/projects/github.com/Spomky-Labs/pwa-bundle) # Scope -This bundle provides the [Spomky-Labs/phpwa](https://github.com/Spomky-Labs/phpwa) bundle for Symfony. +This bundle provides the [Spomky-Labs/pwa-bundle](https://github.com/Spomky-Labs/pwa-bundle) bundle for Symfony. This will help you to generate Progressive Web Apps (PWA) Manifests and assets (icons or screenshots). Also, it will help you to generate Service Workers based on [Workbox](https://developers.google.com/web/tools/workbox). @@ -27,7 +27,7 @@ Please have a look at the [Web app manifests](https://developer.mozilla.org/en-U Install the bundle with Composer: ```bash -composer require spomky-labs/phpwa +composer require spomky-labs/pwa-bundle ``` This project follows the [semantic versioning](http://semver.org/) strictly. @@ -49,9 +49,9 @@ If you really love that project and the work I have done or if you want I priori # Contributing Requests for new features, bug fixed and all other ideas to make this project useful are welcome. -The best contribution you could provide is by fixing the [opened issues where help is wanted](https://github.com/Spomky-Labs/phpwa/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22). +The best contribution you could provide is by fixing the [opened issues where help is wanted](https://github.com/Spomky-Labs/pwa-bundle/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22). -Please report all issues in [the main repository](https://github.com/Spomky-Labs/phpwa/issues). +Please report all issues in [the main repository](https://github.com/Spomky-Labs/pwa-bundle/issues). Please make sure to [follow these best practices](.github/CONTRIBUTING.md). diff --git a/composer.json b/composer.json index 7f68597..584251b 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "spomky-labs/phpwa", + "name": "spomky-labs/pwa-bundle", "description": "Progressive Web App Manifest Generator Bundle for Symfony.", "type": "symfony-bundle", "license": "MIT", @@ -11,7 +11,7 @@ "homepage": "https://github.com/Spomky" },{ "name": "All contributors", - "homepage": "https://github.com/spomky-labs/phpwa/contributors" + "homepage": "https://github.com/spomky-labs/pwa-bundle/contributors" } ], "autoload": { diff --git a/link b/link index e25523c..25c1a41 100644 --- a/link +++ b/link @@ -11,7 +11,7 @@ require __DIR__.'/vendor/autoload.php'; use Symfony\Component\Filesystem\Filesystem; /** - * Links dependencies to components to a local clone of the main spomky-labs/phpwa GitHub repository. + * Links dependencies to components to a local clone of the main spomky-labs/pwa-bundle GitHub repository. * Inspired by symfony/symfony and async-aws/aws */ @@ -19,8 +19,8 @@ $copy = false !== $k = array_search('--copy', $argv, true); $copy && array_splice($argv, $k, 1); $pathToProject = $argv[1] ?? getcwd(); -if (!is_dir("$pathToProject/vendor/spomky-labs/phpwa")) { - echo 'Link (or copy) dependencies to components to a local clone of the spomky-labs/phpwa GitHub repository.'.PHP_EOL.PHP_EOL; +if (!is_dir("$pathToProject/vendor/spomky-labs/pwa-bundle")) { + echo 'Link (or copy) dependencies to components to a local clone of the spomky-labs/pwa-bundle GitHub repository.'.PHP_EOL.PHP_EOL; echo "Usage: $argv[0] /path/to/the/project".PHP_EOL; echo ' Use `--copy` to copy dependencies instead of symlink'.PHP_EOL.PHP_EOL; echo "The directory \"$pathToProject\" does not exist or the dependencies are not installed, did you forget to run \"composer install\" in your project?".PHP_EOL; @@ -38,8 +38,8 @@ foreach ($directories as $dir) { } } -if (is_dir("$pathToProject/vendor/spomky-labs/phpwa")) { - if ($filesystem->exists($composer = "$pathToProject/vendor/spomky-labs/phpwa/composer.json")) { +if (is_dir("$pathToProject/vendor/spomky-labs/pwa-bundle")) { + if ($filesystem->exists($composer = "$pathToProject/vendor/spomky-labs/pwa-bundle/composer.json")) { $packages[json_decode(file_get_contents($composer))->name] = realpath(__DIR__); } } From 18d0df29c8b80bb414e79aa8ac252eb4d4bdd3bd Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Tue, 5 Mar 2024 13:49:39 +0100 Subject: [PATCH 07/12] Cache site manifest (#95) --- phpstan-baseline.neon | 5 ++++ src/Dto/Workbox.php | 3 +++ .../config/definition/service_worker.php | 4 +++ src/Service/ServiceWorkerCompiler.php | 25 +++++++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f786269..0b9b57d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -290,6 +290,11 @@ parameters: count: 4 path: src/Dto/Workbox.php + - + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$cacheManifest\\. Give it default value or assign it in the constructor\\.$#" + count: 1 + path: src/Dto/Workbox.php + - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$enabled\\. Give it default value or assign it in the constructor\\.$#" count: 1 diff --git a/src/Dto/Workbox.php b/src/Dto/Workbox.php index c034d78..28f05c1 100644 --- a/src/Dto/Workbox.php +++ b/src/Dto/Workbox.php @@ -19,6 +19,9 @@ final class Workbox #[SerializedName('workbox_public_url')] public string $workboxPublicUrl; + #[SerializedName('cache_manifest')] + public bool $cacheManifest; + #[SerializedName('workbox_import_placeholder')] #[Deprecated('No longer used.')] public string $workboxImportPlaceholder; diff --git a/src/Resources/config/definition/service_worker.php b/src/Resources/config/definition/service_worker.php index c16e94a..9ead061 100644 --- a/src/Resources/config/definition/service_worker.php +++ b/src/Resources/config/definition/service_worker.php @@ -40,6 +40,10 @@ ->defaultFalse() ->info('Whether to use the local workbox or the CDN.') ->end() + ->booleanNode('cache_manifest') + ->defaultTrue() + ->info('Whether to cache the manifest file.') + ->end() ->scalarNode('version') ->defaultValue('7.0.0') ->info('The version of workbox. When using local files, the version shall be "7.0.0."') diff --git a/src/Service/ServiceWorkerCompiler.php b/src/Service/ServiceWorkerCompiler.php index 08c8d9d..3dd0933 100644 --- a/src/Service/ServiceWorkerCompiler.php +++ b/src/Service/ServiceWorkerCompiler.php @@ -24,16 +24,21 @@ { private array $jsonOptions; + private string $manifestPublicUrl; + public function __construct( private SerializerInterface $serializer, #[Autowire('%spomky_labs_pwa.asset_public_prefix%')] private readonly string $assetPublicPrefix, + #[Autowire('%spomky_labs_pwa.manifest.public_url%')] + string $manifestPublicUrl, #[Autowire('%spomky_labs_pwa.sw.enabled%')] private bool $serviceWorkerEnabled, private Manifest $manifest, private ServiceWorker $serviceWorker, private AssetMapperInterface $assetMapper, ) { + $this->manifestPublicUrl = '/' . trim($manifestPublicUrl, '/'); $this->jsonOptions = [ JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, ]; @@ -89,6 +94,7 @@ private function processWorkbox(Workbox $workbox, string $body): string $body = $this->processFontCacheRules($workbox, $body); $body = $this->processPageImageCacheRule($workbox, $body); $body = $this->processImageCacheRule($workbox, $body); + $body = $this->processCacheRootFilesRule($workbox, $body); return $this->processOfflineFallback($workbox, $body); } @@ -260,6 +266,25 @@ private function processImageCacheRule(Workbox $workbox, string $body): string return $body . PHP_EOL . PHP_EOL . trim($declaration); } + private function processCacheRootFilesRule(Workbox $workbox, string $body): string + { + if ($workbox->cacheManifest === false) { + return $body; + } + + $declaration = << '{$this->manifestPublicUrl}' === url.pathname, + new workbox.strategies.StaleWhileRevalidate({ + cacheName: 'manifest' + }) +); +IMAGE_CACHE_RULE_STRATEGY; + + return $body . PHP_EOL . PHP_EOL . trim($declaration); + } + private function processOfflineFallback(Workbox $workbox, string $body): string { if ($workbox->pageFallback === null && $workbox->imageFallback === null && $workbox->fontFallback === null) { From 2f15eb0c6b8b4a1e3813ebca551db428e3d1c726 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Tue, 5 Mar 2024 14:08:43 +0100 Subject: [PATCH 08/12] Allow Google Font cache --- phpstan-baseline.neon | 10 +++++++++ src/Dto/GoogleFontCache.php | 21 ++++++++++++++++++ src/Dto/Workbox.php | 3 +++ .../config/definition/service_worker.php | 21 ++++++++++++++++++ src/Service/ServiceWorkerCompiler.php | 22 +++++++++++++++++++ 5 files changed, 77 insertions(+) create mode 100644 src/Dto/GoogleFontCache.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 0b9b57d..ee71307 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -120,6 +120,11 @@ parameters: count: 1 path: src/Dto/FileHandler.php + - + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\GoogleFontCache has an uninitialized property \\$enabled\\. Give it default value or assign it in the constructor\\.$#" + count: 1 + path: src/Dto/GoogleFontCache.php + - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Icon has an uninitialized property \\$sizeList\\. Give it default value or assign it in the constructor\\.$#" count: 1 @@ -300,6 +305,11 @@ parameters: count: 1 path: src/Dto/Workbox.php + - + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$googleFontCache\\. Give it default value or assign it in the constructor\\.$#" + count: 1 + path: src/Dto/Workbox.php + - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$offlineFallbackPlaceholder\\. Give it default value or assign it in the constructor\\.$#" count: 1 diff --git a/src/Dto/GoogleFontCache.php b/src/Dto/GoogleFontCache.php new file mode 100644 index 0000000..760ea60 --- /dev/null +++ b/src/Dto/GoogleFontCache.php @@ -0,0 +1,21 @@ +defaultFalse() ->info('Whether to use the local workbox or the CDN.') ->end() + ->arrayNode('google_fonts') + ->canBeDisabled() + ->children() + ->scalarNode('cache_prefix') + ->defaultNull() + ->info('The cache prefix for the Google fonts.') + ->end() + ->integerNode('max_age') + ->defaultNull() + ->info('The maximum age of the Google fonts cache (in seconds).') + ->end() + ->integerNode('max_entries') + ->defaultNull() + ->info('The maximum number of entries in the Google fonts cache.') + ->end() + ->end() + ->end() ->booleanNode('cache_manifest') ->defaultTrue() ->info('Whether to cache the manifest file.') ->end() + ->booleanNode('cache_google_fonts') + ->defaultTrue() + ->info('Whether to cache the Google fonts.') + ->end() ->scalarNode('version') ->defaultValue('7.0.0') ->info('The version of workbox. When using local files, the version shall be "7.0.0."') diff --git a/src/Service/ServiceWorkerCompiler.php b/src/Service/ServiceWorkerCompiler.php index 3dd0933..0f86339 100644 --- a/src/Service/ServiceWorkerCompiler.php +++ b/src/Service/ServiceWorkerCompiler.php @@ -95,6 +95,7 @@ private function processWorkbox(Workbox $workbox, string $body): string $body = $this->processPageImageCacheRule($workbox, $body); $body = $this->processImageCacheRule($workbox, $body); $body = $this->processCacheRootFilesRule($workbox, $body); + $body = $this->processCacheGoogleFontsRule($workbox, $body); return $this->processOfflineFallback($workbox, $body); } @@ -285,6 +286,27 @@ private function processCacheRootFilesRule(Workbox $workbox, string $body): stri return $body . PHP_EOL . PHP_EOL . trim($declaration); } + private function processCacheGoogleFontsRule(Workbox $workbox, string $body): string + { + if ($workbox->googleFontCache->enabled === false) { + return $body; + } + $options = [ + 'cachePrefix' => $workbox->googleFontCache->cachePrefix, + 'maxAge' => $workbox->googleFontCache->maxAge, + 'maxEntries' => $workbox->googleFontCache->maxEntries, + ]; + $options = array_filter($options, static fn (mixed $v): bool => ($v !== null && $v !== '')); + $options = count($options) === 0 ? '' : $this->serializer->serialize($options, 'json', $this->jsonOptions); + + $declaration = <<pageFallback === null && $workbox->imageFallback === null && $workbox->fontFallback === null) { From 5ba6f355f899acfcda00d02aeb355ce32484d107 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Tue, 5 Mar 2024 16:22:42 +0100 Subject: [PATCH 09/12] Better cache configuration --- phpstan-baseline.neon | 133 ++++++++- src/Dto/AssetCache.php | 17 ++ src/Dto/FontCache.php | 24 ++ src/Dto/ImageCache.php | 24 ++ src/Dto/OfflineFallback.php | 21 ++ src/Dto/PageCache.php | 26 ++ src/Dto/Workbox.php | 77 +---- .../config/definition/service_worker.php | 268 +++++++++++++++++- .../config/definition/utils/shortcuts.php | 35 ++- src/Service/ServiceWorkerCompiler.php | 85 +++--- 10 files changed, 568 insertions(+), 142 deletions(-) create mode 100644 src/Dto/AssetCache.php create mode 100644 src/Dto/FontCache.php create mode 100644 src/Dto/ImageCache.php create mode 100644 src/Dto/OfflineFallback.php create mode 100644 src/Dto/PageCache.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ee71307..095f8dc 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -291,8 +291,8 @@ parameters: path: src/Dto/Widget.php - - message: "#^Attribute class JetBrains\\\\PhpStorm\\\\Deprecated does not exist\\.$#" - count: 4 + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$assetCache\\. Give it default value or assign it in the constructor\\.$#" + count: 1 path: src/Dto/Workbox.php - @@ -306,37 +306,37 @@ parameters: path: src/Dto/Workbox.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$googleFontCache\\. Give it default value or assign it in the constructor\\.$#" + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$fontCache\\. Give it default value or assign it in the constructor\\.$#" count: 1 path: src/Dto/Workbox.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$offlineFallbackPlaceholder\\. Give it default value or assign it in the constructor\\.$#" + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$googleFontCache\\. Give it default value or assign it in the constructor\\.$#" count: 1 path: src/Dto/Workbox.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$standardRulesPlaceholder\\. Give it default value or assign it in the constructor\\.$#" + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$imageCache\\. Give it default value or assign it in the constructor\\.$#" count: 1 path: src/Dto/Workbox.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$useCDN\\. Give it default value or assign it in the constructor\\.$#" + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$offlineFallback\\. Give it default value or assign it in the constructor\\.$#" count: 1 path: src/Dto/Workbox.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$version\\. Give it default value or assign it in the constructor\\.$#" + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$pageCache\\. Give it default value or assign it in the constructor\\.$#" count: 1 path: src/Dto/Workbox.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$widgetsPlaceholder\\. Give it default value or assign it in the constructor\\.$#" + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$useCDN\\. Give it default value or assign it in the constructor\\.$#" count: 1 path: src/Dto/Workbox.php - - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$workboxImportPlaceholder\\. Give it default value or assign it in the constructor\\.$#" + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\Workbox has an uninitialized property \\$version\\. Give it default value or assign it in the constructor\\.$#" count: 1 path: src/Dto/Workbox.php @@ -475,11 +475,126 @@ parameters: count: 1 path: src/Resources/config/definition/path_type_reference.php + - + message: "#^Anonymous function should return array but returns mixed\\.$#" + count: 10 + 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/service_worker.php + - + message: "#^Cannot access offset 'asset_cache' on mixed\\.$#" + count: 2 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'asset_cache_name' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'font_cache' on mixed\\.$#" + count: 2 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'font_cache_name' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'font_fallback' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'font_regex' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'image_cache' on mixed\\.$#" + count: 2 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'image_cache_name' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'image_fallback' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'image_regex' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'max_font_age' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'max_font_cache…' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'max_image_age' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'max_image_cache…' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'network_timeout…' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'offline_fallback' on mixed\\.$#" + count: 2 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'page_cache' on mixed\\.$#" + count: 2 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'page_cache_name' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'page_fallback' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'static_regex' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Cannot access offset 'warm_cache_urls' on mixed\\.$#" + count: 1 + path: src/Resources/config/definition/service_worker.php + + - + message: "#^Strict comparison using \\!\\=\\= between mixed and null will always evaluate to true\\.$#" + count: 4 + path: src/Resources/config/definition/service_worker.php + - message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:end\\(\\)\\.$#" count: 1 diff --git a/src/Dto/AssetCache.php b/src/Dto/AssetCache.php new file mode 100644 index 0000000..bb1b9e7 --- /dev/null +++ b/src/Dto/AssetCache.php @@ -0,0 +1,17 @@ + + */ + public array $urls = []; +} diff --git a/src/Dto/Workbox.php b/src/Dto/Workbox.php index 36ed005..443e199 100644 --- a/src/Dto/Workbox.php +++ b/src/Dto/Workbox.php @@ -4,7 +4,6 @@ namespace SpomkyLabs\PwaBundle\Dto; -use JetBrains\PhpStorm\Deprecated; use Symfony\Component\Serializer\Attribute\SerializedName; final class Workbox @@ -22,76 +21,24 @@ final class Workbox #[SerializedName('cache_manifest')] public bool $cacheManifest; - #[SerializedName('google_fonts')] - public GoogleFontCache $googleFontCache; - - #[SerializedName('workbox_import_placeholder')] - #[Deprecated('No longer used.')] - public string $workboxImportPlaceholder; - - #[SerializedName('standard_rules_placeholder')] - #[Deprecated('No longer used.')] - public string $standardRulesPlaceholder; - - #[SerializedName('offline_fallback_placeholder')] - #[Deprecated('No longer used.')] - public string $offlineFallbackPlaceholder; - - #[SerializedName('widgets_placeholder')] - #[Deprecated('No longer used.')] - public string $widgetsPlaceholder; - - #[SerializedName('page_fallback')] - public null|Url $pageFallback = null; - - #[SerializedName('image_fallback')] - public null|Asset $imageFallback = null; - - #[SerializedName('font_fallback')] - public null|Asset $fontFallback = null; - - /** - * @var array - */ - #[SerializedName('warm_cache_urls')] - public array $warmCacheUrls = []; + #[SerializedName('image_cache')] + public ImageCache $imageCache; - #[SerializedName('network_timeout_seconds')] - public int $networkTimeoutSeconds = 3; + #[SerializedName('font_cache')] + public FontCache $fontCache; - #[SerializedName('max_font_age')] - public int $maxFontAge = 60 * 60 * 24 * 365; + #[SerializedName('page_cache')] + public PageCache $pageCache; - #[SerializedName('max_font_cache_entries')] - public int $maxFontCacheEntries = 60; + #[SerializedName('asset_cache')] + public AssetCache $assetCache; - #[SerializedName('max_image_age')] - public int $maxImageAge = 60 * 60 * 24 * 365; - - #[SerializedName('max_image_cache_entries')] - public int $maxImageCacheEntries = 60; - - #[SerializedName('image_regex')] - public string $imageRegex = '/\.(ico|png|jpe?g|gif|svg|webp|bmp)$/'; - - #[SerializedName('static_regex')] - public string $staticRegex = '/\.(css|m?jsx?|json|xml|txt|map|webmanifest)$/'; + #[SerializedName('google_fonts')] + public GoogleFontCache $googleFontCache; - #[SerializedName('font_regex')] - public string $fontRegex = '/\.(ttf|eot|otf|woff2)$/'; + #[SerializedName('offline_fallback')] + public OfflineFallback $offlineFallback; #[SerializedName('clear_cache')] public bool $clearCache = true; - - #[SerializedName('image_cache_name')] - public string $imageCacheName = 'images'; - - #[SerializedName('font_cache_name')] - public string $fontCacheName = 'fonts'; - - #[SerializedName('page_cache_name')] - public string $pageCacheName = 'pages'; - - #[SerializedName('asset_cache_name')] - public string $assetCacheName = 'assets'; } diff --git a/src/Resources/config/definition/service_worker.php b/src/Resources/config/definition/service_worker.php index f33f681..1b1e614 100644 --- a/src/Resources/config/definition/service_worker.php +++ b/src/Resources/config/definition/service_worker.php @@ -35,6 +35,87 @@ ->arrayNode('workbox') ->info('The configuration of the workbox.') ->canBeDisabled() + ->beforeNormalization() + ->ifTrue(static fn (mixed $v): bool => true) + ->then(static function (mixed $v): array { + if (isset($v['asset_cache'])) { + return $v; + } + $v['asset_cache'] = array_filter([ + 'enabled' => true, + 'cache_name' => $v['asset_cache_name'] ?? 'assets', + 'regex' => $v['static_regex'] ?? '/\.(css|js|json|xml|txt|map|ico|png|jpe?g|gif|svg|webp|bmp)$/', + ], static fn (mixed $v): bool => $v !== null); + + return $v; + }) + ->end() + ->beforeNormalization() + ->ifTrue(static fn (mixed $v): bool => true) + ->then(static function (mixed $v): array { + if (isset($v['image_cache'])) { + return $v; + } + $v['image_cache'] = array_filter([ + 'enabled' => true, + 'cache_name' => $v['image_cache_name'] ?? 'images', + 'regex' => $v['image_regex'] ?? '/\.(ico|png|jpe?g|gif|svg|webp|bmp)$/', + 'max_entries' => $v['max_image_cache_entries'] ?? 60, + 'max_age' => $v['max_image_age'] ?? 60 * 60 * 24 * 365, + ], static fn (mixed $v): bool => $v !== null); + + return $v; + }) + ->end() + ->beforeNormalization() + ->ifTrue(static fn (mixed $v): bool => true) + ->then(static function (mixed $v): array { + if (isset($v['font_cache'])) { + return $v; + } + $v['font_cache'] = array_filter([ + 'enabled' => true, + 'cache_name' => $v['font_cache_name'] ?? 'fonts', + 'regex' => $v['font_regex'] ?? '/\.(ttf|eot|otf|woff2)$/', + 'max_entries' => $v['max_font_cache_entries'] ?? 60, + 'max_age' => $v['max_font_age'] ?? 60 * 60 * 24 * 365, + ], static fn (mixed $v): bool => $v !== null); + + return $v; + }) + ->end() + ->beforeNormalization() + ->ifTrue(static fn (mixed $v): bool => true) + ->then(static function (mixed $v): array { + if (isset($v['page_cache'])) { + return $v; + } + $v['page_cache'] = array_filter([ + 'enabled' => true, + 'cache_name' => $v['page_cache_name'] ?? 'pages', + 'network_timeout' => $v['network_timeout_seconds'] ?? 3, + 'urls' => $v['warm_cache_urls'] ?? [], + ], static fn (mixed $v): bool => $v !== null); + + return $v; + }) + ->end() + ->beforeNormalization() + ->ifTrue(static fn (mixed $v): bool => true) + ->then(static function (mixed $v): array { + if (isset($v['offline_fallback'])) { + return $v; + } + $v['offline_fallback'] = array_filter([ + 'enabled' => true, + 'page' => $v['page_fallback'] ?? null, + 'image' => $v['image_fallback'] ?? null, + 'font' => $v['font_fallback'] ?? null, + ], static fn (mixed $v): bool => $v !== null); + + return $v; + }) + ->end() ->children() ->booleanNode('use_cdn') ->defaultFalse() @@ -61,10 +142,6 @@ ->defaultTrue() ->info('Whether to cache the manifest file.') ->end() - ->booleanNode('cache_google_fonts') - ->defaultTrue() - ->info('Whether to cache the Google fonts.') - ->end() ->scalarNode('version') ->defaultValue('7.0.0') ->info('The version of workbox. When using local files, the version shall be "7.0.0."') @@ -119,21 +196,155 @@ ->defaultTrue() ->info('Whether to clear the cache during the service worker activation.') ->end() + ->arrayNode('offline_fallback') + ->canBeDisabled() + ->children() + ->append(getUrlNode('page', 'The URL of the offline page fallback.')) + ->append(getUrlNode('image', 'The URL of the offline image fallback.')) + ->append(getUrlNode('font', 'The URL of the offline font fallback.')) + ->end() + ->end() + ->arrayNode('image_cache') + ->canBeDisabled() + ->children() + ->scalarNode('cache_name') + ->defaultValue('images') + ->info('The name of the image cache.') + ->end() + ->scalarNode('regex') + ->defaultValue('/\.(ico|png|jpe?g|gif|svg|webp|bmp)$/') + ->info('The regex to match the images.') + ->example('/\.(ico|png|jpe?g|gif|svg|webp|bmp)$/') + ->end() + ->integerNode('max_entries') + ->defaultValue(60) + ->info('The maximum number of entries in the image cache.') + ->example([50, 100, 200]) + ->end() + ->integerNode('max_age') + ->defaultValue(60 * 60 * 24 * 365) + ->info('The maximum number of seconds before the image cache is invalidated.') + ->example([60 * 60 * 24 * 365, 60 * 60 * 24 * 30, 60 * 60 * 24 * 7]) + ->end() + ->end() + ->end() + ->arrayNode('asset_cache') + ->canBeDisabled() + ->children() + ->scalarNode('cache_name') + ->defaultValue('assets') + ->info('The name of the asset cache.') + ->end() + ->scalarNode('regex') + ->defaultValue('/\.(css|js|json|xml|txt|map|ico|png|jpe?g|gif|svg|webp|bmp)$/') + ->info('The regex to match the assets.') + ->example('/\.(css|js|json|xml|txt|map|ico|png|jpe?g|gif|svg|webp|bmp)$/') + ->end() + ->end() + ->end() + ->arrayNode('font_cache') + ->canBeDisabled() + ->children() + ->scalarNode('cache_name') + ->defaultValue('fonts') + ->info('The name of the font cache.') + ->end() + ->scalarNode('regex') + ->defaultValue('/\.(ttf|eot|otf|woff2)$/') + ->info('The regex to match the fonts.') + ->example('/\.(ttf|eot|otf|woff2)$/') + ->end() + ->integerNode('max_entries') + ->defaultValue(60) + ->info('The maximum number of entries in the image cache.') + ->example([50, 100, 200]) + ->end() + ->integerNode('max_age') + ->defaultValue(60 * 60 * 24 * 365) + ->info('The maximum number of seconds before the font cache is invalidated.') + ->example([60 * 60 * 24 * 365, 60 * 60 * 24 * 30, 60 * 60 * 24 * 7]) + ->end() + ->end() + ->end() + ->arrayNode('page_cache') + ->canBeDisabled() + ->children() + ->scalarNode('cache_name') + ->defaultValue('pages') + ->info('The name of the page cache.') + ->end() + ->integerNode('network_timeout') + ->defaultValue(3) + ->info( + 'The network timeout in seconds before cache is called (for warm cache URLs only).' + ) + ->example([1, 2, 5]) + ->end() + ->arrayNode('urls') + ->treatNullLike([]) + ->treatFalseLike([]) + ->treatTrueLike([]) + ->info('The URLs to warm the cache. The URLs shall be served by the application.') + ->arrayPrototype() + ->beforeNormalization() + ->ifString() + ->then(static fn (string $v): array => [ + 'path' => $v, + ]) + ->end() + ->children() + ->scalarNode('path') + ->isRequired() + ->info('The URL of the shortcut.') + ->example('app_homepage') + ->end() + ->arrayNode('params') + ->treatFalseLike([]) + ->treatTrueLike([]) + ->treatNullLike([]) + ->prototype('variable')->end() + ->info('The parameters of the action.') + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() ->scalarNode('image_cache_name') ->defaultValue('images') ->info('The name of the image cache.') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.image_cache.cache_name" instead.' + ) ->end() ->scalarNode('font_cache_name') ->defaultValue('fonts') ->info('The name of the font cache.') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.font_cache.cache_name" instead.' + ) ->end() ->scalarNode('page_cache_name') ->defaultValue('pages') ->info('The name of the page cache.') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.page_cache.cache_name" instead.' + ) ->end() ->scalarNode('asset_cache_name') ->defaultValue('assets') ->info('The name of the asset cache.') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.asset_cache.cache_name" instead.' + ) ->end() ->append(getUrlNode('page_fallback', 'The URL of the offline page fallback.')) ->append(getUrlNode('image_fallback', 'The URL of the offline image fallback.')) @@ -142,47 +353,92 @@ ->defaultValue('/\.(ico|png|jpe?g|gif|svg|webp|bmp)$/') ->info('The regex to match the images.') ->example('/\.(ico|png|jpe?g|gif|svg|webp|bmp)$/') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.image_cache.regex" instead.' + ) ->end() ->scalarNode('static_regex') - ->defaultValue('/\.(css|js|json|xml|txt|map|webmanifest)$/') + ->defaultValue('/\.(css|js|json|xml|txt|map)$/') ->info('The regex to match the static files.') - ->example('/\.(css|js|json|xml|txt|woff2|ttf|eot|otf|map|webmanifest)$/') + ->example('/\.(css|js|json|xml|txt|map)$/') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.asset_cache.regex" instead.' + ) ->end() ->scalarNode('font_regex') ->defaultValue('/\.(ttf|eot|otf|woff2)$/') ->info('The regex to match the static files.') ->example('/\.(ttf|eot|otf|woff2)$/') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.font_cache.regex" instead.' + ) ->end() ->integerNode('max_image_cache_entries') ->defaultValue(60) ->info('The maximum number of entries in the image cache.') ->example([50, 100, 200]) + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.image_cache.max_entries" instead.' + ) ->end() ->integerNode('max_image_age') ->defaultValue(60 * 60 * 24 * 365) ->info('The maximum number of seconds before the image cache is invalidated.') ->example([60 * 60 * 24 * 365, 60 * 60 * 24 * 30, 60 * 60 * 24 * 7]) + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.image_cache.max_age" instead.' + ) ->end() ->integerNode('max_font_cache_entries') ->defaultValue(30) ->info('The maximum number of entries in the font cache.') ->example([30, 50, 100]) + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.font_cache.max_entries" instead.' + ) ->end() ->integerNode('max_font_age') ->defaultValue(60 * 60 * 24 * 365) ->info('The maximum number of seconds before the font cache is invalidated.') ->example([60 * 60 * 24 * 365, 60 * 60 * 24 * 30, 60 * 60 * 24 * 7]) + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.font_cache.max_age" instead.' + ) ->end() ->integerNode('network_timeout_seconds') ->defaultValue(3) ->info('The network timeout in seconds before cache is called (for warm cache URLs only).') ->example([1, 2, 5]) + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.page_cache.network_timeout" instead.' + ) ->end() ->arrayNode('warm_cache_urls') ->treatNullLike([]) ->treatFalseLike([]) ->treatTrueLike([]) ->info('The URLs to warm the cache. The URLs shall be served by the application.') + ->setDeprecated( + 'spomky-labs/phpwa', + '1.1.0', + 'The "%node%" option is deprecated and will be removed in 2.0.0. Please use "pwa.serviceworker.workbox.page_cache.urls" instead.' + ) ->arrayPrototype() ->beforeNormalization() ->ifString() diff --git a/src/Resources/config/definition/utils/shortcuts.php b/src/Resources/config/definition/utils/shortcuts.php index 96b2dc7..a7190f9 100644 --- a/src/Resources/config/definition/utils/shortcuts.php +++ b/src/Resources/config/definition/utils/shortcuts.php @@ -16,24 +16,23 @@ function setupShortcuts(): ArrayNodeDefinition ->treatNullLike([]) ->info('The shortcuts of the application.') ->arrayPrototype() - ->children() - ->scalarNode('name') - ->isRequired() - ->info('The name of the shortcut.') - ->example('Awesome shortcut') - ->end() - ->scalarNode('short_name') - ->info('The short name of the shortcut.') - ->example('shortcut') - ->end() - ->scalarNode('description') - ->info('The description of the shortcut.') - ->example('This is an awesome shortcut') - ->end() - ->append(getUrlNode('url', 'The URL of the shortcut.')) - ->append(getIconsNode('The icons of the shortcut.')) - ->end() - ->end() + ->children() + ->scalarNode('name') + ->isRequired() + ->info('The name of the shortcut.') + ->example('Awesome shortcut') + ->end() + ->scalarNode('short_name') + ->info('The short name of the shortcut.') + ->example('shortcut') + ->end() + ->scalarNode('description') + ->info('The description of the shortcut.') + ->example('This is an awesome shortcut') + ->end() + ->append(getUrlNode('url', 'The URL of the shortcut.')) + ->append(getIconsNode('The icons of the shortcut.')) + ->end() ->end(); return $node; diff --git a/src/Service/ServiceWorkerCompiler.php b/src/Service/ServiceWorkerCompiler.php index 0f86339..070446b 100644 --- a/src/Service/ServiceWorkerCompiler.php +++ b/src/Service/ServiceWorkerCompiler.php @@ -37,11 +37,17 @@ public function __construct( private Manifest $manifest, private ServiceWorker $serviceWorker, private AssetMapperInterface $assetMapper, + #[Autowire('%kernel.debug%')] + bool $debug, ) { $this->manifestPublicUrl = '/' . trim($manifestPublicUrl, '/'); - $this->jsonOptions = [ - JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + $options = [ + JsonEncode::OPTIONS => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, ]; + if ($debug === true) { + $options[JsonEncode::OPTIONS] |= JSON_PRETTY_PRINT; + } + $this->jsonOptions = $options; } public function compile(): ?string @@ -141,12 +147,12 @@ private function processClearCache(Workbox $workbox, string $body): string private function processAssetCacheRules(Workbox $workbox, string $body): string { + if ($workbox->assetCache->enabled === false) { + return $body; + } $assets = []; foreach ($this->assetMapper->allAssets() as $asset) { - if (preg_match($workbox->imageRegex, $asset->sourcePath) === 1 || preg_match( - $workbox->staticRegex, - $asset->sourcePath - ) === 1) { + if (preg_match($workbox->assetCache->regex, $asset->sourcePath) === 1) { $assets[] = $asset->publicPath; } } @@ -154,9 +160,8 @@ private function processAssetCacheRules(Workbox $workbox, string $body): string $assetUrlsLength = count($assets) * 2; $declaration = <<assetCacheName}', + cacheName: '{$workbox->assetCache->cacheName}', plugins: [ new workbox.cacheableResponse.CacheableResponsePlugin({statuses: [0, 200]}), new workbox.expiration.ExpirationPlugin({ @@ -187,25 +192,27 @@ private function processAssetCacheRules(Workbox $workbox, string $body): string private function processFontCacheRules(Workbox $workbox, string $body): string { + if ($workbox->fontCache->enabled === false) { + return $body; + } $fonts = []; foreach ($this->assetMapper->allAssets() as $asset) { - if (preg_match($workbox->fontRegex, $asset->sourcePath) === 1) { + if (preg_match($workbox->fontCache->regex, $asset->sourcePath) === 1) { $fonts[] = $asset->publicPath; } } $fontUrls = $this->serializer->serialize($fonts, 'json', $this->jsonOptions); $declaration = <<fontCacheName}', + cacheName: '{$workbox->fontCache->cacheName}', plugins: [ new workbox.cacheableResponse.CacheableResponsePlugin({ statuses: [0, 200], }), new workbox.expiration.ExpirationPlugin({ - maxAgeSeconds: {$workbox->maxFontAge}, - maxEntries: {$workbox->maxFontCacheEntries}, + maxAgeSeconds: {$workbox->fontCache->maxAge}, + maxEntries: {$workbox->fontCache->maxEntries}, }), ], }); @@ -231,13 +238,15 @@ private function processFontCacheRules(Workbox $workbox, string $body): string private function processPageImageCacheRule(Workbox $workbox, string $body): string { - $routes = $this->serializer->serialize($workbox->warmCacheUrls, 'json', $this->jsonOptions); + if ($workbox->pageCache->enabled === false) { + return $body; + } + $routes = $this->serializer->serialize($workbox->pageCache->urls, 'json', $this->jsonOptions); $declaration = <<pageCacheName}', - networkTimeoutSeconds: {$workbox->networkTimeoutSeconds}, + cacheName: '{$workbox->pageCache->cacheName}', + networkTimeoutSeconds: {$workbox->pageCache->networkTimeout}, warmCache: {$routes} }); PAGE_CACHE_RULE_STRATEGY; @@ -247,17 +256,19 @@ private function processPageImageCacheRule(Workbox $workbox, string $body): stri private function processImageCacheRule(Workbox $workbox, string $body): string { + if ($workbox->imageCache->enabled === false) { + return $body; + } $declaration = << (request.destination === 'image' && !url.pathname.startsWith('{$this->assetPublicPrefix}')), new workbox.strategies.CacheFirst({ - cacheName: '{$workbox->imageCacheName}', + cacheName: '{$workbox->imageCache->cacheName}', plugins: [ new workbox.cacheableResponse.CacheableResponsePlugin({statuses: [0, 200]}), new workbox.expiration.ExpirationPlugin({ - maxEntries: {$workbox->maxImageCacheEntries}, - maxAgeSeconds: {$workbox->maxImageAge}, + maxEntries: {$workbox->imageCache->maxEntries}, + maxAgeSeconds: {$workbox->imageCache->maxAge}, }), ], }) @@ -274,7 +285,6 @@ private function processCacheRootFilesRule(Workbox $workbox, string $body): stri } $declaration = << '{$this->manifestPublicUrl}' === url.pathname, new workbox.strategies.StaleWhileRevalidate({ @@ -300,7 +310,6 @@ private function processCacheGoogleFontsRule(Workbox $workbox, string $body): st $options = count($options) === 0 ? '' : $this->serializer->serialize($options, 'json', $this->jsonOptions); $declaration = <<pageFallback === null && $workbox->imageFallback === null && $workbox->fontFallback === null) { + if ($workbox->offlineFallback->enabled === false) { return $body; } - $pageFallback = $workbox->pageFallback === null ? 'null' : $this->serializer->serialize( - $workbox->pageFallback, - 'json', - $this->jsonOptions - ); - $imageFallback = $workbox->imageFallback === null ? 'null' : $this->serializer->serialize( - $workbox->imageFallback, - 'json', - $this->jsonOptions - ); - $fontFallback = $workbox->fontFallback === null ? 'null' : $this->serializer->serialize( - $workbox->fontFallback, - 'json', - $this->jsonOptions - ); + $options = [ + 'pageFallback' => $workbox->offlineFallback->pageFallback, + 'imageFallback' => $workbox->offlineFallback->imageFallback, + 'fontFallback' => $workbox->offlineFallback->fontFallback, + ]; + $options = array_filter($options, static fn (mixed $v): bool => $v !== null); + $options = count($options) === 0 ? '' : $this->serializer->serialize($options, 'json', $this->jsonOptions); $declaration = << Date: Tue, 5 Mar 2024 16:27:31 +0100 Subject: [PATCH 10/12] Fix tests --- tests/config.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/config.php b/tests/config.php index cbfdf5f..8987d1f 100644 --- a/tests/config.php +++ b/tests/config.php @@ -226,8 +226,12 @@ 'scope' => '/', 'use_cache' => true, 'workbox' => [ - 'warm_cache_urls' => ['privacy_policy', 'terms_of_service'], - 'page_fallback' => '/offline.html', + 'page_cache' => [ + 'urls' => ['privacy_policy', 'terms_of_service'], + ], + 'offline_fallback' => [ + 'page' => '/offline.html', + ], ], ], ]); From cbe1cb9d1903934bec138ec8a780a4d23808be86 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Tue, 5 Mar 2024 19:29:04 +0100 Subject: [PATCH 11/12] adds: Symfony UX Controller online/offline status (#98) adds: Symfony UX Controller online/offline status --- .gitattributes | 7 +- .github/workflows/exported_files.yml | 18 ++++++ .gitignore | 9 ++- CODE_OF_CONDUCT.md | 20 +++--- README.md | 13 ++-- RELEASES.md | 2 +- assets/dist/controller.d.ts | 21 ++++++ assets/dist/controller.js | 55 ++++++++++++++++ assets/jest.config.js | 1 + assets/package.json | 28 ++++++++ assets/src/controller.ts | 61 ++++++++++++++++++ assets/test/controller.test.ts | 53 ++++++++++++++++ babel.config.js | 7 ++ bin/build_javascript.js | 29 +++++++++ composer.json | 13 ++-- jest.config.js | 19 ++++++ package.json | 79 +++++++++++++++++++++++ rollup.config.js | 95 ++++++++++++++++++++++++++++ src/SpomkyLabsPwaBundle.php | 17 +++++ tests/setup.js | 3 + tsconfig.json | 24 +++++++ 21 files changed, 549 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/exported_files.yml create mode 100644 assets/dist/controller.d.ts create mode 100644 assets/dist/controller.js create mode 100644 assets/jest.config.js create mode 100644 assets/package.json create mode 100644 assets/src/controller.ts create mode 100644 assets/test/controller.test.ts create mode 100644 babel.config.js create mode 100644 bin/build_javascript.js create mode 100644 jest.config.js create mode 100644 package.json create mode 100644 rollup.config.js create mode 100644 tests/setup.js create mode 100644 tsconfig.json diff --git a/.gitattributes b/.gitattributes index 91d3c46..5a2fbc7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,4 +12,9 @@ /phpstan.neon export-ignore /phpstan-baseline.neon export-ignore /phpunit.xml.dist export-ignore -/rector export-ignore +/rector.php export-ignore +/bin export-ignore +/babel.config.js export-ignore +/jest.config.js export-ignore +/rollup.config.js export-ignore +/tsconfig.json export-ignore diff --git a/.github/workflows/exported_files.yml b/.github/workflows/exported_files.yml new file mode 100644 index 0000000..7539f47 --- /dev/null +++ b/.github/workflows/exported_files.yml @@ -0,0 +1,18 @@ +name: Exported files + +on: [push] + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - name: "Checkout code" + uses: "actions/checkout@v4" + + - name: "Check exported files" + run: | + EXPECTED="LICENSE,README.md,RELEASES.md,SECURITY.md,composer.json,package.json" + CURRENT="$(git archive HEAD | tar --list --exclude="assets" --exclude="assets/*" --exclude="src" --exclude="src/*" | paste -s -d ",")" + echo "CURRENT =${CURRENT}" + echo "EXPECTED=${EXPECTED}" + test "${CURRENT}" == "${EXPECTED}" diff --git a/.gitignore b/.gitignore index 18e5996..b8e03bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ -/vendor/ +.phpunit.result.cache +*.cache +*.log +node_modules +package-lock.json /composer.lock -/.phpunit.cache +/vendor +/.phpunit.cache/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 4ec12c7..86be7ce 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo Examples of behavior that contributes to creating a positive environment include: -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities diff --git a/README.md b/README.md index 7c57fb0..0f57b00 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -Progressive Web App for Symfony -=============================== +# Progressive Web App for Symfony ![Build Status](https://github.com/Spomky-Labs/pwa-bundle/workflows/Coding%20Standards/badge.svg) ![Build Status](https://github.com/Spomky-Labs/pwa-bundle/workflows/Static%20Analyze/badge.svg) @@ -24,7 +23,7 @@ Please have a look at the [Web app manifests](https://developer.mozilla.org/en-U # Installation -Install the bundle with Composer: +Install the bundle with Composer: ```bash composer require spomky-labs/pwa-bundle @@ -42,9 +41,9 @@ I bring solutions to your problems and answer your questions. If you really love that project and the work I have done or if you want I prioritize your issues, then you can help me out for a couple of :beers: or more! -* [Become a sponsor](https://github.com/sponsors/Spomky) -* [Become a Patreon](https://www.patreon.com/FlorentMorselli) -* [Buy me a coffee](https://www.buymeacoffee.com/FlorentMorselli) +- [Become a sponsor](https://github.com/sponsors/Spomky) +- [Become a Patreon](https://www.patreon.com/FlorentMorselli) +- [Buy me a coffee](https://www.buymeacoffee.com/FlorentMorselli) # Contributing @@ -58,7 +57,7 @@ Please make sure to [follow these best practices](.github/CONTRIBUTING.md). # Security Issues If you discover a security vulnerability within the project, please **don't use the bug tracker and don't publish it publicly**. -Instead, all security issues must be sent to security [at] spomky-labs.com. +Instead, all security issues must be sent to security [at] spomky-labs.com. # Licence diff --git a/RELEASES.md b/RELEASES.md index b3f2355..66861f3 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -3,6 +3,6 @@ ## Supported Versions | Version | Supported | -|---------|--------------------| +| ------- | ------------------ | | 1.0.x | :white_check_mark: | | < 1.0.x | :x: | diff --git a/assets/dist/controller.d.ts b/assets/dist/controller.d.ts new file mode 100644 index 0000000..a7c69a2 --- /dev/null +++ b/assets/dist/controller.d.ts @@ -0,0 +1,21 @@ +import { Controller } from '@hotwired/stimulus'; +export default class extends Controller { + static targets: string[]; + static values: { + onlineMessage: { + type: StringConstructor; + default: string; + }; + offlineMessage: { + type: StringConstructor; + default: string; + }; + }; + readonly onlineMessageValue: string; + readonly offlineMessageValue: string; + readonly attributeTargets: HTMLElement[]; + readonly messageTargets: HTMLElement[]; + connect(): void; + dispatchEvent(name: any, payload: any): void; + statusChanged(data: any): void; +} diff --git a/assets/dist/controller.js b/assets/dist/controller.js new file mode 100644 index 0000000..598dc2f --- /dev/null +++ b/assets/dist/controller.js @@ -0,0 +1,55 @@ +import { Controller } from '@hotwired/stimulus'; + +var Status; +(function (Status) { + Status["OFFLINE"] = "OFFLINE"; + Status["ONLINE"] = "ONLINE"; +})(Status || (Status = {})); +class default_1 extends Controller { + connect() { + this.dispatchEvent('connect', {}); + if (navigator.onLine) { + this.statusChanged({ + status: Status.ONLINE, + message: this.onlineMessageValue, + }); + } + else { + this.statusChanged({ + status: Status.OFFLINE, + message: this.offlineMessageValue, + }); + } + window.addEventListener("offline", () => { + this.statusChanged({ + status: Status.OFFLINE, + message: this.offlineMessageValue, + }); + }); + window.addEventListener("online", () => { + this.statusChanged({ + status: Status.ONLINE, + message: this.onlineMessageValue, + }); + }); + } + dispatchEvent(name, payload) { + this.dispatch(name, { detail: payload, prefix: 'connection-status' }); + } + statusChanged(data) { + this.messageTargets.forEach((element) => { + element.innerHTML = data.message; + }); + this.attributeTargets.forEach((element) => { + element.setAttribute('data-connection-status', data.status); + }); + this.dispatchEvent('status-changed', { detail: data }); + } +} +default_1.targets = ['message', 'attribute']; +default_1.values = { + onlineMessage: { type: String, default: 'You are online.' }, + offlineMessage: { type: String, default: 'You are offline.' }, +}; + +export { default_1 as default }; diff --git a/assets/jest.config.js b/assets/jest.config.js new file mode 100644 index 0000000..a7fde9b --- /dev/null +++ b/assets/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../jest.config.js'); diff --git a/assets/package.json b/assets/package.json new file mode 100644 index 0000000..a3a4152 --- /dev/null +++ b/assets/package.json @@ -0,0 +1,28 @@ +{ + "name": "@pwa/connection-status", + "description": "PWA for Symfony", + "license": "MIT", + "version": "1.0.0", + "main": "dist/controller.js", + "types": "dist/controller.d.ts", + "symfony": { + "controllers": { + "connection-status": { + "main": "dist/controller.js", + "name": "pwa/connection-status", + "webpackMode": "eager", + "fetch": "eager", + "enabled": true + } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0" + } + }, + "peerDependencies": { + "@hotwired/stimulus": "^3.0.0" + }, + "devDependencies": { + "@hotwired/stimulus": "^3.0.0" + } +} diff --git a/assets/src/controller.ts b/assets/src/controller.ts new file mode 100644 index 0000000..bf83be2 --- /dev/null +++ b/assets/src/controller.ts @@ -0,0 +1,61 @@ +'use strict'; + +import { Controller } from '@hotwired/stimulus'; + +enum Status { + OFFLINE = 'OFFLINE', + ONLINE = 'ONLINE', +} +export default class extends Controller { + static targets = ['message', 'attribute']; + static values = { + onlineMessage: { type: String, default: 'You are online.' }, + offlineMessage: { type: String, default: 'You are offline.' }, + }; + + declare readonly onlineMessageValue: string; + declare readonly offlineMessageValue: string; + declare readonly attributeTargets: HTMLElement[]; + declare readonly messageTargets: HTMLElement[]; + + connect() { + this.dispatchEvent('connect', {}); + if (navigator.onLine) { + this.statusChanged({ + status: Status.ONLINE, + message: this.onlineMessageValue, + }); + } else { + this.statusChanged({ + status: Status.OFFLINE, + message: this.offlineMessageValue, + }); + } + + window.addEventListener('offline', () => { + this.statusChanged({ + status: Status.OFFLINE, + message: this.offlineMessageValue, + }); + }); + window.addEventListener('online', () => { + this.statusChanged({ + status: Status.ONLINE, + message: this.onlineMessageValue, + }); + }); + } + dispatchEvent(name, payload) { + this.dispatch(name, { detail: payload, prefix: 'connection-status' }); + } + + statusChanged(data) { + this.messageTargets.forEach((element) => { + element.innerHTML = data.message; + }); + this.attributeTargets.forEach((element) => { + element.setAttribute('data-connection-status', data.status); + }); + this.dispatchEvent('status-changed', { detail: data }); + } +} diff --git a/assets/test/controller.test.ts b/assets/test/controller.test.ts new file mode 100644 index 0000000..750b982 --- /dev/null +++ b/assets/test/controller.test.ts @@ -0,0 +1,53 @@ +'use strict'; + +import {Application, Controller} from '@hotwired/stimulus'; +import {getByTestId, waitFor} from '@testing-library/dom'; +import {clearDOM, mountDOM} from '@symfony/stimulus-testing'; +import StatusController from '../src/controller'; + +// Controller used to check the actual controller was properly booted +class CheckController extends Controller { + connect() { + this.element.addEventListener('pwa-status:connect', () => { + this.element.classList.add('connected'); + }); + } +} + +const startStimulus = () => { + const application: Application = Application.start(); + application.register('check', CheckController); + application.register('pwa-status', StatusController); +}; + +describe('StatusController', () => { + let container: any; + + beforeEach(() => { + container = mountDOM(` + + + Symfony UX + + +
+
+ + + `); + }); + + afterEach(() => { + clearDOM(); + }); + + it('connect', async () => { + expect(getByTestId(container, 'pwa-status')).not.toHaveClass('connected'); + + startStimulus(); + await waitFor(() => expect(getByTestId(container, 'pwa-status')).toHaveClass('connected')); + }); +}); diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..da1f3ca --- /dev/null +++ b/babel.config.js @@ -0,0 +1,7 @@ +module.exports = { + presets: [ + ['@babel/preset-env', {targets: {node: 'current'}}], + '@babel/react', + ['@babel/preset-typescript', { allowDeclareFields: true }] + ], +}; diff --git a/bin/build_javascript.js b/bin/build_javascript.js new file mode 100644 index 0000000..af28ddc --- /dev/null +++ b/bin/build_javascript.js @@ -0,0 +1,29 @@ +/** + * This file is used to compile the TypeScript files in the assets/src directory + * of each package. + * + * It allows each package to spawn its own rollup process, which is necessary + * to keep memory usage down. + */ +const { spawnSync } = require('child_process'); +const glob = require('glob'); + +const files = [ + ...glob.sync('assets/src/*controller.ts'), +]; + +files.forEach((file) => { + const result = spawnSync('node', [ + 'node_modules/.bin/rollup', + '-c', + '--environment', + `INPUT_FILE:${file}`, + ], { + stdio: 'inherit', + shell: true + }); + + if (result.error) { + console.error(`Error compiling ${file}:`, result.error); + } +}); diff --git a/composer.json b/composer.json index 584251b..49ff62c 100644 --- a/composer.json +++ b/composer.json @@ -3,25 +3,30 @@ "description": "Progressive Web App Manifest Generator Bundle for Symfony.", "type": "symfony-bundle", "license": "MIT", - "keywords": ["PWA", "Bundle", "Symfony"], + "keywords": [ + "PWA", + "Bundle", + "Symfony" + ], "homepage": "https://github.com/spomky-labs", "authors": [ { "name": "Florent Morselli", "homepage": "https://github.com/Spomky" - },{ + }, + { "name": "All contributors", "homepage": "https://github.com/spomky-labs/pwa-bundle/contributors" } ], "autoload": { "psr-4": { - "SpomkyLabs\\PwaBundle\\": "src/" + "SpomkyLabs\\PwaBundle\\": "src/" } }, "autoload-dev": { "psr-4": { - "SpomkyLabs\\PwaBundle\\Tests\\": "tests/" + "SpomkyLabs\\PwaBundle\\Tests\\": "tests/" } }, "require": { diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..5916d11 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,19 @@ +const path = require('path'); + +module.exports = { + testEnvironmentOptions: { + "url": "https://localhost/" + }, + verbose: true, + testRegex: "test/.*\\.test.ts", + testEnvironment: 'jsdom', + setupFilesAfterEnv: [ + path.join(__dirname, 'tests/setup.js'), + ], + transform: { + '\\.(j|t)s$': ['babel-jest', { configFile: path.join(__dirname, './babel.config.js') }] + }, + "transformIgnorePatterns": [ + "node_modules/(?!@ngrx|(?!deck.gl)|ng-dynamic)" + ] +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..43c8c50 --- /dev/null +++ b/package.json @@ -0,0 +1,79 @@ +{ + "private": true, + "workspaces": [ + "assets" + ], + "scripts": { + "build": "node bin/build_javascript.js", + "test": "yarn workspaces run jest", + "lint": "yarn workspaces run eslint src test", + "format": "prettier assets/src/*.ts assets/test/*.js *.{json,md} --write", + "check-lint": "yarn lint --no-fix", + "check-format": "yarn format --no-write --check" + }, + "devDependencies": { + "@babel/core": "^7.15.8", + "@babel/preset-env": "^7.15.8", + "@babel/preset-react": "^7.15.8", + "@babel/preset-typescript": "^7.15.8", + "@rollup/plugin-commonjs": "^25.0.0", + "@rollup/plugin-node-resolve": "^15.0.0", + "@rollup/plugin-typescript": "^11.0.0", + "@symfony/stimulus-testing": "^2.0.1", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "babel-jest": "^29.0", + "clean-css-cli": "^5.6.2", + "eslint": "^8.1.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-jest": "^27.0.0", + "jest": "^29.0.0", + "jest-environment-jsdom": "^29.0", + "prettier": "^3.0.0", + "rollup": "^3.7.0", + "tslib": "^2.3.1", + "typescript": "^5.0.0" + }, + "eslintConfig": { + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint" + ], + "extends": [ + "eslint:recommended", + "prettier", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/ban-ts-comment": "off", + "quotes": [ + "error", + "single" + ] + }, + "env": { + "browser": true + }, + "overrides": [ + { + "files": [ + "assets/test/**/*.ts" + ], + "extends": [ + "plugin:jest/recommended" + ] + } + ] + }, + "prettier": { + "printWidth": 120, + "trailingComma": "es5", + "tabWidth": 4, + "jsxBracketSameLine": true, + "singleQuote": true + } +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..ba64664 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,95 @@ +const resolve = require('@rollup/plugin-node-resolve'); +const commonjs = require('@rollup/plugin-commonjs'); +const typescript = require('@rollup/plugin-typescript'); +const fs = require('fs'); +const glob = require('glob'); +const path = require('path'); + +/** + * Guarantees that any files imported from a peer dependency are treated as an external. + * + * For example, if we import `chart.js/auto`, that would not normally + * match the "chart.js" we pass to the "externals" config. This plugin + * catches that case and adds it as an external. + * + * Inspired by https://github.com/oat-sa/rollup-plugin-wildcard-external + */ +const wildcardExternalsPlugin = (peerDependencies) => ({ + name: 'wildcard-externals', + resolveId(source, importer) { + if (importer) { + let matchesExternal = false; + peerDependencies.forEach((peerDependency) => { + if (source.includes(`/${peerDependency}/`)) { + matchesExternal = true; + } + }); + + if (matchesExternal) { + return { + id: source, + external: true, + moduleSideEffects: true + }; + } + } + + return null; // other ids should be handled as usually + } +}); + +/** + * Moves the generated TypeScript declaration files to the correct location. + * + * This could probably be configured in the TypeScript plugin. + */ +const moveTypescriptDeclarationsPlugin = (packagePath) => ({ + name: 'move-ts-declarations', + writeBundle: async () => { + const files = glob.sync(path.join(packagePath, 'dist', '**', 'assets', 'src', '**/*.d.ts')); + files.forEach((file) => { + // a bit odd, but remove first 8 directories, which will leave + // only the relative path to the file + const relativePath = file.split('/').slice(5).join('/'); + + const targetFile = path.join(packagePath, 'dist', relativePath); + if (!fs.existsSync(path.dirname(targetFile))) { + fs.mkdirSync(path.dirname(targetFile), { recursive: true }); + } + fs.renameSync(file, targetFile); + }); + } +}); + +const file = process.env.INPUT_FILE; +const packageRoot = path.join(file, '..', '..'); +const packagePath = path.join(packageRoot, 'package.json'); +const packageData = JSON.parse(fs.readFileSync(packagePath, 'utf8')); +const peerDependencies = [ + '@hotwired/stimulus', + ...(packageData.peerDependencies ? Object.keys(packageData.peerDependencies) : []) +]; + +module.exports = { + input: file, + output: { + file: path.join(packageRoot, 'dist', path.basename(file, '.ts') + '.js'), + format: 'esm', + }, + external: peerDependencies, + plugins: [ + resolve(), + typescript({ + filterRoot: packageRoot, + include: ['**/*.ts'], + compilerOptions: { + outDir: 'dist', + declaration: true, + emitDeclarationOnly: true, + } + }), + commonjs(), + wildcardExternalsPlugin(peerDependencies), + moveTypescriptDeclarationsPlugin(packageRoot), + ], +}; diff --git a/src/SpomkyLabsPwaBundle.php b/src/SpomkyLabsPwaBundle.php index 7e1bb91..ee9040f 100644 --- a/src/SpomkyLabsPwaBundle.php +++ b/src/SpomkyLabsPwaBundle.php @@ -57,6 +57,23 @@ public function loadExtension(array $config, ContainerConfigurator $container, C } public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void + { + $this->setAssetPublicPrefix($builder); + $this->setAssetMapperPath($builder); + } + + private function setAssetMapperPath(ContainerBuilder $builder): void + { + $builder->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__ . '/../assets/dist' => '@pwa/connection-status/status', + ], + ], + ]); + } + + private function setAssetPublicPrefix(ContainerBuilder $builder): void { $bundles = $builder->getParameter('kernel.bundles'); if (isset($bundles['FrameworkBundle'])) { diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..9be33c3 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,3 @@ +'use strict'; + +import '@symfony/stimulus-testing/setup'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..140b25f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "lib": ["dom", "es2015"], + "module": "es2015", + "moduleResolution": "node", + "noUnusedLocals": true, + "rootDir": "", + "strict": true, + "strictPropertyInitialization": false, + "target": "es2017", + "removeComments": true, + "outDir": "types", + "baseUrl": ".", + "noEmit": false, + "declaration": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "jsx": "react" + }, + "exclude": ["assets/dist"], + "include": ["assets/src", "assets/test"] +} From 5c9d2406872f7575ccfd9c779e18ba3ec431c385 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Tue, 5 Mar 2024 21:15:21 +0100 Subject: [PATCH 12/12] adds: background sync rules (#102) --- phpstan-baseline.neon | 30 +++++++++++ src/Dto/BackgroundSync.php | 23 ++++++++ src/Dto/Workbox.php | 6 +++ .../config/definition/service_worker.php | 53 +++++++++++++++---- src/Service/ServiceWorkerCompiler.php | 27 ++++++++++ 5 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 src/Dto/BackgroundSync.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 095f8dc..01bd3c0 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -100,6 +100,31 @@ parameters: count: 1 path: src/Command/CreateServiceWorkerCommand.php + - + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\BackgroundSync has an uninitialized property \\$forceSyncFallback\\. Give it default value or assign it in the constructor\\.$#" + count: 1 + path: src/Dto/BackgroundSync.php + + - + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\BackgroundSync has an uninitialized property \\$maxRetentionTime\\. Give it default value or assign it in the constructor\\.$#" + count: 1 + path: src/Dto/BackgroundSync.php + + - + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\BackgroundSync has an uninitialized property \\$method\\. Give it default value or assign it in the constructor\\.$#" + count: 1 + path: src/Dto/BackgroundSync.php + + - + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\BackgroundSync has an uninitialized property \\$queueName\\. Give it default value or assign it in the constructor\\.$#" + count: 1 + path: src/Dto/BackgroundSync.php + + - + message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\BackgroundSync has an uninitialized property \\$regex\\. Give it default value or assign it in the constructor\\.$#" + count: 1 + path: src/Dto/BackgroundSync.php + - message: "#^Class SpomkyLabs\\\\PwaBundle\\\\Dto\\\\File has an uninitialized property \\$accept\\. Give it default value or assign it in the constructor\\.$#" count: 1 @@ -655,6 +680,11 @@ parameters: count: 1 path: src/Service/ServiceWorkerCompiler.php + - + message: "#^Strict comparison using \\!\\=\\= between bool\\|int and null will always evaluate to true\\.$#" + count: 1 + path: src/Service/ServiceWorkerCompiler.php + - message: "#^Cannot access offset 'public_prefix' on mixed\\.$#" count: 1 diff --git a/src/Dto/BackgroundSync.php b/src/Dto/BackgroundSync.php new file mode 100644 index 0000000..7d34b71 --- /dev/null +++ b/src/Dto/BackgroundSync.php @@ -0,0 +1,23 @@ + + */ + #[SerializedName('background_sync')] + public array $backgroundSync = []; + #[SerializedName('clear_cache')] public bool $clearCache = true; } diff --git a/src/Resources/config/definition/service_worker.php b/src/Resources/config/definition/service_worker.php index 1b1e614..abbb1fa 100644 --- a/src/Resources/config/definition/service_worker.php +++ b/src/Resources/config/definition/service_worker.php @@ -32,6 +32,16 @@ ->defaultFalse() ->info('Whether to skip waiting for the service worker to be activated.') ->end() + ->scalarNode('scope') + ->cannotBeEmpty() + ->defaultValue('/') + ->info('The scope of the service worker.') + ->example('/app/') + ->end() + ->booleanNode('use_cache') + ->defaultTrue() + ->info('Whether the service worker should use the cache.') + ->end() ->arrayNode('workbox') ->info('The configuration of the workbox.') ->canBeDisabled() @@ -310,6 +320,39 @@ ->end() ->end() ->end() + ->arrayNode('background_sync') + ->treatNullLike([]) + ->treatFalseLike([]) + ->treatTrueLike([]) + ->info('The background sync configuration.') + ->arrayPrototype() + ->children() + ->scalarNode('queue_name') + ->isRequired() + ->info('The name of the queue.') + ->example(['api-requests', 'image-uploads']) + ->end() + ->scalarNode('regex') + ->isRequired() + ->info('The regex to match the URLs.') + ->example(['/\/api\//']) + ->end() + ->scalarNode('method') + ->defaultValue('POST') + ->info('The HTTP method.') + ->example(['POST', 'PUT', 'PATCH', 'DELETE']) + ->end() + ->integerNode('max_retention_time') + ->defaultValue(60 * 24 * 5) + ->info('The maximum retention time in minutes.') + ->end() + ->booleanNode('force_sync_callback') + ->defaultFalse() + ->info('Whether to force the sync callback.') + ->end() + ->end() + ->end() + ->end() ->scalarNode('image_cache_name') ->defaultValue('images') ->info('The name of the image cache.') @@ -464,16 +507,6 @@ ->end() ->end() ->end() - ->scalarNode('scope') - ->cannotBeEmpty() - ->defaultValue('/') - ->info('The scope of the service worker.') - ->example('/app/') - ->end() - ->booleanNode('use_cache') - ->defaultTrue() - ->info('Whether the service worker should use the cache.') - ->end() ->end() ->end() ->end() diff --git a/src/Service/ServiceWorkerCompiler.php b/src/Service/ServiceWorkerCompiler.php index 070446b..f0e0bd5 100644 --- a/src/Service/ServiceWorkerCompiler.php +++ b/src/Service/ServiceWorkerCompiler.php @@ -102,6 +102,7 @@ private function processWorkbox(Workbox $workbox, string $body): string $body = $this->processImageCacheRule($workbox, $body); $body = $this->processCacheRootFilesRule($workbox, $body); $body = $this->processCacheGoogleFontsRule($workbox, $body); + $body = $this->processBackgroundSyncRule($workbox, $body); return $this->processOfflineFallback($workbox, $body); } @@ -316,6 +317,32 @@ private function processCacheGoogleFontsRule(Workbox $workbox, string $body): st return $body . PHP_EOL . PHP_EOL . trim($declaration); } + private function processBackgroundSyncRule(Workbox $workbox, string $body): string + { + if ($workbox->backgroundSync === []) { + return $body; + } + + $declaration = ''; + foreach ($workbox->backgroundSync as $sync) { + $options = [ + 'maxRetentionTime' => $sync->maxRetentionTime, + 'forceSyncCallback' => $sync->forceSyncFallback, + ]; + $options = array_filter($options, static fn (mixed $v): bool => $v !== null); + $options = count($options) === 0 ? '' : $this->serializer->serialize($options, 'json', $this->jsonOptions); + $declaration .= <<regex}', + new workbox.strategies.NetworkOnly({plugins: [new workbox.backgroundSync.BackgroundSyncPlugin('{$sync->queueName}',{$options})] }), + '{$sync->method}' +); +BACKGROUND_SYNC_RULE_STRATEGY; + } + + return $body . PHP_EOL . PHP_EOL . trim($declaration); + } + private function processOfflineFallback(Workbox $workbox, string $body): string { if ($workbox->offlineFallback->enabled === false) {