diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e703aaf050..1a60b53e4d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,6 +7,7 @@ # Performance Lab Plugin: Individual Site Health checks /plugins/performance-lab/includes/site-health/avif-support @adamsilverstein +/plugins/performance-lab/includes/site-health/avif-headers @adamsilverstein /plugins/performance-lab/includes/site-health/webp-support @adamsilverstein /plugins/performance-lab/includes/site-health/audit-autoloaded-options @manuelRod @felixarntz @mukeshpanchal27 /plugins/performance-lab/includes/site-health/audit-enqueued-assets @manuelRod diff --git a/.github/workflows/js-lint.yml b/.github/workflows/js-lint.yml index 4df00e3ff4..365d32b9d6 100644 --- a/.github/workflows/js-lint.yml +++ b/.github/workflows/js-lint.yml @@ -47,4 +47,6 @@ jobs: - name: npm install run: npm ci - name: JS Lint - run: npm run lint-js \ No newline at end of file + run: npm run lint-js + - name: TypeScript compile + run: npm run tsc diff --git a/.github/workflows/php-test-plugins.yml b/.github/workflows/php-test-plugins.yml index 4e58a117a1..97e862abfd 100644 --- a/.github/workflows/php-test-plugins.yml +++ b/.github/workflows/php-test-plugins.yml @@ -41,13 +41,17 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.2', '8.1', '8.0', '7.4', '7.3', '7.2'] + php: ['8.1', '8.0', '7.4', '7.3', '7.2'] wp: [ 'latest' ] + coverage: [false] include: - php: '7.4' - wp: '6.5' + wp: '6.6' - php: '8.3' wp: 'trunk' + - php: '8.2' + wp: 'latest' + # coverage: true # TODO: Uncomment once coverage reports are fixed. See . env: WP_ENV_PHP_VERSION: ${{ matrix.php }} WP_ENV_CORE: ${{ matrix.wp == 'trunk' && 'WordPress/WordPress' || format( 'https://wordpress.org/wordpress-{0}.zip', matrix.wp ) }} @@ -68,6 +72,34 @@ jobs: - name: Composer Install run: npm run wp-env run tests-cli -- --env-cwd="wp-content/plugins/$(basename $(pwd))" composer install --no-interaction --no-progress - name: Running single site unit tests - run: npm run test-php + run: | + if [ "${{ matrix.coverage }}" == "true" ]; then + npm run test-php -- --coverage-clover=coverage-${{ github.sha }}.xml + else + npm run test-php + fi - name: Running multisite unit tests - run: npm run test-php-multisite + run: | + if [ "${{ matrix.coverage }}" == "true" ]; then + npm run test-php-multisite -- --coverage-clover=coverage-multisite-${{ github.sha }}.xml + else + npm run test-php-multisite + fi + - name: Upload single site coverage reports to Codecov + if: ${{ matrix.coverage == true }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage-${{ github.sha }}.xml + flags: single + name: ${{ matrix.php }}-single-site-coverage + fail_ci_if_error: true + - name: Upload multisite coverage reports to Codecov + if: ${{ matrix.coverage == true }} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage-multisite-${{ github.sha }}.xml + flags: multisite + name: ${{ matrix.php }}-multisite-coverage + fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index 39362b3482..9c1d86dc0f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ nbproject/ build .wp-env.override.json +*.min.js +*.min.css *.asset.php ############ diff --git a/.wp-env.json b/.wp-env.json index 2c652e636f..3887c95a09 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -12,6 +12,11 @@ "./plugins/webp-uploads" ], "env": { + "development": { + "config": { + "WP_DEVELOPMENT_MODE": "plugin" + } + }, "tests": { "config": { "FS_METHOD": "direct" diff --git a/README.md b/README.md index b99f70ce77..d46feffaf3 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,14 @@ The feature plugins which are currently featured by this plugin are: Plugin | Slug | Experimental | Links --------------------------------|---------------------------|--------------|------------- -[Image Placeholders][1] | `dominant-color-images` | No | [Source][8], [Issues][15], [PRs][22] -[Modern Image Formats][2] | `webp-uploads` | No | [Source][9], [Issues][16], [PRs][23] -[Performant Translations][3] | `performant-translations` | No | [Source][10], [Issues][17], [PRs][24] -[Speculative Loading][4] | `speculation-rules` | No | [Source][11], [Issues][18], [PRs][25] -[Embed Optimizer][5] | `embed-optimizer` | Yes | [Source][12], [Issues][19], [PRs][26] -[Enhanced Responsive Images][6] | `auto-sizes` | Yes | [Source][13], [Issues][20], [PRs][27] -[Image Prioritizer][7] | `image-prioritizer` | Yes | [Source][14], [Issues][21], [PRs][28] +[Image Placeholders][1] | `dominant-color-images` | No | [Source][9], [Issues][17], [PRs][25] +[Modern Image Formats][2] | `webp-uploads` | No | [Source][10], [Issues][18], [PRs][26] +[Performant Translations][3] | `performant-translations` | No | [Source][11], [Issues][19], [PRs][27] +[Speculative Loading][4] | `speculation-rules` | No | [Source][12], [Issues][20], [PRs][28] +[Embed Optimizer][5] | `embed-optimizer` | Yes | [Source][13], [Issues][21], [PRs][29] +[Enhanced Responsive Images][6] | `auto-sizes` | Yes | [Source][14], [Issues][22], [PRs][30] +[Image Prioritizer][7] | `image-prioritizer` | Yes | [Source][15], [Issues][23], [PRs][31] +[Web Worker Offloading][8] | `web-worker-offloading` | Yes | [Source][16], [Issues][24], [PRs][32] [1]: https://wordpress.org/plugins/dominant-color-images/ [2]: https://wordpress.org/plugins/webp-uploads/ @@ -26,29 +27,33 @@ Plugin | Slug | Experimental | Lin [5]: https://wordpress.org/plugins/embed-optimizer/ [6]: https://wordpress.org/plugins/auto-sizes/ [7]: https://wordpress.org/plugins/image-prioritizer/ - -[8]: https://github.com/WordPress/performance/tree/trunk/plugins/dominant-color-images -[9]: https://github.com/WordPress/performance/tree/trunk/plugins/webp-uploads -[10]: https://github.com/swissspidy/performant-translations -[11]: https://github.com/WordPress/performance/tree/trunk/plugins/speculation-rules -[12]: https://github.com/WordPress/performance/tree/trunk/plugins/embed-optimizer -[13]: https://github.com/WordPress/performance/tree/trunk/plugins/auto-sizes -[14]: https://github.com/WordPress/performance/tree/trunk/plugins/image-prioritizer - -[15]: https://github.com/WordPress/performance/issues?q=is%3Aopen+label%3A%22%5BPlugin%5D+Image+Placeholders%22 -[16]: https://github.com/WordPress/performance/issues?q=is%3Aopen+label%3A%22%5BPlugin%5D+Modern+Image+Formats%22 -[17]: https://github.com/swissspidy/performant-translations/issues -[18]: https://github.com/WordPress/performance/issues?q=is%3Aopen+label%3A%22%5BPlugin%5D+Speculative+Loading%22 -[19]: https://github.com/WordPress/performance/issues?q=is%3Aopen+label%3A%22%5BPlugin%5D+Embed+Optimizer%22 -[20]: https://github.com/WordPress/performance/issues?q=is%3Aopen+label%3A%22%5BPlugin%5D+Enhanced+Responsive+Images%22 -[21]: https://github.com/WordPress/performance/issues?q=is%3Aopen+label%3A%22%5BPlugin%5D+Image+Prioritizer%22 - -[22]: https://github.com/WordPress/performance/pulls?q=is%3Aopen+label%3A%22%5BPlugin%5D+Image+Placeholders%22 -[23]: https://github.com/WordPress/performance/pulls?q=is%3Aopen+label%3A%22%5BPlugin%5D+Modern+Image+Formats%22 -[24]: https://github.com/swissspidy/performant-translations/pulls -[25]: https://github.com/WordPress/performance/pulls?q=is%3Aopen+label%3A%22%5BPlugin%5D+Speculative+Loading%22 -[26]: https://github.com/WordPress/performance/pulls?q=is%3Aopen+label%3A%22%5BPlugin%5D+Embed+Optimizer%22 -[27]: https://github.com/WordPress/performance/pulls?q=is%3Aopen+label%3A%22%5BPlugin%5D+Enhanced+Responsive+Images%22 -[28]: https://github.com/WordPress/performance/pulls?q=is%3Aopen+label%3A%22%5BPlugin%5D+Image+Prioritizer%22 +[8]: https://wordpress.org/plugins/web-worker-offloading/ + +[9]: https://github.com/WordPress/performance/tree/trunk/plugins/dominant-color-images +[10]: https://github.com/WordPress/performance/tree/trunk/plugins/webp-uploads +[11]: https://github.com/swissspidy/performant-translations +[12]: https://github.com/WordPress/performance/tree/trunk/plugins/speculation-rules +[13]: https://github.com/WordPress/performance/tree/trunk/plugins/embed-optimizer +[14]: https://github.com/WordPress/performance/tree/trunk/plugins/auto-sizes +[15]: https://github.com/WordPress/performance/tree/trunk/plugins/image-prioritizer +[16]: https://github.com/WordPress/performance/tree/trunk/plugins/web-worker-offloading + +[17]: https://github.com/WordPress/performance/issues?q=is%3Aopen+label%3A%22%5BPlugin%5D+Image+Placeholders%22 +[18]: https://github.com/WordPress/performance/issues?q=is%3Aopen+label%3A%22%5BPlugin%5D+Modern+Image+Formats%22 +[19]: https://github.com/swissspidy/performant-translations/issues +[20]: https://github.com/WordPress/performance/issues?q=is%3Aopen+label%3A%22%5BPlugin%5D+Speculative+Loading%22 +[21]: https://github.com/WordPress/performance/issues?q=is%3Aopen+label%3A%22%5BPlugin%5D+Embed+Optimizer%22 +[22]: https://github.com/WordPress/performance/issues?q=is%3Aopen+label%3A%22%5BPlugin%5D+Enhanced+Responsive+Images%22 +[23]: https://github.com/WordPress/performance/issues?q=is%3Aopen+label%3A%22%5BPlugin%5D+Image+Prioritizer%22 +[24]: https://github.com/WordPress/performance/issues?q=is%3Aopen%20label%3A%22%5BPlugin%5D%20Web%20Worker%20Offloading%22 + +[25]: https://github.com/WordPress/performance/pulls?q=is%3Apr+is%3Aopen+label%3A%22%5BPlugin%5D+Image+Placeholders%22 +[26]: https://github.com/WordPress/performance/pulls?q=is%3Apr+is%3Aopen+label%3A%22%5BPlugin%5D+Modern+Image+Formats%22 +[27]: https://github.com/swissspidy/performant-translations/pulls +[28]: https://github.com/WordPress/performance/pulls?q=is%3Apr+is%3Aopen+label%3A%22%5BPlugin%5D+Speculative+Loading%22 +[29]: https://github.com/WordPress/performance/pulls?q=is%3Apr+is%3Aopen+label%3A%22%5BPlugin%5D+Embed+Optimizer%22 +[30]: https://github.com/WordPress/performance/pulls?q=is%3Apr+is%3Aopen+label%3A%22%5BPlugin%5D+Enhanced+Responsive+Images%22 +[31]: https://github.com/WordPress/performance/pulls?q=is%3Apr+is%3Aopen+label%3A%22%5BPlugin%5D+Image+Prioritizer%22 +[32]: https://github.com/WordPress/performance/pulls?q=is%3Apr+is%3Aopen+label%3A%22%5BPlugin%5D+Web%20Worker%20Offloading%22 Note that the plugin names sometimes diverge from the plugin slugs due to scope changes. For example, a plugin's purpose may change as some of its features are merged into WordPress core. diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000000..94ce322661 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,15 @@ +codecov: + notify: + require_ci_to_pass: yes +coverage: + status: + project: + default: + target: auto + threshold: 80% + base: auto + informational: true + patch: + default: + threshold: 80% + informational: true diff --git a/composer.json b/composer.json index 9d9ea27a1d..1503fcf405 100644 --- a/composer.json +++ b/composer.json @@ -89,7 +89,7 @@ "lint:web-worker-offloading": "@lint -- ./plugins/web-worker-offloading --standard=./plugins/web-worker-offloading/phpcs.xml.dist", "lint:webp-uploads": "@lint -- ./plugins/webp-uploads --standard=./plugins/webp-uploads/phpcs.xml.dist", "phpstan": "phpstan analyse --memory-limit=2048M", - "test": "phpunit", + "test": "phpunit --strict-coverage", "test-multisite": [ "WP_MULTISITE=1 phpunit --exclude-group=ms-excluded" ], diff --git a/composer.lock b/composer.lock index 1aac8e9f81..c420d663cd 100644 --- a/composer.lock +++ b/composer.lock @@ -335,27 +335,28 @@ }, { "name": "php-stubs/wordpress-stubs", - "version": "v6.5.3", + "version": "v6.7.1", "source": { "type": "git", "url": "https://github.com/php-stubs/wordpress-stubs.git", - "reference": "e611a83292d02055a25f83291a98fadd0c21e092" + "reference": "83448e918bf06d1ed3d67ceb6a985fc266a02fd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/e611a83292d02055a25f83291a98fadd0c21e092", - "reference": "e611a83292d02055a25f83291a98fadd0c21e092", + "url": "https://api.github.com/repos/php-stubs/wordpress-stubs/zipball/83448e918bf06d1ed3d67ceb6a985fc266a02fd1", + "reference": "83448e918bf06d1ed3d67ceb6a985fc266a02fd1", "shasum": "" }, "require-dev": { "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "nikic/php-parser": "^4.13", - "php": "^7.4 || ~8.0.0", + "php": "^7.4 || ^8.0", "php-stubs/generator": "^0.8.3", - "phpdocumentor/reflection-docblock": "5.3", - "phpstan/phpstan": "^1.10.49", + "phpdocumentor/reflection-docblock": "^5.4.1", + "phpstan/phpstan": "^1.11", "phpunit/phpunit": "^9.5", - "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^0.11" + "szepeviktor/phpcs-psr-12-neutron-hybrid-ruleset": "^1.1.1", + "wp-coding-standards/wpcs": "3.1.0 as 2.3.0" }, "suggest": { "paragonie/sodium_compat": "Pure PHP implementation of libsodium", @@ -376,9 +377,9 @@ ], "support": { "issues": "https://github.com/php-stubs/wordpress-stubs/issues", - "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.5.3" + "source": "https://github.com/php-stubs/wordpress-stubs/tree/v6.7.1" }, - "time": "2024-05-08T02:12:31+00:00" + "time": "2024-11-24T03:57:09+00:00" }, { "name": "phpcompatibility/php-compatibility", @@ -658,16 +659,16 @@ }, { "name": "phpstan/php-8-stubs", - "version": "0.4.0", + "version": "0.4.9", "source": { "type": "git", "url": "https://github.com/phpstan/php-8-stubs.git", - "reference": "693817d86d0d0de1d39b97a70bff4fa728384aa1" + "reference": "1857c330fea6e795af1f7435ed02a18652e7dd8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/php-8-stubs/zipball/693817d86d0d0de1d39b97a70bff4fa728384aa1", - "reference": "693817d86d0d0de1d39b97a70bff4fa728384aa1", + "url": "https://api.github.com/repos/phpstan/php-8-stubs/zipball/1857c330fea6e795af1f7435ed02a18652e7dd8c", + "reference": "1857c330fea6e795af1f7435ed02a18652e7dd8c", "shasum": "" }, "type": "library", @@ -684,9 +685,9 @@ "description": "PHP stubs extracted from php-src", "support": { "issues": "https://github.com/phpstan/php-8-stubs/issues", - "source": "https://github.com/phpstan/php-8-stubs/tree/0.4.0" + "source": "https://github.com/phpstan/php-8-stubs/tree/0.4.9" }, - "time": "2024-09-30T19:56:21+00:00" + "time": "2024-12-02T00:21:59+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -737,16 +738,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.6", + "version": "1.12.7", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "dc4d2f145a88ea7141ae698effd64d9df46527ae" + "reference": "dc2b9976bd8b0f84ec9b0e50cc35378551de7af0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc4d2f145a88ea7141ae698effd64d9df46527ae", - "reference": "dc4d2f145a88ea7141ae698effd64d9df46527ae", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc2b9976bd8b0f84ec9b0e50cc35378551de7af0", + "reference": "dc2b9976bd8b0f84ec9b0e50cc35378551de7af0", "shasum": "" }, "require": { @@ -791,7 +792,7 @@ "type": "github" } ], - "time": "2024-10-06T15:03:59+00:00" + "time": "2024-10-18T11:12:07+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", @@ -2131,16 +2132,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.10.3", + "version": "3.11.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "62d32998e820bddc40f99f8251958aed187a5c9c" + "reference": "1368f4a58c3c52114b86b1abe8f4098869cb0079" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/62d32998e820bddc40f99f8251958aed187a5c9c", - "reference": "62d32998e820bddc40f99f8251958aed187a5c9c", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/1368f4a58c3c52114b86b1abe8f4098869cb0079", + "reference": "1368f4a58c3c52114b86b1abe8f4098869cb0079", "shasum": "" }, "require": { @@ -2207,7 +2208,7 @@ "type": "open_collective" } ], - "time": "2024-09-18T10:38:58+00:00" + "time": "2024-12-11T16:04:26+00:00" }, { "name": "symfony/polyfill-php73", @@ -2466,16 +2467,16 @@ }, { "name": "wp-phpunit/wp-phpunit", - "version": "6.6.2", + "version": "6.7.1", "source": { "type": "git", "url": "https://github.com/wp-phpunit/wp-phpunit.git", - "reference": "7a1d3a2150033a3d3e19de40aa5b2ef2fee36bc3" + "reference": "e63eb1c0980839853c569d3f04ff70263b7795e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/7a1d3a2150033a3d3e19de40aa5b2ef2fee36bc3", - "reference": "7a1d3a2150033a3d3e19de40aa5b2ef2fee36bc3", + "url": "https://api.github.com/repos/wp-phpunit/wp-phpunit/zipball/e63eb1c0980839853c569d3f04ff70263b7795e3", + "reference": "e63eb1c0980839853c569d3f04ff70263b7795e3", "shasum": "" }, "type": "library", @@ -2510,7 +2511,7 @@ "issues": "https://github.com/wp-phpunit/issues", "source": "https://github.com/wp-phpunit/wp-phpunit" }, - "time": "2024-07-17T01:13:44+00:00" + "time": "2024-11-22T01:27:46+00:00" }, { "name": "yoast/phpunit-polyfills", diff --git a/lint-staged.config.js b/lint-staged.config.js index 36c59ab33b..362534e74a 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -25,7 +25,9 @@ const joinFiles = ( files ) => { const PLUGIN_BASE_NAME = path.basename( __dirname ); module.exports = { - '**/*.js': ( files ) => `npm run lint-js -- ${ joinFiles( files ) }`, + '**/*.{js,ts}': ( files ) => { + return [ `npm run lint-js -- ${ joinFiles( files ) }`, `npm run tsc` ]; + }, '**/*.php': ( files ) => { const commands = [ 'composer phpstan' ]; diff --git a/package-lock.json b/package-lock.json index 4b20d4cc0b..19be816bc8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,23 +8,25 @@ "license": "GPL-2.0-or-later", "dependencies": { "@builder.io/partytown": "github:westonruter/partytown#add/wp-i18n-workaround", - "web-vitals": "4.2.3" + "web-vitals": "4.2.4" }, "devDependencies": { "@octokit/rest": "^21.0.2", - "@wordpress/env": "^10.9.0", - "@wordpress/prettier-config": "^4.9.0", - "@wordpress/scripts": "^30.1.0", + "@wordpress/env": "^10.14.0", + "@wordpress/prettier-config": "^4.14.0", + "@wordpress/scripts": "^30.7.0", "commander": "12.1.0", "copy-webpack-plugin": "^12.0.2", + "css-minimizer-webpack-plugin": "^7.0.0", "fast-glob": "^3.3.2", "fs-extra": "^11.2.0", - "husky": "^9.1.6", - "lint-staged": "^15.2.10", + "husky": "^9.1.7", + "lint-staged": "^15.2.11", "lodash": "4.17.21", "micromatch": "^4.0.8", "npm-run-all": "^4.1.5", - "webpackbar": "^6.0.1" + "typescript": "^5.7.2", + "webpackbar": "^7.0.0" }, "engines": { "node": ">=20.10.0", @@ -58,30 +60,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.24.9", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.9.tgz", - "integrity": "sha512-e701mcfApCJqMMueQI0Fb68Amflj83+dvAvHawoBpAz+GDjCIyGHzNwnefjsWJ3xiYAqqiQFoWbspGYBdb2/ng==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.8.tgz", + "integrity": "sha512-ZsysZyXY4Tlx+Q53XdnOFmqwfB9QDTHYxaZYajWRoBLuLEAwI2UIbtxOjWh/cFaa9IKUlcB+DDuoskLuKu56JA==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.24.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", - "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.7.tgz", + "integrity": "sha512-yJ474Zv3cwiSOO9nXJuqzvwEeM+chDuQ8GJirw+pZ91sCGCyOZ3dJkVE09fTV0VEVzXyLWhh3G/AolYTPX7Mow==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.9", - "@babel/helper-compilation-targets": "^7.24.8", - "@babel/helper-module-transforms": "^7.24.9", - "@babel/helpers": "^7.24.8", - "@babel/parser": "^7.24.8", - "@babel/template": "^7.24.7", - "@babel/traverse": "^7.24.8", - "@babel/types": "^7.24.9", + "@babel/code-frame": "^7.25.7", + "@babel/generator": "^7.25.7", + "@babel/helper-compilation-targets": "^7.25.7", + "@babel/helper-module-transforms": "^7.25.7", + "@babel/helpers": "^7.25.7", + "@babel/parser": "^7.25.7", + "@babel/template": "^7.25.7", + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -130,38 +132,39 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", - "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.7.tgz", + "integrity": "sha512-4xwU8StnqnlIhhioZf1tqnVWeQ9pvH/ujS8hRfw/WOza+/a+1qv69BWNy+oY231maTCWgKWhfBU7kDpsds6zAA==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.25.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz", - "integrity": "sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.25.7.tgz", + "integrity": "sha512-12xfNeKNH7jubQNm7PAkzlLwEmCs1tfuX3UjIw6vP6QXi+leKh6+LyC/+Ed4EIQermwd58wsyh070yjDHFlNGg==", "dev": true, "dependencies": { - "@babel/types": "^7.22.15" + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.8.tgz", - "integrity": "sha512-oU+UoqCHdp+nWVDkpldqIQL/i/bvAv53tRqLG/s+cOXxe66zOYLU7ar/Xs3LdmBihrUMEUhwu6dMZwbNOYDwvw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.7.tgz", + "integrity": "sha512-DniTEax0sv6isaw6qSQSfV4gVRNtw2rte8HHM45t9ZR0xILaufBRNkpMifCRiAPyvL4ACD6v0gfCwCmtOQaV4A==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.24.8", - "@babel/helper-validator-option": "^7.24.8", - "browserslist": "^4.23.1", + "@babel/compat-data": "^7.25.7", + "@babel/helper-validator-option": "^7.25.7", + "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -170,19 +173,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.23.7.tgz", - "integrity": "sha512-xCoqR/8+BoNnXOY7RVSgv6X+o7pmT5q1d+gGcRlXYkI+9B31glE4jeejhKVpA04O1AtzOt7OSQ6VYKP5FcRl9g==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.7.tgz", + "integrity": "sha512-bD4WQhbkx80mAyj/WCm4ZHcF4rDxkoLFO6ph8/5/mQ3z4vAzltQXAmbc7GvVJx5H+lk5Mi5EmbTeox5nMGCsbw==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-member-expression-to-functions": "^7.23.0", - "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-annotate-as-pure": "^7.25.7", + "@babel/helper-member-expression-to-functions": "^7.25.7", + "@babel/helper-optimise-call-expression": "^7.25.7", + "@babel/helper-replace-supers": "^7.25.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7", + "@babel/traverse": "^7.25.7", "semver": "^6.3.1" }, "engines": { @@ -193,13 +194,13 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz", - "integrity": "sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.7.tgz", + "integrity": "sha512-byHhumTj/X47wJ6C6eLpK7wW/WBEcnUeb7D0FNc/jFQnQVw7DOso3Zz5u9x/zLrFVkHa89ZGDbkAa1D54NdrCQ==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "regexpu-core": "^5.3.1", + "@babel/helper-annotate-as-pure": "^7.25.7", + "regexpu-core": "^6.1.1", "semver": "^6.3.1" }, "engines": { @@ -210,9 +211,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.4.tgz", - "integrity": "sha512-QcJMILQCu2jm5TFPGA3lCpJJTeEP+mqeXooG/NZbg/h5FTFi6V0+99ahlRsW8/kRLyb24LZVCCiclDedhLKcBA==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz", + "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==", "dev": true, "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", @@ -225,50 +226,14 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz", - "integrity": "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==", - "dev": true, - "dependencies": { - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz", - "integrity": "sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==", - "dev": true, - "dependencies": { - "@babel/template": "^7.24.7", - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz", - "integrity": "sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==", - "dev": true, - "dependencies": { - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz", - "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.7.tgz", + "integrity": "sha512-O31Ssjd5K6lPbTX9AAYpSKrZmLeagt9uwschJd+Ixo6QiRyfpvgtVQp8qrDR9UNFjZ8+DO34ZkdrN+BnPXemeA==", "dev": true, "dependencies": { - "@babel/types": "^7.23.0" + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -288,16 +253,15 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.24.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.9.tgz", - "integrity": "sha512-oYbh+rtFKj/HwBQkFlUzvcybzklmVdVV3UU+mN7n2t/q3yGHbuVdNxyFvSBO1tfvjyArpHNcWMAzsSPdyI46hw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.7.tgz", + "integrity": "sha512-k/6f8dKG3yDz/qCwSM+RKovjMix563SLxQFo0UhRNo239SP6n9u5/eLtKD6EAjwta2JHJ49CsD8pms2HdNiMMQ==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-module-imports": "^7.24.7", - "@babel/helper-simple-access": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7" + "@babel/helper-module-imports": "^7.25.7", + "@babel/helper-simple-access": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.7", + "@babel/traverse": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -307,12 +271,12 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", - "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.7.tgz", + "integrity": "sha512-VAwcwuYhv/AT+Vfr28c9y6SHzTan1ryqrydSTFGjU0uDJHw3uZ+PduI8plCLkRsDnqK2DMEDmwrOQRsK/Ykjng==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -328,14 +292,14 @@ } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz", - "integrity": "sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.7.tgz", + "integrity": "sha512-kRGE89hLnPfcz6fTrlNU+uhgcwv0mBE4Gv3P9Ke9kLVJYpi4AMVVEElXvB5CabrPZW4nCM8P8UyyjrzCM0O2sw==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-wrap-function": "^7.22.20" + "@babel/helper-annotate-as-pure": "^7.25.7", + "@babel/helper-wrap-function": "^7.25.7", + "@babel/traverse": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -345,14 +309,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz", - "integrity": "sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.7.tgz", + "integrity": "sha512-iy8JhqlUW9PtZkd4pHM96v6BdJ66Ba9yWSE4z0W4TvSZwLBPkyDsiIU3ENe4SmrzRBs76F7rQXTy1lYC49n6Lw==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-member-expression-to-functions": "^7.22.15", - "@babel/helper-optimise-call-expression": "^7.22.5" + "@babel/helper-member-expression-to-functions": "^7.25.7", + "@babel/helper-optimise-call-expression": "^7.25.7", + "@babel/traverse": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -362,37 +326,26 @@ } }, "node_modules/@babel/helper-simple-access": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", - "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.7.tgz", + "integrity": "sha512-FPGAkJmyoChQeM+ruBGIDyrT2tKfZJO8NcxdC+CWNJi7N8/rZpSxK7yvBJ5O/nF1gfu5KzN7VKG3YVSLFfRSxQ==", "dev": true, "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", - "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", - "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.7.tgz", + "integrity": "sha512-pPbNbchZBkPMD50K0p3JGcFMNLVUCuU/ABybm/PGNj4JiHrpmNyqqCphBk4i19xXtNV0JhldQJJtbSW5aUvbyA==", "dev": true, "dependencies": { - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -417,36 +370,36 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", - "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.7.tgz", + "integrity": "sha512-ytbPLsm+GjArDYXJ8Ydr1c/KJuutjF2besPNbIZnZ6MKUxi/uTA22t2ymmA4WFjZFpjiAMO0xuuJPqK2nvDVfQ==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz", - "integrity": "sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.7.tgz", + "integrity": "sha512-MA0roW3JF2bD1ptAaJnvcabsVlNQShUaThyJbCDD4bCp8NEgiFvpoqRI2YS22hHlc2thjO/fTg2ShLMC3jygAg==", "dev": true, "dependencies": { - "@babel/helper-function-name": "^7.22.5", - "@babel/template": "^7.22.15", - "@babel/types": "^7.22.19" + "@babel/template": "^7.25.7", + "@babel/traverse": "^7.25.7", + "@babel/types": "^7.25.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.8.tgz", - "integrity": "sha512-gV2265Nkcz7weJJfvDoAEVzC1e2OTDpkGbEsebse8koXUJUXPsCMi7sRo/+SPMuMZ9MtUPnGwITTnQnU5YjyaQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.7.tgz", + "integrity": "sha512-Sv6pASx7Esm38KQpF/U/OXLwPPrdGHNKoeblRxgZRLXnAtnkEe4ptJPDtAZM7fBLadbc1Q07kQpSiGQ0Jg6tRA==", "dev": true, "dependencies": { - "@babel/template": "^7.24.7", - "@babel/types": "^7.24.8" + "@babel/template": "^7.25.7", + "@babel/types": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -553,13 +506,44 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.7.tgz", + "integrity": "sha512-UV9Lg53zyebzD1DwQoT9mzkEKa922LNUp5YkTJ6Uta0RbyXaQNUgcvSt7qIu1PpPzVb6rd10OVNTzkyBGeVmxQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/traverse": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.7.tgz", + "integrity": "sha512-GDDWeVLNxRIkQTnJn2pDOM1pkCgYdSqPeT1a9vh9yIqu2uzzgw1zcqEb+IJOhy+dTBMlNdThrDIksr2o09qrrQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.23.3.tgz", - "integrity": "sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.7.tgz", + "integrity": "sha512-wxyWg2RYaSUYgmd9MR0FyRGyeOMQE/Uzr1wzd/g5cf5bwi9A4v6HFdDm7y1MgDtod/fLOSTZY6jDgV0xU9d5bA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -569,14 +553,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.23.3.tgz", - "integrity": "sha512-WwlxbfMNdVEpQjZmK5mhm7oSwD3dS6eU+Iwsi4Knl9wAletWem7kaRsGOG+8UEbRyqxY4SS5zvtfXwX+jMxUwQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.7.tgz", + "integrity": "sha512-Xwg6tZpLxc4iQjorYsyGMyfJE7nP5MV8t/Ka58BgiA7Jw0fRqQNcANlLfdJ/yvBt9z9LD2We+BEkT7vLqZRWng==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-transform-optional-chaining": "^7.23.3" + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7", + "@babel/plugin-transform-optional-chaining": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -586,13 +570,13 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.23.7.tgz", - "integrity": "sha512-LlRT7HgaifEpQA1ZgLVOIJZZFVPWN5iReq/7/JixwBtwcoeVGDBD53ZV28rrsLYOZs1Y/EHhA8N/Z6aazHR8cw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.7.tgz", + "integrity": "sha512-UVATLMidXrnH+GMUIuxq55nejlj02HP7F5ETyBONzP6G87fPBogG4CH6kxrSrdIuAjdwNO9VzyaYsrZPscWUrw==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/traverse": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -689,12 +673,12 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.23.3.tgz", - "integrity": "sha512-lPgDSU+SJLK3xmFDTV2ZRQAiM7UuUjGidwBywFavObCiZc1BeAAcMtHJKUya92hPHO+at63JJPLygilZard8jw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.25.7.tgz", + "integrity": "sha512-ZvZQRmME0zfJnDQnVBKYzHxXT7lYBB3Revz1GuS7oLXWMgqUPX4G+DDbT30ICClht9WKV34QVrZhSw6WdklwZQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -704,12 +688,12 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.7.tgz", - "integrity": "sha512-hbX+lKKeUMGihnK8nvKqmXBInriT3GVjzXKFriV3YC6APGxMbP8RZNFwy91+hocLXq90Mta+HshoB31802bb8A==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.25.7.tgz", + "integrity": "sha512-AqVo+dguCgmpi/3mYBdu9lkngOBlQ2w2vnNpa6gfiCxQZLzV4ZbhsXitJ2Yblkoe1VQwtHSaNmIaGll/26YWRw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -743,12 +727,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz", - "integrity": "sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.7.tgz", + "integrity": "sha512-ruZOnKO+ajVL/MVx+PwNBPOkrnXTXoWMtte1MBpegfCArhqOe3Bj52avVj1huLLxNKYKXYaSxZ2F+woK1ekXfw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -860,12 +844,12 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.23.3.tgz", - "integrity": "sha512-9EiNjVJOMwCO+43TqoTrgQ8jMwcAd0sWyXi9RPfIsLTj4R2MADDDQXELhffaUx/uJv2AYcxBgPwH6j4TIA4ytQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.7.tgz", + "integrity": "sha512-rR+5FDjpCHqqZN2bzZm18bVYGaejGq5ZkpVCJLXor/+zlSrSoc4KWcHI0URVWjl/68Dyr1uwZUz/1njycEAv9g==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -891,12 +875,12 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.23.3.tgz", - "integrity": "sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.7.tgz", + "integrity": "sha512-EJN2mKxDwfOUCPxMO6MUI58RN3ganiRAG/MS/S3HfB6QFNjroAMelQo/gybyYq97WerCBAZoyrAoW8Tzdq2jWg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -906,15 +890,14 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.7.tgz", - "integrity": "sha512-PdxEpL71bJp1byMG0va5gwQcXHxuEYC/BgI/e88mGTtohbZN28O5Yit0Plkkm/dBzCF/BxmbNcses1RH1T+urA==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.8.tgz", + "integrity": "sha512-9ypqkozyzpG+HxlH4o4gdctalFGIjjdufzo7I2XPda0iBnZ6a+FO0rIEQcdSPXp02CkvGsII1exJhmROPQd5oA==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.20", - "@babel/plugin-syntax-async-generators": "^7.8.4" + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-remap-async-to-generator": "^7.25.7", + "@babel/traverse": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -924,14 +907,14 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.23.3.tgz", - "integrity": "sha512-A7LFsKi4U4fomjqXJlZg/u0ft/n8/7n7lpffUP/ZULx/DtV9SGlNKZolHH6PE8Xl1ngCc0M11OaeZptXVkfKSw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.7.tgz", + "integrity": "sha512-ZUCjAavsh5CESCmi/xCpX1qcCaAglzs/7tmuvoFnJgA1dM7gQplsguljoTg+Ru8WENpX89cQyAtWoaE0I3X3Pg==", "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.20" + "@babel/helper-module-imports": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-remap-async-to-generator": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -941,12 +924,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.23.3.tgz", - "integrity": "sha512-vI+0sIaPIO6CNuM9Kk5VmXcMVRiOpDh7w2zZt9GXzmE/9KD70CUEVhvPR/etAeNK/FAEkhxQtXOzVF3EuRL41A==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.7.tgz", + "integrity": "sha512-xHttvIM9fvqW+0a3tZlYcZYSBpSWzGBFIt/sYG3tcdSzBB8ZeVgz2gBP7Df+sM0N1850jrviYSSeUuc+135dmQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -956,12 +939,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.4.tgz", - "integrity": "sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.7.tgz", + "integrity": "sha512-ZEPJSkVZaeTFG/m2PARwLZQ+OG0vFIhPlKHK/JdIMy8DbRJ/htz6LRrTFtdzxi9EHmcwbNPAKDnadpNSIW+Aow==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -971,13 +954,13 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.23.3.tgz", - "integrity": "sha512-uM+AN8yCIjDPccsKGlw271xjJtGii+xQIF/uMPS8H15L12jZTsLfF4o5vNO7d/oUguOyfdikHGc/yi9ge4SGIg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.7.tgz", + "integrity": "sha512-mhyfEW4gufjIqYFo9krXHJ3ElbFLIze5IDp+wQTxoPd+mwFb1NxatNAwmv8Q8Iuxv7Zc+q8EkiMQwc9IhyGf4g==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-class-features-plugin": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -987,14 +970,13 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.4.tgz", - "integrity": "sha512-nsWu/1M+ggti1SOALj3hfx5FXzAY06fwPJsUZD4/A5e1bWi46VUIWtD+kOX6/IdhXGsXBWllLFDSnqSCdUNydQ==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.25.8.tgz", + "integrity": "sha512-e82gl3TCorath6YLf9xUwFehVvjvfqFhdOo4+0iVIVju+6XOi5XHkqB3P2AXnSwoeTX0HBoXq5gJFtvotJzFnQ==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-class-static-block": "^7.14.5" + "@babel/helper-create-class-features-plugin": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1004,18 +986,16 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.8.tgz", - "integrity": "sha512-yAYslGsY1bX6Knmg46RjiCiNSwJKv2IUC8qOdYKqMMr0491SXFhcHqOdRDeCRohOOIzwN/90C6mQ9qAKgrP7dg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.7.tgz", + "integrity": "sha512-9j9rnl+YCQY0IGoeipXvnk3niWicIB6kCsWRGLwX241qSXpbA4MKxtp/EdvFxsc4zI5vqfLxzOd0twIJ7I99zg==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20", - "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-annotate-as-pure": "^7.25.7", + "@babel/helper-compilation-targets": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-replace-supers": "^7.25.7", + "@babel/traverse": "^7.25.7", "globals": "^11.1.0" }, "engines": { @@ -1026,13 +1006,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.23.3.tgz", - "integrity": "sha512-dTj83UVTLw/+nbiHqQSFdwO9CbTtwq1DsDqm3CUEtDrZNET5rT5E6bIdTlOftDTDLMYxvxHNEYO4B9SLl8SLZw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.7.tgz", + "integrity": "sha512-QIv+imtM+EtNxg/XBKL3hiWjgdLjMOmZ+XzQwSgmBfKbfxUjBzGgVPklUuE55eq5/uVoh8gg3dqlrwR/jw3ZeA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/template": "^7.22.15" + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/template": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1042,12 +1022,12 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.3.tgz", - "integrity": "sha512-n225npDqjDIr967cMScVKHXJs7rout1q+tt50inyBCPkyZ8KxeI6d+GIbSBTT/w/9WdlWDOej3V9HE5Lgk57gw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.7.tgz", + "integrity": "sha512-xKcfLTlJYUczdaM1+epcdh1UGewJqr9zATgrNHcLBcV2QmfvPPEixo/sK/syql9cEmbr7ulu5HMFG5vbbt/sEA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1057,13 +1037,13 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.23.3.tgz", - "integrity": "sha512-vgnFYDHAKzFaTVp+mneDsIEbnJ2Np/9ng9iviHw3P/KVcgONxpNULEW/51Z/BaFojG2GI2GwwXck5uV1+1NOYQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.7.tgz", + "integrity": "sha512-kXzXMMRzAtJdDEgQBLF4oaiT6ZCU3oWHgpARnTKDAqPkDJ+bs3NrZb310YYevR5QlRo3Kn7dzzIdHbZm1VzJdQ==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1073,12 +1053,12 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.23.3.tgz", - "integrity": "sha512-RrqQ+BQmU3Oyav3J+7/myfvRCq7Tbz+kKLLshUmMwNlDHExbGL7ARhajvoBJEvc+fCguPPu887N+3RRXBVKZUA==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.7.tgz", + "integrity": "sha512-by+v2CjoL3aMnWDOyCIg+yxU9KXSRa9tN6MbqggH5xvymmr9p4AMjYkNlQy4brMceBnUyHZ9G8RnpvT8wP7Cfg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1087,14 +1067,29 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.7.tgz", + "integrity": "sha512-HvS6JF66xSS5rNKXLqkk7L9c/jZ/cdIVIcoPVrnl8IsVpLggTjXs8OWekbLHs/VtYDDh5WXnQyeE3PPUGm22MA==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.23.4.tgz", - "integrity": "sha512-V6jIbLhdJK86MaLh4Jpghi8ho5fGzt3imHOBu/x0jlBaPYqDoWz4RDXjmMOfnh+JWNaQleEAByZLV0QzBT4YQQ==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.8.tgz", + "integrity": "sha512-gznWY+mr4ZQL/EWPcbBQUP3BXS5FwZp8RUOw06BaRn8tQLzN4XLIxXejpHN9Qo8x8jjBmAAKp6FoS51AgkSA/A==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-dynamic-import": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1104,13 +1099,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.23.3.tgz", - "integrity": "sha512-5fhCsl1odX96u7ILKHBj4/Y8vipoqwsJMh4csSA8qFfxrZDEA4Ssku2DyNvMJSmZNOEBT750LfFPbtrnTP90BQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.25.7.tgz", + "integrity": "sha512-yjqtpstPfZ0h/y40fAXRv2snciYr0OAoMXY/0ClC7tm4C/nG5NJKmIItlaYlLbIVAWNfrYuy9dq1bE0SbX0PEg==", "dev": true, "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1120,13 +1115,12 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.23.4.tgz", - "integrity": "sha512-GzuSBcKkx62dGzZI1WVgTWvkkz84FZO5TC5T8dl/Tht/rAla6Dg/Mz9Yhypg+ezVACf/rgDuQt3kbWEv7LdUDQ==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.8.tgz", + "integrity": "sha512-sPtYrduWINTQTW7FtOy99VCTWp4H23UX7vYcut7S4CIMEXU+54zKX9uCoGkLsWXteyaMXzVHgzWbLfQ1w4GZgw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1136,13 +1130,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.23.6.tgz", - "integrity": "sha512-aYH4ytZ0qSuBbpfhuofbg/e96oQ7U2w1Aw/UQmKT+1l39uEhUPoFS3fHevDc1G0OvewyDudfMKY1OulczHzWIw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.7.tgz", + "integrity": "sha512-n/TaiBGJxYFWvpJDfsxSj9lEEE44BFM1EPGz4KEiTipTgkoFVVcCmzAL3qA7fdQU96dpo4gGf5HBx/KnDvqiHw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1152,14 +1146,14 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.23.3.tgz", - "integrity": "sha512-I1QXp1LxIvt8yLaib49dRW5Okt7Q4oaxao6tFVKS/anCdEOMtYwWVKoiOA1p34GOWIZjUK0E+zCp7+l1pfQyiw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.7.tgz", + "integrity": "sha512-5MCTNcjCMxQ63Tdu9rxyN6cAWurqfrDZ76qvVPrGYdBxIj+EawuuxTu/+dgJlhK5eRz3v1gLwp6XwS8XaX2NiQ==", "dev": true, "dependencies": { - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-compilation-targets": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/traverse": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1169,13 +1163,12 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.23.4.tgz", - "integrity": "sha512-81nTOqM1dMwZ/aRXQ59zVubN9wHGqk6UtqRK+/q+ciXmRy8fSolhGVvG09HHRGo4l6fr/c4ZhXUQH0uFW7PZbg==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.8.tgz", + "integrity": "sha512-4OMNv7eHTmJ2YXs3tvxAfa/I43di+VcF+M4Wt66c88EAED1RoGaf1D64cL5FkRpNL+Vx9Hds84lksWvd/wMIdA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-json-strings": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1185,12 +1178,12 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.23.3.tgz", - "integrity": "sha512-wZ0PIXRxnwZvl9AYpqNUxpZ5BiTGrYt7kueGQ+N5FiQ7RCOD4cm8iShd6S6ggfVIWaJf2EMk8eRzAh52RfP4rQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.7.tgz", + "integrity": "sha512-fwzkLrSu2fESR/cm4t6vqd7ebNIopz2QHGtjoU+dswQo/P6lwAG04Q98lliE3jkz/XqnbGFLnUcE0q0CVUf92w==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1200,13 +1193,12 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.23.4.tgz", - "integrity": "sha512-Mc/ALf1rmZTP4JKKEhUwiORU+vcfarFVLfcFiolKUo6sewoxSEgl36ak5t+4WamRsNr6nzjZXQjM35WsU+9vbg==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.8.tgz", + "integrity": "sha512-f5W0AhSbbI+yY6VakT04jmxdxz+WsID0neG7+kQZbCOjuyJNdL5Nn4WIBm4hRpKnUcO9lP0eipUhFN12JpoH8g==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1216,12 +1208,12 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.23.3.tgz", - "integrity": "sha512-sC3LdDBDi5x96LA+Ytekz2ZPk8i/Ck+DEuDbRAll5rknJ5XRTSaPKEYwomLcs1AA8wg9b3KjIQRsnApj+q51Ag==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.7.tgz", + "integrity": "sha512-Std3kXwpXfRV0QtQy5JJcRpkqP8/wG4XL7hSKZmGlxPlDqmpXtEPRmhF7ztnlTCtUN3eXRUJp+sBEZjaIBVYaw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1231,13 +1223,13 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.23.3.tgz", - "integrity": "sha512-vJYQGxeKM4t8hYCKVBlZX/gtIY2I7mRGFNcm85sgXGMTBcoV3QdVtdpbcWEbzbfUIUZKwvgFT82mRvaQIebZzw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.7.tgz", + "integrity": "sha512-CgselSGCGzjQvKzghCvDTxKHP3iooenLpJDO842ehn5D2G5fJB222ptnDwQho0WjEvg7zyoxb9P+wiYxiJX5yA==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-module-transforms": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1247,14 +1239,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.3.tgz", - "integrity": "sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.7.tgz", + "integrity": "sha512-L9Gcahi0kKFYXvweO6n0wc3ZG1ChpSFdgG+eV1WYZ3/dGbJK7vvk91FgGgak8YwRgrCuihF8tE/Xg07EkL5COg==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-simple-access": "^7.22.5" + "@babel/helper-module-transforms": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-simple-access": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1264,15 +1256,15 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.3.tgz", - "integrity": "sha512-ZxyKGTkF9xT9YJuKQRo19ewf3pXpopuYQd8cDXqNzc3mUNbOME0RKMoZxviQk74hwzfQsEe66dE92MaZbdHKNQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.7.tgz", + "integrity": "sha512-t9jZIvBmOXJsiuyOwhrIGs8dVcD6jDyg2icw1VL4A/g+FnWyJKwUfSSU2nwJuMV2Zqui856El9u+ElB+j9fV1g==", "dev": true, "dependencies": { - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20" + "@babel/helper-module-transforms": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-validator-identifier": "^7.25.7", + "@babel/traverse": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1282,13 +1274,13 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.23.3.tgz", - "integrity": "sha512-zHsy9iXX2nIsCBFPud3jKn1IRPWg3Ing1qOZgeKV39m1ZgIdpJqvlWVeiHBZC6ITRG0MfskhYe9cLgntfSFPIg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.7.tgz", + "integrity": "sha512-p88Jg6QqsaPh+EB7I9GJrIqi1Zt4ZBHUQtjw3z1bzEXcLh6GfPqzZJ6G+G1HBGKUNukT58MnKG7EN7zXQBCODw==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-module-transforms": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1298,13 +1290,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz", - "integrity": "sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.7.tgz", + "integrity": "sha512-BtAT9LzCISKG3Dsdw5uso4oV1+v2NlVXIIomKJgQybotJY3OwCwJmkongjHgwGKoZXd0qG5UZ12JUlDQ07W6Ow==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1314,12 +1306,12 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.23.3.tgz", - "integrity": "sha512-YJ3xKqtJMAT5/TIZnpAR3I+K+WaDowYbN3xyxI8zxx/Gsypwf9B9h0VB+1Nh6ACAAPRS5NSRje0uVv5i79HYGQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.7.tgz", + "integrity": "sha512-CfCS2jDsbcZaVYxRFo2qtavW8SpdzmBXC2LOI4oO0rP+JSRDxxF3inF4GcPsLgfb5FjkhXG5/yR/lxuRs2pySA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1329,13 +1321,12 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.23.4.tgz", - "integrity": "sha512-jHE9EVVqHKAQx+VePv5LLGHjmHSJR76vawFPTdlxR/LVJPfOEGxREQwQfjuZEOPTwG92X3LINSh3M40Rv4zpVA==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.25.8.tgz", + "integrity": "sha512-Z7WJJWdQc8yCWgAmjI3hyC+5PXIubH9yRKzkl9ZEG647O9szl9zvmKLzpbItlijBnVhTUf1cpyWBsZ3+2wjWPQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1345,13 +1336,12 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.23.4.tgz", - "integrity": "sha512-mps6auzgwjRrwKEZA05cOwuDc9FAzoyFS4ZsG/8F43bTLf/TgkJg7QXOrPO1JO599iA3qgK9MXdMGOEC8O1h6Q==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.8.tgz", + "integrity": "sha512-rm9a5iEFPS4iMIy+/A/PiS0QN0UyjPIeVvbU5EMZFKJZHt8vQnasbpo3T3EFcxzCeYO0BHfc4RqooCZc51J86Q==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-numeric-separator": "^7.10.4" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1361,16 +1351,14 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.23.4.tgz", - "integrity": "sha512-9x9K1YyeQVw0iOXJlIzwm8ltobIIv7j2iLyP2jIhEbqPRQ7ScNgwQufU2I0Gq11VjyG4gI4yMXt2VFags+1N3g==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.8.tgz", + "integrity": "sha512-LkUu0O2hnUKHKE7/zYOIjByMa4VRaV2CD/cdGz0AxU9we+VA3kDDggKEzI0Oz1IroG+6gUP6UmWEHBMWZU316g==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.23.3", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.23.3" + "@babel/helper-compilation-targets": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/plugin-transform-parameters": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1380,13 +1368,13 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.23.3.tgz", - "integrity": "sha512-BwQ8q0x2JG+3lxCVFohg+KbQM7plfpBwThdW9A6TMtWwLsbDA01Ek2Zb/AgDN39BiZsExm4qrXxjk+P1/fzGrA==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.7.tgz", + "integrity": "sha512-pWT6UXCEW3u1t2tcAGtE15ornCBvopHj9Bps9D2DsH15APgNVOTwwczGckX+WkAvBmuoYKRCFa4DK+jM8vh5AA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20" + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-replace-supers": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1396,13 +1384,12 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.23.4.tgz", - "integrity": "sha512-XIq8t0rJPHf6Wvmbn9nFxU6ao4c7WhghTR5WyV8SrJfUFzyxhCm4nhC+iAp3HFhbAKLfYpgzhJ6t4XCtVwqO5A==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.8.tgz", + "integrity": "sha512-EbQYweoMAHOn7iJ9GgZo14ghhb9tTjgOc88xFgYngifx7Z9u580cENCV159M4xDh3q/irbhSjZVpuhpC2gKBbg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1412,14 +1399,13 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.4.tgz", - "integrity": "sha512-ZU8y5zWOfjM5vZ+asjgAPwDaBjJzgufjES89Rs4Lpq63O300R/kOz30WCLo6BxxX6QVEilwSlpClnG5cZaikTA==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.8.tgz", + "integrity": "sha512-q05Bk7gXOxpTHoQ8RSzGSh/LHVB9JEIkKnk3myAWwZHnYiTGYtbdrYkIsS8Xyh4ltKf7GNUSgzs/6P2bJtBAQg==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-syntax-optional-chaining": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1429,12 +1415,12 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.23.3.tgz", - "integrity": "sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.7.tgz", + "integrity": "sha512-FYiTvku63me9+1Nz7TOx4YMtW3tWXzfANZtrzHhUZrz4d47EEtMQhzFoZWESfXuAMMT5mwzD4+y1N8ONAX6lMQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1444,13 +1430,13 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.23.3.tgz", - "integrity": "sha512-UzqRcRtWsDMTLrRWFvUBDwmw06tCQH9Rl1uAjfh6ijMSmGYQ+fpdB+cnqRC8EMh5tuuxSv0/TejGL+7vyj+50g==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.7.tgz", + "integrity": "sha512-KY0hh2FluNxMLwOCHbxVOKfdB5sjWG4M183885FmaqWWiGMhRZq4DQRKH6mHdEucbJnyDyYiZNwNG424RymJjA==", "dev": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-class-features-plugin": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1460,15 +1446,14 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.23.4.tgz", - "integrity": "sha512-9G3K1YqTq3F4Vt88Djx1UZ79PDyj+yKRnUy7cZGSMe+a7jkwD259uKKuUzQlPkGam7R+8RJwh5z4xO27fA1o2A==", + "version": "7.25.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.8.tgz", + "integrity": "sha512-8Uh966svuB4V8RHHg0QJOB32QK287NBksJOByoKmHMp1TAobNniNalIkI2i5IPj5+S9NYCG4VIjbEuiSN8r+ow==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + "@babel/helper-annotate-as-pure": "^7.25.7", + "@babel/helper-create-class-features-plugin": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1478,12 +1463,12 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.23.3.tgz", - "integrity": "sha512-jR3Jn3y7cZp4oEWPFAlRsSWjxKe4PZILGBSd4nis1TsC5qeSpb+nrtihJuDhNI7QHiVbUaiXa0X2RZY3/TI6Nw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.7.tgz", + "integrity": "sha512-lQEeetGKfFi0wHbt8ClQrUSUMfEeI3MMm74Z73T9/kuz990yYVtfofjf3NuA42Jy3auFOpbjDyCSiIkTs1VIYw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1523,16 +1508,16 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz", - "integrity": "sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.7.tgz", + "integrity": "sha512-vILAg5nwGlR9EXE8JIOX4NHXd49lrYbN8hnjffDtoULwpL9hUx/N55nqh2qd0q6FyNDfjl9V79ecKGvFbcSA0Q==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-jsx": "^7.23.3", - "@babel/types": "^7.23.4" + "@babel/helper-annotate-as-pure": "^7.25.7", + "@babel/helper-module-imports": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/plugin-syntax-jsx": "^7.25.7", + "@babel/types": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1573,12 +1558,12 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.23.3.tgz", - "integrity": "sha512-KP+75h0KghBMcVpuKisx3XTu9Ncut8Q8TuvGO4IhY+9D5DFEckQefOuIsB/gQ2tG71lCke4NMrtIPS8pOj18BQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.7.tgz", + "integrity": "sha512-mgDoQCRjrY3XK95UuV60tZlFCQGXEtMg8H+IsW72ldw1ih1jZhzYXbJvghmAEpg5UVhhnCeia1CkGttUvCkiMQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-plugin-utils": "^7.25.7", "regenerator-transform": "^0.15.2" }, "engines": { @@ -1589,12 +1574,12 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.23.3.tgz", - "integrity": "sha512-QnNTazY54YqgGxwIexMZva9gqbPa15t/x9VS+0fsEFWplwVpXYZivtgl43Z1vMpc1bdPP2PP8siFeVcnFvA3Cg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.7.tgz", + "integrity": "sha512-3OfyfRRqiGeOvIWSagcwUTVk2hXBsr/ww7bLn6TRTuXnexA+Udov2icFOxFX9abaj4l96ooYkcNN1qi2Zvqwng==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1623,54 +1608,13 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-runtime/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz", - "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==", + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.7.tgz", + "integrity": "sha512-uBbxNwimHi5Bv3hUccmOFlUy3ATO6WagTApenHz9KzoIdn0XeACdB12ZJ4cjhuB2WSi80Ez2FWzJnarccriJeA==", "dev": true, "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.10.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", - "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", - "dev": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.2", - "core-js-compat": "^3.38.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.2.tgz", - "integrity": "sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==", - "dev": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.23.3.tgz", - "integrity": "sha512-ED2fgqZLmexWiN+YNFX26fx4gh5qHDhn1O2gvEhreLW2iI63Sqm4llRLCXALKrCnbN4Jy0VcMQZl/SAzqug/jg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1680,13 +1624,13 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.23.3.tgz", - "integrity": "sha512-VvfVYlrlBVu+77xVTOAoxQ6mZbnIq5FM0aGBSFEcIh03qHf+zNqA4DC/3XMUozTg7bZV3e3mZQ0i13VB6v5yUg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.7.tgz", + "integrity": "sha512-Mm6aeymI0PBh44xNIv/qvo8nmbkpZze1KvR8MkEqbIREDxoiWTi18Zr2jryfRMwDfVZF9foKh060fWgni44luw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1696,12 +1640,12 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.23.3.tgz", - "integrity": "sha512-HZOyN9g+rtvnOU3Yh7kSxXrKbzgrm5X4GncPY1QOquu7epga5MxKHVpYu2hvQnry/H+JjckSYRb93iNfsioAGg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.7.tgz", + "integrity": "sha512-ZFAeNkpGuLnAQ/NCsXJ6xik7Id+tHuS+NT+ue/2+rn/31zcdnupCdmunOizEaP0JsUmTFSTOPoQY7PkK2pttXw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1711,12 +1655,12 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.23.3.tgz", - "integrity": "sha512-Flok06AYNp7GV2oJPZZcP9vZdszev6vPBkHLwxwSpaIqx75wn6mUd3UFWsSsA0l8nXAKkyCmL/sR02m8RYGeHg==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.7.tgz", + "integrity": "sha512-SI274k0nUsFFmyQupiO7+wKATAmMFf8iFgq2O+vVFXZ0SV9lNfT1NGzBEhjquFmD8I9sqHLguH+gZVN3vww2AA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1726,12 +1670,12 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.23.3.tgz", - "integrity": "sha512-4t15ViVnaFdrPC74be1gXBSMzXk3B4Us9lP7uLRQHTFpV5Dvt33pn+2MyyNxmN3VTTm3oTrZVMUmuw3oBnQ2oQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.7.tgz", + "integrity": "sha512-OmWmQtTHnO8RSUbL0NTdtpbZHeNTnm68Gj5pA4Y2blFNh+V4iZR68V1qL9cI37J21ZN7AaCnkfdHtLExQPf2uA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1741,15 +1685,16 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.23.6.tgz", - "integrity": "sha512-6cBG5mBvUu4VUD04OHKnYzbuHNP8huDsD3EDqqpIpsswTDoqHCjLoHb6+QgsV1WsT2nipRqCPgxD3LXnEO7XfA==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.25.7.tgz", + "integrity": "sha512-VKlgy2vBzj8AmEzunocMun2fF06bsSWV+FvVXohtL6FGve/+L217qhHxRTVGHEDO/YR8IANcjzgJsd04J8ge5Q==", "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.23.6", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-typescript": "^7.23.3" + "@babel/helper-annotate-as-pure": "^7.25.7", + "@babel/helper-create-class-features-plugin": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7", + "@babel/plugin-syntax-typescript": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1759,12 +1704,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.23.3.tgz", - "integrity": "sha512-OMCUx/bU6ChE3r4+ZdylEqAjaQgHAgipgW8nsCfu5pGqDcFytVd91AwRvUJSBZDz0exPGgnjoqhgRYLRjFZc9Q==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.7.tgz", + "integrity": "sha512-BN87D7KpbdiABA+t3HbVqHzKWUDN3dymLaTnPFAMyc8lV+KN3+YzNhVRNdinaCPA4AUqx7ubXbQ9shRjYBl3SQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1774,13 +1719,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.23.3.tgz", - "integrity": "sha512-KcLIm+pDZkWZQAFJ9pdfmh89EwVfmNovFBcXko8szpBeF8z68kWIPeKlmSOkT9BXJxs2C0uk+5LxoxIv62MROA==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.7.tgz", + "integrity": "sha512-IWfR89zcEPQGB/iB408uGtSPlQd3Jpq11Im86vUgcmSTcoWAiQMCTOa2K2yNNqFJEBVICKhayctee65Ka8OB0w==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1790,13 +1735,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.23.3.tgz", - "integrity": "sha512-wMHpNA4x2cIA32b/ci3AfwNgheiva2W0WUKWTK7vBHBhDKfPsc5cFGNWm69WBqpwd86u1qwZ9PWevKqm1A3yAw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.7.tgz", + "integrity": "sha512-8JKfg/hiuA3qXnlLx8qtv5HWRbgyFx2hMMtpDDuU2rTckpKkGu4ycK5yYHwuEa16/quXfoxHBIApEsNyMWnt0g==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1806,13 +1751,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.23.3.tgz", - "integrity": "sha512-W7lliA/v9bNR83Qc3q1ip9CQMZ09CcHDbHfbLRDNuAhn1Mvkr1ZNF7hPmztMQvtTGVLJ9m8IZqWsTkXOml8dbw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.7.tgz", + "integrity": "sha512-YRW8o9vzImwmh4Q3Rffd09bH5/hvY0pxg+1H1i0f7APoUeg12G7+HhLj9ZFNIrYkgBXhIijPJ+IXypN0hLTIbw==", "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-create-regexp-features-plugin": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1822,26 +1767,28 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.8.tgz", - "integrity": "sha512-lFlpmkApLkEP6woIKprO6DO60RImpatTQKtz4sUcDjVcK8M8mQ4sZsuxaTMNOZf0sqAq/ReYW1ZBHnOQwKpLWA==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.25.7.tgz", + "integrity": "sha512-Gibz4OUdyNqqLj+7OAvBZxOD7CklCtMA5/j0JgUEwOnaRULsPDXmic2iKxL2DX2vQduPR5wH2hjZas/Vr/Oc0g==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-option": "^7.23.5", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.23.3", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.23.3", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.23.7", + "@babel/compat-data": "^7.25.7", + "@babel/helper-compilation-targets": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-validator-option": "^7.25.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.7", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-namespace-from": "^7.8.3", - "@babel/plugin-syntax-import-assertions": "^7.23.3", - "@babel/plugin-syntax-import-attributes": "^7.23.3", + "@babel/plugin-syntax-import-assertions": "^7.25.7", + "@babel/plugin-syntax-import-attributes": "^7.25.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", @@ -1853,59 +1800,60 @@ "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.23.3", - "@babel/plugin-transform-async-generator-functions": "^7.23.7", - "@babel/plugin-transform-async-to-generator": "^7.23.3", - "@babel/plugin-transform-block-scoped-functions": "^7.23.3", - "@babel/plugin-transform-block-scoping": "^7.23.4", - "@babel/plugin-transform-class-properties": "^7.23.3", - "@babel/plugin-transform-class-static-block": "^7.23.4", - "@babel/plugin-transform-classes": "^7.23.8", - "@babel/plugin-transform-computed-properties": "^7.23.3", - "@babel/plugin-transform-destructuring": "^7.23.3", - "@babel/plugin-transform-dotall-regex": "^7.23.3", - "@babel/plugin-transform-duplicate-keys": "^7.23.3", - "@babel/plugin-transform-dynamic-import": "^7.23.4", - "@babel/plugin-transform-exponentiation-operator": "^7.23.3", - "@babel/plugin-transform-export-namespace-from": "^7.23.4", - "@babel/plugin-transform-for-of": "^7.23.6", - "@babel/plugin-transform-function-name": "^7.23.3", - "@babel/plugin-transform-json-strings": "^7.23.4", - "@babel/plugin-transform-literals": "^7.23.3", - "@babel/plugin-transform-logical-assignment-operators": "^7.23.4", - "@babel/plugin-transform-member-expression-literals": "^7.23.3", - "@babel/plugin-transform-modules-amd": "^7.23.3", - "@babel/plugin-transform-modules-commonjs": "^7.23.3", - "@babel/plugin-transform-modules-systemjs": "^7.23.3", - "@babel/plugin-transform-modules-umd": "^7.23.3", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", - "@babel/plugin-transform-new-target": "^7.23.3", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.23.4", - "@babel/plugin-transform-numeric-separator": "^7.23.4", - "@babel/plugin-transform-object-rest-spread": "^7.23.4", - "@babel/plugin-transform-object-super": "^7.23.3", - "@babel/plugin-transform-optional-catch-binding": "^7.23.4", - "@babel/plugin-transform-optional-chaining": "^7.23.4", - "@babel/plugin-transform-parameters": "^7.23.3", - "@babel/plugin-transform-private-methods": "^7.23.3", - "@babel/plugin-transform-private-property-in-object": "^7.23.4", - "@babel/plugin-transform-property-literals": "^7.23.3", - "@babel/plugin-transform-regenerator": "^7.23.3", - "@babel/plugin-transform-reserved-words": "^7.23.3", - "@babel/plugin-transform-shorthand-properties": "^7.23.3", - "@babel/plugin-transform-spread": "^7.23.3", - "@babel/plugin-transform-sticky-regex": "^7.23.3", - "@babel/plugin-transform-template-literals": "^7.23.3", - "@babel/plugin-transform-typeof-symbol": "^7.23.3", - "@babel/plugin-transform-unicode-escapes": "^7.23.3", - "@babel/plugin-transform-unicode-property-regex": "^7.23.3", - "@babel/plugin-transform-unicode-regex": "^7.23.3", - "@babel/plugin-transform-unicode-sets-regex": "^7.23.3", + "@babel/plugin-transform-arrow-functions": "^7.25.7", + "@babel/plugin-transform-async-generator-functions": "^7.25.7", + "@babel/plugin-transform-async-to-generator": "^7.25.7", + "@babel/plugin-transform-block-scoped-functions": "^7.25.7", + "@babel/plugin-transform-block-scoping": "^7.25.7", + "@babel/plugin-transform-class-properties": "^7.25.7", + "@babel/plugin-transform-class-static-block": "^7.25.7", + "@babel/plugin-transform-classes": "^7.25.7", + "@babel/plugin-transform-computed-properties": "^7.25.7", + "@babel/plugin-transform-destructuring": "^7.25.7", + "@babel/plugin-transform-dotall-regex": "^7.25.7", + "@babel/plugin-transform-duplicate-keys": "^7.25.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.7", + "@babel/plugin-transform-dynamic-import": "^7.25.7", + "@babel/plugin-transform-exponentiation-operator": "^7.25.7", + "@babel/plugin-transform-export-namespace-from": "^7.25.7", + "@babel/plugin-transform-for-of": "^7.25.7", + "@babel/plugin-transform-function-name": "^7.25.7", + "@babel/plugin-transform-json-strings": "^7.25.7", + "@babel/plugin-transform-literals": "^7.25.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.25.7", + "@babel/plugin-transform-member-expression-literals": "^7.25.7", + "@babel/plugin-transform-modules-amd": "^7.25.7", + "@babel/plugin-transform-modules-commonjs": "^7.25.7", + "@babel/plugin-transform-modules-systemjs": "^7.25.7", + "@babel/plugin-transform-modules-umd": "^7.25.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.7", + "@babel/plugin-transform-new-target": "^7.25.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.7", + "@babel/plugin-transform-numeric-separator": "^7.25.7", + "@babel/plugin-transform-object-rest-spread": "^7.25.7", + "@babel/plugin-transform-object-super": "^7.25.7", + "@babel/plugin-transform-optional-catch-binding": "^7.25.7", + "@babel/plugin-transform-optional-chaining": "^7.25.7", + "@babel/plugin-transform-parameters": "^7.25.7", + "@babel/plugin-transform-private-methods": "^7.25.7", + "@babel/plugin-transform-private-property-in-object": "^7.25.7", + "@babel/plugin-transform-property-literals": "^7.25.7", + "@babel/plugin-transform-regenerator": "^7.25.7", + "@babel/plugin-transform-reserved-words": "^7.25.7", + "@babel/plugin-transform-shorthand-properties": "^7.25.7", + "@babel/plugin-transform-spread": "^7.25.7", + "@babel/plugin-transform-sticky-regex": "^7.25.7", + "@babel/plugin-transform-template-literals": "^7.25.7", + "@babel/plugin-transform-typeof-symbol": "^7.25.7", + "@babel/plugin-transform-unicode-escapes": "^7.25.7", + "@babel/plugin-transform-unicode-property-regex": "^7.25.7", + "@babel/plugin-transform-unicode-regex": "^7.25.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.7", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.7", - "babel-plugin-polyfill-corejs3": "^0.8.7", - "babel-plugin-polyfill-regenerator": "^0.5.4", - "core-js-compat": "^3.31.0", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.38.1", "semver": "^6.3.1" }, "engines": { @@ -1950,16 +1898,16 @@ } }, "node_modules/@babel/preset-typescript": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.23.3.tgz", - "integrity": "sha512-17oIGVlqz6CchO9RFYn5U6ZpWRZIngayYCtrPRSgANSwC2V1Jb+iP74nVxzzXJte8b8BYxrL1yY96xfhTBrNNQ==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.25.7.tgz", + "integrity": "sha512-rkkpaXJZOFN45Fb+Gki0c+KMIglk4+zZXOoMJuyEK8y8Kkc8Jd3BDmP7qPsz0zQMJj+UD7EprF+AqAXcILnexw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-option": "^7.22.15", - "@babel/plugin-syntax-jsx": "^7.23.3", - "@babel/plugin-transform-modules-commonjs": "^7.23.3", - "@babel/plugin-transform-typescript": "^7.23.3" + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-validator-option": "^7.25.7", + "@babel/plugin-syntax-jsx": "^7.25.7", + "@babel/plugin-transform-modules-commonjs": "^7.25.7", + "@babel/plugin-transform-typescript": "^7.25.7" }, "engines": { "node": ">=6.9.0" @@ -1968,16 +1916,10 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", - "dev": true - }, "node_modules/@babel/runtime": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.8.tgz", - "integrity": "sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==", + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", + "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" @@ -2049,9 +1991,9 @@ } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.1.tgz", - "integrity": "sha512-lSquqZCHxDfuTg/Sk2hiS0mcSFCEBuj49JfzPHJogDBT0mGCyY5A1AQzBWngitrp7i1/HAZpIgzF/VjhOEIJIg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.2.tgz", + "integrity": "sha512-6tC/MnlEvs5suR4Ahef4YlBccJDHZuxGsAlxXmybWjZ5jPxlzLSMlRZ9mVHSRvlD+CmtE7+hJ+UQbfXrws/rUQ==", "dev": true, "funding": [ { @@ -2067,13 +2009,13 @@ "node": ">=18" }, "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.1" + "@csstools/css-tokenizer": "^3.0.2" } }, "node_modules/@csstools/css-tokenizer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.1.tgz", - "integrity": "sha512-UBqaiu7kU0lfvaP982/o3khfXccVlHPWp0/vwwiIgDF0GmqqqxoiXC/6FCjlS9u92f7CoEz6nXKQnrn1kIAkOw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.2.tgz", + "integrity": "sha512-IuTRcD53WHsXPCZ6W7ubfGqReTJ9Ra0yRRFmXYP/Re8hFYYfoIYIK4080X5luslVLWimhIeFq0hj09urVMQzTw==", "dev": true, "funding": [ { @@ -2330,6 +2272,96 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2785,13 +2817,13 @@ } }, "node_modules/@jridgewell/source-map": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", "dev": true, "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" } }, "node_modules/@jridgewell/sourcemap-codec": { @@ -3028,56 +3060,362 @@ "@octokit/openapi-types": "^22.2.0" } }, - "node_modules/@pkgr/core": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", - "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "node_modules/@parcel/watcher": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz", + "integrity": "sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.0", + "@parcel/watcher-darwin-arm64": "2.5.0", + "@parcel/watcher-darwin-x64": "2.5.0", + "@parcel/watcher-freebsd-x64": "2.5.0", + "@parcel/watcher-linux-arm-glibc": "2.5.0", + "@parcel/watcher-linux-arm-musl": "2.5.0", + "@parcel/watcher-linux-arm64-glibc": "2.5.0", + "@parcel/watcher-linux-arm64-musl": "2.5.0", + "@parcel/watcher-linux-x64-glibc": "2.5.0", + "@parcel/watcher-linux-x64-musl": "2.5.0", + "@parcel/watcher-win32-arm64": "2.5.0", + "@parcel/watcher-win32-ia32": "2.5.0", + "@parcel/watcher-win32-x64": "2.5.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz", + "integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==", + "cpu": [ + "arm64" + ], "dev": true, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": ">= 10.0.0" }, "funding": { - "url": "https://opencollective.com/unts" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@playwright/test": { - "version": "1.47.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.2.tgz", - "integrity": "sha512-jTXRsoSPONAs8Za9QEQdyjFn+0ZQFjCiIztAIF6bi1HqhBzG9Ma7g1WotyiGqFSBRZjIEqMdT8RUlbk1QVhzCQ==", + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz", + "integrity": "sha512-hyZ3TANnzGfLpRA2s/4U1kbw2ZI4qGxaRJbBH2DCSREFfubMswheh8TeiC1sGZ3z2jUf3s37P0BBlrD3sjVTUw==", + "cpu": [ + "arm64" + ], "dev": true, - "peer": true, - "dependencies": { - "playwright": "1.47.2" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" }, - "bin": { - "playwright": "cli.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz", + "integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz", + "integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=18" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.11", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz", - "integrity": "sha512-7j/6vdTym0+qZ6u4XbSAxrWBGYSdCfTzySkj7WAFgDLmSyWlOrWvpyzxlFh5jtw9dn0oL/jtW+06XfFiisN3JQ==", + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz", + "integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==", + "cpu": [ + "arm" + ], "dev": true, - "dependencies": { - "ansi-html-community": "^0.0.8", - "common-path-prefix": "^3.0.0", - "core-js-pure": "^3.23.3", - "error-stack-parser": "^2.0.6", - "find-up": "^5.0.0", - "html-entities": "^2.1.0", - "loader-utils": "^2.0.4", - "schema-utils": "^3.0.0", - "source-map": "^0.7.3" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz", + "integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 10.13" + "node": ">= 10.0.0" }, - "peerDependencies": { - "@types/webpack": "4.x || 5.x", - "react-refresh": ">=0.10.0 <1.0.0", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz", + "integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz", + "integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz", + "integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz", + "integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz", + "integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/@playwright/test": { + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.1.tgz", + "integrity": "sha512-s9RtWoxkOLmRJdw3oFvhFbs9OJS0BzrLUc8Hf6l2UdCNd1rqeEyD4BhCJkvzeEoD1FsK4mirsWwGerhVmYKtZg==", + "dev": true, + "peer": true, + "dependencies": { + "playwright": "1.48.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz", + "integrity": "sha512-7j/6vdTym0+qZ6u4XbSAxrWBGYSdCfTzySkj7WAFgDLmSyWlOrWvpyzxlFh5jtw9dn0oL/jtW+06XfFiisN3JQ==", + "dev": true, + "dependencies": { + "ansi-html-community": "^0.0.8", + "common-path-prefix": "^3.0.0", + "core-js-pure": "^3.23.3", + "error-stack-parser": "^2.0.6", + "find-up": "^5.0.0", + "html-entities": "^2.1.0", + "loader-utils": "^2.0.4", + "schema-utils": "^3.0.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "@types/webpack": "4.x || 5.x", + "react-refresh": ">=0.10.0 <1.0.0", "sockjs-client": "^1.4.0", "type-fest": ">=0.17.0 <5.0.0", "webpack": ">=4.43.0 <6.0.0", @@ -3113,15 +3451,15 @@ "dev": true }, "node_modules/@puppeteer/browsers": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.4.0.tgz", - "integrity": "sha512-x8J1csfIygOwf6D6qUAZ0ASk3z63zPb7wkNeHRerCMh82qWKUrOgkuP005AJC8lDL6/evtXETGEJVcwykKT4/g==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz", + "integrity": "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==", "dev": true, "dependencies": { - "debug": "^4.3.6", + "debug": "^4.4.0", "extract-zip": "^2.0.1", "progress": "^2.0.3", - "proxy-agent": "^6.4.0", + "proxy-agent": "^6.5.0", "semver": "^7.6.3", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", @@ -3135,9 +3473,9 @@ } }, "node_modules/@puppeteer/browsers/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, "dependencies": { "ms": "^2.1.3" @@ -3311,9 +3649,9 @@ "dev": true }, "node_modules/@sideway/address": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", - "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", "dev": true, "dependencies": { "@hapi/hoek": "^9.0.0" @@ -3874,9 +4212,9 @@ } }, "node_modules/@types/eslint": { - "version": "8.44.2", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.2.tgz", - "integrity": "sha512-sdPRb9K6iL5XZOmBubg8yiFp5yS/JdUDQsq5e6h95km91MCYMuvp7mh1fjPEYUhvHepKpZOjnEaMBR4PxjWDzg==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "dependencies": { "@types/estree": "*", @@ -3884,9 +4222,9 @@ } }, "node_modules/@types/eslint-scope": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", - "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "dev": true, "dependencies": { "@types/eslint": "*", @@ -3894,9 +4232,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", - "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true }, "node_modules/@types/express": { @@ -4515,148 +4853,148 @@ "dev": true }, "node_modules/@webassemblyjs/ast": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", - "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "dev": true, "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "dev": true }, "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "dev": true }, "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", - "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "dev": true }, "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", - "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "dev": true, "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.6", - "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "dev": true }, "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", - "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" } }, "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", - "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "dev": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "node_modules/@webassemblyjs/leb128": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", - "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "dev": true, "dependencies": { "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/utf8": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "dev": true }, "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", - "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-opt": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6", - "@webassemblyjs/wast-printer": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" } }, "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", - "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", - "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" } }, "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", - "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-api-error": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "node_modules/@webassemblyjs/wast-printer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", - "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, @@ -4705,19 +5043,19 @@ } }, "node_modules/@wordpress/babel-preset-default": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.9.0.tgz", - "integrity": "sha512-qkhnRyku8FeiUGXfcMYfr/u2SG6NIj/9hWoe5Ubpay7gpX2A1H9+rLrTvABRiip7zit88JJ6b4VUqLL9Cr23bg==", - "dev": true, - "dependencies": { - "@babel/core": "^7.16.0", - "@babel/plugin-transform-react-jsx": "^7.16.0", - "@babel/plugin-transform-runtime": "^7.16.0", - "@babel/preset-env": "^7.16.0", - "@babel/preset-typescript": "^7.16.0", - "@babel/runtime": "^7.16.0", - "@wordpress/browserslist-config": "^6.9.0", - "@wordpress/warning": "^3.9.0", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.10.0.tgz", + "integrity": "sha512-r4Nziy4imbjAdp+t1uV+BFu6UXLhvGPoS+UdQVPwT8hUIJZxiRkGrw9+O/b1LNv971XdOtpZUDXiw4Heb0BGkQ==", + "dev": true, + "dependencies": { + "@babel/core": "7.25.7", + "@babel/plugin-transform-react-jsx": "7.25.7", + "@babel/plugin-transform-runtime": "7.25.7", + "@babel/preset-env": "7.25.7", + "@babel/preset-typescript": "7.25.7", + "@babel/runtime": "7.25.7", + "@wordpress/browserslist-config": "^6.10.0", + "@wordpress/warning": "^3.10.0", "browserslist": "^4.21.10", "core-js": "^3.31.0", "react": "^18.3.0" @@ -4728,9 +5066,9 @@ } }, "node_modules/@wordpress/base-styles": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-5.9.0.tgz", - "integrity": "sha512-b0erDgc8I6NTjbHaPL4GTa3IbfHp4o1+Yx74oT6gLgV9i7Qd8UjBmsUDYIZTV1jqB/ch9DuaDqDaNYqW6tXpZg==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-5.10.0.tgz", + "integrity": "sha512-khjVExyzDUhm1BvW5LS6Whg0QXrCJcV7PNoT+pZDYTB/7MJm+Sm2FpveFlCLwmVkXADvN8+h2/F+Enu4shG0Ew==", "dev": true, "engines": { "node": ">=18.12.0", @@ -4738,9 +5076,9 @@ } }, "node_modules/@wordpress/browserslist-config": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-6.9.0.tgz", - "integrity": "sha512-yv8KJrMZTvhY+PNWQ6CQVTUs/6sAVgim7AxGpgTkVzDYKvTeJKuZqeHzAWtHKTOG+ORIj/29XtpIOU85R9dkng==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-6.10.0.tgz", + "integrity": "sha512-X5BG4xWvr1Qq9S2x5ERCF7V4bpa24zbj8cWYbIJaGiCfi6vp6dFI1SbvuZPXfKyThyytTVYBvEIr6CSm6G8fuQ==", "dev": true, "engines": { "node": ">=18.12.0", @@ -4748,9 +5086,9 @@ } }, "node_modules/@wordpress/dependency-extraction-webpack-plugin": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-6.9.0.tgz", - "integrity": "sha512-faWHIfJ8dSHjQmTEjl/Q6isLLHn0nBbBbTqztAKWtoImmtOLrz68fVxlUh8Fsboov8l6O4fiWv+6gXkWI5B75w==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-6.10.0.tgz", + "integrity": "sha512-BzHYc8QNzLLWwHli+ZOxWamywrWcvEn0czrw3fD04TuQ4G/YivbajMwSTtOeuvaX8fU9UkIsxSmJxqakY3JgqA==", "dev": true, "dependencies": { "json2php": "^0.0.7" @@ -4763,10 +5101,16 @@ "webpack": "^5.0.0" } }, + "node_modules/@wordpress/dependency-extraction-webpack-plugin/node_modules/json2php": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/json2php/-/json2php-0.0.7.tgz", + "integrity": "sha512-dnSoUiLAoVaMXxFsVi4CrPVYMKOuDBXTghXSmMINX44RZ8WM9cXlY7UqrQnlAcODCVO7FV3+8t/5nDKAjimLfg==", + "dev": true + }, "node_modules/@wordpress/e2e-test-utils-playwright": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-1.9.0.tgz", - "integrity": "sha512-07k7LnkvEPIaMGgPvm+wgmBGAVI+pyH/jVXD3TEvKq2BLhZ7zUurV4RvOpiOs58rHvQOS+BzS+yXUwtXUrkQ4g==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-1.10.0.tgz", + "integrity": "sha512-pxXW0nLToS/OPKzFnpi6Q9xWF67n+JYk3q2lNXrCirE5IffTmpy+MwbRD2eFiHq7qzTcO9MeQKwq/t8V4NTVqA==", "dev": true, "dependencies": { "change-case": "^4.1.2", @@ -4785,9 +5129,9 @@ } }, "node_modules/@wordpress/env": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-10.9.0.tgz", - "integrity": "sha512-XS4dJY1jot25wNKnI8+wL1M4tHRIaiLW1ggwXQSyUUWinyG9kuyQA+jzIBnNGegy2pLgbSwTmovExHQNtfU2Hw==", + "version": "10.14.0", + "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-10.14.0.tgz", + "integrity": "sha512-tDJyW6KaaEs9jz2XMTjY0RpGWdsjEfOCx5jeCMWtzkgrDY5N9iZr1BFjNzmFzY1BcXQshnFsrecsnYdyIfvsTA==", "dev": true, "dependencies": { "chalk": "^4.0.0", @@ -4798,7 +5142,7 @@ "inquirer": "^7.1.0", "js-yaml": "^3.13.1", "ora": "^4.0.2", - "rimraf": "^3.0.2", + "rimraf": "^5.0.10", "simple-git": "^3.5.0", "terminal-link": "^2.0.0", "yargs": "^17.3.0" @@ -4811,17 +5155,76 @@ "npm": ">=8.19.2" } }, + "node_modules/@wordpress/env/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@wordpress/env/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@wordpress/env/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@wordpress/env/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@wordpress/eslint-plugin": { - "version": "21.2.0", - "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-21.2.0.tgz", - "integrity": "sha512-jsqi1C96FV4wTGPtPVP/bj/rQtDgu4dHP5pKqtwCuPs7AU4pnUJPHut67Ass8POD+c4EvjPVhS8UDpBs2MeSJg==", + "version": "21.3.0", + "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-21.3.0.tgz", + "integrity": "sha512-nZTaj4RwfAgE16oVqWf7UkDohHrZkk4S4m3tC8j8ZO8XbH+QMiJi7Ga6vi9VlUu47k6xo4vQYNfvnpX+qzX7eA==", "dev": true, "dependencies": { - "@babel/eslint-parser": "^7.16.0", + "@babel/eslint-parser": "7.25.7", "@typescript-eslint/eslint-plugin": "^6.4.1", "@typescript-eslint/parser": "^6.4.1", - "@wordpress/babel-preset-default": "^8.9.0", - "@wordpress/prettier-config": "^4.9.0", + "@wordpress/babel-preset-default": "^8.10.0", + "@wordpress/prettier-config": "^4.10.0", "cosmiconfig": "^7.0.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-import": "^2.25.2", @@ -4882,12 +5285,12 @@ } }, "node_modules/@wordpress/jest-console": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/jest-console/-/jest-console-8.9.0.tgz", - "integrity": "sha512-0swK5WONAx7y5oPDMBbr38e1R7JR+jPCt6CGFoOEwsVGiSRGa5WqJo09/ysCVjDmJn8po/lBaUp9f+fJSVARDQ==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/jest-console/-/jest-console-8.10.0.tgz", + "integrity": "sha512-ildDLCbMIYslAwJa2ESWwS4GSKYsGLIbHlIi3rHdaHmAxoQZvlSTaXCwnUgbCQuNMqgUInT2oDYJLKNiJfv5Fw==", "dev": true, "dependencies": { - "@babel/runtime": "^7.16.0", + "@babel/runtime": "7.25.7", "jest-matcher-utils": "^29.6.2" }, "engines": { @@ -4899,13 +5302,13 @@ } }, "node_modules/@wordpress/jest-preset-default": { - "version": "12.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/jest-preset-default/-/jest-preset-default-12.9.0.tgz", - "integrity": "sha512-qWON30SuU9JVZg5+SjExbv8XZVYDCvHGCV3jf5DOtYFw9kfpDZqnePTlroRvUMiD3ksKsKiAAYOOnBOiJUR/bA==", + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/jest-preset-default/-/jest-preset-default-12.10.0.tgz", + "integrity": "sha512-coVcPloIg8dmwGT7xGd1b2vp5jdNSrbuC8ihv9AfRWfExyoMA1KFdl0TOS5EtP5R1XduoeDeRhuV20JxAH08xw==", "dev": true, "dependencies": { - "@wordpress/jest-console": "^8.9.0", - "babel-jest": "^29.6.2" + "@wordpress/jest-console": "^8.10.0", + "babel-jest": "29.7.0" }, "engines": { "node": ">=18.12.0", @@ -4917,9 +5320,9 @@ } }, "node_modules/@wordpress/npm-package-json-lint-config": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-5.9.0.tgz", - "integrity": "sha512-4sQBUlUzjtYtrM5OC5P4lcyyYbvTDBsPwBk+u11lUI1h/EOOl36TYioEvLut2AGylqzFJKsnbzlL873tfd/5aQ==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-5.10.0.tgz", + "integrity": "sha512-LYwEmsKAQXCm46x63xMJF/Q8T/4BohjVoDxtb+i23CfuH7QT7R7t1ZbQipA757dQwJBFrVuL3RrHCfZPw8JnXg==", "dev": true, "engines": { "node": ">=18.12.0", @@ -4930,12 +5333,12 @@ } }, "node_modules/@wordpress/postcss-plugins-preset": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-5.9.0.tgz", - "integrity": "sha512-OOK5UU2CG+9ilzo1b8ySwVvtZddF+q+PTTFHcxFrcK23sg5XT1DCBm3WU7bSfzOBF2cd4FIVOFVpwvb07mn8Iw==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-5.10.0.tgz", + "integrity": "sha512-BOkr388PenTYDjnFIQmGvKTzf92gxFt+1A8vJPY1gBOIKgK+tc/emcswU5R2skq068jXyacM7/mGfDtPCw0ZiQ==", "dev": true, "dependencies": { - "@wordpress/base-styles": "^5.9.0", + "@wordpress/base-styles": "^5.10.0", "autoprefixer": "^10.2.5" }, "engines": { @@ -4947,9 +5350,9 @@ } }, "node_modules/@wordpress/prettier-config": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.9.0.tgz", - "integrity": "sha512-kxBTL/UZS1JEqzWWHo+h3q+ErvCtkiHm6GozUDgX9UlZXHAx/XAc24s4UaYXmZpjuH5hWO4sbp3xbj2ZI+Ohkg==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.14.0.tgz", + "integrity": "sha512-DZuASK64Jr8ycj9uaSlwsTURiaQ0sgQnu9ThgSK196jcDF1jxTll8JGoVIXgxhKgo3mFFjdtkNeBZ38DT5z6/g==", "dev": true, "engines": { "node": ">=18.12.0", @@ -4960,33 +5363,33 @@ } }, "node_modules/@wordpress/scripts": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-30.1.0.tgz", - "integrity": "sha512-Jzof5xtkjUIO9ybvx3S+W/Mox/fkI+7JOwsi0jdJV2CxTBfH6A9hEykHbX3BxuLGCKJIf55OkCdap6Jif9JojQ==", + "version": "30.7.0", + "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-30.7.0.tgz", + "integrity": "sha512-vwrf6Xo1GXV2ug4xdYMgZ2CVpNNfArOEJyX6w9CafIRmLOm8GkVGSza0VlEoOh1BTqQPv/awq6uiOKVMbVNB5Q==", "dev": true, "dependencies": { - "@babel/core": "^7.16.0", + "@babel/core": "7.25.7", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", "@svgr/webpack": "^8.0.1", - "@wordpress/babel-preset-default": "^8.9.0", - "@wordpress/browserslist-config": "^6.9.0", - "@wordpress/dependency-extraction-webpack-plugin": "^6.9.0", - "@wordpress/e2e-test-utils-playwright": "^1.9.0", - "@wordpress/eslint-plugin": "^21.2.0", - "@wordpress/jest-preset-default": "^12.9.0", - "@wordpress/npm-package-json-lint-config": "^5.9.0", - "@wordpress/postcss-plugins-preset": "^5.9.0", - "@wordpress/prettier-config": "^4.9.0", - "@wordpress/stylelint-config": "^23.1.0", + "@wordpress/babel-preset-default": "*", + "@wordpress/browserslist-config": "*", + "@wordpress/dependency-extraction-webpack-plugin": "*", + "@wordpress/e2e-test-utils-playwright": "*", + "@wordpress/eslint-plugin": "*", + "@wordpress/jest-preset-default": "*", + "@wordpress/npm-package-json-lint-config": "*", + "@wordpress/postcss-plugins-preset": "*", + "@wordpress/prettier-config": "*", + "@wordpress/stylelint-config": "*", "adm-zip": "^0.5.9", - "babel-jest": "^29.6.2", - "babel-loader": "^8.2.3", + "babel-jest": "29.7.0", + "babel-loader": "9.2.1", "browserslist": "^4.21.10", "chalk": "^4.0.0", "check-node-version": "^4.1.0", "clean-webpack-plugin": "^3.0.0", "copy-webpack-plugin": "^10.2.0", - "cross-spawn": "^5.1.0", + "cross-spawn": "^7.0.6", "css-loader": "^6.2.0", "cssnano": "^6.0.1", "cwd": "^0.10.0", @@ -4996,32 +5399,32 @@ "fast-glob": "^3.2.7", "filenamify": "^4.2.0", "jest": "^29.6.2", - "jest-dev-server": "^9.0.1", + "jest-dev-server": "^10.1.4", "jest-environment-jsdom": "^29.6.2", "jest-environment-node": "^29.6.2", + "json2php": "^0.0.9", "markdownlint-cli": "^0.31.1", "merge-deep": "^3.0.3", - "mini-css-extract-plugin": "^2.5.1", + "mini-css-extract-plugin": "^2.9.2", "minimist": "^1.2.0", "npm-package-json-lint": "^6.4.0", "npm-packlist": "^3.0.0", "postcss": "^8.4.5", - "postcss-import": "^16.1.0", "postcss-loader": "^6.2.1", "prettier": "npm:wp-prettier@3.0.3", - "puppeteer-core": "^23.1.0", + "puppeteer-core": "^23.10.1", "react-refresh": "^0.14.0", "read-pkg-up": "^7.0.1", "resolve-bin": "^0.4.0", "rtlcss-webpack-plugin": "^4.0.7", - "sass": "^1.35.2", - "sass-loader": "^12.1.0", + "sass": "^1.50.1", + "sass-loader": "^16.0.3", "schema-utils": "^4.2.0", "source-map-loader": "^3.0.0", "stylelint": "^16.8.2", - "terser-webpack-plugin": "^5.3.9", + "terser-webpack-plugin": "^5.3.10", "url-loader": "^4.1.1", - "webpack": "^5.88.2", + "webpack": "^5.97.0", "webpack-bundle-analyzer": "^4.9.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1" @@ -5034,7 +5437,7 @@ "npm": ">=8.19.2" }, "peerDependencies": { - "@playwright/test": "^1.47.0", + "@playwright/test": "^1.48.1", "react": "^18.0.0", "react-dom": "^18.0.0" } @@ -5161,9 +5564,9 @@ } }, "node_modules/@wordpress/stylelint-config": { - "version": "23.1.0", - "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-23.1.0.tgz", - "integrity": "sha512-Sqli3HYBVaBpVVkNeEUonHcPeEmVF8N76MW5pvKgMyr4TjgVLxTEov9Gujzh4ArW7YCq/hWoS74HKok7xfUsJQ==", + "version": "23.2.0", + "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-23.2.0.tgz", + "integrity": "sha512-UXFZC308qrhUw+30jSb4tc+tHVauiS1IxzJKXRkXeWZ02oGoQHBlOQWWCapR/rI8ZK5+rl4C33RLSCIITauB0g==", "dev": true, "dependencies": { "@stylistic/stylelint-plugin": "^3.0.1", @@ -5179,9 +5582,9 @@ } }, "node_modules/@wordpress/warning": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.9.0.tgz", - "integrity": "sha512-c+bEWwDjp3+Q7SAGb47CuZe56giBFNvutoyiAkn34pQZeO8pRjPElRABIkR7oyn4dEusjL1f6OQmU3dSYAMTpg==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.10.0.tgz", + "integrity": "sha512-IhvIBhhzsNYuLT61ZtKWm7oMg4G0x//eQD8dlnsBA4edP8BiX1VzwA3wCtz9+QdEFzraPJAq9NG4RPxGQas4Nw==", "dev": true, "engines": { "node": ">=18.12.0", @@ -5220,9 +5623,9 @@ } }, "node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -5241,15 +5644,6 @@ "acorn-walk": "^8.0.2" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", - "dev": true, - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -5422,6 +5816,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansis": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.3.2.tgz", + "integrity": "sha512-cFthbBlt+Oi0i9Pv/j6YdVWJh54CtjGACaMPCIrEV4Ha7HWsIjXDwseYV79TIL0B4+KfSwD5S70PeQDkPUd1rA==", + "dev": true, + "engines": { + "node": ">=15" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -5454,12 +5857,12 @@ } }, "node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, - "dependencies": { - "deep-equal": "^2.0.5" + "engines": { + "node": ">= 0.4" } }, "node_modules/arr-union": { @@ -5740,21 +6143,21 @@ } }, "node_modules/axe-core": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.0.tgz", - "integrity": "sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.1.tgz", + "integrity": "sha512-qPC9o+kD8Tir0lzNGLeghbOrWMr3ZJpaRlCIb6Uobt/7N4FiEDvqUMnxzCHRHmg8vOg14kr5gVNyScRmbMaJ9g==", "dev": true, "engines": { "node": ">=4" } }, "node_modules/axios": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", - "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", "dev": true, "dependencies": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -5796,45 +6199,78 @@ } }, "node_modules/babel-loader": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz", - "integrity": "sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==", + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", + "integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==", "dev": true, "dependencies": { - "find-cache-dir": "^3.3.1", - "loader-utils": "^2.0.0", - "make-dir": "^3.1.0", - "schema-utils": "^2.6.5" + "find-cache-dir": "^4.0.0", + "schema-utils": "^4.0.0" }, "engines": { - "node": ">= 8.9" + "node": ">= 14.15.0" }, "peerDependencies": { - "@babel/core": "^7.0.0", - "webpack": ">=2" + "@babel/core": "^7.12.0", + "webpack": ">=5" } }, - "node_modules/babel-loader/node_modules/schema-utils": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", - "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "node_modules/babel-loader/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "dependencies": { - "@types/json-schema": "^7.0.5", - "ajv": "^6.12.4", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 8.9.0" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "node_modules/babel-loader/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/babel-loader/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/babel-loader/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", "dev": true, "dependencies": { @@ -5877,42 +6313,26 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz", - "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==", - "dev": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.8.7", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.7.tgz", - "integrity": "sha512-KyDvZYxAzkC0Aj2dAPyDzi2Ym15e5JKZSK+maI7NAwSqofvuFglbSsxE7wUOvTg9oFVnHMzVzBKcqEb4PJgtOA==", + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", + "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", "dev": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.4", - "core-js-compat": "^3.33.1" + "@babel/helper-define-polyfill-provider": "^0.6.2", + "core-js-compat": "^3.38.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.4.tgz", - "integrity": "sha512-S/x2iOCvDaCASLYsOOgWOq4bCfKYVqvO/uxjkaYyZ3rVsVE3CeAI/c84NpyuBBymEgNvHgjEot3a9/Z/kXvqsg==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.2.tgz", + "integrity": "sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==", "dev": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.4" + "@babel/helper-define-polyfill-provider": "^0.6.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -5998,9 +6418,9 @@ "optional": true }, "node_modules/bare-fs": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.3.tgz", - "integrity": "sha512-7RYKL+vZVCyAsMLi5SPu7QGauGGT8avnP/HO571ndEuV4MYdGXvLhtW67FuLPeEI8EiIY7zbbRR9x7x7HU0kgw==", + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.5.tgz", + "integrity": "sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==", "dev": true, "optional": true, "dependencies": { @@ -6010,9 +6430,9 @@ } }, "node_modules/bare-os": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.2.tgz", - "integrity": "sha512-HZoJwzC+rZ9lqEemTMiO0luOePoGYNBgsLLgegKR/cljiJvcDNhDZQkzC+NC5Oh0aHbdBNSOHpghwMuB5tqhjg==", + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.4.tgz", + "integrity": "sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==", "dev": true, "optional": true }, @@ -6027,14 +6447,13 @@ } }, "node_modules/bare-stream": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.2.1.tgz", - "integrity": "sha512-YTB47kHwBW9zSG8LD77MIBAAQXjU2WjAkMHeeb7hUplVs6+IoM5I7uEVQNPMB7lj9r8I76UMdoMkGnCodHOLqg==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.1.tgz", + "integrity": "sha512-eVZbtKM+4uehzrsj49KtCy3Pbg7kO1pJ3SKZ1SFrIH/0pnj9scuGGgUlNDf/7qS8WKtGdiJY5Kyhs/ivYPTB/g==", "dev": true, "optional": true, "dependencies": { - "b4a": "^1.6.6", - "streamx": "^2.18.0" + "streamx": "^2.21.0" } }, "node_modules/base64-js": { @@ -6183,9 +6602,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", - "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", "dev": true, "funding": [ { @@ -6202,10 +6621,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001646", - "electron-to-chromium": "^1.5.4", + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.0" + "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" @@ -6408,9 +6827,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001651", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", - "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", + "version": "1.0.30001669", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001669.tgz", + "integrity": "sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4+9HrLaYei4w8BIAL7IB/UEDu889d8vhCTPA0w==", "dev": true, "funding": [ { @@ -6589,9 +7008,9 @@ } }, "node_modules/chromium-bidi": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.5.tgz", - "integrity": "sha512-RuLrmzYrxSb0s9SgpB+QN5jJucPduZQ/9SIe76MDxYJuecPW5mxMdacJ1f4EtgiV+R0p3sCkznTMvH0MPGFqjA==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.8.0.tgz", + "integrity": "sha512-uJydbGdTw0DEUjhoogGveneJVWX/9YuqkWePzMmkBYwtdAqo5d3J/ovNKFr+/2hWXYmYCr6it8mSSTIj6SS6Ug==", "dev": true, "dependencies": { "mitt": "3.0.1", @@ -6680,9 +7099,9 @@ } }, "node_modules/cli-truncate/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, "engines": { "node": ">=12" @@ -6692,9 +7111,9 @@ } }, "node_modules/cli-truncate/node_modules/emoji-regex": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", - "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "dev": true }, "node_modules/cli-truncate/node_modules/string-width": { @@ -6883,12 +7302,6 @@ "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", "dev": true }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true - }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -6972,6 +7385,21 @@ "node": ">=8" } }, + "node_modules/configstore/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/configstore/node_modules/write-file-atomic": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", @@ -7214,9 +7642,9 @@ } }, "node_modules/core-js-compat": { - "version": "3.38.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.0.tgz", - "integrity": "sha512-75LAicdLa4OJVwFxFbQR3NdnZjNgX6ILpVcVzcC4T2smerB5lELMrJQQQoWV6TiuC/vlaFqgU2tKQx9w5s0e0A==", + "version": "3.38.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.1.tgz", + "integrity": "sha512-JRH6gfXxGmrzF3tZ57lFx97YARxCXPaMzPo6jELZhv88pBH5VXpQ+y0znKGlFnzuaihqhLbefxSJxWJMPtfDzw==", "dev": true, "dependencies": { "browserslist": "^4.23.3" @@ -7290,31 +7718,54 @@ } }, "node_modules/cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "dependencies": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" } }, - "node_modules/cross-spawn/node_modules/lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "node_modules/cross-spawn/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "dependencies": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/cross-spawn/node_modules/yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", - "dev": true + "node_modules/cross-spawn/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } }, "node_modules/crypto-random-string": { "version": "2.0.0", @@ -7332,9 +7783,9 @@ "dev": true }, "node_modules/css-declaration-sorter": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.1.1.tgz", - "integrity": "sha512-dZ3bVTEEc1vxr3Bek9vGwfB5Z6ESPULhcRvO472mfjVnj8jRcTnKO8/JTczlvxM10Myb+wBM++1MtdO76eWcaQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz", + "integrity": "sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==", "dev": true, "engines": { "node": "^14 || ^16 || >=18" @@ -7344,9 +7795,9 @@ } }, "node_modules/css-functions-list": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.2.tgz", - "integrity": "sha512-c+N0v6wbKVxTu5gOBBFkr9BEdBWaqqjQeiJ8QvSRIJOf+UxlJh930m8e6/WNeODIK0mYLFkoONrnj16i2EcvfQ==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz", + "integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==", "dev": true, "engines": { "node": ">=12 || >=16" @@ -7411,70 +7862,89 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "node_modules/css-minimizer-webpack-plugin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-7.0.0.tgz", + "integrity": "sha512-niy66jxsQHqO+EYbhPuIhqRQ1mNcNVUHrMnkzzir9kFOERJUaQDDRhh7dKDz33kBpkWMF9M8Vx0QlDbc5AHOsw==", "dev": true, "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" + "@jridgewell/trace-mapping": "^0.3.25", + "cssnano": "^7.0.1", + "jest-worker": "^29.7.0", + "postcss": "^8.4.38", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2" + }, + "engines": { + "node": ">= 18.12.0" }, "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-tree": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", - "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", - "dev": true, - "dependencies": { - "mdn-data": "2.0.30", - "source-map-js": "^1.0.1" + "type": "opencollective", + "url": "https://opencollective.com/webpack" }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@parcel/css": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "lightningcss": { + "optional": true + } } }, - "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "node_modules/css-minimizer-webpack-plugin/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, - "engines": { - "node": ">= 6" + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { - "url": "https://github.com/sponsors/fb55" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "node_modules/css-minimizer-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, - "bin": { - "cssesc": "bin/cssesc" + "dependencies": { + "fast-deep-equal": "^3.1.3" }, - "engines": { - "node": ">=4" + "peerDependencies": { + "ajv": "^8.8.2" } }, - "node_modules/cssnano": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.0.3.tgz", - "integrity": "sha512-MRq4CIj8pnyZpcI2qs6wswoYoDD1t0aL28n+41c1Ukcpm56m1h6mCexIHBGjfZfnTqtGSSCP4/fB1ovxgjBOiw==", + "node_modules/css-minimizer-webpack-plugin/node_modules/cssnano": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.0.6.tgz", + "integrity": "sha512-54woqx8SCbp8HwvNZYn68ZFAepuouZW4lTwiMVnBErM3VkO7/Sd4oTOt3Zz3bPx3kxQ36aISppyXj2Md4lg8bw==", "dev": true, "dependencies": { - "cssnano-preset-default": "^6.0.3", - "lilconfig": "^3.0.0" + "cssnano-preset-default": "^7.0.6", + "lilconfig": "^3.1.2" }, "engines": { - "node": "^14 || ^16 || >=18.0" + "node": "^18.12.0 || ^20.9.0 || >=22.0" }, "funding": { "type": "opencollective", @@ -7484,38 +7954,649 @@ "postcss": "^8.4.31" } }, - "node_modules/cssnano-preset-default": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.0.3.tgz", - "integrity": "sha512-4y3H370aZCkT9Ev8P4SO4bZbt+AExeKhh8wTbms/X7OLDo5E7AYUUy6YPxa/uF5Grf+AJwNcCnxKhZynJ6luBA==", + "node_modules/css-minimizer-webpack-plugin/node_modules/cssnano-preset-default": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.6.tgz", + "integrity": "sha512-ZzrgYupYxEvdGGuqL+JKOY70s7+saoNlHSCK/OGn1vB2pQK8KSET8jvenzItcY+kA7NoWvfbb/YhlzuzNKjOhQ==", "dev": true, "dependencies": { - "css-declaration-sorter": "^7.1.1", - "cssnano-utils": "^4.0.1", - "postcss-calc": "^9.0.1", - "postcss-colormin": "^6.0.2", - "postcss-convert-values": "^6.0.2", - "postcss-discard-comments": "^6.0.1", - "postcss-discard-duplicates": "^6.0.1", - "postcss-discard-empty": "^6.0.1", - "postcss-discard-overridden": "^6.0.1", - "postcss-merge-longhand": "^6.0.2", - "postcss-merge-rules": "^6.0.3", - "postcss-minify-font-values": "^6.0.1", - "postcss-minify-gradients": "^6.0.1", - "postcss-minify-params": "^6.0.2", - "postcss-minify-selectors": "^6.0.2", - "postcss-normalize-charset": "^6.0.1", - "postcss-normalize-display-values": "^6.0.1", - "postcss-normalize-positions": "^6.0.1", - "postcss-normalize-repeat-style": "^6.0.1", - "postcss-normalize-string": "^6.0.1", - "postcss-normalize-timing-functions": "^6.0.1", - "postcss-normalize-unicode": "^6.0.2", - "postcss-normalize-url": "^6.0.1", - "postcss-normalize-whitespace": "^6.0.1", - "postcss-ordered-values": "^6.0.1", - "postcss-reduce-initial": "^6.0.2", + "browserslist": "^4.23.3", + "css-declaration-sorter": "^7.2.0", + "cssnano-utils": "^5.0.0", + "postcss-calc": "^10.0.2", + "postcss-colormin": "^7.0.2", + "postcss-convert-values": "^7.0.4", + "postcss-discard-comments": "^7.0.3", + "postcss-discard-duplicates": "^7.0.1", + "postcss-discard-empty": "^7.0.0", + "postcss-discard-overridden": "^7.0.0", + "postcss-merge-longhand": "^7.0.4", + "postcss-merge-rules": "^7.0.4", + "postcss-minify-font-values": "^7.0.0", + "postcss-minify-gradients": "^7.0.0", + "postcss-minify-params": "^7.0.2", + "postcss-minify-selectors": "^7.0.4", + "postcss-normalize-charset": "^7.0.0", + "postcss-normalize-display-values": "^7.0.0", + "postcss-normalize-positions": "^7.0.0", + "postcss-normalize-repeat-style": "^7.0.0", + "postcss-normalize-string": "^7.0.0", + "postcss-normalize-timing-functions": "^7.0.0", + "postcss-normalize-unicode": "^7.0.2", + "postcss-normalize-url": "^7.0.0", + "postcss-normalize-whitespace": "^7.0.0", + "postcss-ordered-values": "^7.0.1", + "postcss-reduce-initial": "^7.0.2", + "postcss-reduce-transforms": "^7.0.0", + "postcss-svgo": "^7.0.1", + "postcss-unique-selectors": "^7.0.3" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/cssnano-utils": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-5.0.0.tgz", + "integrity": "sha512-Uij0Xdxc24L6SirFr25MlwC2rCFX6scyUmuKpzI+JQ7cyqDEwD42fJ0xfB3yLfOnRDU5LKGgjQ9FA6LYh76GWQ==", + "dev": true, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-calc": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-10.0.2.tgz", + "integrity": "sha512-DT/Wwm6fCKgpYVI7ZEWuPJ4az8hiEHtCUeYjZXqU7Ou4QqYh1Df2yCQ7Ca6N7xqKPFkxN3fhf+u9KSoOCJNAjg==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.1.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12 || ^20.9 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.38" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-colormin": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.2.tgz", + "integrity": "sha512-YntRXNngcvEvDbEjTdRWGU606eZvB5prmHG4BF0yLmVpamXbpsRJzevyy6MZVyuecgzI2AWAlvFi8DAeCqwpvA==", + "dev": true, + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-api": "^3.0.0", + "colord": "^2.9.3", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-convert-values": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.4.tgz", + "integrity": "sha512-e2LSXPqEHVW6aoGbjV9RsSSNDO3A0rZLCBxN24zvxF25WknMPpX8Dm9UxxThyEbaytzggRuZxaGXqaOhxQ514Q==", + "dev": true, + "dependencies": { + "browserslist": "^4.23.3", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-discard-comments": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.3.tgz", + "integrity": "sha512-q6fjd4WU4afNhWOA2WltHgCbkRhZPgQe7cXF74fuVB/ge4QbM9HEaOIzGSiMvM+g/cOsNAUGdf2JDzqA2F8iLA==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-discard-duplicates": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.1.tgz", + "integrity": "sha512-oZA+v8Jkpu1ct/xbbrntHRsfLGuzoP+cpt0nJe5ED2FQF8n8bJtn7Bo28jSmBYwqgqnqkuSXJfSUEE7if4nClQ==", + "dev": true, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-discard-empty": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.0.tgz", + "integrity": "sha512-e+QzoReTZ8IAwhnSdp/++7gBZ/F+nBq9y6PomfwORfP7q9nBpK5AMP64kOt0bA+lShBFbBDcgpJ3X4etHg4lzA==", + "dev": true, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-discard-overridden": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-7.0.0.tgz", + "integrity": "sha512-GmNAzx88u3k2+sBTZrJSDauR0ccpE24omTQCVmaTTZFz1du6AasspjaUPMJ2ud4RslZpoFKyf+6MSPETLojc6w==", + "dev": true, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-merge-longhand": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.4.tgz", + "integrity": "sha512-zer1KoZA54Q8RVHKOY5vMke0cCdNxMP3KBfDerjH/BYHh4nCIh+1Yy0t1pAEQF18ac/4z3OFclO+ZVH8azjR4A==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^7.0.4" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-merge-rules": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.4.tgz", + "integrity": "sha512-ZsaamiMVu7uBYsIdGtKJ64PkcQt6Pcpep/uO90EpLS3dxJi6OXamIobTYcImyXGoW0Wpugh7DSD3XzxZS9JCPg==", + "dev": true, + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^5.0.0", + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-minify-font-values": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-7.0.0.tgz", + "integrity": "sha512-2ckkZtgT0zG8SMc5aoNwtm5234eUx1GGFJKf2b1bSp8UflqaeFzR50lid4PfqVI9NtGqJ2J4Y7fwvnP/u1cQog==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-minify-gradients": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-7.0.0.tgz", + "integrity": "sha512-pdUIIdj/C93ryCHew0UgBnL2DtUS3hfFa5XtERrs4x+hmpMYGhbzo6l/Ir5de41O0GaKVpK1ZbDNXSY6GkXvtg==", + "dev": true, + "dependencies": { + "colord": "^2.9.3", + "cssnano-utils": "^5.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-minify-params": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.2.tgz", + "integrity": "sha512-nyqVLu4MFl9df32zTsdcLqCFfE/z2+f8GE1KHPxWOAmegSo6lpV2GNy5XQvrzwbLmiU7d+fYay4cwto1oNdAaQ==", + "dev": true, + "dependencies": { + "browserslist": "^4.23.3", + "cssnano-utils": "^5.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-minify-selectors": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.0.4.tgz", + "integrity": "sha512-JG55VADcNb4xFCf75hXkzc1rNeURhlo7ugf6JjiiKRfMsKlDzN9CXHZDyiG6x/zGchpjQS+UAgb1d4nqXqOpmA==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-charset": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.0.tgz", + "integrity": "sha512-ABisNUXMeZeDNzCQxPxBCkXexvBrUHV+p7/BXOY+ulxkcjUZO0cp8ekGBwvIh2LbCwnWbyMPNJVtBSdyhM2zYQ==", + "dev": true, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-display-values": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.0.tgz", + "integrity": "sha512-lnFZzNPeDf5uGMPYgGOw7v0BfB45+irSRz9gHQStdkkhiM0gTfvWkWB5BMxpn0OqgOQuZG/mRlZyJxp0EImr2Q==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-positions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-7.0.0.tgz", + "integrity": "sha512-I0yt8wX529UKIGs2y/9Ybs2CelSvItfmvg/DBIjTnoUSrPxSV7Z0yZ8ShSVtKNaV/wAY+m7bgtyVQLhB00A1NQ==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-repeat-style": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-7.0.0.tgz", + "integrity": "sha512-o3uSGYH+2q30ieM3ppu9GTjSXIzOrRdCUn8UOMGNw7Af61bmurHTWI87hRybrP6xDHvOe5WlAj3XzN6vEO8jLw==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-string": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-7.0.0.tgz", + "integrity": "sha512-w/qzL212DFVOpMy3UGyxrND+Kb0fvCiBBujiaONIihq7VvtC7bswjWgKQU/w4VcRyDD8gpfqUiBQ4DUOwEJ6Qg==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-timing-functions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-7.0.0.tgz", + "integrity": "sha512-tNgw3YV0LYoRwg43N3lTe3AEWZ66W7Dh7lVEpJbHoKOuHc1sLrzMLMFjP8SNULHaykzsonUEDbKedv8C+7ej6g==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-unicode": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.2.tgz", + "integrity": "sha512-ztisabK5C/+ZWBdYC+Y9JCkp3M9qBv/XFvDtSw0d/XwfT3UaKeW/YTm/MD/QrPNxuecia46vkfEhewjwcYFjkg==", + "dev": true, + "dependencies": { + "browserslist": "^4.23.3", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-url": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-7.0.0.tgz", + "integrity": "sha512-+d7+PpE+jyPX1hDQZYG+NaFD+Nd2ris6r8fPTBAjE8z/U41n/bib3vze8x7rKs5H1uEw5ppe9IojewouHk0klQ==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-normalize-whitespace": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.0.tgz", + "integrity": "sha512-37/toN4wwZErqohedXYqWgvcHUGlT8O/m2jVkAfAe9Bd4MzRqlBmXrJRePH0e9Wgnz2X7KymTgTOaaFizQe3AQ==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-ordered-values": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-7.0.1.tgz", + "integrity": "sha512-irWScWRL6nRzYmBOXReIKch75RRhNS86UPUAxXdmW/l0FcAsg0lvAXQCby/1lymxn/o0gVa6Rv/0f03eJOwHxw==", + "dev": true, + "dependencies": { + "cssnano-utils": "^5.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-reduce-initial": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.2.tgz", + "integrity": "sha512-pOnu9zqQww7dEKf62Nuju6JgsW2V0KRNBHxeKohU+JkHd/GAH5uvoObqFLqkeB2n20mr6yrlWDvo5UBU5GnkfA==", + "dev": true, + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-reduce-transforms": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-7.0.0.tgz", + "integrity": "sha512-pnt1HKKZ07/idH8cpATX/ujMbtOGhUfE+m8gbqwJE05aTaNw8gbo34a2e3if0xc0dlu75sUOiqvwCGY3fzOHew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-svgo": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.0.1.tgz", + "integrity": "sha512-0WBUlSL4lhD9rA5k1e5D8EN5wCEyZD6HJk0jIvRxl+FDVOMlJ7DePHYWGGVc5QRqrJ3/06FTXM0bxjmJpmTPSA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^3.3.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >= 18" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/postcss-unique-selectors": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.3.tgz", + "integrity": "sha512-J+58u5Ic5T1QjP/LDV9g3Cx4CNOgB5vz+kM6+OxHHhFACdcDeKhBXjQmB7fnIZM12YSTvsL0Opwco83DmacW2g==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/stylehacks": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.4.tgz", + "integrity": "sha512-i4zfNrGMt9SB4xRK9L83rlsFCgdGANfeDAYacO1pkqcE7cRHPdWHwnKZVz7WY17Veq/FvyYsRAU++Ga+qDFIww==", + "dev": true, + "dependencies": { + "browserslist": "^4.23.3", + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-6.0.3.tgz", + "integrity": "sha512-MRq4CIj8pnyZpcI2qs6wswoYoDD1t0aL28n+41c1Ukcpm56m1h6mCexIHBGjfZfnTqtGSSCP4/fB1ovxgjBOiw==", + "dev": true, + "dependencies": { + "cssnano-preset-default": "^6.0.3", + "lilconfig": "^3.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-preset-default": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-6.0.3.tgz", + "integrity": "sha512-4y3H370aZCkT9Ev8P4SO4bZbt+AExeKhh8wTbms/X7OLDo5E7AYUUy6YPxa/uF5Grf+AJwNcCnxKhZynJ6luBA==", + "dev": true, + "dependencies": { + "css-declaration-sorter": "^7.1.1", + "cssnano-utils": "^4.0.1", + "postcss-calc": "^9.0.1", + "postcss-colormin": "^6.0.2", + "postcss-convert-values": "^6.0.2", + "postcss-discard-comments": "^6.0.1", + "postcss-discard-duplicates": "^6.0.1", + "postcss-discard-empty": "^6.0.1", + "postcss-discard-overridden": "^6.0.1", + "postcss-merge-longhand": "^6.0.2", + "postcss-merge-rules": "^6.0.3", + "postcss-minify-font-values": "^6.0.1", + "postcss-minify-gradients": "^6.0.1", + "postcss-minify-params": "^6.0.2", + "postcss-minify-selectors": "^6.0.2", + "postcss-normalize-charset": "^6.0.1", + "postcss-normalize-display-values": "^6.0.1", + "postcss-normalize-positions": "^6.0.1", + "postcss-normalize-repeat-style": "^6.0.1", + "postcss-normalize-string": "^6.0.1", + "postcss-normalize-timing-functions": "^6.0.1", + "postcss-normalize-unicode": "^6.0.2", + "postcss-normalize-url": "^6.0.1", + "postcss-normalize-whitespace": "^6.0.1", + "postcss-ordered-values": "^6.0.1", + "postcss-reduce-initial": "^6.0.2", "postcss-reduce-transforms": "^6.0.1", "postcss-svgo": "^6.0.2", "postcss-unique-selectors": "^6.0.2" @@ -7793,44 +8874,6 @@ } } }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/deep-equal/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -8040,6 +9083,19 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -8226,6 +9282,12 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8233,9 +9295,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.6.tgz", - "integrity": "sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==", + "version": "1.5.41", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.41.tgz", + "integrity": "sha512-dfdv/2xNjX0P8Vzme4cfzHqnPm5xsZXwsolTYr0eyW18IUmNyG08vL+fttvinTfhKfIKdRoqkDIC9e9iWQCNYQ==", "dev": true }, "node_modules/emittery": { @@ -8284,9 +9346,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", - "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dev": true, "dependencies": { "graceful-fs": "^4.2.4", @@ -8441,48 +9503,22 @@ "get-intrinsic": "^1.2.4" }, "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.4" } }, - "node_modules/es-get-iterator/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } }, "node_modules/es-iterator-helpers": { - "version": "1.0.19", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.19.tgz", - "integrity": "sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.1.0.tgz", + "integrity": "sha512-/SurEfycdyssORP/E+bj4sEu1CWw4EmLDsHynHwSXQ7utgbrMRWW195pTrCjFgFCddf/UkYm3oqKPRq5i8bJbw==", "dev": true, "dependencies": { "call-bind": "^1.0.7", @@ -8492,12 +9528,12 @@ "es-set-tostringtag": "^2.0.3", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", - "globalthis": "^1.0.3", + "globalthis": "^1.0.4", "has-property-descriptors": "^1.0.2", "has-proto": "^1.0.3", "has-symbols": "^1.0.3", "internal-slot": "^1.0.7", - "iterator.prototype": "^1.1.2", + "iterator.prototype": "^1.1.3", "safe-array-concat": "^1.1.2" }, "engines": { @@ -8563,9 +9599,9 @@ } }, "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "engines": { "node": ">=6" @@ -8973,12 +10009,12 @@ } }, "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.0.tgz", - "integrity": "sha512-ySOHvXX8eSN6zz8Bywacm7CvGNhUtdjvqfQDVe6020TUK34Cywkw7m0KsCCk1Qtm9G1FayfTN1/7mMYnYO2Bhg==", + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.1.tgz", + "integrity": "sha512-zHByM9WTUMnfsDTafGXRiqxp6lFtNoSOWBY6FonVRn3A+BUwN1L/tdBXT40BcBJi0cZjOGTXZ0eD/rTG9fEJ0g==", "dev": true, "dependencies": { - "aria-query": "~5.1.3", + "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", @@ -8986,14 +10022,14 @@ "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", - "es-iterator-helpers": "^1.0.19", + "es-iterator-helpers": "^1.1.0", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", - "string.prototype.includes": "^2.0.0" + "string.prototype.includes": "^2.0.1" }, "engines": { "node": ">=4.0" @@ -9157,20 +10193,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, - "node_modules/eslint/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/eslint/node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -9226,27 +10248,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/eslint/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/eslint/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -9259,21 +10260,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -9405,20 +10391,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/execa/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/execa/node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -9431,42 +10403,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/execa/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/execa/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/execa/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -9699,9 +10635,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.2.tgz", - "integrity": "sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", "dev": true }, "node_modules/fastest-levenshtein": { @@ -9860,20 +10796,116 @@ "dev": true }, "node_modules/find-cache-dir": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", + "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", + "dev": true, + "dependencies": { + "common-path-prefix": "^3.0.0", + "pkg-dir": "^7.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/find-up": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", "dev": true, "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" }, "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/find-cache-dir/node_modules/pkg-dir": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", + "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", + "dev": true, + "dependencies": { + "find-up": "^6.3.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/yocto-queue": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", + "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "dev": true, + "engines": { + "node": ">=12.20" }, "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/find-file-up": { @@ -10025,6 +11057,34 @@ "node": ">=0.10.0" } }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -10174,9 +11234,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", - "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", "dev": true, "engines": { "node": ">=18" @@ -10360,12 +11420,13 @@ } }, "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, "dependencies": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -10801,9 +11862,9 @@ } }, "node_modules/husky": { - "version": "9.1.6", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.6.tgz", - "integrity": "sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A==", + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true, "bin": { "husky": "bin.js" @@ -10887,9 +11948,9 @@ "dev": true }, "node_modules/immutable": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.2.tgz", - "integrity": "sha512-oGXzbEDem9OOpDWZu88jGiYCvIsLHMvGw+8OXlpsvTFvIQplQbjg1B1cvKg8f7Hoch6+NGjpPsH1Fr+Mc2D1aA==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", + "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", "dev": true }, "node_modules/import-fresh": { @@ -11076,22 +12137,6 @@ "node": ">=8" } }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -11703,39 +12748,12 @@ "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report/node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/istanbul-lib-report/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "bin": { - "semver": "bin/semver.js" + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" }, "engines": { "node": ">=10" @@ -11778,9 +12796,9 @@ } }, "node_modules/iterator.prototype": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", - "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.3.tgz", + "integrity": "sha512-FW5iMbeQ6rBGm/oKgzq2aW4KvAGpxPzYES8N4g4xNXUKpL1mclMvOe+76AcLDTvD+Ze+sOpVhgdAQEKF4L9iGQ==", "dev": true, "dependencies": { "define-properties": "^1.2.1", @@ -11788,6 +12806,24 @@ "has-symbols": "^1.0.3", "reflect.getprototypeof": "^1.0.4", "set-function-name": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/jest": { @@ -11940,18 +12976,18 @@ } }, "node_modules/jest-dev-server": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jest-dev-server/-/jest-dev-server-9.0.2.tgz", - "integrity": "sha512-Zc/JB0IlNNrpXkhBw+h86cGrde/Mey52KvF+FER2eyrtYJTHObOwW7Iarxm3rPyTKby5+3Y2QZtl8pRz/5GCxg==", + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/jest-dev-server/-/jest-dev-server-10.1.4.tgz", + "integrity": "sha512-bGQ6sedNGtT6AFHhCVqGTXMPz7UyJi/ZrhNBgyqsP0XU9N8acCEIfqZEA22rOaZ+NdEVsaltk6tL7UT6aXfI7w==", "dev": true, "dependencies": { "chalk": "^4.1.2", "cwd": "^0.10.0", "find-process": "^1.4.7", "prompts": "^2.4.2", - "spawnd": "^9.0.2", + "spawnd": "^10.1.4", "tree-kill": "^1.2.2", - "wait-on": "^7.2.0" + "wait-on": "^8.0.1" }, "engines": { "node": ">=16" @@ -12276,25 +13312,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-runner/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/jest-runner/node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/jest-runner/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -12469,14 +13486,14 @@ } }, "node_modules/joi": { - "version": "17.11.0", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.11.0.tgz", - "integrity": "sha512-NgB+lZLNoqISVy1rZocE9PZI36bL/77ie924Ri43yEvi9GUUMPeyVIr8KdFTMUlby1p0PBYMk9spIxEUQYqrJQ==", + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", "dev": true, "dependencies": { - "@hapi/hoek": "^9.0.0", - "@hapi/topo": "^5.0.0", - "@sideway/address": "^4.1.3", + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } @@ -12618,9 +13635,9 @@ "dev": true }, "node_modules/json2php": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/json2php/-/json2php-0.0.7.tgz", - "integrity": "sha512-dnSoUiLAoVaMXxFsVi4CrPVYMKOuDBXTghXSmMINX44RZ8WM9cXlY7UqrQnlAcODCVO7FV3+8t/5nDKAjimLfg==", + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/json2php/-/json2php-0.0.9.tgz", + "integrity": "sha512-fQMYwvPsQt8hxRnCGyg1r2JVi6yL8Um0DIIawiKiMK9yhAAkcRNj5UsBWoaFvFzPpcWbgw9L6wzj+UMYA702Mw==", "dev": true }, "node_modules/json5": { @@ -13100,9 +14117,9 @@ } }, "node_modules/lilconfig": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", - "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, "engines": { "node": ">=14" @@ -13127,21 +14144,21 @@ } }, "node_modules/lint-staged": { - "version": "15.2.10", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.10.tgz", - "integrity": "sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==", + "version": "15.2.11", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.11.tgz", + "integrity": "sha512-Ev6ivCTYRTGs9ychvpVw35m/bcNDuBN+mnTeObCL5h+boS5WzBEC6LHI4I9F/++sZm1m+J2LEiy0gxL/R9TBqQ==", "dev": true, "dependencies": { "chalk": "~5.3.0", "commander": "~12.1.0", - "debug": "~4.3.6", + "debug": "~4.4.0", "execa": "~8.0.1", - "lilconfig": "~3.1.2", - "listr2": "~8.2.4", + "lilconfig": "~3.1.3", + "listr2": "~8.2.5", "micromatch": "~4.0.8", "pidtree": "~0.6.0", "string-argv": "~0.3.2", - "yaml": "~2.5.0" + "yaml": "~2.6.1" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -13165,27 +14182,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/lint-staged/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/lint-staged/node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -13264,6 +14267,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lint-staged/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, "node_modules/lint-staged/node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", @@ -13306,27 +14315,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lint-staged/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/lint-staged/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/lint-staged/node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -13351,25 +14339,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lint-staged/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/lint-staged/node_modules/yaml": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", - "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", + "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", "dev": true, "bin": { "yaml": "bin.mjs" @@ -13379,9 +14352,9 @@ } }, "node_modules/listr2": { - "version": "8.2.4", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.4.tgz", - "integrity": "sha512-opevsywziHd3zHCVQGAj8zu+Z3yHNkkoYhWIGnq54RrCVwLz0MozotJEDnKsIBLvkfLGN6BLOyAeRrYI0pKA4g==", + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", + "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", "dev": true, "dependencies": { "cli-truncate": "^4.0.0", @@ -13396,9 +14369,9 @@ } }, "node_modules/listr2/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, "engines": { "node": ">=12" @@ -13420,9 +14393,9 @@ } }, "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", - "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "dev": true }, "node_modules/listr2/node_modules/eventemitter3": { @@ -13651,9 +14624,9 @@ } }, "node_modules/log-update/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, "engines": { "node": ">=12" @@ -13690,9 +14663,9 @@ } }, "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", - "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "dev": true }, "node_modules/log-update/node_modules/is-fullwidth-code-point": { @@ -13870,20 +14843,32 @@ } }, "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "dependencies": { - "semver": "^6.0.0" + "semver": "^7.5.3" }, "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -13942,19 +14927,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/markdown-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", - "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", - "dev": true, - "dependencies": { - "repeat-string": "^1.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/markdownlint": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.25.1.tgz", @@ -14277,12 +15249,13 @@ } }, "node_modules/mini-css-extract-plugin": { - "version": "2.7.6", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.6.tgz", - "integrity": "sha512-Qk7HcgaPkGG6eD77mLvZS1nmxlao3j+9PkrT9Uc7HAE1id3F41+DdBRYRYkbyfNRGzm8/YWtzhw7nVPmwhqTQw==", + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz", + "integrity": "sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==", "dev": true, "dependencies": { - "schema-utils": "^4.0.0" + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" }, "engines": { "node": ">= 12.13.0" @@ -14296,15 +15269,15 @@ } }, "node_modules/mini-css-extract-plugin/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -14330,9 +15303,9 @@ "dev": true }, "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", + "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", "dev": true, "dependencies": { "@types/json-schema": "^7.0.9", @@ -14341,7 +15314,7 @@ "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">= 12.13.0" + "node": ">= 10.13.0" }, "funding": { "type": "opencollective", @@ -14407,6 +15380,15 @@ "node": ">=0.10.0" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -14551,6 +15533,13 @@ "tslib": "^2.0.3" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "optional": true + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -15059,22 +16048,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -15466,32 +16439,29 @@ } }, "node_modules/pac-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.2.tgz", - "integrity": "sha512-BFi3vZnO9X5Qt6NRz7ZOaPja3ic0PhlsmCRYLOpN11+mWBCR6XJDqW5RF3j8jm4WGGQZtBA+bTfxYzeKW73eHg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.1.0.tgz", + "integrity": "sha512-Z5FnLVVZSnX7WjBg0mhDtydeRZ1xMcATZThjySQUHqr+0ksP8kqaw23fNKkaaN/Z8gwLUs/W7xdl0I75eP2Xyw==", "dev": true, "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.5", + "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", - "socks-proxy-agent": "^8.0.4" + "socks-proxy-agent": "^8.0.5" }, "engines": { "node": ">= 14" } }, "node_modules/pac-proxy-agent/node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "dev": true, - "dependencies": { - "debug": "^4.3.4" - }, "engines": { "node": ">= 14" } @@ -15510,12 +16480,12 @@ } }, "node_modules/pac-proxy-agent/node_modules/https-proxy-agent": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", - "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { @@ -15535,6 +16505,12 @@ "node": ">= 14" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -15670,6 +16646,28 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -15825,13 +16823,13 @@ } }, "node_modules/playwright": { - "version": "1.47.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.2.tgz", - "integrity": "sha512-nx1cLMmQWqmA3UsnjaaokyoUpdVaaDhJhMoxX2qj3McpjnsqFHs516QAKYhqHAgOP+oCFTEOCOAaD1RgD/RQfA==", + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.1.tgz", + "integrity": "sha512-j8CiHW/V6HxmbntOfyB4+T/uk08tBy6ph0MpBXwuoofkSnLmlfdYNNkFTYD6ofzzlSqLA1fwH4vwvVFvJgLN0w==", "dev": true, "peer": true, "dependencies": { - "playwright-core": "1.47.2" + "playwright-core": "1.48.1" }, "bin": { "playwright": "cli.js" @@ -15844,9 +16842,9 @@ } }, "node_modules/playwright-core": { - "version": "1.47.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.2.tgz", - "integrity": "sha512-3JvMfF+9LJfe16l7AbSmU555PaTl2tPyQsVInqm3id16pdDfvZ8TTZ/pyzmkbDrZTQefyzU7AIHlZqQnxpqHVQ==", + "version": "1.48.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.1.tgz", + "integrity": "sha512-Yw/t4VAFX/bBr1OzwCuOMZkY1Cnb4z/doAFSwf4huqAGWmf9eMNjmK7NiOljCdLmxeRYcGPPmcDgU0zOlzP0YA==", "dev": true, "peer": true, "bin": { @@ -16021,23 +17019,6 @@ "postcss": "^8.4.31" } }, - "node_modules/postcss-import": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.1.0.tgz", - "integrity": "sha512-7hsAZ4xGXl4MW+OKEWCnF6T5jqBw80/EE9aXg1r2yyn1RsVEU8EtKXbijEODa+rg7iih4bKf7vlvTGYR4CnPNg==", - "dev": true, - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, "node_modules/postcss-loader": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz", @@ -16684,32 +17665,29 @@ } }, "node_modules/proxy-agent": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", - "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", "dev": true, "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.3", + "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.0.1", + "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.2" + "socks-proxy-agent": "^8.0.5" }, "engines": { "node": ">= 14" } }, "node_modules/proxy-agent/node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "dev": true, - "dependencies": { - "debug": "^4.3.4" - }, "engines": { "node": ">= 14" } @@ -16728,12 +17706,12 @@ } }, "node_modules/proxy-agent/node_modules/https-proxy-agent": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", - "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { @@ -16767,12 +17745,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", - "dev": true - }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -16799,15 +17771,15 @@ } }, "node_modules/puppeteer-core": { - "version": "23.3.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.3.0.tgz", - "integrity": "sha512-sB2SsVMFs4gKad5OCdv6w5vocvtEUrRl0zQqSyRPbo/cj1Ktbarmhxy02Zyb9R9HrssBcJDZbkrvBnbaesPyYg==", + "version": "23.10.4", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.10.4.tgz", + "integrity": "sha512-pQAY7+IFAndWDkDodsQGguW1/ifV5OMlGXJDspwtK49Asb7poJZ/V5rXJxVSpq57bWrJasjQBZ1X27z1oWVq4Q==", "dev": true, "dependencies": { - "@puppeteer/browsers": "2.4.0", - "chromium-bidi": "0.6.5", - "debug": "^4.3.6", - "devtools-protocol": "0.0.1330662", + "@puppeteer/browsers": "2.6.1", + "chromium-bidi": "0.8.0", + "debug": "^4.4.0", + "devtools-protocol": "0.0.1367902", "typed-query-selector": "^2.12.0", "ws": "^8.18.0" }, @@ -16816,9 +17788,9 @@ } }, "node_modules/puppeteer-core/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dev": true, "dependencies": { "ms": "^2.1.3" @@ -16833,9 +17805,9 @@ } }, "node_modules/puppeteer-core/node_modules/devtools-protocol": { - "version": "0.0.1330662", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1330662.tgz", - "integrity": "sha512-pzh6YQ8zZfz3iKlCvgzVCu22NdpZ8hNmwU6WnQjNVquh0A9iVosPtNLWDwaWVGyrntQlltPFztTMK5Cg6lfCuw==", + "version": "0.0.1367902", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", + "integrity": "sha512-XxtPuC3PGakY6PD7dG66/o8KwJ/LkH2/EKe19Dcw58w53dv4/vSQEkn/SzuyhHE2q4zPgCkxQBxus3VV4ql+Pg==", "dev": true }, "node_modules/puppeteer-core/node_modules/ms": { @@ -17002,24 +17974,6 @@ "node": ">=0.10.0" } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/read-cache/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -17229,9 +18183,9 @@ "dev": true }, "node_modules/regenerate-unicode-properties": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz", - "integrity": "sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", + "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", "dev": true, "dependencies": { "regenerate": "^1.4.2" @@ -17274,15 +18228,15 @@ } }, "node_modules/regexpu-core": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", - "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.1.1.tgz", + "integrity": "sha512-k67Nb9jvwJcJmVpw0jPttR1/zVfnKf8Km0IPatrU/zJ5XeG3+Slx0xLXs9HByJSzXzrlz5EDvN6yLNMDc2qdnw==", "dev": true, "dependencies": { - "@babel/regjsgen": "^0.8.0", "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.1.0", - "regjsparser": "^0.9.1", + "regenerate-unicode-properties": "^10.2.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.11.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.1.0" }, @@ -17290,36 +18244,24 @@ "node": ">=4" } }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true + }, "node_modules/regjsparser": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", - "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.11.1.tgz", + "integrity": "sha512-1DHODs4B8p/mQHU9kr+jv8+wIC9mtG4eBHxWxIq5mhjE3D5oORhCc6deRKzTjs9DcfRFmj9BHSDguZklqCGFWQ==", "dev": true, "dependencies": { - "jsesc": "~0.5.0" + "jsesc": "~3.0.2" }, "bin": { "regjsparser": "bin/parser" } }, - "node_modules/regjsparser/node_modules/jsesc": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", - "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - } - }, - "node_modules/repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", - "dev": true, - "engines": { - "node": ">=0.10" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -17655,13 +18597,13 @@ "dev": true }, "node_modules/sass": { - "version": "1.65.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.65.1.tgz", - "integrity": "sha512-9DINwtHmA41SEd36eVPQ9BJKpn7eKDQmUHmpI0y5Zv2Rcorrh0zS+cFrt050hdNbmmCNKTW3hV5mWfuegNRsEA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.83.0.tgz", + "integrity": "sha512-qsSxlayzoOjdvXMVLkzF84DJFc2HZEL/rFyGIKbbilYtAvlCxyuzUeff9LawTn4btVnLKg75Z8MMr1lxU1lfGw==", "dev": true, "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", + "chokidar": "^4.0.0", + "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -17669,33 +18611,35 @@ }, "engines": { "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" } }, "node_modules/sass-loader": { - "version": "12.6.0", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", - "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==", + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.4.tgz", + "integrity": "sha512-LavLbgbBGUt3wCiYzhuLLu65+fWXaXLmq7YxivLhEqmiupCFZ5sKUAipK3do6V80YSU0jvSxNhEdT13IXNr3rg==", "dev": true, "dependencies": { - "klona": "^2.0.4", "neo-async": "^2.6.2" }, "engines": { - "node": ">= 12.13.0" + "node": ">= 18.12.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "fibers": ">= 3.1.0", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "@rspack/core": "0.x || 1.x", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", "sass": "^1.3.0", "sass-embedded": "*", "webpack": "^5.0.0" }, "peerDependenciesMeta": { - "fibers": { + "@rspack/core": { "optional": true }, "node-sass": { @@ -17706,9 +18650,40 @@ }, "sass-embedded": { "optional": true + }, + "webpack": { + "optional": true } } }, + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.2.tgz", + "integrity": "sha512-/b57FK+bblSU+dfewfFe0rT1YjVDfOmeLQwCAuC+vwvgLkXboATqqmy+Ipux6JrF6L5joe5CBnFOw+gLWH6yKg==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -18193,12 +19168,12 @@ } }, "node_modules/socks-proxy-agent": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz", - "integrity": "sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "dev": true, "dependencies": { - "agent-base": "^7.1.1", + "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" }, @@ -18207,13 +19182,10 @@ } }, "node_modules/socks-proxy-agent/node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "dev": true, - "dependencies": { - "debug": "^4.3.4" - }, "engines": { "node": ">= 14" } @@ -18270,9 +19242,9 @@ } }, "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", "dev": true, "dependencies": { "buffer-from": "^1.0.0", @@ -18289,9 +19261,9 @@ } }, "node_modules/spawnd": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/spawnd/-/spawnd-9.0.2.tgz", - "integrity": "sha512-nl8DVHEDQ57IcKakzpjanspVChkMpGLuVwMR/eOn9cXE55Qr6luD2Kn06sA0ootRMdgrU4tInN6lA6ohTNvysw==", + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/spawnd/-/spawnd-10.1.4.tgz", + "integrity": "sha512-drqHc0mKJmtMsiGMOCwzlc5eZ0RPtRvT7tQAluW2A0qUc0G7TQ8KLcn3E6K5qzkLkH2UkS3nYQiVGULvvsD9dw==", "dev": true, "dependencies": { "signal-exit": "^4.1.0", @@ -18461,22 +19433,10 @@ "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", "dev": true }, - "node_modules/stop-iteration-iterator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", - "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", - "dev": true, - "dependencies": { - "internal-slot": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/streamx": { - "version": "2.20.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.0.tgz", - "integrity": "sha512-ZGd1LhDeGFucr1CUCTBOS58ZhEendd0ttpGT3usTvosS4ntIwKN9LJFp+OeCSprsCPL14BXVRZlHGRY1V9PVzQ==", + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.21.1.tgz", + "integrity": "sha512-PhP9wUnFLa+91CPy3N6tiQsK+gnYyUNuk15S3YG/zjYE7RuPeCjJngqnzpC31ow0lzBHQ+QGO4cNJnd0djYUsw==", "dev": true, "dependencies": { "fast-fifo": "^1.3.2", @@ -18532,6 +19492,36 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -18548,13 +19538,17 @@ } }, "node_modules/string.prototype.includes": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.0.tgz", - "integrity": "sha512-E34CkBgyeqNDcrbU76cDjL5JLcVrtSdYq0MEh/B10r17pRP4ciHLwTgnuLV8Ay6cgEMLkcBkFCKyFZ43YldYzg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", "dev": true, "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/string.prototype.matchall": { @@ -18671,6 +19665,19 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -18757,9 +19764,9 @@ } }, "node_modules/stylelint": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.9.0.tgz", - "integrity": "sha512-31Nm3WjxGOBGpQqF43o3wO9L5AC36TPIe6030Lnm13H3vDMTcS21DrLh69bMX+DBilKqMMVLian4iG6ybBoNRQ==", + "version": "16.10.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.10.0.tgz", + "integrity": "sha512-z/8X2rZ52dt2c0stVwI9QL2AFJhLhbPkyfpDFcizs200V/g7v+UYY6SNcB9hKOLcDDX/yGLDsY/pX08sLkz9xQ==", "dev": true, "funding": [ { @@ -18780,17 +19787,17 @@ "balanced-match": "^2.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.0", - "css-functions-list": "^3.2.2", - "css-tree": "^2.3.1", - "debug": "^4.3.6", + "css-functions-list": "^3.2.3", + "css-tree": "^3.0.0", + "debug": "^4.3.7", "fast-glob": "^3.3.2", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^9.0.0", + "file-entry-cache": "^9.1.0", "global-modules": "^2.0.0", "globby": "^11.1.0", "globjoin": "^0.1.4", "html-tags": "^3.3.1", - "ignore": "^5.3.2", + "ignore": "^6.0.2", "imurmurhash": "^0.1.4", "is-plain-object": "^5.0.0", "known-css-properties": "^0.34.0", @@ -18799,14 +19806,13 @@ "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.0.1", - "postcss": "^8.4.41", + "postcss": "^8.4.47", "postcss-resolve-nested-selector": "^0.1.6", - "postcss-safe-parser": "^7.0.0", + "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^6.1.2", "postcss-value-parser": "^4.2.0", "resolve-from": "^5.0.0", "string-width": "^4.2.3", - "strip-ansi": "^7.1.0", "supports-hyperlinks": "^3.1.0", "svg-tags": "^1.0.0", "table": "^6.8.2", @@ -18865,14 +19871,15 @@ } }, "node_modules/stylelint-scss": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-6.7.0.tgz", - "integrity": "sha512-RFIa2A+pVWS5wjNT+whtK7wsbZEWazyqesCuSaPbPlZ8lh2TujwVJSnCYJijg6ChZzwI8pZPRZS1L6A9aCbXDg==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-6.8.1.tgz", + "integrity": "sha512-al+5eRb72bKrFyVAY+CLWKUMX+k+wsDCgyooSfhISJA2exqnJq1PX1iIIpdrvhu3GtJgNJZl9/BIW6EVSMCxdg==", "dev": true, "dependencies": { - "css-tree": "2.3.1", - "is-plain-object": "5.0.0", + "css-tree": "^3.0.0", + "is-plain-object": "^5.0.0", "known-css-properties": "^0.34.0", + "mdn-data": "^2.11.1", "postcss-media-query-parser": "^0.2.3", "postcss-resolve-nested-selector": "^0.1.6", "postcss-selector-parser": "^6.1.2", @@ -18885,18 +19892,31 @@ "stylelint": "^16.0.2" } }, - "node_modules/stylelint/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "node_modules/stylelint-scss/node_modules/css-tree": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.0.0.tgz", + "integrity": "sha512-o88DVQ6GzsABn1+6+zo2ct801dBO5OASVyxbbvA2W20ue2puSh/VOuqUj90eUeMSX/xqGqBmOKiRQN7tJOuBXw==", "dev": true, - "engines": { - "node": ">=12" + "dependencies": { + "mdn-data": "2.10.0", + "source-map-js": "^1.0.1" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/stylelint-scss/node_modules/css-tree/node_modules/mdn-data": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.10.0.tgz", + "integrity": "sha512-qq7C3EtK3yJXMwz1zAab65pjl+UhohqMOctTgcqjLOWABqmwj+me02LSsCuEUxnst9X1lCBpoE0WArGKgdGDzw==", + "dev": true + }, + "node_modules/stylelint-scss/node_modules/mdn-data": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.11.1.tgz", + "integrity": "sha512-Hdx3wmyqPFrhd6YHVuSkUK2eIGAcxR0xlndcgZqjA68yMJTbfXrjJwbgsBOsNjI7LnBIVUQnmyMVSdi/ob0GpQ==", + "dev": true + }, "node_modules/stylelint/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -18935,6 +19955,19 @@ } } }, + "node_modules/stylelint/node_modules/css-tree": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.0.0.tgz", + "integrity": "sha512-o88DVQ6GzsABn1+6+zo2ct801dBO5OASVyxbbvA2W20ue2puSh/VOuqUj90eUeMSX/xqGqBmOKiRQN7tJOuBXw==", + "dev": true, + "dependencies": { + "mdn-data": "2.10.0", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/stylelint/node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -19004,9 +20037,9 @@ } }, "node_modules/stylelint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-6.0.2.tgz", + "integrity": "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==", "dev": true, "engines": { "node": ">= 4" @@ -19033,6 +20066,12 @@ "node": ">=0.10.0" } }, + "node_modules/stylelint/node_modules/mdn-data": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.10.0.tgz", + "integrity": "sha512-qq7C3EtK3yJXMwz1zAab65pjl+UhohqMOctTgcqjLOWABqmwj+me02LSsCuEUxnst9X1lCBpoE0WArGKgdGDzw==", + "dev": true + }, "node_modules/stylelint/node_modules/meow": { "version": "13.2.0", "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", @@ -19063,21 +20102,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/stylelint/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/stylelint/node_modules/supports-hyperlinks": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.1.0.tgz", @@ -19157,9 +20181,9 @@ "dev": true }, "node_modules/svgo": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.2.0.tgz", - "integrity": "sha512-4PP6CMW/V7l/GmKRKzsLR8xxjdHTV4IMvhTnpuHwwBazSIlw5W/5SmPjN8Dwyt7lKbSJrRDgp4t9ph0HgChFBQ==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", + "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", "dev": true, "dependencies": { "@trysound/sax": "0.2.0", @@ -19327,9 +20351,9 @@ } }, "node_modules/terser": { - "version": "5.19.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.2.tgz", - "integrity": "sha512-qC5+dmecKJA4cpYxRa5aVkKehYsQKc+AHeKl0Oe62aYjBL8ZA33tTljktDHJSaxxMnbI5ZYw+o/S2DxxLu8OfA==", + "version": "5.36.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", + "integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==", "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", @@ -19345,16 +20369,16 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.9", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz", - "integrity": "sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==", + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", "dev": true, "dependencies": { - "@jridgewell/trace-mapping": "^0.3.17", + "@jridgewell/trace-mapping": "^0.3.20", "jest-worker": "^27.4.5", "schema-utils": "^3.1.1", "serialize-javascript": "^6.0.1", - "terser": "^5.16.8" + "terser": "^5.26.0" }, "engines": { "node": ">= 10.13.0" @@ -19384,6 +20408,25 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true }, + "node_modules/terser/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/terser/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -19776,11 +20819,11 @@ } }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, - "peer": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19821,9 +20864,9 @@ } }, "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", - "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", "dev": true, "engines": { "node": ">=4" @@ -19843,9 +20886,9 @@ } }, "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", - "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", + "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", "dev": true, "engines": { "node": ">=4" @@ -19909,9 +20952,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", - "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", "dev": true, "funding": [ { @@ -19928,8 +20971,8 @@ } ], "dependencies": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.0" }, "bin": { "update-browserslist-db": "cli.js" @@ -20097,13 +21140,13 @@ } }, "node_modules/wait-on": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", - "integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.1.tgz", + "integrity": "sha512-1wWQOyR2LVVtaqrcIL2+OM+x7bkpmzVROa0Nf6FryXkS+er5Sa1kzFGjzZRqLnHa3n1rACFLeTwUqE1ETL9Mig==", "dev": true, "dependencies": { - "axios": "^1.6.1", - "joi": "^17.11.0", + "axios": "^1.7.7", + "joi": "^17.13.3", "lodash": "^4.17.21", "minimist": "^1.2.8", "rxjs": "^7.8.1" @@ -20134,9 +21177,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", "dev": true, "dependencies": { "glob-to-regexp": "^0.4.1", @@ -20165,9 +21208,9 @@ } }, "node_modules/web-vitals": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.3.tgz", - "integrity": "sha512-/CFAm1mNxSmOj6i0Co+iGFJ58OS4NRGVP+AWS/l509uIK5a1bSoIVaHz/ZumpHTfHSZBpgrJ+wjfpAOrTHok5Q==" + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==" }, "node_modules/webidl-conversions": { "version": "7.0.0", @@ -20179,34 +21222,33 @@ } }, "node_modules/webpack": { - "version": "5.88.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.2.tgz", - "integrity": "sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ==", - "dev": true, - "dependencies": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^1.0.0", - "@webassemblyjs/ast": "^1.11.5", - "@webassemblyjs/wasm-edit": "^1.11.5", - "@webassemblyjs/wasm-parser": "^1.11.5", - "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", - "browserslist": "^4.14.5", + "version": "5.97.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", + "integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.6", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.14.0", + "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.15.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", + "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^3.2.0", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.7", - "watchpack": "^2.4.0", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, "bin": { @@ -20336,56 +21378,6 @@ "node": ">=14" } }, - "node_modules/webpack-cli/node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/webpack-cli/node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/webpack-cli/node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/webpack-cli/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/webpack-dev-middleware": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", @@ -20645,25 +21637,30 @@ } }, "node_modules/webpackbar": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-6.0.1.tgz", - "integrity": "sha512-TnErZpmuKdwWBdMoexjio3KKX6ZtoKHRVvLIU0A47R0VVBDtx3ZyOJDktgYixhoJokZTYTt1Z37OkO9pnGJa9Q==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-7.0.0.tgz", + "integrity": "sha512-aS9soqSO2iCHgqHoCrj4LbfGQUboDCYJPSFOAchEK+9psIjNrfSWW4Y0YEz67MKURNvMmfo0ycOg9d/+OOf9/Q==", "dev": true, "dependencies": { - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", + "ansis": "^3.2.0", "consola": "^3.2.3", - "figures": "^3.2.0", - "markdown-table": "^2.0.0", "pretty-time": "^1.1.0", - "std-env": "^3.7.0", - "wrap-ansi": "^7.0.0" + "std-env": "^3.7.0" }, "engines": { "node": ">=14.21.3" }, "peerDependencies": { + "@rspack/core": "*", "webpack": "3 || 4 || 5" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } } }, "node_modules/websocket-driver": { @@ -20864,6 +21861,24 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index edfa9eafd6..624ceae946 100644 --- a/package.json +++ b/package.json @@ -8,23 +8,25 @@ }, "dependencies": { "@builder.io/partytown": "github:westonruter/partytown#add/wp-i18n-workaround", - "web-vitals": "4.2.3" + "web-vitals": "4.2.4" }, "devDependencies": { "@octokit/rest": "^21.0.2", - "@wordpress/env": "^10.9.0", - "@wordpress/prettier-config": "^4.9.0", - "@wordpress/scripts": "^30.1.0", + "@wordpress/env": "^10.14.0", + "@wordpress/prettier-config": "^4.14.0", + "@wordpress/scripts": "^30.7.0", "commander": "12.1.0", "copy-webpack-plugin": "^12.0.2", + "css-minimizer-webpack-plugin": "^7.0.0", "fast-glob": "^3.3.2", "fs-extra": "^11.2.0", - "husky": "^9.1.6", - "lint-staged": "^15.2.10", + "husky": "^9.1.7", + "lint-staged": "^15.2.11", "lodash": "4.17.21", "micromatch": "^4.0.8", "npm-run-all": "^4.1.5", - "webpackbar": "^6.0.1" + "typescript": "^5.7.2", + "webpackbar": "^7.0.0" }, "scripts": { "changelog": "./bin/plugin/cli.js changelog", @@ -46,6 +48,7 @@ "generate-pending-release-diffs": "bin/generate-pending-release-diffs.sh", "format-js": "wp-scripts format", "lint-js": "wp-scripts lint-js", + "tsc": "tsc", "format-php": "composer format:all", "phpstan": "composer phpstan", "lint-php": "composer lint:all", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index c66cc423a5..5695724a95 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -10,7 +10,6 @@ parameters: - plugins/performance-lab/load.php bootstrapFiles: - tools/phpstan/constants.php - - plugins/performance-lab/load.php - plugins/webp-uploads/load.php scanDirectories: - vendor/wp-phpunit/wp-phpunit/ diff --git a/plugins/auto-sizes/auto-sizes.php b/plugins/auto-sizes/auto-sizes.php index 48dbe6a46d..dc549e485f 100644 --- a/plugins/auto-sizes/auto-sizes.php +++ b/plugins/auto-sizes/auto-sizes.php @@ -3,9 +3,9 @@ * Plugin Name: Enhanced Responsive Images * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/auto-sizes * Description: Improves responsive images with better sizes calculations and auto-sizes for lazy-loaded images. - * Requires at least: 6.5 + * Requires at least: 6.6 * Requires PHP: 7.2 - * Version: 1.3.0 + * Version: 1.4.0 * Author: WordPress Performance Team * Author URI: https://make.wordpress.org/performance/ * License: GPLv2 or later @@ -25,6 +25,8 @@ return; } -define( 'IMAGE_AUTO_SIZES_VERSION', '1.3.0' ); +define( 'IMAGE_AUTO_SIZES_VERSION', '1.4.0' ); +require_once __DIR__ . '/includes/auto-sizes.php'; +require_once __DIR__ . '/includes/improve-calculate-sizes.php'; require_once __DIR__ . '/hooks.php'; diff --git a/plugins/auto-sizes/hooks.php b/plugins/auto-sizes/hooks.php index b77c06903b..55cbe9dec2 100644 --- a/plugins/auto-sizes/hooks.php +++ b/plugins/auto-sizes/hooks.php @@ -10,102 +10,6 @@ exit; // Exit if accessed directly. } -/** - * Adds auto to the sizes attribute to the image, if applicable. - * - * @since 1.0.0 - * - * @param array|mixed $attr Attributes for the image markup. - * @return array The filtered attributes for the image markup. - */ -function auto_sizes_update_image_attributes( $attr ): array { - if ( ! is_array( $attr ) ) { - $attr = array(); - } - - // Bail early if the image is not lazy-loaded. - if ( ! isset( $attr['loading'] ) || 'lazy' !== $attr['loading'] ) { - return $attr; - } - - // Bail early if the image is not responsive. - if ( ! isset( $attr['sizes'] ) ) { - return $attr; - } - - // Don't add 'auto' to the sizes attribute if it already exists. - if ( auto_sizes_attribute_includes_valid_auto( $attr['sizes'] ) ) { - return $attr; - } - - $attr['sizes'] = 'auto, ' . $attr['sizes']; - - return $attr; -} - -/** - * Adds auto to the sizes attribute to the image, if applicable. - * - * @since 1.0.0 - * - * @param string|mixed $html The HTML image tag markup being filtered. - * @return string The filtered HTML image tag markup. - */ -function auto_sizes_update_content_img_tag( $html ): string { - if ( ! is_string( $html ) ) { - $html = ''; - } - - $processor = new WP_HTML_Tag_Processor( $html ); - - // Bail if there is no IMG tag. - if ( ! $processor->next_tag( array( 'tag_name' => 'IMG' ) ) ) { - return $html; - } - - // Bail early if the image is not lazy-loaded. - $value = $processor->get_attribute( 'loading' ); - if ( ! is_string( $value ) || 'lazy' !== strtolower( trim( $value, " \t\f\r\n" ) ) ) { - return $html; - } - - $sizes = $processor->get_attribute( 'sizes' ); - - // Bail early if the image is not responsive. - if ( ! is_string( $sizes ) ) { - return $html; - } - - // Don't add 'auto' to the sizes attribute if it already exists. - if ( auto_sizes_attribute_includes_valid_auto( $sizes ) ) { - return $html; - } - - $processor->set_attribute( 'sizes', "auto, $sizes" ); - return $processor->get_updated_html(); -} - -// Skip loading plugin filters if WordPress Core already loaded the functionality. -if ( ! function_exists( 'wp_sizes_attribute_includes_valid_auto' ) ) { - add_filter( 'wp_get_attachment_image_attributes', 'auto_sizes_update_image_attributes' ); - add_filter( 'wp_content_img_tag', 'auto_sizes_update_content_img_tag' ); -} - -/** - * Checks whether the given 'sizes' attribute includes the 'auto' keyword as the first item in the list. - * - * Per the HTML spec, if present it must be the first entry. - * - * @since 1.2.0 - * - * @param string $sizes_attr The 'sizes' attribute value. - * @return bool True if the 'auto' keyword is present, false otherwise. - */ -function auto_sizes_attribute_includes_valid_auto( string $sizes_attr ): bool { - list( $first_size ) = explode( ',', $sizes_attr, 2 ); - return 'auto' === strtolower( trim( $first_size, " \t\f\r\n" ) ); -} - /** * Displays the HTML generator tag for the plugin. * @@ -120,135 +24,19 @@ function auto_sizes_render_generator(): void { add_action( 'wp_head', 'auto_sizes_render_generator' ); /** - * Gets the smaller image size if the layout width is bigger. - * - * It will return the smaller image size and return "px" if the layout width - * is something else, e.g. min(640px, 90vw) or 90vw. - * - * @since 1.1.0 - * - * @param string $layout_width The layout width. - * @param int $image_width The image width. - * @return string The proper width after some calculations. + * Filters related to the auto-sizes functionality. */ -function auto_sizes_get_width( string $layout_width, int $image_width ): string { - if ( str_ends_with( $layout_width, 'px' ) ) { - return $image_width > (int) $layout_width ? $layout_width : $image_width . 'px'; - } - return $image_width . 'px'; -} - -/** - * Filter the sizes attribute for images to improve the default calculation. - * - * @since 1.1.0 - * - * @param string $content The block content about to be rendered. - * @param array{ attrs?: array{ align?: string, width?: string } } $parsed_block The parsed block. - * @return string The updated block content. - */ -function auto_sizes_filter_image_tag( string $content, array $parsed_block ): string { - $processor = new WP_HTML_Tag_Processor( $content ); - $has_image = $processor->next_tag( array( 'tag_name' => 'img' ) ); - - // Only update the markup if an image is found. - if ( $has_image ) { - $processor->set_attribute( 'data-needs-sizes-update', true ); - if ( isset( $parsed_block['attrs']['align'] ) ) { - $processor->set_attribute( 'data-align', $parsed_block['attrs']['align'] ); - } - - // Resize image width. - if ( isset( $parsed_block['attrs']['width'] ) ) { - $processor->set_attribute( 'data-resize-width', $parsed_block['attrs']['width'] ); - } - - $content = $processor->get_updated_html(); - } - return $content; +// Skip loading plugin filters if WordPress Core already loaded the functionality. +if ( ! function_exists( 'wp_img_tag_add_auto_sizes' ) ) { + add_filter( 'wp_get_attachment_image_attributes', 'auto_sizes_update_image_attributes' ); + add_filter( 'wp_content_img_tag', 'auto_sizes_update_content_img_tag' ); } -add_filter( 'render_block_core/image', 'auto_sizes_filter_image_tag', 10, 2 ); -add_filter( 'render_block_core/cover', 'auto_sizes_filter_image_tag', 10, 2 ); /** - * Filter the sizes attribute for images to improve the default calculation. - * - * @since 1.1.0 - * - * @param string $content The block content about to be rendered. - * @return string The updated block content. + * Filters related to the improved image sizes functionality. */ -function auto_sizes_improve_image_sizes_attributes( string $content ): string { - $processor = new WP_HTML_Tag_Processor( $content ); - if ( ! $processor->next_tag( array( 'tag_name' => 'img' ) ) ) { - return $content; - } - - $remove_data_attributes = static function () use ( $processor ): void { - $processor->remove_attribute( 'data-needs-sizes-update' ); - $processor->remove_attribute( 'data-align' ); - $processor->remove_attribute( 'data-resize-width' ); - }; - - // Bail early if the responsive images are disabled. - if ( null === $processor->get_attribute( 'sizes' ) ) { - $remove_data_attributes(); - return $processor->get_updated_html(); - } - - // Skips second time parsing if already processed. - if ( null === $processor->get_attribute( 'data-needs-sizes-update' ) ) { - return $content; - } - - $align = $processor->get_attribute( 'data-align' ); - - // Retrieve width from the image tag itself. - $image_width = $processor->get_attribute( 'width' ); - if ( ! is_string( $image_width ) && ! in_array( $align, array( 'full', 'wide' ), true ) ) { - return $content; - } - - $layout = wp_get_global_settings( array( 'layout' ) ); - - $sizes = null; - // Handle different alignment use cases. - switch ( $align ) { - case 'full': - $sizes = '100vw'; - break; - - case 'wide': - if ( array_key_exists( 'wideSize', $layout ) ) { - $sizes = sprintf( '(max-width: %1$s) 100vw, %1$s', $layout['wideSize'] ); - } - break; - - case 'left': - case 'right': - case 'center': - // Resize image width. - $image_width = $processor->get_attribute( 'data-resize-width' ) ?? $image_width; - $sizes = sprintf( '(max-width: %1$dpx) 100vw, %1$dpx', $image_width ); - break; - - default: - if ( array_key_exists( 'contentSize', $layout ) ) { - // Resize image width. - $image_width = $processor->get_attribute( 'data-resize-width' ) ?? $image_width; - $width = auto_sizes_get_width( $layout['contentSize'], (int) $image_width ); - $sizes = sprintf( '(max-width: %1$s) 100vw, %1$s', $width ); - } - break; - } - - if ( is_string( $sizes ) ) { - $processor->set_attribute( 'sizes', $sizes ); - } - - $remove_data_attributes(); - - return $processor->get_updated_html(); -} -// Run filter prior to auto sizes "auto_sizes_update_content_img_tag" filter. -add_filter( 'wp_content_img_tag', 'auto_sizes_improve_image_sizes_attributes', 9 ); +add_filter( 'the_content', 'auto_sizes_prime_attachment_caches', 9 ); // This must run before 'do_blocks', which runs at priority 9. +add_filter( 'render_block_core/image', 'auto_sizes_filter_image_tag', 10, 3 ); +add_filter( 'render_block_core/cover', 'auto_sizes_filter_image_tag', 10, 3 ); +add_filter( 'get_block_type_uses_context', 'auto_sizes_filter_uses_context', 10, 2 ); +add_filter( 'render_block_context', 'auto_sizes_filter_render_block_context', 10, 2 ); diff --git a/plugins/auto-sizes/includes/auto-sizes.php b/plugins/auto-sizes/includes/auto-sizes.php new file mode 100644 index 0000000000..02ecad68be --- /dev/null +++ b/plugins/auto-sizes/includes/auto-sizes.php @@ -0,0 +1,97 @@ +|mixed $attr Attributes for the image markup. + * @return array The filtered attributes for the image markup. + */ +function auto_sizes_update_image_attributes( $attr ): array { + if ( ! is_array( $attr ) ) { + $attr = array(); + } + + // Bail early if the image is not lazy-loaded. + if ( ! isset( $attr['loading'] ) || 'lazy' !== $attr['loading'] ) { + return $attr; + } + + // Bail early if the image is not responsive. + if ( ! isset( $attr['sizes'] ) ) { + return $attr; + } + + // Don't add 'auto' to the sizes attribute if it already exists. + if ( auto_sizes_attribute_includes_valid_auto( $attr['sizes'] ) ) { + return $attr; + } + + $attr['sizes'] = 'auto, ' . $attr['sizes']; + + return $attr; +} + +/** + * Adds auto to the sizes attribute to the image, if applicable. + * + * @since 1.0.0 + * + * @param string|mixed $html The HTML image tag markup being filtered. + * @return string The filtered HTML image tag markup. + */ +function auto_sizes_update_content_img_tag( $html ): string { + if ( ! is_string( $html ) ) { + $html = ''; + } + + $processor = new WP_HTML_Tag_Processor( $html ); + + // Bail if there is no IMG tag. + if ( ! $processor->next_tag( array( 'tag_name' => 'IMG' ) ) ) { + return $html; + } + + // Bail early if the image is not lazy-loaded. + $value = $processor->get_attribute( 'loading' ); + if ( ! is_string( $value ) || 'lazy' !== strtolower( trim( $value, " \t\f\r\n" ) ) ) { + return $html; + } + + $sizes = $processor->get_attribute( 'sizes' ); + + // Bail early if the image is not responsive. + if ( ! is_string( $sizes ) ) { + return $html; + } + + // Don't add 'auto' to the sizes attribute if it already exists. + if ( auto_sizes_attribute_includes_valid_auto( $sizes ) ) { + return $html; + } + + $processor->set_attribute( 'sizes', "auto, $sizes" ); + return $processor->get_updated_html(); +} + +/** + * Checks whether the given 'sizes' attribute includes the 'auto' keyword as the first item in the list. + * + * Per the HTML spec, if present it must be the first entry. + * + * @since 1.2.0 + * + * @param string $sizes_attr The 'sizes' attribute value. + * @return bool True if the 'auto' keyword is present, false otherwise. + */ +function auto_sizes_attribute_includes_valid_auto( string $sizes_attr ): bool { + list( $first_size ) = explode( ',', $sizes_attr, 2 ); + return 'auto' === strtolower( trim( $first_size, " \t\f\r\n" ) ); +} diff --git a/plugins/auto-sizes/includes/improve-calculate-sizes.php b/plugins/auto-sizes/includes/improve-calculate-sizes.php new file mode 100644 index 0000000000..7b57f08e36 --- /dev/null +++ b/plugins/auto-sizes/includes/improve-calculate-sizes.php @@ -0,0 +1,302 @@ +next_tag( array( 'tag_name' => 'IMG' ) ) ) { + $class = $processor->get_attribute( 'class' ); + + if ( ! is_string( $class ) ) { + continue; + } + + if ( preg_match( '/(?:^|\s)wp-image-([1-9][0-9]*)(?:\s|$)/', $class, $class_id ) === 1 ) { + $attachment_id = (int) $class_id[1]; + if ( $attachment_id > 0 ) { + $images[] = $attachment_id; + } + } + } + + // Reduce the array to unique attachment IDs. + $attachment_ids = array_unique( $images ); + + if ( count( $attachment_ids ) > 1 ) { + /* + * Warm the object cache with post and meta information for all found + * images to avoid making individual database calls. + */ + _prime_post_caches( $attachment_ids, false, true ); + } + + return $content; +} + +/** + * Filter the sizes attribute for images to improve the default calculation. + * + * @since 1.1.0 + * + * @param string|mixed $content The block content about to be rendered. + * @param array{ attrs?: array{ align?: string, width?: string } } $parsed_block The parsed block. + * @param WP_Block $block Block instance. + * @return string The updated block content. + */ +function auto_sizes_filter_image_tag( $content, array $parsed_block, WP_Block $block ): string { + if ( ! is_string( $content ) ) { + return ''; + } + + $processor = new WP_HTML_Tag_Processor( $content ); + $has_image = $processor->next_tag( array( 'tag_name' => 'IMG' ) ); + + // Only update the markup if an image is found. + if ( $has_image ) { + + /** + * Callback for calculating image sizes attribute value for an image block. + * + * This is a workaround to use block context data when calculating the img sizes attribute. + * + * @param string $sizes The image sizes attribute value. + * @param string $size The image size data. + */ + $filter = static function ( $sizes, $size ) use ( $block ) { + + $id = isset( $block->attributes['id'] ) ? (int) $block->attributes['id'] : 0; + $alignment = $block->attributes['align'] ?? ''; + $width = isset( $block->attributes['width'] ) ? (int) $block->attributes['width'] : 0; + $max_alignment = $block->context['max_alignment'] ?? ''; + + /* + * Update width for cover block. + * See https://github.com/WordPress/gutenberg/blob/938720602082dc50a1746bd2e33faa3d3a6096d4/packages/block-library/src/cover/style.scss#L82-L87. + */ + if ( 'core/cover' === $block->name && in_array( $alignment, array( 'left', 'right' ), true ) ) { + $size = array( 420, 420 ); + } + + $better_sizes = auto_sizes_calculate_better_sizes( $id, $size, $alignment, $width, $max_alignment ); + + // If better sizes can't be calculated, use the default sizes. + return false !== $better_sizes ? $better_sizes : $sizes; + }; + + // Hook this filter early, before default filters are run. + add_filter( 'wp_calculate_image_sizes', $filter, 9, 2 ); + + $sizes = wp_calculate_image_sizes( + // If we don't have a size slug, assume the full size was used. + $parsed_block['attrs']['sizeSlug'] ?? 'full', + null, + null, + $parsed_block['attrs']['id'] ?? 0 + ); + + remove_filter( 'wp_calculate_image_sizes', $filter, 9 ); + + // Bail early if sizes are not calculated. + if ( false === $sizes ) { + return $content; + } + + $processor->set_attribute( 'sizes', $sizes ); + + return $processor->get_updated_html(); + } + + return $content; +} + +/** + * Modifies the sizes attribute of an image based on layout context. + * + * @since 1.4.0 + * + * @param int $id The image attachment post ID. + * @param string|array{int, int} $size Image size name or array of width and height. + * @param string $align The image alignment. + * @param int $resize_width Resize image width. + * @param string $max_alignment The maximum usable layout alignment. + * @return string|false An improved sizes attribute or false if a better size cannot be calculated. + */ +function auto_sizes_calculate_better_sizes( int $id, $size, string $align, int $resize_width, string $max_alignment ) { + // Bail early if not a block theme. + if ( ! wp_is_block_theme() ) { + return false; + } + + // Without an image ID or a resize width, we cannot calculate a better size. + if ( 0 === $id && 0 === $resize_width ) { + return false; + } + + $image_data = wp_get_attachment_image_src( $id, $size ); + + $image_width = false !== $image_data ? $image_data[1] : 0; + + // If we don't have an image width or a resize width, we cannot calculate a better size. + if ( 0 === $image_width && 0 === $resize_width ) { + return false; + } + + /* + * If we don't have an image width, use the resize width. + * If we have both an image width and a resize width, use the smaller of the two. + */ + if ( 0 === $image_width ) { + $image_width = $resize_width; + } elseif ( 0 !== $resize_width ) { + $image_width = min( $image_width, $resize_width ); + } + + // Normalize default alignment values. + $align = '' !== $align ? $align : 'default'; + + /* + * Map alignment values to a weighting value so they can be compared. + * Note that 'left' and 'right' alignments are only constrained by max alignment. + */ + $constraints = array( + 'full' => 0, + 'wide' => 1, + 'left' => 2, + 'right' => 2, + 'default' => 3, + 'center' => 3, + ); + + $alignment = $constraints[ $align ] > $constraints[ $max_alignment ] ? $align : $max_alignment; + + // Handle different alignment use cases. + switch ( $alignment ) { + case 'full': + $layout_width = auto_sizes_get_layout_width( 'full' ); + break; + + case 'wide': + $layout_width = auto_sizes_get_layout_width( 'wide' ); + break; + + case 'left': + case 'right': + case 'center': + default: + $layout_alignment = in_array( $alignment, array( 'left', 'right' ), true ) ? 'wide' : 'default'; + $layout_width = auto_sizes_get_layout_width( $layout_alignment ); + + /* + * If the layout width is in pixels, we can compare against the image width + * on the server. Otherwise, we need to rely on CSS functions. + */ + if ( str_ends_with( $layout_width, 'px' ) ) { + $layout_width = sprintf( '%dpx', min( (int) $layout_width, $image_width ) ); + } else { + $layout_width = sprintf( 'min(%1$s, %2$spx)', $layout_width, $image_width ); + } + + break; + } + + // Format layout width when not 'full'. + if ( 'full' !== $alignment ) { + $layout_width = sprintf( '(max-width: %1$s) 100vw, %1$s', $layout_width ); + } + + return $layout_width; +} + +/** + * Retrieves the layout width for an alignment defined in theme.json. + * + * @since 1.4.0 + * + * @param string $alignment The alignment value. + * @return string The alignment width based. + */ +function auto_sizes_get_layout_width( string $alignment ): string { + $layout = wp_get_global_settings( array( 'layout' ) ); + + $layout_widths = array( + 'full' => '100vw', // Todo: incorporate useRootPaddingAwareAlignments. + 'wide' => array_key_exists( 'wideSize', $layout ) ? $layout['wideSize'] : '', + 'default' => array_key_exists( 'contentSize', $layout ) ? $layout['contentSize'] : '', + ); + + return $layout_widths[ $alignment ] ?? ''; +} + +/** + * Filters the context keys that a block type uses. + * + * @since 1.4.0 + * + * @param string[] $uses_context Array of registered uses context for a block type. + * @param WP_Block_Type $block_type The full block type object. + * @return string[] The filtered context keys used by the block type. + */ +function auto_sizes_filter_uses_context( array $uses_context, WP_Block_Type $block_type ): array { + // The list of blocks that can consume outer layout context. + $consumer_blocks = array( + 'core/cover', + 'core/image', + ); + + if ( in_array( $block_type->name, $consumer_blocks, true ) ) { + // Use array_values to reset the array keys after merging. + return array_values( array_unique( array_merge( $uses_context, array( 'max_alignment' ) ) ) ); + } + return $uses_context; +} + +/** + * Modifies the block context during rendering to blocks. + * + * @since 1.4.0 + * + * @param array $context Current block context. + * @param array $block The block being rendered. + * @return array Modified block context. + */ +function auto_sizes_filter_render_block_context( array $context, array $block ): array { + // When no max alignment is set, the maximum is assumed to be 'full'. + $context['max_alignment'] = $context['max_alignment'] ?? 'full'; + + // The list of blocks that can modify outer layout context. + $provider_blocks = array( + 'core/columns', + 'core/group', + ); + + if ( in_array( $block['blockName'], $provider_blocks, true ) ) { + $alignment = $block['attrs']['align'] ?? ''; + + // If the container block doesn't have alignment, it's assumed to be 'default'. + if ( '' === $alignment ) { + $context['max_alignment'] = 'default'; + } elseif ( 'wide' === $alignment ) { + $context['max_alignment'] = 'wide'; + } + } + + return $context; +} diff --git a/plugins/auto-sizes/readme.txt b/plugins/auto-sizes/readme.txt index 582d84eded..73a53923f5 100644 --- a/plugins/auto-sizes/readme.txt +++ b/plugins/auto-sizes/readme.txt @@ -1,8 +1,8 @@ === Enhanced Responsive Images === Contributors: wordpressdotorg -Tested up to: 6.6 -Stable tag: 1.3.0 +Tested up to: 6.7 +Stable tag: 1.4.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, images, auto-sizes @@ -52,6 +52,23 @@ Contributions are always welcome! Learn more about how to get involved in the [C == Changelog == += 1.4.0 = + +**Features** + +* Accurate Sizes: Incorporate layout constraints in image sizes calculations. ([1738](https://github.com/WordPress/performance/pull/1738)) + +**Enhancements** + +* Accurate sizes: Pass parent alignment context to images. ([1701](https://github.com/WordPress/performance/pull/1701)) +* Accurate sizes: Reorganize file structure by feature. ([1699](https://github.com/WordPress/performance/pull/1699)) +* Accurate sizes: Support relative alignment widths. ([1737](https://github.com/WordPress/performance/pull/1737)) +* Remove `auto_sizes_get_layout_settings()`. ([1743](https://github.com/WordPress/performance/pull/1743)) + +**Bug Fixes** + +* Accurate sizes: Disable layout calculations for classic themes. ([1744](https://github.com/WordPress/performance/pull/1744)) + = 1.3.0 = **Enhancements** diff --git a/plugins/auto-sizes/tests/test-improve-sizes.php b/plugins/auto-sizes/tests/test-improve-calculate-sizes.php similarity index 54% rename from plugins/auto-sizes/tests/test-improve-sizes.php rename to plugins/auto-sizes/tests/test-improve-calculate-sizes.php index 943de98381..693161ee92 100644 --- a/plugins/auto-sizes/tests/test-improve-sizes.php +++ b/plugins/auto-sizes/tests/test-improve-calculate-sizes.php @@ -3,10 +3,10 @@ * Tests for the improve sizes for Images. * * @package auto-sizes - * @group improve-sizes + * @group improve-calculate-sizes */ -class Tests_Improve_Sizes extends WP_UnitTestCase { +class Tests_Improve_Calculate_Sizes extends WP_UnitTestCase { /** * Attachment ID. @@ -31,12 +31,16 @@ public function set_up(): void { // Disable auto sizes. remove_filter( 'wp_content_img_tag', 'auto_sizes_update_content_img_tag' ); + + // Disable lazy loading attribute. + add_filter( 'wp_img_tag_add_loading_attr', '__return_false' ); + + // Run each test with fresh WP_Theme_JSON data so we can filter layout values. + wp_clean_theme_json_cache(); } /** * Test that if disable responsive image then it will not add sizes attribute. - * - * @covers ::auto_sizes_improve_image_sizes_attributes */ public function test_that_if_disable_responsive_image_then_it_will_not_add_sizes_attribute(): void { // Disable responsive images. @@ -61,7 +65,7 @@ public function test_that_if_disable_responsive_image_then_it_will_not_add_sizes * @param string $image_size Image size. */ public function test_image_block_with_full_alignment( string $image_size ): void { - $block_content = '
'; + $block_content = $this->get_image_block_markup( self::$image_id, $image_size, 'full' ); $result = apply_filters( 'the_content', $block_content ); @@ -92,7 +96,7 @@ public function test_cover_block_with_full_alignment(): void { * @param string $image_size Image size. */ public function test_image_block_with_wide_alignment( string $image_size ): void { - $block_content = '
'; + $block_content = $this->get_image_block_markup( self::$image_id, $image_size, 'wide' ); $result = apply_filters( 'the_content', $block_content ); @@ -272,14 +276,14 @@ public function data_image_sizes_for_left_right_center_alignment(): array { 'sizes="(max-width: 300px) 100vw, 300px" ', 'center', ), - 'Return large image size 1024px with center alignment' => array( + 'Return large image size 620px with center alignment' => array( 'large', - 'sizes="(max-width: 1024px) 100vw, 1024px" ', + 'sizes="(max-width: 620px) 100vw, 620px" ', 'center', ), - 'Return full image size 1080px with center alignment' => array( + 'Return full image size 620px with center alignment' => array( 'full', - 'sizes="(max-width: 1080px) 100vw, 1080px" ', + 'sizes="(max-width: 620px) 100vw, 620px" ', 'center', ), 'Return resized size 100px instead of medium image size 300px with left alignment' => array( @@ -340,13 +344,14 @@ public function data_image_sizes_for_left_right_center_alignment(): array { } /** - * Test the cover block with left and right alignment. + * Test the cover block with left, right and center alignment. * * @dataProvider data_image_left_right_center_alignment * * @param string $alignment Alignment of the image. + * @param string $expected Expected output. */ - public function test_cover_block_with_left_right_center_alignment( string $alignment ): void { + public function test_cover_block_with_left_right_center_alignment( string $alignment, string $expected ): void { $image_url = wp_get_attachment_image_url( self::$image_id, 'full' ); $block_content = '
@@ -356,7 +361,7 @@ public function test_cover_block_with_left_right_center_alignment( string $align $result = apply_filters( 'the_content', $block_content ); - $this->assertStringContainsString( 'sizes="(max-width: 1080px) 100vw, 1080px" ', $result ); + $this->assertStringContainsString( $expected, $result ); } /** @@ -366,9 +371,9 @@ public function test_cover_block_with_left_right_center_alignment( string $align */ public function data_image_left_right_center_alignment(): array { return array( - array( 'left' ), - array( 'right' ), - array( 'center' ), + array( 'left', 'sizes="(max-width: 420px) 100vw, 420px' ), + array( 'right', 'sizes="(max-width: 420px) 100vw, 420px' ), + array( 'center', 'sizes="(max-width: 620px) 100vw, 620px' ), ); } @@ -384,4 +389,329 @@ public function test_no_image(): void { $this->assertStringContainsString( '

No image here

', $result ); } + + /** + * Test that the layout property of a group block is passed by context to the image block. + * + * @dataProvider data_ancestor_and_image_block_alignment + * + * @param string $ancestor_block_alignment Ancestor block alignment. + * @param string $image_block_alignment Image block alignment. + * @param string $expected Expected output. + */ + public function test_ancestor_layout_is_passed_by_context( string $ancestor_block_alignment, string $image_block_alignment, string $expected ): void { + $block_content = $this->get_group_block_markup( + $this->get_image_block_markup( self::$image_id, 'large', $image_block_alignment ), + array( + 'align' => $ancestor_block_alignment, + ) + ); + + $result = apply_filters( 'the_content', $block_content ); + + $this->assertStringContainsString( $expected, $result ); + } + + /** + * Data provider. + * + * @return array> The ancestor and image alignments. + */ + public function data_ancestor_and_image_block_alignment(): array { + return array( + // Parent default alignment. + 'Return contentSize 620px, parent block default alignment, image block default alignment' => array( + '', + '', + 'sizes="(max-width: 620px) 100vw, 620px" ', + ), + 'Return contentSize 620px, parent block default alignment, image block wide alignment' => array( + '', + 'wide', + 'sizes="(max-width: 620px) 100vw, 620px" ', + ), + 'Return contentSize 620px, parent block default alignment, image block full alignment' => array( + '', + 'full', + 'sizes="(max-width: 620px) 100vw, 620px" ', + ), + 'Return contentSize 620px, parent block default alignment, image block left alignment' => array( + '', + 'left', + 'sizes="(max-width: 620px) 100vw, 620px" ', + ), + 'Return contentSize 620px, parent block default alignment, image block center alignment' => array( + '', + 'center', + 'sizes="(max-width: 620px) 100vw, 620px" ', + ), + 'Return contentSize 620px, parent block default alignment, image block right alignment' => array( + '', + 'right', + 'sizes="(max-width: 620px) 100vw, 620px" ', + ), + + // Parent wide alignment. + 'Return contentSize 620px, parent block wide alignment, image block default alignment' => array( + 'wide', + '', + 'sizes="(max-width: 620px) 100vw, 620px" ', + ), + 'Return wideSize 1280px, parent block wide alignment, image block wide alignment' => array( + 'wide', + 'wide', + 'sizes="(max-width: 1280px) 100vw, 1280px" ', + ), + 'Return wideSize 1280px, parent block wide alignment, image block full alignment' => array( + 'wide', + 'full', + 'sizes="(max-width: 1280px) 100vw, 1280px" ', + ), + 'Return image size 1024px, parent block wide alignment, image block left alignment' => array( + 'wide', + 'left', + 'sizes="(max-width: 1024px) 100vw, 1024px" ', + ), + 'Return image size 620px, parent block wide alignment, image block center alignment' => array( + 'wide', + 'center', + 'sizes="(max-width: 620px) 100vw, 620px" ', + ), + 'Return image size 1024px, parent block wide alignment, image block right alignment' => array( + 'wide', + 'right', + 'sizes="(max-width: 1024px) 100vw, 1024px" ', + ), + + // Parent full alignment. + 'Return contentSize 620px, parent block full alignment, image block default alignment' => array( + 'full', + '', + 'sizes="(max-width: 620px) 100vw, 620px" ', + ), + 'Return wideSize 1280px, parent block full alignment, image block wide alignment' => array( + 'full', + 'wide', + 'sizes="(max-width: 1280px) 100vw, 1280px" ', + ), + 'Return full size, parent block full alignment, image block full alignment' => array( + 'full', + 'full', + 'sizes="100vw" ', + ), + 'Return image size 1024px, parent block full alignment, image block left alignment' => array( + 'full', + 'left', + 'sizes="(max-width: 1024px) 100vw, 1024px" ', + ), + 'Return image size 620px, parent block full alignment, image block center alignment' => array( + 'full', + 'center', + 'sizes="(max-width: 620px) 100vw, 620px" ', + ), + 'Return image size 1024px, parent block full alignment, image block right alignment' => array( + 'full', + 'right', + 'sizes="(max-width: 1024px) 100vw, 1024px" ', + ), + ); + } + + /** + * Test sizes attributes when alignments use relative units. + * + * @dataProvider data_image_blocks_with_relative_alignment + * + * @param string $ancestor_alignment Ancestor alignment. + * @param string $image_alignment Image alignment. + * @param string $expected Expected output. + */ + public function test_sizes_with_relative_layout_sizes( string $ancestor_alignment, string $image_alignment, string $expected ): void { + add_filter( 'wp_theme_json_data_user', array( $this, 'filter_theme_json_layout_sizes' ) ); + + $block_content = $this->get_group_block_markup( + $this->get_image_block_markup( self::$image_id, 'large', $image_alignment ), + array( + 'align' => $ancestor_alignment, + ) + ); + + $result = apply_filters( 'the_content', $block_content ); + + $this->assertStringContainsString( $expected, $result ); + } + + /** + * Data provider. + * + * @return array> The ancestor and image alignments. + */ + public function data_image_blocks_with_relative_alignment(): array { + return array( + // Parent default alignment. + 'Return contentSize 50vw, parent block default alignment, image block default alignment' => array( + '', + '', + 'sizes="(max-width: min(50vw, 1024px)) 100vw, min(50vw, 1024px)" ', + ), + 'Return contentSize 50vw, parent block default alignment, image block wide alignment' => array( + '', + 'wide', + 'sizes="(max-width: min(50vw, 1024px)) 100vw, min(50vw, 1024px)" ', + ), + 'Return contentSize 50vw, parent block default alignment, image block full alignment' => array( + '', + 'full', + 'sizes="(max-width: min(50vw, 1024px)) 100vw, min(50vw, 1024px)" ', + ), + 'Return contentSize 50vw, parent block default alignment, image block left alignment' => array( + '', + 'left', + 'sizes="(max-width: min(50vw, 1024px)) 100vw, min(50vw, 1024px)" ', + ), + 'Return contentSize 50vw, parent block default alignment, image block center alignment' => array( + '', + 'center', + 'sizes="(max-width: min(50vw, 1024px)) 100vw, min(50vw, 1024px)" ', + ), + 'Return contentSize 50vw, parent block default alignment, image block right alignment' => array( + '', + 'right', + 'sizes="(max-width: min(50vw, 1024px)) 100vw, min(50vw, 1024px)" ', + ), + + // Parent wide alignment. + 'Return contentSize 50vw, parent block wide alignment, image block default alignment' => array( + 'wide', + '', + 'sizes="(max-width: min(50vw, 1024px)) 100vw, min(50vw, 1024px)" ', + ), + 'Return wideSize 75vw, parent block wide alignment, image block wide alignment' => array( + 'wide', + 'wide', + 'sizes="(max-width: 75vw) 100vw, 75vw" ', + ), + 'Return wideSize 75vw, parent block wide alignment, image block full alignment' => array( + 'wide', + 'full', + 'sizes="(max-width: 75vw) 100vw, 75vw" ', + ), + 'Return image size 1024px, parent block wide alignment, image block left alignment' => array( + 'wide', + 'left', + 'sizes="(max-width: min(75vw, 1024px)) 100vw, min(75vw, 1024px)" ', + ), + 'Return image size 620px, parent block wide alignment, image block center alignment' => array( + 'wide', + 'center', + 'sizes="(max-width: min(50vw, 1024px)) 100vw, min(50vw, 1024px)" ', + ), + 'Return image size 1024px, parent block wide alignment, image block right alignment' => array( + 'wide', + 'right', + 'sizes="(max-width: min(75vw, 1024px)) 100vw, min(75vw, 1024px)" ', + ), + ); + } + + /** + * Test the image block with different alignment in classic theme. + * + * @dataProvider data_image_blocks_with_relative_alignment_for_classic_theme + * + * @param string $image_alignment Image alignment. + */ + public function test_image_block_with_different_alignment_in_classic_theme( string $image_alignment ): void { + switch_theme( 'twentytwentyone' ); + + $block_content = $this->get_image_block_markup( self::$image_id, 'large', $image_alignment ); + + $result = apply_filters( 'the_content', $block_content ); + + $this->assertStringContainsString( 'sizes="(max-width: 1024px) 100vw, 1024px" ', $result ); + } + + /** + * Data provider. + * + * @return array> The ancestor and image alignments. + */ + public function data_image_blocks_with_relative_alignment_for_classic_theme(): array { + return array( + array( '' ), + array( 'wide' ), + array( 'left' ), + array( 'center' ), + array( 'right' ), + ); + } + + /** + * Filter the theme.json data to include relative layout sizes. + * + * @param WP_Theme_JSON_Data $theme_json Theme JSON object. + * @return WP_Theme_JSON_Data Updated theme JSON object. + */ + public function filter_theme_json_layout_sizes( WP_Theme_JSON_Data $theme_json ): WP_Theme_JSON_Data { + $data = array( + 'version' => 2, + 'settings' => array( + 'layout' => array( + 'contentSize' => '50vw', + 'wideSize' => '75vw', + ), + ), + ); + + $theme_json = $theme_json->update_with( $data ); + + return $theme_json; + } + + /** + * Helper to generate image block markup. + * + * @param int $attachment_id Attachment ID. + * @param string $size Optional. Image size. Default 'full'. + * @param string $align Optional. Image alignment. Default null. + * @return string Image block markup. + */ + public function get_image_block_markup( int $attachment_id, string $size = 'full', string $align = null ): string { + $image_url = wp_get_attachment_image_url( $attachment_id, $size ); + + $atts = array( + 'id' => $attachment_id, + 'sizeSlug' => $size, + 'align' => $align, + 'linkDestination' => 'none', + ); + + $align_class = null !== $align ? ' align' . $align : ''; + + return '
'; + } + + /** + * Helper to generate group block markup. + * + * @param string $content Block content. + * @param array $atts Optional. Block attributes. Default empty array. + * @return string Group block markup. + */ + public function get_group_block_markup( string $content, array $atts = array() ): string { + $atts = wp_parse_args( + $atts, + array( + 'layout' => array( + 'type' => 'constrained', + ), + ) + ); + + $align_class = (bool) $atts['align'] ? ' align' . $atts['align'] : ''; + + return ' +
' . $content . '
+ '; + } } diff --git a/plugins/dominant-color-images/hooks.php b/plugins/dominant-color-images/hooks.php index 71c304a98d..40ccf1c672 100644 --- a/plugins/dominant-color-images/hooks.php +++ b/plugins/dominant-color-images/hooks.php @@ -186,3 +186,97 @@ function dominant_color_render_generator(): void { echo '' . "\n"; } add_action( 'wp_head', 'dominant_color_render_generator' ); + +/** + * Adds inline CSS for dominant color styling in the WordPress admin area. + * + * This function registers and enqueues a custom style handle, then adds inline CSS + * to apply background color based on the dominant color for attachment previews + * in the WordPress admin interface. + * + * @since 1.2.0 + */ +function dominant_color_admin_inline_style(): void { + $handle = 'dominant-color-admin-styles'; + // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Version not used since this handle is only registered for adding an inline style. + wp_register_style( $handle, false ); + wp_enqueue_style( $handle ); + $custom_css = '.wp-core-ui .attachment-preview[data-dominant-color]:not(.has-transparency) { background-color: var(--dominant-color); }'; + wp_add_inline_style( $handle, $custom_css ); +} +add_action( 'admin_enqueue_scripts', 'dominant_color_admin_inline_style' ); + +/** + * Adds a script to the admin footer to modify the attachment template. + * + * This function injects a JavaScript snippet into the admin footer that modifies + * the attachment template. It adds attributes for dominant color and transparency + * to the template, allowing these properties to be displayed in the media library. + * + * @since 1.2.0 + * @see wp_print_media_templates() + */ +function dominant_color_admin_script(): void { + ?> + + |mixed $response The current response array for the attachment. + * @param WP_Post $attachment The attachment post object. + * @param array|false $meta The attachment metadata. + * @return array The modified response array with added dominant color and transparency information. + */ +function dominant_color_prepare_attachment_for_js( $response, WP_Post $attachment, $meta ): array { + if ( ! is_array( $response ) ) { + $response = array(); + } + if ( ! is_array( $meta ) ) { + return $response; + } + + $response['dominantColor'] = ''; + if ( + isset( $meta['dominant_color'] ) + && + 1 === preg_match( '/^[0-9a-f]+$/', $meta['dominant_color'] ) // See format returned by dominant_color_rgb_to_hex(). + ) { + $response['dominantColor'] = $meta['dominant_color']; + } + $response['hasTransparency'] = ''; + if ( isset( $meta['has_transparency'] ) ) { + $response['hasTransparency'] = (bool) $meta['has_transparency']; + } + + return $response; +} +add_filter( 'wp_prepare_attachment_for_js', 'dominant_color_prepare_attachment_for_js', 10, 3 ); diff --git a/plugins/dominant-color-images/load.php b/plugins/dominant-color-images/load.php index 6f36b559eb..56a7f51b7d 100644 --- a/plugins/dominant-color-images/load.php +++ b/plugins/dominant-color-images/load.php @@ -3,9 +3,9 @@ * Plugin Name: Image Placeholders * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/dominant-color-images * Description: Displays placeholders based on an image's dominant color while the image is loading. - * Requires at least: 6.5 + * Requires at least: 6.6 * Requires PHP: 7.2 - * Version: 1.1.2 + * Version: 1.2.0 * Author: WordPress Performance Team * Author URI: https://make.wordpress.org/performance/ * License: GPLv2 or later @@ -25,7 +25,7 @@ return; } -define( 'DOMINANT_COLOR_IMAGES_VERSION', '1.1.2' ); +define( 'DOMINANT_COLOR_IMAGES_VERSION', '1.2.0' ); require_once __DIR__ . '/helper.php'; require_once __DIR__ . '/hooks.php'; diff --git a/plugins/dominant-color-images/readme.txt b/plugins/dominant-color-images/readme.txt index 2ac409272c..1aa6df61f6 100644 --- a/plugins/dominant-color-images/readme.txt +++ b/plugins/dominant-color-images/readme.txt @@ -1,8 +1,8 @@ === Image Placeholders === Contributors: wordpressdotorg -Tested up to: 6.6 -Stable tag: 1.1.2 +Tested up to: 6.7 +Stable tag: 1.2.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, images, dominant color @@ -47,6 +47,12 @@ Contributions are always welcome! Learn more about how to get involved in the [C == Changelog == += 1.2.0 = + +**Enhancements** + +* Enhance admin media UI with dominant color support. ([1719](https://github.com/WordPress/performance/pull/1719)) + = 1.1.2 = **Enhancements** diff --git a/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php b/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php index aa968d7bf0..9c30bdfc70 100644 --- a/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php +++ b/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php @@ -14,6 +14,8 @@ /** * Tag visitor that optimizes embeds. * + * @phpstan-import-type DOMRect from OD_URL_Metric + * * @since 0.2.0 * @access private */ @@ -26,99 +28,259 @@ final class Embed_Optimizer_Tag_Visitor { */ protected $added_lazy_script = false; + /** + * Determines whether the processor is currently at a figure.wp-block-embed tag. + * + * @since 0.3.0 + * + * @param OD_HTML_Tag_Processor $processor Processor. + * @return bool Whether at the tag. + */ + private function is_embed_figure( OD_HTML_Tag_Processor $processor ): bool { + return ( + 'FIGURE' === $processor->get_tag() + && + true === $processor->has_class( 'wp-block-embed' ) + ); + } + + /** + * Determines whether the processor is currently at a div.wp-block-embed__wrapper tag (which is a child of figure.wp-block-embed). + * + * @since 0.3.0 + * + * @param OD_HTML_Tag_Processor $processor Processor. + * @return bool Whether the tag should be measured and stored in URL Metrics. + */ + private function is_embed_wrapper( OD_HTML_Tag_Processor $processor ): bool { + return ( + 'DIV' === $processor->get_tag() + && + true === $processor->has_class( 'wp-block-embed__wrapper' ) + ); + } + /** * Visits a tag. * + * This visitor has two entry points, the `figure.wp-block-embed` tag and its child the `div.wp-block-embed__wrapper` + * tag. For example: + * + *
+ *
+ * + * + *
+ *
+ * + * For the `div.wp-block-embed__wrapper` tag, the only thing this tag visitor does is flag it for tracking in URL + * Metrics (by returning true). When visiting the parent `figure.wp-block-embed` tag, it does all the actual + * processing. In particular, it will use the element metrics gathered for the child `div.wp-block-embed__wrapper` + * element to set the min-height style on the `figure.wp-block-embed` to avoid layout shifts. Additionally, when + * the embed is in the initial viewport for any breakpoint, it will add preconnect links for key resources. + * Otherwise, if the embed is not in any initial viewport, it will add lazy-loading logic. + * * @since 0.2.0 + * @since 0.4.0 Adds preconnect links for each viewport group and skips if the element is not in the viewport for that group. * * @param OD_Tag_Visitor_Context $context Tag visitor context. - * @return bool Whether the tag should be tracked in URL metrics. + * @return bool Whether the tag should be tracked in URL Metrics. */ public function __invoke( OD_Tag_Visitor_Context $context ): bool { $processor = $context->processor; - if ( ! ( - 'FIGURE' === $processor->get_tag() - && - true === $processor->has_class( 'wp-block-embed' ) - ) ) { + + /* + * The only thing we need to do if it is a div.wp-block-embed__wrapper tag is return true so that the tag + * will get measured and stored in the URL Metrics. + */ + if ( $this->is_embed_wrapper( $processor ) ) { + return true; + } + + // Short-circuit if not a figure.wp-block-embed tag. + if ( ! $this->is_embed_figure( $processor ) ) { return false; } - $max_intersection_ratio = $context->url_metric_group_collection->get_element_max_intersection_ratio( $processor->get_xpath() ); - - if ( $max_intersection_ratio > 0 ) { - /* - * The following embeds have been chosen for optimization due to their relative popularity among all embed types. - * See . - * The list of hosts being preconnected to was obtained by inserting an embed into a post and then looking - * at the network log on the frontend as the embed renders. Each should include the host of the iframe src - * as well as URLs for assets used by the embed, _if_ the URL looks like it is not geotargeted (e.g. '-us') - * or load-balanced (e.g. 's0.example.com'). For the load balancing case, attempt to load the asset by - * incrementing the number appearing in the subdomain (e.g. s1.example.com). If the asset still loads, then - * it is a likely case of a load balancing domain name which cannot be safely preconnected since it could - * not end up being the load balanced domain used for the embed. Lastly, these domains are only for the URLs - * for GET requests, as POST requests are not likely to be part of the critical rendering path. - */ - $preconnect_hrefs = array(); - $has_class = static function ( string $wanted_class ) use ( $processor ): bool { - return true === $processor->has_class( $wanted_class ); - }; - if ( $has_class( 'wp-block-embed-youtube' ) ) { - $preconnect_hrefs[] = 'https://www.youtube.com'; - $preconnect_hrefs[] = 'https://i.ytimg.com'; - } elseif ( $has_class( 'wp-block-embed-twitter' ) ) { - $preconnect_hrefs[] = 'https://syndication.twitter.com'; - $preconnect_hrefs[] = 'https://pbs.twimg.com'; - } elseif ( $has_class( 'wp-block-embed-vimeo' ) ) { - $preconnect_hrefs[] = 'https://player.vimeo.com'; - $preconnect_hrefs[] = 'https://f.vimeocdn.com'; - $preconnect_hrefs[] = 'https://i.vimeocdn.com'; - } elseif ( $has_class( 'wp-block-embed-spotify' ) ) { - $preconnect_hrefs[] = 'https://apresolve.spotify.com'; - $preconnect_hrefs[] = 'https://embed-cdn.spotifycdn.com'; - $preconnect_hrefs[] = 'https://encore.scdn.co'; - $preconnect_hrefs[] = 'https://i.scdn.co'; - } elseif ( $has_class( 'wp-block-embed-videopress' ) || $has_class( 'wp-block-embed-wordpress-tv' ) ) { - $preconnect_hrefs[] = 'https://video.wordpress.com'; - $preconnect_hrefs[] = 'https://public-api.wordpress.com'; - $preconnect_hrefs[] = 'https://videos.files.wordpress.com'; - $preconnect_hrefs[] = 'https://v0.wordpress.com'; // This does not appear to be a load-balanced domain since v1.wordpress.com is not valid. - } elseif ( $has_class( 'wp-block-embed-instagram' ) ) { - $preconnect_hrefs[] = 'https://www.instagram.com'; - $preconnect_hrefs[] = 'https://static.cdninstagram.com'; - $preconnect_hrefs[] = 'https://scontent.cdninstagram.com'; - } elseif ( $has_class( 'wp-block-embed-tiktok' ) ) { - $preconnect_hrefs[] = 'https://www.tiktok.com'; - // Note: The other domains used for TikTok embeds include https://lf16-tiktok-web.tiktokcdn-us.com, - // https://lf16-cdn-tos.tiktokcdn-us.com, and https://lf16-tiktok-common.tiktokcdn-us.com among others - // which either appear to be geo-targeted ('-us') _or_ load-balanced ('lf16'). So these are not added - // to the preconnected hosts. - } elseif ( $has_class( 'wp-block-embed-amazon' ) ) { - $preconnect_hrefs[] = 'https://read.amazon.com'; - $preconnect_hrefs[] = 'https://m.media-amazon.com'; - } elseif ( $has_class( 'wp-block-embed-soundcloud' ) ) { - $preconnect_hrefs[] = 'https://w.soundcloud.com'; - $preconnect_hrefs[] = 'https://widget.sndcdn.com'; - // Note: There is also https://i1.sndcdn.com which is for the album art, but the '1' indicates it may be geotargeted/load-balanced. - } elseif ( $has_class( 'wp-block-embed-pinterest' ) ) { - $preconnect_hrefs[] = 'https://assets.pinterest.com'; - $preconnect_hrefs[] = 'https://widgets.pinterest.com'; - $preconnect_hrefs[] = 'https://i.pinimg.com'; + $this->reduce_layout_shifts( $context ); + + // Preconnect links and lazy-loading can only be done once there are URL Metrics collected for both mobile and desktop. + if ( + $context->url_metric_group_collection->get_first_group()->count() > 0 + && + $context->url_metric_group_collection->get_last_group()->count() > 0 + ) { + $embed_wrapper_xpath = self::get_embed_wrapper_xpath( $processor->get_xpath() ); + $max_intersection_ratio = $context->url_metric_group_collection->get_element_max_intersection_ratio( $embed_wrapper_xpath ); + if ( $max_intersection_ratio > 0 ) { + /* + * The following embeds have been chosen for optimization due to their relative popularity among all embed types. + * See . + * The list of hosts being preconnected to was obtained by inserting an embed into a post and then looking + * at the network log on the frontend as the embed renders. Each should include the host of the iframe src + * as well as URLs for assets used by the embed, _if_ the URL looks like it is not geotargeted (e.g. '-us') + * or load-balanced (e.g. 's0.example.com'). For the load balancing case, attempt to load the asset by + * incrementing the number appearing in the subdomain (e.g. s1.example.com). If the asset still loads, then + * it is a likely case of a load balancing domain name which cannot be safely preconnected since it could + * not end up being the load balanced domain used for the embed. Lastly, these domains are only for the URLs + * for GET requests, as POST requests are not likely to be part of the critical rendering path. + */ + $preconnect_hrefs = array(); + $has_class = static function ( string $wanted_class ) use ( $processor ): bool { + return true === $processor->has_class( $wanted_class ); + }; + if ( $has_class( 'wp-block-embed-youtube' ) ) { + $preconnect_hrefs[] = 'https://www.youtube.com'; + $preconnect_hrefs[] = 'https://i.ytimg.com'; + } elseif ( $has_class( 'wp-block-embed-twitter' ) ) { + $preconnect_hrefs[] = 'https://syndication.twitter.com'; + $preconnect_hrefs[] = 'https://pbs.twimg.com'; + } elseif ( $has_class( 'wp-block-embed-vimeo' ) ) { + $preconnect_hrefs[] = 'https://player.vimeo.com'; + $preconnect_hrefs[] = 'https://f.vimeocdn.com'; + $preconnect_hrefs[] = 'https://i.vimeocdn.com'; + } elseif ( $has_class( 'wp-block-embed-spotify' ) ) { + $preconnect_hrefs[] = 'https://apresolve.spotify.com'; + $preconnect_hrefs[] = 'https://embed-cdn.spotifycdn.com'; + $preconnect_hrefs[] = 'https://encore.scdn.co'; + $preconnect_hrefs[] = 'https://i.scdn.co'; + } elseif ( $has_class( 'wp-block-embed-videopress' ) || $has_class( 'wp-block-embed-wordpress-tv' ) ) { + $preconnect_hrefs[] = 'https://video.wordpress.com'; + $preconnect_hrefs[] = 'https://public-api.wordpress.com'; + $preconnect_hrefs[] = 'https://videos.files.wordpress.com'; + $preconnect_hrefs[] = 'https://v0.wordpress.com'; // This does not appear to be a load-balanced domain since v1.wordpress.com is not valid. + } elseif ( $has_class( 'wp-block-embed-instagram' ) ) { + $preconnect_hrefs[] = 'https://www.instagram.com'; + $preconnect_hrefs[] = 'https://static.cdninstagram.com'; + $preconnect_hrefs[] = 'https://scontent.cdninstagram.com'; + } elseif ( $has_class( 'wp-block-embed-tiktok' ) ) { + $preconnect_hrefs[] = 'https://www.tiktok.com'; + // Note: The other domains used for TikTok embeds include https://lf16-tiktok-web.tiktokcdn-us.com, + // https://lf16-cdn-tos.tiktokcdn-us.com, and https://lf16-tiktok-common.tiktokcdn-us.com among others + // which either appear to be geo-targeted ('-us') _or_ load-balanced ('lf16'). So these are not added + // to the preconnected hosts. + } elseif ( $has_class( 'wp-block-embed-amazon' ) ) { + $preconnect_hrefs[] = 'https://read.amazon.com'; + $preconnect_hrefs[] = 'https://m.media-amazon.com'; + } elseif ( $has_class( 'wp-block-embed-soundcloud' ) ) { + $preconnect_hrefs[] = 'https://w.soundcloud.com'; + $preconnect_hrefs[] = 'https://widget.sndcdn.com'; + // Note: There is also https://i1.sndcdn.com which is for the album art, but the '1' indicates it may be geotargeted/load-balanced. + } elseif ( $has_class( 'wp-block-embed-pinterest' ) ) { + $preconnect_hrefs[] = 'https://assets.pinterest.com'; + $preconnect_hrefs[] = 'https://widgets.pinterest.com'; + $preconnect_hrefs[] = 'https://i.pinimg.com'; + } + + foreach ( $preconnect_hrefs as $preconnect_href ) { + foreach ( $context->url_metric_group_collection as $group ) { + if ( ! ( $group->get_element_max_intersection_ratio( $embed_wrapper_xpath ) > 0.0 ) ) { + continue; + } + + $context->link_collection->add_link( + array( + 'rel' => 'preconnect', + 'href' => $preconnect_href, + ), + $group->get_minimum_viewport_width(), + $group->get_maximum_viewport_width() + ); + } + } + } elseif ( embed_optimizer_update_markup( $processor, false ) && ! $this->added_lazy_script ) { + $processor->append_body_html( wp_get_inline_script_tag( embed_optimizer_get_lazy_load_script(), array( 'type' => 'module' ) ) ); + $this->added_lazy_script = true; } + } + + /* + * At this point the tag is a figure.wp-block-embed, and we can return false because this does not need to be + * measured and stored in URL Metrics. Only the child div.wp-block-embed__wrapper tag is measured and stored + * so that this visitor can look up the height to set as a min-height on the figure.wp-block-embed. For more + * information on what the return values mean for tag visitors, see . + */ + return false; + } + + /** + * Gets the XPath for the embed wrapper DIV which is the sole child of the embed block FIGURE. + * + * @since 0.3.0 + * + * @param string $embed_block_xpath XPath for the embed block FIGURE tag. For example: `/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]`. + * @return string XPath for the child DIV. For example: `/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]` + */ + private static function get_embed_wrapper_xpath( string $embed_block_xpath ): string { + return $embed_block_xpath . '/*[1][self::DIV]'; + } + + /** + * Reduces layout shifts. + * + * @since 0.3.0 + * + * @param OD_Tag_Visitor_Context $context Tag visitor context, with the cursor currently at an embed block. + */ + private function reduce_layout_shifts( OD_Tag_Visitor_Context $context ): void { + $processor = $context->processor; + $embed_wrapper_xpath = self::get_embed_wrapper_xpath( $processor->get_xpath() ); + + /** + * Collection of the minimum heights for the element with each group keyed by the minimum viewport width. + * + * @var array $minimums + */ + $minimums = array(); - foreach ( $preconnect_hrefs as $preconnect_href ) { - $context->link_collection->add_link( - array( - 'rel' => 'preconnect', - 'href' => $preconnect_href, - ) + $elements = $context->url_metric_group_collection->get_xpath_elements_map()[ $embed_wrapper_xpath ] ?? array(); + foreach ( $elements as $element ) { + /** + * Resized bounding client rect. + * + * @var DOMRect|null $resized_bounding_client_rect + */ + $resized_bounding_client_rect = $element->get( 'resizedBoundingClientRect' ); + if ( ! is_array( $resized_bounding_client_rect ) ) { + continue; + } + $group = $element->get_url_metric_group(); + if ( null === $group ) { + continue; // Technically could be null but in practice it never will be. + } + $group_min_width = $group->get_minimum_viewport_width(); + if ( ! isset( $minimums[ $group_min_width ] ) ) { + $minimums[ $group_min_width ] = array( + 'group' => $group, + 'height' => $resized_bounding_client_rect['height'], + ); + } else { + $minimums[ $group_min_width ]['height'] = min( + $minimums[ $group_min_width ]['height'], + $resized_bounding_client_rect['height'] ); } - } elseif ( embed_optimizer_update_markup( $processor, false ) && ! $this->added_lazy_script ) { - $processor->append_body_html( wp_get_inline_script_tag( embed_optimizer_get_lazy_load_script(), array( 'type' => 'module' ) ) ); - $this->added_lazy_script = true; } - return true; + // Add style rules to set the min-height for each viewport group. + if ( count( $minimums ) > 0 ) { + $element_id = $processor->get_attribute( 'id' ); + if ( ! is_string( $element_id ) ) { + $element_id = 'embed-optimizer-' . md5( $processor->get_xpath() ); + $processor->set_attribute( 'id', $element_id ); + } + + $style_rules = array(); + foreach ( $minimums as $minimum ) { + $style_rules[] = sprintf( + '@media %s { #%s { min-height: %dpx; } }', + od_generate_media_query( $minimum['group']->get_minimum_viewport_width(), $minimum['group']->get_maximum_viewport_width() ), + $element_id, + $minimum['height'] + ); + } + + $processor->append_head_html( sprintf( "\n", join( "\n", $style_rules ) ) ); + } } } diff --git a/plugins/embed-optimizer/detect.js b/plugins/embed-optimizer/detect.js new file mode 100644 index 0000000000..38068387b0 --- /dev/null +++ b/plugins/embed-optimizer/detect.js @@ -0,0 +1,123 @@ +/** + * Embed Optimizer module for Optimization Detective + * + * When a URL Metric is being collected by Optimization Detective, this module adds a ResizeObserver to keep track of + * the changed heights for embed blocks. This data is extended/amended onto the element data of the pending URL Metric + * when it is submitted for storage. + */ + +const consoleLogPrefix = '[Embed Optimizer]'; + +/** + * @typedef {import("../optimization-detective/types.ts").URLMetric} URLMetric + * @typedef {import("../optimization-detective/types.ts").Extension} Extension + * @typedef {import("../optimization-detective/types.ts").InitializeCallback} InitializeCallback + * @typedef {import("../optimization-detective/types.ts").InitializeArgs} InitializeArgs + * @typedef {import("../optimization-detective/types.ts").FinalizeArgs} FinalizeArgs + * @typedef {import("../optimization-detective/types.ts").FinalizeCallback} FinalizeCallback + * @typedef {import("../optimization-detective/types.ts").ExtendedElementData} ExtendedElementData + */ + +/** + * Logs a message. + * + * @param {...*} message + */ +function log( ...message ) { + // eslint-disable-next-line no-console + console.log( consoleLogPrefix, ...message ); +} + +/** + * Logs an error. + * + * @param {...*} message + */ +function error( ...message ) { + // eslint-disable-next-line no-console + console.error( consoleLogPrefix, ...message ); +} + +/** + * Embed element heights. + * + * @type {Map} + */ +const loadedElementContentRects = new Map(); + +/** + * Initializes extension. + * + * @type {InitializeCallback} + * @param {InitializeArgs} args Args. + */ +export async function initialize( { isDebug } ) { + /** @type NodeListOf */ + const embedWrappers = document.querySelectorAll( + '.wp-block-embed > .wp-block-embed__wrapper[data-od-xpath]' + ); + + for ( const embedWrapper of embedWrappers ) { + monitorEmbedWrapperForResizes( embedWrapper, isDebug ); + } + + if ( isDebug ) { + log( 'Loaded embed content rects:', loadedElementContentRects ); + } +} + +/** + * Finalizes extension. + * + * @type {FinalizeCallback} + * @param {FinalizeArgs} args Args. + */ +export async function finalize( { + isDebug, + getElementData, + extendElementData, +} ) { + for ( const [ xpath, domRect ] of loadedElementContentRects.entries() ) { + try { + extendElementData( xpath, { + resizedBoundingClientRect: domRect, + } ); + if ( isDebug ) { + const elementData = getElementData( xpath ); + log( + `boundingClientRect for ${ xpath } resized:`, + elementData.boundingClientRect, + '=>', + domRect + ); + } + } catch ( err ) { + error( + `Failed to extend element data for ${ xpath } with resizedBoundingClientRect:`, + domRect, + err + ); + } + } +} + +/** + * Monitors embed wrapper for resizes. + * + * @param {HTMLDivElement} embedWrapper Embed wrapper DIV. + * @param {boolean} isDebug Whether debug. + */ +function monitorEmbedWrapperForResizes( embedWrapper, isDebug ) { + if ( ! ( 'odXpath' in embedWrapper.dataset ) ) { + throw new Error( 'Embed wrapper missing data-od-xpath attribute.' ); + } + const xpath = embedWrapper.dataset.odXpath; + const observer = new ResizeObserver( ( entries ) => { + const [ entry ] = entries; + loadedElementContentRects.set( xpath, entry.contentRect ); + if ( isDebug ) { + log( `Resized element ${ xpath }:`, entry.contentRect ); + } + } ); + observer.observe( embedWrapper, { box: 'content-box' } ); +} diff --git a/plugins/embed-optimizer/hooks.php b/plugins/embed-optimizer/hooks.php index 0e4e9e39fe..e32e360cc3 100644 --- a/plugins/embed-optimizer/hooks.php +++ b/plugins/embed-optimizer/hooks.php @@ -18,14 +18,53 @@ function embed_optimizer_add_hooks(): void { add_action( 'wp_head', 'embed_optimizer_render_generator' ); - if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) { - add_action( 'od_register_tag_visitors', 'embed_optimizer_register_tag_visitors' ); - } else { - add_filter( 'embed_oembed_html', 'embed_optimizer_filter_oembed_html' ); - } + add_action( 'od_init', 'embed_optimizer_init_optimization_detective' ); + add_action( 'wp_loaded', 'embed_optimizer_add_non_optimization_detective_hooks' ); } add_action( 'init', 'embed_optimizer_add_hooks' ); +/** + * Adds hooks for when the Optimization Detective logic is not running. + * + * @since 0.3.0 + */ +function embed_optimizer_add_non_optimization_detective_hooks(): void { + if ( false === has_action( 'od_register_tag_visitors', 'embed_optimizer_register_tag_visitors' ) ) { + add_filter( 'embed_oembed_html', 'embed_optimizer_filter_oembed_html_to_lazy_load' ); + } +} + +/** + * Initializes Embed Optimizer when Optimization Detective has loaded. + * + * @since 0.3.0 + * + * @param string $optimization_detective_version Current version of the optimization detective plugin. + */ +function embed_optimizer_init_optimization_detective( string $optimization_detective_version ): void { + $required_od_version = '0.9.0'; + if ( version_compare( (string) strtok( $optimization_detective_version, '-' ), $required_od_version, '<' ) ) { + add_action( + 'admin_notices', + static function (): void { + global $pagenow; + if ( ! in_array( $pagenow, array( 'index.php', 'plugins.php' ), true ) ) { + return; + } + wp_admin_notice( + esc_html__( 'The Embed Optimizer plugin requires a newer version of the Optimization Detective plugin. Please update your plugins.', 'embed-optimizer' ), + array( 'type' => 'warning' ) + ); + } + ); + return; + } + + add_action( 'od_register_tag_visitors', 'embed_optimizer_register_tag_visitors' ); + add_filter( 'embed_oembed_html', 'embed_optimizer_filter_oembed_html_to_detect_embed_presence' ); + add_filter( 'od_url_metric_schema_element_item_additional_properties', 'embed_optimizer_add_element_item_schema_properties' ); +} + /** * Registers the tag visitor for embeds. * @@ -40,17 +79,85 @@ function embed_optimizer_register_tag_visitors( OD_Tag_Visitor_Registry $registr } /** - * Filter the oEmbed HTML. + * Filters additional properties for the element item schema for Optimization Detective. + * + * @since 0.3.0 + * + * @param array $additional_properties Additional properties. + * @return array Additional properties. + */ +function embed_optimizer_add_element_item_schema_properties( array $additional_properties ): array { + $additional_properties['resizedBoundingClientRect'] = array( + 'type' => 'object', + 'properties' => array_fill_keys( + array( + 'width', + 'height', + 'x', + 'y', + 'top', + 'right', + 'bottom', + 'left', + ), + array( + 'type' => 'number', + 'required' => true, + ) + ), + ); + return $additional_properties; +} + +/** + * Filters the list of Optimization Detective extension module URLs to include the extension for Embed Optimizer. + * + * @since 0.3.0 + * + * @param string[]|mixed $extension_module_urls Extension module URLs. + * @return string[] Extension module URLs. + */ +function embed_optimizer_filter_extension_module_urls( $extension_module_urls ): array { + if ( ! is_array( $extension_module_urls ) ) { + $extension_module_urls = array(); + } + $extension_module_urls[] = add_query_arg( 'ver', EMBED_OPTIMIZER_VERSION, plugin_dir_url( __FILE__ ) . embed_optimizer_get_asset_path( 'detect.js' ) ); + return $extension_module_urls; +} + +/** + * Filter the oEmbed HTML to detect when an embed is present so that the Optimization Detective extension module can be enqueued. + * + * This ensures that the module for handling embeds is only loaded when there is an embed on the page. + * + * @since 0.3.0 + * + * @param string|mixed $html The oEmbed HTML. + * @return string Unchanged oEmbed HTML. + */ +function embed_optimizer_filter_oembed_html_to_detect_embed_presence( $html ): string { + if ( ! is_string( $html ) ) { + $html = ''; + } + add_filter( 'od_extension_module_urls', 'embed_optimizer_filter_extension_module_urls' ); + return $html; +} + +/** + * Filter the oEmbed HTML to lazy load the embed. * * Add loading="lazy" to any iframe tags. * Lazy load any script tags. * * @since 0.1.0 * - * @param string $html The oEmbed HTML. + * @param string|mixed $html The oEmbed HTML. * @return string Filtered oEmbed HTML. */ -function embed_optimizer_filter_oembed_html( string $html ): string { +function embed_optimizer_filter_oembed_html_to_lazy_load( $html ): string { + if ( ! is_string( $html ) ) { + $html = ''; + } $html_processor = new WP_HTML_Tag_Processor( $html ); if ( embed_optimizer_update_markup( $html_processor, true ) ) { add_action( 'wp_footer', 'embed_optimizer_lazy_load_scripts' ); @@ -74,8 +181,9 @@ function embed_optimizer_update_markup( WP_HTML_Tag_Processor $html_processor, b 'script' => 'embed_optimizer_script', 'iframe' => 'embed_optimizer_iframe', ); - $trigger_error = static function ( string $message ): void { - wp_trigger_error( __FUNCTION__, esc_html( $message ) ); + $function_name = __FUNCTION__; + $trigger_error = static function ( string $message ) use ( $function_name ): void { + wp_trigger_error( $function_name, esc_html( $message ) ); }; try { /* @@ -218,44 +326,13 @@ function embed_optimizer_lazy_load_scripts(): void { * @since 0.2.0 */ function embed_optimizer_get_lazy_load_script(): string { - return << { - for ( const entry of entries ) { - if ( entry.isIntersecting ) { - const lazyEmbedParent = entry.target; - const lazyEmbedScript = /** @type {HTMLScriptElement} */ lazyEmbedScriptsByParents.get( lazyEmbedParent ); - const embedScript = document.createElement( 'script' ); - for ( const attr of lazyEmbedScript.attributes ) { - if ( attr.nodeName === 'type' ) { - // Omit type=application/vnd.embed-optimizer.javascript type. - continue; - } - embedScript.setAttribute( - attr.nodeName === 'data-original-type' ? 'type' : attr.nodeName, - attr.nodeValue - ); - } - lazyEmbedScript.replaceWith( embedScript ); - lazyEmbedObserver.unobserve( lazyEmbedParent ); - } - } - }, - { - rootMargin: '100% 0% 100% 0%', - threshold: 0 - } - ); + if ( false === $script ) { + return ''; + } - for ( const lazyEmbedScript of lazyEmbedsScripts ) { - const lazyEmbedParent = /** @type {HTMLElement} */ lazyEmbedScript.parentNode; - lazyEmbedScriptsByParents.set( lazyEmbedParent, lazyEmbedScript ); - lazyEmbedObserver.observe( lazyEmbedParent ); - } -JS; + return $script; } /** @@ -347,3 +424,39 @@ function embed_optimizer_render_generator(): void { // Use the plugin slug as it is immutable. echo '' . "\n"; } + +/** + * Gets the path to a script or stylesheet. + * + * @since 0.4.0 + * + * @param string $src_path Source path, relative to plugin root. + * @param string|null $min_path Minified path. If not supplied, then '.min' is injected before the file extension in the source path. + * @return string URL to script or stylesheet. + */ +function embed_optimizer_get_asset_path( string $src_path, ?string $min_path = null ): string { + if ( null === $min_path ) { + // Note: wp_scripts_get_suffix() is not used here because we need access to both the source and minified paths. + $min_path = (string) preg_replace( '/(?=\.\w+$)/', '.min', $src_path ); + } + + $force_src = false; + if ( WP_DEBUG && ! file_exists( trailingslashit( __DIR__ ) . $min_path ) ) { + $force_src = true; + wp_trigger_error( + __FUNCTION__, + sprintf( + /* translators: %s is the minified asset path */ + __( 'Minified asset has not been built: %s', 'embed-optimizer' ), + $min_path + ), + E_USER_WARNING + ); + } + + if ( SCRIPT_DEBUG || $force_src ) { + return $src_path; + } + + return $min_path; +} diff --git a/plugins/embed-optimizer/lazy-load.js b/plugins/embed-optimizer/lazy-load.js new file mode 100644 index 0000000000..81e209e8c9 --- /dev/null +++ b/plugins/embed-optimizer/lazy-load.js @@ -0,0 +1,55 @@ +/** + * Lazy load embeds + * + * When an embed block is lazy loaded, the script tag is replaced with a script tag that has the original attributes + */ + +const lazyEmbedsScripts = document.querySelectorAll( + 'script[type="application/vnd.embed-optimizer.javascript"]' +); +const lazyEmbedScriptsByParents = new Map(); + +const lazyEmbedObserver = new IntersectionObserver( + ( entries ) => { + for ( const entry of entries ) { + if ( entry.isIntersecting ) { + const lazyEmbedParent = entry.target; + const lazyEmbedScript = + /** @type {HTMLScriptElement} */ lazyEmbedScriptsByParents.get( + lazyEmbedParent + ); + const embedScript = + /** @type {HTMLScriptElement} */ document.createElement( + 'script' + ); + for ( const attr of lazyEmbedScript.attributes ) { + if ( attr.nodeName === 'type' ) { + // Omit type=application/vnd.embed-optimizer.javascript type. + continue; + } + embedScript.setAttribute( + attr.nodeName === 'data-original-type' + ? 'type' + : attr.nodeName, + attr.nodeValue + ); + } + lazyEmbedScript.replaceWith( embedScript ); + lazyEmbedObserver.unobserve( lazyEmbedParent ); + } + } + }, + { + rootMargin: '100% 0% 100% 0%', + threshold: 0, + } +); + +for ( const lazyEmbedScript of lazyEmbedsScripts ) { + const lazyEmbedParent = + /** @type {HTMLElement} */ lazyEmbedScript.parentNode; + if ( lazyEmbedParent instanceof HTMLElement ) { + lazyEmbedScriptsByParents.set( lazyEmbedParent, lazyEmbedScript ); + lazyEmbedObserver.observe( lazyEmbedParent ); + } +} diff --git a/plugins/embed-optimizer/load.php b/plugins/embed-optimizer/load.php index 1bca76100f..eda818a6ec 100644 --- a/plugins/embed-optimizer/load.php +++ b/plugins/embed-optimizer/load.php @@ -2,10 +2,10 @@ /** * Plugin Name: Embed Optimizer * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/embed-optimizer - * Description: Optimizes the performance of embeds by lazy-loading iframes and scripts. - * Requires at least: 6.5 + * Description: Optimizes the performance of embeds through lazy-loading, preconnecting, and reserving space to reduce layout shifts. + * Requires at least: 6.6 * Requires PHP: 7.2 - * Version: 0.2.0 + * Version: 0.4.0 * Author: WordPress Performance Team * Author URI: https://make.wordpress.org/performance/ * License: GPLv2 or later @@ -43,9 +43,14 @@ static function ( string $global_var_name, string $version, Closure $load ): voi } }; - // Wait until after the plugins have loaded and the theme has loaded. The after_setup_theme action is used - // because it is the first action that fires once the theme is loaded. - add_action( 'after_setup_theme', $bootstrap, PHP_INT_MIN ); + /* + * Wait until after the plugins have loaded and the theme has loaded. The after_setup_theme action could be + * used since it is the first action that fires once the theme is loaded. However, plugins may embed this + * logic inside a module which initializes even later at the init action. The earliest action that this + * plugin has hooks for is the init action at the default priority of 10 (which includes the rest_api_init + * action), so this is why it gets initialized at priority 9. + */ + add_action( 'init', $bootstrap, 9 ); } // Register this copy of the plugin. @@ -65,8 +70,11 @@ static function ( string $global_var_name, string $version, Closure $load ): voi } )( 'embed_optimizer_pending_plugin', - '0.2.0', + '0.4.0', static function ( string $version ): void { + if ( defined( 'EMBED_OPTIMIZER_VERSION' ) ) { + return; + } define( 'EMBED_OPTIMIZER_VERSION', $version ); diff --git a/plugins/embed-optimizer/readme.txt b/plugins/embed-optimizer/readme.txt index 8157189226..65c30a2a15 100644 --- a/plugins/embed-optimizer/readme.txt +++ b/plugins/embed-optimizer/readme.txt @@ -1,19 +1,35 @@ === Embed Optimizer === Contributors: wordpressdotorg -Tested up to: 6.6 -Stable tag: 0.2.0 +Tested up to: 6.7 +Stable tag: 0.4.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, embeds -Optimizes the performance of embeds by lazy-loading iframes and scripts. +Optimizes the performance of embeds through lazy-loading, preconnecting, and reserving space to reduce layout shifts. == Description == -This plugin's purpose is to optimize the performance of [embeds in WordPress](https://wordpress.org/documentation/article/embeds/), such as YouTube videos, TikToks, and so on. Initially this is achieved by lazy-loading them only when they come into view. This improves performance because embeds are generally very resource-intensive and so lazy-loading them ensures that they don't compete with resources when the page is loading. [Other optimizations](https://github.com/WordPress/performance/issues?q=is%3Aissue+is%3Aopen+label%3A%22%5BPlugin%5D+Embed+Optimizer%22) are planned for the future. +This plugin's purpose is to optimize the performance of [embeds in WordPress](https://wordpress.org/documentation/article/embeds/), such as Tweets, YouTube videos, TikToks, and others. -This plugin also recommends that you install and activate the [Optimization Detective](https://wordpress.org/plugins/optimization-detective/) plugin. When it is active, it will start recording which embeds appear in the initial viewport based on actual visitors to your site. With this information in hand, Embed Optimizer will then avoid lazy-loading embeds which appear in the initial viewport (above the fold). This is important because lazy-loading adds a delay which can hurt the user experience and even degrade the Largest Contentful Paint (LCP) score for the page. In addition to not lazy-loading such above-the-fold embeds, Embed Optimizer will add preconnect links for the hosts of network resources known to be required for the most popular embeds (e.g. YouTube, Twitter, Vimeo, Spotify, VideoPress); this can further speed up the loading of critical embeds. Again, these performance enhancements are only enabled when Optimization Detective is active. +The current optimizations include: + +1. Lazy loading embeds just before they come into view. +2. Adding preconnect links for embeds in the initial viewport. +3. Reserving space for embeds that resize to reduce layout shifting. + +**Lazy loading embeds** improves performance because embeds are generally very resource-intensive, so lazy loading them ensures that they don't compete with resources when the page is loading. Lazy loading of `IFRAME`\-based embeds is handled simply by adding the `loading=lazy` attribute. Lazy loading embeds that include `SCRIPT` tags is handled by using an Intersection Observer to watch for when the embed’s `FIGURE` container is going to enter the viewport and then it dynamically inserts the `SCRIPT` tag. + +**This plugin also recommends that you install and activate the [Optimization Detective](https://wordpress.org/plugins/optimization-detective/) plugin**, which unlocks several optimizations beyond just lazy loading. Without Optimization Detective, lazy loading can actually degrade performance *when an embed is positioned in the initial viewport*. This is because lazy loading such viewport-initial elements can degrade LCP since rendering is delayed by the logic to determine whether the element is visible. This is why WordPress Core tries its best to [avoid](https://make.wordpress.org/core/2021/07/15/refining-wordpress-cores-lazy-loading-implementation/) [lazy loading](https://make.wordpress.org/core/2021/07/15/refining-wordpress-cores-lazy-loading-implementation/) `IMG` tags which appear in the initial viewport, although the server-side heuristics aren’t perfect. This is where Optimization Detective comes in since it detects whether an embed appears in any breakpoint-specific viewports, like mobile, tablet, and desktop. (See also the [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) plugin which extends Optimization Detective to ensure lazy loading is correctly applied based on whether an IMG is in the initial viewport.) + +When Optimization Detective is active, it will start keeping track of which embeds appear in the initial viewport based on actual visits to your site. With this information in hand, Embed Optimizer will then avoid lazy loading embeds which appear in the initial viewport. Furthermore, for such above-the-fold embeds Embed Optimizer will also **add preconnect links** for resources known to be used by those embeds. For example, if a YouTube embed appears in the initial viewport, Embed Optimizer with Optimization Detective will omit `loading=lazy` while also adding a preconnect link for `https://i.ytimg.com` which is the domain from which YouTube video poster images are served. Such preconnect links cause the initial-viewport embeds to load even faster. + +The other major feature in Embed Optimizer enabled by Optimization Detective is the **reduction of layout shifts** caused by embeds that resize when they load. This is seen commonly in WordPress post embeds or Tweet embeds. Embed Optimizer keeps track of the resized heights of these embeds. With these resized heights stored, Embed Optimizer sets the appropriate height on the container FIGURE element as the viewport-specific `min-height` so that when the embed loads it does not cause a layout shift. + +Since Optimization Detective relies on page visits to learn how the page is laid out, you’ll need to wait until you have visits from a mobile and desktop device to start seeing optimizations applied. Also, note that Optimization Detective does not apply optimizations by default for logged-in admin users. + +Please note that the optimizations are intended to apply to Embed blocks. So if you do not see optimizations applied, make sure that your embeds are not inside of a Classic Block. There are currently **no settings** and no user interface for this plugin since it is designed to work without any configuration. @@ -51,6 +67,20 @@ The [plugin source code](https://github.com/WordPress/performance/tree/trunk/plu == Changelog == += 0.4.0 = + +**Enhancements** + +* Incorporate media queries into preconnect links to account for whether embeds are in viewport. ([1654](https://github.com/WordPress/performance/pull/1654)) +* Serve unminified scripts when `SCRIPT_DEBUG` is enabled. ([1643](https://github.com/WordPress/performance/pull/1643)) + += 0.3.0 = + +**Enhancements** + +* Leverage URL Metrics to reserve space for embeds to reduce CLS. ([1373](https://github.com/WordPress/performance/pull/1373)) +* Avoid lazy-loading images and embeds unless there are URL Metrics for both mobile and desktop. ([1604](https://github.com/WordPress/performance/pull/1604)) + = 0.2.0 = **Enhancements** diff --git a/plugins/embed-optimizer/tests/test-cases/nested-figure-embed.php b/plugins/embed-optimizer/tests/test-cases/nested-figure-embed.php index d96f9eb6ff..60c56b04ac 100644 --- a/plugins/embed-optimizer/tests/test-cases/nested-figure-embed.php +++ b/plugins/embed-optimizer/tests/test-cases/nested-figure-embed.php @@ -4,9 +4,10 @@ $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => false, - 'intersectionRatio' => 1, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => false, + 'intersectionRatio' => 1, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 ) ), ), array( 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]/*[1][self::VIDEO]', @@ -14,9 +15,10 @@ 'intersectionRatio' => 1, ), array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::FIGURE]', - 'isLCP' => false, - 'intersectionRatio' => 0, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => false, + 'intersectionRatio' => 0, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 654 ) ), ), array( 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::FIGURE]/*[1][self::DIV]/*[1][self::FIGURE]/*[2][self::VIDEO]', @@ -74,12 +76,12 @@ static function ( OD_Tag_Visitor_Context $context ) use ( $test_case ): bool { ... -
+
-
+

So I heard you like FIGURE?

@@ -98,16 +100,28 @@ static function ( OD_Tag_Visitor_Context $context ) use ( $test_case ): bool { ... + + -
-
+
+
-
-
+
+

So I heard you like FIGURE?

diff --git a/plugins/embed-optimizer/tests/test-cases/single-spotify-embed-outside-viewport-with-subsequent-script.php b/plugins/embed-optimizer/tests/test-cases/single-spotify-embed-outside-viewport-with-subsequent-script.php index 2867ff1303..484b25cb2e 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-spotify-embed-outside-viewport-with-subsequent-script.php +++ b/plugins/embed-optimizer/tests/test-cases/single-spotify-embed-outside-viewport-with-subsequent-script.php @@ -4,9 +4,10 @@ $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => false, - 'intersectionRatio' => 0, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => false, + 'intersectionRatio' => 0, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 ) ), ), ), false @@ -33,10 +34,16 @@ ... + -
-
+
+
diff --git a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-inside-viewport-without-resized-data.php b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-inside-viewport-without-resized-data.php new file mode 100644 index 0000000000..37886051b9 --- /dev/null +++ b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-inside-viewport-without-resized-data.php @@ -0,0 +1,49 @@ + static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { + $test_case->populate_url_metrics( + array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => true, + 'intersectionRatio' => 1, + // Intentionally omitting resizedBoundingClientRect here to test behavior when data isn't supplied. + ), + ) + ); + }, + 'buffer' => ' + + + + ... + + +
+
+ + +
+
+ + + ', + 'expected' => ' + + + + ... + + + + +
+
+ + +
+
+ + + ', +); diff --git a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-inside-viewport.php b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-inside-viewport.php index 1336dcfcbd..04b94117d3 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-inside-viewport.php +++ b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-inside-viewport.php @@ -4,9 +4,10 @@ $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => true, - 'intersectionRatio' => 1, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => true, + 'intersectionRatio' => 1, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 ) ), ), ) ); @@ -32,11 +33,17 @@ ... - + + -
+
diff --git a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport-on-mobile.php b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport-on-mobile.php new file mode 100644 index 0000000000..3c70222e49 --- /dev/null +++ b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport-on-mobile.php @@ -0,0 +1,73 @@ + static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { + foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $i => $viewport_width ) { + $elements = array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => true, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 + $i * 100 ) ), + ), + ); + + // Embed not visible on mobile. + if ( 480 === $viewport_width ) { + $elements[0]['intersectionRatio'] = 0; + $elements[0]['isLCP'] = false; + } + + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + for ( $j = 0; $j < $sample_size; $j++ ) { + OD_URL_Metrics_Post_Type::store_url_metric( + od_get_url_metrics_slug( od_get_normalized_query_vars() ), + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'elements' => $elements, + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + +
+
+ + +
+
+ + + ', + 'expected' => ' + + + + ... + + + + + +
+
+ + +
+
+ + + ', +); diff --git a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport.php b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport.php index 3da5a29fac..ccdf5fbcfc 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport.php +++ b/plugins/embed-optimizer/tests/test-cases/single-twitter-embed-outside-viewport.php @@ -4,9 +4,10 @@ $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => false, - 'intersectionRatio' => 0, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => false, + 'intersectionRatio' => 0, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 ) ), ), ) ); @@ -32,9 +33,15 @@ ... + -
+
diff --git a/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-inside-viewport.php b/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-inside-viewport.php index 5992973bf4..85a0e4e8e9 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-inside-viewport.php +++ b/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-inside-viewport.php @@ -4,9 +4,10 @@ $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => true, - 'intersectionRatio' => 1, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => true, + 'intersectionRatio' => 1, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 ) ), ), ), false @@ -33,14 +34,20 @@ ... - + - + + -
-
+
+
diff --git a/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport-on-mobile.php b/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport-on-mobile.php new file mode 100644 index 0000000000..0755256069 --- /dev/null +++ b/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport-on-mobile.php @@ -0,0 +1,73 @@ + static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { + foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $i => $viewport_width ) { + $elements = array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => true, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 + $i * 100 ) ), + ), + ); + + // Embed not visible on mobile. + if ( 480 === $viewport_width ) { + $elements[0]['intersectionRatio'] = 0; + $elements[0]['isLCP'] = false; + } + + OD_URL_Metrics_Post_Type::store_url_metric( + od_get_url_metrics_slug( od_get_normalized_query_vars() ), + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'elements' => $elements, + ) + ) + ); + } + }, + 'buffer' => ' + + + + ... + + +
+
+ + +
+
+ + + ', + 'expected' => ' + + + + ... + + + + + + + +
+
+ + +
+
+ + + + ', +); diff --git a/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport.php b/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport.php index a48ed16b11..7d3cd53df8 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport.php +++ b/plugins/embed-optimizer/tests/test-cases/single-wordpress-tv-embed-outside-viewport.php @@ -4,9 +4,10 @@ $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => false, - 'intersectionRatio' => 0, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => false, + 'intersectionRatio' => 0, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 ) ), ), ), false @@ -33,10 +34,16 @@ ... + -
-
+
+
diff --git a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport.php b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport.php index c4ed8b770c..5605d8c124 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport.php +++ b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-inside-viewport.php @@ -4,9 +4,10 @@ $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => true, - 'intersectionRatio' => 1, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => true, + 'intersectionRatio' => 1, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 ) ), ), ) ); @@ -31,11 +32,17 @@ ... - + + -
+
diff --git a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport-on-mobile.php b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport-on-mobile.php new file mode 100644 index 0000000000..da1c4b8b47 --- /dev/null +++ b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport-on-mobile.php @@ -0,0 +1,71 @@ + static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { + foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { + $elements = array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => true, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 ) ), + ), + ); + + // Embed is ONLY visible on phablet and tablet. + if ( ! in_array( $viewport_width, array( 600, 782 ), true ) ) { + $elements[0]['intersectionRatio'] = 0; + $elements[0]['isLCP'] = false; + } + + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + for ( $i = 0; $i < $sample_size; $i++ ) { + OD_URL_Metrics_Post_Type::store_url_metric( + od_get_url_metrics_slug( od_get_normalized_query_vars() ), + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'elements' => $elements, + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + +
+
+ +
+
+ + + ', + 'expected' => ' + + + + ... + + + + + +
+
+ +
+
+ + + ', +); diff --git a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport-with-only-mobile-url-metrics.php b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport-with-only-mobile-url-metrics.php new file mode 100644 index 0000000000..ec86becada --- /dev/null +++ b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport-with-only-mobile-url-metrics.php @@ -0,0 +1,55 @@ + static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { + OD_URL_Metrics_Post_Type::store_url_metric( + od_get_url_metrics_slug( od_get_normalized_query_vars() ), + $test_case->get_sample_url_metric( + array( + 'viewport_width' => 100, + 'elements' => array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => false, + 'intersectionRatio' => 0.0, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 ) ), + ), + ), + ) + ) + ); + }, + 'buffer' => ' + + + + ... + + +
+
+ +
+
+ + + ', + 'expected' => ' + + + + ... + + + +
+
+ +
+
+ + + + ', +); diff --git a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport.php b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport.php index 85c53e81bf..daef5292c6 100644 --- a/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport.php +++ b/plugins/embed-optimizer/tests/test-cases/single-youtube-embed-outside-viewport.php @@ -4,9 +4,10 @@ $test_case->populate_url_metrics( array( array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - 'isLCP' => false, - 'intersectionRatio' => 0, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]/*[1][self::DIV]', + 'isLCP' => false, + 'intersectionRatio' => 0, + 'resizedBoundingClientRect' => array_merge( $test_case->get_sample_dom_rect(), array( 'height' => 500 ) ), ), ) ); @@ -31,9 +32,15 @@ ... + -
+
diff --git a/plugins/embed-optimizer/tests/test-cases/too-many-bookmarks.php b/plugins/embed-optimizer/tests/test-cases/too-many-bookmarks.php index 76df5b30e6..bd56e9c71d 100644 --- a/plugins/embed-optimizer/tests/test-cases/too-many-bookmarks.php +++ b/plugins/embed-optimizer/tests/test-cases/too-many-bookmarks.php @@ -3,6 +3,17 @@ 'set_up' => static function ( Test_Embed_Optimizer_Optimization_Detective $test_case ): void { $test_case->setExpectedIncorrectUsage( 'WP_HTML_Tag_Processor::set_bookmark' ); + $test_case->populate_url_metrics( + array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::BOGUS]', + 'isLCP' => false, + 'intersectionRatio' => 0.0, + ), + ), + false + ); + // Check what happens when there are too many bookmarks. add_action( 'od_register_tag_visitors', @@ -59,8 +70,8 @@ static function ( OD_Tag_Visitor_Context $context ) use ( $test_case ): bool { ... -
-
+
+
diff --git a/plugins/embed-optimizer/tests/test-hooks.php b/plugins/embed-optimizer/tests/test-hooks.php index 0e6b223d6c..5c4f041b2e 100644 --- a/plugins/embed-optimizer/tests/test-hooks.php +++ b/plugins/embed-optimizer/tests/test-hooks.php @@ -10,28 +10,95 @@ class Test_Embed_Optimizer_Hooks extends WP_UnitTestCase { /** * @covers ::embed_optimizer_add_hooks */ - public function test_hooks(): void { + public function test_embed_optimizer_add_hooks(): void { + remove_all_actions( 'od_init' ); + remove_all_actions( 'wp_head' ); + remove_all_actions( 'wp_loaded' ); embed_optimizer_add_hooks(); - if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) { - $this->assertFalse( has_filter( 'embed_oembed_html', 'embed_optimizer_filter_oembed_html' ) ); - } else { - $this->assertSame( 10, has_filter( 'embed_oembed_html', 'embed_optimizer_filter_oembed_html' ) ); - } + $this->assertSame( 10, has_action( 'od_init', 'embed_optimizer_init_optimization_detective' ) ); $this->assertSame( 10, has_action( 'wp_head', 'embed_optimizer_render_generator' ) ); + $this->assertSame( 10, has_action( 'wp_loaded', 'embed_optimizer_add_non_optimization_detective_hooks' ) ); + } + + /** + * @return array> + */ + public function data_provider_to_test_embed_optimizer_add_non_optimization_detective_hooks(): array { + return array( + 'without_optimization_detective' => array( + 'set_up' => static function (): void {}, + 'expected' => 10, + ), + 'with_optimization_detective' => array( + 'set_up' => static function (): void { + add_action( 'od_register_tag_visitors', 'embed_optimizer_register_tag_visitors' ); + }, + 'expected' => false, + ), + ); + } + + /** + * @dataProvider data_provider_to_test_embed_optimizer_add_non_optimization_detective_hooks + * @covers ::embed_optimizer_add_non_optimization_detective_hooks + * + * @param Closure $set_up Set up. + * @param int|false $expected Expected. + */ + public function test_embed_optimizer_add_non_optimization_detective_hooks( Closure $set_up, $expected ): void { + remove_all_filters( 'embed_oembed_html' ); + remove_all_actions( 'od_register_tag_visitors' ); + $set_up(); + embed_optimizer_add_non_optimization_detective_hooks(); + $this->assertSame( $expected, has_filter( 'embed_oembed_html', 'embed_optimizer_filter_oembed_html_to_lazy_load' ) ); + } + + /** + * @return array> + */ + public function data_provider_to_test_embed_optimizer_init_optimization_detective(): array { + return array( + 'with_old_version' => array( + 'version' => '0.5.0', + 'expected' => false, + ), + 'with_new_version' => array( + 'version' => '99.0.0', + 'expected' => true, + ), + ); + } + + /** + * @covers ::embed_optimizer_init_optimization_detective + * @dataProvider data_provider_to_test_embed_optimizer_init_optimization_detective + */ + public function test_embed_optimizer_init_optimization_detective( string $version, bool $expected ): void { + remove_all_actions( 'admin_notices' ); + remove_all_actions( 'od_register_tag_visitors' ); + remove_all_filters( 'embed_oembed_html' ); + remove_all_filters( 'od_url_metric_schema_element_item_additional_properties' ); + + embed_optimizer_init_optimization_detective( $version ); + + $this->assertSame( ! $expected, has_action( 'admin_notices' ) ); + $this->assertSame( $expected ? 10 : false, has_action( 'od_register_tag_visitors', 'embed_optimizer_register_tag_visitors' ) ); + $this->assertSame( $expected ? 10 : false, has_action( 'embed_oembed_html', 'embed_optimizer_filter_oembed_html_to_detect_embed_presence' ) ); + $this->assertSame( $expected ? 10 : false, has_filter( 'od_url_metric_schema_element_item_additional_properties', 'embed_optimizer_add_element_item_schema_properties' ) ); } /** * Test that the oEmbed HTML is filtered. * - * @covers ::embed_optimizer_filter_oembed_html + * @covers ::embed_optimizer_filter_oembed_html_to_lazy_load * @covers ::embed_optimizer_update_markup * @dataProvider get_data_to_test_filter_oembed_html_data */ - public function test_embed_optimizer_filter_oembed_html( string $html, string $expected_html = null, bool $expected_lazy_script = false ): void { + public function test_embed_optimizer_filter_oembed_html_to_lazy_load( string $html, string $expected_html = null, bool $expected_lazy_script = false ): void { if ( null === $expected_html ) { $expected_html = $html; // No change. } - $this->assertEquals( $expected_html, embed_optimizer_filter_oembed_html( $html ) ); + $this->assertEquals( $expected_html, embed_optimizer_filter_oembed_html_to_lazy_load( $html ) ); $this->assertSame( $expected_lazy_script ? 10 : false, has_action( 'wp_footer', 'embed_optimizer_lazy_load_scripts' ) ); } diff --git a/plugins/embed-optimizer/tests/test-optimization-detective.php b/plugins/embed-optimizer/tests/test-optimization-detective.php index eed257784c..9994859e1d 100644 --- a/plugins/embed-optimizer/tests/test-optimization-detective.php +++ b/plugins/embed-optimizer/tests/test-optimization-detective.php @@ -18,6 +18,12 @@ public function set_up(): void { if ( ! defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) { $this->markTestSkipped( 'Optimization Detective is not active.' ); } + + // Normalize the data for computing the current URL Metrics ETag to work around the issue where there is no + // global variable storing the OD_Tag_Visitor_Registry instance along with any registered tag visitors, so + // during set up we do not know what the ETag will look like. The current ETag is only established when + // the output begins to be processed by od_optimize_template_output_buffer(). + add_filter( 'od_current_url_metrics_etag_data', '__return_empty_array' ); } /** @@ -32,6 +38,46 @@ public function test_embed_optimizer_register_tag_visitors(): void { $this->assertInstanceOf( Embed_Optimizer_Tag_Visitor::class, $registry->get_registered( 'embeds' ) ); } + + /** + * Tests embed_optimizer_add_element_item_schema_properties(). + * + * @covers ::embed_optimizer_add_element_item_schema_properties + */ + public function test_embed_optimizer_add_element_item_schema_properties(): void { + $props = embed_optimizer_add_element_item_schema_properties( array( 'foo' => array() ) ); + $this->assertArrayHasKey( 'foo', $props ); + $this->assertArrayHasKey( 'resizedBoundingClientRect', $props ); + $this->assertArrayHasKey( 'properties', $props['resizedBoundingClientRect'] ); + } + + /** + * Tests embed_optimizer_filter_extension_module_urls(). + * + * @covers ::embed_optimizer_filter_extension_module_urls + */ + public function test_embed_optimizer_filter_extension_module_urls(): void { + $urls = embed_optimizer_filter_extension_module_urls( null ); + $this->assertCount( 1, $urls ); + $this->assertStringContainsString( 'detect', $urls[0] ); + + $urls = embed_optimizer_filter_extension_module_urls( array( 'foo.js' ) ); + $this->assertCount( 2, $urls ); + $this->assertStringContainsString( 'foo.js', $urls[0] ); + $this->assertStringContainsString( 'detect', $urls[1] ); + } + + /** + * Tests embed_optimizer_filter_oembed_html_to_detect_embed_presence(). + * + * @covers ::embed_optimizer_filter_oembed_html_to_detect_embed_presence + */ + public function test_embed_optimizer_filter_oembed_html_to_detect_embed_presence(): void { + $this->assertFalse( has_filter( 'od_extension_module_urls', 'embed_optimizer_filter_extension_module_urls' ) ); + $this->assertSame( '...', embed_optimizer_filter_oembed_html_to_detect_embed_presence( '...' ) ); + $this->assertSame( 10, has_filter( 'od_extension_module_urls', 'embed_optimizer_filter_extension_module_urls' ) ); + } + /** * Data provider. * diff --git a/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php index 1b2379df36..0661bdfb98 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php @@ -14,16 +14,47 @@ /** * Tag visitor that optimizes elements with background-image styles. * + * @phpstan-type LcpElementExternalBackgroundImage array{ + * url: non-empty-string, + * tag: non-empty-string, + * id: string|null, + * class: string|null, + * } + * * @since 0.1.0 * @access private */ final class Image_Prioritizer_Background_Image_Styled_Tag_Visitor extends Image_Prioritizer_Tag_Visitor { + /** + * Class name used to indicate a background image which is lazy-loaded. + * + * @since 0.3.0 + * @var string + */ + const LAZY_BG_IMAGE_CLASS_NAME = 'od-lazy-bg-image'; + + /** + * Whether the lazy-loading script and stylesheet have been added. + * + * @since 0.3.0 + * @var bool + */ + private $added_lazy_assets = false; + + /** + * Tuples of URL Metric group and the common LCP element external background image. + * + * @since 0.3.0 + * @var array + */ + private $group_common_lcp_element_external_background_images; + /** * Visits a tag. * * @param OD_Tag_Visitor_Context $context Tag visitor context. - * @return bool Whether the tag should be tracked in URL metrics. + * @return bool Whether the tag should be tracked in URL Metrics. */ public function __invoke( OD_Tag_Visitor_Context $context ): bool { $processor = $context->processor; @@ -49,6 +80,7 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { } if ( is_null( $background_image_url ) ) { + $this->maybe_preload_external_lcp_background_image( $context ); return false; } @@ -56,21 +88,152 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { // If this element is the LCP (for a breakpoint group), add a preload link for it. foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) { - $link_attributes = array( + $this->add_image_preload_link( $context->link_collection, $group, $background_image_url ); + } + + $this->lazy_load_bg_images( $context ); + + return true; + } + + /** + * Gets the common LCP element external background image for a URL Metric group. + * + * @since 0.3.0 + * + * @param OD_URL_Metric_Group $group Group. + * @return LcpElementExternalBackgroundImage|null + */ + private function get_common_lcp_element_external_background_image( OD_URL_Metric_Group $group ): ?array { + + // If the group is not fully populated, we don't have enough URL Metrics to reliably know whether the background image is consistent across page loads. + // This is intentionally not using $group->is_complete() because we still will use stale URL Metrics in the calculation. + if ( $group->count() !== $group->get_sample_size() ) { + return null; + } + + $previous_lcp_element_external_background_image = null; + foreach ( $group as $url_metric ) { + /** + * Stored data. + * + * @var LcpElementExternalBackgroundImage|null $lcp_element_external_background_image + */ + $lcp_element_external_background_image = $url_metric->get( 'lcpElementExternalBackgroundImage' ); + if ( ! is_array( $lcp_element_external_background_image ) ) { + return null; + } + if ( null !== $previous_lcp_element_external_background_image && $previous_lcp_element_external_background_image !== $lcp_element_external_background_image ) { + return null; + } + $previous_lcp_element_external_background_image = $lcp_element_external_background_image; + } + + return $previous_lcp_element_external_background_image; + } + + /** + * Maybe preloads external background image. + * + * @since 0.3.0 + * + * @param OD_Tag_Visitor_Context $context Context. + */ + private function maybe_preload_external_lcp_background_image( OD_Tag_Visitor_Context $context ): void { + // Gather the tuples of URL Metric group and the common LCP element external background image. + // Note the groups of URL Metrics do not change across invocations, we just need to compute this once for all. + if ( ! is_array( $this->group_common_lcp_element_external_background_images ) ) { + $this->group_common_lcp_element_external_background_images = array(); + foreach ( $context->url_metric_group_collection as $group ) { + $common = $this->get_common_lcp_element_external_background_image( $group ); + if ( is_array( $common ) ) { + $this->group_common_lcp_element_external_background_images[] = array( $group, $common ); + } + } + } + + // There are no common LCP background images, so abort. + if ( count( $this->group_common_lcp_element_external_background_images ) === 0 ) { + return; + } + + $processor = $context->processor; + $tag_name = strtoupper( (string) $processor->get_tag() ); + foreach ( array_keys( $this->group_common_lcp_element_external_background_images ) as $i ) { + list( $group, $common ) = $this->group_common_lcp_element_external_background_images[ $i ]; + if ( + // Note that the browser may send a lower-case tag name in the case of XHTML or embedded SVG/MathML, but + // the HTML Tag Processor is currently normalizing to all upper-case. The HTML Processor on the other + // hand may return the expected case. + strtoupper( $common['tag'] ) === $tag_name + && + $processor->get_attribute( 'id' ) === $common['id'] // May be checking equality with null. + && + $processor->get_attribute( 'class' ) === $common['class'] // May be checking equality with null. + ) { + $this->add_image_preload_link( $context->link_collection, $group, $common['url'] ); + + // Now that the preload link has been added, eliminate the entry to stop looking for it while iterating over the rest of the document. + unset( $this->group_common_lcp_element_external_background_images[ $i ] ); + } + } + } + + /** + * Adds an image preload link for the group. + * + * @since 0.3.0 + * + * @param OD_Link_Collection $link_collection Link collection. + * @param OD_URL_Metric_Group $group URL Metric group. + * @param non-empty-string $url Image URL. + */ + private function add_image_preload_link( OD_Link_Collection $link_collection, OD_URL_Metric_Group $group, string $url ): void { + $link_collection->add_link( + array( 'rel' => 'preload', 'fetchpriority' => 'high', 'as' => 'image', - 'href' => $background_image_url, + 'href' => $url, 'media' => 'screen', - ); + ), + $group->get_minimum_viewport_width(), + $group->get_maximum_viewport_width() + ); + } - $context->link_collection->add_link( - $link_attributes, - $group->get_minimum_viewport_width(), - $group->get_maximum_viewport_width() - ); + /** + * Optimizes an element with a background image based on whether it is displayed in any initial viewport. + * + * @since 0.3.0 + * + * @param OD_Tag_Visitor_Context $context Tag visitor context, with the cursor currently at block with a background image. + */ + private function lazy_load_bg_images( OD_Tag_Visitor_Context $context ): void { + $processor = $context->processor; + + // Lazy-loading can only be done once there are URL Metrics collected for both mobile and desktop. + if ( + $context->url_metric_group_collection->get_first_group()->count() === 0 + || + $context->url_metric_group_collection->get_last_group()->count() === 0 + ) { + return; } - return true; + $xpath = $processor->get_xpath(); + + // If the element is in the initial viewport, do not lazy load its background image. + if ( false !== $context->url_metric_group_collection->is_element_positioned_in_any_initial_viewport( $xpath ) ) { + return; + } + + $processor->add_class( self::LAZY_BG_IMAGE_CLASS_NAME ); + + if ( ! $this->added_lazy_assets ) { + $processor->append_head_html( sprintf( "\n", image_prioritizer_get_lazy_load_bg_image_stylesheet() ) ); + $processor->append_body_html( wp_get_inline_script_tag( image_prioritizer_get_lazy_load_bg_image_script(), array( 'type' => 'module' ) ) ); + $this->added_lazy_assets = true; + } } } diff --git a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php index 7884de1b5b..15a3008ce4 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -14,6 +14,8 @@ /** * Tag visitor that optimizes IMG tags. * + * @phpstan-import-type LinkAttributes from OD_Link_Collection + * * @since 0.1.0 * @access private */ @@ -22,37 +24,45 @@ final class Image_Prioritizer_Img_Tag_Visitor extends Image_Prioritizer_Tag_Visi /** * Visits a tag. * - * @param OD_Tag_Visitor_Context $context Tag visitor context. + * @since 0.1.0 + * @since 0.3.0 Separate the processing of IMG and PICTURE elements. * - * @return bool Whether the tag should be tracked in URL metrics. + * @param OD_Tag_Visitor_Context $context Tag visitor context. + * @return bool Whether the tag should be tracked in URL Metrics. */ public function __invoke( OD_Tag_Visitor_Context $context ): bool { $processor = $context->processor; - if ( 'IMG' !== $processor->get_tag() ) { - return false; + $tag = $processor->get_tag(); + + if ( 'PICTURE' === $tag ) { + return $this->process_picture( $processor, $context ); + } elseif ( 'IMG' === $tag ) { + return $this->process_img( $processor, $context ); } - // Skip empty src attributes and data: URLs. - $src = trim( (string) $processor->get_attribute( 'src' ) ); - if ( '' === $src || $this->is_data_url( $src ) ) { + return false; + } + + /** + * Process an IMG element. + * + * @since 0.3.0 + * + * @param OD_HTML_Tag_Processor $processor HTML tag processor. + * @param OD_Tag_Visitor_Context $context Tag visitor context. + * @return bool Whether the tag should be tracked in URL Metrics. + */ + private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool { + $src = $this->get_valid_src( $processor ); + if ( null === $src ) { return false; } $xpath = $processor->get_xpath(); - /** - * Gets attribute value. - * - * @param string $attribute_name Attribute name. - * @return string|true|null Normalized attribute value. - */ - $get_attribute_value = static function ( string $attribute_name ) use ( $processor ) { - $value = $processor->get_attribute( $attribute_name ); - if ( is_string( $value ) ) { - $value = strtolower( trim( $value, " \t\f\r\n" ) ); - } - return $value; - }; + $current_fetchpriority = $this->get_attribute_value( $processor, 'fetchpriority' ); + $is_lazy_loaded = 'lazy' === $this->get_attribute_value( $processor, 'loading' ); + $updated_fetchpriority = null; /* * When the same LCP element is common/shared among all viewport groups, make sure that the element has @@ -60,17 +70,10 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { * will also be added. Additionally, ensure that this common LCP element is never lazy-loaded. */ $common_lcp_element = $context->url_metric_group_collection->get_common_lcp_element(); - if ( ! is_null( $common_lcp_element ) && $xpath === $common_lcp_element['xpath'] ) { - if ( 'high' === $get_attribute_value( 'fetchpriority' ) ) { - $processor->set_meta_attribute( 'fetchpriority-already-added', true ); - } else { - $processor->set_attribute( 'fetchpriority', 'high' ); - } + if ( $common_lcp_element instanceof OD_Element && $xpath === $common_lcp_element->get_xpath() ) { + $updated_fetchpriority = 'high'; } elseif ( - is_string( $processor->get_attribute( 'fetchpriority' ) ) - && - // Temporary condition in case someone updates Image Prioritizer without also updating Optimization Detective. - method_exists( $context->url_metric_group_collection, 'is_any_group_populated' ) + 'high' === $current_fetchpriority && $context->url_metric_group_collection->is_any_group_populated() ) { @@ -78,36 +81,74 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { * At this point, the element is not the shared LCP across all viewport groups. Nevertheless, server-side * heuristics have added fetchpriority=high to the element, but this is not warranted either due to a lack * of data or because the LCP element is not common across all viewport groups. Since we have collected at - * least some URL metrics (per is_any_group_populated), further below a fetchpriority=high preload link will + * least some URL Metrics (per is_any_group_populated), further below a fetchpriority=high preload link will * be added for the viewport(s) for which this is actually the LCP element. Some viewport groups may never * get populated due to a lack of traffic (e.g. from tablets or phablets), so it is important to remove * fetchpriority=high in such case to prevent server-side heuristics from prioritizing loading the image * which isn't actually the LCP element for actual visitors. */ - $processor->remove_attribute( 'fetchpriority' ); + $updated_fetchpriority = false; // That is, remove it. } + /* + * Do not do any lazy-loading if the mobile and desktop viewport groups lack URL Metrics. This is important + * because if there is an IMG in the initial viewport on desktop but not mobile, if then there are only URL + * metrics collected for mobile then the IMG will get lazy-loaded which is good for mobile but for desktop + * it will hurt performance. So this is why it is important to have URL Metrics collected for both desktop and + * mobile to verify whether maximum intersectionRatio is accounting for both screen sizes. + */ $element_max_intersection_ratio = $context->url_metric_group_collection->get_element_max_intersection_ratio( $xpath ); // If the element was not found, we don't know if it was visible for not, so don't do anything. if ( is_null( $element_max_intersection_ratio ) ) { $processor->set_meta_attribute( 'unknown-tag', true ); // Mostly useful for debugging why an IMG isn't optimized. - } else { + } elseif ( + $context->url_metric_group_collection->get_first_group()->count() > 0 + && + $context->url_metric_group_collection->get_last_group()->count() > 0 + ) { + // TODO: Take into account whether the element has the computed style of visibility:hidden, in such case it should also get fetchpriority=low. // Otherwise, make sure visible elements omit the loading attribute, and hidden elements include loading=lazy. $is_visible = $element_max_intersection_ratio > 0.0; - $loading = $get_attribute_value( 'loading' ); - if ( $is_visible && 'lazy' === $loading ) { - $processor->remove_attribute( 'loading' ); - } elseif ( ! $is_visible && 'lazy' !== $loading ) { + if ( true === $context->url_metric_group_collection->is_element_positioned_in_any_initial_viewport( $xpath ) ) { + if ( ! $is_visible ) { + // If an element is positioned in the initial viewport and yet it is it not visible, it may be + // located in a subsequent carousel slide or inside a hidden navigation menu which could be + // displayed at any time. Therefore, it should get fetchpriority=low so that any images which are + // visible can be loaded with a higher priority. + $updated_fetchpriority = 'low'; + + // Also prevent the image from being lazy-loaded (or eager-loaded) since it may be revealed at any + // time without the browser having any signal (e.g. user scrolling toward it) to start downloading. + $processor->remove_attribute( 'loading' ); + } elseif ( $is_lazy_loaded ) { + // Otherwise, if the image is positioned inside any initial viewport then it should never get lazy-loaded. + $processor->remove_attribute( 'loading' ); + } + } elseif ( ! $is_lazy_loaded && ! $is_visible ) { + // Otherwise, the element is not positioned in any initial viewport, so it should always get lazy-loaded. + // The `! $is_visible` condition should always evaluate to true since the intersectionRatio of an + // element positioned below the initial viewport should by definition never be visible. $processor->set_attribute( 'loading', 'lazy' ); } } // TODO: If an image is visible in one breakpoint but not another, add loading=lazy AND add a regular-priority preload link with media queries (unless LCP in which case it should already have a fetchpriority=high link) so that the image won't be eagerly-loaded for viewports on which it is not shown. + // Set the fetchpriority attribute if needed. + if ( is_string( $updated_fetchpriority ) ) { + if ( $updated_fetchpriority !== $current_fetchpriority ) { + $processor->set_attribute( 'fetchpriority', $updated_fetchpriority ); + } else { + $processor->set_meta_attribute( 'fetchpriority-already-added', true ); + } + } elseif ( false === $updated_fetchpriority ) { + $processor->remove_attribute( 'fetchpriority' ); + } + // Ensure that sizes=auto is set properly. $sizes = $processor->get_attribute( 'sizes' ); if ( is_string( $sizes ) ) { - $is_lazy = 'lazy' === $get_attribute_value( 'loading' ); + $is_lazy = 'lazy' === $this->get_attribute_value( $processor, 'loading' ); $has_auto = $this->sizes_attribute_includes_valid_auto( $sizes ); if ( $is_lazy && ! $has_auto ) { @@ -121,41 +162,207 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { } } - // If this element is the LCP (for a breakpoint group), add a preload link for it. - foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) { - $link_attributes = array_merge( + $parent_tag = $this->get_parent_tag_name( $context ); + if ( 'PICTURE' !== $parent_tag ) { + $this->add_image_preload_link_for_lcp_element_groups( + $context, + $xpath, array( - 'rel' => 'preload', - 'fetchpriority' => 'high', - 'as' => 'image', - ), - array_filter( - array( - 'href' => (string) $processor->get_attribute( 'src' ), - 'imagesrcset' => (string) $processor->get_attribute( 'srcset' ), - 'imagesizes' => (string) $processor->get_attribute( 'sizes' ), - ), - static function ( string $value ): bool { - return '' !== $value; - } + 'href' => $processor->get_attribute( 'src' ), + 'imagesrcset' => $processor->get_attribute( 'srcset' ), + 'imagesizes' => $processor->get_attribute( 'sizes' ), + 'crossorigin' => $this->get_attribute_value( $processor, 'crossorigin' ), + 'referrerpolicy' => $this->get_attribute_value( $processor, 'referrerpolicy' ), ) ); + } + + return true; + } + + /** + * Process a PICTURE element. + * + * @since 0.3.0 + * + * @param OD_HTML_Tag_Processor $processor HTML tag processor. + * @param OD_Tag_Visitor_Context $context Tag visitor context. + * @return bool Whether the tag should be tracked in URL Metrics. + */ + private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_Context $context ): bool { + /** + * First SOURCE tag's attributes. + * + * @var array{ srcset: non-empty-string, sizes: string|null, type: non-empty-string }|null $first_source + */ + $first_source = null; + $img_xpath = null; - $crossorigin = $get_attribute_value( 'crossorigin' ); - if ( null !== $crossorigin ) { - $link_attributes['crossorigin'] = 'use-credentials' === $crossorigin ? 'use-credentials' : 'anonymous'; + $referrerpolicy = null; + $crossorigin = null; + + // Loop through child tags until we reach the closing PICTURE tag. + while ( $processor->next_tag() ) { + $tag = $processor->get_tag(); + + // If we reached the closing PICTURE tag, break. + if ( 'PICTURE' === $tag && $processor->is_tag_closer() ) { + break; + } + + // Process the SOURCE elements. + if ( 'SOURCE' === $tag && ! $processor->is_tag_closer() ) { + // Abort processing if the PICTURE involves art direction since then adding a preload link is infeasible. + if ( null !== $processor->get_attribute( 'media' ) ) { + return false; + } + + // Abort processing if a SOURCE lacks the required srcset attribute. + $srcset = $this->get_valid_src( $processor, 'srcset' ); + if ( null === $srcset ) { + return false; + } + + // Abort processing if there is no valid image type. + $type = $this->get_attribute_value( $processor, 'type' ); + if ( ! is_string( $type ) || ! str_starts_with( $type, 'image/' ) ) { + return false; + } + + // Collect the first valid SOURCE as the preload link. + if ( null === $first_source ) { + $sizes = $processor->get_attribute( 'sizes' ); + $first_source = array( + 'srcset' => $srcset, + 'sizes' => is_string( $sizes ) ? $sizes : null, + 'type' => $type, + ); + } + } + + // Process the IMG element within the PICTURE. + if ( 'IMG' === $tag && ! $processor->is_tag_closer() ) { + $src = $this->get_valid_src( $processor ); + if ( null === $src ) { + return false; + } + + // These attributes are only defined on the IMG itself. + $referrerpolicy = $this->get_attribute_value( $processor, 'referrerpolicy' ); + $crossorigin = $this->get_attribute_value( $processor, 'crossorigin' ); + + // Capture the XPath for the IMG since the browser captures it as the LCP element, so we need this to + // look up whether it is the LCP element in the URL Metric groups. + $img_xpath = $processor->get_xpath(); } + } - $link_attributes['media'] = 'screen'; + // Abort if we never encountered a SOURCE or IMG tag. + if ( null === $img_xpath || null === $first_source ) { + return false; + } + + $this->add_image_preload_link_for_lcp_element_groups( + $context, + $img_xpath, + array( + 'imagesrcset' => $first_source['srcset'], + 'imagesizes' => $first_source['sizes'], + 'type' => $first_source['type'], + 'crossorigin' => $crossorigin, + 'referrerpolicy' => $referrerpolicy, + ) + ); + return false; + } + + /** + * Gets valid src attribute value for preloading. + * + * Returns null if the src attribute is not a string (i.e. src was used as a boolean attribute was used), if it + * it has an empty string value after trimming, or if it is a data: URL. + * + * @since 0.3.0 + * + * @param OD_HTML_Tag_Processor $processor Processor. + * @param 'src'|'srcset' $attribute_name Attribute name. + * @return non-empty-string|null URL which is not a data: URL. + */ + private function get_valid_src( OD_HTML_Tag_Processor $processor, string $attribute_name = 'src' ): ?string { + $src = $processor->get_attribute( $attribute_name ); + if ( ! is_string( $src ) ) { + return null; + } + $src = trim( $src ); + if ( '' === $src || $this->is_data_url( $src ) ) { + return null; + } + return $src; + } + + /** + * Adds a LINK with the supplied attributes for each viewport group when the provided XPath is the LCP element. + * + * @since 0.3.0 + * + * @param OD_Tag_Visitor_Context $context Tag visitor context. + * @param string $xpath XPath of the element. + * @param array $attributes Attributes to add to the link. + */ + private function add_image_preload_link_for_lcp_element_groups( OD_Tag_Visitor_Context $context, string $xpath, array $attributes ): void { + $attributes = array_filter( + $attributes, + static function ( $attribute_value ) { + return is_string( $attribute_value ) && '' !== $attribute_value; + } + ); + + /** + * Link attributes. + * + * This type is needed because PHPStan isn't apparently aware of the new keys added after the array_merge(). + * Note that there is no type checking being done on the attributes above other than ensuring they are + * non-empty-strings. + * + * @var LinkAttributes $attributes + */ + $attributes = array_merge( + array( + 'rel' => 'preload', + 'fetchpriority' => 'high', + 'as' => 'image', + ), + $attributes, + array( + 'media' => 'screen', + ) + ); + + foreach ( $context->url_metric_group_collection->get_groups_by_lcp_element( $xpath ) as $group ) { $context->link_collection->add_link( - $link_attributes, + $attributes, $group->get_minimum_viewport_width(), $group->get_maximum_viewport_width() ); } + } - return true; + /** + * Gets the parent tag name. + * + * @since 0.3.0 + * + * @param OD_Tag_Visitor_Context $context Tag visitor context. + * @return string|null The parent tag name or null if not found. + */ + private function get_parent_tag_name( OD_Tag_Visitor_Context $context ): ?string { + $breadcrumbs = $context->processor->get_breadcrumbs(); + $length = count( $breadcrumbs ); + if ( $length < 2 ) { + return null; + } + return $breadcrumbs[ $length - 2 ]; } /** diff --git a/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php index e0f4d564e7..ba2850f6a8 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-tag-visitor.php @@ -14,6 +14,8 @@ /** * Tag visitor that optimizes image tags. * + * @phpstan-type NormalizedAttributeNames 'fetchpriority'|'loading'|'crossorigin'|'preload'|'referrerpolicy'|'type' + * * @since 0.1.0 * @access private */ @@ -23,7 +25,7 @@ abstract class Image_Prioritizer_Tag_Visitor { * Visits a tag. * * @param OD_Tag_Visitor_Context $context Tag visitor context. - * @return bool Whether the tag should be tracked in URL metrics. + * @return bool Whether the tag should be tracked in URL Metrics. */ abstract public function __invoke( OD_Tag_Visitor_Context $context ): bool; @@ -36,4 +38,32 @@ abstract public function __invoke( OD_Tag_Visitor_Context $context ): bool; protected function is_data_url( string $url ): bool { return str_starts_with( strtolower( $url ), 'data:' ); } + + /** + * Gets attribute value for select attributes. + * + * @since 0.2.0 + * @todo Move this into the OD_HTML_Tag_Processor/OD_HTML_Processor class eventually. + * @todo It would be nice if PHPStan could know that if you pass 'crossorigin' as $attribute_name that you will get back null|'anonymous'|'use-credentials'. + * + * @phpstan-param NormalizedAttributeNames $attribute_name + * + * @param OD_HTML_Tag_Processor $processor Processor. + * @param string $attribute_name Attribute name. + * @return string|true|null Normalized attribute value. + */ + protected function get_attribute_value( OD_HTML_Tag_Processor $processor, string $attribute_name ) { + $value = $processor->get_attribute( $attribute_name ); + if ( null === $value ) { + return null; + } + + if ( is_string( $value ) ) { + $value = strtolower( trim( $value, " \t\f\r\n" ) ); + } + if ( 'crossorigin' === $attribute_name && 'use-credentials' !== $value ) { + $value = 'anonymous'; + } + return $value; + } } diff --git a/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php new file mode 100644 index 0000000000..4399d7a40e --- /dev/null +++ b/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php @@ -0,0 +1,247 @@ +processor; + if ( 'VIDEO' !== $processor->get_tag() ) { + return false; + } + + $poster = $this->get_poster( $context ); + + if ( null !== $poster ) { + $this->reduce_poster_image_size( $poster, $context ); + $this->preload_poster_image( $poster, $context ); + } + + $this->lazy_load_videos( $poster, $context ); + + return true; + } + + /** + * Gets the poster from the current VIDEO element. + * + * Skips empty poster attributes and data: URLs. + * + * @since 0.2.0 + * + * @param OD_Tag_Visitor_Context $context Tag visitor context. + * @return non-empty-string|null Poster or null if not defined or is a data: URL. + */ + private function get_poster( OD_Tag_Visitor_Context $context ): ?string { + $poster = trim( (string) $context->processor->get_attribute( 'poster' ) ); + if ( '' === $poster || $this->is_data_url( $poster ) ) { + return null; + } + return $poster; + } + + /** + * Reduces poster image size by choosing one that fits the maximum video size more closely. + * + * @since 0.2.0 + * + * @param non-empty-string $poster Poster image URL. + * @param OD_Tag_Visitor_Context $context Tag visitor context, with the cursor currently at a VIDEO tag. + */ + private function reduce_poster_image_size( string $poster, OD_Tag_Visitor_Context $context ): void { + $processor = $context->processor; + + $xpath = $processor->get_xpath(); + + /* + * Obtain maximum width of the element exclusively from the URL Metrics group with the widest viewport width, + * which would be desktop. This prevents the situation where if URL Metrics have only so far been gathered for + * mobile viewports that an excessively-small poster would end up getting served to the first desktop visitor. + */ + $max_element_width = 0; + foreach ( $context->url_metric_group_collection->get_last_group() as $url_metric ) { + foreach ( $url_metric->get_elements() as $element ) { + if ( $element->get_xpath() === $xpath ) { + $max_element_width = max( $max_element_width, $element->get_bounding_client_rect()['width'] ); + break; // Move on to the next URL Metric. + } + } + } + + // If the element wasn't present in any URL Metrics gathered for desktop, then abort downsizing the poster. + if ( 0 === $max_element_width ) { + return; + } + + $poster_id = attachment_url_to_postid( $poster ); + + if ( $poster_id > 0 ) { + $smaller_image_url = wp_get_attachment_image_url( $poster_id, array( (int) $max_element_width, 0 ) ); + if ( is_string( $smaller_image_url ) ) { + $processor->set_attribute( 'poster', $smaller_image_url ); + } + } + } + + /** + * Preloads poster image for the LCP
+

Pretend this is a super long paragraph that pushes the next div out of the initial viewport.

+
This is so background!
@@ -66,6 +84,9 @@ static function () { ... + @@ -89,7 +110,10 @@ static function () {

This post does have a featured image, and the server-side heuristics in WordPress cause it to get fetchpriority=high, but it should not have this since it is out of the viewport on mobile.

+

Pretend this is a super long paragraph that pushes the next div out of the initial viewport.

+
This is so background!
+ diff --git a/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php b/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php new file mode 100644 index 0000000000..f4ca8404ad --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/picture-element-as-lcp-tablet-and-desktop-metrics-missing.php @@ -0,0 +1,68 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + + // Only populate the mobile and phablet viewport groups. + foreach ( array( 480, 600 ) as $viewport_width ) { + for ( $i = 0; $i < $sample_size; $i++ ) { + OD_URL_Metrics_Post_Type::store_url_metric( + $slug, + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'elements' => array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::PICTURE]/*[3][self::IMG]', + 'isLCP' => true, + ), + ), + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + + + + + Foo + + + + ', + 'expected' => ' + + + + ... + + + + + + + Foo + + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-cases/picture-element-with-lcp-image-and-fully-populated-sample-data.php b/plugins/image-prioritizer/tests/test-cases/picture-element-with-lcp-image-and-fully-populated-sample-data.php new file mode 100644 index 0000000000..836c0d993c --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/picture-element-with-lcp-image-and-fully-populated-sample-data.php @@ -0,0 +1,51 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + $test_case->populate_url_metrics( + array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::PICTURE]/*[2][self::IMG]', + 'isLCP' => true, + ), + ) + ); + }, + 'buffer' => ' + + + + ... + + + + + Foo + + + + ', + 'expected' => ' + + + + ... + + + + + + Foo + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-having-media-attribute.php b/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-having-media-attribute.php new file mode 100644 index 0000000000..8f2af94d69 --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-having-media-attribute.php @@ -0,0 +1,50 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + $test_case->populate_url_metrics( + array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::PICTURE]/*[2][self::IMG]', + 'isLCP' => true, + ), + ) + ); + }, + 'buffer' => ' + + + + ... + + + + + Foo + + + + ', + 'expected' => ' + + + + ... + + + + + Foo + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-missing-type-attribute.php b/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-missing-type-attribute.php new file mode 100644 index 0000000000..24c4021b01 --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/picture-element-with-source-missing-type-attribute.php @@ -0,0 +1,50 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + $test_case->populate_url_metrics( + array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::PICTURE]/*[2][self::IMG]', + 'isLCP' => true, + ), + ) + ); + }, + 'buffer' => ' + + + + ... + + + + + Foo + + + + ', + 'expected' => ' + + + + ... + + + + + Foo + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-cases/responsive-background-images.php b/plugins/image-prioritizer/tests/test-cases/responsive-background-images.php index aabfab56ea..1f9d515776 100644 --- a/plugins/image-prioritizer/tests/test-cases/responsive-background-images.php +++ b/plugins/image-prioritizer/tests/test-cases/responsive-background-images.php @@ -56,9 +56,9 @@ static function () use ( $mobile_breakpoint, $tablet_breakpoint ): array { ... + -
This is the desktop background!
diff --git a/plugins/image-prioritizer/tests/test-cases/video-with-large-poster-and-desktop-url-metrics-collected.php b/plugins/image-prioritizer/tests/test-cases/video-with-large-poster-and-desktop-url-metrics-collected.php new file mode 100644 index 0000000000..876d50c423 --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/video-with-large-poster-and-desktop-url-metrics-collected.php @@ -0,0 +1,82 @@ + static function ( Test_Image_Prioritizer_Helper $test_case, WP_UnitTest_Factory $factory ) use ( &$full_url, &$expected_url ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + $element = array( + 'isLCP' => false, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::VIDEO]', + 'boundingClientRect' => $test_case->get_sample_dom_rect(), + 'intersectionRatio' => 1.0, + ); + + foreach ( array_merge( $breakpoint_max_widths, array( 1000 ) ) as $viewport_width ) { + OD_URL_Metrics_Post_Type::store_url_metric( + od_get_url_metrics_slug( od_get_normalized_query_vars() ), + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'elements' => array( $element ), + ) + ) + ); + } + + $attachment_id = $factory->attachment->create_object( + DIR_TESTDATA . '/images/33772.jpg', + 0, + array( + 'post_mime_type' => 'image/jpeg', + 'post_excerpt' => 'A sample caption', + ) + ); + + wp_generate_attachment_metadata( $attachment_id, DIR_TESTDATA . '/images/33772.jpg' ); + + $full_url = wp_get_attachment_url( $attachment_id ); + $expected_url = wp_get_attachment_image_url( $attachment_id, array( (int) $element['boundingClientRect']['width'], 0 ) ); + }, + 'buffer' => static function () use ( &$full_url ) { + return << + + + ... + + + + + +HTML; + }, + 'expected' => static function () use ( &$full_url, &$expected_url ) { + return << + + + ... + + + + + + +HTML; + }, +); diff --git a/plugins/image-prioritizer/tests/test-cases/video-with-large-poster-and-desktop-url-metrics-missing.php b/plugins/image-prioritizer/tests/test-cases/video-with-large-poster-and-desktop-url-metrics-missing.php new file mode 100644 index 0000000000..ebe62de558 --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/video-with-large-poster-and-desktop-url-metrics-missing.php @@ -0,0 +1,80 @@ + static function ( Test_Image_Prioritizer_Helper $test_case, WP_UnitTest_Factory $factory ) use ( &$full_url ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + $element = array( + 'isLCP' => false, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::VIDEO]', + 'boundingClientRect' => $test_case->get_sample_dom_rect(), + 'intersectionRatio' => 1.0, + ); + + foreach ( $breakpoint_max_widths as $non_desktop_viewport_width ) { + OD_URL_Metrics_Post_Type::store_url_metric( + od_get_url_metrics_slug( od_get_normalized_query_vars() ), + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $non_desktop_viewport_width, + 'elements' => array( $element ), + ) + ) + ); + } + + $attachment_id = $factory->attachment->create_object( + DIR_TESTDATA . '/images/33772.jpg', + 0, + array( + 'post_mime_type' => 'image/jpeg', + 'post_excerpt' => 'A sample caption', + ) + ); + + wp_generate_attachment_metadata( $attachment_id, DIR_TESTDATA . '/images/33772.jpg' ); + + $full_url = wp_get_attachment_url( $attachment_id ); + }, + 'buffer' => static function () use ( &$full_url ) { + return << + + + ... + + + + + +HTML; + }, + 'expected' => static function () use ( &$full_url ) { + return << + + + ... + + + + + + +HTML; + }, +); diff --git a/plugins/image-prioritizer/tests/test-cases/video-with-poster-lcp-element-on-all-breakpoints.php b/plugins/image-prioritizer/tests/test-cases/video-with-poster-lcp-element-on-all-breakpoints.php new file mode 100644 index 0000000000..1029644204 --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/video-with-poster-lcp-element-on-all-breakpoints.php @@ -0,0 +1,54 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + foreach ( array_merge( $breakpoint_max_widths, array( 1000 ) ) as $viewport_width ) { + OD_URL_Metrics_Post_Type::store_url_metric( + od_get_url_metrics_slug( od_get_normalized_query_vars() ), + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'elements' => array( + array( + 'isLCP' => true, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::VIDEO]', + ), + ), + ) + ) + ); + } + }, + 'buffer' => ' + + + + ... + + + + + + ', + 'expected' => ' + + + + ... + + + + + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-cases/video-with-poster-lcp-element-on-desktop-only.php b/plugins/image-prioritizer/tests/test-cases/video-with-poster-lcp-element-on-desktop-only.php new file mode 100644 index 0000000000..eceac8e3ca --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/video-with-poster-lcp-element-on-desktop-only.php @@ -0,0 +1,87 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + foreach ( $breakpoint_max_widths as $non_desktop_viewport_width ) { + $elements = array( + array( + 'isLCP' => true, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', + ), + array( + 'isLCP' => false, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::VIDEO]', + ), + ); + OD_URL_Metrics_Post_Type::store_url_metric( + od_get_url_metrics_slug( od_get_normalized_query_vars() ), + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $non_desktop_viewport_width, + 'elements' => $elements, + ) + ) + ); + } + + OD_URL_Metrics_Post_Type::store_url_metric( + od_get_url_metrics_slug( od_get_normalized_query_vars() ), + $test_case->get_sample_url_metric( + array( + 'viewport_width' => 1000, + 'elements' => array( + array( + 'isLCP' => false, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', + ), + array( + 'isLCP' => true, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::VIDEO]', + ), + ), + ) + ) + ); + }, + 'buffer' => ' + + + + ... + + + Mobile Logo + + + + ', + 'expected' => ' + + + + ... + + + + + Mobile Logo + + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-cases/video-with-poster-lcp-element-on-mobile-and-desktop-but-not-tablet.php b/plugins/image-prioritizer/tests/test-cases/video-with-poster-lcp-element-on-mobile-and-desktop-but-not-tablet.php new file mode 100644 index 0000000000..b8d3611277 --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/video-with-poster-lcp-element-on-mobile-and-desktop-but-not-tablet.php @@ -0,0 +1,101 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + OD_URL_Metrics_Post_Type::store_url_metric( + od_get_url_metrics_slug( od_get_normalized_query_vars() ), + $test_case->get_sample_url_metric( + array( + 'viewport_width' => 480, + 'elements' => array( + array( + 'isLCP' => false, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', + ), + array( + 'isLCP' => true, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::VIDEO]', + ), + ), + ) + ) + ); + + foreach ( array( 600, 782 ) as $tablet_viewport_width ) { + $elements = array( + array( + 'isLCP' => true, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', + ), + array( + 'isLCP' => false, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::VIDEO]', + ), + ); + OD_URL_Metrics_Post_Type::store_url_metric( + od_get_url_metrics_slug( od_get_normalized_query_vars() ), + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $tablet_viewport_width, + 'elements' => $elements, + ) + ) + ); + } + + OD_URL_Metrics_Post_Type::store_url_metric( + od_get_url_metrics_slug( od_get_normalized_query_vars() ), + $test_case->get_sample_url_metric( + array( + 'viewport_width' => 1000, + 'elements' => array( + array( + 'isLCP' => false, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', + ), + array( + 'isLCP' => true, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::VIDEO]', + ), + ), + ) + ) + ); + }, + 'buffer' => ' + + + + ... + + + Tablet header + + + + ', + 'expected' => ' + + + + ... + + + + + + Tablet header + + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-cases/video-without-poster-lcp-element-on-desktop-only.php b/plugins/image-prioritizer/tests/test-cases/video-without-poster-lcp-element-on-desktop-only.php new file mode 100644 index 0000000000..ccda59f24f --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/video-without-poster-lcp-element-on-desktop-only.php @@ -0,0 +1,82 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + $breakpoint_max_widths = array( 480, 600, 782 ); + + add_filter( + 'od_breakpoint_max_widths', + static function () use ( $breakpoint_max_widths ) { + return $breakpoint_max_widths; + } + ); + + foreach ( $breakpoint_max_widths as $non_desktop_viewport_width ) { + $elements = array( + array( + 'isLCP' => true, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', + ), + array( + 'isLCP' => false, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::VIDEO]', + ), + ); + OD_URL_Metrics_Post_Type::store_url_metric( + od_get_url_metrics_slug( od_get_normalized_query_vars() ), + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $non_desktop_viewport_width, + 'elements' => $elements, + ) + ) + ); + } + + OD_URL_Metrics_Post_Type::store_url_metric( + od_get_url_metrics_slug( od_get_normalized_query_vars() ), + $test_case->get_sample_url_metric( + array( + 'viewport_width' => 1000, + 'elements' => array( + array( + 'isLCP' => false, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', + ), + ), + ) + ) + ); + }, + 'buffer' => ' + + + + ... + + + Mobile Logo + + + + ', + 'expected' => ' + + + + ... + + + + Mobile Logo + + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-helper.php b/plugins/image-prioritizer/tests/test-helper.php index 708c062a53..dfd05b8549 100644 --- a/plugins/image-prioritizer/tests/test-helper.php +++ b/plugins/image-prioritizer/tests/test-helper.php @@ -10,6 +10,51 @@ class Test_Image_Prioritizer_Helper extends WP_UnitTestCase { use Optimization_Detective_Test_Helpers; + /** + * Runs the routine before each test is executed. + */ + public function set_up(): void { + parent::set_up(); + + // Normalize the data for computing the current URL Metrics ETag to work around the issue where there is no + // global variable storing the OD_Tag_Visitor_Registry instance along with any registered tag visitors, so + // during set up we do not know what the ETag will look like. The current ETag is only established when + // the output begins to be processed by od_optimize_template_output_buffer(). + add_filter( 'od_current_url_metrics_etag_data', '__return_empty_array' ); + } + + /** + * @return array> + */ + public function data_provider_to_test_image_prioritizer_init(): array { + return array( + 'with_old_version' => array( + 'version' => '0.5.0', + 'expected' => false, + ), + 'with_new_version' => array( + 'version' => '99.0.0', + 'expected' => true, + ), + ); + } + + /** + * @covers ::image_prioritizer_init + * @dataProvider data_provider_to_test_image_prioritizer_init + */ + public function test_image_prioritizer_init( string $version, bool $expected ): void { + remove_all_actions( 'admin_notices' ); + remove_all_actions( 'wp_head' ); + remove_all_actions( 'od_register_tag_visitors' ); + + image_prioritizer_init( $version ); + + $this->assertSame( ! $expected, has_action( 'admin_notices' ) ); + $this->assertSame( $expected ? 10 : false, has_action( 'wp_head', 'image_prioritizer_render_generator_meta_tag' ) ); + $this->assertSame( $expected ? 10 : false, has_action( 'od_register_tag_visitors', 'image_prioritizer_register_tag_visitors' ) ); + } + /** * Test printing the meta generator tag. * @@ -39,7 +84,7 @@ public function data_provider_test_filter_tag_visitors(): array { } /** - * Test image_prioritizer_register_tag_visitors(). + * Test end-to-end. * * @covers ::image_prioritizer_register_tag_visitors * @covers Image_Prioritizer_Tag_Visitor @@ -47,16 +92,34 @@ public function data_provider_test_filter_tag_visitors(): array { * @covers Image_Prioritizer_Background_Image_Styled_Tag_Visitor * * @dataProvider data_provider_test_filter_tag_visitors + * + * @param callable $set_up Setup function. + * @param callable|string $buffer Content before. + * @param callable|string $expected Expected content after. */ - public function test_image_prioritizer_register_tag_visitors( Closure $set_up, string $buffer, string $expected ): void { - $set_up( $this ); + public function test_end_to_end( callable $set_up, $buffer, $expected ): void { + $set_up( $this, $this::factory() ); - $buffer = preg_replace( - '::s', - '', - od_optimize_template_output_buffer( $buffer ) + $buffer = is_string( $buffer ) ? $buffer : $buffer(); + $buffer = od_optimize_template_output_buffer( $buffer ); + $buffer = preg_replace_callback( + ':():s', + static function ( $matches ) { + array_shift( $matches ); + if ( false !== strpos( $matches[1], 'import detect' ) ) { + $matches[1] = '/* import detect ... */'; + } elseif ( false !== strpos( $matches[1], 'const lazyVideoObserver' ) ) { + $matches[1] = '/* const lazyVideoObserver ... */'; + } elseif ( false !== strpos( $matches[1], 'const lazyBgImageObserver' ) ) { + $matches[1] = '/* const lazyBgImageObserver ... */'; + } + return implode( '', $matches ); + }, + $buffer ); + $expected = is_string( $expected ) ? $expected : $expected(); + $this->assertEquals( $this->remove_initial_tabs( $expected ), $this->remove_initial_tabs( $buffer ), @@ -70,6 +133,13 @@ public function test_image_prioritizer_register_tag_visitors( Closure $set_up, s * @return array Data. */ public function data_provider_test_auto_sizes(): array { + $outside_viewport_rect = array_merge( + $this->get_sample_dom_rect(), + array( + 'top' => 1000, + ) + ); + return array( // Note: The Image Prioritizer plugin removes the loading attribute, and so then Auto Sizes does not then add sizes=auto. 'wrongly_lazy_responsive_img' => array( @@ -84,9 +154,11 @@ public function data_provider_test_auto_sizes(): array { 'non_responsive_image' => array( 'element_metrics' => array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', - 'isLCP' => false, - 'intersectionRatio' => 0, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', + 'isLCP' => false, + 'intersectionRatio' => 0, + 'intersectionRect' => $outside_viewport_rect, + 'boundingClientRect' => $outside_viewport_rect, ), 'buffer' => 'Quux', 'expected' => 'Quux', @@ -94,9 +166,11 @@ public function data_provider_test_auto_sizes(): array { 'auto_sizes_added' => array( 'element_metrics' => array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', - 'isLCP' => false, - 'intersectionRatio' => 0, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', + 'isLCP' => false, + 'intersectionRatio' => 0, + 'intersectionRect' => $outside_viewport_rect, + 'boundingClientRect' => $outside_viewport_rect, ), 'buffer' => 'Foo', 'expected' => 'Foo', @@ -104,9 +178,11 @@ public function data_provider_test_auto_sizes(): array { 'auto_sizes_already_added' => array( 'element_metrics' => array( - 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', - 'isLCP' => false, - 'intersectionRatio' => 0, + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', + 'isLCP' => false, + 'intersectionRatio' => 0, + 'intersectionRect' => $outside_viewport_rect, + 'boundingClientRect' => $outside_viewport_rect, ), 'buffer' => 'Foo', 'expected' => 'Foo', @@ -143,7 +219,7 @@ public function data_provider_test_auto_sizes(): array { * @dataProvider data_provider_test_auto_sizes * @phpstan-param array{ xpath: string, isLCP: bool, intersectionRatio: int } $element_metrics */ - public function test_auto_sizes( array $element_metrics, string $buffer, string $expected ): void { + public function test_auto_sizes_end_to_end( array $element_metrics, string $buffer, string $expected ): void { $this->populate_url_metrics( array( $element_metrics ) ); $html_start_doc = '...'; @@ -159,4 +235,728 @@ public function test_auto_sizes( array $element_metrics, string $buffer, string "Buffer snapshot:\n$buffer" ); } + + /** + * Test image_prioritizer_register_tag_visitors. + * + * @covers ::image_prioritizer_register_tag_visitors + */ + public function test_image_prioritizer_register_tag_visitors(): void { + $registry = new OD_Tag_Visitor_Registry(); + image_prioritizer_register_tag_visitors( $registry ); + $this->assertTrue( $registry->is_registered( 'image-prioritizer/img' ) ); + $this->assertTrue( $registry->is_registered( 'image-prioritizer/background-image' ) ); + $this->assertTrue( $registry->is_registered( 'image-prioritizer/video' ) ); + } + + /** + * Test image_prioritizer_filter_extension_module_urls. + * + * @covers ::image_prioritizer_filter_extension_module_urls + */ + public function test_image_prioritizer_filter_extension_module_urls(): void { + $initial_modules = array( + home_url( '/module.js' ), + ); + $filtered_modules = image_prioritizer_filter_extension_module_urls( $initial_modules ); + $this->assertCount( 2, $filtered_modules ); + $this->assertSame( $initial_modules[0], $filtered_modules[0] ); + $this->assertStringContainsString( 'detect.', $filtered_modules[1] ); + } + + /** + * Test image_prioritizer_add_element_item_schema_properties. + * + * @covers ::image_prioritizer_add_element_item_schema_properties + */ + public function test_image_prioritizer_add_element_item_schema_properties(): void { + $initial_schema = array( + 'foo' => array( + 'type' => 'string', + ), + ); + $filtered_schema = image_prioritizer_add_element_item_schema_properties( $initial_schema ); + $this->assertCount( 2, $filtered_schema ); + $this->assertArrayHasKey( 'foo', $filtered_schema ); + $this->assertArrayHasKey( 'lcpElementExternalBackgroundImage', $filtered_schema ); + $this->assertSame( 'object', $filtered_schema['lcpElementExternalBackgroundImage']['type'] ); + $this->assertSameSets( array( 'url', 'id', 'tag', 'class' ), array_keys( $filtered_schema['lcpElementExternalBackgroundImage']['properties'] ) ); + } + + /** + * @return array + */ + public function data_provider_for_test_image_prioritizer_add_element_item_schema_properties_inputs(): array { + return array( + 'bad_type' => array( + 'input_value' => 'not_an_object', + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage] is not of type object.', + 'output_value' => null, + ), + 'missing_props' => array( + 'input_value' => array(), + 'expected_exception' => 'url is a required property of OD_URL_Metric[lcpElementExternalBackgroundImage].', + 'output_value' => null, + ), + 'bad_url_protocol' => array( + 'input_value' => array( + 'url' => 'javascript:alert(1)', + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][url] does not match pattern ^https?://.', + 'output_value' => null, + ), + 'bad_url_format' => array( + 'input_value' => array( + 'url' => 'https://not a valid URL!!!', + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ), + 'expected_exception' => null, + 'output_value' => array( + 'url' => 'https://not%20a%20valid%20URL!!!', // This is due to sanitize_url() being used in core. More validation is needed. + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ), + ), + 'bad_url_length' => array( + 'input_value' => array( + 'url' => 'https://example.com/' . str_repeat( 'a', 501 ), + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][url] must be at most 500 characters long.', + 'output_value' => null, + ), + 'bad_null_tag' => array( + 'input_value' => array( + 'url' => 'https://example.com/', + 'tag' => null, + 'id' => null, + 'class' => null, + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][tag] is not of type string.', + 'output_value' => null, + ), + 'bad_format_tag' => array( + 'input_value' => array( + 'url' => 'https://example.com/', + 'tag' => 'bad tag name!!', + 'id' => null, + 'class' => null, + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][tag] does not match pattern ^[a-zA-Z0-9\-]+\z.', + 'output_value' => null, + ), + 'bad_length_tag' => array( + 'input_value' => array( + 'url' => 'https://example.com/', + 'tag' => str_repeat( 'a', 101 ), + 'id' => null, + 'class' => null, + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][tag] must be at most 100 characters long.', + 'output_value' => null, + ), + 'bad_type_id' => array( + 'input_value' => array( + 'url' => 'https://example.com/', + 'tag' => 'DIV', + 'id' => array( 'bad' ), + 'class' => null, + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][id] is not of type string,null.', + 'output_value' => null, + ), + 'bad_length_id' => array( + 'input_value' => array( + 'url' => 'https://example.com/', + 'tag' => 'DIV', + 'id' => str_repeat( 'a', 101 ), + 'class' => null, + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][id] must be at most 100 characters long.', + 'output_value' => null, + ), + 'bad_type_class' => array( + 'input_value' => array( + 'url' => 'https://example.com/', + 'tag' => 'DIV', + 'id' => 'main', + 'class' => array( 'bad' ), + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][class] is not of type string,null.', + 'output_value' => null, + ), + 'bad_length_class' => array( + 'input_value' => array( + 'url' => 'https://example.com/', + 'tag' => 'DIV', + 'id' => 'main', + 'class' => str_repeat( 'a', 501 ), + ), + 'expected_exception' => 'OD_URL_Metric[lcpElementExternalBackgroundImage][class] must be at most 500 characters long.', + 'output_value' => null, + ), + 'ok_minimal' => array( + 'input_value' => array( + 'url' => 'https://example.com/bg.jpg', + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ), + 'expected_exception' => null, + 'output_value' => array( + 'url' => 'https://example.com/bg.jpg', + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ), + ), + 'ok_maximal' => array( + 'input_value' => array( + 'url' => 'https://example.com/' . str_repeat( 'a', 476 ) . '.jpg', + 'tag' => str_repeat( 'a', 100 ), + 'id' => str_repeat( 'b', 100 ), + 'class' => str_repeat( 'c', 500 ), + ), + 'expected_exception' => null, + 'output_value' => array( + 'url' => 'https://example.com/' . str_repeat( 'a', 476 ) . '.jpg', + 'tag' => str_repeat( 'a', 100 ), + 'id' => str_repeat( 'b', 100 ), + 'class' => str_repeat( 'c', 500 ), + ), + ), + ); + } + + /** + * Test image_prioritizer_add_element_item_schema_properties for various inputs. + * + * @covers ::image_prioritizer_add_element_item_schema_properties + * + * @dataProvider data_provider_for_test_image_prioritizer_add_element_item_schema_properties_inputs + * + * @param mixed $input_value Input value. + * @param string|null $expected_exception Expected exception message. + * @param array|null $output_value Output value. + */ + public function test_image_prioritizer_add_element_item_schema_properties_inputs( $input_value, ?string $expected_exception, ?array $output_value ): void { + $data = $this->get_sample_url_metric( array() )->jsonSerialize(); + $data['lcpElementExternalBackgroundImage'] = $input_value; + $exception_message = null; + try { + $url_metric = new OD_URL_Metric( $data ); + } catch ( OD_Data_Validation_Exception $e ) { + $exception_message = $e->getMessage(); + } + + $this->assertSame( + $expected_exception, + $exception_message, + isset( $url_metric ) ? 'Data: ' . wp_json_encode( $url_metric->jsonSerialize(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) : '' + ); + if ( isset( $url_metric ) ) { + $this->assertSame( $output_value, $url_metric->jsonSerialize()['lcpElementExternalBackgroundImage'] ); + } + } + + /** + * Data provider. + * + * @return array + */ + public function data_provider_to_test_image_prioritizer_validate_background_image_url(): array { + return array( + 'bad_url_parse_error' => array( + 'set_up' => static function (): string { + return 'https:///www.example.com'; + }, + 'expect_error' => 'background_image_url_lacks_host', + ), + 'bad_url_no_host' => array( + 'set_up' => static function (): string { + return '/foo/bar?baz=1'; + }, + 'expect_error' => 'background_image_url_lacks_host', + ), + + 'bad_url_disallowed_origin' => array( + 'set_up' => static function (): string { + return 'https://bad.example.com/foo.jpg'; + }, + 'expect_error' => 'disallowed_background_image_url_host', + ), + + 'good_other_origin_via_allowed_http_origins_filter' => array( + 'set_up' => static function (): string { + $image_url = 'https://other-origin.example.com/foo.jpg'; + + add_filter( + 'allowed_http_origins', + static function ( array $allowed_origins ): array { + $allowed_origins[] = 'https://other-origin.example.com'; + return $allowed_origins; + } + ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $image_url ) { + if ( 'HEAD' !== $parsed_args['method'] || $image_url !== $url ) { + return $pre; + } + return array( + 'headers' => array( + 'content-type' => 'image/jpeg', + 'content-length' => '288449', + ), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + }, + 10, + 3 + ); + + return $image_url; + }, + 'expect_error' => null, + ), + + 'good_url_allowed_cdn_origin' => array( + 'set_up' => function (): string { + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/car.jpeg' ); + $this->assertIsInt( $attachment_id ); + + add_filter( + 'wp_get_attachment_image_src', + static function ( $src ): array { + $src[0] = preg_replace( '#^https?://#i', 'https://my-image-cdn.example.com/', $src[0] ); + return $src; + } + ); + + $src = wp_get_attachment_image_src( $attachment_id, 'large' ); + $this->assertIsArray( $src ); + $this->assertStringStartsWith( 'https://my-image-cdn.example.com/', $src[0] ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $src ) { + if ( 'HEAD' !== $parsed_args['method'] || $src[0] !== $url ) { + return $pre; + } + return array( + 'headers' => array( + 'content-type' => 'image/jpeg', + 'content-length' => '288449', + ), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + }, + 10, + 3 + ); + + return $src[0]; + }, + 'expect_error' => null, + ), + + 'bad_not_found' => array( + 'set_up' => static function (): string { + $image_url = home_url( '/bad.jpg' ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $image_url ) { + if ( 'HEAD' !== $parsed_args['method'] || $image_url !== $url ) { + return $pre; + } + return array( + 'headers' => array( + 'content-type' => 'text/html', + 'content-length' => 1000, + ), + 'body' => '', + 'response' => array( + 'code' => 404, + 'message' => 'Not Found', + ), + ); + }, + 10, + 3 + ); + + return $image_url; + }, + 'expect_error' => 'background_image_response_not_ok', + ), + + 'bad_content_type' => array( + 'set_up' => static function (): string { + $video_url = home_url( '/bad.mp4' ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $video_url ) { + if ( 'HEAD' !== $parsed_args['method'] || $video_url !== $url ) { + return $pre; + } + return array( + 'headers' => array( + 'content-type' => 'video/mp4', + 'content-length' => '288449000', + ), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + }, + 10, + 3 + ); + + return $video_url; + }, + 'expect_error' => 'background_image_response_not_image', + ), + + 'bad_content_length' => array( + 'set_up' => static function (): string { + $image_url = home_url( '/massive-image.jpg' ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $image_url ) { + if ( 'HEAD' !== $parsed_args['method'] || $image_url !== $url ) { + return $pre; + } + return array( + 'headers' => array( + 'content-type' => 'image/jpeg', + 'content-length' => (string) ( 2 * MB_IN_BYTES + 1 ), + ), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + }, + 10, + 3 + ); + + return $image_url; + }, + 'expect_error' => 'background_image_content_length_too_large', + ), + + 'bad_redirect' => array( + 'set_up' => static function (): string { + $redirect_url = home_url( '/redirect.jpg' ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $redirect_url ) { + if ( $redirect_url === $url ) { + return new WP_Error( 'http_request_failed', 'Too many redirects.' ); + } + return $pre; + }, + 10, + 3 + ); + + return $redirect_url; + }, + 'expect_error' => 'http_request_failed', + ), + + 'good_same_origin' => array( + 'set_up' => static function (): string { + $image_url = home_url( '/good.jpg' ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $image_url ) { + if ( 'HEAD' !== $parsed_args['method'] || $image_url !== $url ) { + return $pre; + } + return array( + 'headers' => array( + 'content-type' => 'image/jpeg', + 'content-length' => '288449', + ), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + }, + 10, + 3 + ); + + return $image_url; + }, + 'expect_error' => null, + ), + ); + } + + /** + * Tests image_prioritizer_validate_background_image_url(). + * + * @covers ::image_prioritizer_validate_background_image_url + * + * @dataProvider data_provider_to_test_image_prioritizer_validate_background_image_url + */ + public function test_image_prioritizer_validate_background_image_url( Closure $set_up, ?string $expect_error ): void { + $url = $set_up(); + $validity = image_prioritizer_validate_background_image_url( $url ); + if ( null === $expect_error ) { + $this->assertTrue( $validity ); + } else { + $this->assertInstanceOf( WP_Error::class, $validity ); + $this->assertSame( $expect_error, $validity->get_error_code() ); + } + } + + /** + * Data provider. + * + * @return array + */ + public function data_provider_to_test_image_prioritizer_filter_rest_request_before_callbacks(): array { + $get_sample_url_metric_data = function (): array { + return $this->get_sample_url_metric( array() )->jsonSerialize(); + }; + + $create_request = static function ( array $url_metric_data ): WP_REST_Request { + $request = new WP_REST_Request( 'POST', '/' . OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $url_metric_data ) ); + return $request; + }; + + $bad_origin_data = array( + 'url' => 'https://bad-origin.example.com/image.jpg', + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ); + + return array( + 'invalid_external_bg_image' => array( + 'set_up' => static function () use ( $get_sample_url_metric_data, $create_request, $bad_origin_data ): WP_REST_Request { + $url_metric_data = $get_sample_url_metric_data(); + + $url_metric_data['lcpElementExternalBackgroundImage'] = $bad_origin_data; + $url_metric_data['viewport']['width'] = 10101; + $url_metric_data['viewport']['height'] = 20202; + return $create_request( $url_metric_data ); + }, + 'assert' => function ( WP_REST_Request $request ): void { + $this->assertArrayNotHasKey( 'lcpElementExternalBackgroundImage', $request ); + $this->assertSame( + array( + 'width' => 10101, + 'height' => 20202, + ), + $request['viewport'] + ); + }, + ), + + 'valid_external_bg_image' => array( + 'set_up' => static function () use ( $get_sample_url_metric_data, $create_request ): WP_REST_Request { + $url_metric_data = $get_sample_url_metric_data(); + $image_url = home_url( '/good.jpg' ); + + add_filter( + 'pre_http_request', + static function ( $pre, $parsed_args, $url ) use ( $image_url ) { + if ( 'HEAD' !== $parsed_args['method'] || $image_url !== $url ) { + return $pre; + } + return array( + 'headers' => array( + 'content-type' => 'image/jpeg', + 'content-length' => '288449', + ), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + }, + 10, + 3 + ); + + $url_metric_data['lcpElementExternalBackgroundImage'] = array( + 'url' => $image_url, + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ); + + $url_metric_data['viewport']['width'] = 30303; + $url_metric_data['viewport']['height'] = 40404; + return $create_request( $url_metric_data ); + }, + 'assert' => function ( WP_REST_Request $request ): void { + $this->assertArrayHasKey( 'lcpElementExternalBackgroundImage', $request ); + $this->assertIsArray( $request['lcpElementExternalBackgroundImage'] ); + $this->assertSame( + array( + 'url' => home_url( '/good.jpg' ), + 'tag' => 'DIV', + 'id' => null, + 'class' => null, + ), + $request['lcpElementExternalBackgroundImage'] + ); + $this->assertSame( + array( + 'width' => 30303, + 'height' => 40404, + ), + $request['viewport'] + ); + }, + ), + + 'invalid_external_bg_image_uppercase_route' => array( + 'set_up' => static function () use ( $get_sample_url_metric_data, $create_request, $bad_origin_data ): WP_REST_Request { + $request = $create_request( + array_merge( + $get_sample_url_metric_data(), + array( 'lcpElementExternalBackgroundImage' => $bad_origin_data ) + ) + ); + $request->set_route( str_replace( 'store', 'STORE', $request->get_route() ) ); + return $request; + }, + 'assert' => function ( WP_REST_Request $request ): void { + $this->assertArrayNotHasKey( 'lcpElementExternalBackgroundImage', $request ); + }, + ), + + 'invalid_external_bg_image_trailing_newline_route' => array( + 'set_up' => static function () use ( $get_sample_url_metric_data, $create_request, $bad_origin_data ): WP_REST_Request { + $request = $create_request( + array_merge( + $get_sample_url_metric_data(), + array( 'lcpElementExternalBackgroundImage' => $bad_origin_data ) + ) + ); + $request->set_route( $request->get_route() . "\n" ); + return $request; + }, + 'assert' => function ( WP_REST_Request $request ): void { + $this->assertArrayNotHasKey( 'lcpElementExternalBackgroundImage', $request ); + }, + ), + + 'not_store_post_request' => array( + 'set_up' => static function () use ( $get_sample_url_metric_data, $create_request, $bad_origin_data ): WP_REST_Request { + $request = $create_request( + array_merge( + $get_sample_url_metric_data(), + array( 'lcpElementExternalBackgroundImage' => $bad_origin_data ) + ) + ); + $request->set_method( 'GET' ); + return $request; + }, + 'assert' => function ( WP_REST_Request $request ) use ( $bad_origin_data ): void { + $this->assertArrayHasKey( 'lcpElementExternalBackgroundImage', $request ); + $this->assertSame( $bad_origin_data, $request['lcpElementExternalBackgroundImage'] ); + }, + ), + + 'not_store_request' => array( + 'set_up' => static function () use ( $get_sample_url_metric_data, $create_request ): WP_REST_Request { + $url_metric_data = $get_sample_url_metric_data(); + $url_metric_data['lcpElementExternalBackgroundImage'] = 'https://totally-different.example.com/'; + $request = $create_request( $url_metric_data ); + $request->set_route( '/foo/v2/bar' ); + return $request; + }, + 'assert' => function ( WP_REST_Request $request ): void { + $this->assertArrayHasKey( 'lcpElementExternalBackgroundImage', $request ); + $this->assertSame( 'https://totally-different.example.com/', $request['lcpElementExternalBackgroundImage'] ); + }, + ), + ); + } + + /** + * Tests image_prioritizer_filter_rest_request_before_callbacks(). + * + * @dataProvider data_provider_to_test_image_prioritizer_filter_rest_request_before_callbacks + * + * @covers ::image_prioritizer_filter_rest_request_before_callbacks + * @covers ::image_prioritizer_validate_background_image_url + */ + public function test_image_prioritizer_filter_rest_request_before_callbacks( Closure $set_up, Closure $assert ): void { + $request = $set_up(); + $response = new WP_REST_Response(); + $handler = array(); + $filtered_response = image_prioritizer_filter_rest_request_before_callbacks( $response, $handler, $request ); + $this->assertSame( $response, $filtered_response ); + $assert( $request ); + } + + /** + * Test image_prioritizer_get_video_lazy_load_script. + * + * @covers ::image_prioritizer_get_video_lazy_load_script + * @covers ::image_prioritizer_get_asset_path + */ + public function test_image_prioritizer_get_video_lazy_load_script(): void { + $this->assertStringContainsString( 'new IntersectionObserver', image_prioritizer_get_video_lazy_load_script() ); + } + + /** + * Test image_prioritizer_get_lazy_load_bg_image_script. + * + * @covers ::image_prioritizer_get_lazy_load_bg_image_script + * @covers ::image_prioritizer_get_asset_path + */ + public function test_image_prioritizer_get_lazy_load_bg_image_script(): void { + $this->assertStringContainsString( 'new IntersectionObserver', image_prioritizer_get_lazy_load_bg_image_script() ); + } + + /** + * Test image_prioritizer_get_lazy_load_bg_image_stylesheet. + * + * @covers ::image_prioritizer_get_lazy_load_bg_image_stylesheet + * @covers ::image_prioritizer_get_asset_path + */ + public function test_image_prioritizer_get_lazy_load_bg_image_stylesheet(): void { + $this->assertStringContainsString( '.od-lazy-bg-image', image_prioritizer_get_lazy_load_bg_image_stylesheet() ); + } } diff --git a/plugins/image-prioritizer/tests/test-hooks.php b/plugins/image-prioritizer/tests/test-hooks.php new file mode 100644 index 0000000000..840212da4b --- /dev/null +++ b/plugins/image-prioritizer/tests/test-hooks.php @@ -0,0 +1,19 @@ +assertEquals( 10, has_action( 'od_init', 'image_prioritizer_init' ) ); + $this->assertEquals( 10, has_filter( 'od_extension_module_urls', 'image_prioritizer_filter_extension_module_urls' ) ); + $this->assertEquals( 10, has_filter( 'od_url_metric_schema_root_additional_properties', 'image_prioritizer_add_element_item_schema_properties' ) ); + $this->assertEquals( 10, has_filter( 'rest_request_before_callbacks', 'image_prioritizer_filter_rest_request_before_callbacks' ) ); + } +} diff --git a/plugins/optimization-detective/class-od-data-validation-exception.php b/plugins/optimization-detective/class-od-data-validation-exception.php index 8ecde91ad3..2efab6fc9c 100644 --- a/plugins/optimization-detective/class-od-data-validation-exception.php +++ b/plugins/optimization-detective/class-od-data-validation-exception.php @@ -12,7 +12,7 @@ } /** - * Exception thrown when failing to validate URL metrics data. + * Exception thrown when failing to validate URL Metrics data. * * @since 0.1.0 * @access private diff --git a/plugins/optimization-detective/class-od-element.php b/plugins/optimization-detective/class-od-element.php new file mode 100644 index 0000000000..c7f243d99b --- /dev/null +++ b/plugins/optimization-detective/class-od-element.php @@ -0,0 +1,234 @@ +, ElementData[key-of]> + * @todo The above implements tag should account for additional undefined keys which can be supplied by extending the element schema. May depend on . + * + * @since 0.7.0 + * @access private + */ +class OD_Element implements ArrayAccess, JsonSerializable { + + /** + * Data. + * + * @since 0.7.0 + * @var ElementData + */ + protected $data; + + /** + * URL Metric that this element belongs to. + * + * @since 0.7.0 + * @var OD_URL_Metric + */ + protected $url_metric; + + /** + * Constructor. + * + * @since 0.7.0 + * + * @phpstan-param ElementData $data + * + * @param array $data Element data. + * @param OD_URL_Metric $url_metric URL Metric. + */ + public function __construct( array $data, OD_URL_Metric $url_metric ) { + $this->data = $data; + $this->url_metric = $url_metric; + } + + /** + * Gets the URL Metric that this element belongs to. + * + * @since 0.7.0 + * + * @return OD_URL_Metric URL Metric. + */ + public function get_url_metric(): OD_URL_Metric { + return $this->url_metric; + } + + /** + * Gets the group that this element's URL Metric is a part of (which may not be any). + * + * @since 0.7.0 + * + * @return OD_URL_Metric_Group|null Group. + */ + public function get_url_metric_group(): ?OD_URL_Metric_Group { + return $this->url_metric->get_group(); + } + + /** + * Gets property value for an arbitrary key. + * + * This is particularly useful in conjunction with the `od_url_metric_schema_element_item_additional_properties` filter. + * + * @since 0.7.0 + * + * @param string $key Property. + * @return mixed|null The property value, or null if not set. + */ + public function get( string $key ) { + return $this->data[ $key ] ?? null; + } + + /** + * Determines whether element was detected as LCP. + * + * @since 0.7.0 + * + * @return bool Whether LCP. + */ + public function is_lcp(): bool { + return $this->data['isLCP']; + } + + /** + * Determines whether element was detected as an LCP candidate. + * + * @since 0.7.0 + * + * @return bool Whether LCP candidate. + */ + public function is_lcp_candidate(): bool { + return $this->data['isLCPCandidate']; + } + + /** + * Gets XPath for element. + * + * @since 0.7.0 + * + * @return non-empty-string XPath. + */ + public function get_xpath(): string { + return $this->data['xpath']; + } + + /** + * Gets intersectionRatio for element. + * + * @since 0.7.0 + * + * @return float Intersection ratio. + */ + public function get_intersection_ratio(): float { + return $this->data['intersectionRatio']; + } + + /** + * Gets intersectionRect for element. + * + * @since 0.7.0 + * + * @phpstan-return DOMRect + * + * @return array Intersection rect. + */ + public function get_intersection_rect(): array { + return $this->data['intersectionRect']; + } + + /** + * Gets boundingClientRect for element. + * + * @since 0.7.0 + * + * @phpstan-return DOMRect + * + * @return array Bounding client rect. + */ + public function get_bounding_client_rect(): array { + return $this->data['boundingClientRect']; + } + + /** + * Checks whether an offset exists. + * + * @since 0.7.0 + * + * @param mixed $offset Key. + * @return bool Whether the offset exists. + */ + public function offsetExists( $offset ): bool { + return isset( $this->data[ $offset ] ); + } + + /** + * Retrieves an offset. + * + * @since 0.7.0 + * + * @template T of key-of + * @phpstan-param T $offset + * @phpstan-return ElementData[T]|null + * @todo This should account for additional undefined keys which can be supplied by extending the element schema. May depend on . + * + * @param mixed $offset Key. + * @return mixed May return any value from ElementData including possible extensions. + */ + #[ReturnTypeWillChange] + public function offsetGet( $offset ) { + return $this->data[ $offset ] ?? null; + } + + /** + * Sets an offset. + * + * This is disallowed. Attempting to set a property will throw an error. + * + * @since 0.7.0 + * + * @param mixed $offset Key. + * @param mixed $value Value. + * + * @throws Exception When attempting to set a property. + */ + public function offsetSet( $offset, $value ): void { + throw new Exception( 'Element data may not be set.' ); + } + + /** + * Offset to unset. + * + * This is disallowed. Attempting to unset a property will throw an error. + * + * @since 0.7.0 + * + * @param mixed $offset Offset. + * + * @throws Exception When attempting to unset a property. + */ + public function offsetUnset( $offset ): void { + throw new Exception( 'Element data may not be unset.' ); + } + + /** + * Specifies data which should be serialized to JSON. + * + * @since 0.7.0 + * @return ElementData Exports to be serialized by json_encode(). + */ + public function jsonSerialize(): array { + return $this->data; + } +} diff --git a/plugins/optimization-detective/class-od-html-tag-processor.php b/plugins/optimization-detective/class-od-html-tag-processor.php index 3996dfdf8a..48ad11d126 100644 --- a/plugins/optimization-detective/class-od-html-tag-processor.php +++ b/plugins/optimization-detective/class-od-html-tag-processor.php @@ -178,6 +178,15 @@ final class OD_HTML_Tag_Processor extends WP_HTML_Tag_Processor { */ private $buffered_text_replacements = array(); + /** + * Whether the end of the document was reached. + * + * @since 0.7.0 + * @see self::next_token() + * @var bool + */ + private $reached_end_of_document = false; + /** * Count for the number of times that the cursor was moved. * @@ -263,6 +272,9 @@ public function next_token(): bool { if ( ! parent::next_token() ) { $this->open_stack_tags = array(); $this->open_stack_indices = array(); + + // Mark that the end of the document was reached, meaning that get_modified_html() should now be able to append markup to the HEAD and the BODY. + $this->reached_end_of_document = true; return false; } @@ -284,7 +296,7 @@ public function next_token(): bool { $i = array_search( 'P', $this->open_stack_tags, true ); if ( false !== $i ) { array_splice( $this->open_stack_tags, (int) $i ); - array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) ); + array_splice( $this->open_stack_indices, count( $this->open_stack_tags ) + 1 ); } } @@ -365,8 +377,8 @@ public function get_cursor_move_count(): int { public function set_attribute( $name, $value ): bool { // phpcs:ignore SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint $existing_value = $this->get_attribute( $name ); $result = parent::set_attribute( $name, $value ); - if ( $result ) { - if ( is_string( $existing_value ) ) { + if ( $result && $existing_value !== $value ) { + if ( null !== $existing_value ) { $this->set_meta_attribute( "replaced-{$name}", $existing_value ); } else { $this->set_meta_attribute( "added-{$name}", true ); @@ -485,15 +497,31 @@ public function release_bookmark( $name ): bool { * A breadcrumb consists of a tag name and its sibling index. * * @since 0.4.0 + * @since 0.9.0 Renamed from get_breadcrumbs() to get_indexed_breadcrumbs(). * * @return Generator Breadcrumb. */ - private function get_breadcrumbs(): Generator { + private function get_indexed_breadcrumbs(): Generator { foreach ( $this->open_stack_tags as $i => $breadcrumb_tag_name ) { yield array( $breadcrumb_tag_name, $this->open_stack_indices[ $i ] ); } } + /** + * Computes the HTML breadcrumbs for the currently-matched node, if matched. + * + * Breadcrumbs start at the outermost parent and descend toward the matched element. + * They always include the entire path from the root HTML node to the matched element. + * + * @since 0.9.0 + * @see WP_HTML_Processor::get_breadcrumbs() + * + * @return string[] Array of tag names representing path to matched node. + */ + public function get_breadcrumbs(): array { + return $this->open_stack_tags; + } + /** * Determines whether currently inside a foreign element (MATH or SVG). * @@ -523,7 +551,7 @@ private function is_foreign_element(): bool { public function get_xpath(): string { if ( null === $this->current_xpath ) { $this->current_xpath = ''; - foreach ( $this->get_breadcrumbs() as list( $tag_name, $index ) ) { + foreach ( $this->get_indexed_breadcrumbs() as list( $tag_name, $index ) ) { $this->current_xpath .= sprintf( '/*[%d][self::%s]', $index + 1, $tag_name ); } } @@ -559,11 +587,25 @@ public function append_body_html( string $html ): void { /** * Returns the string representation of the HTML Tag Processor. * + * Once the end of the document has been reached this is responsible for adding the pending markup to append to the + * HEAD and the BODY. It waits to do this injection until the end of the document has been reached because every + * time that seek() is called it the HTML Processor will flush any pending updates to the document. This means that + * if there is any pending markup to append to the end of the BODY then the insertion will fail because the closing + * tag for the BODY has not been encountered yet. Additionally, by not prematurely processing the buffered text + * replacements in get_updated_html() then we avoid trying to insert them every time that seek() is called which is + * wasteful as they are only needed once finishing iterating over the document. + * * @since 0.4.0 + * @see WP_HTML_Tag_Processor::get_updated_html() + * @see WP_HTML_Tag_Processor::seek() * * @return string The processed HTML. */ public function get_updated_html(): string { + if ( ! $this->reached_end_of_document ) { + return parent::get_updated_html(); + } + foreach ( array_keys( $this->buffered_text_replacements ) as $bookmark ) { $html_strings = $this->buffered_text_replacements[ $bookmark ]; if ( count( $html_strings ) === 0 ) { @@ -599,10 +641,19 @@ public function get_updated_html(): string { * * @since 0.4.0 * + * @phpstan-param callable-string $function_name + * * @param string $function_name Function name. * @param string $message Warning message. + * + * @noinspection PhpDocMissingThrowsInspection */ private function warn( string $function_name, string $message ): void { + /** + * No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level. + * + * @noinspection PhpUnhandledExceptionInspection + */ wp_trigger_error( $function_name, esc_html( $message ) diff --git a/plugins/optimization-detective/class-od-link-collection.php b/plugins/optimization-detective/class-od-link-collection.php index b1fbfacb6c..4e41a7af74 100644 --- a/plugins/optimization-detective/class-od-link-collection.php +++ b/plugins/optimization-detective/class-od-link-collection.php @@ -29,6 +29,7 @@ * fetchpriority?: 'high'|'low'|'auto', * as?: 'audio'|'document'|'embed'|'fetch'|'font'|'image'|'object'|'script'|'style'|'track'|'video'|'worker', * media?: non-empty-string, + * type?: non-empty-string, * integrity?: non-empty-string, * referrerpolicy?: 'no-referrer'|'no-referrer-when-downgrade'|'origin'|'origin-when-cross-origin'|'unsafe-url' * } @@ -130,18 +131,29 @@ function ( array $links ): array { */ private function merge_consecutive_links( array $links ): array { - // Ensure links are sorted by the minimum_viewport_width. usort( $links, /** * Comparator. * + * The links are sorted first by the 'href' attribute to group identical URLs together. + * If the 'href' attributes are the same, the links are then sorted by 'minimum_viewport_width'. + * * @param Link $a First link. * @param Link $b Second link. * @return int Comparison result. */ static function ( array $a, array $b ): int { - return $a['minimum_viewport_width'] <=> $b['minimum_viewport_width']; + // Get href values, defaulting to empty string if not present. + $href_a = $a['attributes']['href'] ?? ''; + $href_b = $b['attributes']['href'] ?? ''; + + $href_comparison = strcmp( $href_a, $href_b ); + if ( 0 === $href_comparison ) { + return $a['minimum_viewport_width'] <=> $b['minimum_viewport_width']; + } + + return $href_comparison; } ); @@ -192,20 +204,13 @@ static function ( array $carry, array $link ): array { // Add media attributes to the deduplicated links. return array_map( static function ( array $link ): array { - $media_attributes = array(); - if ( null !== $link['minimum_viewport_width'] && $link['minimum_viewport_width'] > 0 ) { - $media_attributes[] = sprintf( '(min-width: %dpx)', $link['minimum_viewport_width'] ); - } - if ( null !== $link['maximum_viewport_width'] && PHP_INT_MAX !== $link['maximum_viewport_width'] ) { - $media_attributes[] = sprintf( '(max-width: %dpx)', $link['maximum_viewport_width'] ); - } - if ( count( $media_attributes ) > 0 ) { + $media_query = od_generate_media_query( $link['minimum_viewport_width'], $link['maximum_viewport_width'] ); + if ( null !== $media_query ) { if ( ! isset( $link['attributes']['media'] ) ) { - $link['attributes']['media'] = ''; + $link['attributes']['media'] = $media_query; } else { - $link['attributes']['media'] .= ' and '; + $link['attributes']['media'] .= " and $media_query"; } - $link['attributes']['media'] .= implode( ' and ', $media_attributes ); } return $link['attributes']; }, diff --git a/plugins/optimization-detective/class-od-strict-url-metric.php b/plugins/optimization-detective/class-od-strict-url-metric.php index f3ecfede21..490315461b 100644 --- a/plugins/optimization-detective/class-od-strict-url-metric.php +++ b/plugins/optimization-detective/class-od-strict-url-metric.php @@ -14,8 +14,8 @@ /** * Representation of the measurements taken from a single client's visit to a specific URL without additionalProperties allowed. * - * This is used exclusively in the REST API endpoint for capturing new URL metrics to prevent invalid additional data from being - * submitted in the request. For URL metrics which have been stored the looser OD_URL_Metric class is used instead. + * This is used exclusively in the REST API endpoint for capturing new URL Metrics to prevent invalid additional data from being + * submitted in the request. For URL Metrics which have been stored the looser OD_URL_Metric class is used instead. * * @since 0.6.0 * @access private diff --git a/plugins/optimization-detective/class-od-tag-visitor-context.php b/plugins/optimization-detective/class-od-tag-visitor-context.php index 246db7f31a..4dd1279f53 100644 --- a/plugins/optimization-detective/class-od-tag-visitor-context.php +++ b/plugins/optimization-detective/class-od-tag-visitor-context.php @@ -30,7 +30,7 @@ final class OD_Tag_Visitor_Context { public $processor; /** - * URL metric group collection. + * URL Metric group collection. * * @var OD_URL_Metric_Group_Collection * @readonly @@ -49,7 +49,7 @@ final class OD_Tag_Visitor_Context { * Constructor. * * @param OD_HTML_Tag_Processor $processor HTML tag processor. - * @param OD_URL_Metric_Group_Collection $url_metric_group_collection URL metric group collection. + * @param OD_URL_Metric_Group_Collection $url_metric_group_collection URL Metric group collection. * @param OD_Link_Collection $link_collection Link collection. */ public function __construct( OD_HTML_Tag_Processor $processor, OD_URL_Metric_Group_Collection $url_metric_group_collection, OD_Link_Collection $link_collection ) { @@ -61,11 +61,11 @@ public function __construct( OD_HTML_Tag_Processor $processor, OD_URL_Metric_Gro /** * Gets deprecated property. * - * @since n.e.x.t + * @since 0.7.0 * @todo Remove this when no plugins are possibly referring to the url_metrics_group_collection property anymore. * * @param string $name Property name. - * @return OD_URL_Metric_Group_Collection URL metric group collection. + * @return OD_URL_Metric_Group_Collection URL Metric group collection. * * @throws Error When property is unknown. */ @@ -80,7 +80,7 @@ public function __get( string $name ): OD_URL_Metric_Group_Collection { __CLASS__ . '::$url_metric_group_collection' ) ), - 'optimization-detective n.e.x.t' + 'optimization-detective 0.7.0' ); return $this->url_metric_group_collection; } diff --git a/plugins/optimization-detective/class-od-tag-visitor-registry.php b/plugins/optimization-detective/class-od-tag-visitor-registry.php index d57f6f051f..e6b57527cd 100644 --- a/plugins/optimization-detective/class-od-tag-visitor-registry.php +++ b/plugins/optimization-detective/class-od-tag-visitor-registry.php @@ -80,7 +80,7 @@ public function unregister( string $id ): bool { } /** - * Returns an iterator for the URL metrics in the group. + * Returns an iterator for the URL Metrics in the group. * * @return ArrayIterator ArrayIterator for tag visitors. */ @@ -89,9 +89,9 @@ public function getIterator(): ArrayIterator { } /** - * Counts the URL metrics in the group. + * Counts the URL Metrics in the group. * - * @return int<0, max> URL metric count. + * @return int<0, max> URL Metric count. */ public function count(): int { return count( $this->visitors ); diff --git a/plugins/optimization-detective/class-od-url-metric-group-collection.php b/plugins/optimization-detective/class-od-url-metric-group-collection.php index 506b7b0f2a..56d257402e 100644 --- a/plugins/optimization-detective/class-od-url-metric-group-collection.php +++ b/plugins/optimization-detective/class-od-url-metric-group-collection.php @@ -14,8 +14,6 @@ /** * Collection of URL groups according to the breakpoints. * - * @phpstan-import-type ElementData from OD_URL_Metric - * * @implements IteratorAggregate * * @since 0.1.0 @@ -24,19 +22,27 @@ final class OD_URL_Metric_Group_Collection implements Countable, IteratorAggregate, JsonSerializable { /** - * URL metric groups. + * URL Metric groups. * * The number of groups corresponds to one greater than the number of * breakpoints. This is because breakpoints are the dividing line between - * the groups of URL metrics with specific viewport widths. This extends + * the groups of URL Metrics with specific viewport widths. This extends * even to when there are zero breakpoints: there will still be one group - * in this case, in which every single URL metric is added. + * in this case, in which every single URL Metric is added. * * @var OD_URL_Metric_Group[] * @phpstan-var non-empty-array */ private $groups; + /** + * The current ETag. + * + * @since 0.9.0 + * @var non-empty-string + */ + private $current_etag; + /** * Breakpoints in max widths. * @@ -47,13 +53,16 @@ final class OD_URL_Metric_Group_Collection implements Countable, IteratorAggrega * until PHP_INT_MAX. So a breakpoint cannot be PHP_INT_MAX because then the minimum viewport width for the final group * would end up being larger than PHP_INT_MAX. * + * This array may be empty in which case there are no responsive breakpoints and all URL Metrics are collected in a + * single group. + * * @var int[] * @phpstan-var positive-int[] */ private $breakpoints; /** - * Sample size for URL metrics for a given breakpoint. + * Sample size for URL Metrics for a given breakpoint. * * @var int * @phpstan-var positive-int @@ -61,9 +70,9 @@ final class OD_URL_Metric_Group_Collection implements Countable, IteratorAggrega private $sample_size; /** - * Freshness age (TTL) for a given URL metric. + * Freshness age (TTL) for a given URL Metric. * - * A freshness age of zero means a URL metric will always be considered stale. + * A freshness age of zero means a URL Metric will always be considered stale. * * @var int * @phpstan-var 0|positive-int @@ -79,8 +88,10 @@ final class OD_URL_Metric_Group_Collection implements Countable, IteratorAggrega * is_any_group_populated?: bool, * is_every_group_complete?: bool, * get_groups_by_lcp_element?: array, - * get_common_lcp_element?: ElementData|null, - * get_all_element_max_intersection_ratios?: array + * get_common_lcp_element?: OD_Element|null, + * get_all_element_max_intersection_ratios?: array, + * get_xpath_elements_map?: array>, + * get_all_elements_positioned_in_any_initial_viewport?: array, * } */ private $result_cache = array(); @@ -90,12 +101,27 @@ final class OD_URL_Metric_Group_Collection implements Countable, IteratorAggrega * * @throws InvalidArgumentException When an invalid argument is supplied. * - * @param OD_URL_Metric[] $url_metrics URL metrics. - * @param int[] $breakpoints Breakpoints in max widths. - * @param int $sample_size Sample size for the maximum number of viewports in a group between breakpoints. - * @param int $freshness_ttl Freshness age (TTL) for a given URL metric. + * @param OD_URL_Metric[] $url_metrics URL Metrics. + * @param non-empty-string $current_etag The current ETag. + * @param int[] $breakpoints Breakpoints in max widths. + * @param int $sample_size Sample size for the maximum number of viewports in a group between breakpoints. + * @param int $freshness_ttl Freshness age (TTL) for a given URL Metric. */ - public function __construct( array $url_metrics, array $breakpoints, int $sample_size, int $freshness_ttl ) { + public function __construct( array $url_metrics, string $current_etag, array $breakpoints, int $sample_size, int $freshness_ttl ) { + // Set current ETag. + if ( 1 !== preg_match( '/^[a-f0-9]{32}\z/', $current_etag ) ) { + throw new InvalidArgumentException( + esc_html( + sprintf( + /* translators: %s is the invalid ETag */ + __( 'The current ETag must be a valid MD5 hash, but provided: %s', 'optimization-detective' ), + $current_etag + ) + ) + ); + } + $this->current_etag = $current_etag; + // Set breakpoints. sort( $breakpoints ); $breakpoints = array_values( array_unique( $breakpoints, SORT_NUMERIC ) ); @@ -150,7 +176,7 @@ public function __construct( array $url_metrics, array $breakpoints, int $sample } $this->freshness_ttl = $freshness_ttl; - // Create groups and the URL metrics to them. + // Create groups and the URL Metrics to them. $this->groups = $this->create_groups(); foreach ( $url_metrics as $url_metric ) { $this->add_url_metric( $url_metric ); @@ -158,14 +184,59 @@ public function __construct( array $url_metrics, array $breakpoints, int $sample } /** - * Clear result cache. + * Gets the current ETag. + * + * @since 0.9.0 + * + * @return non-empty-string Current ETag. + */ + public function get_current_etag(): string { + return $this->current_etag; + } + + /** + * Gets the first URL Metric group. + * + * This group normally represents viewports for mobile devices. This group always has a minimum viewport width of 0 + * and the maximum viewport width corresponds to the smallest defined breakpoint returned by + * {@see od_get_breakpoint_max_widths()}. + * + * @since 0.7.0 + * + * @return OD_URL_Metric_Group First URL Metric group. + */ + public function get_first_group(): OD_URL_Metric_Group { + return $this->groups[0]; + } + + /** + * Gets the last URL Metric group. + * + * This group normally represents viewports for desktop devices. This group always has a minimum viewport width + * defined as one greater than the largest breakpoint returned by {@see od_get_breakpoint_max_widths()}. + * The maximum viewport is always `PHP_INT_MAX`, or in other words it is unbounded. + * + * @since 0.7.0 + * + * @return OD_URL_Metric_Group Last URL Metric group. + */ + public function get_last_group(): OD_URL_Metric_Group { + return $this->groups[ count( $this->groups ) - 1 ]; + } + + /** + * Clears result cache. + * + * @since 0.3.0 */ public function clear_cache(): void { $this->result_cache = array(); } /** - * Create groups. + * Creates groups. + * + * @since 0.1.0 * * @phpstan-return non-empty-array * @@ -183,13 +254,14 @@ private function create_groups(): array { } /** - * Adds a new URL metric to a group. + * Adds a new URL Metric to a group. * - * Once a group reaches the sample size, the oldest URL metric is pushed out. + * Once a group reaches the sample size, the oldest URL Metric is pushed out. * - * @throws InvalidArgumentException If there is no group available to add a URL metric to. + * @since 0.1.0 + * @throws InvalidArgumentException If there is no group available to add a URL Metric to. * - * @param OD_URL_Metric $new_url_metric New URL metric. + * @param OD_URL_Metric $new_url_metric New URL Metric. */ public function add_url_metric( OD_URL_Metric $new_url_metric ): void { foreach ( $this->groups as $group ) { @@ -199,17 +271,18 @@ public function add_url_metric( OD_URL_Metric $new_url_metric ): void { } } throw new InvalidArgumentException( - esc_html__( 'No group available to add URL metric to.', 'optimization-detective' ) + esc_html__( 'No group available to add URL Metric to.', 'optimization-detective' ) ); } /** * Gets group for viewport width. * + * @since 0.1.0 * @throws InvalidArgumentException When there is no group for the provided viewport width. This would only happen if a negative width is provided. * * @param int $viewport_width Viewport width. - * @return OD_URL_Metric_Group URL metric group for the viewport width. + * @return OD_URL_Metric_Group URL Metric group for the viewport width. */ public function get_group_for_viewport_width( int $viewport_width ): OD_URL_Metric_Group { if ( array_key_exists( __FUNCTION__, $this->result_cache ) && array_key_exists( $viewport_width, $this->result_cache[ __FUNCTION__ ] ) ) { @@ -226,7 +299,7 @@ public function get_group_for_viewport_width( int $viewport_width ): OD_URL_Metr esc_html( sprintf( /* translators: %d is viewport width */ - __( 'No URL metric group found for viewport width: %d', 'optimization-detective' ), + __( 'No URL Metric group found for viewport width: %d', 'optimization-detective' ), $viewport_width ) ) @@ -238,9 +311,11 @@ public function get_group_for_viewport_width( int $viewport_width ): OD_URL_Metr } /** - * Checks whether any group is populated with at least one URL metric. + * Checks whether any group is populated with at least one URL Metric. * - * @return bool Whether at least one group has some URL metrics. + * @since 0.5.0 + * + * @return bool Whether at least one group has some URL Metrics. */ public function is_any_group_populated(): bool { if ( array_key_exists( __FUNCTION__, $this->result_cache ) ) { @@ -261,16 +336,17 @@ public function is_any_group_populated(): bool { } /** - * Checks whether every group is populated with at least one URL metric each. + * Checks whether every group is populated with at least one URL Metric each. * * They aren't necessarily filled to the sample size, however. - * The URL metrics may also be stale (non-fresh). This method + * The URL Metrics may also be stale (non-fresh). This method * should be contrasted with the `is_every_group_complete()` * method below. * + * @since 0.1.0 * @see OD_URL_Metric_Group_Collection::is_every_group_complete() * - * @return bool Whether all groups have some URL metrics. + * @return bool Whether all groups have some URL Metrics. */ public function is_every_group_populated(): bool { if ( array_key_exists( __FUNCTION__, $this->result_cache ) ) { @@ -293,6 +369,7 @@ public function is_every_group_populated(): bool { /** * Checks whether every group is complete. * + * @since 0.1.0 * @see OD_URL_Metric_Group::is_complete() * * @return bool Whether all groups are complete. @@ -319,6 +396,7 @@ public function is_every_group_complete(): bool { /** * Gets the groups with the provided LCP element XPath. * + * @since 0.3.0 * @see OD_URL_Metric_Group::get_lcp_element() * * @param string $xpath XPath for LCP element. @@ -333,7 +411,7 @@ public function get_groups_by_lcp_element( string $xpath ): array { $groups = array(); foreach ( $this->groups as $group ) { $lcp_element = $group->get_lcp_element(); - if ( ! is_null( $lcp_element ) && $xpath === $lcp_element['xpath'] ) { + if ( $lcp_element instanceof OD_Element && $xpath === $lcp_element->get_xpath() ) { $groups[] = $group; } } @@ -348,47 +426,52 @@ public function get_groups_by_lcp_element( string $xpath ): array { /** * Gets common LCP element. * - * @return ElementData|null + * @since 0.3.0 + * @since 0.9.0 An LCP element is also considered common if it is the same in the narrowest and widest viewport groups, and all intermediate groups are empty. + * + * @return OD_Element|null Common LCP element if it exists. */ - public function get_common_lcp_element(): ?array { + public function get_common_lcp_element(): ?OD_Element { if ( array_key_exists( __FUNCTION__, $this->result_cache ) ) { return $this->result_cache[ __FUNCTION__ ]; } $result = ( function () { - // If every group isn't populated, then we can't say whether there is a common LCP element across every viewport group. - if ( ! $this->is_every_group_populated() ) { + // Ensure both the narrowest (first) and widest (last) viewport groups are populated. + $first_group = $this->get_first_group(); + $last_group = $this->get_last_group(); + if ( $first_group->count() === 0 || $last_group->count() === 0 ) { return null; } - // Look at the LCP elements across all the viewport groups. - $groups_by_lcp_element_xpath = array(); - $lcp_elements_by_xpath = array(); - $group_has_unknown_lcp_element = false; - foreach ( $this->groups as $group ) { - $lcp_element = $group->get_lcp_element(); - if ( ! is_null( $lcp_element ) ) { - $groups_by_lcp_element_xpath[ $lcp_element['xpath'] ][] = $group; - $lcp_elements_by_xpath[ $lcp_element['xpath'] ][] = $lcp_element; - } else { - $group_has_unknown_lcp_element = true; - } - } + $first_group_lcp_element = $first_group->get_lcp_element(); + $last_group_lcp_element = $last_group->get_lcp_element(); + // Validate LCP elements exist and have matching XPaths in the extreme viewport groups. if ( - // All breakpoints share the same LCP element. - 1 === count( $groups_by_lcp_element_xpath ) - && - // The breakpoints don't share a common lack of a detected LCP element. - ! $group_has_unknown_lcp_element + ! $first_group_lcp_element instanceof OD_Element + || + ! $last_group_lcp_element instanceof OD_Element + || + $first_group_lcp_element->get_xpath() !== $last_group_lcp_element->get_xpath() ) { - $xpath = key( $lcp_elements_by_xpath ); + return null; // No common LCP element across the narrowest and widest viewports. + } - return $lcp_elements_by_xpath[ $xpath ][0]; + // Check intermediate viewport groups for conflicting LCP elements. + foreach ( array_slice( $this->groups, 1, -1 ) as $group ) { + $group_lcp_element = $group->get_lcp_element(); + if ( + $group_lcp_element instanceof OD_Element + && + $group_lcp_element->get_xpath() !== $first_group_lcp_element->get_xpath() + ) { + return null; // Conflicting LCP element found in an intermediate group. + } } - return null; + return $first_group_lcp_element; } )(); $this->result_cache[ __FUNCTION__ ] = $result; @@ -396,7 +479,42 @@ public function get_common_lcp_element(): ?array { } /** - * Gets the max intersection ratios of all elements across all groups and their captured URL metrics. + * Gets all elements from all URL Metrics from all groups keyed by the elements' XPaths. + * + * This is an O(n^3) function so its results must be cached. This being said, the number of groups should be 4 (one + * more than the default number of breakpoints) and the number of URL Metrics for each group should be 3 + * (the default sample size). Therefore, given the number (n) of visited elements on the page this will only + * end up running n*4*3 times. + * + * @since 0.7.0 + * + * @return array> Keys are XPaths and values are the element instances. + */ + public function get_xpath_elements_map(): array { + if ( array_key_exists( __FUNCTION__, $this->result_cache ) ) { + return $this->result_cache[ __FUNCTION__ ]; + } + + $result = ( function () { + $all_elements = array(); + foreach ( $this->groups as $group ) { + foreach ( $group->get_xpath_elements_map() as $xpath => $elements ) { + foreach ( $elements as $element ) { + $all_elements[ $xpath ][] = $element; + } + } + } + return $all_elements; + } )(); + + $this->result_cache[ __FUNCTION__ ] = $result; + return $result; + } + + /** + * Gets the max intersection ratios of all elements across all groups and their captured URL Metrics. + * + * @since 0.3.0 * * @return array Keys are XPaths and values are the intersection ratios. */ @@ -406,24 +524,55 @@ public function get_all_element_max_intersection_ratios(): array { } $result = ( function () { - $element_max_intersection_ratios = array(); - - /* - * O(n^3) my! Yes. This is why the result is cached. This being said, the number of groups should be 4 (one - * more than the default number of breakpoints) and the number of URL metrics for each group should be 3 - * (the default sample size). Therefore, given the number (n) of visited elements on the page this will only - * end up running n*4*3 times. - */ + $elements_max_intersection_ratios = array(); foreach ( $this->groups as $group ) { - foreach ( $group as $url_metric ) { - foreach ( $url_metric->get_elements() as $element ) { - $element_max_intersection_ratios[ $element['xpath'] ] = array_key_exists( $element['xpath'], $element_max_intersection_ratios ) - ? max( $element_max_intersection_ratios[ $element['xpath'] ], $element['intersectionRatio'] ) - : $element['intersectionRatio']; + foreach ( $group->get_all_element_max_intersection_ratios() as $xpath => $element_max_intersection_ratio ) { + $elements_max_intersection_ratios[ $xpath ] = (float) max( + $elements_max_intersection_ratios[ $xpath ] ?? 0, + $element_max_intersection_ratio + ); + } + } + return $elements_max_intersection_ratios; + } )(); + + $this->result_cache[ __FUNCTION__ ] = $result; + return $result; + } + + /** + * Gets all elements' status for whether they are positioned in any initial viewport. + * + * An element is positioned in the initial viewport if its `boundingClientRect.top` is less than the + * `viewport.height` for any of its recorded URL Metrics. Note that even though the element may be positioned in the + * initial viewport, it may not actually be visible. It could be occluded as a latter slide in a carousel in which + * case it will have intersectionRatio of 0. Or the element may not be visible due to it or an ancestor having the + * `visibility:hidden` style, such as in the case of a dropdown navigation menu. When, for example, an IMG element + * is positioned in any initial viewport, it should not get `loading=lazy` but rather `fetchpriority=low`. + * Furthermore, the element may be positioned _above_ the initial viewport or to the left or right of the viewport, + * in which case the element may be dynamically displayed at any time in response to a user interaction. + * + * @since 0.7.0 + * + * @return array Keys are XPaths and values whether the element is positioned in any initial viewport. + */ + public function get_all_elements_positioned_in_any_initial_viewport(): array { + if ( array_key_exists( __FUNCTION__, $this->result_cache ) ) { + return $this->result_cache[ __FUNCTION__ ]; + } + + $result = ( function () { + $elements_positioned = array(); + foreach ( $this->get_xpath_elements_map() as $xpath => $elements ) { + $elements_positioned[ $xpath ] = false; + foreach ( $elements as $element ) { + if ( $element->get_bounding_client_rect()['top'] < $element->get_url_metric()->get_viewport()['height'] ) { + $elements_positioned[ $xpath ] = true; + break; } } } - return $element_max_intersection_ratios; + return $elements_positioned; } )(); $this->result_cache[ __FUNCTION__ ] = $result; @@ -431,7 +580,9 @@ public function get_all_element_max_intersection_ratios(): array { } /** - * Gets the max intersection ratio of an element across all groups and their captured URL metrics. + * Gets the max intersection ratio of an element across all groups and their captured URL Metrics. + * + * @since 0.3.0 * * @param string $xpath XPath for the element. * @return float|null Max intersection ratio of null if tag is unknown (not captured). @@ -441,9 +592,23 @@ public function get_element_max_intersection_ratio( string $xpath ): ?float { } /** - * Gets URL metrics from all groups flattened into one list. + * Determines whether an element is positioned in any initial viewport. + * + * @since 0.7.0 * - * @return OD_URL_Metric[] All URL metrics. + * @param string $xpath XPath for the element. + * @return bool|null Whether element is positioned in any initial viewport of null if unknown. + */ + public function is_element_positioned_in_any_initial_viewport( string $xpath ): ?bool { + return $this->get_all_elements_positioned_in_any_initial_viewport()[ $xpath ] ?? null; + } + + /** + * Gets URL Metrics from all groups flattened into one list. + * + * @since 0.1.0 + * + * @return OD_URL_Metric[] All URL Metrics. */ public function get_flattened_url_metrics(): array { // The duplication of iterator_to_array is not a mistake. This collection is an @@ -458,7 +623,9 @@ public function get_flattened_url_metrics(): array { } /** - * Returns an iterator for the groups of URL metrics. + * Returns an iterator for the groups of URL Metrics. + * + * @since 0.1.0 * * @return ArrayIterator Array iterator for OD_URL_Metric_Group instances. */ @@ -467,7 +634,9 @@ public function getIterator(): ArrayIterator { } /** - * Counts the URL metric groups in the collection. + * Counts the URL Metric groups in the collection. + * + * @since 0.1.0 * * @return int<0, max> Group count. */ @@ -481,15 +650,16 @@ public function count(): int { * @since 0.3.1 * * @return array{ + * current_etag: non-empty-string, * breakpoints: positive-int[], * freshness_ttl: 0|positive-int, * sample_size: positive-int, * all_element_max_intersection_ratios: array, - * common_lcp_element: ?ElementData, + * common_lcp_element: ?OD_Element, * every_group_complete: bool, * every_group_populated: bool, * groups: array $this->current_etag, 'breakpoints' => $this->breakpoints, 'freshness_ttl' => $this->freshness_ttl, 'sample_size' => $this->sample_size, diff --git a/plugins/optimization-detective/class-od-url-metric-group.php b/plugins/optimization-detective/class-od-url-metric-group.php index 091872cf98..1e81641fdc 100644 --- a/plugins/optimization-detective/class-od-url-metric-group.php +++ b/plugins/optimization-detective/class-od-url-metric-group.php @@ -12,10 +12,9 @@ } /** - * URL metrics grouped by viewport according to breakpoints. + * URL Metrics grouped by viewport according to breakpoints. * * @implements IteratorAggregate - * @phpstan-import-type ElementData from OD_URL_Metric * * @since 0.1.0 * @access private @@ -23,7 +22,9 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSerializable { /** - * URL metrics. + * URL Metrics. + * + * @since 0.1.0 * * @var OD_URL_Metric[] */ @@ -32,6 +33,8 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * Minimum possible viewport width for the group (inclusive). * + * @since 0.1.0 + * * @var int * @phpstan-var 0|positive-int */ @@ -40,13 +43,17 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * Maximum possible viewport width for the group (inclusive). * + * @since 0.1.0 + * * @var int * @phpstan-var positive-int */ private $maximum_viewport_width; /** - * Sample size for URL metrics for a given breakpoint. + * Sample size for URL Metrics for a given breakpoint. + * + * @since 0.1.0 * * @var int * @phpstan-var positive-int @@ -54,7 +61,9 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer private $sample_size; /** - * Freshness age (TTL) for a given URL metric. + * Freshness age (TTL) for a given URL Metric. + * + * @since 0.1.0 * * @var int * @phpstan-var 0|positive-int @@ -64,16 +73,22 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * Collection that this instance belongs to. * - * @var OD_URL_Metric_Group_Collection|null + * @since 0.3.0 + * + * @var OD_URL_Metric_Group_Collection */ private $collection; /** * Result cache. * + * @since 0.3.0 + * * @var array{ - * get_lcp_element?: ElementData|null, - * is_complete?: bool + * get_lcp_element?: OD_Element|null, + * is_complete?: bool, + * get_xpath_elements_map?: array>, + * get_all_element_max_intersection_ratios?: array, * } */ private $result_cache = array(); @@ -81,16 +96,19 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * Constructor. * + * This class should never be directly constructed. It should only be constructed by the {@see OD_URL_Metric_Group_Collection::create_groups()}. + * + * @access private * @throws InvalidArgumentException If arguments are invalid. * - * @param OD_URL_Metric[] $url_metrics URL metrics to add to the group. - * @param int $minimum_viewport_width Minimum possible viewport width for the group. Must be zero or greater. - * @param int $maximum_viewport_width Maximum possible viewport width for the group. Must be greater than zero and the minimum viewport width. - * @param int $sample_size Sample size for the maximum number of viewports in a group between breakpoints. - * @param int $freshness_ttl Freshness age (TTL) for a given URL metric. - * @param OD_URL_Metric_Group_Collection|null $collection Collection that this instance belongs to. Optional. + * @param OD_URL_Metric[] $url_metrics URL Metrics to add to the group. + * @param int $minimum_viewport_width Minimum possible viewport width for the group. Must be zero or greater. + * @param int $maximum_viewport_width Maximum possible viewport width for the group. Must be greater than zero and the minimum viewport width. + * @param int $sample_size Sample size for the maximum number of viewports in a group between breakpoints. + * @param int $freshness_ttl Freshness age (TTL) for a given URL Metric. + * @param OD_URL_Metric_Group_Collection $collection Collection that this instance belongs to. */ - public function __construct( array $url_metrics, int $minimum_viewport_width, int $maximum_viewport_width, int $sample_size, int $freshness_ttl, ?OD_URL_Metric_Group_Collection $collection = null ) { + public function __construct( array $url_metrics, int $minimum_viewport_width, int $maximum_viewport_width, int $sample_size, int $freshness_ttl, OD_URL_Metric_Group_Collection $collection ) { if ( $minimum_viewport_width < 0 ) { throw new InvalidArgumentException( esc_html__( 'The minimum viewport width must be at least zero.', 'optimization-detective' ) @@ -134,17 +152,16 @@ public function __construct( array $url_metrics, int $minimum_viewport_width, in ); } $this->freshness_ttl = $freshness_ttl; - - if ( ! is_null( $collection ) ) { - $this->collection = $collection; - } - - $this->url_metrics = $url_metrics; + $this->collection = $collection; + $this->url_metrics = $url_metrics; } /** * Gets the minimum possible viewport width (inclusive). * + * @since 0.1.0 + * + * @todo Eliminate in favor of readonly public property. * @return int<0, max> Minimum viewport width. */ public function get_minimum_viewport_width(): int { @@ -154,6 +171,9 @@ public function get_minimum_viewport_width(): int { /** * Gets the maximum possible viewport width (inclusive). * + * @since 0.1.0 + * + * @todo Eliminate in favor of readonly public property. * @return int<1, max> Minimum viewport width. */ public function get_maximum_viewport_width(): int { @@ -161,7 +181,35 @@ public function get_maximum_viewport_width(): int { } /** - * Checks whether the provided viewport width is within the minimum/maximum range for + * Gets the sample size for URL Metrics for a given breakpoint. + * + * @since 0.9.0 + * + * @todo Eliminate in favor of readonly public property. + * @phpstan-return positive-int + * @return int Sample size. + */ + public function get_sample_size(): int { + return $this->sample_size; + } + + /** + * Gets the freshness age (TTL) for a given URL Metric. + * + * @since 0.9.0 + * + * @todo Eliminate in favor of readonly public property. + * @phpstan-return 0|positive-int + * @return int Freshness age. + */ + public function get_freshness_ttl(): int { + return $this->freshness_ttl; + } + + /** + * Checks whether the provided viewport width is within the minimum/maximum range for. + * + * @since 0.1.0 * * @param int $viewport_width Viewport width. * @return bool Whether the viewport width is in range. @@ -174,30 +222,30 @@ public function is_viewport_width_in_range( int $viewport_width ): bool { } /** - * Adds a URL metric to the group. + * Adds a URL Metric to the group. + * + * @since 0.1.0 * - * @throws InvalidArgumentException If the viewport width of the URL metric is not within the min/max bounds of the group. + * @throws InvalidArgumentException If the viewport width of the URL Metric is not within the min/max bounds of the group. * - * @param OD_URL_Metric $url_metric URL metric. + * @param OD_URL_Metric $url_metric URL Metric. */ public function add_url_metric( OD_URL_Metric $url_metric ): void { if ( ! $this->is_viewport_width_in_range( $url_metric->get_viewport_width() ) ) { throw new InvalidArgumentException( - esc_html__( 'URL metric is not in the viewport range for group.', 'optimization-detective' ) + esc_html__( 'URL Metric is not in the viewport range for group.', 'optimization-detective' ) ); } - $this->result_cache = array(); - if ( ! is_null( $this->collection ) ) { - $this->collection->clear_cache(); - } + $this->clear_cache(); + $url_metric->set_group( $this ); $this->url_metrics[] = $url_metric; - // If we have too many URL metrics now, remove the oldest ones up to the sample size. + // If we have too many URL Metrics now, remove the oldest ones up to the sample size. if ( count( $this->url_metrics ) > $this->sample_size ) { - // Sort URL metrics in descending order by timestamp. + // Sort URL Metrics in descending order by timestamp. usort( $this->url_metrics, static function ( OD_URL_Metric $a, OD_URL_Metric $b ): int { @@ -205,16 +253,19 @@ static function ( OD_URL_Metric $a, OD_URL_Metric $b ): int { } ); - // Only keep the sample size of the newest URL metrics. + // Only keep the sample size of the newest URL Metrics. $this->url_metrics = array_slice( $this->url_metrics, 0, $this->sample_size ); } } /** - * Determines whether the URL metric group is complete. + * Determines whether the URL Metric group is complete. + * + * A group is complete if it has the full sample size of URL Metrics + * and all of these URL Metrics are fresh. * - * A group is complete if it has the full sample size of URL metrics - * and all of these URL metrics are fresh. + * @since 0.1.0 + * @since 0.9.0 If the current environment's generated ETag does not match the URL Metric's ETag, the URL Metric is considered stale. * * @return bool Whether complete. */ @@ -229,9 +280,20 @@ public function is_complete(): bool { } $current_time = microtime( true ); foreach ( $this->url_metrics as $url_metric ) { + // The URL Metric is too old to be fresh. if ( $current_time > $url_metric->get_timestamp() + $this->freshness_ttl ) { return false; } + + // The ETag is not populated yet, so this is stale. Eventually this will be required. + if ( $url_metric->get_etag() === null ) { + return false; + } + + // The ETag of the URL Metric does not match the current ETag for the collection, so it is stale. + if ( ! hash_equals( $url_metric->get_etag(), $this->collection->get_current_etag() ) ) { + return false; + } } return true; @@ -244,10 +306,12 @@ public function is_complete(): bool { /** * Gets the LCP element in the viewport group. * - * @return ElementData|null LCP element data or null if not available, either because there are no URL metrics or + * @since 0.3.0 + * + * @return OD_Element|null LCP element data or null if not available, either because there are no URL Metrics or * the LCP element type is not supported. */ - public function get_lcp_element(): ?array { + public function get_lcp_element(): ?OD_Element { if ( array_key_exists( __FUNCTION__, $this->result_cache ) ) { return $this->result_cache[ __FUNCTION__ ]; } @@ -278,26 +342,26 @@ public function get_lcp_element(): ?array { /** * Breadcrumb element. * - * @var array $breadcrumb_element + * @var array $breadcrumb_element */ $breadcrumb_element = array(); foreach ( $this->url_metrics as $url_metric ) { foreach ( $url_metric->get_elements() as $element ) { - if ( ! $element['isLCP'] ) { + if ( ! $element->is_lcp() ) { continue; } - $i = array_search( $element['xpath'], $seen_breadcrumbs, true ); + $i = array_search( $element->get_xpath(), $seen_breadcrumbs, true ); if ( false === $i ) { $i = count( $seen_breadcrumbs ); - $seen_breadcrumbs[ $i ] = $element['xpath']; + $seen_breadcrumbs[ $i ] = $element->get_xpath(); $breadcrumb_counts[ $i ] = 0; } $breadcrumb_counts[ $i ] += 1; $breadcrumb_element[ $i ] = $element; - break; // We found the LCP element for the URL metric, go to the next URL metric. + break; // We found the LCP element for the URL Metric, go to the next URL Metric. } } @@ -319,7 +383,75 @@ public function get_lcp_element(): ?array { } /** - * Returns an iterator for the URL metrics in the group. + * Gets all elements from all URL Metrics in the viewport group keyed by the elements' XPaths. + * + * @since 0.9.0 + * + * @return array> Keys are XPaths and values are the element instances. + */ + public function get_xpath_elements_map(): array { + if ( array_key_exists( __FUNCTION__, $this->result_cache ) ) { + return $this->result_cache[ __FUNCTION__ ]; + } + + $result = ( function () { + $all_elements = array(); + foreach ( $this->url_metrics as $url_metric ) { + foreach ( $url_metric->get_elements() as $element ) { + $all_elements[ $element->get_xpath() ][] = $element; + } + } + return $all_elements; + } )(); + + $this->result_cache[ __FUNCTION__ ] = $result; + return $result; + } + + /** + * Gets the max intersection ratios of all elements in the viewport group and its captured URL Metrics. + * + * @since 0.9.0 + * + * @return array Keys are XPaths and values are the intersection ratios. + */ + public function get_all_element_max_intersection_ratios(): array { + if ( array_key_exists( __FUNCTION__, $this->result_cache ) ) { + return $this->result_cache[ __FUNCTION__ ]; + } + + $result = ( function () { + $elements_max_intersection_ratios = array(); + foreach ( $this->get_xpath_elements_map() as $xpath => $elements ) { + $element_intersection_ratios = array(); + foreach ( $elements as $element ) { + $element_intersection_ratios[] = $element->get_intersection_ratio(); + } + $elements_max_intersection_ratios[ $xpath ] = (float) max( $element_intersection_ratios ); + } + return $elements_max_intersection_ratios; + } )(); + + $this->result_cache[ __FUNCTION__ ] = $result; + return $result; + } + + /** + * Gets the max intersection ratio of an element in the viewport group and its captured URL Metrics. + * + * @since 0.9.0 + * + * @param string $xpath XPath for the element. + * @return float|null Max intersection ratio of null if tag is unknown (not captured). + */ + public function get_element_max_intersection_ratio( string $xpath ): ?float { + return $this->get_all_element_max_intersection_ratios()[ $xpath ] ?? null; + } + + /** + * Returns an iterator for the URL Metrics in the group. + * + * @since 0.1.0 * * @return ArrayIterator ArrayIterator for OD_URL_Metric instances. */ @@ -328,14 +460,26 @@ public function getIterator(): ArrayIterator { } /** - * Counts the URL metrics in the group. + * Counts the URL Metrics in the group. * - * @return int<0, max> URL metric count. + * @since 0.1.0 + * + * @return int<0, max> URL Metric count. */ public function count(): int { return count( $this->url_metrics ); } + /** + * Clears result cache. + * + * @since 0.9.0 + */ + public function clear_cache(): void { + $this->result_cache = array(); + $this->collection->clear_cache(); + } + /** * Specifies data which should be serialized to JSON. * @@ -346,7 +490,7 @@ public function count(): int { * sample_size: positive-int, * minimum_viewport_width: 0|positive-int, * maximum_viewport_width: positive-int, - * lcp_element: ?ElementData, + * lcp_element: ?OD_Element, * complete: bool, * url_metrics: OD_URL_Metric[] * } Data which can be serialized by json_encode(). diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php index 9de7d57a98..0a164edc9e 100644 --- a/plugins/optimization-detective/class-od-url-metric.php +++ b/plugins/optimization-detective/class-od-url-metric.php @@ -31,14 +31,15 @@ * @phpstan-type ElementData array{ * isLCP: bool, * isLCPCandidate: bool, - * xpath: string, + * xpath: non-empty-string, * intersectionRatio: float, * intersectionRect: DOMRect, * boundingClientRect: DOMRect, * } * @phpstan-type Data array{ - * uuid: string, - * url: string, + * uuid: non-empty-string, + * etag?: non-empty-string, + * url: non-empty-string, * timestamp: float, * viewport: ViewportRect, * elements: ElementData[] @@ -56,6 +57,21 @@ class OD_URL_Metric implements JsonSerializable { */ protected $data; + /** + * Elements. + * + * @var OD_Element[] + */ + protected $elements; + + /** + * Group. + * + * @since 0.7.0 + * @var OD_URL_Metric_Group|null + */ + protected $group = null; + /** * Constructor. * @@ -63,7 +79,7 @@ class OD_URL_Metric implements JsonSerializable { * * @throws OD_Data_Validation_Exception When the input is invalid. * - * @param array $data URL metric data. + * @param array $data URL Metric data. */ public function __construct( array $data ) { if ( ! isset( $data['uuid'] ) ) { @@ -96,11 +112,12 @@ private function prepare_data( array $data ): array { throw new OD_Data_Validation_Exception( esc_html( sprintf( - /* translators: 1: current aspect ratio, 2: minimum aspect ratio, 3: maximum aspect ratio */ - __( 'Viewport aspect ratio (%1$s) is not in the accepted range of %2$s to %3$s.', 'optimization-detective' ), + /* translators: 1: current aspect ratio, 2: minimum aspect ratio, 3: maximum aspect ratio, 4: viewport dimensions */ + __( 'Viewport aspect ratio (%1$s) is not in the accepted range of %2$s to %3$s. Viewport dimensions: %4$s', 'optimization-detective' ), $aspect_ratio, $min_aspect_ratio, - $max_aspect_ratio + $max_aspect_ratio, + $data['viewport']['width'] . 'x' . $data['viewport']['height'] ) ) ); @@ -108,9 +125,39 @@ private function prepare_data( array $data ): array { return rest_sanitize_value_from_schema( $data, $schema, self::class ); } + /** + * Gets the group that this URL Metric is a part of (which may not be any). + * + * @since 0.7.0 + * + * @return OD_URL_Metric_Group|null Group. + */ + public function get_group(): ?OD_URL_Metric_Group { + return $this->group; + } + + /** + * Sets the group that this URL Metric is a part of. + * + * @since 0.7.0 + * + * @param OD_URL_Metric_Group $group Group. + * + * @throws InvalidArgumentException When the supplied group has minimum/maximum viewport widths which are out of bounds with the viewport width for this URL Metric. + */ + public function set_group( OD_URL_Metric_Group $group ): void { + if ( ! $group->is_viewport_width_in_range( $this->get_viewport_width() ) ) { + throw new InvalidArgumentException( 'Group does not have the correct minimum or maximum viewport widths for this URL Metric.' ); + } + $this->group = $group; + } + /** * Gets JSON schema for URL Metric. * + * @since 0.1.0 + * @since 0.9.0 Added the 'etag' property to the schema. + * * @todo Cache the return value? * * @return array Schema. @@ -157,12 +204,21 @@ public static function get_json_schema(): array { 'required' => true, 'properties' => array( 'uuid' => array( - 'description' => __( 'The UUID for the URL metric.', 'optimization-detective' ), + 'description' => __( 'The UUID for the URL Metric.', 'optimization-detective' ), 'type' => 'string', 'format' => 'uuid', 'required' => true, 'readonly' => true, // Omit from REST API. ), + 'etag' => array( + 'description' => __( 'The ETag for the URL Metric.', 'optimization-detective' ), + 'type' => 'string', + 'pattern' => '^[0-9a-f]{32}\z', + 'minLength' => 32, + 'maxLength' => 32, + 'required' => false, // To be made required in a future release. + 'readonly' => true, // Omit from REST API. + ), 'url' => array( 'description' => __( 'The URL for which the metric was obtained.', 'optimization-detective' ), 'type' => 'string', @@ -189,7 +245,7 @@ public static function get_json_schema(): array { 'additionalProperties' => false, ), 'timestamp' => array( - 'description' => __( 'Timestamp at which the URL metric was captured.', 'optimization-detective' ), + 'description' => __( 'Timestamp at which the URL Metric was captured.', 'optimization-detective' ), 'type' => 'number', 'required' => true, 'readonly' => true, // Omit from REST API. @@ -241,7 +297,7 @@ public static function get_json_schema(): array { ); /** - * Filters additional schema properties which should be allowed at the root of a URL metric. + * Filters additional schema properties which should be allowed at the root of a URL Metric. * * @since 0.6.0 * @@ -253,7 +309,7 @@ public static function get_json_schema(): array { } /** - * Filters additional schema properties which should be allowed for an elements item in a URL metric. + * Filters additional schema properties which should be allowed for an element's item in a URL Metric. * * @since 0.6.0 * @@ -264,7 +320,7 @@ public static function get_json_schema(): array { $schema['properties']['elements']['items']['properties'] = self::extend_schema_with_optional_properties( $schema['properties']['elements']['items']['properties'], $additional_properties, - 'od_url_metric_schema_root_additional_properties' + 'od_url_metric_schema_element_item_additional_properties' ); } @@ -355,21 +411,40 @@ protected static function extend_schema_with_optional_properties( array $propert * @return mixed|null The property value, or null if not set. */ public function get( string $key ) { + if ( 'elements' === $key ) { + return $this->get_elements(); + } return $this->data[ $key ] ?? null; } /** * Gets UUID. * + * @since 0.6.0 + * * @return string UUID. */ public function get_uuid(): string { return $this->data['uuid']; } + /** + * Gets ETag. + * + * @since 0.9.0 + * + * @return non-empty-string|null ETag. + */ + public function get_etag(): ?string { + // Since the ETag is optional for now, return null for old URL Metrics that do not have one. + return $this->data['etag'] ?? null; + } + /** * Gets URL. * + * @since 0.1.0 + * * @return string URL. */ public function get_url(): string { @@ -379,6 +454,8 @@ public function get_url(): string { /** * Gets viewport data. * + * @since 0.1.0 + * * @return ViewportRect Viewport data. */ public function get_viewport(): array { @@ -388,6 +465,8 @@ public function get_viewport(): array { /** * Gets viewport width. * + * @since 0.1.0 + * * @return int Viewport width. */ public function get_viewport_width(): int { @@ -397,6 +476,8 @@ public function get_viewport_width(): int { /** * Gets timestamp. * + * @since 0.1.0 + * * @return float Timestamp. */ public function get_timestamp(): float { @@ -406,18 +487,39 @@ public function get_timestamp(): float { /** * Gets elements. * - * @return ElementData[] Elements. + * @since 0.1.0 + * + * @return OD_Element[] Elements. */ public function get_elements(): array { - return $this->data['elements']; + if ( ! is_array( $this->elements ) ) { + $this->elements = array_map( + function ( array $element ): OD_Element { + return new OD_Element( $element, $this ); + }, + $this->data['elements'] + ); + } + return $this->elements; } /** * Specifies data which should be serialized to JSON. * + * @since 0.1.0 + * * @return Data Exports to be serialized by json_encode(). */ public function jsonSerialize(): array { - return $this->data; + $data = $this->data; + + $data['elements'] = array_map( + static function ( OD_Element $element ): array { + return $element->jsonSerialize(); + }, + $this->get_elements() + ); + + return $data; } } diff --git a/plugins/optimization-detective/detect.js b/plugins/optimization-detective/detect.js index 72433be5cc..ded7ff898b 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -1,4 +1,17 @@ -/** @typedef {import("web-vitals").LCPMetric} LCPMetric */ +/** + * @typedef {import("web-vitals").LCPMetric} LCPMetric + * @typedef {import("./types.ts").ElementData} ElementData + * @typedef {import("./types.ts").OnTTFBFunction} OnTTFBFunction + * @typedef {import("./types.ts").OnFCPFunction} OnFCPFunction + * @typedef {import("./types.ts").OnLCPFunction} OnLCPFunction + * @typedef {import("./types.ts").OnINPFunction} OnINPFunction + * @typedef {import("./types.ts").OnCLSFunction} OnCLSFunction + * @typedef {import("./types.ts").URLMetric} URLMetric + * @typedef {import("./types.ts").URLMetricGroupStatus} URLMetricGroupStatus + * @typedef {import("./types.ts").Extension} Extension + * @typedef {import("./types.ts").ExtendedRootData} ExtendedRootData + * @typedef {import("./types.ts").ExtendedElementData} ExtendedElementData + */ const win = window; const doc = win.document; @@ -33,7 +46,7 @@ function isStorageLocked( currentTime, storageLockTTL ) { } /** - * Set the storage lock. + * Sets the storage lock. * * @param {number} currentTime - Current time in milliseconds. */ @@ -47,7 +60,7 @@ function setStorageLock( currentTime ) { } /** - * Log a message. + * Logs a message. * * @param {...*} message */ @@ -57,7 +70,7 @@ function log( ...message ) { } /** - * Log a warning. + * Logs a warning. * * @param {...*} message */ @@ -67,7 +80,7 @@ function warn( ...message ) { } /** - * Log an error. + * Logs an error. * * @param {...*} message */ @@ -77,36 +90,11 @@ function error( ...message ) { } /** - * @typedef {Object} ElementMetrics - * @property {boolean} isLCP - Whether it is the LCP candidate. - * @property {boolean} isLCPCandidate - Whether it is among the LCP candidates. - * @property {string} xpath - XPath. - * @property {number} intersectionRatio - Intersection ratio. - * @property {DOMRectReadOnly} intersectionRect - Intersection rectangle. - * @property {DOMRectReadOnly} boundingClientRect - Bounding client rectangle. - */ - -/** - * @typedef {Object} URLMetric - * @property {string} url - URL of the page. - * @property {Object} viewport - Viewport. - * @property {number} viewport.width - Viewport width. - * @property {number} viewport.height - Viewport height. - * @property {ElementMetrics[]} elements - Metrics for the elements observed on the page. - */ - -/** - * @typedef {Object} URLMetricGroupStatus - * @property {number} minimumViewportWidth - Minimum viewport width. - * @property {boolean} complete - Whether viewport group is complete. - */ - -/** - * Checks whether the URL metric(s) for the provided viewport width is needed. + * Checks whether the URL Metric(s) for the provided viewport width is needed. * * @param {number} viewportWidth - Current viewport width. * @param {URLMetricGroupStatus[]} urlMetricGroupStatuses - Viewport group statuses. - * @return {boolean} Whether URL metrics are needed. + * @return {boolean} Whether URL Metrics are needed. */ function isViewportNeeded( viewportWidth, urlMetricGroupStatuses ) { let lastWasLacking = false; @@ -129,61 +117,184 @@ function getCurrentTime() { return Date.now(); } +/** + * Recursively freezes an object to prevent mutation. + * + * @param {Object} obj Object to recursively freeze. + */ +function recursiveFreeze( obj ) { + for ( const prop of Object.getOwnPropertyNames( obj ) ) { + const value = obj[ prop ]; + if ( null !== value && typeof value === 'object' ) { + recursiveFreeze( value ); + } + } + Object.freeze( obj ); +} + +/** + * URL Metric being assembled for submission. + * + * @type {URLMetric} + */ +let urlMetric; + +/** + * Reserved root property keys. + * + * @see {URLMetric} + * @see {ExtendedElementData} + * @type {Set} + */ +const reservedRootPropertyKeys = new Set( [ 'url', 'viewport', 'elements' ] ); + +/** + * Gets root URL Metric data. + * + * @return {URLMetric} URL Metric. + */ +function getRootData() { + const immutableUrlMetric = structuredClone( urlMetric ); + recursiveFreeze( immutableUrlMetric ); + return immutableUrlMetric; +} + +/** + * Extends root URL Metric data. + * + * @param {ExtendedRootData} properties + */ +function extendRootData( properties ) { + for ( const key of Object.getOwnPropertyNames( properties ) ) { + if ( reservedRootPropertyKeys.has( key ) ) { + throw new Error( `Disallowed setting of key '${ key }' on root.` ); + } + } + Object.assign( urlMetric, properties ); +} + +/** + * Mapping of XPath to element data. + * + * @type {Map} + */ +const elementsByXPath = new Map(); + +/** + * Reserved element property keys. + * + * @see {ElementData} + * @see {ExtendedRootData} + * @type {Set} + */ +const reservedElementPropertyKeys = new Set( [ + 'isLCP', + 'isLCPCandidate', + 'xpath', + 'intersectionRatio', + 'intersectionRect', + 'boundingClientRect', +] ); + +/** + * Gets element data. + * + * @param {string} xpath XPath. + * @return {ElementData|null} Element data, or null if no element for the XPath exists. + */ +function getElementData( xpath ) { + const elementData = elementsByXPath.get( xpath ); + if ( elementData ) { + const cloned = structuredClone( elementData ); + recursiveFreeze( cloned ); + return cloned; + } + return null; +} + +/** + * Extends element data. + * + * @param {string} xpath XPath. + * @param {ExtendedElementData} properties Properties. + */ +function extendElementData( xpath, properties ) { + if ( ! elementsByXPath.has( xpath ) ) { + throw new Error( `Unknown element with XPath: ${ xpath }` ); + } + for ( const key of Object.getOwnPropertyNames( properties ) ) { + if ( reservedElementPropertyKeys.has( key ) ) { + throw new Error( + `Disallowed setting of key '${ key }' on element.` + ); + } + } + const elementData = elementsByXPath.get( xpath ); + Object.assign( elementData, properties ); +} + +/** + * @typedef {{timestamp: number, creationDate: Date}} UrlMetricDebugData + * @typedef {{groups: Array<{url_metrics: Array}>}} CollectionDebugData + */ + /** * Detects the LCP element, loaded images, client viewport and store for future optimizations. * * @param {Object} args Args. - * @param {number} args.serveTime The serve time of the page in milliseconds from PHP via `microtime( true ) * 1000`. - * @param {number} args.detectionTimeWindow The number of milliseconds between now and when the page was first generated in which detection should proceed. + * @param {string[]} args.extensionModuleUrls URLs for extension script modules to import. * @param {number} args.minViewportAspectRatio Minimum aspect ratio allowed for the viewport. * @param {number} args.maxViewportAspectRatio Maximum aspect ratio allowed for the viewport. * @param {boolean} args.isDebug Whether to show debug messages. * @param {string} args.restApiEndpoint URL for where to send the detection data. - * @param {string} args.restApiNonce Nonce for writing to the REST API. + * @param {string} args.currentETag Current ETag. * @param {string} args.currentUrl Current URL. - * @param {string} args.urlMetricSlug Slug for URL metric. - * @param {string} args.urlMetricNonce Nonce for URL metric storage. - * @param {URLMetricGroupStatus[]} args.urlMetricGroupStatuses URL metric group statuses. - * @param {number} args.storageLockTTL The TTL (in seconds) for the URL metric storage lock. + * @param {string} args.urlMetricSlug Slug for URL Metric. + * @param {number|null} args.cachePurgePostId Cache purge post ID. + * @param {string} args.urlMetricHMAC HMAC for URL Metric storage. + * @param {URLMetricGroupStatus[]} args.urlMetricGroupStatuses URL Metric group statuses. + * @param {number} args.storageLockTTL The TTL (in seconds) for the URL Metric storage lock. * @param {string} args.webVitalsLibrarySrc The URL for the web-vitals library. - * @param {Object} [args.urlMetricGroupCollection] URL metric group collection, when in debug mode. + * @param {CollectionDebugData} [args.urlMetricGroupCollection] URL Metric group collection, when in debug mode. */ export default async function detect( { - serveTime, - detectionTimeWindow, minViewportAspectRatio, maxViewportAspectRatio, isDebug, + extensionModuleUrls, restApiEndpoint, - restApiNonce, + currentETag, currentUrl, urlMetricSlug, - urlMetricNonce, + cachePurgePostId, + urlMetricHMAC, urlMetricGroupStatuses, storageLockTTL, webVitalsLibrarySrc, urlMetricGroupCollection, } ) { - const currentTime = getCurrentTime(); - if ( isDebug ) { - log( 'Stored URL metric group collection:', urlMetricGroupCollection ); - } - - // Abort running detection logic if it was served in a cached page. - if ( currentTime - serveTime > detectionTimeWindow ) { - if ( isDebug ) { - warn( - 'Aborted detection due to being outside detection time window.' - ); + const allUrlMetrics = /** @type Array */ []; + for ( const group of urlMetricGroupCollection.groups ) { + for ( const otherUrlMetric of group.url_metrics ) { + otherUrlMetric.creationDate = new Date( + otherUrlMetric.timestamp * 1000 + ); + allUrlMetrics.push( otherUrlMetric ); + } } - return; + log( 'Stored URL Metric Group Collection:', urlMetricGroupCollection ); + allUrlMetrics.sort( ( a, b ) => b.timestamp - a.timestamp ); + log( + 'Stored URL Metrics in reverse chronological order:', + allUrlMetrics + ); } - // Abort if the current viewport is not among those which need URL metrics. + // Abort if the current viewport is not among those which need URL Metrics. if ( ! isViewportNeeded( win.innerWidth, urlMetricGroupStatuses ) ) { if ( isDebug ) { - log( 'No need for URL metrics from the current viewport.' ); + log( 'No need for URL Metrics from the current viewport.' ); } return; } @@ -227,16 +338,36 @@ export default async function detect( { } ); } + // TODO: Does this make sense here? Should it be moved up above the isViewportNeeded condition? // As an alternative to this, the od_print_detection_script() function can short-circuit if the // od_is_url_metric_storage_locked() function returns true. However, the downside with that is page caching could // result in metrics missed from being gathered when a user navigates around a site and primes the page cache. - if ( isStorageLocked( currentTime, storageLockTTL ) ) { + if ( isStorageLocked( getCurrentTime(), storageLockTTL ) ) { if ( isDebug ) { warn( 'Aborted detection due to storage being locked.' ); } return; } + // Keep track of whether the window resized. If it resized, we abort sending the URLMetric. + let didWindowResize = false; + window.addEventListener( + 'resize', + () => { + didWindowResize = true; + }, + { once: true } + ); + + const { + /** @type OnTTFBFunction */ onTTFB, + /** @type OnFCPFunction */ onFCP, + /** @type OnLCPFunction */ onLCP, + /** @type OnINPFunction */ onINP, + /** @type OnCLSFunction */ onCLS, + } = await import( webVitalsLibrarySrc ); + + // TODO: Does this make sense here? // Prevent detection when page is not scrolled to the initial viewport. if ( doc.documentElement.scrollTop > 0 ) { if ( isDebug ) { @@ -251,9 +382,62 @@ export default async function detect( { log( 'Proceeding with detection' ); } + /** @type {Map} */ + const extensions = new Map(); + + /** @type {Promise[]} */ + const extensionInitializePromises = []; + + /** @type {string[]} */ + const initializingExtensionModuleUrls = []; + + for ( const extensionModuleUrl of extensionModuleUrls ) { + try { + /** @type {Extension} */ + const extension = await import( extensionModuleUrl ); + extensions.set( extensionModuleUrl, extension ); + // TODO: There should to be a way to pass additional args into the module. Perhaps extensionModuleUrls should be a mapping of URLs to args. + if ( extension.initialize instanceof Function ) { + const initializePromise = extension.initialize( { + isDebug, + onTTFB, + onFCP, + onLCP, + onINP, + onCLS, + } ); + if ( initializePromise instanceof Promise ) { + extensionInitializePromises.push( initializePromise ); + initializingExtensionModuleUrls.push( extensionModuleUrl ); + } + } + } catch ( err ) { + error( + `Failed to start initializing extension '${ extensionModuleUrl }':`, + err + ); + } + } + + // Wait for all extensions to finish initializing. + const settledInitializePromises = await Promise.allSettled( + extensionInitializePromises + ); + for ( const [ + i, + settledInitializePromise, + ] of settledInitializePromises.entries() ) { + if ( settledInitializePromise.status === 'rejected' ) { + error( + `Failed to initialize extension '${ initializingExtensionModuleUrls[ i ] }':`, + settledInitializePromise.reason + ); + } + } + const breadcrumbedElements = doc.body.querySelectorAll( '[data-od-xpath]' ); - /** @type {Map} */ + /** @type {Map} */ const breadcrumbedElementsMap = new Map( [ ...breadcrumbedElements ].map( /** @@ -306,15 +490,13 @@ export default async function detect( { } ); } - const { onLCP } = await import( webVitalsLibrarySrc ); - /** @type {LCPMetric[]} */ const lcpMetricCandidates = []; // Obtain at least one LCP candidate. More may be reported before the page finishes loading. await new Promise( ( resolve ) => { onLCP( - ( metric ) => { + ( /** @type LCPMetric */ metric ) => { lcpMetricCandidates.push( metric ); resolve(); }, @@ -333,8 +515,7 @@ export default async function detect( { log( 'Detection is stopping.' ); } - /** @type {URLMetric} */ - const urlMetric = { + urlMetric = { url: currentUrl, viewport: { width: win.innerWidth, @@ -354,16 +535,21 @@ export default async function detect( { continue; } - const isLCP = - elementIntersection.target === lcpMetric?.entries[ 0 ]?.element; + const element = /** @type {Element|null} */ ( + lcpMetric?.entries[ 0 ]?.element + ); + const isLCP = elementIntersection.target === element; - /** @type {ElementMetrics} */ - const elementMetrics = { + /** @type {ElementData} */ + const elementData = { isLCP, isLCPCandidate: !! lcpMetricCandidates.find( - ( lcpMetricCandidate ) => - lcpMetricCandidate.entries[ 0 ]?.element === - elementIntersection.target + ( lcpMetricCandidate ) => { + const candidateElement = /** @type {Element|null} */ ( + lcpMetricCandidate.entries[ 0 ]?.element + ); + return candidateElement === elementIntersection.target; + } ), xpath, intersectionRatio: elementIntersection.intersectionRatio, @@ -371,49 +557,118 @@ export default async function detect( { boundingClientRect: elementIntersection.boundingClientRect, }; - urlMetric.elements.push( elementMetrics ); + urlMetric.elements.push( elementData ); + elementsByXPath.set( elementData.xpath, elementData ); } if ( isDebug ) { - log( 'Current URL metric:', urlMetric ); + log( 'Current URL Metric:', urlMetric ); } - // Yield to main before sending data to server to further break up task. + // Wait for the page to be hidden. await new Promise( ( resolve ) => { - setTimeout( resolve, 0 ); - } ); - - try { - const restUrl = new URL( restApiEndpoint ); - restUrl.searchParams.append( 'slug', urlMetricSlug ); - restUrl.searchParams.append( 'nonce', urlMetricNonce ); - const response = await fetch( restUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': restApiNonce, + win.addEventListener( 'pagehide', resolve, { once: true } ); + win.addEventListener( 'pageswap', resolve, { once: true } ); + doc.addEventListener( + 'visibilitychange', + () => { + if ( document.visibilityState === 'hidden' ) { + // TODO: This will fire even when switching tabs. + resolve(); + } }, - body: JSON.stringify( urlMetric ), - } ); + { once: true } + ); + } ); - if ( response.status === 200 ) { - setStorageLock( getCurrentTime() ); + // Only proceed with submitting the URL Metric if viewport stayed the same size. Changing the viewport size (e.g. due + // to resizing a window or changing the orientation of a device) will result in unexpected metrics being collected. + if ( didWindowResize ) { + if ( isDebug ) { + log( + 'Aborting URL Metric collection due to viewport size change.' + ); } + return; + } - if ( isDebug ) { - const body = await response.json(); - if ( response.status === 200 ) { - log( 'Response:', body ); - } else { - error( 'Failure:', body ); + if ( extensions.size > 0 ) { + /** @type {Promise[]} */ + const extensionFinalizePromises = []; + + /** @type {string[]} */ + const finalizingExtensionModuleUrls = []; + + for ( const [ + extensionModuleUrl, + extension, + ] of extensions.entries() ) { + if ( extension.finalize instanceof Function ) { + try { + const finalizePromise = extension.finalize( { + isDebug, + getRootData, + getElementData, + extendElementData, + extendRootData, + } ); + if ( finalizePromise instanceof Promise ) { + extensionFinalizePromises.push( finalizePromise ); + finalizingExtensionModuleUrls.push( + extensionModuleUrl + ); + } + } catch ( err ) { + error( + `Unable to start finalizing extension '${ extensionModuleUrl }':`, + err + ); + } } } - } catch ( err ) { - if ( isDebug ) { - error( err ); + + // Wait for all extensions to finish finalizing. + const settledFinalizePromises = await Promise.allSettled( + extensionFinalizePromises + ); + for ( const [ + i, + settledFinalizePromise, + ] of settledFinalizePromises.entries() ) { + if ( settledFinalizePromise.status === 'rejected' ) { + error( + `Failed to finalize extension '${ finalizingExtensionModuleUrls[ i ] }':`, + settledFinalizePromise.reason + ); + } } } + // Even though the server may reject the REST API request, we still have to set the storage lock + // because we can't look at the response when sending a beacon. + setStorageLock( getCurrentTime() ); + + if ( isDebug ) { + log( 'Sending URL Metric:', urlMetric ); + } + + const url = new URL( restApiEndpoint ); + url.searchParams.set( 'slug', urlMetricSlug ); + url.searchParams.set( 'current_etag', currentETag ); + if ( typeof cachePurgePostId === 'number' ) { + url.searchParams.set( + 'cache_purge_post_id', + cachePurgePostId.toString() + ); + } + url.searchParams.set( 'hmac', urlMetricHMAC ); + navigator.sendBeacon( + url, + new Blob( [ JSON.stringify( urlMetric ) ], { + type: 'application/json', + } ) + ); + // Clean up. breadcrumbedElementsMap.clear(); } diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index 1a1ca55639..2fa2a6dee7 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -10,46 +10,95 @@ exit; // Exit if accessed directly. } +/** + * Obtains the ID for a post related to this response so that page caches can be told to invalidate their cache. + * + * If the queried object for the response is a post, then that post's ID is used. Otherwise, it uses the ID of the first + * post in The Loop. + * + * When the queried object is a post (e.g. is_singular, is_posts_page, is_front_page w/ show_on_front=page), then this + * is the perfect match. A page caching plugin will be able to most reliably invalidate the cache for a URL via + * this ID if the relevant actions are triggered for the post (e.g. clean_post_cache, save_post, transition_post_status). + * + * Otherwise, if the response is an archive page or the front page where show_on_front=posts (i.e. is_home), then + * there is no singular post object that represents the URL. In this case, we obtain the first post in the main + * loop. By triggering the relevant actions for this post ID, page caches will have their best shot at invalidating + * the related URLs. Page caching plugins which leverage surrogate keys will be the most reliable here. Otherwise, + * caching plugins may just resort to automatically purging the cache for the homepage whenever any post is edited, + * which is better than nothing. + * + * There should not be any situation by default in which a page optimized with Optimization Detective does not have such + * a post available for cache purging. As seen in {@see od_can_optimize_response()}, when such a post ID is not + * available for cache purging then it returns false, as it also does in another case like if is_404(). + * + * @since 0.8.0 + * @access private + * + * @global WP_Query $wp_query WordPress Query object. + * + * @return int|null Post ID or null if none found. + */ +function od_get_cache_purge_post_id(): ?int { + $queried_object = get_queried_object(); + if ( $queried_object instanceof WP_Post ) { + return $queried_object->ID; + } + + global $wp_query; + if ( + $wp_query instanceof WP_Query + && + $wp_query->post_count > 0 + && + isset( $wp_query->posts[0] ) + && + $wp_query->posts[0] instanceof WP_Post + ) { + return $wp_query->posts[0]->ID; + } + + return null; +} + /** * Prints the script for detecting loaded images and the LCP element. * * @since 0.1.0 * @access private * - * @param string $slug URL metrics slug. - * @param OD_URL_Metric_Group_Collection $group_collection URL metric group collection. + * @param string $slug URL Metrics slug. + * @param OD_URL_Metric_Group_Collection $group_collection URL Metric group collection. */ function od_get_detection_script( string $slug, OD_URL_Metric_Group_Collection $group_collection ): string { + $web_vitals_lib_data = require __DIR__ . '/build/web-vitals.asset.php'; + $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_lib_data['version'], plugin_dir_url( __FILE__ ) . 'build/web-vitals.js' ); + /** - * Filters the time window between serve time and run time in which loading detection is allowed to run. - * - * This is the allowance of milliseconds between when the page was first generated (and perhaps cached) and when the - * detect function on the page is allowed to perform its detection logic and submit the request to store the results. - * This avoids situations in which there is missing URL Metrics in which case a site with page caching which - * also has a lot of traffic could result in a cache stampede. + * Filters the list of extension script module URLs to import when performing detection. * - * @since 0.1.0 - * @todo The value should probably be something like the 99th percentile of Time To Last Byte (TTLB) for WordPress sites in CrUX. + * @since 0.7.0 * - * @param int $detection_time_window Detection time window in milliseconds. + * @param string[] $extension_module_urls Extension module URLs. */ - $detection_time_window = apply_filters( 'od_detection_time_window', 5000 ); + $extension_module_urls = (array) apply_filters( 'od_extension_module_urls', array() ); - $web_vitals_lib_data = require __DIR__ . '/build/web-vitals.asset.php'; - $web_vitals_lib_src = add_query_arg( 'ver', $web_vitals_lib_data['version'], plugin_dir_url( __FILE__ ) . 'build/web-vitals.js' ); + $cache_purge_post_id = od_get_cache_purge_post_id(); $current_url = od_get_current_url(); + + $current_etag = $group_collection->get_current_etag(); + $detect_args = array( - 'serveTime' => microtime( true ) * 1000, // In milliseconds for comparison with `Date.now()` in JavaScript. - 'detectionTimeWindow' => $detection_time_window, 'minViewportAspectRatio' => od_get_minimum_viewport_aspect_ratio(), 'maxViewportAspectRatio' => od_get_maximum_viewport_aspect_ratio(), 'isDebug' => WP_DEBUG, + 'extensionModuleUrls' => $extension_module_urls, 'restApiEndpoint' => rest_url( OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE ), - 'restApiNonce' => wp_create_nonce( 'wp_rest' ), + 'currentETag' => $current_etag, 'currentUrl' => $current_url, 'urlMetricSlug' => $slug, - 'urlMetricNonce' => od_get_url_metrics_storage_nonce( $slug, $current_url ), + 'cachePurgePostId' => od_get_cache_purge_post_id(), + 'urlMetricHMAC' => od_get_url_metrics_storage_hmac( $slug, $current_etag, $current_url, $cache_purge_post_id ), 'urlMetricGroupStatuses' => array_map( static function ( OD_URL_Metric_Group $group ): array { return array( @@ -69,7 +118,7 @@ static function ( OD_URL_Metric_Group $group ): array { return wp_get_inline_script_tag( sprintf( 'import detect from %s; detect( %s );', - wp_json_encode( add_query_arg( 'ver', OPTIMIZATION_DETECTIVE_VERSION, plugin_dir_url( __FILE__ ) . 'detect.js' ) ), + wp_json_encode( add_query_arg( 'ver', OPTIMIZATION_DETECTIVE_VERSION, plugin_dir_url( __FILE__ ) . od_get_asset_path( 'detect.js' ) ) ), wp_json_encode( $detect_args ) ), array( 'type' => 'module' ) diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index 553d55cb11..27073205d1 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -10,6 +10,49 @@ exit; // Exit if accessed directly. } +/** + * Initializes extensions for Optimization Detective. + * + * @since 0.7.0 + */ +function od_initialize_extensions(): void { + /** + * Fires when extensions to Optimization Detective can be loaded and initialized. + * + * @since 0.7.0 + * + * @param string $version Optimization Detective version. + */ + do_action( 'od_init', OPTIMIZATION_DETECTIVE_VERSION ); +} + +/** + * Generates a media query for the provided minimum and maximum viewport widths. + * + * @since 0.7.0 + * + * @param int|null $minimum_viewport_width Minimum viewport width. + * @param int|null $maximum_viewport_width Maximum viewport width. + * @return non-empty-string|null Media query, or null if the min/max were both unspecified or invalid. + */ +function od_generate_media_query( ?int $minimum_viewport_width, ?int $maximum_viewport_width ): ?string { + if ( is_int( $minimum_viewport_width ) && is_int( $maximum_viewport_width ) && $minimum_viewport_width > $maximum_viewport_width ) { + _doing_it_wrong( __FUNCTION__, esc_html__( 'The minimum width cannot be greater than the maximum width.', 'optimization-detective' ), 'Optimization Detective 0.7.0' ); + return null; + } + $media_attributes = array(); + if ( null !== $minimum_viewport_width && $minimum_viewport_width > 0 ) { + $media_attributes[] = sprintf( '(min-width: %dpx)', $minimum_viewport_width ); + } + if ( null !== $maximum_viewport_width && PHP_INT_MAX !== $maximum_viewport_width ) { + $media_attributes[] = sprintf( '(max-width: %dpx)', $maximum_viewport_width ); + } + if ( count( $media_attributes ) === 0 ) { + return null; + } + return join( ' and ', $media_attributes ); +} + /** * Displays the HTML generator meta tag for the Optimization Detective plugin. * @@ -21,3 +64,46 @@ function od_render_generator_meta_tag(): void { // Use the plugin slug as it is immutable. echo '' . "\n"; } + +/** + * Gets the path to a script or stylesheet. + * + * @since 0.9.0 + * + * @param string $src_path Source path, relative to plugin root. + * @param string|null $min_path Minified path. If not supplied, then '.min' is injected before the file extension in the source path. + * @return string URL to script or stylesheet. + * + * @noinspection PhpDocMissingThrowsInspection + */ +function od_get_asset_path( string $src_path, ?string $min_path = null ): string { + if ( null === $min_path ) { + // Note: wp_scripts_get_suffix() is not used here because we need access to both the source and minified paths. + $min_path = (string) preg_replace( '/(?=\.\w+$)/', '.min', $src_path ); + } + + $force_src = false; + if ( WP_DEBUG && ! file_exists( trailingslashit( __DIR__ ) . $min_path ) ) { + $force_src = true; + /** + * No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level. + * + * @noinspection PhpUnhandledExceptionInspection + */ + wp_trigger_error( + __FUNCTION__, + sprintf( + /* translators: %s is the minified asset path */ + __( 'Minified asset has not been built: %s', 'optimization-detective' ), + $min_path + ), + E_USER_WARNING + ); + } + + if ( SCRIPT_DEBUG || $force_src ) { + return $src_path; + } + + return $min_path; +} diff --git a/plugins/optimization-detective/hooks.php b/plugins/optimization-detective/hooks.php index 6b6feb924d..c0f94d148c 100644 --- a/plugins/optimization-detective/hooks.php +++ b/plugins/optimization-detective/hooks.php @@ -10,6 +10,7 @@ exit; // Exit if accessed directly. } +add_action( 'init', 'od_initialize_extensions', PHP_INT_MAX ); add_filter( 'template_include', 'od_buffer_output', PHP_INT_MAX ); OD_URL_Metrics_Post_Type::add_hooks(); add_action( 'wp', 'od_maybe_add_template_output_buffer_filter' ); diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index a5f241f273..81b60cb75f 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -3,9 +3,9 @@ * Plugin Name: Optimization Detective * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/optimization-detective * Description: Provides an API for leveraging real user metrics to detect optimizations to apply on the frontend to improve page performance. - * Requires at least: 6.5 + * Requires at least: 6.6 * Requires PHP: 7.2 - * Version: 0.6.0 + * Version: 0.9.0 * Author: WordPress Performance Team * Author URI: https://make.wordpress.org/performance/ * License: GPLv2 or later @@ -43,9 +43,14 @@ static function ( string $global_var_name, string $version, Closure $load ): voi } }; - // Wait until after the plugins have loaded and the theme has loaded. The after_setup_theme action is used - // because it is the first action that fires once the theme is loaded. - add_action( 'after_setup_theme', $bootstrap, PHP_INT_MIN ); + /* + * Wait until after the plugins have loaded and the theme has loaded. The after_setup_theme action could be + * used since it is the first action that fires once the theme is loaded. However, plugins may embed this + * logic inside a module which initializes even later at the init action. The earliest action that this + * plugin has hooks for is the init action at the default priority of 10 (which includes the rest_api_init + * action), so this is why it gets initialized at priority 9. + */ + add_action( 'init', $bootstrap, 9 ); } // Register this copy of the plugin. @@ -65,10 +70,8 @@ static function ( string $global_var_name, string $version, Closure $load ): voi } )( 'optimization_detective_pending_plugin', - '0.6.0', + '0.9.0', static function ( string $version ): void { - - // Define the constant. if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) { return; } @@ -99,6 +102,7 @@ static function ( string $version ): void { require_once __DIR__ . '/class-od-data-validation-exception.php'; require_once __DIR__ . '/class-od-html-tag-processor.php'; require_once __DIR__ . '/class-od-url-metric.php'; + require_once __DIR__ . '/class-od-element.php'; require_once __DIR__ . '/class-od-strict-url-metric.php'; require_once __DIR__ . '/class-od-url-metric-group.php'; require_once __DIR__ . '/class-od-url-metric-group-collection.php'; diff --git a/plugins/optimization-detective/optimization.php b/plugins/optimization-detective/optimization.php index ffb97c8a41..fa72a6194e 100644 --- a/plugins/optimization-detective/optimization.php +++ b/plugins/optimization-detective/optimization.php @@ -98,6 +98,8 @@ function_exists( 'perflab_server_timing_use_output_buffer' ) * Determines whether the current response can be optimized. * * @since 0.1.0 + * @since 0.9.0 Response is optimized for admin users as well when in 'plugin' development mode. + * * @access private * * @return bool Whether response can be optimized. @@ -116,11 +118,15 @@ function od_can_optimize_response(): bool { is_customize_preview() || // Since the images detected in the response body of a POST request cannot, by definition, be cached. ( isset( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== $_SERVER['REQUEST_METHOD'] ) || - // The aim is to optimize pages for the majority of site visitors, not those who administer the site. For admin - // users, additional elements will be present like the script from wp_customize_support_script() which will - // interfere with the XPath indices. Note that od_get_normalized_query_vars() is varied by is_user_logged_in() - // so membership sites and e-commerce sites will still be able to be optimized for their normal visitors. - current_user_can( 'customize' ) + // The aim is to optimize pages for the majority of site visitors, not for those who administer the site, unless + // in 'plugin' development mode. For admin users, additional elements will be present, like the script from + // wp_customize_support_script(), which will interfere with the XPath indices. Note that + // od_get_normalized_query_vars() is varied by is_user_logged_in(), so membership sites and e-commerce sites + // will still be able to be optimized for their normal visitors. + ( current_user_can( 'customize' ) && ! wp_is_development_mode( 'plugin' ) ) || + // Page caching plugins can only reliably be told to invalidate a cached page when a post is available to trigger + // the relevant actions on. + null === od_get_cache_purge_post_id() ); /** @@ -164,10 +170,14 @@ function od_is_response_html_content_type(): bool { * @since 0.1.0 * @access private * + * @global WP_Query $wp_the_query WP_Query object. + * * @param string $buffer Template output buffer. * @return string Filtered template output buffer. */ function od_optimize_template_output_buffer( string $buffer ): string { + global $wp_the_query; + // If the content-type is not HTML or the output does not start with '<', then abort since the buffer is definitely not HTML. if ( ! od_is_response_html_content_type() || @@ -189,16 +199,6 @@ function od_optimize_template_output_buffer( string $buffer ): string { $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); $post = OD_URL_Metrics_Post_Type::get_post( $slug ); - $group_collection = new OD_URL_Metric_Group_Collection( - $post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(), - od_get_breakpoint_max_widths(), - od_get_url_metrics_breakpoint_sample_size(), - od_get_url_metric_freshness_ttl() - ); - - // Whether we need to add the data-od-xpath attribute to elements and whether the detection script should be injected. - $needs_detection = ! $group_collection->is_every_group_complete(); - $tag_visitor_registry = new OD_Tag_Visitor_Registry(); /** @@ -210,10 +210,22 @@ function od_optimize_template_output_buffer( string $buffer ): string { */ do_action( 'od_register_tag_visitors', $tag_visitor_registry ); + $current_etag = od_get_current_url_metrics_etag( $tag_visitor_registry, $wp_the_query, od_get_current_theme_template() ); + $group_collection = new OD_URL_Metric_Group_Collection( + $post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(), + $current_etag, + od_get_breakpoint_max_widths(), + od_get_url_metrics_breakpoint_sample_size(), + od_get_url_metric_freshness_ttl() + ); $link_collection = new OD_Link_Collection(); $tag_visitor_context = new OD_Tag_Visitor_Context( $processor, $group_collection, $link_collection ); $current_tag_bookmark = 'optimization_detective_current_tag'; $visitors = iterator_to_array( $tag_visitor_registry ); + + // Whether we need to add the data-od-xpath attribute to elements and whether the detection script should be injected. + $needs_detection = ! $group_collection->is_every_group_complete(); + do { $tracked_in_url_metrics = false; $processor->set_bookmark( $current_tag_bookmark ); // TODO: Should we break if this returns false? diff --git a/plugins/optimization-detective/readme.txt b/plugins/optimization-detective/readme.txt index d23ff3dfc7..62dac5293a 100644 --- a/plugins/optimization-detective/readme.txt +++ b/plugins/optimization-detective/readme.txt @@ -1,8 +1,8 @@ === Optimization Detective === Contributors: wordpressdotorg -Tested up to: 6.6 -Stable tag: 0.6.0 +Tested up to: 6.7 +Stable tag: 0.9.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, optimization, rum @@ -11,38 +11,105 @@ Provides an API for leveraging real user metrics to detect optimizations to appl == Description == -This plugin captures real user metrics about what elements are displayed on the page across a variety of device form factors (e.g. desktop, tablet, and phone) in order to apply loading optimizations which are not possible with WordPress’s current server-side heuristics. This plugin is a dependency which does not provide end-user functionality on its own. For that, please install the dependent plugin [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) (among [others](https://github.com/WordPress/performance/labels/%5BPlugin%5D%20Optimization%20Detective) to come from the WordPress Core Performance team). +This plugin captures real user metrics about what elements are displayed on the page across a variety of device form factors (e.g. desktop, tablet, and phone) in order to apply loading optimizations which are not possible with WordPress’s current server-side heuristics. + +This plugin is a dependency which does not provide end-user functionality on its own. For that, please install the dependent plugin [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) or [Embed Optimizer](https://wordpress.org/plugins/embed-optimizer/) (among [others](https://github.com/WordPress/performance/labels/%5BPlugin%5D%20Optimization%20Detective) to come from the WordPress Core Performance team). = Background = -WordPress uses [server-side heuristics](https://make.wordpress.org/core/2023/07/13/image-performance-enhancements-in-wordpress-6-3/) to make educated guesses about which images are likely to be in the initial viewport. Likewise, it uses server-side heuristics to identify a hero image which is likely to be the Largest Contentful Paint (LCP) element. To optimize page loading, it avoids lazy-loading any of these images while also adding `fetchpriority=high` to the hero image. When these heuristics are applied successfully, the LCP metric for page loading can be improved 5-10%. Unfortunately, however, there are limitations to the heuristics that make the correct identification of which image is the LCP element only about 50% effective. See [Analyzing the Core Web Vitals performance impact of WordPress 6.3 in the field](https://make.wordpress.org/core/2023/09/19/analyzing-the-core-web-vitals-performance-impact-of-wordpress-6-3-in-the-field/). For example, it is [common](https://github.com/GoogleChromeLabs/wpp-research/pull/73) for the LCP element to vary between different viewport widths, such as desktop versus mobile. Since WordPress's heuristics are completely server-side it has no knowledge of how the page is actually laid out, and it cannot prioritize loading of images according to the client's viewport width. +WordPress uses [server-side heuristics](https://make.wordpress.org/core/2023/07/13/image-performance-enhancements-in-wordpress-6-3/) to make educated guesses about which images are likely to be in the initial viewport. Likewise, it uses server-side heuristics to identify a hero image which is likely to be the Largest Contentful Paint (LCP) element. To optimize page loading, it avoids lazy loading any of these images while also adding `fetchpriority=high` to the hero image. When these heuristics are applied successfully, the LCP metric for page loading can be improved 5-10%. Unfortunately, however, there are limitations to the heuristics that make the correct identification of which image is the LCP element only about 50% effective. See [Analyzing the Core Web Vitals performance impact of WordPress 6.3 in the field](https://make.wordpress.org/core/2023/09/19/analyzing-the-core-web-vitals-performance-impact-of-wordpress-6-3-in-the-field/). For example, it is [common](https://github.com/GoogleChromeLabs/wpp-research/pull/73) for the LCP element to vary between different viewport widths, such as desktop versus mobile. Since WordPress's heuristics are completely server-side it has no knowledge of how the page is actually laid out, and it cannot prioritize loading of images according to the client's viewport width. In order to increase the accuracy of identifying the LCP element, including across various client viewport widths, this plugin gathers metrics from real users (RUM) to detect the actual LCP element and then use this information to optimize the page for future visitors so that the loading of the LCP element is properly prioritized. This is the purpose of Optimization Detective. The approach is heavily inspired by Philip Walton’s [Dynamic LCP Priority: Learning from Past Visits](https://philipwalton.com/articles/dynamic-lcp-priority/). See also the initial exploration document that laid out this project: [Image Loading Optimization via Client-side Detection](https://docs.google.com/document/u/1/d/16qAJ7I_ljhEdx2Cn2VlK7IkiixobY9zNn8FXxN9T9Ls/view). = Technical Foundation = -At the core of Optimization Detective is the “URL Metric”, information about a page according to how it was loaded by a client with a specific viewport width. This includes which elements were visible in the initial viewport and which one was the LCP element. Each URL on a site can have an associated set of these URL Metrics (stored in a custom post type) which are gathered from real users. It gathers a sample of URL Metrics according to common responsive breakpoints (e.g. mobile, tablet, and desktop). When no more URL Metrics are needed for a URL due to the sample size being obtained for the breakpoints, it discontinues serving the JavaScript to gather the metrics (leveraging the [web-vitals.js](https://github.com/GoogleChrome/web-vitals) library). With the URL Metrics in hand, the output-buffered page is sent through the HTML Tag Processor and--when the [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) dependent plugin is installed--the images which were the LCP element for various breakpoints will get prioritized with high-priority preload links (along with `fetchpriority=high` on the actual `img` tag when it is the common LCP element across all breakpoints). LCP elements with background images added via inline `background-image` styles are also prioritized with preload links. +At the core of Optimization Detective is the “URL Metric”, information about a page according to how it was loaded by a client with a specific viewport width. This includes which elements were visible in the initial viewport and which one was the LCP element. The URL Metric data is also extensible. Each URL on a site can have an associated set of these URL Metrics (stored in a custom post type) which are gathered from the visits of real users. It gathers samples of URL Metrics which are grouped according to WordPress's default responsive breakpoints: + +1. Mobile: 0-480px +2. Phablet: 481-600px +3. Tablet: 601-782px +4. Desktop: \>782px + +When no more URL Metrics are needed for a URL due to the sample size being obtained for the viewport group, it discontinues serving the JavaScript to gather the metrics (leveraging the [web-vitals.js](https://github.com/GoogleChrome/web-vitals) library). With the URL Metrics in hand, the output-buffered page is sent through the HTML Tag Processor and--when the [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) dependent plugin is installed--the images which were the LCP element for various breakpoints will get prioritized with high-priority preload links (along with `fetchpriority=high` on the actual `img` tag when it is the common LCP element across all breakpoints). LCP elements with background images added via inline `background-image` styles are also prioritized with preload links. URL Metrics have a “freshness TTL” after which they will be stale and the JavaScript will be served again to start gathering metrics again to ensure that the right elements continue to get their loading prioritized. When a URL Metrics custom post type hasn't been touched in a while, it is automatically garbage-collected. -👉 **Note:** This plugin optimizes pages for actual visitors, and it depends on visitors to optimize pages (since URL metrics need to be collected). As such, you won't see optimizations applied immediately after activating the plugin (and dependent plugin(s)). And since administrator users are not normal visitors typically, optimizations are not applied for admins by default (but this can be overridden with the `od_can_optimize_response` filter below). URL metrics are not collected for administrators because it is likely that additional elements will be present on the page which are not also shown to non-administrators, meaning the URL metrics could not reliably be reused between them. +👉 **Note:** This plugin optimizes pages for actual visitors, and it depends on visitors to optimize pages (since URL Metrics need to be collected). As such, you won't see optimizations applied immediately after activating the plugin (and dependent plugin(s)). And since administrator users are not normal visitors typically, optimizations are not applied for admins by default (but this can be overridden with the `od_can_optimize_response` filter below). URL Metrics are not collected for administrators because it is likely that additional elements will be present on the page which are not also shown to non-administrators, meaning the URL Metrics could not reliably be reused between them. There are currently **no settings** and no user interface for this plugin since it is designed to work without any configuration. When the `WP_DEBUG` constant is enabled, additional logging for Optimization Detective is added to the browser console. += Use Cases and Examples = + +As mentioned above, this plugin is a dependency that doesn't provide features on its own. Dependent plugins leverage the collected URL Metrics to apply optimizations. What follows us a running list of the optimizations which are enabled by Optimization Detective, along with a links to the related code used for the implementation: + +**[Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) ([GitHub](https://github.com/WordPress/performance/tree/trunk/plugins/image-prioritizer)):** + +1. Add breakpoint-specific `fetchpriority=high` preload links (`LINK[rel=preload]`) for image URLs of LCP elements: + 1. An `IMG` element, including the `srcset`/`sizes` attributes supplied as `imagesrcset`/`imagesizes` on the `LINK`. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L167-L177), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L304-L349)) + 2. The first `SOURCE` element with a `type` attribute in a `PICTURE` element. (Art-directed `PICTURE` elements using media queries are not supported.) ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L192-L275), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L304-L349)) + 3. An element with a CSS `background-image` inline `style` attribute. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L62-L92), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L182-L203)) + 4. An element with a CSS `background-image` applied with a stylesheet (when the image is from an allowed origin). ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/hooks.php#L14-L16), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L82-L83), [3](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L135-L203), [4](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/helper.php#L83-L320), [5](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/detect.js)) + 5. A `VIDEO` element's `poster` image. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php#L127-L161)) +2. Ensure `fetchpriority=high` is only added to an `IMG` when it is the LCP element across all responsive breakpoints. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L65-L91), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L137-L146)) +3. Add `fetchpriority=low` to `IMG` tags which appear in the initial viewport but are not visible, such as when they are subsequent carousel slides. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L105-L123), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L137-L146)) +4. Lazy loading: + 1. Apply lazy loading to `IMG` tags based on whether they appear in any breakpoint’s initial viewport. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L124-L133)) + 2. Implement lazy loading of CSS background images added via inline `style` attributes. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-background-image-styled-tag-visitor.php#L205-L238), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/helper.php#L365-L380), [3](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/lazy-load-bg-image.js)) + 3. Lazy-load `VIDEO` tags by setting the appropriate attributes based on whether they appear in the initial viewport. If a `VIDEO` is the LCP element, it gets `preload=auto`; if it is in an initial viewport, the `preload=metadata` default is left; if it is not in an initial viewport, it gets `preload=none`. Lazy-loaded videos also get initial `preload`, `autoplay`, and `poster` attributes restored when the `VIDEO` is going to enter the viewport. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php#L163-L246), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/helper.php#L365-L380), [3](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/lazy-load-video.js)) +5. Ensure that [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is added to all lazy-loaded `IMG` elements. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php#L148-L163)) +6. Reduce the size of the `poster` image of a `VIDEO` from full size to the size appropriate for the maximum width of the video (on desktop). ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php#L84-L125)) + +**[Embed Optimizer](https://wordpress.org/plugins/embed-optimizer/) ([GitHub](https://github.com/WordPress/performance/tree/trunk/plugins/embed-optimizer)):** + +1. Lazy loading embeds just before they come into view. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php#L191-L194), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/embed-optimizer/hooks.php#L168-L336)) +2. Adding preconnect links for embeds in the initial viewport. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php#L114-L190)) +3. Reserving space for embeds that resize to reduce layout shifting. ([1](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/embed-optimizer/hooks.php#L64-L65), [2](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/embed-optimizer/hooks.php#L81-L144), [3](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/embed-optimizer/detect.js), [4](https://github.com/WordPress/performance/blob/f5f50f9179c26deadeef966734367d199ba6de6f/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php#L218-L285)) + = Hooks = -**Filter:** `od_breakpoint_max_widths` (default: [480, 600, 782]) +**Action:** `od_init` (argument: plugin version) + +Fires when the Optimization Detective is initializing. This action is useful for loading extension code that depends on Optimization Detective to be running. The version of the plugin is passed as the sole argument so that if the required version is not present, the callback can short circuit. -Filters the breakpoint max widths to group URL metrics for various viewports. Each number represents the maximum width (inclusive) for a given breakpoint. So if there is one number, 480, then this means there will be two viewport groupings, one for 0<=480, and another >480. If instead there were three provided breakpoints (320, 480, 576) then this means there will be four groups: +**Action:** `od_register_tag_visitors` (argument: `OD_Tag_Visitor_Registry`) - 1. 0-320 (small smartphone) - 2. 321-480 (normal smartphone) - 3. 481-576 (phablets) - 4. >576 (desktop) +Fires to register tag visitors before walking over the document to perform optimizations. -The default breakpoints are reused from Gutenberg which appear to be used the most in media queries that affect frontend styles. +For example, to register a new tag visitor that targets `H1` elements: + +` +register( + 'my-plugin/h1', + static function ( OD_Tag_Visitor_Context $context ): bool { + if ( $context->processor->get_tag() !== 'H1' ) { + return false; + } + // Now optimize based on stored URL Metrics in $context->url_metric_group_collection. + // ... + + // Returning true causes the tag to be tracked in URL Metrics. If there is no need + // for this, as in there is no reference to $context->url_metric_group_collection + // in a tag visitor, then this can instead return false. + return true; + } + ); + } +); +` + +Refer to [Image Prioritizer](https://github.com/WordPress/performance/tree/trunk/plugins/image-prioritizer) and [Embed Optimizer](https://github.com/WordPress/performance/tree/trunk/plugins/embed-optimizer) for real world examples of how tag visitors are used. Registered tag visitors need only be callables, so in addition to providing a closure you may provide a `callable-string` or even a class which has an `__invoke()` method. + +**Filter:** `od_breakpoint_max_widths` (default: `array(480, 600, 782)`) + +Filters the breakpoint max widths to group URL Metrics for various viewports. Each number represents the maximum width (inclusive) for a given breakpoint. So if there is one number, 480, then this means there will be two viewport groupings, one for 0\<=480, and another \>480. If instead there are the two breakpoints defined, 480 and 782, then this means there will be three viewport groups of URL Metrics, one for 0\<=480 (i.e. mobile), another 481\<=782 (i.e. phablet/tablet), and another \>782 (i.e. desktop). + +These default breakpoints are reused from Gutenberg which appear to be used the most in media queries that affect frontend styles. **Filter:** `od_can_optimize_response` (default: boolean condition, see below) @@ -52,9 +119,10 @@ Filters whether the current response can be optimized. By default, detection and 2. It’s not a post embed template (`is_embed()`). 3. It’s not the Customizer preview (`is_customize_preview()`) 4. It’s not the response to a `POST` request. -5. The user is not an administrator (`current_user_can( 'customize' )`). +5. The user is not an administrator (`current_user_can( 'customize' )`), unless you're in plugin development mode (`wp_is_development_mode( 'plugin' )`). +6. There is at least one queried post on the page. This is used to facilitate the purging of page caches after a new URL Metric is stored. -During development, you may want to force this to always be enabled: +To force every response to be optimized regardless of the conditions above, you can do: ` 'object', + 'properties' => array_fill_keys( + array( + 'width', + 'height', + 'x', + 'y', + 'top', + 'right', + 'bottom', + 'left', + ), + array( + 'type' => 'number', + 'required' => true, + ) + ), + ); + return $additional_properties; + } +); +` + +See also [example usage](https://github.com/WordPress/performance/blob/6bb8405c5c446e3b66c2bfa3ae03ba61b188bca2/plugins/embed-optimizer/hooks.php#L81-L110) in Embed Optimizer. + +**Filter:** `od_url_metric_schema_root_additional_properties` (default: empty array) + +Filters additional schema properties which should be allowed at the root of a URL Metric. + +The usage here is the same as the previous filter, except it allows new properties to be added to the root of the URL Metric and not just to one of the object items in the `elements` property. + +**Filter:** `od_extension_module_urls` (default: empty array of strings) + +Filters the list of extension script module URLs to import when performing detection. + +For example: + +` +> $request * * @param WP_REST_Request $request REST API request. - * @param int $post_id ID for the URL metric post. - * @param OD_URL_Metric_Group_Collection $url_metric_group_collection URL metric group collection. - * @param OD_URL_Metric_Group $url_metric_group URL metric group. - * @param OD_URL_Metric $url_metric URL metric. + * @param int $post_id ID for the URL Metric post. + * @param OD_URL_Metric_Group_Collection $url_metric_group_collection URL Metric group collection. + * @param OD_URL_Metric_Group $url_metric_group URL Metric group. + * @param OD_URL_Metric $url_metric URL Metric. */ public function __construct( WP_REST_Request $request, int $post_id, OD_URL_Metric_Group_Collection $url_metric_group_collection, OD_URL_Metric_Group $url_metric_group, OD_URL_Metric $url_metric ) { $this->request = $request; diff --git a/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php b/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php index c59ef9a204..8bf337691f 100644 --- a/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php +++ b/plugins/optimization-detective/storage/class-od-url-metrics-post-type.php @@ -51,7 +51,7 @@ public static function add_hooks(): void { } /** - * Registers post type for URL metrics storage. + * Registers post type for URL Metrics storage. * * This the configuration for this post type is similar to the oembed_cache in core. * @@ -78,11 +78,11 @@ public static function register_post_type(): void { } /** - * Gets URL metrics post. + * Gets URL Metrics post. * * @since 0.1.0 * - * @param string $slug URL metrics slug. + * @param string $slug URL Metrics slug. * @return WP_Post|null Post object if exists. */ public static function get_post( string $slug ): ?WP_Post { @@ -109,16 +109,26 @@ public static function get_post( string $slug ): ?WP_Post { } /** - * Parses post content in URL metrics post. + * Parses post content in URL Metrics post. * * @since 0.1.0 * - * @param WP_Post $post URL metrics post. - * @return OD_URL_Metric[] URL metrics. + * @param WP_Post $post URL Metrics post. + * @return OD_URL_Metric[] URL Metrics. + * @noinspection PhpDocMissingThrowsInspection */ public static function get_url_metrics_from_post( WP_Post $post ): array { $this_function = __METHOD__; $trigger_error = static function ( string $message, int $error_level = E_USER_NOTICE ) use ( $this_function ): void { + // Default to E_USER_NOTICE. + if ( ! in_array( $error_level, array( E_USER_NOTICE, E_USER_WARNING, E_USER_ERROR, E_USER_DEPRECATED ), true ) ) { + $error_level = E_USER_NOTICE; + } + /** + * No WP_Exception is thrown by wp_trigger_error() since E_USER_ERROR is not passed as the error level. + * + * @noinspection PhpUnhandledExceptionInspection + */ wp_trigger_error( $this_function, esc_html( $message ), $error_level ); }; @@ -171,7 +181,7 @@ static function ( $url_metric_data ) use ( $trigger_error ) { $e->getMessage() . $suffix ), // This is not a warning because schema changes will happen, and so it is expected - // that this will result in existing URL metrics being invalidated. + // that this will result in existing URL Metrics being invalidated. E_USER_NOTICE ); @@ -185,13 +195,13 @@ static function ( $url_metric_data ) use ( $trigger_error ) { } /** - * Stores URL metric by merging it with the other URL metrics which share the same normalized query vars. + * Stores URL Metric by merging it with the other URL Metrics which share the same normalized query vars. * * @since 0.1.0 * @todo There is duplicate logic here with od_handle_rest_request(). * * @param string $slug Slug (hash of normalized query vars). - * @param OD_URL_Metric $new_url_metric New URL metric. + * @param OD_URL_Metric $new_url_metric New URL Metric. * @return int|WP_Error Post ID or WP_Error otherwise. */ public static function store_url_metric( string $slug, OD_URL_Metric $new_url_metric ) { @@ -213,8 +223,18 @@ public static function store_url_metric( string $slug, OD_URL_Metric $new_url_me $url_metrics = array(); } + $etag = $new_url_metric->get_etag(); + if ( null === $etag ) { + // This case actually will never occur in practice because the store_url_metric function is only called + // in the REST API endpoint where the ETag parameter is required. It is here exclusively for the sake of + // PHPStan's static analysis. This entire condition can be removed in a future release when the 'etag' + // property becomes required. + return new WP_Error( 'missing_etag' ); + } + $group_collection = new OD_URL_Metric_Group_Collection( $url_metrics, + $etag, od_get_breakpoint_max_widths(), od_get_url_metrics_breakpoint_sample_size(), od_get_url_metric_freshness_ttl() diff --git a/plugins/optimization-detective/storage/data.php b/plugins/optimization-detective/storage/data.php index 5c7920b1b7..367637f00d 100644 --- a/plugins/optimization-detective/storage/data.php +++ b/plugins/optimization-detective/storage/data.php @@ -11,9 +11,9 @@ } /** - * Gets the freshness age (TTL) for a given URL metric. + * Gets the freshness age (TTL) for a given URL Metric. * - * When a URL metric expires it is eligible to be replaced by a newer one if its viewport lies within the same breakpoint. + * When a URL Metric expires it is eligible to be replaced by a newer one if its viewport lies within the same breakpoint. * * @since 0.1.0 * @access private @@ -22,9 +22,9 @@ */ function od_get_url_metric_freshness_ttl(): int { /** - * Filters the freshness age (TTL) for a given URL metric. + * Filters the freshness age (TTL) for a given URL Metric. * - * The freshness TTL must be at least zero, in which it considers URL metrics to always be stale. + * The freshness TTL must be at least zero, in which it considers URL Metrics to always be stale. * In practice, the value should be at least an hour. * * @since 0.1.0 @@ -54,7 +54,7 @@ function od_get_url_metric_freshness_ttl(): int { /** * Gets the normalized query vars for the current request. * - * This is used as a cache key for stored URL metrics. + * This is used as a cache key for stored URL Metrics. * * TODO: For non-singular requests, consider adding the post IDs from The Loop to ensure publishing a new post will invalidate the cache. * @@ -77,7 +77,7 @@ function od_get_normalized_query_vars(): array { ); } - // Vary URL metrics by whether the user is logged in since additional elements may be present. + // Vary URL Metrics by whether the user is logged in since additional elements may be present. if ( is_user_logged_in() ) { $normalized_query_vars['user_logged_in'] = true; } @@ -124,7 +124,7 @@ function od_get_current_url(): string { } /** - * Gets slug for URL metrics. + * Gets slug for URL Metrics. * * A slug is the hash of the normalized query vars. * @@ -141,55 +141,167 @@ function od_get_url_metrics_slug( array $query_vars ): string { } /** - * Computes nonce for storing URL metrics for a specific slug. + * Gets the current template for a block theme or a classic theme. * - * This is used in the REST API to authenticate the storage of new URL metrics from a given URL. + * @since 0.9.0 + * @access private * - * @since 0.1.0 + * @global string|null $_wp_current_template_id Current template ID. + * @global string|null $template Template file path. + * + * @return string|WP_Block_Template|null Template. + */ +function od_get_current_theme_template() { + global $template, $_wp_current_template_id; + + if ( wp_is_block_theme() && isset( $_wp_current_template_id ) ) { + $block_template = get_block_template( $_wp_current_template_id, 'wp_template' ); + if ( $block_template instanceof WP_Block_Template ) { + return $block_template; + } + } + if ( isset( $template ) && is_string( $template ) ) { + return basename( $template ); + } + return null; +} + +/** + * Gets the current ETag for URL Metrics. + * + * Generates a hash based on the IDs of registered tag visitors, the queried object, + * posts in The Loop, and theme information in the current environment. This ETag + * is used to assess if the URL Metrics are stale when its value changes. + * + * @since 0.9.0 * @access private * - * @see wp_create_nonce() - * @see od_verify_url_metrics_storage_nonce() + * @param OD_Tag_Visitor_Registry $tag_visitor_registry Tag visitor registry. + * @param WP_Query|null $wp_query The WP_Query instance. + * @param string|WP_Block_Template|null $current_template The current template being used. + * @return non-empty-string Current ETag. + */ +function od_get_current_url_metrics_etag( OD_Tag_Visitor_Registry $tag_visitor_registry, ?WP_Query $wp_query, $current_template ): string { + $queried_object = $wp_query instanceof WP_Query ? $wp_query->get_queried_object() : null; + $queried_object_data = array( + 'id' => null, + 'type' => null, + ); + + if ( $queried_object instanceof WP_Post ) { + $queried_object_data['id'] = $queried_object->ID; + $queried_object_data['type'] = 'post'; + $queried_object_data['post_modified_gmt'] = $queried_object->post_modified_gmt; + } elseif ( $queried_object instanceof WP_Term ) { + $queried_object_data['id'] = $queried_object->term_id; + $queried_object_data['type'] = 'term'; + } elseif ( $queried_object instanceof WP_User ) { + $queried_object_data['id'] = $queried_object->ID; + $queried_object_data['type'] = 'user'; + } elseif ( $queried_object instanceof WP_Post_Type ) { + $queried_object_data['type'] = $queried_object->name; + } + + $data = array( + 'tag_visitors' => array_keys( iterator_to_array( $tag_visitor_registry ) ), + 'queried_object' => $queried_object_data, + 'queried_posts' => array_filter( + array_map( + static function ( $post ): ?array { + if ( is_int( $post ) ) { + $post = get_post( $post ); + } + if ( ! ( $post instanceof WP_Post ) ) { + return null; + } + return array( + 'id' => $post->ID, + 'post_modified_gmt' => $post->post_modified_gmt, + ); + }, + ( $wp_query instanceof WP_Query && $wp_query->post_count > 0 ) ? $wp_query->posts : array() + ) + ), + 'active_theme' => array( + 'template' => array( + 'name' => get_template(), + 'version' => wp_get_theme( get_template() )->get( 'Version' ), + ), + 'stylesheet' => array( + 'name' => get_stylesheet(), + 'version' => wp_get_theme()->get( 'Version' ), + ), + ), + 'current_template' => $current_template instanceof WP_Block_Template ? get_object_vars( $current_template ) : $current_template, + ); + + /** + * Filters the data that goes into computing the current ETag for URL Metrics. + * + * @since 0.9.0 + * + * @param array $data Data. + */ + $data = (array) apply_filters( 'od_current_url_metrics_etag_data', $data ); + + return md5( (string) wp_json_encode( $data ) ); +} + +/** + * Computes HMAC for storing URL Metrics for a specific slug. + * + * This is used in the REST API to authenticate the storage of new URL Metrics from a given URL. + * + * @since 0.8.0 + * @since 0.9.0 Introduced the `$current_etag` parameter. + * @access private + * + * @see od_verify_url_metrics_storage_hmac() * @see od_get_url_metrics_slug() * - * @param string $slug Slug (hash of normalized query vars). - * @param string $url URL. - * @return string Nonce. + * @param string $slug Slug (hash of normalized query vars). + * @param non-empty-string $current_etag Current ETag. + * @param string $url URL. + * @param int|null $cache_purge_post_id Cache purge post ID. + * @return string HMAC. */ -function od_get_url_metrics_storage_nonce( string $slug, string $url ): string { - return wp_create_nonce( "store_url_metrics:$slug:$url" ); +function od_get_url_metrics_storage_hmac( string $slug, string $current_etag, string $url, ?int $cache_purge_post_id = null ): string { + $action = "store_url_metric:$slug:$current_etag:$url:$cache_purge_post_id"; + return wp_hash( $action, 'nonce' ); } /** - * Verifies nonce for storing URL metrics for a specific slug. + * Verifies HMAC for storing URL Metrics for a specific slug. * - * @since 0.1.0 + * @since 0.8.0 + * @since 0.9.0 Introduced the `$current_etag` parameter. * @access private * - * @see wp_verify_nonce() - * @see od_get_url_metrics_storage_nonce() + * @see od_get_url_metrics_storage_hmac() * @see od_get_url_metrics_slug() * - * @param string $nonce Nonce. - * @param string $slug Slug (hash of normalized query vars). - * @param String $url URL. - * @return bool Whether the nonce is valid. + * @param string $hmac HMAC. + * @param string $slug Slug (hash of normalized query vars). + * @param non-empty-string $current_etag Current ETag. + * @param string $url URL. + * @param int|null $cache_purge_post_id Cache purge post ID. + * @return bool Whether the HMAC is valid. */ -function od_verify_url_metrics_storage_nonce( string $nonce, string $slug, string $url ): bool { - return (bool) wp_verify_nonce( $nonce, "store_url_metrics:$slug:$url" ); +function od_verify_url_metrics_storage_hmac( string $hmac, string $slug, string $current_etag, string $url, ?int $cache_purge_post_id = null ): bool { + return hash_equals( od_get_url_metrics_storage_hmac( $slug, $current_etag, $url, $cache_purge_post_id ), $hmac ); } /** - * Gets the minimum allowed viewport aspect ratio for URL metrics. + * Gets the minimum allowed viewport aspect ratio for URL Metrics. * * @since 0.6.0 * @access private * - * @return float Minimum viewport aspect ratio for URL metrics. + * @return float Minimum viewport aspect ratio for URL Metrics. */ function od_get_minimum_viewport_aspect_ratio(): float { /** - * Filters the minimum allowed viewport aspect ratio for URL metrics. + * Filters the minimum allowed viewport aspect ratio for URL Metrics. * * The 0.4 default value is intended to accommodate the phone with the greatest known aspect * ratio at 21:9 when rotated 90 degrees to 9:21 (0.429). @@ -202,16 +314,16 @@ function od_get_minimum_viewport_aspect_ratio(): float { } /** - * Gets the maximum allowed viewport aspect ratio for URL metrics. + * Gets the maximum allowed viewport aspect ratio for URL Metrics. * * @since 0.6.0 * @access private * - * @return float Maximum viewport aspect ratio for URL metrics. + * @return float Maximum viewport aspect ratio for URL Metrics. */ function od_get_maximum_viewport_aspect_ratio(): float { /** - * Filters the maximum allowed viewport aspect ratio for URL metrics. + * Filters the maximum allowed viewport aspect ratio for URL Metrics. * * The 2.5 default value is intended to accommodate the phone with the greatest known aspect * ratio at 21:9 (2.333). @@ -224,7 +336,7 @@ function od_get_maximum_viewport_aspect_ratio(): float { } /** - * Gets the breakpoint max widths to group URL metrics for various viewports. + * Gets the breakpoint max widths to group URL Metrics for various viewports. * * Each number represents the maximum width (inclusive) for a given breakpoint. So if there is one number, 480, then * this means there will be two viewport groupings, one for 0<=480, and another >480. If instead there were three @@ -243,6 +355,9 @@ function od_get_maximum_viewport_aspect_ratio(): float { * * These breakpoints appear to be used the most in media queries that affect frontend styles. * + * This array may be empty in which case there are no responsive breakpoints and all URL Metrics are collected in a + * single group. + * * @since 0.1.0 * @access private * @link https://github.com/WordPress/gutenberg/blob/093d52cbfd3e2c140843d3fb91ad3d03330320a5/packages/base-styles/_breakpoints.scss#L11-L13 @@ -285,9 +400,10 @@ static function ( $original_breakpoint ) use ( $function_name ): int { return $breakpoint; }, /** - * Filters the breakpoint max widths to group URL metrics for various viewports. + * Filters the breakpoint max widths to group URL Metrics for various viewports. * - * A breakpoint must be greater than zero and less than PHP_INT_MAX. + * A breakpoint must be greater than zero and less than PHP_INT_MAX. This array may be empty in which case there + * are no responsive breakpoints and all URL Metrics are collected in a single group. * * @since 0.1.0 * @@ -302,11 +418,11 @@ static function ( $original_breakpoint ) use ( $function_name ): int { } /** - * Gets the sample size for a breakpoint's URL metrics on a given URL. + * Gets the sample size for a breakpoint's URL Metrics on a given URL. * - * A breakpoint divides URL metrics for viewports which are smaller and those which are larger. Given the default + * A breakpoint divides URL Metrics for viewports which are smaller and those which are larger. Given the default * sample size of 3 and there being just a single breakpoint (480) by default, for a given URL, there would be a maximum - * total of 6 URL metrics stored for a given URL: 3 for mobile and 3 for desktop. + * total of 6 URL Metrics stored for a given URL: 3 for mobile and 3 for desktop. * * @since 0.1.0 * @access private @@ -315,7 +431,7 @@ static function ( $original_breakpoint ) use ( $function_name ): int { */ function od_get_url_metrics_breakpoint_sample_size(): int { /** - * Filters the sample size for a breakpoint's URL metrics on a given URL. + * Filters the sample size for a breakpoint's URL Metrics on a given URL. * * The sample size must be greater than zero. * diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index e96211b3cb..09ce02501e 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -18,7 +18,7 @@ const OD_REST_API_NAMESPACE = 'optimization-detective/v1'; /** - * Route for storing a URL metric. + * Route for storing a URL Metric. * * Note the `:store` art of the endpoint follows Google's guidance in AIP-136 for the use of the POST method in a way * that does not strictly follow the standard usage. Namely, submitting a POST request to this endpoint will either @@ -30,30 +30,46 @@ const OD_URL_METRICS_ROUTE = '/url-metrics:store'; /** - * Registers endpoint for storage of URL metric. + * Registers endpoint for storage of URL Metric. * * @since 0.1.0 * @access private */ function od_register_endpoint(): void { + // The slug and cache_purge_post_id args are further validated via the validate_callback for the 'hmac' parameter, + // they are provided as input with the 'url' argument to create the HMAC by the server. $args = array( - 'slug' => array( + 'slug' => array( 'type' => 'string', 'description' => __( 'An MD5 hash of the query args.', 'optimization-detective' ), 'required' => true, - 'pattern' => '^[0-9a-f]{32}$', - // This is further validated via the validate_callback for the nonce argument, as it is provided as input - // with the 'url' argument to create the nonce by the server. which then is verified to match in the REST API request. + 'pattern' => '^[0-9a-f]{32}\z', + 'minLength' => 32, + 'maxLength' => 32, ), - 'nonce' => array( + 'current_etag' => array( + 'type' => 'string', + 'description' => __( 'ETag for the current environment.', 'optimization-detective' ), + 'required' => true, + 'pattern' => '^[0-9a-f]{32}\z', + 'minLength' => 32, + 'maxLength' => 32, + ), + 'cache_purge_post_id' => array( + 'type' => 'integer', + 'description' => __( 'Cache purge post ID.', 'optimization-detective' ), + 'required' => false, + 'minimum' => 1, + ), + 'hmac' => array( 'type' => 'string', - 'description' => __( 'Nonce originally computed by server required to authorize the request.', 'optimization-detective' ), + 'description' => __( 'HMAC originally computed by server required to authorize the request.', 'optimization-detective' ), 'required' => true, - 'pattern' => '^[0-9a-f]+$', - 'validate_callback' => static function ( string $nonce, WP_REST_Request $request ) { - if ( ! od_verify_url_metrics_storage_nonce( $nonce, $request->get_param( 'slug' ), $request->get_param( 'url' ) ) ) { - return new WP_Error( 'invalid_nonce', __( 'URL metrics nonce verification failure.', 'optimization-detective' ) ); + 'pattern' => '^[0-9a-f]+\z', + 'validate_callback' => static function ( string $hmac, WP_REST_Request $request ) { + if ( ! od_verify_url_metrics_storage_hmac( $hmac, $request['slug'], $request['current_etag'], $request['url'], $request['cache_purge_post_id'] ?? null ) ) { + return new WP_Error( 'invalid_hmac', __( 'URL Metrics HMAC verification failure.', 'optimization-detective' ) ); } return true; }, @@ -77,8 +93,8 @@ function od_register_endpoint(): void { if ( OD_Storage_Lock::is_locked() ) { return new WP_Error( 'url_metric_storage_locked', - __( 'URL metric storage is presently locked for the current IP.', 'optimization-detective' ), - array( 'status' => 403 ) + __( 'URL Metric storage is presently locked for the current IP.', 'optimization-detective' ), + array( 'status' => 403 ) // TODO: Consider 423 Locked status code. ); } return true; @@ -88,6 +104,27 @@ function od_register_endpoint(): void { } add_action( 'rest_api_init', 'od_register_endpoint' ); +/** + * Determines if the HTTP origin is an authorized one. + * + * Note that `is_allowed_http_origin()` is not used directly because the underlying `get_allowed_http_origins()` does + * not account for the URL port (although there is a to-do comment committed in core to address this). Additionally, + * the `is_allowed_http_origin()` function in core for some reason returns a string rather than a boolean. + * + * @since 0.8.0 + * @access private + * + * @see is_allowed_http_origin() + * + * @param string $origin Origin to check. + * @return bool Whether the origin is allowed. + */ +function od_is_allowed_http_origin( string $origin ): bool { + // Strip out the port number since core does not account for it yet as noted in get_allowed_http_origins(). + $origin = preg_replace( '/:\d+$/', '', $origin ); + return '' !== is_allowed_http_origin( $origin ); +} + /** * Handles REST API request to store metrics. * @@ -100,27 +137,39 @@ function od_register_endpoint(): void { * @return WP_REST_Response|WP_Error Response. */ function od_handle_rest_request( WP_REST_Request $request ) { + // Block cross-origin storage requests since by definition URL Metrics data can only be sourced from the frontend of the site. + $origin = $request->get_header( 'origin' ); + if ( null === $origin || ! od_is_allowed_http_origin( $origin ) ) { + return new WP_Error( + 'rest_cross_origin_forbidden', + __( 'Cross-origin requests are not allowed for this endpoint.', 'optimization-detective' ), + array( 'status' => 403 ) + ); + } + $post = OD_URL_Metrics_Post_Type::get_post( $request->get_param( 'slug' ) ); $url_metric_group_collection = new OD_URL_Metric_Group_Collection( $post instanceof WP_Post ? OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ) : array(), + $request->get_param( 'current_etag' ), od_get_breakpoint_max_widths(), od_get_url_metrics_breakpoint_sample_size(), od_get_url_metric_freshness_ttl() ); - // Block the request if URL metrics aren't needed for the provided viewport width. + // Block the request if URL Metrics aren't needed for the provided viewport width. try { $url_metric_group = $url_metric_group_collection->get_group_for_viewport_width( $request->get_param( 'viewport' )['width'] ); } catch ( InvalidArgumentException $exception ) { + // Note: This should never happen because an exception only occurs if a viewport width is less than zero, and the JSON Schema enforces that the viewport.width have a minimum of zero. return new WP_Error( 'invalid_viewport_width', $exception->getMessage() ); } if ( $url_metric_group->is_complete() ) { return new WP_Error( 'url_metric_group_complete', - __( 'The URL metric group for the provided viewport is already complete.', 'optimization-detective' ), + __( 'The URL Metric group for the provided viewport is already complete.', 'optimization-detective' ), array( 'status' => 403 ) ); } @@ -145,6 +194,7 @@ function od_handle_rest_request( WP_REST_Request $request ) { // Now supply the readonly args which were omitted from the REST API params due to being `readonly`. 'timestamp' => microtime( true ), 'uuid' => wp_generate_uuid4(), + 'etag' => $request->get_param( 'current_etag' ), ) ) ); @@ -152,8 +202,8 @@ function od_handle_rest_request( WP_REST_Request $request ) { return new WP_Error( 'rest_invalid_param', sprintf( - /* translators: %s is exception name */ - __( 'Failed to validate URL metric: %s', 'optimization-detective' ), + /* translators: %s is exception message */ + __( 'Failed to validate URL Metric: %s', 'optimization-detective' ), $e->getMessage() ), array( 'status' => 400 ) @@ -165,16 +215,36 @@ function od_handle_rest_request( WP_REST_Request $request ) { $request->get_param( 'slug' ), $url_metric ); - if ( $result instanceof WP_Error ) { - return $result; + $error_data = array( + 'status' => 500, + ); + if ( WP_DEBUG ) { + $error_data['error_code'] = $result->get_error_code(); + $error_data['error_message'] = $result->get_error_message(); + } + return new WP_Error( + 'unable_to_store_url_metric', + __( 'Unable to store URL Metric.', 'optimization-detective' ), + $error_data + ); } $post_id = $result; + // Schedule an event in 10 minutes to trigger an invalidation of the page cache (hopefully). + $cache_purge_post_id = $request->get_param( 'cache_purge_post_id' ); + if ( is_int( $cache_purge_post_id ) && false === wp_next_scheduled( 'od_trigger_page_cache_invalidation', array( $cache_purge_post_id ) ) ) { + wp_schedule_single_event( + time() + 10 * MINUTE_IN_SECONDS, + 'od_trigger_page_cache_invalidation', + array( $cache_purge_post_id ) + ); + } + /** * Fires whenever a URL Metric was successfully stored. * - * @since n.e.x.t + * @since 0.7.0 * * @param OD_URL_Metric_Store_Request_Context $context Context about the successful URL Metric collection. */ @@ -195,3 +265,49 @@ function od_handle_rest_request( WP_REST_Request $request ) { ) ); } + +/** + * Triggers actions for page caches to invalidate their caches related to the supplied cache purge post ID. + * + * This is intended to flush any page cache for the URL after the new URL Metric was submitted so that the optimizations + * which depend on that URL Metric can start to take effect. + * + * @since 0.8.0 + * @access private + * + * @param int $cache_purge_post_id Cache purge post ID. + */ +function od_trigger_page_cache_invalidation( int $cache_purge_post_id ): void { + $post = get_post( $cache_purge_post_id ); + if ( ! ( $post instanceof WP_Post ) ) { + return; + } + + // Fire actions that page caching plugins listen to flush caches. + + /* + * The clean_post_cache action is used to flush page caches by: + * - Pantheon Advanced Cache + * - WP Super Cache + * - Batcache + */ + /** This action is documented in wp-includes/post.php. */ + do_action( 'clean_post_cache', $post->ID, $post ); + + /* + * The transition_post_status action is used to flush page caches by: + * - Jetpack Boost + * - WP Super Cache + * - LightSpeed Cache + */ + /** This action is documented in wp-includes/post.php. */ + do_action( 'transition_post_status', $post->post_status, $post->post_status, $post ); + + /* + * The clean_post_cache action is used to flush page caches by: + * - W3 Total Cache + * - WP Rocket + */ + /** This action is documented in wp-includes/post.php. */ + do_action( 'save_post', $post->ID, $post, /* $update */ true ); +} diff --git a/plugins/optimization-detective/tests/data/themes/block-theme/style.css b/plugins/optimization-detective/tests/data/themes/block-theme/style.css new file mode 100644 index 0000000000..72f24c1672 --- /dev/null +++ b/plugins/optimization-detective/tests/data/themes/block-theme/style.css @@ -0,0 +1,7 @@ +/* +Theme Name: Block Theme +Theme URI: https://wordpress.org/ +Description: For testing purposes only. +Version: 1.0.0 +Text Domain: block-theme +*/ diff --git a/plugins/optimization-detective/tests/data/themes/block-theme/templates/index.html b/plugins/optimization-detective/tests/data/themes/block-theme/templates/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/optimization-detective/tests/storage/test-data.php b/plugins/optimization-detective/tests/storage/test-data.php index 7009fccc59..120779590a 100644 --- a/plugins/optimization-detective/tests/storage/test-data.php +++ b/plugins/optimization-detective/tests/storage/test-data.php @@ -22,6 +22,8 @@ public function set_up(): void { public function tear_down(): void { $_SERVER['REQUEST_URI'] = $this->original_request_uri; unset( $GLOBALS['wp_customize'] ); + unset( $GLOBALS['template'] ); + unset( $GLOBALS['_wp_current_template_id'] ); parent::tear_down(); } @@ -282,54 +284,318 @@ public function test_od_get_url_metrics_slug(): void { $second = od_get_url_metrics_slug( array( 'p' => 1 ) ); $this->assertNotEquals( $second, $first ); foreach ( array( $first, $second ) as $slug ) { - $this->assertMatchesRegularExpression( '/^[0-9a-f]{32}$/', $slug ); + $this->assertMatchesRegularExpression( '/^[0-9a-f]{32}\z/', $slug ); } } /** - * Test od_get_url_metrics_storage_nonce(). + * Data provider. * - * @covers ::od_get_url_metrics_storage_nonce - * @covers ::od_verify_url_metrics_storage_nonce + * @return array */ - public function test_od_get_url_metrics_storage_nonce_and_od_verify_url_metrics_storage_nonce(): void { - $user_id = self::factory()->user->create(); + public function data_provider_test_od_get_current_url_metrics_etag(): array { + return array( + 'homepage_one_post' => array( + 'set_up' => function (): Closure { + $post = self::factory()->post->create_and_get(); + $this->assertInstanceOf( WP_Post::class, $post ); + $this->go_to( '/' ); + $GLOBALS['template'] = trailingslashit( get_template_directory() ) . 'home.php'; + + return function ( array $etag_data, Closure $get_etag ) use ( $post ): void { + $this->assertTrue( is_home() ); + $this->assertTrue( is_front_page() ); + $this->assertNull( $etag_data['queried_object']['id'] ); + $this->assertNull( $etag_data['queried_object']['type'] ); + $this->assertCount( 1, $etag_data['queried_posts'] ); + $this->assertSame( $post->ID, $etag_data['queried_posts'][0]['id'] ); + $this->assertSame( $post->post_modified_gmt, $etag_data['queried_posts'][0]['post_modified_gmt'] ); + $this->assertSame( 'home.php', $etag_data['current_template'] ); + + // Modify data using filters. + $etag = $get_etag(); + add_filter( + 'od_current_url_metrics_etag_data', + static function ( $data ) { + $data['custom'] = true; + return $data; + } + ); + $etag_after_filtering = $get_etag(); + $this->assertNotEquals( $etag, $etag_after_filtering ); + }; + }, + ), + + 'singular_post_then_modified' => array( + 'set_up' => function (): Closure { + $force_old_post_modified_data = static function ( $data ) { + $data['post_modified'] = '1970-01-01 00:00:00'; + $data['post_modified_gmt'] = '1970-01-01 00:00:00'; + return $data; + }; + add_filter( 'wp_insert_post_data', $force_old_post_modified_data ); + $post = self::factory()->post->create_and_get(); + $this->assertInstanceOf( WP_Post::class, $post ); + remove_filter( 'wp_insert_post_data', $force_old_post_modified_data ); + $this->go_to( get_permalink( $post ) ); + $GLOBALS['template'] = trailingslashit( get_template_directory() ) . 'single.php'; + + return function ( array $etag_data, Closure $get_etag ) use ( $post ): void { + $this->assertTrue( is_single( $post ) ); + $this->assertSame( $post->ID, $etag_data['queried_object']['id'] ); + $this->assertSame( 'post', $etag_data['queried_object']['type'] ); + $this->assertArrayHasKey( 'post_modified_gmt', $etag_data['queried_object'] ); + $this->assertSame( $post->post_modified_gmt, $etag_data['queried_object']['post_modified_gmt'] ); + $this->assertCount( 1, $etag_data['queried_posts'] ); + $this->assertSame( $post->ID, $etag_data['queried_posts'][0]['id'] ); + $this->assertSame( $post->post_modified_gmt, $etag_data['queried_posts'][0]['post_modified_gmt'] ); + $this->assertSame( 'single.php', $etag_data['current_template'] ); + + // Now try updating the post and re-navigating to it to verify that the modified date changes the ETag. + $previous_etag = $get_etag(); + $r = wp_update_post( + array( + 'ID' => $post->ID, + 'post_title' => 'Modified Title!', + ), + true + ); + $this->assertIsInt( $r ); + $this->go_to( get_permalink( $post ) ); + $next_etag = $get_etag(); + $this->assertNotSame( $previous_etag, $next_etag ); + }; + }, + ), + + 'category_archive' => array( + 'set_up' => function (): Closure { + $term = self::factory()->category->create_and_get(); + $this->assertInstanceOf( WP_Term::class, $term ); + $post_ids = self::factory()->post->create_many( 2 ); + foreach ( $post_ids as $post_id ) { + wp_set_post_terms( $post_id, array( $term->term_id ), 'category' ); + } + $this->go_to( get_category_link( $term ) ); + $GLOBALS['template'] = trailingslashit( get_template_directory() ) . 'category.php'; + + return function ( array $etag_data ) use ( $term, $post_ids ): void { + $this->assertTrue( is_category( $term ) ); + $this->assertSame( $term->term_id, $etag_data['queried_object']['id'] ); + $this->assertSame( 'term', $etag_data['queried_object']['type'] ); + $this->assertCount( 2, $etag_data['queried_posts'] ); + $this->assertEqualSets( $post_ids, wp_list_pluck( $etag_data['queried_posts'], 'id' ) ); + $this->assertSame( 'category.php', $etag_data['current_template'] ); + }; + }, + ), + + 'user_archive' => array( + 'set_up' => function (): Closure { + $user_id = self::factory()->user->create(); + $this->assertIsInt( $user_id ); + $post_ids = self::factory()->post->create_many( 3, array( 'post_author' => $user_id ) ); + + // This is a workaround because the author URL pretty permalink is failing for some reason only on GHA. + add_filter( + 'author_link', + static function ( $link, $author_id ) { + return add_query_arg( 'author', $author_id, home_url( '/' ) ); + }, + 10, + 2 + ); + $this->go_to( get_author_posts_url( $user_id ) ); + $GLOBALS['template'] = trailingslashit( get_template_directory() ) . 'author.php'; + + return function ( array $etag_data ) use ( $user_id, $post_ids ): void { + $this->assertTrue( is_author( $user_id ), 'Expected is_author() after having gone to ' . get_author_posts_url( $user_id ) ); + $this->assertSame( $user_id, $etag_data['queried_object']['id'] ); + $this->assertSame( 'user', $etag_data['queried_object']['type'] ); + $this->assertCount( 3, $etag_data['queried_posts'] ); + $this->assertEqualSets( $post_ids, wp_list_pluck( $etag_data['queried_posts'], 'id' ) ); + $this->assertSame( 'author.php', $etag_data['current_template'] ); + }; + }, + ), + + 'post_type_archive' => array( + 'set_up' => function (): Closure { + register_post_type( + 'book', + array( + 'public' => true, + 'has_archive' => true, + ) + ); + $post_ids = self::factory()->post->create_many( 4, array( 'post_type' => 'book' ) ); + $this->go_to( get_post_type_archive_link( 'book' ) ); + $GLOBALS['template'] = trailingslashit( get_template_directory() ) . 'archive-book.php'; + + return function ( array $etag_data ) use ( $post_ids ): void { + $this->assertTrue( is_post_type_archive( 'book' ) ); + $this->assertNull( $etag_data['queried_object']['id'] ); + $this->assertSame( 'book', $etag_data['queried_object']['type'] ); + $this->assertCount( 4, $etag_data['queried_posts'] ); + $this->assertEqualSets( $post_ids, wp_list_pluck( $etag_data['queried_posts'], 'id' ) ); + $this->assertSame( 'archive-book.php', $etag_data['current_template'] ); + }; + }, + ), + + 'page_for_posts' => array( + 'set_up' => function (): Closure { + $page_id = self::factory()->post->create( array( 'post_type' => 'page' ) ); + update_option( 'show_on_front', 'page' ); + update_option( 'page_for_posts', $page_id ); + + $post_ids = self::factory()->post->create_many( 5 ); + $this->go_to( get_page_link( $page_id ) ); + $GLOBALS['template'] = trailingslashit( get_template_directory() ) . 'home.php'; + + return function ( array $etag_data ) use ( $page_id, $post_ids ): void { + $this->assertTrue( is_home() ); + $this->assertFalse( is_front_page() ); + $this->assertSame( $page_id, $etag_data['queried_object']['id'] ); + $this->assertSame( 'post', $etag_data['queried_object']['type'] ); + $this->assertCount( 5, $etag_data['queried_posts'] ); + $this->assertEqualSets( $post_ids, wp_list_pluck( $etag_data['queried_posts'], 'id' ) ); + $this->assertSame( 'home.php', $etag_data['current_template'] ); + }; + }, + ), + + 'block_theme' => array( + 'set_up' => function (): Closure { + self::factory()->post->create(); + register_theme_directory( __DIR__ . '/../data/themes' ); + update_option( 'template', 'block-theme' ); + update_option( 'stylesheet', 'block-theme' ); + $this->go_to( '/' ); + $this->assertTrue( is_home() ); + $this->assertTrue( is_front_page() ); + $GLOBALS['_wp_current_template_id'] = 'block-theme//index'; + + return function ( array $etag_data ): void { + $this->assertTrue( wp_is_block_theme() ); + $this->assertIsArray( $etag_data['current_template'] ); + $this->assertEquals( 'wp_template', $etag_data['current_template']['type'] ); + $this->assertEquals( 'block-theme//index', $etag_data['current_template']['id'] ); + $this->assertArrayHasKey( 'modified', $etag_data['current_template'] ); + }; + }, + ), + ); + } - $nonce_life_actions = array(); + /** + * Test od_get_current_url_metrics_etag(). + * + * @dataProvider data_provider_test_od_get_current_url_metrics_etag + * + * @covers ::od_get_current_url_metrics_etag + * @covers ::od_get_current_theme_template + */ + public function test_od_get_current_url_metrics_etag( Closure $set_up ): void { + $captured_etag_data = null; add_filter( - 'nonce_life', - static function ( int $life, string $action ) use ( &$nonce_life_actions ): int { - $nonce_life_actions[] = $action; - return $life; + 'od_current_url_metrics_etag_data', + static function ( array $data ) use ( &$captured_etag_data ) { + $captured_etag_data = $data; + return $data; }, - 10, - 2 + PHP_INT_MAX + ); + + $registry = new OD_Tag_Visitor_Registry(); + $registry->register( 'foo', static function (): void {} ); + $registry->register( 'bar', static function (): void {} ); + $registry->register( 'baz', static function (): void {} ); + $get_etag = static function () use ( $registry ) { + global $wp_the_query; + return od_get_current_url_metrics_etag( $registry, $wp_the_query, od_get_current_theme_template() ); + }; + + $extra_assert = $set_up(); + + $initial_active_theme = array( + 'template' => array( + 'name' => get_template(), + 'version' => wp_get_theme( get_template() )->get( 'Version' ), + ), + 'stylesheet' => array( + 'name' => get_stylesheet(), + 'version' => wp_get_theme( get_stylesheet() )->get( 'Version' ), + ), ); - // Create first nonce for unauthenticated user. - $url = home_url( '/' ); - $slug = od_get_url_metrics_slug( array() ); - $nonce1 = od_get_url_metrics_storage_nonce( $slug, $url ); - $this->assertMatchesRegularExpression( '/^[0-9a-f]{10}$/', $nonce1 ); - $this->assertTrue( od_verify_url_metrics_storage_nonce( $nonce1, $slug, $url ) ); - $this->assertCount( 2, $nonce_life_actions ); - - // Create second nonce for unauthenticated user. - $nonce2 = od_get_url_metrics_storage_nonce( $slug, $url ); - $this->assertSame( $nonce1, $nonce2 ); - $this->assertCount( 3, $nonce_life_actions ); - - // Create third nonce, this time for authenticated user. - wp_set_current_user( $user_id ); - $nonce3 = od_get_url_metrics_storage_nonce( $slug, $url ); - $this->assertNotEquals( $nonce3, $nonce2 ); - $this->assertFalse( od_verify_url_metrics_storage_nonce( $nonce1, $slug, $url ) ); - $this->assertTrue( od_verify_url_metrics_storage_nonce( $nonce3, $slug, $url ) ); - $this->assertCount( 6, $nonce_life_actions ); - - foreach ( $nonce_life_actions as $nonce_life_action ) { - $this->assertSame( "store_url_metrics:{$slug}:{$url}", $nonce_life_action ); + $etag = $get_etag(); + $this->assertMatchesRegularExpression( '/^[a-z0-9]{32}\z/', $etag ); + $this->assertIsArray( $captured_etag_data ); + $expected_keys = array( 'tag_visitors', 'queried_object', 'queried_posts', 'active_theme', 'current_template' ); + foreach ( $expected_keys as $expected_key ) { + $this->assertArrayHasKey( $expected_key, $captured_etag_data ); } + $this->assertSame( $initial_active_theme, $captured_etag_data['active_theme'] ); + $this->assertContains( 'foo', $captured_etag_data['tag_visitors'] ); + $this->assertContains( 'bar', $captured_etag_data['tag_visitors'] ); + $this->assertContains( 'baz', $captured_etag_data['tag_visitors'] ); + $this->assertArrayHasKey( 'id', $captured_etag_data['queried_object'] ); + $this->assertArrayHasKey( 'type', $captured_etag_data['queried_object'] ); + $previous_captured_etag_data = $captured_etag_data; + $this->assertSame( $etag, $get_etag() ); + $this->assertSame( $captured_etag_data, $previous_captured_etag_data ); + + if ( $extra_assert instanceof Closure ) { + $extra_assert( $captured_etag_data, $get_etag ); + } + } + + /** + * Data provider. + * + * @return array Data. + */ + public function data_provider_to_test_hmac(): array { + return array( + 'is_home' => array( + 'set_up' => static function (): array { + $post_id = self::factory()->post->create(); + return array( + home_url(), + od_get_url_metrics_slug( array() ), + $post_id, + ); + }, + ), + 'is_single' => array( + 'set_up' => static function (): array { + $post_id = self::factory()->post->create(); + return array( + get_permalink( $post_id ), + od_get_url_metrics_slug( array( 'p' => $post_id ) ), + $post_id, + ); + }, + ), + ); + } + + /** + * Test od_get_url_metrics_storage_hmac() and od_verify_url_metrics_storage_hmac(). + * + * @dataProvider data_provider_to_test_hmac + * + * @covers ::od_get_url_metrics_storage_hmac + * @covers ::od_verify_url_metrics_storage_hmac + */ + public function test_od_get_url_metrics_storage_hmac_and_od_verify_url_metrics_storage_hmac( Closure $set_up ): void { + list( $url, $slug, $cache_purge_post_id ) = $set_up(); + $this->go_to( $url ); + $hmac = od_get_url_metrics_storage_hmac( $slug, $url, $cache_purge_post_id ); + $this->assertMatchesRegularExpression( '/^[0-9a-f]+\z/', $hmac ); + $this->assertTrue( od_verify_url_metrics_storage_hmac( $hmac, $slug, $url, $cache_purge_post_id ) ); } /** diff --git a/plugins/optimization-detective/tests/storage/test-rest-api.php b/plugins/optimization-detective/tests/storage/test-rest-api.php index 81e9e1d77f..3671b5784d 100644 --- a/plugins/optimization-detective/tests/storage/test-rest-api.php +++ b/plugins/optimization-detective/tests/storage/test-rest-api.php @@ -28,29 +28,42 @@ public function test_od_register_endpoint_hooked(): void { * @return array */ public function data_provider_to_test_rest_request_good_params(): array { + $add_root_extra_property = static function ( string $property_name ): void { + add_filter( + 'od_url_metric_schema_root_additional_properties', + static function ( array $properties ) use ( $property_name ): array { + $properties[ $property_name ] = array( + 'type' => 'string', + ); + return $properties; + } + ); + }; + return array( - 'not_extended' => array( - 'set_up' => function () { + 'not_extended' => array( + 'set_up' => function (): array { return $this->get_valid_params(); }, ), - 'extended' => array( - 'set_up' => function () { - add_filter( - 'od_url_metric_schema_root_additional_properties', - static function ( array $properties ): array { - $properties['extra'] = array( - 'type' => 'string', - ); - return $properties; - } - ); - + 'extended' => array( + 'set_up' => function () use ( $add_root_extra_property ): array { + $add_root_extra_property( 'extra' ); $params = $this->get_valid_params(); $params['extra'] = 'foo'; return $params; }, ), + 'with_cache_purge_post_id' => array( + 'set_up' => function (): array { + $params = $this->get_valid_params(); + $params['cache_purge_post_id'] = self::factory()->post->create(); + $params['url'] = get_permalink( $params['cache_purge_post_id'] ); + $params['slug'] = od_get_url_metrics_slug( array( 'p' => $params['cache_purge_post_id'] ) ); + $params['hmac'] = od_get_url_metrics_storage_hmac( $params['slug'], $params['current_etag'], $params['url'], $params['cache_purge_post_id'] ); + return $params; + }, + ), ); } @@ -61,44 +74,67 @@ static function ( array $properties ): array { * * @covers ::od_register_endpoint * @covers ::od_handle_rest_request + * @covers ::od_trigger_page_cache_invalidation */ public function test_rest_request_good_params( Closure $set_up ): void { + $stored_context = null; add_action( 'od_url_metric_stored', - function ( OD_URL_Metric_Store_Request_Context $context ): void { + function ( OD_URL_Metric_Store_Request_Context $context ) use ( &$stored_context ): void { $this->assertInstanceOf( OD_URL_Metric_Group_Collection::class, $context->url_metric_group_collection ); $this->assertInstanceOf( OD_URL_Metric_Group::class, $context->url_metric_group ); $this->assertInstanceOf( OD_URL_Metric::class, $context->url_metric ); $this->assertInstanceOf( WP_REST_Request::class, $context->request ); $this->assertIsInt( $context->post_id ); + $stored_context = $context; } ); $valid_params = $set_up(); + + if ( isset( $valid_params['cache_purge_post_id'] ) ) { + $this->assertFalse( wp_next_scheduled( 'od_trigger_page_cache_invalidation', array( $valid_params['cache_purge_post_id'] ) ) ); + } + $this->assertCount( 0, get_posts( array( 'post_type' => OD_URL_Metrics_Post_Type::SLUG ) ) ); $request = $this->create_request( $valid_params ); $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + $this->assertSame( 1, did_action( 'od_url_metric_stored' ) ); + + $this->assertSame( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); $data = $response->get_data(); + $this->assertCount( 1, get_posts( array( 'post_type' => OD_URL_Metrics_Post_Type::SLUG ) ) ); + $this->assertTrue( $data['success'] ); - $this->assertCount( 1, get_posts( array( 'post_type' => OD_URL_Metrics_Post_Type::SLUG ) ) ); $post = OD_URL_Metrics_Post_Type::get_post( $valid_params['slug'] ); $this->assertInstanceOf( WP_Post::class, $post ); $url_metrics = OD_URL_Metrics_Post_Type::get_url_metrics_from_post( $post ); - $this->assertCount( 1, $url_metrics, 'Expected number of URL metrics stored.' ); - $this->assertSame( $valid_params['elements'], $url_metrics[0]->get_elements() ); + $this->assertCount( 1, $url_metrics, 'Expected number of URL Metrics stored.' ); + $this->assertSame( $valid_params['elements'], $this->get_array_json_data( $url_metrics[0]->get( 'elements' ) ) ); $this->assertSame( $valid_params['viewport']['width'], $url_metrics[0]->get_viewport_width() ); $expected_data = $valid_params; - unset( $expected_data['nonce'], $expected_data['slug'] ); + unset( $expected_data['hmac'], $expected_data['slug'], $expected_data['current_etag'], $expected_data['cache_purge_post_id'] ); + unset( $expected_data['unset_prop'] ); $this->assertSame( $expected_data, wp_array_slice_assoc( $url_metrics[0]->jsonSerialize(), array_keys( $expected_data ) ) ); - $this->assertSame( 1, did_action( 'od_url_metric_stored' ) ); + + $this->assertInstanceOf( OD_URL_Metric_Store_Request_Context::class, $stored_context ); + + // Now check that od_trigger_page_cache_invalidation() cleaned caches as expected. + $this->assertSame( $url_metrics[0]->jsonSerialize(), $stored_context->url_metric->jsonSerialize() ); + if ( isset( $valid_params['cache_purge_post_id'] ) ) { + $cache_purge_post_id = $stored_context->request->get_param( 'cache_purge_post_id' ); + $this->assertSame( $valid_params['cache_purge_post_id'], $cache_purge_post_id ); + $scheduled = wp_next_scheduled( 'od_trigger_page_cache_invalidation', array( $cache_purge_post_id ) ); + $this->assertIsInt( $scheduled ); + $this->assertGreaterThan( time(), $scheduled ); + } } /** @@ -107,26 +143,36 @@ function ( OD_URL_Metric_Store_Request_Context $context ): void { * @return array Test data. */ public function data_provider_invalid_params(): array { - $valid_element = $this->get_valid_params()['elements'][0]; + $valid_params = $this->get_valid_params(); + $valid_element = $valid_params['elements'][0]; return array_map( - function ( $params ) { + static function ( $params ) use ( $valid_params ) { return array( - 'params' => array_merge( $this->get_valid_params(), $params ), + 'params' => array_merge( $valid_params, $params ), ); }, array( 'bad_url' => array( 'url' => 'bad://url', ), + 'bad_current_etag1' => array( + 'current_etag' => 'foo', + ), + 'bad_current_etag2' => array( + 'current_etag' => $valid_params['current_etag'] . "\n", + ), 'bad_slug' => array( 'slug' => '', ), - 'bad_nonce' => array( - 'nonce' => 'not even a hash', + 'bad_hmac' => array( + 'hmac' => 'not even a hash', ), - 'invalid_nonce' => array( - 'nonce' => od_get_url_metrics_storage_nonce( od_get_url_metrics_slug( array( 'different' => 'query vars' ) ), home_url( '/' ) ), + 'invalid_hmac' => array( + 'hmac' => od_get_url_metrics_storage_hmac( od_get_url_metrics_slug( array( 'different' => 'query vars' ) ), $valid_params['current_etag'], home_url( '/' ) ), + ), + 'invalid_hmac_with_queried_object' => array( + 'hmac' => od_get_url_metrics_storage_hmac( od_get_url_metrics_slug( array() ), $valid_params['current_etag'], home_url( '/' ), 1 ), ), 'invalid_viewport_type' => array( 'viewport' => '640x480', @@ -240,6 +286,58 @@ public function test_rest_request_bad_params( array $params ): void { $this->assertSame( 0, did_action( 'od_url_metric_stored' ) ); } + /** + * Test sending data when no Origin request header is sent. + * + * @covers ::od_register_endpoint + * @covers ::od_handle_rest_request + * @covers ::od_is_allowed_http_origin + */ + public function test_rest_request_without_origin(): void { + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_body_params( $this->get_valid_params() ); // Valid and yet set as POST params and not as JSON body, so this is why it fails. + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + $this->assertSame( 'rest_cross_origin_forbidden', $response->get_data()['code'], 'Response: ' . wp_json_encode( $response ) ); + $this->assertSame( 0, did_action( 'od_url_metric_stored' ) ); + } + + /** + * Test sending data when a cross-domain Origin request header is sent. + * + * @covers ::od_register_endpoint + * @covers ::od_handle_rest_request + * @covers ::od_is_allowed_http_origin + */ + public function test_rest_request_cross_origin(): void { + $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_header( 'Origin', 'https://cross-origin.example.com' ); + $request->set_body_params( $this->get_valid_params() ); // Valid and yet set as POST params and not as JSON body, so this is why it fails. + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + $this->assertSame( 'rest_cross_origin_forbidden', $response->get_data()['code'], 'Response: ' . wp_json_encode( $response ) ); + $this->assertSame( 0, did_action( 'od_url_metric_stored' ) ); + } + + /** + * Test REST API request when 'home_url' is filtered. + * + * @covers ::od_register_endpoint + * @covers ::od_handle_rest_request + * @covers ::od_is_allowed_http_origin + */ + public function test_rest_request_origin_when_home_url_filtered(): void { + $request = $this->create_request( $this->get_valid_params() ); + add_filter( + 'home_url', + static function ( string $url ): string { + return trailingslashit( $url ) . 'home/en/?foo=bar#baz'; + } + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + } + /** * Test not sending JSON data. * @@ -248,6 +346,7 @@ public function test_rest_request_bad_params( array $params ): void { */ public function test_rest_request_not_json_data(): void { $request = new WP_REST_Request( 'POST', self::ROUTE ); + $request->set_header( 'Origin', home_url() ); $request->set_body_params( $this->get_valid_params() ); // Valid and yet set as POST params and not as JSON body, so this is why it fails. $response = rest_get_server()->dispatch( $request ); $this->assertSame( 400, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); @@ -417,7 +516,7 @@ public function test_rest_request_breakpoint_not_needed_for_specific_breakpoint( public function test_rest_request_over_populate_wider_viewport_group(): void { add_filter( 'od_url_metric_storage_lock_ttl', '__return_zero' ); - // First establish a single breakpoint, so there are two groups of URL metrics + // First establish a single breakpoint, so there are two groups of URL Metrics // with viewport widths 0-480 and >481. $breakpoint_width = 480; add_filter( @@ -439,6 +538,7 @@ static function () use ( $breakpoint_width ): array { // Sanity check that the groups were constructed as expected. $group_collection = new OD_URL_Metric_Group_Collection( OD_URL_Metrics_Post_Type::get_url_metrics_from_post( OD_URL_Metrics_Post_Type::get_post( od_get_url_metrics_slug( array() ) ) ), + $wider_viewport_params['current_etag'], od_get_breakpoint_max_widths(), od_get_url_metrics_breakpoint_sample_size(), HOUR_IN_SECONDS @@ -456,7 +556,7 @@ static function ( OD_URL_Metric_Group $group ) { $this->assertCount( 0, $url_metric_groups[0], 'Expected first group to be empty.' ); $this->assertCount( $sample_size, end( $url_metric_groups ), 'Expected last group to be fully populated.' ); - // Now attempt to store one more URL metric for the wider viewport group. + // Now attempt to store one more URL Metric for the wider viewport group. // This should fail because the group is already fully populated to the sample size. $request = $this->create_request( $wider_viewport_params ); $response = rest_get_server()->dispatch( $request ); @@ -472,7 +572,7 @@ static function ( OD_URL_Metric_Group $group ) { public function test_rest_request_over_populate_narrower_viewport_group(): void { add_filter( 'od_url_metric_storage_lock_ttl', '__return_zero' ); - // First establish a single breakpoint, so there are two groups of URL metrics + // First establish a single breakpoint, so there are two groups of URL Metrics // with viewport widths 0-480 and >481. $breakpoint_width = 480; add_filter( @@ -490,7 +590,7 @@ static function () use ( $breakpoint_width ): array { $narrower_viewport_params ); - // Now attempt to store one more URL metric for the narrower viewport group. + // Now attempt to store one more URL Metric for the narrower viewport group. // This should fail because the group is already fully populated to the sample size. $request = $this->create_request( $narrower_viewport_params ); $response = rest_get_server()->dispatch( $request ); @@ -498,10 +598,65 @@ static function () use ( $breakpoint_width ): array { } /** - * Populate URL metrics. + * Test od_trigger_page_cache_invalidation(). + * + * @covers ::od_trigger_page_cache_invalidation + */ + public function test_od_trigger_page_cache_invalidation(): void { + $cache_purge_post_id = self::factory()->post->create(); + + $all_hook_callback_args = array(); + add_action( + 'all', + static function ( string $hook, ...$args ) use ( &$all_hook_callback_args ): void { + $all_hook_callback_args[ $hook ][] = $args; + }, + 10, + PHP_INT_MAX + ); + + od_trigger_page_cache_invalidation( $cache_purge_post_id ); + + $this->assertArrayHasKey( 'clean_post_cache', $all_hook_callback_args ); + $found = false; + foreach ( $all_hook_callback_args['clean_post_cache'] as $args ) { + if ( $args[0] === $cache_purge_post_id ) { + $this->assertInstanceOf( WP_Post::class, $args[1] ); + $this->assertSame( $cache_purge_post_id, $args[1]->ID ); + $found = true; + } + } + $this->assertTrue( $found, 'Expected clean_post_cache to have been fired for the post queried object.' ); + + $this->assertArrayHasKey( 'transition_post_status', $all_hook_callback_args ); + $found = false; + foreach ( $all_hook_callback_args['transition_post_status'] as $args ) { + $this->assertInstanceOf( WP_Post::class, $args[2] ); + if ( $args[2]->ID === $cache_purge_post_id ) { + $this->assertSame( $args[2]->post_status, $args[0] ); + $this->assertSame( $args[2]->post_status, $args[1] ); + $found = true; + } + } + $this->assertTrue( $found, 'Expected transition_post_status to have been fired for the post queried object.' ); + + $this->assertArrayHasKey( 'save_post', $all_hook_callback_args ); + $found = false; + foreach ( $all_hook_callback_args['save_post'] as $args ) { + if ( $args[0] === $cache_purge_post_id ) { + $this->assertInstanceOf( WP_Post::class, $args[1] ); + $this->assertSame( $cache_purge_post_id, $args[1]->ID ); + $found = true; + } + } + $this->assertTrue( $found, 'Expected save_post to have been fired for the post queried object.' ); + } + + /** + * Populate URL Metrics. * - * @param int $count Count of URL metrics to populate. - * @param array $params Params for URL metric. + * @param int $count Count of URL Metrics to populate. + * @param array $params Params for URL Metric. */ private function populate_url_metrics( int $count, array $params ): void { for ( $i = 0; $i < $count; $i++ ) { @@ -523,18 +678,19 @@ private function get_valid_params( array $extras = array() ): array { array( 'viewport_width' => 480, 'element' => array( - 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::DIV]/*[1][self::MAIN]/*[0][self::DIV]/*[0][self::FIGURE]/*[0][self::IMG]', + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::DIV]/*[2][self::MAIN]/*[1][self::DIV]/*[1][self::FIGURE]/*[1][self::IMG]', ), ) )->jsonSerialize(); - unset( $data['timestamp'], $data['uuid'] ); // Since these are readonly. $data = array_merge( array( - 'slug' => $slug, - 'nonce' => od_get_url_metrics_storage_nonce( $slug, $data['url'] ), + 'slug' => $slug, + 'hmac' => od_get_url_metrics_storage_hmac( $slug, $data['etag'], $data['url'] ), + 'current_etag' => $data['etag'], ), $data ); + unset( $data['timestamp'], $data['uuid'], $data['etag'] ); // Since these are readonly. if ( count( $extras ) > 0 ) { $data = $this->recursive_merge( $data, $extras ); } @@ -566,7 +722,7 @@ private function recursive_merge( array $base_array, array $sparse_array ): arra } /** - * Creates a request to store a URL metric. + * Creates a request to store a URL Metric. * * @param array $params Params. * @return WP_REST_Request> Request. @@ -579,8 +735,9 @@ private function create_request( array $params ): WP_REST_Request { */ $request = new WP_REST_Request( 'POST', self::ROUTE ); $request->set_header( 'Content-Type', 'application/json' ); - $request->set_query_params( wp_array_slice_assoc( $params, array( 'nonce', 'slug' ) ) ); - unset( $params['nonce'], $params['slug'] ); + $request->set_query_params( wp_array_slice_assoc( $params, array( 'hmac', 'current_etag', 'slug', 'cache_purge_post_id' ) ) ); + $request->set_header( 'Origin', home_url() ); + unset( $params['hmac'], $params['slug'], $params['current_etag'], $params['cache_purge_post_id'] ); $request->set_body( wp_json_encode( $params ) ); return $request; } diff --git a/plugins/optimization-detective/tests/test-cases/complete-url-metrics.php b/plugins/optimization-detective/tests/test-cases/complete-url-metrics.php index 237f183081..95dc5ec95d 100644 --- a/plugins/optimization-detective/tests/test-cases/complete-url-metrics.php +++ b/plugins/optimization-detective/tests/test-cases/complete-url-metrics.php @@ -3,6 +3,12 @@ 'set_up' => static function ( Test_OD_Optimization $test_case ): void { ini_set( 'default_mimetype', 'text/html; charset=utf-8' ); // phpcs:ignore WordPress.PHP.IniSet.Risky + // Normalize the data for computing the current URL Metrics ETag to work around the issue where there is no + // global variable storing the OD_Tag_Visitor_Registry instance along with any registered tag visitors, so + // during set up we do not know what the ETag will look like. The current ETag is only established when + // the output begins to be processed by od_optimize_template_output_buffer(). + add_filter( 'od_current_url_metrics_etag_data', '__return_empty_array' ); + $test_case->populate_url_metrics( array( array( diff --git a/plugins/optimization-detective/tests/test-class-od-element.php b/plugins/optimization-detective/tests/test-class-od-element.php new file mode 100644 index 0000000000..52a4cc43ff --- /dev/null +++ b/plugins/optimization-detective/tests/test-class-od-element.php @@ -0,0 +1,151 @@ + 'string', + ); + return $schema; + } + ); + + $element_data = array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', + 'isLCP' => false, + 'isLCPCandidate' => true, + 'intersectionRatio' => 0.123, + 'intersectionRect' => array( + 'width' => 100.0, + 'height' => 200.0, + 'x' => 0.0, + 'y' => 101.0, + 'top' => 1.0, + 'right' => 2000.0, + 'bottom' => 90.0, + 'left' => 111.0, + ), + 'boundingClientRect' => array( + 'width' => 200.0, + 'height' => 400.0, + 'x' => 1.0, + 'y' => 201.0, + 'top' => 2.0, + 'right' => 4000.0, + 'bottom' => 180.0, + 'left' => 211.0, + ), + 'customProp' => 'customValue', + ); + $url_metric = $this->get_sample_url_metric( array( 'element' => $element_data ) ); + + $element = $url_metric->get_elements()[0]; + $this->assertInstanceOf( OD_Element::class, $element ); + $this->assertSame( $url_metric, $element->get_url_metric() ); + $this->assertNull( $element->get_url_metric_group() ); + $current_etag = md5( '' ); + $collection = new OD_URL_Metric_Group_Collection( array( $url_metric ), $current_etag, array(), 1, DAY_IN_SECONDS ); + $collection->add_url_metric( $url_metric ); + $this->assertSame( iterator_to_array( $collection )[0], $element->get_url_metric_group() ); + + $this->assertSame( $element_data['xpath'], $element->get_xpath() ); + $this->assertSame( $element_data['xpath'], $element['xpath'] ); + $this->assertSame( $element_data['xpath'], $element->offsetGet( 'xpath' ) ); + $this->assertSame( $element_data['xpath'], $element->get( 'xpath' ) ); + $this->assertTrue( isset( $element['xpath'] ) ); + $this->assertTrue( $element->offsetExists( 'xpath' ) ); + + $this->assertSame( $element_data['isLCP'], $element->is_lcp() ); + $this->assertSame( $element_data['isLCP'], $element['isLCP'] ); + $this->assertSame( $element_data['isLCP'], $element->offsetGet( 'isLCP' ) ); + $this->assertSame( $element_data['isLCP'], $element->get( 'isLCP' ) ); + $this->assertTrue( isset( $element['isLCP'] ) ); + $this->assertTrue( $element->offsetExists( 'isLCP' ) ); + + $this->assertSame( $element_data['isLCPCandidate'], $element->is_lcp_candidate() ); + $this->assertSame( $element_data['isLCPCandidate'], $element['isLCPCandidate'] ); + $this->assertSame( $element_data['isLCPCandidate'], $element->offsetGet( 'isLCPCandidate' ) ); + $this->assertSame( $element_data['isLCPCandidate'], $element->get( 'isLCPCandidate' ) ); + $this->assertTrue( isset( $element['isLCPCandidate'] ) ); + $this->assertTrue( $element->offsetExists( 'isLCPCandidate' ) ); + + $this->assertSame( $element_data['intersectionRatio'], $element->get_intersection_ratio() ); + $this->assertSame( $element_data['intersectionRatio'], $element['intersectionRatio'] ); + $this->assertSame( $element_data['intersectionRatio'], $element->offsetGet( 'intersectionRatio' ) ); + $this->assertSame( $element_data['intersectionRatio'], $element->get( 'intersectionRatio' ) ); + $this->assertTrue( isset( $element['intersectionRatio'] ) ); + $this->assertTrue( $element->offsetExists( 'intersectionRatio' ) ); + + $this->assertSame( $element_data['intersectionRect'], $element->get_intersection_rect() ); + $this->assertSame( $element_data['intersectionRect'], $element['intersectionRect'] ); + $this->assertSame( $element_data['intersectionRect'], $element->offsetGet( 'intersectionRect' ) ); + $this->assertSame( $element_data['intersectionRect'], $element->get( 'intersectionRect' ) ); + $this->assertTrue( isset( $element['intersectionRect'] ) ); + $this->assertTrue( $element->offsetExists( 'intersectionRect' ) ); + + $this->assertSame( $element_data['boundingClientRect'], $element->get_bounding_client_rect() ); + $this->assertSame( $element_data['boundingClientRect'], $element['boundingClientRect'] ); + $this->assertSame( $element_data['boundingClientRect'], $element->offsetGet( 'boundingClientRect' ) ); + $this->assertSame( $element_data['boundingClientRect'], $element->get( 'boundingClientRect' ) ); + $this->assertTrue( isset( $element['boundingClientRect'] ) ); + $this->assertTrue( $element->offsetExists( 'boundingClientRect' ) ); + + $this->assertNull( $element['notFound'] ); + $this->assertNull( $element->get( 'notFound' ) ); + $this->assertNull( $element->offsetGet( 'notFound' ) ); // @phpstan-ignore argument.templateType (Likely resolved by ) + $this->assertFalse( isset( $element['notFound'] ) ); + $this->assertFalse( $element->offsetExists( 'notFound' ) ); + + $this->assertSame( $element_data['customProp'], $element['customProp'] ); // TODO: Why is PHPStan not complaining about the argument.templateType here? + $this->assertSame( $element_data['customProp'], $element->get( 'customProp' ) ); + $this->assertSame( $element_data['customProp'], $element->offsetGet( 'customProp' ) ); // @phpstan-ignore argument.templateType (Likely resolved by ) + $this->assertTrue( isset( $element['customProp'] ) ); + $this->assertTrue( $element->offsetExists( 'customProp' ) ); + + $this->assertEquals( $element_data, $element->jsonSerialize() ); + + $exception = null; + try { + $element['isLCP'] = true; + } catch ( Exception $e ) { + $exception = $e; + } + $this->assertInstanceOf( Exception::class, $exception ); + + $exception = null; + try { + unset( $element['isLCP'] ); + } catch ( Exception $e ) { // @phpstan-ignore catch.neverThrown (It is thrown by offsetUnset actually.) + $exception = $e; + } + $this->assertInstanceOf( Exception::class, $exception ); // @phpstan-ignore method.impossibleType (It is thrown by offsetUnset actually.) + } +} diff --git a/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php b/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php index c16402b1ee..4590d28cb7 100644 --- a/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php +++ b/plugins/optimization-detective/tests/test-class-od-html-tag-processor.php @@ -24,7 +24,7 @@ class Test_OD_HTML_Tag_Processor extends WP_UnitTestCase { public function data_provider_sample_documents(): array { return array( 'well-formed-html' => array( - 'document' => ' + 'document' => ' @@ -45,26 +45,26 @@ public function data_provider_sample_documents(): array { ', - 'open_tags' => array( 'HTML', 'HEAD', 'META', 'TITLE', 'SCRIPT', 'STYLE', 'BODY', 'IFRAME', 'P', 'BR', 'IMG', 'FORM', 'TEXTAREA', 'FOOTER' ), - 'xpaths' => array( - '/*[1][self::HTML]', - '/*[1][self::HTML]/*[1][self::HEAD]', - '/*[1][self::HTML]/*[1][self::HEAD]/*[1][self::META]', - '/*[1][self::HTML]/*[1][self::HEAD]/*[2][self::TITLE]', - '/*[1][self::HTML]/*[1][self::HEAD]/*[3][self::SCRIPT]', - '/*[1][self::HTML]/*[1][self::HEAD]/*[4][self::STYLE]', - '/*[1][self::HTML]/*[2][self::BODY]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IFRAME]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]/*[1][self::BR]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]/*[2][self::IMG]', - '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::FORM]', - '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::FORM]/*[1][self::TEXTAREA]', - '/*[1][self::HTML]/*[2][self::BODY]/*[4][self::FOOTER]', + 'open_tags' => array( 'HTML', 'HEAD', 'META', 'TITLE', 'SCRIPT', 'STYLE', 'BODY', 'IFRAME', 'P', 'BR', 'IMG', 'FORM', 'TEXTAREA', 'FOOTER' ), + 'xpath_breadcrumbs' => array( + '/*[1][self::HTML]' => array( 'HTML' ), + '/*[1][self::HTML]/*[1][self::HEAD]' => array( 'HTML', 'HEAD' ), + '/*[1][self::HTML]/*[1][self::HEAD]/*[1][self::META]' => array( 'HTML', 'HEAD', 'META' ), + '/*[1][self::HTML]/*[1][self::HEAD]/*[2][self::TITLE]' => array( 'HTML', 'HEAD', 'TITLE' ), + '/*[1][self::HTML]/*[1][self::HEAD]/*[3][self::SCRIPT]' => array( 'HTML', 'HEAD', 'SCRIPT' ), + '/*[1][self::HTML]/*[1][self::HEAD]/*[4][self::STYLE]' => array( 'HTML', 'HEAD', 'STYLE' ), + '/*[1][self::HTML]/*[2][self::BODY]' => array( 'HTML', 'BODY' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IFRAME]' => array( 'HTML', 'BODY', 'IFRAME' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]/*[1][self::BR]' => array( 'HTML', 'BODY', 'P', 'BR' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]/*[2][self::IMG]' => array( 'HTML', 'BODY', 'P', 'IMG' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::FORM]' => array( 'HTML', 'BODY', 'FORM' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::FORM]/*[1][self::TEXTAREA]' => array( 'HTML', 'BODY', 'FORM', 'TEXTAREA' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[4][self::FOOTER]' => array( 'HTML', 'BODY', 'FOOTER' ), ), ), 'foreign-elements' => array( - 'document' => ' + 'document' => ' @@ -84,25 +84,25 @@ public function data_provider_sample_documents(): array { ', - 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'SVG', 'G', 'PATH', 'CIRCLE', 'G', 'RECT', 'MATH', 'MN', 'MSPACE', 'MN' ), - 'xpaths' => array( - '/*[1][self::HTML]', - '/*[1][self::HTML]/*[1][self::HEAD]', - '/*[1][self::HTML]/*[2][self::BODY]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[1][self::PATH]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[2][self::CIRCLE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[3][self::G]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[4][self::RECT]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]/*[1][self::MN]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]/*[2][self::MSPACE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]/*[3][self::MN]', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'SVG', 'G', 'PATH', 'CIRCLE', 'G', 'RECT', 'MATH', 'MN', 'MSPACE', 'MN' ), + 'xpath_breadcrumbs' => array( + '/*[1][self::HTML]' => array( 'HTML' ), + '/*[1][self::HTML]/*[1][self::HEAD]' => array( 'HTML', 'HEAD' ), + '/*[1][self::HTML]/*[2][self::BODY]' => array( 'HTML', 'BODY' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]' => array( 'HTML', 'BODY', 'SVG' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]' => array( 'HTML', 'BODY', 'SVG', 'G' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[1][self::PATH]' => array( 'HTML', 'BODY', 'SVG', 'G', 'PATH' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[2][self::CIRCLE]' => array( 'HTML', 'BODY', 'SVG', 'G', 'CIRCLE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[3][self::G]' => array( 'HTML', 'BODY', 'SVG', 'G', 'G' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SVG]/*[1][self::G]/*[4][self::RECT]' => array( 'HTML', 'BODY', 'SVG', 'G', 'RECT' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]' => array( 'HTML', 'BODY', 'MATH' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]/*[1][self::MN]' => array( 'HTML', 'BODY', 'MATH', 'MN' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]/*[2][self::MSPACE]' => array( 'HTML', 'BODY', 'MATH', 'MSPACE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::MATH]/*[3][self::MN]' => array( 'HTML', 'BODY', 'MATH', 'MN' ), ), ), 'closing-void-tag' => array( - 'document' => ' + 'document' => ' @@ -112,18 +112,18 @@ public function data_provider_sample_documents(): array { ', - 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'SPAN', 'META', 'SPAN' ), - 'xpaths' => array( - '/*[1][self::HTML]', - '/*[1][self::HTML]/*[1][self::HEAD]', - '/*[1][self::HTML]/*[2][self::BODY]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SPAN]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::META]', - '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::SPAN]', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'SPAN', 'META', 'SPAN' ), + 'xpath_breadcrumbs' => array( + '/*[1][self::HTML]' => array( 'HTML' ), + '/*[1][self::HTML]/*[1][self::HEAD]' => array( 'HTML', 'HEAD' ), + '/*[1][self::HTML]/*[2][self::BODY]' => array( 'HTML', 'BODY' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SPAN]' => array( 'HTML', 'BODY', 'SPAN' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::META]' => array( 'HTML', 'BODY', 'META' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::SPAN]' => array( 'HTML', 'BODY', 'SPAN' ), ), ), 'void-tags' => array( - 'document' => ' + 'document' => ' @@ -153,36 +153,36 @@ public function data_provider_sample_documents(): array { ', - 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'AREA', 'BASE', 'BASEFONT', 'BGSOUND', 'BR', 'COL', 'EMBED', 'FRAME', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR', 'DIV', 'SPAN', 'EM' ), - 'xpaths' => array( - '/*[1][self::HTML]', - '/*[1][self::HTML]/*[1][self::HEAD]', - '/*[1][self::HTML]/*[2][self::BODY]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::AREA]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::BASE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::BASEFONT]', - '/*[1][self::HTML]/*[2][self::BODY]/*[4][self::BGSOUND]', - '/*[1][self::HTML]/*[2][self::BODY]/*[5][self::BR]', - '/*[1][self::HTML]/*[2][self::BODY]/*[6][self::COL]', - '/*[1][self::HTML]/*[2][self::BODY]/*[7][self::EMBED]', - '/*[1][self::HTML]/*[2][self::BODY]/*[8][self::FRAME]', - '/*[1][self::HTML]/*[2][self::BODY]/*[9][self::HR]', - '/*[1][self::HTML]/*[2][self::BODY]/*[10][self::IMG]', - '/*[1][self::HTML]/*[2][self::BODY]/*[11][self::INPUT]', - '/*[1][self::HTML]/*[2][self::BODY]/*[12][self::KEYGEN]', - '/*[1][self::HTML]/*[2][self::BODY]/*[13][self::LINK]', - '/*[1][self::HTML]/*[2][self::BODY]/*[14][self::META]', - '/*[1][self::HTML]/*[2][self::BODY]/*[15][self::PARAM]', - '/*[1][self::HTML]/*[2][self::BODY]/*[16][self::SOURCE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[17][self::TRACK]', - '/*[1][self::HTML]/*[2][self::BODY]/*[18][self::WBR]', - '/*[1][self::HTML]/*[2][self::BODY]/*[19][self::DIV]', - '/*[1][self::HTML]/*[2][self::BODY]/*[19][self::DIV]/*[1][self::SPAN]', - '/*[1][self::HTML]/*[2][self::BODY]/*[19][self::DIV]/*[1][self::SPAN]/*[1][self::EM]', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'AREA', 'BASE', 'BASEFONT', 'BGSOUND', 'BR', 'COL', 'EMBED', 'FRAME', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR', 'DIV', 'SPAN', 'EM' ), + 'xpath_breadcrumbs' => array( + '/*[1][self::HTML]' => array( 'HTML' ), + '/*[1][self::HTML]/*[1][self::HEAD]' => array( 'HTML', 'HEAD' ), + '/*[1][self::HTML]/*[2][self::BODY]' => array( 'HTML', 'BODY' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::AREA]' => array( 'HTML', 'BODY', 'AREA' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::BASE]' => array( 'HTML', 'BODY', 'BASE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::BASEFONT]' => array( 'HTML', 'BODY', 'BASEFONT' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[4][self::BGSOUND]' => array( 'HTML', 'BODY', 'BGSOUND' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[5][self::BR]' => array( 'HTML', 'BODY', 'BR' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[6][self::COL]' => array( 'HTML', 'BODY', 'COL' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[7][self::EMBED]' => array( 'HTML', 'BODY', 'EMBED' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[8][self::FRAME]' => array( 'HTML', 'BODY', 'FRAME' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[9][self::HR]' => array( 'HTML', 'BODY', 'HR' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[10][self::IMG]' => array( 'HTML', 'BODY', 'IMG' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[11][self::INPUT]' => array( 'HTML', 'BODY', 'INPUT' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[12][self::KEYGEN]' => array( 'HTML', 'BODY', 'KEYGEN' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[13][self::LINK]' => array( 'HTML', 'BODY', 'LINK' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[14][self::META]' => array( 'HTML', 'BODY', 'META' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[15][self::PARAM]' => array( 'HTML', 'BODY', 'PARAM' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[16][self::SOURCE]' => array( 'HTML', 'BODY', 'SOURCE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[17][self::TRACK]' => array( 'HTML', 'BODY', 'TRACK' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[18][self::WBR]' => array( 'HTML', 'BODY', 'WBR' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[19][self::DIV]' => array( 'HTML', 'BODY', 'DIV' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[19][self::DIV]/*[1][self::SPAN]' => array( 'HTML', 'BODY', 'DIV', 'SPAN' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[19][self::DIV]/*[1][self::SPAN]/*[1][self::EM]' => array( 'HTML', 'BODY', 'DIV', 'SPAN', 'EM' ), ), ), 'optional-closing-p' => array( - 'document' => ' + 'document' => ' @@ -225,75 +225,75 @@ public function data_provider_sample_documents(): array { ', - 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'P', 'P', 'EM', 'P', 'P', 'ADDRESS', 'P', 'ARTICLE', 'P', 'ASIDE', 'P', 'BLOCKQUOTE', 'P', 'DETAILS', 'P', 'DIV', 'P', 'DL', 'P', 'FIELDSET', 'P', 'FIGCAPTION', 'P', 'FIGURE', 'P', 'FOOTER', 'P', 'FORM', 'P', 'H1', 'P', 'H2', 'P', 'H3', 'P', 'H4', 'P', 'H5', 'P', 'H6', 'P', 'HEADER', 'P', 'HGROUP', 'P', 'HR', 'P', 'MAIN', 'P', 'MENU', 'P', 'NAV', 'P', 'OL', 'P', 'PRE', 'P', 'SEARCH', 'P', 'SECTION', 'P', 'TABLE', 'P', 'UL' ), - 'xpaths' => array( - '/*[1][self::HTML]', - '/*[1][self::HTML]/*[1][self::HEAD]', - '/*[1][self::HTML]/*[2][self::BODY]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::P]/*[1][self::EM]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::ADDRESS]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::ARTICLE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::ASIDE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::BLOCKQUOTE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::DETAILS]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::DIV]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::DL]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIELDSET]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGCAPTION]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FIGURE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FOOTER]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::FORM]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::H1]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::H2]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::H3]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::H4]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::H5]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::H6]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::HEADER]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::HGROUP]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::HR]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::MAIN]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::MENU]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::NAV]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::OL]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::PRE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SEARCH]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::SECTION]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::TABLE]', - '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]', - '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::UL]', + 'open_tags' => array( 'HTML', 'HEAD', 'BODY', 'P', 'P', 'EM', 'P', 'P', 'ADDRESS', 'P', 'ARTICLE', 'P', 'ASIDE', 'P', 'BLOCKQUOTE', 'P', 'DETAILS', 'P', 'DIV', 'P', 'DL', 'P', 'FIELDSET', 'P', 'FIGCAPTION', 'P', 'FIGURE', 'P', 'FOOTER', 'P', 'FORM', 'P', 'H1', 'P', 'H2', 'P', 'H3', 'P', 'H4', 'P', 'H5', 'P', 'H6', 'P', 'HEADER', 'P', 'HGROUP', 'P', 'HR', 'P', 'MAIN', 'P', 'MENU', 'P', 'NAV', 'P', 'OL', 'P', 'PRE', 'P', 'SEARCH', 'P', 'SECTION', 'P', 'TABLE', 'P', 'UL' ), + 'xpath_breadcrumbs' => array( + '/*[1][self::HTML]' => array( 'HTML' ), + '/*[1][self::HTML]/*[1][self::HEAD]' => array( 'HTML', 'HEAD' ), + '/*[1][self::HTML]/*[2][self::BODY]' => array( 'HTML', 'BODY' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::P]/*[1][self::EM]' => array( 'HTML', 'BODY', 'P', 'EM' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[3][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[4][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[5][self::ADDRESS]' => array( 'HTML', 'BODY', 'ADDRESS' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[6][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[7][self::ARTICLE]' => array( 'HTML', 'BODY', 'ARTICLE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[8][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[9][self::ASIDE]' => array( 'HTML', 'BODY', 'ASIDE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[10][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[11][self::BLOCKQUOTE]' => array( 'HTML', 'BODY', 'BLOCKQUOTE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[12][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[13][self::DETAILS]' => array( 'HTML', 'BODY', 'DETAILS' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[14][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[15][self::DIV]' => array( 'HTML', 'BODY', 'DIV' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[16][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[17][self::DL]' => array( 'HTML', 'BODY', 'DL' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[18][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[19][self::FIELDSET]' => array( 'HTML', 'BODY', 'FIELDSET' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[20][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[21][self::FIGCAPTION]' => array( 'HTML', 'BODY', 'FIGCAPTION' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[22][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[23][self::FIGURE]' => array( 'HTML', 'BODY', 'FIGURE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[24][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[25][self::FOOTER]' => array( 'HTML', 'BODY', 'FOOTER' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[26][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[27][self::FORM]' => array( 'HTML', 'BODY', 'FORM' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[28][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[29][self::H1]' => array( 'HTML', 'BODY', 'H1' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[30][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[31][self::H2]' => array( 'HTML', 'BODY', 'H2' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[32][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[33][self::H3]' => array( 'HTML', 'BODY', 'H3' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[34][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[35][self::H4]' => array( 'HTML', 'BODY', 'H4' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[36][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[37][self::H5]' => array( 'HTML', 'BODY', 'H5' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[38][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[39][self::H6]' => array( 'HTML', 'BODY', 'H6' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[40][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[41][self::HEADER]' => array( 'HTML', 'BODY', 'HEADER' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[42][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[43][self::HGROUP]' => array( 'HTML', 'BODY', 'HGROUP' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[44][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[45][self::HR]' => array( 'HTML', 'BODY', 'HR' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[46][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[47][self::MAIN]' => array( 'HTML', 'BODY', 'MAIN' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[48][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[49][self::MENU]' => array( 'HTML', 'BODY', 'MENU' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[50][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[51][self::NAV]' => array( 'HTML', 'BODY', 'NAV' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[52][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[53][self::OL]' => array( 'HTML', 'BODY', 'OL' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[54][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[55][self::PRE]' => array( 'HTML', 'BODY', 'PRE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[56][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[57][self::SEARCH]' => array( 'HTML', 'BODY', 'SEARCH' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[58][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[59][self::SECTION]' => array( 'HTML', 'BODY', 'SECTION' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[60][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[61][self::TABLE]' => array( 'HTML', 'BODY', 'TABLE' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[62][self::P]' => array( 'HTML', 'BODY', 'P' ), + '/*[1][self::HTML]/*[2][self::BODY]/*[63][self::UL]' => array( 'HTML', 'BODY', 'UL' ), ), ), ); @@ -306,25 +306,30 @@ public function data_provider_sample_documents(): array { * @covers ::next_tag * @covers ::next_token * @covers ::get_xpath + * @covers ::get_breadcrumbs * * @dataProvider data_provider_sample_documents * - * @param string $document Document. - * @param string[] $open_tags Open tags. - * @param string[] $xpaths XPaths. + * @param string $document Document. + * @param string[] $open_tags Open tags. + * @param array $xpath_breadcrumbs XPaths mapped to their breadcrumbs. */ - public function test_next_tag_and_get_xpath( string $document, array $open_tags, array $xpaths ): void { + public function test_next_tag_and_get_xpath( string $document, array $open_tags, array $xpath_breadcrumbs ): void { $p = new OD_HTML_Tag_Processor( $document ); $this->assertSame( '', $p->get_xpath(), 'Expected empty XPath since iteration has not started.' ); - $actual_open_tags = array(); - $actual_xpaths = array(); + $actual_open_tags = array(); + $actual_xpath_breadcrumbs_mapping = array(); while ( $p->next_open_tag() ) { $actual_open_tags[] = $p->get_tag(); - $actual_xpaths[] = $p->get_xpath(); + + $xpath = $p->get_xpath(); + $this->assertArrayNotHasKey( $xpath, $actual_xpath_breadcrumbs_mapping, 'Each tag must have a unique XPath.' ); + + $actual_xpath_breadcrumbs_mapping[ $xpath ] = $p->get_breadcrumbs(); } $this->assertSame( $open_tags, $actual_open_tags, "Expected list of open tags to match.\nSnapshot: " . $this->export_array_snapshot( $actual_open_tags, true ) ); - $this->assertSame( $xpaths, $actual_xpaths, "Expected list of XPaths to match.\nSnapshot: " . $this->export_array_snapshot( $actual_xpaths ) ); + $this->assertSame( $xpath_breadcrumbs, $actual_xpath_breadcrumbs_mapping, "Expected list of XPaths to match.\nSnapshot: " . $this->export_array_snapshot( $actual_xpath_breadcrumbs_mapping ) ); } /** @@ -338,80 +343,15 @@ public function test_next_tag_with_query(): void { $p->next_tag( array( 'tag_name' => 'HTML' ) ); } - /** - * Test append_head_html(). - * - * @covers ::append_head_html - */ - public function test_append_head_html(): void { - $html = ' - - - - - - - -

Hello World

- - - '; - $processor = new OD_HTML_Tag_Processor( $html ); - $early_injected = ''; - $late_injected = ''; - $processor->append_head_html( $early_injected ); - - $saw_head = false; - while ( $processor->next_open_tag() ) { - $tag = $processor->get_tag(); - if ( 'HEAD' === $tag ) { - $saw_head = true; - } - } - $this->assertTrue( $saw_head ); - - $processor->append_head_html( $late_injected ); - $expected = " - - - - - {$early_injected}{$late_injected} - - -

Hello World

- - - "; - - $this->assertSame( $expected, $processor->get_updated_html() ); - - $later_injected = ''; - $processor->append_head_html( $later_injected ); - - $expected = " - - - - - {$early_injected}{$late_injected}{$later_injected} - - -

Hello World

- - - "; - $this->assertSame( $expected, $processor->get_updated_html() ); - } - /** * Test both append_head_html() and append_body_html(). * * @covers ::append_head_html * @covers ::append_body_html + * @covers ::get_updated_html */ public function test_append_head_and_body_html(): void { - $html = ' + $html = ' @@ -425,36 +365,53 @@ public function test_append_head_and_body_html(): void { '; - $head_injected = ''; - $body_injected = ''; - $processor = new OD_HTML_Tag_Processor( $html ); + $head_injected = ''; + $body_injected = ''; + $later_head_injected = ''; + $processor = new OD_HTML_Tag_Processor( $html ); + + $processor->append_head_html( $head_injected ); + $processor->append_body_html( $body_injected ); $saw_head = false; $saw_body = false; + $did_seek = false; while ( $processor->next_open_tag() ) { + $this->assertStringNotContainsString( $head_injected, $processor->get_updated_html(), 'Only expecting end-of-head injection once document was finalized.' ); + $this->assertStringNotContainsString( $body_injected, $processor->get_updated_html(), 'Only expecting end-of-body injection once document was finalized.' ); $tag = $processor->get_tag(); if ( 'HEAD' === $tag ) { $saw_head = true; } elseif ( 'BODY' === $tag ) { $saw_body = true; + $this->assertTrue( $processor->set_bookmark( 'cuerpo' ) ); + } + if ( ! $did_seek && 'H1' === $tag ) { + $processor->append_head_html( '' ); + $processor->append_body_html( '' ); + $this->assertTrue( $processor->seek( 'cuerpo' ) ); + $did_seek = true; } } + $this->assertTrue( $did_seek ); $this->assertTrue( $saw_head ); $this->assertTrue( $saw_body ); + $this->assertStringContainsString( $head_injected, $processor->get_updated_html(), 'Only expecting end-of-head injection once document was finalized.' ); + $this->assertStringContainsString( $body_injected, $processor->get_updated_html(), 'Only expecting end-of-body injection once document was finalized.' ); + + $processor->append_head_html( $later_head_injected ); - $processor->append_head_html( $head_injected ); - $processor->append_body_html( $body_injected ); $expected = " - {$head_injected} + {$head_injected}{$later_head_injected}

Hello World

- {$body_injected} + {$body_injected} "; @@ -469,18 +426,23 @@ public function test_append_head_and_body_html(): void { * @covers ::set_meta_attribute */ public function test_html_tag_processor_wrapper_methods(): void { - $processor = new OD_HTML_Tag_Processor( '' ); + $processor = new OD_HTML_Tag_Processor( '' ); while ( $processor->next_open_tag() ) { $open_tag = $processor->get_tag(); if ( 'HTML' === $open_tag ) { $processor->set_attribute( 'lang', 'es' ); + $processor->set_attribute( 'class', 'foo' ); // Unchanged from source to test that data-od-replaced-class metadata attribute won't be added. $processor->remove_attribute( 'dir' ); $processor->set_attribute( 'id', 'root' ); $processor->set_meta_attribute( 'foo', 'bar' ); $processor->set_meta_attribute( 'baz', true ); + $processor->set_attribute( 'data-novalue', 'Nevermind!' ); } } - $this->assertSame( '', $processor->get_updated_html() ); + $this->assertSame( + '', + $processor->get_updated_html() + ); } /** @@ -659,10 +621,25 @@ public function test_get_cursor_move_count(): void { * @return string Snapshot. */ private function export_array_snapshot( array $data, bool $one_line = false ): string { - $php = (string) preg_replace( '/^\s*\d+\s*=>\s*/m', '', var_export( $data, true ) ); - if ( $one_line ) { - $php = str_replace( "\n", ' ', $php ); + $php = 'array('; + $php .= $one_line ? ' ' : "\n"; + foreach ( $data as $key => $value ) { + if ( ! $one_line ) { + $php .= "\t"; + } + if ( ! is_numeric( $key ) ) { + $php .= var_export( $key, true ) . ' => '; + } + + if ( is_array( $value ) ) { + $php .= $this->export_array_snapshot( $value, true ); + } else { + $php .= str_replace( "\n", ' ', var_export( $value, true ) ); + } + $php .= ','; + $php .= $one_line ? ' ' : "\n"; } + $php .= ')'; return $php; } } diff --git a/plugins/optimization-detective/tests/test-class-od-link-collection.php b/plugins/optimization-detective/tests/test-class-od-link-collection.php index dffaa3ead6..3c0ea1bd4c 100644 --- a/plugins/optimization-detective/tests/test-class-od-link-collection.php +++ b/plugins/optimization-detective/tests/test-class-od-link-collection.php @@ -30,13 +30,14 @@ public function data_provider_to_test_add_link(): array { 'media' => 'screen', 'integrity' => 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC', 'referrerpolicy' => 'origin', + 'type' => 'image/jpeg', ), ), ), 'expected_html' => ' - + ', - 'expected_header' => 'Link: ; rel="preload"; imagesrcset="https://example.com/foo-400.jpg 400w, https://example.com/foo-800.jpg 800w"; imagesizes="100vw"; crossorigin="anonymous"; fetchpriority="high"; as="image"; media="screen"; integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"; referrerpolicy="origin"', + 'expected_header' => 'Link: ; rel="preload"; imagesrcset="https://example.com/foo-400.jpg 400w, https://example.com/foo-800.jpg 800w"; imagesizes="100vw"; crossorigin="anonymous"; fetchpriority="high"; as="image"; media="screen"; integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"; referrerpolicy="origin"; type="image/jpeg"', 'expected_count' => 1, 'error' => '', ), diff --git a/plugins/optimization-detective/tests/test-class-od-url-metric.php b/plugins/optimization-detective/tests/test-class-od-url-metric.php index 0f9370a0b1..51b23c243e 100644 --- a/plugins/optimization-detective/tests/test-class-od-url-metric.php +++ b/plugins/optimization-detective/tests/test-class-od-url-metric.php @@ -22,7 +22,7 @@ public function data_provider_to_test_constructor(): array { $valid_element = array( 'isLCP' => true, 'isLCPCandidate' => true, - 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', 'intersectionRatio' => 1.0, 'intersectionRect' => $this->get_sample_dom_rect(), 'boundingClientRect' => $this->get_sample_dom_rect(), @@ -31,6 +31,7 @@ public function data_provider_to_test_constructor(): array { return array( 'valid_minimal' => array( 'data' => array( + // Note: The 'etag' field is currently optional, so this data is still valid without it. 'url' => home_url( '/' ), 'viewport' => $viewport, 'timestamp' => microtime( true ), @@ -40,6 +41,7 @@ public function data_provider_to_test_constructor(): array { 'valid_with_element' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), + 'etag' => md5( '' ), 'url' => home_url( '/' ), 'viewport' => $viewport, 'timestamp' => microtime( true ), @@ -51,6 +53,7 @@ public function data_provider_to_test_constructor(): array { // This tests that sanitization converts values into their expected PHP types. 'valid_but_props_are_strings' => array( 'data' => array( + 'etag' => md5( '' ), 'url' => home_url( '/' ), 'viewport' => array_map( 'strval', $viewport ), 'timestamp' => (string) microtime( true ), @@ -71,6 +74,7 @@ static function ( $value ) { 'bad_uuid' => array( 'data' => array( 'uuid' => 'foo', + 'etag' => md5( '' ), 'url' => home_url( '/' ), 'viewport' => $viewport, 'timestamp' => microtime( true ), @@ -78,9 +82,64 @@ static function ( $value ) { ), 'error' => 'OD_URL_Metric[uuid] is not a valid UUID.', ), + 'etag_too_short' => array( + 'data' => array( + 'uuid' => wp_generate_uuid4(), + 'etag' => 'foo', + 'url' => home_url( '/' ), + 'viewport' => $viewport, + 'timestamp' => microtime( true ), + 'elements' => array(), + ), + 'error' => 'OD_URL_Metric[etag] must be at least 32 characters long.', + ), + 'etag_too_long' => array( + 'data' => array( + 'uuid' => wp_generate_uuid4(), + 'etag' => 'd41d8cd98f00b204e9800998ecf8427e1', + 'url' => home_url( '/' ), + 'viewport' => $viewport, + 'timestamp' => microtime( true ), + 'elements' => array(), + ), + 'error' => 'OD_URL_Metric[etag] must be at most 32 characters long.', + ), + 'bad_etag1' => array( + 'data' => array( + 'uuid' => wp_generate_uuid4(), + 'etag' => 'd41d8cd98f00b204e9800998ecf8427$', + 'url' => home_url( '/' ), + 'viewport' => $viewport, + 'timestamp' => microtime( true ), + 'elements' => array(), + ), + 'error' => 'OD_URL_Metric[etag] does not match pattern ^[0-9a-f]{32}\z.', + ), + 'bad_etag2' => array( + 'data' => array( + 'uuid' => wp_generate_uuid4(), + 'etag' => md5( '' ) . "\n", + 'url' => home_url( '/' ), + 'viewport' => $viewport, + 'timestamp' => microtime( true ), + 'elements' => array(), + ), + 'error' => 'OD_URL_Metric[etag] must be at most 32 characters long.', + ), + 'missing_etag' => array( + 'data' => array( + 'uuid' => wp_generate_uuid4(), + 'url' => home_url( '/' ), + 'viewport' => $viewport, + 'timestamp' => microtime( true ), + 'elements' => array(), + ), + // Note: Add error message 'etag is a required property of OD_URL_Metric.' when 'etag' becomes mandatory. + ), 'missing_viewport' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), + 'etag' => md5( '' ), 'url' => home_url( '/' ), 'timestamp' => microtime( true ), 'elements' => array(), @@ -90,6 +149,7 @@ static function ( $value ) { 'missing_viewport_width' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), + 'etag' => md5( '' ), 'url' => home_url( '/' ), 'viewport' => array( 'height' => 640 ), 'timestamp' => microtime( true ), @@ -100,6 +160,7 @@ static function ( $value ) { 'bad_viewport' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), + 'etag' => md5( '' ), 'url' => home_url( '/' ), 'viewport' => array( 'height' => 'tall', @@ -113,6 +174,7 @@ static function ( $value ) { 'viewport_aspect_ratio_too_small' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), + 'etag' => md5( '' ), 'url' => home_url( '/' ), 'viewport' => array( 'width' => 1000, @@ -126,6 +188,7 @@ static function ( $value ) { 'viewport_aspect_ratio_too_large' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), + 'etag' => md5( '' ), 'url' => home_url( '/' ), 'viewport' => array( 'width' => 10000, @@ -139,6 +202,7 @@ static function ( $value ) { 'missing_timestamp' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), + 'etag' => md5( '' ), 'url' => home_url( '/' ), 'viewport' => $viewport, 'elements' => array(), @@ -148,6 +212,7 @@ static function ( $value ) { 'missing_elements' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), + 'etag' => md5( '' ), 'url' => home_url( '/' ), 'viewport' => $viewport, 'timestamp' => microtime( true ), @@ -157,6 +222,7 @@ static function ( $value ) { 'missing_url' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), + 'etag' => md5( '' ), 'viewport' => $viewport, 'timestamp' => microtime( true ), 'elements' => array(), @@ -166,6 +232,7 @@ static function ( $value ) { 'bad_elements' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), + 'etag' => md5( '' ), 'url' => home_url( '/' ), 'viewport' => $viewport, 'timestamp' => microtime( true ), @@ -180,6 +247,7 @@ static function ( $value ) { 'bad_intersection_width' => array( 'data' => array( 'uuid' => wp_generate_uuid4(), + 'etag' => md5( '' ), 'url' => home_url( '/' ), 'viewport' => $viewport, 'timestamp' => microtime( true ), @@ -202,9 +270,13 @@ static function ( $value ) { * @covers ::get_viewport_width * @covers ::get_timestamp * @covers ::get_elements + * @covers ::get_url + * @covers ::get_etag * @covers ::jsonSerialize * @covers ::get * @covers ::get_json_schema + * @covers ::set_group + * @covers ::get_group * * @dataProvider data_provider_to_test_constructor * @@ -217,6 +289,12 @@ public function test_constructor( array $data, string $error = '' ): void { $this->expectExceptionMessage( $error ); } $url_metric = new OD_URL_Metric( $data ); + $this->assertNull( $url_metric->get_group() ); + $current_etag = md5( '' ); + $collection = new OD_URL_Metric_Group_Collection( array(), $current_etag, array(), 1, DAY_IN_SECONDS ); + $group = $collection->get_first_group(); + $url_metric->set_group( $group ); + $this->assertSame( $group, $url_metric->get_group() ); $this->assertSame( array_map( 'intval', $data['viewport'] ), $url_metric->get_viewport() ); $this->assertSame( array_map( 'intval', $data['viewport'] ), $url_metric->get( 'viewport' ) ); @@ -233,11 +311,28 @@ public function test_constructor( array $data, string $error = '' ): void { $this->assertSame( array_map( 'floatval', $data['elements'][ $i ]['boundingClientRect'] ), $url_metric->get_elements()[ $i ]['boundingClientRect'] ); $this->assertSame( array_map( 'floatval', $data['elements'][ $i ]['intersectionRect'] ), $url_metric->get_elements()[ $i ]['intersectionRect'] ); } - $this->assertSame( $url_metric->get_elements(), $url_metric->get( 'elements' ) ); + $this->assertSame( + array_map( + static function ( OD_Element $element ) { + return $element->jsonSerialize(); + }, + $url_metric->get_elements() + ), + $this->get_array_json_data( $url_metric->get( 'elements' ) ) + ); $this->assertSame( $data['url'], $url_metric->get_url() ); $this->assertSame( $data['url'], $url_metric->get( 'url' ) ); + // Note: When the 'etag' field becomes required, the else statement can be removed. + if ( array_key_exists( 'etag', $data ) ) { + $this->assertSame( $data['etag'], $url_metric->get_etag() ); + $this->assertSame( $data['etag'], $url_metric->get( 'etag' ) ); + $this->assertTrue( 1 === preg_match( '/^[a-f0-9]{32}$/', $url_metric->get_etag() ) ); + } else { + $this->assertNull( $url_metric->get_etag() ); + } + $this->assertTrue( wp_is_uuid( $url_metric->get_uuid() ) ); $this->assertSame( $url_metric->get_uuid(), $url_metric->get( 'uuid' ) ); @@ -264,7 +359,7 @@ public function data_provider_to_test_constructor_with_extended_schema(): array $valid_element = array( 'isLCP' => true, 'isLCPCandidate' => true, - 'xpath' => '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]', + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', 'intersectionRatio' => 1.0, 'intersectionRect' => $this->get_sample_dom_rect(), 'boundingClientRect' => $this->get_sample_dom_rect(), @@ -612,7 +707,7 @@ static function ( $additional_properties ) { 'assert' => function ( array $original_schema, $extended_schema ): void { $this->assertSame( $original_schema, $extended_schema ); }, - 'expected_incorrect_usage' => 'Filter: 'od_url_metric_schema_root_additional_properties'', + 'expected_incorrect_usage' => 'Filter: 'od_url_metric_schema_element_item_additional_properties'', ), 'adding_root_string' => array( @@ -701,7 +796,8 @@ public function test_get_json_schema_extensibility( Closure $set_up, Closure $as */ protected function check_schema_subset( array $schema, string $path, bool $extended = false ): void { $this->assertArrayHasKey( 'required', $schema, $path ); - if ( ! $extended ) { + // Skipping the check for 'root/etag' as it is currently optional. + if ( ! $extended && 'root/etag' !== $path ) { $this->assertTrue( $schema['required'], $path ); } $this->assertArrayHasKey( 'type', $schema, $path ); diff --git a/plugins/optimization-detective/tests/test-class-od-url-metrics-group-collection.php b/plugins/optimization-detective/tests/test-class-od-url-metrics-group-collection.php index f221e2925d..ef05fc2ab4 100644 --- a/plugins/optimization-detective/tests/test-class-od-url-metrics-group-collection.php +++ b/plugins/optimization-detective/tests/test-class-od-url-metrics-group-collection.php @@ -19,9 +19,12 @@ class Test_OD_URL_Metric_Group_Collection extends WP_UnitTestCase { * @return array Data. */ public function data_provider_test_construction(): array { + $current_etag = md5( '' ); + return array( 'no_breakpoints_ok' => array( 'url_metrics' => array(), + 'current_etag' => $current_etag, 'breakpoints' => array(), 'sample_size' => 3, 'freshness_ttl' => HOUR_IN_SECONDS, @@ -29,6 +32,7 @@ public function data_provider_test_construction(): array { ), 'negative_breakpoint_bad' => array( 'url_metrics' => array(), + 'current_etag' => $current_etag, 'breakpoints' => array( -1 ), 'sample_size' => 3, 'freshness_ttl' => HOUR_IN_SECONDS, @@ -36,6 +40,7 @@ public function data_provider_test_construction(): array { ), 'zero_breakpoint_bad' => array( 'url_metrics' => array(), + 'current_etag' => $current_etag, 'breakpoints' => array( 0 ), 'sample_size' => 3, 'freshness_ttl' => HOUR_IN_SECONDS, @@ -43,6 +48,7 @@ public function data_provider_test_construction(): array { ), 'max_breakpoint_bad' => array( 'url_metrics' => array(), + 'current_etag' => $current_etag, 'breakpoints' => array( PHP_INT_MAX ), 'sample_size' => 3, 'freshness_ttl' => HOUR_IN_SECONDS, @@ -50,6 +56,7 @@ public function data_provider_test_construction(): array { ), 'string_breakpoint_bad' => array( 'url_metrics' => array(), + 'current_etag' => $current_etag, 'breakpoints' => array( 'narrow' ), 'sample_size' => 3, 'freshness_ttl' => HOUR_IN_SECONDS, @@ -57,6 +64,7 @@ public function data_provider_test_construction(): array { ), 'negative_sample_size_bad' => array( 'url_metrics' => array(), + 'current_etag' => $current_etag, 'breakpoints' => array( 400 ), 'sample_size' => -3, 'freshness_ttl' => HOUR_IN_SECONDS, @@ -64,13 +72,31 @@ public function data_provider_test_construction(): array { ), 'negative_freshness_tll_bad' => array( 'url_metrics' => array(), + 'current_etag' => $current_etag, 'breakpoints' => array( 400 ), 'sample_size' => 3, 'freshness_ttl' => -HOUR_IN_SECONDS, 'exception' => InvalidArgumentException::class, ), + 'invalid_current_etag_bad' => array( + 'url_metrics' => array(), + 'current_etag' => 'invalid_etag', + 'breakpoints' => array( 400 ), + 'sample_size' => 3, + 'freshness_ttl' => HOUR_IN_SECONDS, + 'exception' => InvalidArgumentException::class, + ), + 'invalid_current_etag_bad2' => array( + 'url_metrics' => array(), + 'current_etag' => md5( '' ) . PHP_EOL, // Note that /^[a-f0-9]{32}$/ would erroneously validate this. So the \z is required instead in /^[a-f0-9]{32}\z/. + 'breakpoints' => array( 400 ), + 'sample_size' => 3, + 'freshness_ttl' => HOUR_IN_SECONDS, + 'exception' => InvalidArgumentException::class, + ), 'invalid_url_metrics_bad' => array( 'url_metrics' => array( 'bad' ), + 'current_etag' => $current_etag, 'breakpoints' => array( 400 ), 'sample_size' => 3, 'freshness_ttl' => HOUR_IN_SECONDS, @@ -81,6 +107,7 @@ public function data_provider_test_construction(): array { $this->get_sample_url_metric( array( 'viewport_width' => 200 ) ), $this->get_sample_url_metric( array( 'viewport_width' => 400 ) ), ), + 'current_etag' => $current_etag, 'breakpoints' => array( 400 ), 'sample_size' => 3, 'freshness_ttl' => HOUR_IN_SECONDS, @@ -94,15 +121,18 @@ public function data_provider_test_construction(): array { * * @dataProvider data_provider_test_construction * - * @param OD_URL_Metric[] $url_metrics URL Metrics. - * @param int[] $breakpoints Breakpoints. - * @param int $sample_size Sample size. + * @param OD_URL_Metric[] $url_metrics URL Metrics. + * @param non-empty-string $current_etag Current ETag. + * @param int[] $breakpoints Breakpoints. + * @param int $sample_size Sample size. + * @param int $freshness_ttl Freshness TTL. + * @param string $exception Expected exception. */ - public function test_construction( array $url_metrics, array $breakpoints, int $sample_size, int $freshness_ttl, string $exception ): void { + public function test_construction( array $url_metrics, string $current_etag, array $breakpoints, int $sample_size, int $freshness_ttl, string $exception ): void { if ( '' !== $exception ) { $this->expectException( $exception ); } - $group_collection = new OD_URL_Metric_Group_Collection( $url_metrics, $breakpoints, $sample_size, $freshness_ttl ); + $group_collection = new OD_URL_Metric_Group_Collection( $url_metrics, $current_etag, $breakpoints, $sample_size, $freshness_ttl ); $this->assertCount( count( $breakpoints ) + 1, $group_collection ); } @@ -169,6 +199,40 @@ public function data_provider_sample_size_and_breakpoints(): array { ); } + /** + * Test clear_cache(). + * + * @covers ::clear_cache + * @covers OD_URL_Metric_Group::clear_cache + */ + public function test_clear_cache(): void { + $collection = new OD_URL_Metric_Group_Collection( array(), md5( '' ), array(), 1, DAY_IN_SECONDS ); + $populated_value = array( 'foo' => true ); + $group = $collection->get_first_group(); + + // Get private members. + $collection_result_cache_reflection_property = new ReflectionProperty( OD_URL_Metric_Group_Collection::class, 'result_cache' ); + $collection_result_cache_reflection_property->setAccessible( true ); + $this->assertSame( array(), $collection_result_cache_reflection_property->getValue( $collection ) ); + $group_result_cache_reflection_property = new ReflectionProperty( OD_URL_Metric_Group::class, 'result_cache' ); + $group_result_cache_reflection_property->setAccessible( true ); + $this->assertSame( array(), $group_result_cache_reflection_property->getValue( $group ) ); + + // Test clear_cache() on collection. + $collection_result_cache_reflection_property->setValue( $collection, $populated_value ); + $collection->clear_cache(); + $this->assertSame( array(), $collection_result_cache_reflection_property->getValue( $collection ) ); + + // Test that adding a URL metric to a collection clears the caches. + $collection_result_cache_reflection_property->setValue( $collection, $populated_value ); + $group_result_cache_reflection_property->setValue( $group, $populated_value ); + $collection->add_url_metric( $this->get_sample_url_metric( array() ) ); + $url_metric = $group->getIterator()->current(); + $this->assertInstanceOf( OD_URL_Metric::class, $url_metric ); + $this->assertSame( array(), $collection_result_cache_reflection_property->getValue( $collection ) ); + $this->assertSame( array(), $group_result_cache_reflection_property->getValue( $group ) ); + } + /** * Test add_url_metric(). * @@ -176,13 +240,14 @@ public function data_provider_sample_size_and_breakpoints(): array { * * @param int $sample_size Sample size. * @param int[] $breakpoints Breakpoints. - * @param array $viewport_widths Viewport widths mapped to the number of URL metrics to instantiate. + * @param array $viewport_widths Viewport widths mapped to the number of URL Metrics to instantiate. * @param array $expected_counts Minimum viewport widths mapped to the expected counts in each group. * * @dataProvider data_provider_sample_size_and_breakpoints */ public function test_add_url_metric( int $sample_size, array $breakpoints, array $viewport_widths, array $expected_counts ): void { - $group_collection = new OD_URL_Metric_Group_Collection( array(), $breakpoints, $sample_size, HOUR_IN_SECONDS ); + $current_etag = md5( '' ); + $group_collection = new OD_URL_Metric_Group_Collection( array(), $current_etag, $breakpoints, $sample_size, HOUR_IN_SECONDS ); // Over-populate the sample size for the breakpoints by a dozen. foreach ( $viewport_widths as $viewport_width => $count ) { @@ -210,11 +275,12 @@ public function test_add_url_metric( int $sample_size, array $breakpoints, array * @covers ::add_url_metric */ public function test_adding_pushes_out_old_metrics(): void { + $current_etag = md5( '' ); $sample_size = 3; $breakpoints = array( 400, 600 ); - $group_collection = new OD_URL_Metric_Group_Collection( array(), $breakpoints, $sample_size, HOUR_IN_SECONDS ); + $group_collection = new OD_URL_Metric_Group_Collection( array(), $current_etag, $breakpoints, $sample_size, HOUR_IN_SECONDS ); - // Populate the groups with stale URL metrics. + // Populate the groups with stale URL Metrics. $viewport_widths = array( 300, 500, 700 ); $old_timestamp = microtime( true ) - ( HOUR_IN_SECONDS + 1 ); @@ -233,7 +299,7 @@ public function test_adding_pushes_out_old_metrics(): void { } } - // Try adding one URL metric for each breakpoint group. + // Try adding one URL Metric for each breakpoint group. foreach ( $viewport_widths as $viewport_width ) { $group_collection->add_url_metric( $this->get_sample_url_metric( array( 'viewport_width' => $viewport_width ) ) ); } @@ -242,7 +308,7 @@ public function test_adding_pushes_out_old_metrics(): void { $this->assertCount( $max_possible_url_metrics_count, $group_collection->get_flattened_url_metrics(), - 'Expected the total count of URL metrics to not exceed the multiple of the sample size.' + 'Expected the total count of URL Metrics to not exceed the multiple of the sample size.' ); $new_count = 0; foreach ( $group_collection->get_flattened_url_metrics() as $url_metric ) { @@ -250,8 +316,8 @@ public function test_adding_pushes_out_old_metrics(): void { ++$new_count; } } - $this->assertGreaterThan( 0, $new_count, 'Expected there to be at least one new URL metric.' ); - $this->assertSame( count( $viewport_widths ), $new_count, 'Expected the new URL metrics to all have been added.' ); + $this->assertGreaterThan( 0, $new_count, 'Expected there to be at least one new URL Metric.' ); + $this->assertSame( count( $viewport_widths ), $new_count, 'Expected the new URL Metrics to all have been added.' ); } /** @@ -298,13 +364,26 @@ public function data_provider_test_get_iterator(): array { ), ), ), + '0-breakpoints-and-4-viewport-widths' => array( + 'breakpoints' => array(), + 'viewport_widths' => array( 250, 500, 1000 ), + 'expected_groups' => array( + array( + 'minimum_viewport_width' => 0, + 'maximum_viewport_width' => PHP_INT_MAX, + 'url_metric_viewport_widths' => array( 250, 500, 1000 ), + ), + ), + ), ); } /** - * Test getIterator(). + * Test getIterator(), get_first_group(), and get_last_group(). * * @covers ::getIterator + * @covers ::get_first_group + * @covers ::get_last_group * * @dataProvider data_provider_test_get_iterator * @@ -320,7 +399,8 @@ function ( $viewport_width ) { $viewport_widths ); - $group_collection = new OD_URL_Metric_Group_Collection( $url_metrics, $breakpoints, 3, HOUR_IN_SECONDS ); + $current_etag = md5( '' ); + $group_collection = new OD_URL_Metric_Group_Collection( $url_metrics, $current_etag, $breakpoints, 3, HOUR_IN_SECONDS ); $this->assertCount( count( $breakpoints ) + 1, @@ -343,6 +423,10 @@ static function ( OD_URL_Metric $url_metric ): int { } $this->assertEquals( $expected_groups, $actual_groups ); + + $groups_array = iterator_to_array( $group_collection ); + $this->assertSame( $groups_array[0], $group_collection->get_first_group() ); + $this->assertSame( $groups_array[ count( $groups_array ) - 1 ], $group_collection->get_last_group() ); } /** @@ -352,16 +436,22 @@ static function ( OD_URL_Metric $url_metric ): int { */ public function data_provider_test_get_group_for_viewport_width(): array { $current_time = microtime( true ); + $current_etag = md5( '' ); $none_needed_data = array( - 'url_metrics' => ( function () use ( $current_time ): array { + 'url_metrics' => ( function () use ( $current_time, $current_etag ): array { return array_merge( array_fill( 0, 3, new OD_URL_Metric( array_merge( - $this->get_sample_url_metric( array( 'viewport_width' => 400 ) )->jsonSerialize(), + $this->get_sample_url_metric( + array( + 'viewport_width' => 400, + 'etag' => $current_etag, + ) + )->jsonSerialize(), array( 'timestamp' => $current_time ) ) ) @@ -371,7 +461,12 @@ public function data_provider_test_get_group_for_viewport_width(): array { 3, new OD_URL_Metric( array_merge( - $this->get_sample_url_metric( array( 'viewport_width' => 600 ) )->jsonSerialize(), + $this->get_sample_url_metric( + array( + 'viewport_width' => 600, + 'etag' => $current_etag, + ) + )->jsonSerialize(), array( 'timestamp' => $current_time ) ) ) @@ -379,6 +474,7 @@ public function data_provider_test_get_group_for_viewport_width(): array { ); } )(), 'current_time' => $current_time, + 'current_etag' => $current_etag, 'breakpoints' => array( 480 ), 'sample_size' => 3, 'freshness_ttl' => HOUR_IN_SECONDS, @@ -458,6 +554,34 @@ public function data_provider_test_get_group_for_viewport_width(): array { ), ) ), + + 'url-metric-stale-etag' => array_merge( + ( static function ( $data ): array { + $url_metrics_data = $data['url_metrics'][ count( $data['url_metrics'] ) - 1 ]->jsonSerialize(); + $url_metrics_data['etag'] = md5( 'something new!' ); + $data['url_metrics'][ count( $data['url_metrics'] ) - 1 ] = new OD_URL_Metric( $url_metrics_data ); + return $data; + } )( $none_needed_data ), + array( + 'expected_return' => array( + array( + 'minimumViewportWidth' => 0, + 'complete' => true, + ), + array( + 'minimumViewportWidth' => 481, + 'complete' => false, + ), + ), + 'expected_is_group_complete' => array( + 200 => true, + 400 => true, + 480 => true, + 481 => false, + 500 => false, + ), + ) + ), ); } @@ -473,14 +597,15 @@ public function data_provider_test_get_group_for_viewport_width(): array { * * @param OD_URL_Metric[] $url_metrics URL Metrics. * @param float $current_time Current time. + * @param non-empty-string $current_etag Current ETag. * @param int[] $breakpoints Breakpoints. * @param int $sample_size Sample size. * @param int $freshness_ttl Freshness TTL. * @param array $expected_return Expected return. * @param array $expected_is_group_complete Expected is group complete. */ - public function test_get_group_for_viewport_width( array $url_metrics, float $current_time, array $breakpoints, int $sample_size, int $freshness_ttl, array $expected_return, array $expected_is_group_complete ): void { - $group_collection = new OD_URL_Metric_Group_Collection( $url_metrics, $breakpoints, $sample_size, $freshness_ttl ); + public function test_get_group_for_viewport_width( array $url_metrics, float $current_time, string $current_etag, array $breakpoints, int $sample_size, int $freshness_ttl, array $expected_return, array $expected_is_group_complete ): void { + $group_collection = new OD_URL_Metric_Group_Collection( $url_metrics, $current_etag, $breakpoints, $sample_size, $freshness_ttl ); $this->assertSame( $expected_return, array_map( @@ -512,28 +637,58 @@ static function ( OD_URL_Metric_Group $group ): array { public function test_is_every_group_populated(): void { $breakpoints = array( 480, 800 ); $sample_size = 3; + $current_etag = md5( '' ); $group_collection = new OD_URL_Metric_Group_Collection( array(), + $current_etag, $breakpoints, $sample_size, HOUR_IN_SECONDS ); $this->assertFalse( $group_collection->is_every_group_populated() ); $this->assertFalse( $group_collection->is_every_group_complete() ); - $group_collection->add_url_metric( $this->get_sample_url_metric( array( 'viewport_width' => 200 ) ) ); + $group_collection->add_url_metric( + $this->get_sample_url_metric( + array( + 'viewport_width' => 200, + 'etag' => $current_etag, + ) + ) + ); $this->assertFalse( $group_collection->is_every_group_populated() ); $this->assertFalse( $group_collection->is_every_group_complete() ); - $group_collection->add_url_metric( $this->get_sample_url_metric( array( 'viewport_width' => 500 ) ) ); + $group_collection->add_url_metric( + $this->get_sample_url_metric( + array( + 'viewport_width' => 500, + 'etag' => $current_etag, + ) + ) + ); $this->assertFalse( $group_collection->is_every_group_populated() ); $this->assertFalse( $group_collection->is_every_group_complete() ); - $group_collection->add_url_metric( $this->get_sample_url_metric( array( 'viewport_width' => 900 ) ) ); + $group_collection->add_url_metric( + $this->get_sample_url_metric( + array( + 'viewport_width' => 900, + 'etag' => $current_etag, + ) + ) + ); $this->assertTrue( $group_collection->is_every_group_populated() ); $this->assertFalse( $group_collection->is_every_group_complete() ); // Now finish completing all the groups. foreach ( array_merge( $breakpoints, array( 1000 ) ) as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { - $group_collection->add_url_metric( $this->get_sample_url_metric( array( 'viewport_width' => $viewport_width ) ) ); + $group_collection->add_url_metric( + $this->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'etag' => $current_etag, + ) + ) + ); } } $this->assertTrue( $group_collection->is_every_group_complete() ); @@ -547,9 +702,9 @@ public function test_is_every_group_populated(): void { */ public function test_get_groups_by_lcp_element(): void { - $first_child_image_xpath = '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]/*[1]'; - $second_child_image_xpath = '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]/*[2]'; - $first_child_h1_xpath = '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::H1]/*[1]'; + $first_child_image_xpath = '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]'; + $second_child_image_xpath = '/*[1][self::HTML]/*[2][self::BODY]/*[2][self::IMG]'; + $first_child_h1_xpath = '/*[1][self::HTML]/*21][self::BODY]/*[1][self::H1]'; $get_url_metric_with_one_lcp_element = function ( int $viewport_width, string $lcp_element_xpath ): OD_URL_Metric { return $this->get_sample_url_metric( @@ -565,6 +720,7 @@ public function test_get_groups_by_lcp_element(): void { $breakpoints = array( 480, 800 ); $sample_size = 3; + $current_etag = md5( '' ); $group_collection = new OD_URL_Metric_Group_Collection( array( // Group 1: 0-480 viewport widths. @@ -577,6 +733,7 @@ public function test_get_groups_by_lcp_element(): void { $get_url_metric_with_one_lcp_element( 820, $first_child_image_xpath ), $get_url_metric_with_one_lcp_element( 900, $first_child_image_xpath ), ), + $current_etag, $breakpoints, $sample_size, HOUR_IN_SECONDS @@ -598,43 +755,119 @@ public function test_get_groups_by_lcp_element(): void { $this->assertNull( $group_collection->get_common_lcp_element() ); } + /** + * Data provider. + * + * @return array + */ + public function data_provider_test_get_common_lcp_element(): array { + $xpath1 = '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]/*[1]'; + $xpath2 = '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]/*[2]'; + + $get_sample_url_metric = function ( int $viewport_width, string $lcp_element_xpath, bool $is_lcp = true ): OD_URL_Metric { + return $this->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'element' => array( + 'isLCP' => $is_lcp, + 'xpath' => $lcp_element_xpath, + ), + ) + ); + }; + + return array( + 'all_groups_have_common_lcp' => array( + 'url_metrics' => array( + $get_sample_url_metric( 400, $xpath1 ), + $get_sample_url_metric( 600, $xpath1 ), + $get_sample_url_metric( 1000, $xpath1 ), + ), + 'expected' => $xpath1, + ), + 'no_url_metrics' => array( + 'url_metrics' => array(), + 'expected' => null, + ), + 'empty_first_group' => array( + 'url_metrics' => array( + $get_sample_url_metric( 600, $xpath1 ), + $get_sample_url_metric( 1000, $xpath1 ), + ), + 'expected' => null, + ), + 'empty_last_group' => array( + 'url_metrics' => array( + $get_sample_url_metric( 400, $xpath1 ), + $get_sample_url_metric( 600, $xpath1 ), + ), + 'expected' => null, + ), + 'first_and_last_common_lcp_others_empty' => array( + 'url_metrics' => array( + $get_sample_url_metric( 400, $xpath1 ), + $get_sample_url_metric( 1000, $xpath1 ), + ), + 'expected' => $xpath1, + ), + 'intermediate_groups_conflict' => array( + 'url_metrics' => array( + $get_sample_url_metric( 400, $xpath1 ), + $get_sample_url_metric( 600, $xpath2 ), + $get_sample_url_metric( 1000, $xpath1 ), + ), + 'expected' => null, + ), + 'first_and_last_lcp_mismatch' => array( + 'url_metrics' => array( + $get_sample_url_metric( 400, $xpath1 ), + $get_sample_url_metric( 600, $xpath1 ), + $get_sample_url_metric( 1000, $xpath2 ), + ), + 'expected' => null, + ), + 'no_lcp_metrics' => array( + 'url_metrics' => array( + $get_sample_url_metric( 400, $xpath1, false ), + $get_sample_url_metric( 600, $xpath1, false ), + $get_sample_url_metric( 1000, $xpath1, false ), + ), + 'expected' => null, + ), + ); + } + /** * Test get_common_lcp_element(). * * @covers ::get_common_lcp_element + * + * @dataProvider data_provider_test_get_common_lcp_element + * + * @param OD_URL_Metric[] $url_metrics URL Metrics. + * @param string|null $expected Expected. */ - public function test_get_common_lcp_element(): void { + public function test_get_common_lcp_element( array $url_metrics, ?string $expected ): void { $breakpoints = array( 480, 800 ); $sample_size = 3; + $current_etag = md5( '' ); $group_collection = new OD_URL_Metric_Group_Collection( - array(), + $url_metrics, + $current_etag, $breakpoints, $sample_size, HOUR_IN_SECONDS ); - $lcp_element_xpath = '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]/*[1]'; - - foreach ( array_merge( $breakpoints, array( 1000 ) ) as $viewport_width ) { - for ( $i = 0; $i < $sample_size; $i++ ) { - $group_collection->add_url_metric( - $this->get_sample_url_metric( - array( - 'viewport_width' => $viewport_width, - 'element' => array( - 'isLCP' => true, - 'xpath' => $lcp_element_xpath, - ), - ) - ) - ); - } - } - $this->assertCount( 3, $group_collection ); + $common_lcp_element = $group_collection->get_common_lcp_element(); - $this->assertIsArray( $common_lcp_element ); - $this->assertSame( $lcp_element_xpath, $common_lcp_element['xpath'] ); + if ( is_string( $expected ) ) { + $this->assertInstanceOf( OD_Element::class, $common_lcp_element ); + $this->assertSame( $expected, $common_lcp_element->get_xpath() ); + } else { + $this->assertNull( $common_lcp_element ); + } } /** @@ -643,9 +876,9 @@ public function test_get_common_lcp_element(): void { * @return array */ public function data_provider_element_max_intersection_ratios(): array { - $xpath1 = '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]/*[1]'; - $xpath2 = '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]/*[2]'; - $xpath3 = '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]/*[3]'; + $xpath1 = '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]/*[1]'; + $xpath2 = '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]/*[2]'; + $xpath3 = '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]/*[3]'; $get_sample_url_metric = function ( int $viewport_width, string $lcp_element_xpath, float $intersection_ratio ): OD_URL_Metric { return $this->get_sample_url_metric( @@ -661,7 +894,15 @@ public function data_provider_element_max_intersection_ratios(): array { }; return array( - 'one-element-sample-size-one' => array( + 'one-element-one-group' => array( + 'url_metrics' => array( + $get_sample_url_metric( 600, $xpath1, 0.5 ), + ), + 'expected' => array( + $xpath1 => 0.5, + ), + ), + 'one-element-three-groups-of-one' => array( 'url_metrics' => array( $get_sample_url_metric( 400, $xpath1, 0.0 ), $get_sample_url_metric( 600, $xpath1, 0.5 ), @@ -671,7 +912,7 @@ public function data_provider_element_max_intersection_ratios(): array { $xpath1 => 1.0, ), ), - 'three-elements-sample-size-two' => array( + 'three-elements-sample-size-two' => array( 'url_metrics' => array( // Group 1. $get_sample_url_metric( 400, $xpath1, 0.0 ), @@ -689,7 +930,7 @@ public function data_provider_element_max_intersection_ratios(): array { $xpath3 => 0.6, ), ), - 'no-url-metrics' => array( + 'no-url-metrics' => array( 'url_metrics' => array(), 'expected' => array(), ), @@ -698,26 +939,207 @@ public function data_provider_element_max_intersection_ratios(): array { } /** - * Test get_all_element_max_intersection_ratios() and get_element_max_intersection_ratio(). + * Test get_all_element_max_intersection_ratios(), get_element_max_intersection_ratio(), and get_all_denormalized_elements(). * * @covers ::get_all_element_max_intersection_ratios * @covers ::get_element_max_intersection_ratio + * @covers ::get_xpath_elements_map * * @dataProvider data_provider_element_max_intersection_ratios * - * @param array $url_metrics URL metrics. + * @param array $url_metrics URL Metrics. * @param array $expected Expected. */ public function test_get_all_element_max_intersection_ratios( array $url_metrics, array $expected ): void { + $current_etag = md5( '' ); $breakpoints = array( 480, 600, 782 ); $sample_size = 3; - $group_collection = new OD_URL_Metric_Group_Collection( $url_metrics, $breakpoints, $sample_size, 0 ); + $group_collection = new OD_URL_Metric_Group_Collection( $url_metrics, $current_etag, $breakpoints, $sample_size, 0 ); $actual = $group_collection->get_all_element_max_intersection_ratios(); $this->assertSame( $actual, $group_collection->get_all_element_max_intersection_ratios(), 'Cached result is identical.' ); $this->assertSame( $expected, $actual ); foreach ( $expected as $expected_xpath => $expected_max_ratio ) { $this->assertSame( $expected_max_ratio, $group_collection->get_element_max_intersection_ratio( $expected_xpath ) ); } + + $this->assertNull( $group_collection->get_element_max_intersection_ratio( '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::BLINK]/*[1]' ) ); + + // Check get_all_denormalized_elements. + $all_elements = $group_collection->get_xpath_elements_map(); + $xpath_counts = array(); + foreach ( $url_metrics as $url_metric ) { + foreach ( $url_metric->get_elements() as $element ) { + if ( ! isset( $xpath_counts[ $element['xpath'] ] ) ) { + $xpath_counts[ $element['xpath'] ] = 0; + } + $xpath_counts[ $element['xpath'] ] += 1; + } + } + $this->assertCount( count( $xpath_counts ), $all_elements ); + foreach ( $all_elements as $xpath => $elements ) { + foreach ( $elements as $element ) { + $this->assertSame( $element->get_url_metric()->get_group(), $element->get_url_metric_group() ); + $this->assertInstanceOf( OD_Element::class, $element ); + $this->assertSame( $xpath, $element['xpath'] ); + } + } + } + + /** + * Data provider. + * + * @return array + */ + public function data_provider_element_minimum_heights(): array { + $xpath1 = '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]/*[1]'; + $xpath2 = '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]/*[2]'; + $xpath3 = '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]/*[3]'; + + $get_sample_url_metric = function ( int $viewport_width, string $lcp_element_xpath, float $element_height ): OD_URL_Metric { + return $this->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'element' => array( + 'isLCP' => true, + 'xpath' => $lcp_element_xpath, + 'intersectionRect' => array_merge( + $this->get_sample_dom_rect(), + array( 'height' => $element_height ) + ), + 'boundingClientRect' => array_merge( + $this->get_sample_dom_rect(), + array( 'height' => $element_height ) + ), + ), + ) + ); + }; + + return array( + 'one-element-sample-size-one' => array( + 'url_metrics' => array( + $get_sample_url_metric( 400, $xpath1, 480 ), + $get_sample_url_metric( 600, $xpath1, 240 ), + $get_sample_url_metric( 800, $xpath1, 768 ), + ), + 'expected' => array( + $xpath1 => 240.0, + ), + ), + 'three-elements-sample-size-two' => array( + 'url_metrics' => array( + // Group 1. + $get_sample_url_metric( 400, $xpath1, 400 ), + $get_sample_url_metric( 400, $xpath1, 600 ), + // Group 2. + $get_sample_url_metric( 600, $xpath2, 100.1 ), + $get_sample_url_metric( 600, $xpath2, 100.2 ), + $get_sample_url_metric( 600, $xpath2, 100.05 ), + // Group 3. + $get_sample_url_metric( 800, $xpath3, 500 ), + $get_sample_url_metric( 800, $xpath3, 500 ), + ), + 'expected' => array( + $xpath1 => 400.0, + $xpath2 => 100.05, + $xpath3 => 500.0, + ), + ), + 'no-url-metrics' => array( + 'url_metrics' => array(), + 'expected' => array(), + ), + + ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_provider_get_all_elements_positioned_in_any_initial_viewport(): array { + $xpath1 = '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]/*[1]'; + $xpath2 = '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::IMG]/*[2]'; + + $get_sample_url_metric = function ( int $viewport_width, int $viewport_height, string $xpath, float $intersection_ratio, float $top ): OD_URL_Metric { + return $this->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'viewport_height' => $viewport_height, + 'element' => array( + 'isLCP' => false, + 'xpath' => $xpath, + 'intersectionRatio' => $intersection_ratio, + 'intersectionRect' => array_merge( + $this->get_sample_dom_rect(), + array( 'top' => $top ) + ), + 'boundingClientRect' => array_merge( + $this->get_sample_dom_rect(), + array( 'top' => $top ) + ), + ), + ) + ); + }; + + return array( + 'element-inside-viewport' => array( + 'url_metrics' => array( + $get_sample_url_metric( 360, 640, $xpath1, 1.0, 0 ), + $get_sample_url_metric( 360, 640, $xpath1, 1.0, 100 ), + $get_sample_url_metric( 360, 640, $xpath1, 1.0, 639 ), + ), + 'expected' => array( + $xpath1 => true, + ), + ), + 'element-outside-viewport' => array( + 'url_metrics' => array( + $get_sample_url_metric( 360, 640, $xpath1, 0.0, 640 ), + $get_sample_url_metric( 360, 640, $xpath1, 0.0, 641 ), + ), + 'expected' => array( + $xpath1 => false, + ), + ), + 'two-elements-inside-and-outside-viewport' => array( + 'url_metrics' => array( + $get_sample_url_metric( 360, 640, $xpath1, 1.0, 100 ), + $get_sample_url_metric( 360, 640, $xpath2, 0.0, 1000 ), + ), + 'expected' => array( + $xpath1 => true, + $xpath2 => false, + ), + ), + ); + } + + /** + * Test get_all_elements_positioned_in_any_initial_viewport() and is_element_positioned_in_any_initial_viewport(). + * + * @covers ::get_all_elements_positioned_in_any_initial_viewport + * @covers ::is_element_positioned_in_any_initial_viewport + * + * @dataProvider data_provider_get_all_elements_positioned_in_any_initial_viewport + * + * @param array $url_metrics URL Metrics. + * @param array $expected Expected. + */ + public function test_get_all_elements_positioned_in_any_initial_viewport( array $url_metrics, array $expected ): void { + $current_etag = md5( '' ); + $breakpoints = array( 480, 600, 782 ); + $sample_size = 3; + $group_collection = new OD_URL_Metric_Group_Collection( $url_metrics, $current_etag, $breakpoints, $sample_size, 0 ); + $actual = $group_collection->get_all_elements_positioned_in_any_initial_viewport(); + $this->assertSame( $actual, $group_collection->get_all_elements_positioned_in_any_initial_viewport(), 'Cached result is identical.' ); + $this->assertSame( $expected, $actual ); + foreach ( $expected as $expected_xpath => $expected_is_positioned ) { + $this->assertSame( $expected_is_positioned, $group_collection->is_element_positioned_in_any_initial_viewport( $expected_xpath ) ); + } + $this->assertNull( $group_collection->is_element_positioned_in_any_initial_viewport( '/*[0][self::HTML]/*[1][self::BODY]/*[0][self::BLINK]/*[1]' ) ); } /** @@ -734,6 +1156,7 @@ public function test_get_flattened_url_metrics(): void { $group_collection = new OD_URL_Metric_Group_Collection( $url_metrics, + md5( '' ), array( 500, 700 ), 3, HOUR_IN_SECONDS @@ -761,6 +1184,7 @@ public function test_json_serialize(): void { $group_collection = new OD_URL_Metric_Group_Collection( $url_metrics, + md5( '' ), array( 500, 700 ), 3, HOUR_IN_SECONDS @@ -769,6 +1193,7 @@ public function test_json_serialize(): void { $json = wp_json_encode( $group_collection ); $parsed_json = json_decode( $json, true ); $expected_keys = array( + 'current_etag', 'breakpoints', 'freshness_ttl', 'sample_size', diff --git a/plugins/optimization-detective/tests/test-class-od-url-metrics-group.php b/plugins/optimization-detective/tests/test-class-od-url-metrics-group.php index ef59513515..2eee0717a9 100644 --- a/plugins/optimization-detective/tests/test-class-od-url-metrics-group.php +++ b/plugins/optimization-detective/tests/test-class-od-url-metrics-group.php @@ -95,6 +95,8 @@ public function data_provider_test_construction(): array { * @covers ::__construct * @covers ::get_minimum_viewport_width * @covers ::get_maximum_viewport_width + * @covers ::get_sample_size + * @covers ::get_freshness_ttl * @covers ::getIterator * @covers ::count * @@ -111,11 +113,18 @@ public function test_construction( array $url_metrics, int $minimum_viewport_wid if ( '' !== $exception ) { $this->expectException( $exception ); } - $group = new OD_URL_Metric_Group( $url_metrics, $minimum_viewport_width, $maximum_viewport_width, $sample_size, $freshness_ttl ); + + // This is not example usage for how a group should be constructed. Normally, a group is only ever constructed + // by OD_URL_Metric_Group_Collection when the collection is constructed. The OD_URL_Metric_Group is being + // constructed here just for the sake of testing. + $dummy_collection = new OD_URL_Metric_Group_Collection( array(), md5( '' ), array(), 1, DAY_IN_SECONDS ); + $group = new OD_URL_Metric_Group( $url_metrics, $minimum_viewport_width, $maximum_viewport_width, $sample_size, $freshness_ttl, $dummy_collection ); $this->assertCount( count( $url_metrics ), $group ); $this->assertSame( $minimum_viewport_width, $group->get_minimum_viewport_width() ); $this->assertSame( $maximum_viewport_width, $group->get_maximum_viewport_width() ); + $this->assertSame( $sample_size, $group->get_sample_size() ); + $this->assertSame( $freshness_ttl, $group->get_freshness_ttl() ); $this->assertCount( count( $url_metrics ), $group ); $this->assertSame( $url_metrics, iterator_to_array( $group ) ); } @@ -128,8 +137,8 @@ public function test_construction( array $url_metrics, int $minimum_viewport_wid public function data_provider_test_is_viewport_width_in_range(): array { return array( '0-10' => array( - 'minimum_viewport_width' => 0, - 'maximum_viewport_width' => 10, + 'breakpoints' => array( 10 ), + 'group_index' => 0, 'viewport_widths_expected' => array( 0 => true, 1 => true, @@ -139,8 +148,8 @@ public function data_provider_test_is_viewport_width_in_range(): array { ), ), '100-200' => array( - 'minimum_viewport_width' => 100, - 'maximum_viewport_width' => 200, + 'breakpoints' => array( 99, 200 ), + 'group_index' => 1, 'viewport_widths_expected' => array( 0 => false, 99 => false, @@ -160,12 +169,22 @@ public function data_provider_test_is_viewport_width_in_range(): array { * * @dataProvider data_provider_test_is_viewport_width_in_range * - * @param int $minimum_viewport_width Minimum viewport width. - * @param int $maximum_viewport_width Maximum viewport width. + * @param int[] $breakpoints Breakpoints. + * @param int $group_index Group index. * @param array $viewport_widths_expected Viewport widths expected. */ - public function test_is_viewport_width_in_range( int $minimum_viewport_width, int $maximum_viewport_width, array $viewport_widths_expected ): void { - $group = new OD_URL_Metric_Group( array(), $minimum_viewport_width, $maximum_viewport_width, 3, HOUR_IN_SECONDS ); + public function test_is_viewport_width_in_range( array $breakpoints, int $group_index, array $viewport_widths_expected ): void { + $collection = new OD_URL_Metric_Group_Collection( + array(), + md5( '' ), + $breakpoints, + 3, + HOUR_IN_SECONDS + ); + $groups = iterator_to_array( $collection ); + $this->assertArrayHasKey( $group_index, $groups ); + $group = $groups[ $group_index ]; + $this->assertInstanceOf( OD_URL_Metric_Group::class, $group ); foreach ( $viewport_widths_expected as $viewport_width => $expected ) { $this->assertSame( $expected, $group->is_viewport_width_in_range( $viewport_width ), "Failed for viewport width of $viewport_width" ); } @@ -199,13 +218,19 @@ public function test_add_url_metric( int $viewport_width, string $exception ): v if ( '' !== $exception ) { $this->expectException( $exception ); } - $group = new OD_URL_Metric_Group( array(), 480, 799, 1, HOUR_IN_SECONDS ); + + $etag = md5( '' ); + $collection = new OD_URL_Metric_Group_Collection( array(), $etag, array( 480, 800 ), 1, HOUR_IN_SECONDS ); + $groups = iterator_to_array( $collection ); + $this->assertCount( 3, $groups ); + $group = $groups[1]; $this->assertFalse( $group->is_complete() ); $group->add_url_metric( new OD_URL_Metric( array( 'url' => home_url( '/' ), + 'etag' => $etag, 'viewport' => array( 'width' => $viewport_width, 'height' => ceil( $viewport_width / 2 ), @@ -221,6 +246,65 @@ public function test_add_url_metric( int $viewport_width, string $exception ): v $this->assertTrue( $group->is_complete() ); } + /** + * Data provider. + * + * @return array Data. + */ + public function data_provider_test_is_complete(): array { + // Note: Test cases for empty URL Metrics and for exact sample size are already covered in the test_add_url_metric() method. + return array( + 'old_url_metric' => array( + 'url_metric' => $this->get_sample_url_metric( + array( + 'timestamp' => microtime( true ) - ( HOUR_IN_SECONDS + 1 ), + 'etag' => md5( '' ), + ) + ), + 'expected_is_group_complete' => false, + ), + // Note: The following test case will not be required once the ETag is mandatory in a future release. + 'etag_missing' => array( + 'url_metric' => new OD_URL_Metric( + array( + 'url' => home_url( '/' ), + 'viewport' => array( + 'width' => 400, + 'height' => 700, + ), + 'timestamp' => microtime( true ), + 'elements' => array(), + ) + ), + 'expected_is_group_complete' => false, + ), + 'etag_mismatch' => array( + 'url_metric' => $this->get_sample_url_metric( array( 'etag' => md5( 'different_etag' ) ) ), + 'expected_is_group_complete' => false, + ), + 'etag_match' => array( + 'url_metric' => $this->get_sample_url_metric( array( 'etag' => md5( '' ) ) ), + 'expected_is_group_complete' => true, + ), + ); + } + + /** + * Test is_complete(). + * + * @covers ::is_complete + * + * @dataProvider data_provider_test_is_complete + */ + public function test_is_complete( OD_URL_Metric $url_metric, bool $expected_is_group_complete ): void { + $collection = new OD_URL_Metric_Group_Collection( array(), md5( '' ), array( 768 ), 1, HOUR_IN_SECONDS ); + $group = $collection->get_first_group(); + + $group->add_url_metric( $url_metric ); + + $this->assertSame( $expected_is_group_complete, $group->is_complete() ); + } + /** * Data provider. * @@ -325,7 +409,8 @@ public function data_provider_test_get_lcp_element(): array { * @param array $expected_lcp_element_xpaths Expected XPaths. */ public function test_get_lcp_element( array $breakpoints, array $url_metrics, array $expected_lcp_element_xpaths ): void { - $group_collection = new OD_URL_Metric_Group_Collection( $url_metrics, $breakpoints, 10, HOUR_IN_SECONDS ); + $current_etag = md5( '' ); + $group_collection = new OD_URL_Metric_Group_Collection( $url_metrics, $current_etag, $breakpoints, 10, HOUR_IN_SECONDS ); $lcp_element_xpaths_by_minimum_viewport_widths = array(); foreach ( $group_collection as $group ) { @@ -346,19 +431,17 @@ public function test_get_lcp_element( array $breakpoints, array $url_metrics, ar * @covers ::jsonSerialize */ public function test_json_serialize(): void { - $group = new OD_URL_Metric_Group( - array_map( - function ( $viewport_width ) { - return $this->get_sample_url_metric( array( 'viewport_width' => $viewport_width ) ); - }, - array( 400, 600, 800 ) - ), - 0, - 1000, - 1, - HOUR_IN_SECONDS + $current_etag = md5( '' ); + $url_metrics = array_map( + function ( $viewport_width ) { + return $this->get_sample_url_metric( array( 'viewport_width' => $viewport_width ) ); + }, + array( 400, 600, 800 ) ); + $collection = new OD_URL_Metric_Group_Collection( $url_metrics, $current_etag, array( 1000 ), 1, HOUR_IN_SECONDS ); + $group = $collection->get_first_group(); + $json = wp_json_encode( $group ); $parsed_json = json_decode( $json, true ); $expected_keys = array( diff --git a/plugins/optimization-detective/tests/test-detection.php b/plugins/optimization-detective/tests/test-detection.php index 4439041a72..a159591115 100644 --- a/plugins/optimization-detective/tests/test-detection.php +++ b/plugins/optimization-detective/tests/test-detection.php @@ -7,6 +7,76 @@ class Test_OD_Detection extends WP_UnitTestCase { + /** + * Data provider. + * + * @return array + */ + public function data_provider_od_get_cache_purge_post_id(): array { + return array( + 'singular' => array( + 'set_up' => function () { + $post_id = self::factory()->post->create(); + $this->go_to( get_permalink( $post_id ) ); + return $post_id; + }, + 'expected_is_query_object' => true, + 'expected_query_object_class' => WP_Post::class, + ), + 'home' => array( + 'set_up' => function () { + $post_id = self::factory()->post->create(); + $this->go_to( home_url() ); + return $post_id; + }, + 'expected_is_query_object' => false, + 'expected_query_object_class' => null, + ), + 'category' => array( + 'set_up' => function () { + $cat_id = self::factory()->category->create(); + $post_id = self::factory()->post->create(); + wp_set_post_categories( $post_id, array( $cat_id ) ); + $this->go_to( get_category_link( $cat_id ) ); + return $post_id; + }, + 'expected_is_query_object' => false, + 'expected_query_object_class' => WP_Term::class, + ), + 'not_found' => array( + 'set_up' => function () { + $this->go_to( '/this-page-does-not-exist' ); + return null; + }, + 'expected_is_query_object' => false, + 'expected_query_object_class' => null, + ), + ); + } + + /** + * Tests od_get_cache_purge_post_id(). + * + * @covers ::od_get_cache_purge_post_id + * + * @dataProvider data_provider_od_get_cache_purge_post_id + */ + public function test_od_get_cache_purge_post_id( Closure $set_up, bool $expected_is_query_object, ?string $expected_query_object_class ): void { + $expected = $set_up(); + $this->assertSame( $expected, od_get_cache_purge_post_id() ); + if ( $expected_is_query_object ) { + $this->assertSame( $expected, get_queried_object_id() ); + } else { + $this->assertNotSame( $expected, get_queried_object_id() ); + } + + if ( null === $expected_query_object_class ) { + $this->assertNull( get_queried_object() ); + } else { + $this->assertSame( $expected_query_object_class, get_class( get_queried_object() ) ); + } + } + /** * Data provider. * @@ -17,28 +87,29 @@ public function data_provider_od_get_detection_script(): array { 'unfiltered' => array( 'set_up' => static function (): void {}, 'expected_exports' => array( - 'detectionTimeWindow' => 5000, 'storageLockTTL' => MINUTE_IN_SECONDS, + 'extensionModuleUrls' => array(), ), ), 'filtered' => array( 'set_up' => static function (): void { add_filter( - 'od_detection_time_window', + 'od_url_metric_storage_lock_ttl', static function (): int { - return 2500; + return HOUR_IN_SECONDS; } ); add_filter( - 'od_url_metric_storage_lock_ttl', - static function (): int { - return HOUR_IN_SECONDS; + 'od_extension_module_urls', + static function ( array $urls ): array { + $urls[] = home_url( '/my-extension.js', 'https' ); + return $urls; } ); }, 'expected_exports' => array( - 'detectionTimeWindow' => 2500, 'storageLockTTL' => HOUR_IN_SECONDS, + 'extensionModuleUrls' => array( home_url( '/my-extension.js', 'https' ) ), ), ), ); @@ -56,10 +127,11 @@ static function (): int { */ public function test_od_get_detection_script_returns_script( Closure $set_up, array $expected_exports ): void { $set_up(); - $slug = od_get_url_metrics_slug( array( 'p' => '1' ) ); + $slug = od_get_url_metrics_slug( array( 'p' => '1' ) ); + $current_etag = md5( '' ); $breakpoints = array( 480, 600, 782 ); - $group_collection = new OD_URL_Metric_Group_Collection( array(), $breakpoints, 3, HOUR_IN_SECONDS ); + $group_collection = new OD_URL_Metric_Group_Collection( array(), $current_etag, $breakpoints, 3, HOUR_IN_SECONDS ); $script = od_get_detection_script( $slug, $group_collection ); diff --git a/plugins/optimization-detective/tests/test-helper.php b/plugins/optimization-detective/tests/test-helper.php index af824a3631..42bcf62dd8 100644 --- a/plugins/optimization-detective/tests/test-helper.php +++ b/plugins/optimization-detective/tests/test-helper.php @@ -7,6 +7,82 @@ class Test_OD_Helper extends WP_UnitTestCase { + /** + * @covers ::od_initialize_extensions + */ + public function test_od_initialize_extensions(): void { + unset( $GLOBALS['wp_actions']['od_init'] ); + $passed_version = null; + add_action( + 'od_init', + static function ( string $version ) use ( &$passed_version ): void { + $passed_version = $version; + } + ); + od_initialize_extensions(); + $this->assertSame( 1, did_action( 'od_init' ) ); + $this->assertSame( OPTIMIZATION_DETECTIVE_VERSION, $passed_version ); + } + + /** + * Data provider. + * + * @return array> + */ + public function data_to_test_od_generate_media_query(): array { + return array( + 'mobile' => array( + 'min_width' => 0, + 'max_width' => 320, + 'expected' => '(max-width: 320px)', + ), + 'mobile_alt' => array( + 'min_width' => null, + 'max_width' => 320, + 'expected' => '(max-width: 320px)', + ), + 'tablet' => array( + 'min_width' => 321, + 'max_width' => 600, + 'expected' => '(min-width: 321px) and (max-width: 600px)', + ), + 'desktop' => array( + 'min_width' => 601, + 'max_width' => PHP_INT_MAX, + 'expected' => '(min-width: 601px)', + ), + 'desktop_alt' => array( + 'min_width' => 601, + 'max_width' => null, + 'expected' => '(min-width: 601px)', + ), + 'no_widths' => array( + 'min_width' => null, + 'max_width' => null, + 'expected' => null, + ), + 'bad_widths' => array( + 'min_width' => 1000, + 'max_width' => 10, + 'expected' => null, + 'incorrect_usage' => 'od_generate_media_query', + ), + ); + } + + /** + * Test generating media query. + * + * @dataProvider data_to_test_od_generate_media_query + * @covers ::od_generate_media_query + */ + public function test_od_generate_media_query( ?int $min_width, ?int $max_width, ?string $expected, ?string $incorrect_usage = null ): void { + if ( null !== $incorrect_usage ) { + $this->setExpectedIncorrectUsage( $incorrect_usage ); + } + $this->assertSame( $expected, od_generate_media_query( $min_width, $max_width ) ); + } + /** * Test printing the meta generator tag. * diff --git a/plugins/optimization-detective/tests/test-optimization.php b/plugins/optimization-detective/tests/test-optimization.php index cff742dc18..cba6a73837 100644 --- a/plugins/optimization-detective/tests/test-optimization.php +++ b/plugins/optimization-detective/tests/test-optimization.php @@ -27,10 +27,10 @@ class Test_OD_Optimization extends WP_UnitTestCase { private $default_mimetype; public function set_up(): void { + parent::set_up(); $this->original_request_uri = $_SERVER['REQUEST_URI']; $this->original_request_method = $_SERVER['REQUEST_METHOD']; $this->default_mimetype = (string) ini_get( 'default_mimetype' ); - parent::set_up(); } public function tear_down(): void { @@ -185,27 +185,35 @@ public function test_od_maybe_add_template_output_buffer_filter_with_query_var_t */ public function data_provider_test_od_can_optimize_response(): array { return array( - 'homepage' => array( + 'home_as_anonymous' => array( 'set_up' => function (): void { $this->go_to( home_url( '/' ) ); }, 'expected' => true, ), - 'homepage_filtered' => array( + 'home_filtered_as_anonymous' => array( 'set_up' => function (): void { $this->go_to( home_url( '/' ) ); add_filter( 'od_can_optimize_response', '__return_false' ); }, 'expected' => false, ), - 'search' => array( + 'singular_as_anonymous' => array( + 'set_up' => function (): void { + $posts = get_posts(); + $this->assertInstanceOf( WP_Post::class, $posts[0] ); + $this->go_to( get_permalink( $posts[0] ) ); + }, + 'expected' => true, + ), + 'search_as_anonymous' => array( 'set_up' => function (): void { self::factory()->post->create( array( 'post_title' => 'Hello' ) ); $this->go_to( home_url( '?s=Hello' ) ); }, 'expected' => false, ), - 'customizer_preview' => array( + 'home_customizer_preview_as_anonymous' => array( 'set_up' => function (): void { $this->go_to( home_url( '/' ) ); global $wp_customize; @@ -215,21 +223,28 @@ public function data_provider_test_od_can_optimize_response(): array { }, 'expected' => false, ), - 'post_request' => array( + 'home_post_request_as_anonymous' => array( 'set_up' => function (): void { $this->go_to( home_url( '/' ) ); $_SERVER['REQUEST_METHOD'] = 'POST'; }, 'expected' => false, ), - 'subscriber_user' => array( + 'home_as_subscriber' => array( 'set_up' => function (): void { wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); $this->go_to( home_url( '/' ) ); }, 'expected' => true, ), - 'admin_user' => array( + 'empty_author_page_as_anonymous' => array( + 'set_up' => function (): void { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $this->go_to( get_author_posts_url( $user_id ) ); + }, + 'expected' => false, + ), + 'home_as_admin' => array( 'set_up' => function (): void { wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); $this->go_to( home_url( '/' ) ); @@ -243,10 +258,12 @@ public function data_provider_test_od_can_optimize_response(): array { * Test od_can_optimize_response(). * * @covers ::od_can_optimize_response + * @covers ::od_get_cache_purge_post_id * * @dataProvider data_provider_test_od_can_optimize_response */ public function test_od_can_optimize_response( Closure $set_up, bool $expected ): void { + self::factory()->post->create(); // Make sure there is at least one post in the DB. $set_up(); $this->assertSame( $expected, od_can_optimize_response() ); } diff --git a/plugins/optimization-detective/types.ts b/plugins/optimization-detective/types.ts new file mode 100644 index 0000000000..d92c532143 --- /dev/null +++ b/plugins/optimization-detective/types.ts @@ -0,0 +1,66 @@ +// h/t https://stackoverflow.com/a/59801602/93579 +type ExcludeProps< T > = { [ k: string ]: any } & { [ K in keyof T ]?: never }; + +import { onTTFB, onFCP, onLCP, onINP, onCLS } from 'web-vitals'; + +export interface ElementData { + isLCP: boolean; + isLCPCandidate: boolean; + xpath: string; + intersectionRatio: number; + intersectionRect: DOMRectReadOnly; + boundingClientRect: DOMRectReadOnly; +} + +export type ExtendedElementData = ExcludeProps< ElementData >; + +export interface URLMetric { + url: string; + viewport: { + width: number; + height: number; + }; + elements: ElementData[]; +} + +export type ExtendedRootData = ExcludeProps< URLMetric >; + +export interface URLMetricGroupStatus { + minimumViewportWidth: number; + complete: boolean; +} + +export type OnTTFBFunction = typeof onTTFB; +export type OnFCPFunction = typeof onFCP; +export type OnLCPFunction = typeof onLCP; +export type OnINPFunction = typeof onINP; +export type OnCLSFunction = typeof onCLS; + +export type InitializeArgs = { + readonly isDebug: boolean; + readonly onTTFB: OnTTFBFunction; + readonly onFCP: OnFCPFunction; + readonly onLCP: OnLCPFunction; + readonly onINP: OnINPFunction; + readonly onCLS: OnCLSFunction; +}; + +export type InitializeCallback = ( args: InitializeArgs ) => Promise< void >; + +export type FinalizeArgs = { + readonly getRootData: () => URLMetric; + readonly extendRootData: ( properties: ExtendedRootData ) => void; + readonly getElementData: ( xpath: string ) => ElementData | null; + readonly extendElementData: ( + xpath: string, + properties: ExtendedElementData + ) => void; + readonly isDebug: boolean; +}; + +export type FinalizeCallback = ( args: FinalizeArgs ) => Promise< void >; + +export interface Extension { + initialize?: InitializeCallback; + finalize?: FinalizeCallback; +} diff --git a/plugins/performance-lab/includes/admin/load.php b/plugins/performance-lab/includes/admin/load.php index 819c116e28..aecac103d5 100644 --- a/plugins/performance-lab/includes/admin/load.php +++ b/plugins/performance-lab/includes/admin/load.php @@ -49,9 +49,6 @@ function perflab_load_features_page(): void { // Handle style for settings page. add_action( 'admin_head', 'perflab_print_features_page_style' ); - - // Handle script for settings page. - add_action( 'admin_footer', 'perflab_print_plugin_progress_indicator_script' ); } /** @@ -216,6 +213,42 @@ function perflab_dismiss_wp_pointer_wrapper(): void { } add_action( 'wp_ajax_dismiss-wp-pointer', 'perflab_dismiss_wp_pointer_wrapper', 0 ); +/** + * Gets the path to a script or stylesheet. + * + * @since 3.7.0 + * + * @param string $src_path Source path. + * @param string|null $min_path Minified path. If not supplied, then '.min' is injected before the file extension in the source path. + * @return string URL to script or stylesheet. + */ +function perflab_get_asset_path( string $src_path, ?string $min_path = null ): string { + if ( null === $min_path ) { + // Note: wp_scripts_get_suffix() is not used here because we need access to both the source and minified paths. + $min_path = (string) preg_replace( '/(?=\.\w+$)/', '.min', $src_path ); + } + + $force_src = false; + if ( WP_DEBUG && ! file_exists( trailingslashit( PERFLAB_PLUGIN_DIR_PATH ) . $min_path ) ) { + $force_src = true; + wp_trigger_error( + __FUNCTION__, + sprintf( + /* translators: %s is the minified asset path */ + __( 'Minified asset has not been built: %s', 'performance-lab' ), + $min_path + ), + E_USER_WARNING + ); + } + + if ( SCRIPT_DEBUG || $force_src ) { + return $src_path; + } + + return $min_path; +} + /** * Callback function to handle admin scripts. * @@ -228,8 +261,14 @@ function perflab_enqueue_features_page_scripts(): void { wp_enqueue_style( 'thickbox' ); wp_enqueue_script( 'plugin-install' ); - // Enqueue the a11y script. - wp_enqueue_script( 'wp-a11y' ); + // Enqueue plugin activate AJAX script and localize script data. + wp_enqueue_script( + 'perflab-plugin-activate-ajax', + plugin_dir_url( PERFLAB_MAIN_FILE ) . perflab_get_asset_path( 'includes/admin/plugin-activate-ajax.js' ), + array( 'wp-i18n', 'wp-a11y', 'wp-api-fetch' ), + PERFLAB_VERSION, + true + ); } /** @@ -396,42 +435,6 @@ static function ( $name ) { } } -/** - * Callback function that print plugin progress indicator script. - * - * @since 3.1.0 - */ -function perflab_print_plugin_progress_indicator_script(): void { - $js_function = << 'module' ) - ); -} - /** * Gets the URL to the plugin settings screen if one exists. * diff --git a/plugins/performance-lab/includes/admin/plugin-activate-ajax.js b/plugins/performance-lab/includes/admin/plugin-activate-ajax.js new file mode 100644 index 0000000000..7be5297929 --- /dev/null +++ b/plugins/performance-lab/includes/admin/plugin-activate-ajax.js @@ -0,0 +1,114 @@ +/** + * Handles activation of Performance Features (Plugins) using AJAX. + */ + +( function () { + // @ts-ignore + const { i18n, a11y, apiFetch } = wp; + const { __ } = i18n; + + // Queue to hold pending activation requests. + const activationQueue = []; + let isProcessingActivation = false; + + /** + * Enqueues plugin activation requests and starts processing if not already in progress. + * + * @param {MouseEvent} event - The click event object. + */ + function enqueuePluginActivation( event ) { + // Prevent the default link behavior. + event.preventDefault(); + + const target = /** @type {HTMLElement} */ ( event.target ); + + if ( + target.classList.contains( 'updating-message' ) || + target.classList.contains( 'disabled' ) + ) { + return; + } + + target.classList.add( 'updating-message' ); + target.textContent = __( 'Waiting…', 'performance-lab' ); + + const pluginSlug = target.dataset.pluginSlug; + + activationQueue.push( { target, pluginSlug } ); + + // Start processing the queue if not already doing so. + if ( ! isProcessingActivation ) { + handlePluginActivation(); + } + } + + /** + * Handles activation of feature/plugin using queue based approach. + * + * @return {Promise} The asynchronous function returns a promise that resolves to void. + */ + async function handlePluginActivation() { + if ( 0 === activationQueue.length ) { + isProcessingActivation = false; + return; + } + + isProcessingActivation = true; + + const { target, pluginSlug } = activationQueue.shift(); + + target.textContent = __( 'Activating…', 'performance-lab' ); + + a11y.speak( __( 'Activating…', 'performance-lab' ) ); + + try { + // Activate the plugin/feature via the REST API. + await apiFetch( { + path: `/performance-lab/v1/features/${ pluginSlug }:activate`, + method: 'POST', + } ); + + // Fetch the plugin/feature information via the REST API. + /** @type {{settingsUrl: string|null}} */ + const featureInfo = await apiFetch( { + path: `/performance-lab/v1/features/${ pluginSlug }`, + method: 'GET', + } ); + + if ( featureInfo.settingsUrl ) { + const actionButtonList = document.querySelector( + `.plugin-card-${ pluginSlug } .plugin-action-buttons` + ); + + const listItem = document.createElement( 'li' ); + const anchor = document.createElement( 'a' ); + + anchor.href = featureInfo.settingsUrl; + anchor.textContent = __( 'Settings', 'performance-lab' ); + + listItem.appendChild( anchor ); + actionButtonList.appendChild( listItem ); + } + + a11y.speak( __( 'Plugin activated.', 'performance-lab' ) ); + + target.textContent = __( 'Active', 'performance-lab' ); + target.classList.remove( 'updating-message' ); + target.classList.add( 'disabled' ); + } catch ( error ) { + a11y.speak( __( 'Plugin failed to activate.', 'performance-lab' ) ); + + target.classList.remove( 'updating-message' ); + target.textContent = __( 'Activate', 'performance-lab' ); + } finally { + handlePluginActivation(); + } + } + + // Attach the event listeners. + document + .querySelectorAll( '.perflab-install-active-plugin' ) + .forEach( ( item ) => { + item.addEventListener( 'click', enqueuePluginActivation ); + } ); +} )(); diff --git a/plugins/performance-lab/includes/admin/plugins.php b/plugins/performance-lab/includes/admin/plugins.php index 882b337eff..003138e52e 100644 --- a/plugins/performance-lab/includes/admin/plugins.php +++ b/plugins/performance-lab/includes/admin/plugins.php @@ -16,16 +16,19 @@ * @since 2.8.0 * * @param string $plugin_slug The string identifier for the plugin in questions slug. - * @return array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], download_link: string, version: string}|WP_Error Array of plugin data or WP_Error if failed. + * @return array{name: string, slug: string, short_description: string, requires: string|false, requires_php: string|false, requires_plugins: string[], version: string}|WP_Error Array of plugin data or WP_Error if failed. */ function perflab_query_plugin_info( string $plugin_slug ) { $transient_key = 'perflab_plugins_info'; $plugins = get_transient( $transient_key ); - if ( is_array( $plugins ) ) { - // If the specific plugin_slug is not in the cache, return an error. - if ( ! isset( $plugins[ $plugin_slug ] ) ) { - return new WP_Error( 'plugin_not_found', __( 'Plugin not found.', 'performance-lab' ) ); + if ( is_array( $plugins ) && isset( $plugins[ $plugin_slug ] ) ) { + if ( isset( $plugins[ $plugin_slug ]['error'] ) ) { + // Plugin was requested before but an error occurred for it. + return new WP_Error( + $plugins[ $plugin_slug ]['error']['code'], + $plugins[ $plugin_slug ]['error']['message'] + ); } return $plugins[ $plugin_slug ]; // Return cached plugin info if found. } @@ -37,7 +40,6 @@ function perflab_query_plugin_info( string $plugin_slug ) { 'requires', 'requires_php', 'requires_plugins', - 'download_link', 'version', // Needed by install_plugin_install_status(). ); @@ -52,41 +54,101 @@ function perflab_query_plugin_info( string $plugin_slug ) { ) ); + $has_errors = false; + $plugins = array(); + if ( is_wp_error( $response ) ) { - return new WP_Error( - 'api_error', - sprintf( - /* translators: %s: API error message */ - __( 'Failed to retrieve plugins data from WordPress.org API: %s', 'performance-lab' ), - $response->get_error_message() - ) + $plugins[ $plugin_slug ] = array( + 'error' => array( + 'code' => 'api_error', + 'message' => sprintf( + /* translators: %s: API error message */ + __( 'Failed to retrieve plugins data from WordPress.org API: %s', 'performance-lab' ), + $response->get_error_message() + ), + ), ); - } - // Check if the response contains plugins. - if ( ! ( is_object( $response ) && property_exists( $response, 'plugins' ) ) ) { - return new WP_Error( 'no_plugins', __( 'No plugins found in the API response.', 'performance-lab' ) ); - } + foreach ( perflab_get_standalone_plugins() as $standalone_plugin ) { + $plugins[ $standalone_plugin ] = $plugins[ $plugin_slug ]; + } - $plugins = array(); - $standalone_plugins = array_flip( perflab_get_standalone_plugins() ); - foreach ( $response->plugins as $plugin_data ) { - if ( ! isset( $standalone_plugins[ $plugin_data['slug'] ] ) ) { - continue; + $has_errors = true; + } elseif ( ! is_object( $response ) || ! property_exists( $response, 'plugins' ) ) { + $plugins[ $plugin_slug ] = array( + 'error' => array( + 'code' => 'no_plugins', + 'message' => __( 'No plugins found in the API response.', 'performance-lab' ), + ), + ); + + foreach ( perflab_get_standalone_plugins() as $standalone_plugin ) { + $plugins[ $standalone_plugin ] = $plugins[ $plugin_slug ]; + } + + $has_errors = true; + } else { + $plugin_queue = perflab_get_standalone_plugins(); + + // Index the plugins from the API response by their slug for efficient lookup. + $all_performance_plugins = array_column( $response->plugins, null, 'slug' ); + + // Start processing the plugins using a queue-based approach. + while ( count( $plugin_queue ) > 0 ) { // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found + $current_plugin_slug = array_shift( $plugin_queue ); + + // Skip already-processed plugins. + if ( isset( $plugins[ $current_plugin_slug ] ) ) { + continue; + } + + if ( ! isset( $all_performance_plugins[ $current_plugin_slug ] ) ) { + // Cache the fact that the plugin was not found. + $plugins[ $current_plugin_slug ] = array( + 'error' => array( + 'code' => 'plugin_not_found', + 'message' => __( 'Plugin not found in API response.', 'performance-lab' ), + ), + ); + + $has_errors = true; + } else { + $plugin_data = $all_performance_plugins[ $current_plugin_slug ]; + $plugins[ $current_plugin_slug ] = wp_array_slice_assoc( $plugin_data, $fields ); + + // Enqueue the required plugins slug by adding it to the queue. + if ( isset( $plugin_data['requires_plugins'] ) && is_array( $plugin_data['requires_plugins'] ) ) { + $plugin_queue = array_merge( $plugin_queue, $plugin_data['requires_plugins'] ); + } + } + } + + if ( ! isset( $plugins[ $plugin_slug ] ) ) { + // Cache the fact that the plugin was not found. + $plugins[ $plugin_slug ] = array( + 'error' => array( + 'code' => 'plugin_not_found', + 'message' => __( 'The requested plugin is not part of Performance Lab plugins.', 'performance-lab' ), + ), + ); + + $has_errors = true; } - $plugins[ $plugin_data['slug'] ] = wp_array_slice_assoc( $plugin_data, $fields ); } - set_transient( $transient_key, $plugins, HOUR_IN_SECONDS ); + set_transient( $transient_key, $plugins, $has_errors ? MINUTE_IN_SECONDS : HOUR_IN_SECONDS ); - if ( ! isset( $plugins[ $plugin_slug ] ) ) { - return new WP_Error( 'plugin_not_found', __( 'Plugin not found.', 'performance-lab' ) ); + if ( isset( $plugins[ $plugin_slug ]['error'] ) ) { + return new WP_Error( + $plugins[ $plugin_slug ]['error']['code'], + $plugins[ $plugin_slug ]['error']['message'] + ); } /** * Validated (mostly) plugin data. * - * @var array $plugins + * @var array $plugins */ return $plugins[ $plugin_slug ]; } @@ -230,6 +292,31 @@ static function ( string $error_message ): string {
+

+ 'WordPress Performance Team', + 'plugin_status' => 'all', + ), + admin_url( 'plugins.php' ) + ); + echo wp_kses( + sprintf( + /* translators: %s is the URL to the plugins screen */ + __( 'Performance features are installed as plugins. To update features or remove them, manage them on the plugins screen.', 'performance-lab' ), + esc_url( $plugins_url ) + ), + array( + 'a' => array( 'href' => true ), + ) + ); + ?> +

+ $plugin_slug, + 'fields' => array( + 'download_link' => true, + 'requires_plugins' => true, + 'sections' => false, // Omit the bulk of the response which we don't need. + ), + ) + ); + if ( $plugin_data instanceof WP_Error ) { return $plugin_data; } + if ( is_object( $plugin_data ) ) { + $plugin_data = (array) $plugin_data; + } + + // Add recommended plugins (soft dependencies) to the list of plugins installed and activated. + if ( 'embed-optimizer' === $plugin_slug ) { + $plugin_data['requires_plugins'][] = 'optimization-detective'; + } + // Install and activate plugin dependencies first. foreach ( $plugin_data['requires_plugins'] as $requires_plugin_slug ) { $result = perflab_install_and_activate_plugin( $requires_plugin_slug ); @@ -351,8 +459,11 @@ function perflab_install_and_activate_plugin( string $plugin_slug, array &$proce } $plugins = get_plugins( '/' . $plugin_slug ); - if ( empty( $plugins ) ) { - return new WP_Error( 'plugin_not_found', __( 'Plugin not found.', 'default' ) ); + if ( count( $plugins ) === 0 ) { + return new WP_Error( + 'plugin_not_found', + __( 'Plugin not found among installed plugins.', 'performance-lab' ) + ); } $plugin_file_names = array_keys( $plugins ); @@ -423,8 +534,9 @@ function perflab_render_plugin_card( array $plugin_data ): void { ); $action_links[] = sprintf( - '%s', + '%s', esc_url( $url ), + esc_attr( $plugin_data['slug'] ), esc_html__( 'Activate', 'default' ) ); } else { diff --git a/plugins/performance-lab/includes/admin/rest-api.php b/plugins/performance-lab/includes/admin/rest-api.php new file mode 100644 index 0000000000..509f2bba88 --- /dev/null +++ b/plugins/performance-lab/includes/admin/rest-api.php @@ -0,0 +1,183 @@ +[a-z0-9_-]+):activate'; + +/** + * Route for fetching plugin/feature information. + * + * @since 3.6.0 + * @var string + */ +const PERFLAB_FEATURES_INFORMATION_ROUTE = '/features/(?P[a-z0-9_-]+)'; + +/** + * Registers endpoint for performance-lab REST API. + * + * @since 3.6.0 + * @access private + */ +function perflab_register_endpoint(): void { + register_rest_route( + PERFLAB_REST_API_NAMESPACE, + PERFLAB_FEATURES_ACTIVATE_ROUTE, + array( + 'methods' => 'POST', + 'args' => array( + 'slug' => array( + 'type' => 'string', + 'description' => __( 'Plugin slug of the Performance Lab feature to be activated.', 'performance-lab' ), + 'required' => true, + 'validate_callback' => 'perflab_validate_slug_endpoint_arg', + ), + ), + 'callback' => 'perflab_handle_feature_activation', + 'permission_callback' => static function () { + // Important: The endpoint calls perflab_install_and_activate_plugin() which does more granular capability checks. + if ( current_user_can( 'activate_plugins' ) ) { + return true; + } + + return new WP_Error( 'cannot_activate', __( 'Sorry, you are not allowed to activate this feature.', 'performance-lab' ) ); + }, + ) + ); + + register_rest_route( + PERFLAB_REST_API_NAMESPACE, + PERFLAB_FEATURES_INFORMATION_ROUTE, + array( + 'methods' => 'GET', + 'args' => array( + 'slug' => array( + 'type' => 'string', + 'description' => __( 'Plugin slug of plugin/feature whose information is needed.', 'performance-lab' ), + 'required' => true, + 'validate_callback' => 'perflab_validate_slug_endpoint_arg', + ), + ), + 'callback' => 'perflab_handle_get_feature_information', + 'permission_callback' => static function () { + if ( current_user_can( 'manage_options' ) ) { + return true; + } + + return new WP_Error( 'cannot_access_plugin_settings_url', __( 'Sorry, you are not allowed to access plugin/feature information on this site.', 'performance-lab' ) ); + }, + ) + ); +} +add_action( 'rest_api_init', 'perflab_register_endpoint' ); + +/** + * Validates whether the provided plugin slug is a valid Performance Lab plugin. + * + * Note that an enum is not being used because additional PHP files have to be required to access the necessary functions, + * and this would not be ideal to do at rest_api_init. + * + * @since 3.6.0 + * @access private + * + * @param string $slug Plugin slug. + * @return bool Whether valid. + */ +function perflab_validate_slug_endpoint_arg( string $slug ): bool { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/admin/load.php'; + require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/admin/plugins.php'; + return in_array( $slug, perflab_get_standalone_plugins(), true ); +} + +/** + * Handles REST API request to activate plugin/feature. + * + * @since 3.6.0 + * @access private + * + * @phpstan-param WP_REST_Request> $request + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error Response. + */ +function perflab_handle_feature_activation( WP_REST_Request $request ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php'; + + // Install and activate the plugin/feature and its dependencies. + $result = perflab_install_and_activate_plugin( $request['slug'] ); + if ( is_wp_error( $result ) ) { + switch ( $result->get_error_code() ) { + case 'cannot_install_plugin': + case 'cannot_activate_plugin': + $response_code = rest_authorization_required_code(); + break; + case 'plugin_not_found': + $response_code = 404; + break; + default: + $response_code = 500; + } + return new WP_Error( + $result->get_error_code(), + $result->get_error_message(), + array( 'status' => $response_code ) + ); + } + + return new WP_REST_Response( + array( + 'success' => true, + ) + ); +} + +/** + * Handles REST API request to get plugin/feature information. + * + * @since 3.6.0 + * @access private + * + * @phpstan-param WP_REST_Request> $request + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response Response. + */ +function perflab_handle_get_feature_information( WP_REST_Request $request ): WP_REST_Response { + $plugin_settings_url = perflab_get_plugin_settings_url( $request['slug'] ); + + return new WP_REST_Response( + array( + 'slug' => $request['slug'], + 'settingsUrl' => $plugin_settings_url, + ) + ); +} diff --git a/plugins/performance-lab/includes/site-health/avif-headers/helper.php b/plugins/performance-lab/includes/site-health/avif-headers/helper.php new file mode 100644 index 0000000000..443cf30acc --- /dev/null +++ b/plugins/performance-lab/includes/site-health/avif-headers/helper.php @@ -0,0 +1,84 @@ + __( 'Your site sends AVIF image headers', 'performance-lab' ), + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Performance', 'performance-lab' ), + 'color' => 'blue', + ), + 'description' => sprintf( + '

%s

', + esc_html( + sprintf( + /* translators: 1: image/avif, 2: content-type */ + __( 'Images with the mime type %1$s served without the correct %2$s header may not render properly.', 'performance-lab' ), + 'image/avif', + 'content-type' + ) + ) + ), + 'actions' => '', + 'test' => 'is_avif_headers_enabled', + ); + + $avif_headers_enabled = avif_headers_is_enabled(); + + if ( ! $avif_headers_enabled ) { + $result['status'] = 'recommended'; + $result['label'] = __( 'Your site does not send AVIF image headers correctly', 'performance-lab' ); + $result['actions'] = sprintf( + '

%s

', + esc_html__( 'AVIF headers can be enabled with a small configuration change by your hosting provider.', 'performance-lab' ) + ); + } + + return $result; +} + +/** + * Checks if AVIF headers are enabled. + * + * @since 3.6.0 + * + * @return bool True if AVIF headers are enabled, false otherwise. + */ +function avif_headers_is_enabled(): bool { + // Request an AVIF image at a known URL bundled with the plugin. + $url = plugins_url( 'avif-headers/images/lossy.avif', __DIR__ ); + + $response = wp_remote_request( $url, array( 'sslverify' => false ) ); + + if ( is_wp_error( $response ) ) { + return false; + } + + // Check the image headers; the type should be `avif` not `octet-stream`. + $headers = wp_remote_retrieve_headers( $response ); + + if ( ! isset( $headers['content-type'] ) ) { + return false; + } + + $content_type = $headers['content-type']; + + return ( 'image/avif' === $content_type ); +} diff --git a/plugins/performance-lab/includes/site-health/avif-headers/hooks.php b/plugins/performance-lab/includes/site-health/avif-headers/hooks.php new file mode 100644 index 0000000000..b4ea1ea37a --- /dev/null +++ b/plugins/performance-lab/includes/site-health/avif-headers/hooks.php @@ -0,0 +1,28 @@ +} $tests Site Health Tests. + * @return array{direct: array} Amended tests. + */ +function avif_headers_add_is_avif_headers_enabled_test( array $tests ): array { + $tests['direct']['avif_headers'] = array( + 'label' => __( 'AVIF Headers', 'performance-lab' ), + 'test' => 'avif_headers_check_avif_headers_test', + ); + return $tests; +} +add_filter( 'site_status_tests', 'avif_headers_add_is_avif_headers_enabled_test' ); diff --git a/plugins/performance-lab/includes/site-health/avif-headers/images/lossy.avif b/plugins/performance-lab/includes/site-health/avif-headers/images/lossy.avif new file mode 100644 index 0000000000..0aba41c1bf Binary files /dev/null and b/plugins/performance-lab/includes/site-health/avif-headers/images/lossy.avif differ diff --git a/plugins/performance-lab/includes/site-health/load.php b/plugins/performance-lab/includes/site-health/load.php index b3ade868da..e4cd71a596 100644 --- a/plugins/performance-lab/includes/site-health/load.php +++ b/plugins/performance-lab/includes/site-health/load.php @@ -25,3 +25,7 @@ // AVIF Support site health check. require_once __DIR__ . '/avif-support/helper.php'; require_once __DIR__ . '/avif-support/hooks.php'; + +// AVIF headers site health check. +require_once __DIR__ . '/avif-headers/helper.php'; +require_once __DIR__ . '/avif-headers/hooks.php'; diff --git a/plugins/performance-lab/load.php b/plugins/performance-lab/load.php index a2eeafa285..8b5fbd7724 100644 --- a/plugins/performance-lab/load.php +++ b/plugins/performance-lab/load.php @@ -3,9 +3,9 @@ * Plugin Name: Performance Lab * Plugin URI: https://github.com/WordPress/performance * Description: Performance plugin from the WordPress Performance Team, which is a collection of standalone performance features. - * Requires at least: 6.5 + * Requires at least: 6.6 * Requires PHP: 7.2 - * Version: 3.4.1 + * Version: 3.7.0 * Author: WordPress Performance Team * Author URI: https://make.wordpress.org/performance/ * License: GPLv2 or later @@ -19,7 +19,7 @@ exit; // Exit if accessed directly. } -define( 'PERFLAB_VERSION', '3.4.1' ); +define( 'PERFLAB_VERSION', '3.7.0' ); define( 'PERFLAB_MAIN_FILE', __FILE__ ); define( 'PERFLAB_PLUGIN_DIR_PATH', plugin_dir_path( PERFLAB_MAIN_FILE ) ); define( 'PERFLAB_SCREEN', 'performance-lab' ); @@ -339,3 +339,6 @@ function perflab_cleanup_option(): void { require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/admin/server-timing.php'; require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/admin/plugins.php'; } + +// Load REST API. +require_once PERFLAB_PLUGIN_DIR_PATH . 'includes/admin/rest-api.php'; diff --git a/plugins/performance-lab/readme.txt b/plugins/performance-lab/readme.txt index f74e3e808e..98276fc6e6 100644 --- a/plugins/performance-lab/readme.txt +++ b/plugins/performance-lab/readme.txt @@ -1,8 +1,8 @@ === Performance Lab === Contributors: wordpressdotorg -Tested up to: 6.6 -Stable tag: 3.4.1 +Tested up to: 6.7 +Stable tag: 3.7.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, site health, measurement, optimization, diagnostics @@ -22,6 +22,7 @@ The feature plugins which are currently featured by this plugin are: * [Embed Optimizer](https://wordpress.org/plugins/embed-optimizer/) _(experimental)_ * [Enhanced Responsive Images](https://wordpress.org/plugins/auto-sizes/) _(experimental)_ * [Image Prioritizer](https://wordpress.org/plugins/image-prioritizer/) _(experimental)_ +* [Web Worker Offloading](https://wordpress.org/plugins/web-worker-offloading/) _(experimental)_ These plugins can also be installed separately from installing Performance Lab, but having the Performance Lab plugin also active will ensure you find out about new performance features as they are developed. @@ -70,6 +71,51 @@ Contributions are always welcome! Learn more about how to get involved in the [C == Changelog == += 3.7.0 = + +**Enhancements** + +* Add guidance for managing Performance feature plugins. ([1734](https://github.com/WordPress/performance/pull/1734)) +* Automatically discover plugin dependencies when obtaining Performance feature plugins from WordPress.org. ([1680](https://github.com/WordPress/performance/pull/1680)) +* Disregard transient cache in `perflab_query_plugin_info()` when a plugin is absent. ([1694](https://github.com/WordPress/performance/pull/1694)) +* Minify script used for ajax activation of features; warn if absent and serve original file when SCRIPT_DEBUG is enabled. ([1658](https://github.com/WordPress/performance/pull/1658)) + +**Bug Fixes** + +* Fix latest plugin version not being downloaded consistently. ([1693](https://github.com/WordPress/performance/pull/1693)) + += 3.6.1 = + +**Bug Fixes** + +* Fix race condition bug where activating multiple features sequentially could fail to activate some features. ([#1675](https://github.com/WordPress/performance/pull/1675)) + += 3.6.0 = + +**Enhancements** + +* Use AJAX for activating features / plugins in Performance Lab. ([1646](https://github.com/WordPress/performance/pull/1646)) +* Introduce AVIF header health check. ([1612](https://github.com/WordPress/performance/pull/1612)) +* Install and activate Optimization Detective when the Embed Optimizer feature is activated from the Performance screen. ([1644](https://github.com/WordPress/performance/pull/1644)) + +**Bug Fixes** + +* Fix uses of 'Plugin not found' string. ([1651](https://github.com/WordPress/performance/pull/1651)) + += 3.5.1 = + +**Bug Fixes** + +* Account for plugin dependencies when storing relevant plugin info. ([1613](https://github.com/WordPress/performance/pull/1613)) + += 3.5.0 = + +**Enhancements** + +* Add Web Worker Offloading to list of Performance features. ([1577](https://github.com/WordPress/performance/pull/1577)) +* Only store info for relevant standalone plugins in the transient cache. ([1573](https://github.com/WordPress/performance/pull/1573)) +* Use a single WordPress.org API request to get information for all plugins. ([1562](https://github.com/WordPress/performance/pull/1562)) + = 3.4.1 = **Bug Fixes** diff --git a/plugins/speculation-rules/load.php b/plugins/speculation-rules/load.php index 36d74d2c78..2417295f91 100644 --- a/plugins/speculation-rules/load.php +++ b/plugins/speculation-rules/load.php @@ -2,8 +2,8 @@ /** * Plugin Name: Speculative Loading * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/speculation-rules - * Description: Enables browsers to speculatively prerender or prefetch pages when hovering over links. - * Requires at least: 6.5 + * Description: Enables browsers to speculatively prerender or prefetch pages to achieve near-instant loads based on user interaction. + * Requires at least: 6.6 * Requires PHP: 7.2 * Version: 1.3.1 * Author: WordPress Performance Team diff --git a/plugins/speculation-rules/readme.txt b/plugins/speculation-rules/readme.txt index 154cd7ae6d..7802d57bfc 100644 --- a/plugins/speculation-rules/readme.txt +++ b/plugins/speculation-rules/readme.txt @@ -1,32 +1,30 @@ === Speculative Loading === Contributors: wordpressdotorg -Tested up to: 6.6 +Tested up to: 6.7 Stable tag: 1.3.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, javascript, speculation rules, prerender, prefetch -Enables browsers to speculatively prerender or prefetch pages when hovering over links. +Enables browsers to speculatively prerender or prefetch pages to achieve near-instant loads based on user interaction. == Description == -This plugin adds support for the [Speculation Rules API](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API), which allows defining rules by which certain URLs are dynamically prefetched or prerendered based on user interaction. +This plugin adds support for the [Speculation Rules API](https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API), which allows defining rules by which certain URLs are dynamically prefetched or prerendered. See the [Speculation Rules WICG specification draft](https://wicg.github.io/nav-speculation/speculation-rules.html). -By default, the plugin is configured to prerender WordPress frontend URLs when the user hovers over a relevant link. This can be customized via the "Speculative Loading" section under _Settings > Reading_. +By default, the plugin is configured to prerender WordPress frontend URLs when the user interacts with a relevant link. This can be customized via the "Speculative Loading" section in the _Settings > Reading_ admin screen. -A filter can be used to exclude certain URL paths from being eligible for prefetching and prerendering (see FAQ section). Alternatively, you can add the 'no-prerender' CSS class to any link (`` tag) that should not be prerendered. See FAQ for more information. +A filter can be used to exclude certain URL paths from being eligible for prefetching and prerendering (see FAQ section). Alternatively, you can add the `no-prerender` CSS class to any link (`` tag) that should not be prerendered. See FAQ for more information. = Browser support = -The Speculation Rules API is a new web API, and the functionality used by the plugin is supported in Chromium-based browsers such as Chrome, Edge, or Opera using version 121 or above. Other browsers such as Safari and Firefox will ignore the functionality with no ill effects but will not benefit from the speculative loading. Note that extensions may disable preloading by default (for example, uBlock Origin does this). - -Other browsers will not see any adverse effects, however the feature will not work for those clients. +The Speculation Rules API is a new web API, and the functionality used by the plugin is supported in Chromium-based browsers such as Chrome, Edge, or Opera using version 121 or above. Other browsers such as Safari and Firefox will ignore the functionality with no ill effects; they will simply not benefit from the speculative loading. Note that certain browser extensions may disable preloading by default. * [Browser support for the Speculation Rules API in general](https://caniuse.com/mdn-html_elements_script_type_speculationrules) -* [Information on document rules syntax support used by the plugin](https://developer.chrome.com/blog/chrome-121-beta#speculation_rules_api) +* [Information on document rules syntax support used by the plugin](https://developer.chrome.com/docs/web-platform/prerender-pages) _This plugin was formerly known as Speculation Rules._ @@ -48,12 +46,11 @@ _This plugin was formerly known as Speculation Rules._ = How can I prevent certain URLs from being prefetched and prerendered? = -Not every URL can be reasonably prerendered. Prerendering static content is typically reliable, however prerendering interactive content, such as a logout URL, can lead to issues. For this reason, certain WordPress core URLs such as `/wp-login.php` and `/wp-admin/*` are excluded from prefetching and prerendering. Additionally, any URL generated with `wp_nonce_url()` (or which contain the `_wpnonce` query var) is also ignored. You can exclude additional URL patterns by using the `plsr_speculation_rules_href_exclude_paths` filter. +Not every URL can be reasonably prerendered. Prerendering static content is typically reliable, however prerendering interactive content, such as a logout URL, can lead to issues. For this reason, certain WordPress core URLs such as `/wp-login.php` and `/wp-admin/*` are excluded from prefetching and prerendering. Additionally, any URLs generated with `wp_nonce_url()` (or which contains the `_wpnonce` query var) and `nofollow` links are also ignored. You can exclude additional URL patterns by using the `plsr_speculation_rules_href_exclude_paths` filter. -This example would ensure that URLs like `https://example.com/cart/` or `https://example.com/cart/foo` would be excluded from prefetching and prerendering. +The following example ensures that URLs like `https://example.com/cart/` or `https://example.com/cart/foo` are excluded from prefetching and prerendering: ` ` element from the page using standard JavaScript DOM APIs should this be needed when state changes without a new page load. = Where can I submit my plugin feedback? = diff --git a/plugins/web-worker-offloading/hooks.php b/plugins/web-worker-offloading/hooks.php index 8dd474edf7..7e90a5eed4 100644 --- a/plugins/web-worker-offloading/hooks.php +++ b/plugins/web-worker-offloading/hooks.php @@ -21,7 +21,13 @@ function plwwo_register_default_scripts( WP_Scripts $scripts ): void { // The source code for partytown.js is built from . // See webpack config in the WordPress/performance repo: . - $partytown_js = file_get_contents( __DIR__ . '/build/partytown.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request. + if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) { + $partytown_js_path = '/build/debug/partytown.js'; + } else { + $partytown_js_path = '/build/partytown.js'; + } + + $partytown_js = file_get_contents( __DIR__ . $partytown_js_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a local filesystem path not a remote request. if ( false === $partytown_js ) { return; } @@ -121,3 +127,16 @@ function plwwo_filter_inline_script_attributes( $attributes ): array { return $attributes; } add_filter( 'wp_inline_script_attributes', 'plwwo_filter_inline_script_attributes' ); + +/** + * Displays the HTML generator meta tag for the Web Worker Offloading plugin. + * + * See {@see 'wp_head'}. + * + * @since 0.1.1 + */ +function plwwo_render_generator_meta_tag(): void { + // Use the plugin slug as it is immutable. + echo '' . "\n"; +} +add_action( 'wp_head', 'plwwo_render_generator_meta_tag' ); diff --git a/plugins/web-worker-offloading/load.php b/plugins/web-worker-offloading/load.php index 95eb7d79bf..a540a131da 100644 --- a/plugins/web-worker-offloading/load.php +++ b/plugins/web-worker-offloading/load.php @@ -3,9 +3,9 @@ * Plugin Name: Web Worker Offloading * Plugin URI: https://github.com/WordPress/performance/issues/176 * Description: Offloads select JavaScript execution to a Web Worker to reduce work on the main thread and improve the Interaction to Next Paint (INP) metric. - * Requires at least: 6.5 + * Requires at least: 6.6 * Requires PHP: 7.2 - * Version: 0.1.0 + * Version: 0.2.0 * Author: WordPress Performance Team * Author URI: https://make.wordpress.org/performance/ * License: GPLv2 or later @@ -43,7 +43,7 @@ ); } -define( 'WEB_WORKER_OFFLOADING_VERSION', '0.1.0' ); +define( 'WEB_WORKER_OFFLOADING_VERSION', '0.2.0' ); require_once __DIR__ . '/helper.php'; require_once __DIR__ . '/hooks.php'; diff --git a/plugins/web-worker-offloading/readme.txt b/plugins/web-worker-offloading/readme.txt index b0b5c2af85..e2d6623c09 100644 --- a/plugins/web-worker-offloading/readme.txt +++ b/plugins/web-worker-offloading/readme.txt @@ -1,11 +1,11 @@ === Web Worker Offloading === -Contributors: wordpressdotorg -Tested up to: 6.6 -Stable tag: 0.1.0 -License: GPLv2 or later -License URI: https://www.gnu.org/licenses/gpl-2.0.html -Tags: performance, JavaScript, web worker, partytown, analytics +Contributors: wordpressdotorg +Tested up to: 6.7 +Stable tag: 0.2.0 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html +Tags: performance, JavaScript, web worker, partytown, analytics Offloads select JavaScript execution to a Web Worker to reduce work on the main thread and improve the Interaction to Next Paint (INP) metric. @@ -26,10 +26,10 @@ Unlike with the script loading strategies (async/defer), any inline before/after Otherwise, the plugin currently ships with built-in integrations to offload Google Analytics to a web worker for the following plugin: +* [Rank Math SEO](https://wordpress.org/plugins/seo-by-rank-math/) +* [Site Kit by Google](https://wordpress.org/plugins/google-site-kit/) * [WooCommerce](https://wordpress.org/plugins/woocommerce/) -Support for [Site Kit by Google](https://wordpress.org/plugins/google-site-kit/) and [Rank Math SEO](https://wordpress.org/plugins/seo-by-rank-math/) are [planned](https://github.com/WordPress/performance/issues/1455). - Please monitor your analytics once activating to ensure all the expected events are being logged. At the same time, monitor your INP scores to check for improvement. This plugin relies on the [Partytown 🎉](https://partytown.builder.io/) library by Builder.io, released under the MIT license. This library is in beta and there are quite a few [open bugs](https://github.com/BuilderIO/partytown/issues?q=is%3Aopen+is%3Aissue+label%3Abug). @@ -94,6 +94,24 @@ The [plugin source code](https://github.com/WordPress/performance/tree/trunk/plu == Changelog == += 0.2.0 = + +**Enhancements** + +* Integrate Web Worker Offloading with Google Site Kit. ([1686](https://github.com/WordPress/performance/pull/1686)) +* Integrate Web Worker Offloading with Rank Math SEO. ([1685](https://github.com/WordPress/performance/pull/1685)) +* Serve unminified scripts when `SCRIPT_DEBUG` is enabled. ([1643](https://github.com/WordPress/performance/pull/1643)) + +**Bug Fixes** + +* Fix tracking events like add_to_cart in WooCommerce integration. ([1740](https://github.com/WordPress/performance/pull/1740)) + += 0.1.1 = + +**Enhancements** + +* Add Web Worker Offloading meta generator. ([1598](https://github.com/WordPress/performance/pull/1598)) + = 0.1.0 = * Initial release. diff --git a/plugins/web-worker-offloading/tests/test-web-worker-offloading.php b/plugins/web-worker-offloading/tests/test-web-worker-offloading.php index c3eb52b8ea..d9c5ebcb0c 100644 --- a/plugins/web-worker-offloading/tests/test-web-worker-offloading.php +++ b/plugins/web-worker-offloading/tests/test-web-worker-offloading.php @@ -306,6 +306,18 @@ private function replace_placeholders( string $template ): string { ); } + /** + * Test printing the meta generator tag. + * + * @covers ::plwwo_render_generator_meta_tag + */ + public function test_plwwo_render_generator_meta_tag(): void { + $tag = get_echo( 'plwwo_render_generator_meta_tag' ); + $this->assertStringStartsWith( 'assertStringContainsString( 'generator', $tag ); + $this->assertStringContainsString( 'web-worker-offloading ' . WEB_WORKER_OFFLOADING_VERSION, $tag ); + } + /** * Reset WP_Scripts and WP_Styles. */ diff --git a/plugins/web-worker-offloading/third-party.php b/plugins/web-worker-offloading/third-party.php index d6916a3fbb..51b7ab0a03 100644 --- a/plugins/web-worker-offloading/third-party.php +++ b/plugins/web-worker-offloading/third-party.php @@ -39,9 +39,13 @@ static function ( $to_do ) use ( $script_handles ) { */ function plwwo_load_third_party_integrations(): void { $plugins_with_integrations = array( - // TODO: google-site-kit. - // TODO: seo-by-rank-math. - 'woocommerce' => static function (): bool { + 'google-site-kit' => static function (): bool { + return defined( 'GOOGLESITEKIT_VERSION' ); + }, + 'seo-by-rank-math' => static function (): bool { + return class_exists( 'RankMath' ); + }, + 'woocommerce' => static function (): bool { // See . return class_exists( 'WooCommerce' ); }, diff --git a/plugins/web-worker-offloading/third-party/google-site-kit.php b/plugins/web-worker-offloading/third-party/google-site-kit.php new file mode 100644 index 0000000000..cf334056e8 --- /dev/null +++ b/plugins/web-worker-offloading/third-party/google-site-kit.php @@ -0,0 +1,71 @@ +|mixed $configuration Configuration. + * @return array Configuration. + */ +function plwwo_google_site_kit_configure( $configuration ): array { + $configuration = (array) $configuration; + + $configuration['globalFns'][] = 'gtag'; // Allow calling from other Partytown scripts. + $configuration['globalFns'][] = 'wp_has_consent'; // Allow calling function from main thread. See . + + // Expose on the main tread. See . + $configuration['forward'][] = 'dataLayer.push'; + $configuration['forward'][] = 'gtag'; + + // See , + // and . + $configuration['mainWindowAccessors'][] = '_googlesitekitConsentCategoryMap'; + $configuration['mainWindowAccessors'][] = '_googlesitekitConsents'; + $configuration['mainWindowAccessors'][] = 'wp_consent_type'; + $configuration['mainWindowAccessors'][] = 'wp_fallback_consent_type'; + $configuration['mainWindowAccessors'][] = 'wp_has_consent'; + $configuration['mainWindowAccessors'][] = 'waitfor_consent_hook'; + + return $configuration; +} +add_filter( 'plwwo_configuration', 'plwwo_google_site_kit_configure' ); + +plwwo_mark_scripts_for_offloading( + array( + 'google_gtagjs', + 'googlesitekit-consent-mode', + ) +); + +/** + * Filters inline script attributes to offload Google Site Kit's GTag script tag to Partytown. + * + * @since 0.2.0 + * @access private + * @link https://github.com/google/site-kit-wp/blob/abbb74ff21f98a8779fbab0eeb9a16279a122bc4/includes/Core/Consent_Mode/Consent_Mode.php#L244-L259 + * + * @param array|mixed $attributes Script attributes. + * @return array|mixed Filtered inline script attributes. + */ +function plwwo_google_site_kit_filter_inline_script_attributes( $attributes ) { + if ( isset( $attributes['id'] ) && 'google_gtagjs-js-consent-mode-data-layer' === $attributes['id'] ) { + wp_enqueue_script( 'web-worker-offloading' ); + $attributes['type'] = 'text/partytown'; + } + return $attributes; +} + +add_filter( 'wp_inline_script_attributes', 'plwwo_google_site_kit_filter_inline_script_attributes' ); diff --git a/plugins/web-worker-offloading/third-party/seo-by-rank-math.php b/plugins/web-worker-offloading/third-party/seo-by-rank-math.php new file mode 100644 index 0000000000..302d218b50 --- /dev/null +++ b/plugins/web-worker-offloading/third-party/seo-by-rank-math.php @@ -0,0 +1,81 @@ +|mixed $configuration Configuration. + * @return array Configuration. + */ +function plwwo_rank_math_configure( $configuration ): array { + $configuration = (array) $configuration; + + $configuration['globalFns'][] = 'gtag'; // Because gtag() is defined in one script and called in another. + + // Expose on the main tread. See . + $configuration['forward'][] = 'dataLayer.push'; + $configuration['forward'][] = 'gtag'; + return $configuration; +} +add_filter( 'plwwo_configuration', 'plwwo_rank_math_configure' ); + +/* + * Note: The following integration is not targeting the \RankMath\Analytics\GTag::enqueue_gtag_js() code which is only + * used for WP<5.7. In WP 5.7, the wp_script_attributes and wp_inline_script_attributes filters were introduced, and + * Rank Math then deemed it preferable to use wp_print_script_tag() and wp_print_inline_script_tag() rather than + * wp_enqueue_script() and wp_add_inline_script(), respectively. Since Web Worker Offloading requires WP 6.5+, there + * is no point to integrate with the pre-5.7 code in Rank Math. + */ + +/** + * Filters script attributes to offload Rank Math's GTag script tag to Partytown. + * + * @since 0.2.0 + * @access private + * @link https://github.com/rankmath/seo-by-rank-math/blob/c78adba6f78079f27ff1430fabb75c6ac3916240/includes/modules/analytics/class-gtag.php#L161-L167 + * + * @param array|mixed $attributes Script attributes. + * @return array|mixed Filtered script attributes. + */ +function plwwo_rank_math_filter_script_attributes( $attributes ) { + if ( isset( $attributes['id'] ) && 'google_gtagjs' === $attributes['id'] ) { + wp_enqueue_script( 'web-worker-offloading' ); + $attributes['type'] = 'text/partytown'; + } + return $attributes; +} + +add_filter( 'wp_script_attributes', 'plwwo_rank_math_filter_script_attributes' ); + +/** + * Filters inline script attributes to offload Rank Math's GTag script tag to Partytown. + * + * @since 0.2.0 + * @access private + * @link https://github.com/rankmath/seo-by-rank-math/blob/c78adba6f78079f27ff1430fabb75c6ac3916240/includes/modules/analytics/class-gtag.php#L169-L174 + * + * @param array|mixed $attributes Script attributes. + * @return array|mixed Filtered inline script attributes. + */ +function plwwo_rank_math_filter_inline_script_attributes( $attributes ) { + if ( isset( $attributes['id'] ) && 'google_gtagjs-inline' === $attributes['id'] ) { + wp_enqueue_script( 'web-worker-offloading' ); + $attributes['type'] = 'text/partytown'; + } + return $attributes; +} + +add_filter( 'wp_inline_script_attributes', 'plwwo_rank_math_filter_inline_script_attributes' ); diff --git a/plugins/web-worker-offloading/third-party/woocommerce.php b/plugins/web-worker-offloading/third-party/woocommerce.php index d94dbb7f62..4ab54b9664 100644 --- a/plugins/web-worker-offloading/third-party/woocommerce.php +++ b/plugins/web-worker-offloading/third-party/woocommerce.php @@ -23,18 +23,20 @@ function plwwo_woocommerce_configure( $configuration ): array { $configuration = (array) $configuration; - $configuration['mainWindowAccessors'][] = 'wp'; // Because woocommerce-google-analytics-integration needs to access wp.i18n. - $configuration['mainWindowAccessors'][] = 'ga4w'; // Because woocommerce-google-analytics-integration needs to access window.ga4w. - $configuration['globalFns'][] = 'gtag'; // Because gtag() is defined in one script and called in another. - $configuration['forward'][] = 'dataLayer.push'; // Because the Partytown integration has this in its example config. + $configuration['globalFns'][] = 'gtag'; // Allow calling from other Partytown scripts. + + // Expose on the main tread. See . + $configuration['forward'][] = 'dataLayer.push'; + $configuration['forward'][] = 'gtag'; + return $configuration; } add_filter( 'plwwo_configuration', 'plwwo_woocommerce_configure' ); plwwo_mark_scripts_for_offloading( + // Note: 'woocommerce-google-analytics-integration' is intentionally not included because for some reason events like add_to_cart don't get tracked. array( 'google-tag-manager', - 'woocommerce-google-analytics-integration', 'woocommerce-google-analytics-integration-gtag', ) ); diff --git a/plugins/webp-uploads/helper.php b/plugins/webp-uploads/helper.php index 7669154b64..2ca917012e 100644 --- a/plugins/webp-uploads/helper.php +++ b/plugins/webp-uploads/helper.php @@ -28,7 +28,7 @@ function webp_uploads_get_upload_image_mime_transforms(): array { $default_transforms = array( 'image/jpeg' => array( 'image/' . $output_format ), - 'image/webp' => array( 'image/webp' ), + 'image/webp' => array( 'image/' . $output_format ), 'image/avif' => array( 'image/avif' ), 'image/png' => array( 'image/' . $output_format ), ); @@ -411,6 +411,17 @@ function webp_uploads_is_fallback_enabled(): bool { return (bool) get_option( 'perflab_generate_webp_and_jpeg' ); } +/** + * Checks if the `perflab_generate_all_fallback_sizes` option is enabled. + * + * @since 2.4.0 + * + * @return bool Whether the option is enabled. Default is false. + */ +function webp_uploads_should_generate_all_fallback_sizes(): bool { + return (bool) get_option( 'perflab_generate_all_fallback_sizes', 0 ); +} + /** * Retrieves the image URL for a specified MIME type from the attachment metadata. * @@ -468,3 +479,32 @@ function webp_uploads_get_mime_type_image( int $attachment_id, string $src, stri return null; } + +/** + * Retrieves the MIME type of an attachment file, checking the file directly if possible. + * + * If checking the file directly fails, the function falls back to the attachment's MIME type. + * + * @since 2.3.0 + * + * @param int $attachment_id The attachment ID. + * @param string $file Optional. The path to the file. + * @return string The MIME type of the file, or an empty string if not found. + */ +function webp_uploads_get_attachment_file_mime_type( int $attachment_id, string $file = '' ): string { + if ( '' === $file ) { + $file = get_attached_file( $attachment_id, true ); + // File does not exist. + if ( false === $file ) { + return ''; + } + } + + /* + * We need to get the MIME type ideally from the file, as WordPress Core may have already altered it. + * The post MIME type is typically not updated during that process. + */ + $filetype = wp_check_filetype( $file ); + $mime_type = $filetype['type'] ?? get_post_mime_type( $attachment_id ); + return is_string( $mime_type ) ? $mime_type : ''; +} diff --git a/plugins/webp-uploads/hooks.php b/plugins/webp-uploads/hooks.php index 8354eea92c..3f5e5014d2 100644 --- a/plugins/webp-uploads/hooks.php +++ b/plugins/webp-uploads/hooks.php @@ -52,18 +52,21 @@ * } An array with the updated structure for the metadata before is stored in the database. */ function webp_uploads_create_sources_property( array $metadata, int $attachment_id ): array { - // This should take place only on the JPEG image. - $valid_mime_transforms = webp_uploads_get_upload_image_mime_transforms(); + $file = get_attached_file( $attachment_id, true ); + // File does not exist. + if ( false === $file || ! file_exists( $file ) ) { + return $metadata; + } - // Not a supported mime type to create the sources property. - $mime_type = get_post_mime_type( $attachment_id ); - if ( ! is_string( $mime_type ) || ! isset( $valid_mime_transforms[ $mime_type ] ) ) { + $mime_type = webp_uploads_get_attachment_file_mime_type( $attachment_id, $file ); + if ( '' === $mime_type ) { return $metadata; } - $file = get_attached_file( $attachment_id, true ); - // File does not exist. - if ( false === $file || ! file_exists( $file ) ) { + $valid_mime_transforms = webp_uploads_get_upload_image_mime_transforms(); + + // Not a supported mime type to create the sources property. + if ( ! isset( $valid_mime_transforms[ $mime_type ] ) ) { return $metadata; } @@ -777,3 +780,50 @@ function webp_uploads_init(): void { } } add_action( 'init', 'webp_uploads_init' ); + +/** + * Automatically opt into extra image sizes when generating fallback images. + * + * @since 2.4.0 + * + * @global array $_wp_additional_image_sizes Associative array of additional image sizes. + */ +function webp_uploads_opt_in_extra_image_sizes(): void { + if ( ! webp_uploads_is_fallback_enabled() ) { + return; + } + + global $_wp_additional_image_sizes; + + // Modify global to mimic the "hypothetical" WP core API behavior via an additional `add_image_size()` parameter. + + if ( isset( $_wp_additional_image_sizes['1536x1536'] ) && ! isset( $_wp_additional_image_sizes['1536x1536']['provide_additional_mime_types'] ) ) { + $_wp_additional_image_sizes['1536x1536']['provide_additional_mime_types'] = true; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + } + + if ( isset( $_wp_additional_image_sizes['2048x2048'] ) && ! isset( $_wp_additional_image_sizes['2048x2048']['provide_additional_mime_types'] ) ) { + $_wp_additional_image_sizes['2048x2048']['provide_additional_mime_types'] = true; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + } +} +add_action( 'plugins_loaded', 'webp_uploads_opt_in_extra_image_sizes' ); + +/** + * Enables additional MIME type support for all image sizes based on the generate all fallback sizes settings. + * + * @since 2.4.0 + * + * @param array $allowed_sizes A map of image size names and whether they are allowed to have additional MIME types. + * @return array Modified map of image sizes with additional MIME type support. + */ +function webp_uploads_enable_additional_mime_type_support_for_all_sizes( array $allowed_sizes ): array { + if ( ! webp_uploads_should_generate_all_fallback_sizes() ) { + return $allowed_sizes; + } + + foreach ( array_keys( $allowed_sizes ) as $size ) { + $allowed_sizes[ $size ] = true; + } + + return $allowed_sizes; +} +add_filter( 'webp_uploads_image_sizes_with_additional_mime_type_support', 'webp_uploads_enable_additional_mime_type_support_for_all_sizes' ); diff --git a/plugins/webp-uploads/load.php b/plugins/webp-uploads/load.php index b8319e61a7..3ba80c299a 100644 --- a/plugins/webp-uploads/load.php +++ b/plugins/webp-uploads/load.php @@ -3,9 +3,9 @@ * Plugin Name: Modern Image Formats * Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/webp-uploads * Description: Converts images to more modern formats such as WebP or AVIF during upload. - * Requires at least: 6.5 + * Requires at least: 6.6 * Requires PHP: 7.2 - * Version: 2.2.0 + * Version: 2.4.0 * Author: WordPress Performance Team * Author URI: https://make.wordpress.org/performance/ * License: GPLv2 or later @@ -25,7 +25,7 @@ return; } -define( 'WEBP_UPLOADS_VERSION', '2.2.0' ); +define( 'WEBP_UPLOADS_VERSION', '2.4.0' ); define( 'WEBP_UPLOADS_MAIN_FILE', plugin_basename( __FILE__ ) ); require_once __DIR__ . '/helper.php'; diff --git a/plugins/webp-uploads/picture-element.php b/plugins/webp-uploads/picture-element.php index e5ea68cbfb..25714a2b4b 100644 --- a/plugins/webp-uploads/picture-element.php +++ b/plugins/webp-uploads/picture-element.php @@ -22,9 +22,15 @@ function webp_uploads_wrap_image_in_picture( string $image, string $context, int if ( 'the_content' !== $context ) { return $image; } - $image_meta = wp_get_attachment_metadata( $attachment_id ); - $original_file_mime_type = get_post_mime_type( $attachment_id ); - if ( false === $original_file_mime_type || ! isset( $image_meta['sizes'] ) ) { + + $original_file_mime_type = webp_uploads_get_attachment_file_mime_type( $attachment_id ); + if ( '' === $original_file_mime_type ) { + return $image; + } + + $image_meta = wp_get_attachment_metadata( $attachment_id ); + + if ( ! isset( $image_meta['sizes'] ) ) { return $image; } diff --git a/plugins/webp-uploads/readme.txt b/plugins/webp-uploads/readme.txt index ebf564de4a..559b3c23a7 100644 --- a/plugins/webp-uploads/readme.txt +++ b/plugins/webp-uploads/readme.txt @@ -1,8 +1,8 @@ === Modern Image Formats === Contributors: wordpressdotorg -Tested up to: 6.6 -Stable tag: 2.2.0 +Tested up to: 6.7 +Stable tag: 2.4.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, images, webp, avif, modern image formats @@ -60,6 +60,25 @@ By default, the Modern Image Formats plugin will only generate WebP versions of == Changelog == += 2.4.0 = + +**Enhancements** + +* Automatically opt into 1536x1536 and 2048x2048 sizes when generating fallback images. ([1679](https://github.com/WordPress/performance/pull/1679)) +* Convert WebP to AVIF on upload. ([1724](https://github.com/WordPress/performance/pull/1724)) +* Enable end user opt-in to generate all sizes in fallback format. ([1689](https://github.com/WordPress/performance/pull/1689)) + += 2.3.0 = + +**Enhancements** + +* Introduce `webp_uploads_get_file_mime_type` helper function. ([1642](https://github.com/WordPress/performance/pull/1642)) +* Rename `webp_uploads_get_file_mime_type` to `webp_uploads_get_attachment_file_mime_type` to clarify scope. ([1662](https://github.com/WordPress/performance/pull/1662)) + +**Bug Fixes** + +* Fix bug that would prevent uploaded images from being converted to the intended output format when having fallback formats enabled. ([1635](https://github.com/WordPress/performance/pull/1635)) + = 2.2.0 = **Enhancements** diff --git a/plugins/webp-uploads/settings.php b/plugins/webp-uploads/settings.php index c44d6c452a..fa27269c47 100644 --- a/plugins/webp-uploads/settings.php +++ b/plugins/webp-uploads/settings.php @@ -40,6 +40,18 @@ function webp_uploads_register_media_settings_field(): void { 'show_in_rest' => false, ) ); + + // Add a setting to generate fallback images in all sizes including custom sizes. + register_setting( + 'media', + 'perflab_generate_all_fallback_sizes', + array( + 'type' => 'boolean', + 'default' => false, + 'show_in_rest' => false, + ) + ); + // Add a setting to use the picture element. register_setting( 'media', @@ -96,6 +108,16 @@ function webp_uploads_add_media_settings_fields(): void { array( 'class' => 'perflab-generate-webp-and-jpeg' ) ); + // Add setting field for generating fallback images in all sizes including custom sizes. + add_settings_field( + 'perflab_generate_all_fallback_sizes', + __( 'Generate all fallback image sizes', 'webp-uploads' ), + 'webp_uploads_generate_all_fallback_sizes_callback', + 'media', + 'perflab_modern_image_format_settings', + array( 'class' => 'perflab-generate-fallback-all-sizes' ) + ); + // Add picture element support settings field. add_settings_field( 'webp_uploads_use_picture_element', @@ -178,6 +200,94 @@ function webp_uploads_generate_webp_jpeg_setting_callback(): void { + +
> +

+
+
+ +

+
+ + set_image_output_type( 'avif' ); $default_transforms = array( 'image/jpeg' => array( 'image/avif' ), - 'image/webp' => array( 'image/webp' ), + 'image/webp' => array( 'image/avif' ), 'image/avif' => array( 'image/avif' ), 'image/png' => array( 'image/avif' ), ); diff --git a/plugins/webp-uploads/tests/test-load.php b/plugins/webp-uploads/tests/test-load.php index f70fd19e9f..1aa09f5626 100644 --- a/plugins/webp-uploads/tests/test-load.php +++ b/plugins/webp-uploads/tests/test-load.php @@ -43,9 +43,13 @@ static function ( string $filename ) { /** * Don't create the original mime type for JPEG images. * + * @covers ::wp_get_attachment_metadata + * @covers ::wp_get_original_image_path + * @covers ::get_post_mime_type * @dataProvider data_provider_supported_image_types + * @dataProvider data_provider_supported_image_types_with_threshold */ - public function test_it_should_not_create_the_original_mime_type_for_jpeg_images( string $image_type ): void { + public function test_it_should_not_create_the_original_mime_type_for_jpeg_images( string $image_type, bool $above_big_image_size = false ): void { $mime_type = 'image/' . $image_type; $this->set_image_output_type( $image_type ); if ( ! webp_uploads_mime_type_supported( $mime_type ) ) { @@ -53,6 +57,16 @@ public function test_it_should_not_create_the_original_mime_type_for_jpeg_images } $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' ); + if ( $above_big_image_size ) { + // Add threshold to create a `-scaled` output image for testing. + add_filter( + 'big_image_size_threshold', + static function () { + return 850; + } + ); + } + // There should be an image_type source, but no JPEG source for the full image. $this->assertImageHasSource( $attachment_id, $mime_type ); $this->assertImageNotHasSource( $attachment_id, 'image/jpeg' ); @@ -79,6 +93,9 @@ public function test_it_should_not_create_the_original_mime_type_for_jpeg_images /** * Create the original mime type for WebP images. + * + * @covers ::wp_get_attachment_metadata + * @covers ::get_post_mime_type */ public function test_it_should_create_the_original_mime_type_as_well_with_all_the_available_sources_for_the_specified_mime(): void { update_option( 'perflab_generate_webp_and_jpeg', false ); @@ -106,45 +123,11 @@ public function test_it_should_create_the_original_mime_type_as_well_with_all_th } } - /** - * Create JPEG and output type for JPEG images, if opted in. - * - * @dataProvider data_provider_supported_image_types - */ - public function test_it_should_create_jpeg_and_webp_for_jpeg_images_if_opted_in( string $image_type ): void { - $mime_type = 'image/' . $image_type; - if ( ! webp_uploads_mime_type_supported( $mime_type ) ) { - $this->markTestSkipped( "Mime type $mime_type is not supported." ); - } - $this->set_image_output_type( $image_type ); - - update_option( 'perflab_generate_webp_and_jpeg', true ); - $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' ); - - // There should be JPEG and mime_type sources for the full image. - $this->assertImageHasSource( $attachment_id, 'image/jpeg' ); - $this->assertImageHasSource( $attachment_id, $mime_type ); - - $metadata = wp_get_attachment_metadata( $attachment_id ); - - // The full image should be a JPEG. - $this->assertArrayHasKey( 'file', $metadata ); - $this->assertStringEndsWith( $metadata['sources']['image/jpeg']['file'], $metadata['file'] ); - $this->assertStringEndsWith( $metadata['sources']['image/jpeg']['file'], get_attached_file( $attachment_id ) ); - - // The post MIME type should be JPEG. - $this->assertSame( 'image/jpeg', get_post_mime_type( $attachment_id ) ); - - // There should be JPEG and WebP sources for all sizes. - foreach ( array_keys( $metadata['sizes'] ) as $size_name ) { - $this->assertImageHasSizeSource( $attachment_id, $size_name, 'image/jpeg' ); - $this->assertImageHasSizeSource( $attachment_id, $size_name, $mime_type ); - } - } - /** * Create JPEG and output format for JPEG images, if perflab_generate_webp_and_jpeg option set. * + * @covers ::wp_get_attachment_metadata + * @covers ::get_post_mime_type * @dataProvider data_provider_supported_image_types */ public function test_it_should_create_jpeg_and_webp_for_jpeg_images_if_generate_webp_and_jpeg_set( string $image_type ): void { @@ -181,6 +164,9 @@ public function test_it_should_create_jpeg_and_webp_for_jpeg_images_if_generate_ /** * Don't create the sources property if no transform is provided. + * + * @covers ::wp_get_attachment_metadata + * @covers ::assertArrayNotHasKey */ public function test_it_should_not_create_the_sources_property_if_no_transform_is_provided(): void { add_filter( 'webp_uploads_upload_image_mime_transforms', '__return_empty_array' ); @@ -200,6 +186,8 @@ public function test_it_should_not_create_the_sources_property_if_no_transform_i /** * Create the sources property when no transform is available + * + * @covers ::wp_get_attachment_metadata */ public function test_it_should_create_the_sources_property_when_no_transform_is_available(): void { add_filter( @@ -225,6 +213,9 @@ static function () { /** * Not create the sources property if the mime is not specified on the transforms images + * + * @covers ::wp_get_attachment_metadata + * @covers ::assertArrayNotHasKey */ public function test_it_should_not_create_the_sources_property_if_the_mime_is_not_specified_on_the_transforms_images(): void { add_filter( @@ -249,6 +240,9 @@ static function () { /** * Create a WebP version with all the required properties + * + * @covers ::wp_get_attachment_metadata + * @covers ::assertFileExists */ public function test_it_should_create_a_webp_version_with_all_the_required_properties(): void { $attachment_id = self::factory()->attachment->create_upload_object( @@ -275,6 +269,8 @@ public function test_it_should_create_a_webp_version_with_all_the_required_prope /** * Create the full size images when no size is available + * + * @covers ::wp_get_attachment_metadata */ public function test_it_should_create_the_full_size_images_when_no_size_is_available(): void { add_filter( 'intermediate_image_sizes', '__return_empty_array' ); @@ -291,6 +287,9 @@ public function test_it_should_create_the_full_size_images_when_no_size_is_avail /** * Remove `scaled` suffix from the generated filename + * + * @covers ::get_attached_file + * @covers ::wp_get_attachment_metadata */ public function test_it_should_remove_scaled_suffix_from_the_generated_filename(): void { // Create JPEG and WebP to check for scaled suffix. @@ -316,6 +315,9 @@ static function () { /** * Remove the generated webp images when the attachment is deleted + * + * @covers ::get_attached_file + * @covers ::wp_get_attachment_metadata */ public function test_it_should_remove_the_generated_webp_images_when_the_attachment_is_deleted(): void { $attachment_id = self::factory()->attachment->create_upload_object( @@ -349,6 +351,9 @@ public function test_it_should_remove_the_generated_webp_images_when_the_attachm /** * Remove the attached WebP version if the attachment is force deleted + * + * @covers ::wp_delete_attachment + * @covers ::wp_get_attachment_metadata */ public function test_it_should_remove_the_attached_webp_version_if_the_attachment_is_force_deleted(): void { $attachment_id = self::factory()->attachment->create_upload_object( @@ -374,6 +379,9 @@ public function test_it_should_remove_the_attached_webp_version_if_the_attachmen /** * Remove full size images when no size image exists + * + * @covers ::wp_delete_attachment + * @covers ::wp_get_attachment_metadata */ public function test_it_should_remove_full_size_images_when_no_size_image_exists(): void { add_filter( 'intermediate_image_sizes', '__return_empty_array' ); @@ -397,6 +405,10 @@ public function test_it_should_remove_full_size_images_when_no_size_image_exists /** * Remove the attached WebP version if the attachment is force deleted after edit. + * + * @covers ::wp_delete_attachment + * @covers ::wp_get_attachment_metadata + * @covers ::get_post_meta */ public function test_it_should_remove_the_backup_sizes_and_sources_if_the_attachment_is_deleted_after_edit(): void { $attachment_id = self::factory()->attachment->create_upload_object( @@ -429,6 +441,7 @@ public function test_it_should_remove_the_backup_sizes_and_sources_if_the_attach /** * Avoid the change of URLs of images that are not part of the media library * + * @covers ::webp_uploads_update_image_references * @group webp_uploads_update_image_references */ public function test_it_should_avoid_the_change_of_urls_of_images_that_are_not_part_of_the_media_library(): void { @@ -443,6 +456,7 @@ public function test_it_should_avoid_the_change_of_urls_of_images_that_are_not_p /** * Avoid replacing not existing attachment IDs * + * @covers ::webp_uploads_update_image_references * @group webp_uploads_update_image_references */ public function test_it_should_avoid_replacing_not_existing_attachment_i_ds(): void { @@ -457,6 +471,7 @@ public function test_it_should_avoid_replacing_not_existing_attachment_i_ds(): v /** * Prevent replacing a WebP image * + * @covers ::webp_uploads_update_image_references * @group webp_uploads_update_image_references */ public function test_it_should_prevent_replacing_a_webp_image(): void { @@ -485,6 +500,8 @@ public function test_it_should_prevent_replacing_a_webp_image(): void { /** * Prevent replacing a jpg image if the image does not have the target class name + * + * @covers ::webp_uploads_update_image_references */ public function test_it_should_prevent_replacing_a_jpg_image_if_the_image_does_not_have_the_target_class_name(): void { $attachment_id = self::factory()->attachment->create_upload_object( @@ -502,6 +519,8 @@ public function test_it_should_prevent_replacing_a_jpg_image_if_the_image_does_n /** * Replace references to a JPG image to a WebP version * + * @covers ::webp_uploads_img_tag_update_mime_type + * @covers ::webp_uploads_update_image_references * @dataProvider provider_replace_images_with_different_extensions * @group webp_uploads_update_image_references */ @@ -530,6 +549,8 @@ public function test_it_should_replace_references_to_a_jpg_image_to_a_webp_versi /** * Should not replace jpeg images in the content if other mime types are disabled via filter. * + * @covers ::webp_uploads_img_tag_update_mime_type + * @covers ::webp_uploads_update_image_references * @dataProvider provider_replace_images_with_different_extensions * @group webp_uploads_update_image_references */ @@ -559,6 +580,9 @@ public function provider_replace_images_with_different_extensions(): Generator { /** * Replace all the images including the full size image + * + * @covers ::webp_uploads_img_tag_update_mime_type + * @covers ::webp_uploads_update_image_references */ public function test_it_should_replace_all_the_images_including_the_full_size_image(): void { // Create JPEG and WebP to check replacement of JPEG => WebP. @@ -586,6 +610,8 @@ public function test_it_should_replace_all_the_images_including_the_full_size_im /** * Prevent replacing an image with no available sources * + * @covers ::webp_uploads_img_tag_update_mime_type + * @covers ::webp_uploads_update_image_references * @group webp_uploads_update_image_references */ public function test_it_should_prevent_replacing_an_image_with_no_available_sources(): void { @@ -602,6 +628,8 @@ public function test_it_should_prevent_replacing_an_image_with_no_available_sour /** * Prevent update not supported images with no available sources * + * @covers ::webp_uploads_img_tag_update_mime_type + * @covers ::webp_uploads_update_image_references * @dataProvider data_provider_not_supported_webp_images * @group webp_uploads_update_image_references */ @@ -654,6 +682,8 @@ static function () { /** * Tests that we can force generating jpeg subsizes using the webp_uploads_upload_image_mime_transforms filter. + * + * @covers \Test_WebP_Uploads_Load::opt_in_to_jpeg_and_webp */ public function test_it_should_preserve_jpeg_subsizes_using_transform_filter(): void { // Create JPEG and WebP. @@ -674,6 +704,8 @@ public function test_it_should_preserve_jpeg_subsizes_using_transform_filter(): /** * Allow the upload of an image if at least one editor supports the image type * + * @covers ::wp_image_editor_supports + * @covers ::set_post_thumbnail * @dataProvider data_provider_supported_image_types * * @group current1 @@ -716,6 +748,8 @@ static function ( $editors ) { /** * Replace the featured image to the proper type when requesting the featured image. * + * @covers ::get_the_post_thumbnail + * @covers ::set_post_thumbnail * @dataProvider data_provider_supported_image_types */ public function test_it_should_replace_the_featured_image_to_webp_when_requesting_the_featured_image( string $image_type ): void { @@ -749,9 +783,24 @@ public function data_provider_supported_image_types(): array { ); } + /** + * Data provider for tests returns the supported image types to run the tests with and without threshold check. + * + * @return array> An array of valid image types. + */ + public function data_provider_supported_image_types_with_threshold(): array { + return array( + 'webp' => array( 'webp' ), + 'webp with 850 threshold' => array( 'webp', true ), + 'avif' => array( 'avif' ), + 'avif with 850 threshold' => array( 'avif', true ), + ); + } + /** * Prevent replacing an image if image was uploaded via external source or plugin. * + * @covers ::webp_uploads_update_image_references * @group webp_uploads_update_image_references */ public function test_it_should_prevent_replacing_an_image_uploaded_via_external_source(): void { @@ -774,6 +823,9 @@ static function () { /** * The image with the smaller filesize should be used when webp_uploads_discard_larger_generated_images is set to true. + * + * @covers ::webp_uploads_img_tag_update_mime_type + * @covers ::webp_uploads_update_image_references */ public function test_it_should_create_webp_when_webp_is_smaller_than_jpegs(): void { // Create JPEG and WebP. @@ -813,6 +865,9 @@ public function test_it_should_create_webp_when_webp_is_smaller_than_jpegs(): vo /** * The image with the smaller filesize should be used when webp_uploads_discard_larger_generated_images is set to true. + * + * @covers ::webp_uploads_img_tag_update_mime_type + * @covers ::webp_uploads_update_image_references */ public function test_it_should_create_webp_for_full_size_which_is_smaller_in_webp_format(): void { // Create JPEG and WebP. @@ -840,6 +895,9 @@ public function test_it_should_create_webp_for_full_size_which_is_smaller_in_web /** * The image with the smaller filesize should be used when webp_uploads_discard_larger_generated_images is set to true. + * + * @covers ::webp_uploads_img_tag_update_mime_type + * @covers ::webp_uploads_update_image_references */ public function test_it_should_create_webp_for_some_sizes_which_are_smaller_in_webp_format(): void { // Create JPEG and WebP. @@ -987,4 +1045,78 @@ public function temp_filename(): string { return $filename; } + + /** + * Test that fallback images are generated for all sizes when the `perflab_generate_all_fallback_sizes` option is enabled. + * + * @dataProvider data_provider_supported_image_types + * + * @param string $image_type The image type. + */ + public function test_it_should_generate_fallback_images_for_all_sizes_when_generate_all_fallback_sizes_option_is_enabled( string $image_type ): void { + $mime_type = 'image/' . $image_type; + + // Ensure the MIME type is supported; skip the test if not. + if ( ! webp_uploads_mime_type_supported( $mime_type ) ) { + $this->markTestSkipped( 'Mime type ' . $mime_type . ' is not supported.' ); + } + + // Register custom image sizes. + add_image_size( 'custom_size_1', 200, 200, true ); + add_image_size( 'custom_size_2', 400, 400, true ); + + // Generate image output type and fallback image. + update_option( 'perflab_generate_webp_and_jpeg', true ); + + // Generate fallback images for all sizes. + update_option( 'perflab_generate_all_fallback_sizes', true ); + + $this->set_image_output_type( $image_type ); + + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' ); + + // Clean up custom sizes. + remove_image_size( 'custom_size_1' ); + remove_image_size( 'custom_size_2' ); + + // Verify that fallback images are generated for custom sizes. + foreach ( array( 'custom_size_1', 'custom_size_2' ) as $size_name ) { + $this->assertImageHasSizeSource( $attachment_id, $size_name, 'image/jpeg' ); + $this->assertImageHasSizeSource( $attachment_id, $size_name, $mime_type ); + } + + wp_delete_attachment( $attachment_id ); + } + + /** + * Convert WebP to AVIF on uploads. + */ + public function test_that_it_should_convert_webp_to_avif_on_upload(): void { + // Ensure the AVIF MIME type is supported; skip the test if not. + if ( ! webp_uploads_mime_type_supported( 'image/avif' ) ) { + $this->markTestSkipped( 'Mime type image/avif is not supported.' ); + } + + $this->set_image_output_type( 'avif' ); + + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/balloons.webp' ); + + // There should be a AVIF source, but no WebP source for the full image. + $this->assertImageNotHasSource( $attachment_id, 'image/webp' ); + $this->assertImageHasSource( $attachment_id, 'image/avif' ); + + $metadata = wp_get_attachment_metadata( $attachment_id ); + + // The full image should be a AVIF. + $this->assertArrayHasKey( 'file', $metadata ); + $this->assertStringEndsWith( $metadata['sources']['image/avif']['file'], $metadata['file'] ); + $this->assertStringEndsWith( $metadata['sources']['image/avif']['file'], get_attached_file( $attachment_id ) ); + + // There should be a AVIF source, but no WebP source for all sizes. + foreach ( array_keys( $metadata['sizes'] ) as $size_name ) { + $this->assertImageNotHasSizeSource( $attachment_id, $size_name, 'image/webp' ); + $this->assertImageHasSizeSource( $attachment_id, $size_name, 'image/avif' ); + } + wp_delete_attachment( $attachment_id ); + } } diff --git a/plugins/webp-uploads/uninstall.php b/plugins/webp-uploads/uninstall.php index 46fba3c78d..8b3fae0aee 100644 --- a/plugins/webp-uploads/uninstall.php +++ b/plugins/webp-uploads/uninstall.php @@ -38,4 +38,5 @@ */ function webp_uploads_delete_plugin_option(): void { delete_option( 'perflab_generate_webp_and_jpeg' ); + delete_option( 'perflab_generate_all_fallback_sizes' ); } diff --git a/tests/class-optimization-detective-test-helpers.php b/tests/class-optimization-detective-test-helpers.php index ba273a3e89..bfe551fed8 100644 --- a/tests/class-optimization-detective-test-helpers.php +++ b/tests/class-optimization-detective-test-helpers.php @@ -27,6 +27,7 @@ trait Optimization_Detective_Test_Helpers { */ public function populate_url_metrics( array $elements, bool $complete = true ): void { $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); + $etag = od_get_current_url_metrics_etag( new OD_Tag_Visitor_Registry(), null, null ); // Note: Tests rely on the od_current_url_metrics_etag_data filter to set the desired value. $sample_size = $complete ? od_get_url_metrics_breakpoint_sample_size() : 1; foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $viewport_width ) { for ( $i = 0; $i < $sample_size; $i++ ) { @@ -34,6 +35,7 @@ public function populate_url_metrics( array $elements, bool $complete = true ): $slug, $this->get_sample_url_metric( array( + 'etag' => $etag, 'viewport_width' => $viewport_width, 'elements' => $elements, ) @@ -50,8 +52,8 @@ public function populate_url_metrics( array $elements, bool $complete = true ): */ public function get_sample_dom_rect(): array { return array( - 'width' => 100.1, - 'height' => 100.2, + 'width' => 500.1, + 'height' => 500.2, 'x' => 100.3, 'y' => 100.4, 'top' => 0.1, @@ -65,10 +67,13 @@ public function get_sample_dom_rect(): array { * Gets a sample URL metric. * * @phpstan-param array{ - * url?: string, - * viewport_width?: int, - * element?: ElementDataSubset, - * elements?: array + * timestamp?: float, + * etag?: non-empty-string, + * url?: string, + * viewport_width?: int, + * viewport_height?: int, + * element?: ElementDataSubset, + * elements?: array * } $params Params. * * @return OD_URL_Metric URL metric. @@ -76,9 +81,12 @@ public function get_sample_dom_rect(): array { public function get_sample_url_metric( array $params ): OD_URL_Metric { $params = array_merge( array( + 'etag' => od_get_current_url_metrics_etag( new OD_Tag_Visitor_Registry(), null, null ), // Note: Tests rely on the od_current_url_metrics_etag_data filter to set the desired value. 'url' => home_url( '/' ), 'viewport_width' => 480, 'elements' => array(), + 'timestamp' => microtime( true ), + 'extended_root' => array(), ), $params ); @@ -87,14 +95,15 @@ public function get_sample_url_metric( array $params ): OD_URL_Metric { $params['elements'][] = $params['element']; } - return new OD_URL_Metric( + $data = array_merge( array( - 'url' => home_url( '/' ), + 'etag' => $params['etag'], + 'url' => $params['url'], 'viewport' => array( 'width' => $params['viewport_width'], 'height' => $params['viewport_height'] ?? ceil( $params['viewport_width'] / 2 ), ), - 'timestamp' => microtime( true ), + 'timestamp' => $params['timestamp'], 'elements' => array_map( function ( array $element ): array { return array_merge( @@ -110,8 +119,10 @@ function ( array $element ): array { }, $params['elements'] ), - ) + ), + $params['extended_root'] ); + return new OD_URL_Metric( $data ); } /** @@ -123,4 +134,19 @@ function ( array $element ): array { public function remove_initial_tabs( string $input ): string { return (string) preg_replace( '/^\t+/m', '', $input ); } + + /** + * Gets JSON-serializable data from an array of JsonSerializable objects. + * + * @param JsonSerializable[] $items Items. + * @return array Data from items. + */ + public function get_array_json_data( array $items ): array { + return array_map( + static function ( JsonSerializable $item ) { + return $item->jsonSerialize(); + }, + $items + ); + } } diff --git a/tools/phpstan/constants.php b/tools/phpstan/constants.php index d740676c88..ae469bf4dc 100644 --- a/tools/phpstan/constants.php +++ b/tools/phpstan/constants.php @@ -17,3 +17,5 @@ define( 'IMAGE_PRIORITIZER_VERSION', '0.0.0' ); define( 'EMBED_OPTIMIZER_VERSION', '0.0.0' ); + +define( 'PERFLAB_PLUGIN_DIR_PATH', __DIR__ . '/../../plugins/performance-lab/' ); diff --git a/tools/webpack/utils.js b/tools/webpack/utils.js index 6d596e1e0f..1e85f10276 100644 --- a/tools/webpack/utils.js +++ b/tools/webpack/utils.js @@ -2,6 +2,7 @@ const fs = require( 'fs' ); const path = require( 'path' ); const { chdir } = require( 'process' ); const { spawnSync } = require( 'child_process' ); +const CssMinimizerPlugin = require( 'css-minimizer-webpack-plugin' ); /** * Return plugin root path. @@ -99,6 +100,37 @@ const assetDataTransformer = ( content, absoluteFrom ) => { return ` array(), 'version' => '${ version }');`; }; +/** + * Transformer to minify CSS content. + * + * @param {Buffer} content The content as a Buffer of the file being transformed. + * @param {string} absoluteFrom The absolute path to the file being transformed. + * + * @return {Promise} A promise that resolves to the transformed (minified) content. + */ +const cssMinifyTransformer = ( content, absoluteFrom ) => { + const cssContent = content.toString(); + + return Promise.resolve( + CssMinimizerPlugin.cssnanoMinify( + { [ absoluteFrom ]: cssContent }, + undefined, + { + preset: [ + 'default', + { + discardComments: { + removeAll: true, + }, + }, + ], + } + ) + ).then( ( result ) => { + return result.code; + } ); +}; + /** * Create plugins zip file using `zip` command. * @@ -129,5 +161,6 @@ module.exports = { getPluginVersion, generateBuildManifest, assetDataTransformer, + cssMinifyTransformer, createPluginZip, }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..137042b518 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "checkJs": true, + "noEmit": true, + "allowJs": true, + "target": "es2020", + "moduleResolution": "node", + "module": "esnext", + "skipLibCheck": true + }, + "include": [ + "plugins/**/*.js", + "plugins/**/*.ts", + ], + "exclude": [ + "plugins/*/build/*" + ] +} diff --git a/webpack.config.js b/webpack.config.js index 49f965ab45..faaaa32677 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -12,6 +12,7 @@ const { plugins: standalonePlugins } = require( './plugins.json' ); const { createPluginZip, assetDataTransformer, + cssMinifyTransformer, deleteFileOrDirectory, generateBuildManifest, } = require( './tools/webpack/utils' ); @@ -34,7 +35,132 @@ const sharedConfig = { }; // Store plugins that require build process. -const pluginsWithBuild = [ 'optimization-detective', 'web-worker-offloading' ]; +const pluginsWithBuild = [ + 'performance-lab', + 'embed-optimizer', + 'image-prioritizer', + 'optimization-detective', + 'web-worker-offloading', +]; + +/** + * Webpack Config: Performance Lab + * + * @param {*} env Webpack environment + * @return {Object} Webpack configuration + */ +const performanceLab = ( env ) => { + if ( env.plugin && env.plugin !== 'performance-lab' ) { + return defaultBuildConfig; + } + + const pluginDir = path.resolve( __dirname, 'plugins/performance-lab' ); + + return { + ...sharedConfig, + name: 'performance-lab', + plugins: [ + new CopyWebpackPlugin( { + patterns: [ + { + from: `${ pluginDir }/includes/admin/plugin-activate-ajax.js`, + to: `${ pluginDir }/includes/admin/plugin-activate-ajax.min.js`, + }, + ], + } ), + new WebpackBar( { + name: 'Building Performance Lab Assets', + color: '#2196f3', + } ), + ], + }; +}; + +/** + * Webpack Config: Embed Optimizer + * + * @param {*} env Webpack environment + * @return {Object} Webpack configuration + */ +const embedOptimizer = ( env ) => { + if ( env.plugin && env.plugin !== 'embed-optimizer' ) { + return defaultBuildConfig; + } + + const pluginDir = path.resolve( __dirname, 'plugins/embed-optimizer' ); + + return { + ...sharedConfig, + name: 'embed-optimizer', + plugins: [ + new CopyWebpackPlugin( { + patterns: [ + { + from: `${ pluginDir }/detect.js`, + to: `${ pluginDir }/detect.min.js`, + }, + { + from: `${ pluginDir }/lazy-load.js`, + to: `${ pluginDir }/lazy-load.min.js`, + }, + ], + } ), + new WebpackBar( { + name: 'Building Embed Optimizer Assets', + color: '#2196f3', + } ), + ], + }; +}; + +/** + * Webpack Config: Image Prioritizer + * + * @param {*} env Webpack environment + * @return {Object} Webpack configuration + */ +const imagePrioritizer = ( env ) => { + if ( env.plugin && env.plugin !== 'image-prioritizer' ) { + return defaultBuildConfig; + } + + const pluginDir = path.resolve( __dirname, 'plugins/image-prioritizer' ); + + return { + ...sharedConfig, + name: 'image-prioritizer', + plugins: [ + new CopyWebpackPlugin( { + patterns: [ + { + from: `${ pluginDir }/detect.js`, + to: `${ pluginDir }/detect.min.js`, + }, + { + from: `${ pluginDir }/lazy-load-video.js`, + to: `${ pluginDir }/lazy-load-video.min.js`, + }, + { + from: `${ pluginDir }/lazy-load-bg-image.js`, + to: `${ pluginDir }/lazy-load-bg-image.min.js`, + }, + { + from: `${ pluginDir }/lazy-load-bg-image.css`, + to: `${ pluginDir }/lazy-load-bg-image.min.css`, + transform: { + transformer: cssMinifyTransformer, + cache: false, + }, + }, + ], + } ), + new WebpackBar( { + name: 'Building Image Prioritizer Assets', + color: '#2196f3', + } ), + ], + }; +}; /** * Webpack Config: Optimization Detective @@ -50,7 +176,7 @@ const optimizationDetective = ( env ) => { const source = path.resolve( __dirname, 'node_modules/web-vitals' ); const destination = path.resolve( __dirname, - 'plugins/optimization-detective/build' + 'plugins/optimization-detective' ); return { @@ -61,16 +187,21 @@ const optimizationDetective = ( env ) => { patterns: [ { from: `${ source }/dist/web-vitals.js`, - to: `${ destination }/web-vitals.js`, + to: `${ destination }/build/web-vitals.js`, + info: { minimized: true }, }, { from: `${ source }/package.json`, - to: `${ destination }/web-vitals.asset.php`, + to: `${ destination }/build/web-vitals.asset.php`, transform: { transformer: assetDataTransformer, cache: false, }, }, + { + from: `${ destination }/detect.js`, + to: `${ destination }/detect.min.js`, + }, ], } ), new WebpackBar( { @@ -110,6 +241,7 @@ const webWorkerOffloading = ( env ) => { { from: `${ source }/lib/`, to: `${ destination }`, + info: { minimized: true }, }, { from: `${ source }/package.json`, @@ -164,6 +296,7 @@ const buildPlugin = ( env ) => { { from, to, + info: { minimized: true }, globOptions: { dot: true, ignore: [ @@ -203,4 +336,11 @@ const buildPlugin = ( env ) => { }; }; -module.exports = [ optimizationDetective, webWorkerOffloading, buildPlugin ]; +module.exports = [ + performanceLab, + embedOptimizer, + imagePrioritizer, + optimizationDetective, + webWorkerOffloading, + buildPlugin, +];