diff --git a/README.md b/README.md index f1fb3ce..d7e663e 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,14 @@ This project follows the [semantic versioning](http://semver.org/) strictly. # Documentation -## Manifest Configuration +A Progressive Web Application (PWA) is a web application that has a manifest and can be installed on a device. +The manifest is a JSON file that describes the application and its assets (icons, screenshots, etc.). + +A Service Worker can be used to provide offline capabilities to the application or to provide push notifications. + +## Manifest + +### Manifest Configuration The bundle is able to generate a manifest from a configuration file. The manifest members defined in the [Web app manifests](https://developer.mozilla.org/en-US/docs/Web/Manifest) are supported @@ -57,7 +64,7 @@ phpwa: 'application/json': ['.json'] ``` -## Manifest Generation +### Manifest Generation When the configuration is set, you can generate the manifest with the following command: @@ -98,7 +105,7 @@ For instance, if your application root URL is https://example.com/foo/bar, set t } ``` -## Manifest Icons +### Manifest Icons The bundle is able to generate icons from a source image. The icons must be square and the source image should be at best quality as possible. @@ -109,7 +116,7 @@ Depending on your system, you may have to install one extension or the other. ```yaml # config/packages/phpwa.yaml phpwa: - icon_processor: 'pwa.icon_processor.gd' # or 'pwa.icon_processor.imagick' + image_processor: 'pwa.image_processor.gd' # or 'pwa.image_processor.imagick' icons: - src: "%kernel.project_dir%/assets/images/logo.png" sizes: [48, 57, 60, 72, 76, 96, 114, 128, 144, 152, 180, 192, 256, 384, 512, 1024] @@ -126,7 +133,7 @@ With the configuration above, the bundle will generate * 16 icons from the `mask.png` image. The format will be `png` and the purpose will be `maskable`. * And 1 icon from the `logo.svg` image. The format will be `svg` and the size will be `any`. -## Manifest Screenshots +### Manifest Screenshots The bundle is able to generate screenshots from a source image. Similar to icons, the source image should be at best quality as possible. @@ -134,6 +141,7 @@ Similar to icons, the source image should be at best quality as possible. ```yaml # config/packages/phpwa.yaml phpwa: + image_processor: 'pwa.image_processor.gd' # or 'pwa.image_processor.imagick' screenshots: - src: "%kernel.project_dir%/assets/screenshots/narrow.png" label: "View of the application home page" @@ -149,7 +157,7 @@ The bundle will automatically generate screenshots from the source images and ad such as the `sizes` and the `form_factor` (`wide` or `narrow`). The `format` parameter is optional. It indicates the format of the generated image. If not set, the format will be the same as the source image. -## Manifest Shortcuts +### Manifest Shortcuts The `shortcuts` member may contain a list of icons. The parameters are very similar to the `icons` member. @@ -157,6 +165,7 @@ The parameters are very similar to the `icons` member. ```yaml # config/packages/phpwa.yaml phpwa: + image_processor: 'pwa.image_processor.gd' # or 'pwa.image_processor.imagick' shortcuts: - name: "Shortcut 1" short_name: "shortcut-1" @@ -176,6 +185,47 @@ phpwa: format: 'webp' ``` +### Using the Manifest + +The manifest can be used in your HTML pages with the following code in the `
` section. +In you customized the output filename or the public folder, please replace `pwa.json` with the path to your manifest file. + +```html + +``` + +## Service Worker + +The following command will generate a Service Worker in the `public` directory. + +```bash +symfony console pwa:sw +``` + +You can change the output file name and the output folder with the following options: + +* `--output` or `-o` to change the output file name (default: `sw.js`) +* `--public_folder` or `-p` to change the public folder (default: `%kernel.project_dir%/public`) + +Next, you have to register the Service Worker in your HTML pages with the following code in the `` section. +It can also be done in a JavaScript file such as `app.js`. +In you customized the output filename or the public folder, please replace `sw.js` with the path to your Service Worker file. + +```html + +``` + +### Service Worker Configuration + +The Service Worker uses Workbox and comes with predefined configuration and recipes. +You are free to change the configuration and the recipes to fit your needs. +In particular, you can change the cache strategy, the cache expiration, the cache name, etc. # Support diff --git a/src/Command/GenerateManifestCommand.php b/src/Command/GenerateManifestCommand.php index f5f553e..d8c3cea 100644 --- a/src/Command/GenerateManifestCommand.php +++ b/src/Command/GenerateManifestCommand.php @@ -31,17 +31,15 @@ final class GenerateManifestCommand extends Command { private readonly MimeTypes $mime; - private readonly Filesystem $filesystem; - public function __construct( private readonly null|ImageProcessor $imageProcessor, #[Autowire('%spomky_labs_pwa.config%')] private readonly array $config, #[Autowire('%kernel.project_dir%')] private readonly string $rootDir, + private readonly Filesystem $filesystem, ) { $this->mime = MimeTypes::getDefault(); - $this->filesystem = new Filesystem(); parent::__construct(); } diff --git a/src/Command/WorkboxInitCommand.php b/src/Command/WorkboxInitCommand.php new file mode 100644 index 0000000..a83df96 --- /dev/null +++ b/src/Command/WorkboxInitCommand.php @@ -0,0 +1,72 @@ +addOption( + 'public_folder', + 'p', + InputOption::VALUE_OPTIONAL, + 'Public folder', + $this->rootDir . '/public' + ) + ->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Output file', 'sw.js') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title('Workbox Service Worker'); + + $publicFolder = Path::canonicalize($input->getOption('public_folder')); + $outputFile = '/' . trim((string) $input->getOption('output'), '/'); + + if (! $this->filesystem->exists($publicFolder)) { + $this->filesystem->mkdir($publicFolder); + } + + $resourcePath = $this->fileLocator->locate('@SpomkyLabsPwaBundle/Resources/workbox.js', null, false); + if (count($resourcePath) !== 1) { + $io->error('Unable to find the Workbox resource.'); + return self::FAILURE; + } + $resourcePath = $resourcePath[0]; + $this->filesystem->copy($resourcePath, $publicFolder . $outputFile); + + + $io->success('Workbox is ready to use!'); + + return self::SUCCESS; + } +} diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 4b2f8ef..27df479 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -3,6 +3,7 @@ declare(strict_types=1); use SpomkyLabs\PwaBundle\Command\GenerateManifestCommand; +use SpomkyLabs\PwaBundle\Command\WorkboxInitCommand; use SpomkyLabs\PwaBundle\ImageProcessor\GDImageProcessor; use SpomkyLabs\PwaBundle\ImageProcessor\ImagickImageProcessor; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; @@ -16,6 +17,7 @@ ; $container->set(GenerateManifestCommand::class); + $container->set(WorkboxInitCommand::class); if (extension_loaded('imagick')) { $container diff --git a/src/Resources/workbox.js b/src/Resources/workbox.js new file mode 100644 index 0000000..fbb9f6d --- /dev/null +++ b/src/Resources/workbox.js @@ -0,0 +1,65 @@ +importScripts( + 'https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js' +); + +const { + pageCache, // Cache pages with a network-first strategy. + imageCache, // Cache images with a cache-first strategy. + staticResourceCache, // Cache CSS, JS, and Web Worker requests with a cache-first strategy for 1 year. + offlineFallback, // Serve an offline fallback page when the user is offline and try to revalidate the request when the user is online. + warmStrategyCache, // Warm the cache with URLs that are likely to be visited next or during offline navigation. +} = workbox.recipes; +const { CacheFirst } = workbox.strategies; +const { registerRoute } = workbox.routing; +const { CacheableResponsePlugin } = workbox.cacheableResponse; +const { ExpirationPlugin } = workbox.expiration; + +const PAGE_CACHE_NAME = 'pages'; +const FONT_CACHE_NAME = 'fonts'; +const STATIC_CACHE_NAME = 'assets'; +const IMAGE_CACHE_NAME = 'images'; +const OFFLINE_URI = '/offline'; // URI of the offline fallback page. +const warmCacheUrls = [ // URLs to warm the cache with. + '/', +]; + +// *** Recipes *** +// Cache pages with a network-first strategy. +pageCache({ + cacheName: PAGE_CACHE_NAME +}); +// Cache CSS, JS, and Web Worker requests with a cache-first strategy. +staticResourceCache({ + cacheName: STATIC_CACHE_NAME, +}); +// Cache images with a cache-first strategy. +imageCache({ + cacheName: IMAGE_CACHE_NAME, + maxEntries: 60, // Default 60 images + maxAgeSeconds: 60 * 60 * 24 * 30, // Default 30 days +}); +// Serve an offline fallback page when the user is offline and try to revalidate the request when the user is online. +offlineFallback({ + pageFallback: OFFLINE_URI, +}); + +// Cache the underlying font files with a cache-first strategy. +registerRoute( + ({request}) => request.destination === 'font', + new CacheFirst({ + cacheName: FONT_CACHE_NAME, + plugins: [ + new CacheableResponsePlugin({ + statuses: [0, 200], + }), + new ExpirationPlugin({ + maxAgeSeconds: 60 * 60 * 24 * 365, + maxEntries: 30, + }), + ], + }), +); + +// Warm the cache with URLs that are likely to be visited next or during offline navigation. +const strategy = new CacheFirst(); +warmStrategyCache({urls: warmCacheUrls, strategy});