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
new file mode 100644
index 0000000..66861f3
--- /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: |
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.
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/ecs.php b/ecs.php
index b3b5192..62aa8e7 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/Resources/config'],
+ MethodChainingNewlineFixer::class => [__DIR__ . '/src/Resources/config'],
]);
$config->parallel();
- $config->paths([
- __DIR__.'/src',
- __DIR__.'/tests',
- ]);
+ $config->paths([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/ecs.php', __DIR__ . '/rector.php']);
};
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/phpstan-baseline.neon b/phpstan-baseline.neon
index a26fa3e..01bd3c0 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -101,39 +101,29 @@ parameters:
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\\(\\)\\.$#"
+ 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/DependencyInjection/Configuration.php
+ path: src/Dto/BackgroundSync.php
-
- message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:end\\(\\)\\.$#"
+ 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/DependencyInjection/Configuration.php
+ path: src/Dto/BackgroundSync.php
-
- message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:scalarNode\\(\\)\\.$#"
- count: 5
- path: src/DependencyInjection/Configuration.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: "#^Cannot access offset 'public_prefix' on mixed\\.$#"
+ 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/DependencyInjection/SpomkyLabsPwaExtension.php
+ path: src/Dto/BackgroundSync.php
-
- message: "#^Method SpomkyLabs\\\\PwaBundle\\\\DependencyInjection\\\\SpomkyLabsPwaExtension\\:\\:getConfiguration\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#"
+ 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/DependencyInjection/SpomkyLabsPwaExtension.php
+ 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\\.$#"
@@ -155,6 +145,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
@@ -320,38 +315,53 @@ parameters:
count: 1
path: src/Dto/Widget.php
+ -
+ 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
+
+ -
+ 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
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 \\$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 \\$standardRulesPlaceholder\\. 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 \\$useCDN\\. 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 \\$version\\. 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 \\$widgetsPlaceholder\\. 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 \\$workboxImportPlaceholder\\. 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 \\$version\\. Give it default value or assign it in the constructor\\.$#"
count: 1
path: src/Dto/Workbox.php
@@ -468,4 +478,219 @@ 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: "#^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: "#^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
+ 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
+
+ -
+ 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
+ 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/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/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/Command/CreateServiceWorkerCommand.php b/src/Command/CreateServiceWorkerCommand.php
index 50bc38c..0d0e233 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('%kernel.project_dir%')]
private readonly string $projectDir,
) {
@@ -55,7 +54,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 2201ad6..0000000
--- a/src/DependencyInjection/Configuration.php
+++ /dev/null
@@ -1,808 +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')
- ->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')
- ->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')
- ->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')
- ->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/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/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/Dto/Workbox.php b/src/Dto/Workbox.php
index 62313a7..4b4a132 100644
--- a/src/Dto/Workbox.php
+++ b/src/Dto/Workbox.php
@@ -18,69 +18,33 @@ final class Workbox
#[SerializedName('workbox_public_url')]
public string $workboxPublicUrl;
- #[SerializedName('workbox_import_placeholder')]
- public string $workboxImportPlaceholder;
+ #[SerializedName('cache_manifest')]
+ public bool $cacheManifest;
- #[SerializedName('standard_rules_placeholder')]
- public string $standardRulesPlaceholder;
+ #[SerializedName('image_cache')]
+ public ImageCache $imageCache;
- #[SerializedName('offline_fallback_placeholder')]
- public string $offlineFallbackPlaceholder;
+ #[SerializedName('font_cache')]
+ public FontCache $fontCache;
- #[SerializedName('widgets_placeholder')]
- public string $widgetsPlaceholder;
+ #[SerializedName('page_cache')]
+ public PageCache $pageCache;
- #[SerializedName('page_fallback')]
- public null|Url $pageFallback = null;
+ #[SerializedName('asset_cache')]
+ public AssetCache $assetCache;
- #[SerializedName('image_fallback')]
- public null|Asset $imageFallback = null;
+ #[SerializedName('google_fonts')]
+ public GoogleFontCache $googleFontCache;
- #[SerializedName('font_fallback')]
- public null|Asset $fontFallback = null;
+ #[SerializedName('offline_fallback')]
+ public OfflineFallback $offlineFallback;
/**
- * @var array
+ * @var array
*/
- #[SerializedName('warm_cache_urls')]
- public array $warmCacheUrls = [];
-
- #[SerializedName('network_timeout_seconds')]
- public int $networkTimeoutSeconds = 3;
-
- #[SerializedName('max_font_age')]
- public int $maxFontAge = 60 * 60 * 24 * 365;
-
- #[SerializedName('max_font_cache_entries')]
- public int $maxFontCacheEntries = 60;
-
- #[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('font_regex')]
- public string $fontRegex = '/\.(ttf|eot|otf|woff2)$/';
+ #[SerializedName('background_sync')]
+ public array $backgroundSync = [];
#[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/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/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..8b66d7e
--- /dev/null
+++ b/src/Resources/config/definition/path_type_reference.php
@@ -0,0 +1,37 @@
+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.'
+ )
+ ->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..abbb1fa
--- /dev/null
+++ b/src/Resources/config/definition/service_worker.php
@@ -0,0 +1,514 @@
+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()
+ ->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()
+ ->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()
+ ->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()
+ ->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()
+ ->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()
+ ->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.')
+ ->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.'))
+ ->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)$/')
+ ->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)$/')
+ ->info('The regex to match the static files.')
+ ->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()
+ ->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()
+ ->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..a7190f9
--- /dev/null
+++ b/src/Resources/config/definition/utils/shortcuts.php
@@ -0,0 +1,39 @@
+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();
+
+ 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..9ebacc0
--- /dev/null
+++ b/src/Resources/config/definition/utils/url_node.php
@@ -0,0 +1,63 @@
+ $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()
+ ->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()
+ ->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/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 fbf8842..9a2c3d5 100644
--- a/src/Service/ServiceWorkerCompiler.php
+++ b/src/Service/ServiceWorkerCompiler.php
@@ -18,19 +18,34 @@
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;
+
+ private string $manifestPublicUrl;
+
public function __construct(
private SerializerInterface $serializer,
- #[Autowire('%spomky_labs_pwa.asset_public_prefix%')]
- private 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,
+ #[Autowire('%kernel.debug%')]
+ bool $debug,
) {
+ $this->manifestPublicUrl = '/' . trim($manifestPublicUrl, '/');
+ $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
@@ -52,6 +67,7 @@ public function compile(): ?string
if ($workbox->enabled === true) {
$body = $this->processWorkbox($workbox, $body);
}
+ $body = $this->processWidgets($body);
return $this->processSkipWaiting($body);
}
@@ -71,24 +87,26 @@ 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);
+ $body = $this->processCacheRootFilesRule($workbox, $body);
+ $body = $this->processCacheGoogleFontsRule($workbox, $body);
+ $body = $this->processBackgroundSyncRule($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 +119,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,62 +141,26 @@ 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)) {
+ if ($workbox->assetCache->enabled === false) {
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) {
+ if (preg_match($workbox->assetCache->regex, $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},
- }),
- ],
- })
-);
-
-// Assets served by Asset Mapper
-// - Strategy: CacheFirst
+ $declaration = <<assetCacheName}',
+ cacheName: '{$workbox->assetCache->cacheName}',
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({statuses: [0, 200]}),
new workbox.expiration.ExpirationPlugin({
@@ -187,7 +169,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,17 +184,34 @@ 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
+ {
+ if ($workbox->fontCache->enabled === false) {
+ return $body;
+ }
+ $fonts = [];
+ foreach ($this->assetMapper->allAssets() as $asset) {
+ 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},
}),
],
});
@@ -232,58 +230,140 @@ 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
+ {
+ if ($workbox->pageCache->enabled === false) {
+ return $body;
+ }
+ $routes = $this->serializer->serialize($workbox->pageCache->urls, 'json', $this->jsonOptions);
- return str_replace($workbox->standardRulesPlaceholder, trim($declaration), $body);
+ $declaration = <<pageCache->cacheName}',
+ networkTimeoutSeconds: {$workbox->pageCache->networkTimeout},
+ warmCache: {$routes}
+});
+PAGE_CACHE_RULE_STRATEGY;
+
+ return $body . PHP_EOL . PHP_EOL . trim($declaration);
}
- private function processOfflineFallback(Workbox $workbox, string $body): string
+ private function processImageCacheRule(Workbox $workbox, string $body): string
{
- if (! str_contains($body, $workbox->offlineFallbackPlaceholder)) {
+ if ($workbox->imageCache->enabled === false) {
return $body;
}
- if ($workbox->pageFallback === null && $workbox->imageFallback === null && $workbox->fontFallback === null) {
+ $declaration = << (request.destination === 'image' && !url.pathname.startsWith('{$this->assetPublicPrefix}')),
+ new workbox.strategies.CacheFirst({
+ cacheName: '{$workbox->imageCache->cacheName}',
+ plugins: [
+ new workbox.cacheableResponse.CacheableResponsePlugin({statuses: [0, 200]}),
+ new workbox.expiration.ExpirationPlugin({
+ maxEntries: {$workbox->imageCache->maxEntries},
+ maxAgeSeconds: {$workbox->imageCache->maxAge},
+ }),
+ ],
+ })
+);
+IMAGE_CACHE_RULE_STRATEGY;
+
+ return $body . PHP_EOL . PHP_EOL . trim($declaration);
+ }
+
+ private function processCacheRootFilesRule(Workbox $workbox, string $body): string
+ {
+ if ($workbox->cacheManifest === false) {
return $body;
}
- $jsonOptions = [
- JsonEncode::OPTIONS => JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR,
+ $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 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,
];
- $pageFallback = $workbox->pageFallback === null ? 'null' : $this->serializer->serialize(
- $workbox->pageFallback,
- 'json',
- $jsonOptions
- );
- $imageFallback = $workbox->imageFallback === null ? 'null' : $this->serializer->serialize(
- $workbox->imageFallback,
- 'json',
- $jsonOptions
- );
- $fontFallback = $workbox->fontFallback === null ? 'null' : $this->serializer->serialize(
- $workbox->fontFallback,
- 'json',
- $jsonOptions
- );
+ $options = array_filter($options, static fn (mixed $v): bool => ($v !== null && $v !== ''));
+ $options = count($options) === 0 ? '' : $this->serializer->serialize($options, 'json', $this->jsonOptions);
+
+ $declaration = <<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) {
+ return $body;
+ }
+ $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 = <<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 +437,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);
}
}
diff --git a/src/SpomkyLabsPwaBundle.php b/src/SpomkyLabsPwaBundle.php
index f1e545b..ee9040f 100644
--- a/src/SpomkyLabsPwaBundle.php
+++ b/src/SpomkyLabsPwaBundle.php
@@ -4,13 +4,93 @@
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']);
+ }
+ $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
+ {
+ $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
{
- 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;
}
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',
+ ],
],
],
]);
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"]
+}