diff --git a/.github/workflows/deploy-plugins.yml b/.github/workflows/deploy-plugins.yml index 7a3c286d39..4cd7d41bcf 100644 --- a/.github/workflows/deploy-plugins.yml +++ b/.github/workflows/deploy-plugins.yml @@ -69,9 +69,6 @@ jobs: - name: Install npm dependencies run: npm ci - - name: Check plugin versions - run: npm run versions -- --plugin=${{ matrix.plugin }} - - name: Build plugin run: npm run build:plugin:${{ matrix.plugin }} @@ -101,6 +98,10 @@ jobs: echo "deploy=true" >> $GITHUB_OUTPUT + - name: Check plugin version integrity + if: steps.check-deployment.outputs.deploy == 'true' + run: npm run versions -- --plugin=${{ matrix.plugin }} + - name: Create zip file if: steps.check-deployment.outputs.deploy == 'true' run: | diff --git a/composer.lock b/composer.lock index f2daba96fe..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", @@ -2131,16 +2132,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.11.1", + "version": "3.11.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87" + "reference": "1368f4a58c3c52114b86b1abe8f4098869cb0079" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/19473c30efe4f7b3cd42522d0b2e6e7f243c6f87", - "reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/1368f4a58c3c52114b86b1abe8f4098869cb0079", + "reference": "1368f4a58c3c52114b86b1abe8f4098869cb0079", "shasum": "" }, "require": { @@ -2207,7 +2208,7 @@ "type": "open_collective" } ], - "time": "2024-11-16T12:02:36+00:00" + "time": "2024-12-11T16:04:26+00:00" }, { "name": "symfony/polyfill-php73", diff --git a/package-lock.json b/package-lock.json index 8c2d919131..558323f571 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,16 +12,16 @@ }, "devDependencies": { "@octokit/rest": "^21.0.2", - "@wordpress/env": "^10.13.0", - "@wordpress/prettier-config": "^4.13.0", - "@wordpress/scripts": "^30.6.0", - "commander": "12.1.0", + "@wordpress/env": "^10.14.0", + "@wordpress/prettier-config": "^4.14.0", + "@wordpress/scripts": "^30.7.0", + "commander": "13.0.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.7", - "lint-staged": "^15.2.10", + "lint-staged": "^15.3.0", "lodash": "4.17.21", "micromatch": "^4.0.8", "npm-run-all": "^4.1.5", @@ -2272,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", @@ -2970,6 +3060,312 @@ "@octokit/openapi-types": "^22.2.0" } }, + "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": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "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": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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, + "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.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", @@ -3055,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", @@ -3077,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" @@ -3253,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" @@ -3815,6 +4211,26 @@ "@types/node": "*" } }, + "node_modules/@types/eslint": { + "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": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "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": "*", + "@types/estree": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -4437,148 +4853,148 @@ "dev": true }, "node_modules/@webassemblyjs/ast": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", - "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "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.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "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.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", - "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "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.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.12.1" + "@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.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", - "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "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.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-opt": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1", - "@webassemblyjs/wast-printer": "1.12.1" + "@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.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", - "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "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.12.1", - "@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.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", - "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "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.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1" + "@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.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", - "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "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.12.1", - "@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.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", - "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "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.12.1", + "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, @@ -4713,11 +5129,10 @@ } }, "node_modules/@wordpress/env": { - "version": "10.13.0", - "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-10.13.0.tgz", - "integrity": "sha512-Q7ay+/jZ+O/Pkc65LDJ5BzoqTT/B0+gDgvYnWMyySPiMpFz+iQ+XoQibrj3VneiQDH7nJjtk/ZuyPHu7wGdlBg==", + "version": "10.14.0", + "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-10.14.0.tgz", + "integrity": "sha512-tDJyW6KaaEs9jz2XMTjY0RpGWdsjEfOCx5jeCMWtzkgrDY5N9iZr1BFjNzmFzY1BcXQshnFsrecsnYdyIfvsTA==", "dev": true, - "license": "GPL-2.0-or-later", "dependencies": { "chalk": "^4.0.0", "copy-dir": "^1.3.0", @@ -4727,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" @@ -4740,6 +5155,65 @@ "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.3.0", "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-21.3.0.tgz", @@ -4876,11 +5350,10 @@ } }, "node_modules/@wordpress/prettier-config": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.13.0.tgz", - "integrity": "sha512-TgjsY0dU6fwtQs4Re73OlKZnxilaHbXmwb373qouDY/AzG72VkpQpQ2KcenCoJ7Do1BKdWWehzDo609nQhk/Yg==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-4.14.0.tgz", + "integrity": "sha512-DZuASK64Jr8ycj9uaSlwsTURiaQ0sgQnu9ThgSK196jcDF1jxTll8JGoVIXgxhKgo3mFFjdtkNeBZ38DT5z6/g==", "dev": true, - "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", "npm": ">=8.19.2" @@ -4890,11 +5363,10 @@ } }, "node_modules/@wordpress/scripts": { - "version": "30.6.0", - "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-30.6.0.tgz", - "integrity": "sha512-2i6wqCdvCcf00/vLi7twNzHUdAAOo8Uk0lqntZiBKpVrjHyLkzjmPwT3So6W/VYso5QMzEXRXYVHVKGE4wX4rg==", + "version": "30.7.0", + "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-30.7.0.tgz", + "integrity": "sha512-vwrf6Xo1GXV2ug4xdYMgZ2CVpNNfArOEJyX6w9CafIRmLOm8GkVGSza0VlEoOh1BTqQPv/awq6uiOKVMbVNB5Q==", "dev": true, - "license": "GPL-2.0-or-later", "dependencies": { "@babel/core": "7.25.7", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", @@ -4917,7 +5389,7 @@ "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", @@ -4927,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-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.10", "url-loader": "^4.1.1", - "webpack": "^5.95.0", + "webpack": "^5.97.0", "webpack-bundle-analyzer": "^4.9.1", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1" @@ -5151,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" @@ -5172,15 +5644,6 @@ "acorn-walk": "^8.0.2" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "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", @@ -5689,12 +6152,12 @@ } }, "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" } @@ -5955,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": { @@ -5967,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 }, @@ -5984,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": { @@ -6546,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", @@ -6637,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" @@ -6649,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": { @@ -6817,9 +7279,9 @@ } }, "node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz", + "integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==", "dev": true, "engines": { "node": ">=18" @@ -7256,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", @@ -8598,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", @@ -8784,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", @@ -9689,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", @@ -9758,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", @@ -9791,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", @@ -9937,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", @@ -9963,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", @@ -10653,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", @@ -10802,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" @@ -11516,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": { @@ -12379,6 +12811,21 @@ "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": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -12529,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" @@ -13039,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" } @@ -13670,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" @@ -13697,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.3.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.3.0.tgz", + "integrity": "sha512-vHFahytLoF2enJklgtOtCtIjZrKD/LoxlaUusd5nh7dWv/dkKQJY74ndFSzxCdv7g0ueGg1ORgTSt4Y9LPZn9A==", "dev": true, "dependencies": { - "chalk": "~5.3.0", + "chalk": "~5.4.1", "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" @@ -13724,9 +14171,9 @@ } }, "node_modules/lint-staged/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", "dev": true, "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -13735,27 +14182,22 @@ "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==", + "node_modules/lint-staged/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, "engines": { - "node": ">= 8" + "node": ">=18" } }, "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" @@ -13834,6 +14276,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", @@ -13876,27 +14324,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", @@ -13921,25 +14348,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" @@ -13949,9 +14361,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", @@ -13966,9 +14378,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" @@ -13990,9 +14402,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": { @@ -14221,9 +14633,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" @@ -14260,9 +14672,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": { @@ -14846,12 +15258,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" @@ -14865,15 +15278,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", @@ -14899,9 +15312,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", @@ -14910,7 +15323,7 @@ "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">= 12.13.0" + "node": ">= 10.13.0" }, "funding": { "type": "opencollective", @@ -14976,6 +15389,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", @@ -15120,6 +15542,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", @@ -16019,32 +16448,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" } @@ -16063,12 +16489,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": { @@ -16088,6 +16514,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", @@ -16223,6 +16655,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", @@ -17220,32 +17674,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" } @@ -17264,12 +17715,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": { @@ -17303,12 +17754,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", @@ -17335,15 +17780,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" }, @@ -17352,9 +17797,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" @@ -17369,9 +17814,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": { @@ -18161,13 +18606,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": { @@ -18175,33 +18620,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": { @@ -18212,9 +18659,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", @@ -18699,12 +19177,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" }, @@ -18713,13 +19191,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" } @@ -18795,9 +19270,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", @@ -18968,9 +19443,9 @@ "dev": true }, "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", @@ -19026,6 +19501,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", @@ -19169,6 +19674,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", @@ -20631,13 +21149,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" @@ -20713,18 +21231,18 @@ } }, "node_modules/webpack": { - "version": "5.95.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.95.0.tgz", - "integrity": "sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q==", + "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/estree": "^1.0.5", - "@webassemblyjs/ast": "^1.12.1", - "@webassemblyjs/wasm-edit": "^1.12.1", - "@webassemblyjs/wasm-parser": "^1.12.1", - "acorn": "^8.7.1", - "acorn-import-attributes": "^1.9.5", - "browserslist": "^4.21.10", + "@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.17.1", "es-module-lexer": "^1.2.1", @@ -20869,56 +21387,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", @@ -21402,6 +21870,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 123184509d..f8ec87cd84 100644 --- a/package.json +++ b/package.json @@ -12,16 +12,16 @@ }, "devDependencies": { "@octokit/rest": "^21.0.2", - "@wordpress/env": "^10.13.0", - "@wordpress/prettier-config": "^4.13.0", - "@wordpress/scripts": "^30.6.0", - "commander": "12.1.0", + "@wordpress/env": "^10.14.0", + "@wordpress/prettier-config": "^4.14.0", + "@wordpress/scripts": "^30.7.0", + "commander": "13.0.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.7", - "lint-staged": "^15.2.10", + "lint-staged": "^15.3.0", "lodash": "4.17.21", "micromatch": "^4.0.8", "npm-run-all": "^4.1.5", 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 abd90c6321..dc549e485f 100644 --- a/plugins/auto-sizes/auto-sizes.php +++ b/plugins/auto-sizes/auto-sizes.php @@ -5,7 +5,7 @@ * Description: Improves responsive images with better sizes calculations and auto-sizes for lazy-loaded images. * 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 b5cca63074..73a53923f5 100644 --- a/plugins/auto-sizes/readme.txt +++ b/plugins/auto-sizes/readme.txt @@ -2,7 +2,7 @@ Contributors: wordpressdotorg Tested up to: 6.7 -Stable tag: 1.3.0 +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 e2e3c0c1bc..56a7f51b7d 100644 --- a/plugins/dominant-color-images/load.php +++ b/plugins/dominant-color-images/load.php @@ -5,7 +5,7 @@ * Description: Displays placeholders based on an image's dominant color while the image is loading. * 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 b639dc4e53..1aa6df61f6 100644 --- a/plugins/dominant-color-images/readme.txt +++ b/plugins/dominant-color-images/readme.txt @@ -2,7 +2,7 @@ Contributors: wordpressdotorg Tested up to: 6.7 -Stable tag: 1.1.2 +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 a1d02b98f1..9c30bdfc70 100644 --- a/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php +++ b/plugins/embed-optimizer/class-embed-optimizer-tag-visitor.php @@ -81,7 +81,7 @@ private function is_embed_wrapper( OD_HTML_Tag_Processor $processor ): bool { * Otherwise, if the embed is not in any initial viewport, it will add lazy-loading logic. * * @since 0.2.0 - * @since n.e.x.t Adds preconnect links for each viewport group and skips if the element is not in the viewport for that group. + * @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. diff --git a/plugins/embed-optimizer/detect.js b/plugins/embed-optimizer/detect.js index 1aab3d0838..38068387b0 100644 --- a/plugins/embed-optimizer/detect.js +++ b/plugins/embed-optimizer/detect.js @@ -51,7 +51,7 @@ const loadedElementContentRects = new Map(); * @type {InitializeCallback} * @param {InitializeArgs} args Args. */ -export function initialize( { isDebug } ) { +export async function initialize( { isDebug } ) { /** @type NodeListOf */ const embedWrappers = document.querySelectorAll( '.wp-block-embed > .wp-block-embed__wrapper[data-od-xpath]' diff --git a/plugins/embed-optimizer/hooks.php b/plugins/embed-optimizer/hooks.php index 988a5a2dfd..560a6096dc 100644 --- a/plugins/embed-optimizer/hooks.php +++ b/plugins/embed-optimizer/hooks.php @@ -121,7 +121,7 @@ function embed_optimizer_filter_extension_module_urls( $extension_module_urls ): 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' ) ); + $extension_module_urls[] = plugins_url( add_query_arg( 'ver', EMBED_OPTIMIZER_VERSION, embed_optimizer_get_asset_path( 'detect.js' ) ), __FILE__ ); return $extension_module_urls; } @@ -428,7 +428,7 @@ function embed_optimizer_render_generator(): void { /** * Gets the path to a script or stylesheet. * - * @since n.e.x.t + * @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. diff --git a/plugins/embed-optimizer/load.php b/plugins/embed-optimizer/load.php index 4510fb5dc9..eda818a6ec 100644 --- a/plugins/embed-optimizer/load.php +++ b/plugins/embed-optimizer/load.php @@ -5,7 +5,7 @@ * 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.3.0 + * Version: 0.4.0 * Author: WordPress Performance Team * Author URI: https://make.wordpress.org/performance/ * License: GPLv2 or later @@ -70,7 +70,7 @@ static function ( string $global_var_name, string $version, Closure $load ): voi } )( 'embed_optimizer_pending_plugin', - '0.3.0', + '0.4.0', static function ( string $version ): void { if ( defined( 'EMBED_OPTIMIZER_VERSION' ) ) { return; diff --git a/plugins/embed-optimizer/readme.txt b/plugins/embed-optimizer/readme.txt index b02756cfbb..65c30a2a15 100644 --- a/plugins/embed-optimizer/readme.txt +++ b/plugins/embed-optimizer/readme.txt @@ -2,7 +2,7 @@ Contributors: wordpressdotorg Tested up to: 6.7 -Stable tag: 0.3.0 +Stable tag: 0.4.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, embeds @@ -15,9 +15,9 @@ This plugin's purpose is to optimize the performance of [embeds in WordPress](ht 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 +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. @@ -67,6 +67,13 @@ 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** 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 a778f867dc..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,6 +14,13 @@ /** * 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 */ @@ -22,7 +29,7 @@ final class Image_Prioritizer_Background_Image_Styled_Tag_Visitor extends Image_ /** * Class name used to indicate a background image which is lazy-loaded. * - * @since n.e.x.t + * @since 0.3.0 * @var string */ const LAZY_BG_IMAGE_CLASS_NAME = 'od-lazy-bg-image'; @@ -30,11 +37,19 @@ final class Image_Prioritizer_Background_Image_Styled_Tag_Visitor extends Image_ /** * Whether the lazy-loading script and stylesheet have been added. * - * @since n.e.x.t + * @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. * @@ -65,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; } @@ -72,19 +88,7 @@ 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( - 'rel' => 'preload', - 'fetchpriority' => 'high', - 'as' => 'image', - 'href' => $background_image_url, - 'media' => 'screen', - ); - - $context->link_collection->add_link( - $link_attributes, - $group->get_minimum_viewport_width(), - $group->get_maximum_viewport_width() - ); + $this->add_image_preload_link( $context->link_collection, $group, $background_image_url ); } $this->lazy_load_bg_images( $context ); @@ -92,10 +96,116 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { 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' => $url, + 'media' => 'screen', + ), + $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 n.e.x.t + * @since 0.3.0 * * @param OD_Tag_Visitor_Context $context Tag visitor context, with the cursor currently at block with a background image. */ 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 ba14edf57e..15a3008ce4 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-img-tag-visitor.php @@ -25,7 +25,7 @@ final class Image_Prioritizer_Img_Tag_Visitor extends Image_Prioritizer_Tag_Visi * Visits a tag. * * @since 0.1.0 - * @since n.e.x.t Separate the processing of IMG and PICTURE elements. + * @since 0.3.0 Separate the processing of IMG and PICTURE elements. * * @param OD_Tag_Visitor_Context $context Tag visitor context. * @return bool Whether the tag should be tracked in URL Metrics. @@ -46,7 +46,7 @@ public function __invoke( OD_Tag_Visitor_Context $context ): bool { /** * Process an IMG element. * - * @since n.e.x.t + * @since 0.3.0 * * @param OD_HTML_Tag_Processor $processor HTML tag processor. * @param OD_Tag_Visitor_Context $context Tag visitor context. @@ -183,7 +183,7 @@ private function process_img( OD_HTML_Tag_Processor $processor, OD_Tag_Visitor_C /** * Process a PICTURE element. * - * @since n.e.x.t + * @since 0.3.0 * * @param OD_HTML_Tag_Processor $processor HTML tag processor. * @param OD_Tag_Visitor_Context $context Tag visitor context. @@ -283,7 +283,7 @@ private function process_picture( OD_HTML_Tag_Processor $processor, OD_Tag_Visit * 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 n.e.x.t + * @since 0.3.0 * * @param OD_HTML_Tag_Processor $processor Processor. * @param 'src'|'srcset' $attribute_name Attribute name. @@ -304,7 +304,7 @@ private function get_valid_src( OD_HTML_Tag_Processor $processor, string $attrib /** * Adds a LINK with the supplied attributes for each viewport group when the provided XPath is the LCP element. * - * @since n.e.x.t + * @since 0.3.0 * * @param OD_Tag_Visitor_Context $context Tag visitor context. * @param string $xpath XPath of the element. @@ -351,7 +351,7 @@ static function ( $attribute_value ) { /** * Gets the parent tag name. * - * @since n.e.x.t + * @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. diff --git a/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php b/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php index 1e940fab01..4399d7a40e 100644 --- a/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php +++ b/plugins/image-prioritizer/class-image-prioritizer-video-tag-visitor.php @@ -17,7 +17,6 @@ * Image Prioritizer: Image_Prioritizer_Video_Tag_Visitor class * * @since 0.2.0 - * * @access private */ final class Image_Prioritizer_Video_Tag_Visitor extends Image_Prioritizer_Tag_Visitor { diff --git a/plugins/image-prioritizer/detect.js b/plugins/image-prioritizer/detect.js new file mode 100644 index 0000000000..63c1c27705 --- /dev/null +++ b/plugins/image-prioritizer/detect.js @@ -0,0 +1,179 @@ +/** + * Image Prioritizer module for Optimization Detective + * + * This extension to Optimization Detective captures the LCP element's CSS background image which is not defined with + * an inline style attribute but rather in either an external stylesheet loaded with a LINK tag or by stylesheet in + * a STYLE element. The URL for this LCP background image and the tag's name, ID, and class are all amended to the + * stored URL Metric so that a responsive preload link with fetchpriority=high will be added for that background image + * once a URL Metric group is fully populated with URL Metrics that all agree on that being the LCP image, and if the + * document has a tag with the same name, ID, and class. + */ + +const consoleLogPrefix = '[Image Prioritizer]'; + +/** + * Detected LCP external background image candidates. + * + * @type {Array<{ + * url: string, + * tag: string, + * id: string|null, + * class: string|null, + * }>} + */ +const externalBackgroundImages = []; + +/** + * @typedef {import("web-vitals").LCPMetric} LCPMetric + * @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 + */ + +/** + * Logs a message. + * + * @since 0.3.0 + * + * @param {...*} message + */ +function log( ...message ) { + // eslint-disable-next-line no-console + console.log( consoleLogPrefix, ...message ); +} + +/** + * Initializes extension. + * + * @since 0.3.0 + * + * @type {InitializeCallback} + * @param {InitializeArgs} args Args. + */ +export async function initialize( { isDebug, onLCP } ) { + onLCP( + ( metric ) => { + handleLCPMetric( metric, isDebug ); + }, + { + // This avoids needing to click to finalize LCP candidate. While this is helpful for testing, it also + // ensures that we always get an LCP candidate reported. Otherwise, the callback may never fire if the + // user never does a click or keydown, per . + reportAllChanges: true, + } + ); +} + +/** + * Handles a new LCP metric being reported. + * + * @since 0.3.0 + * + * @param {LCPMetric} metric - LCP Metric. + * @param {boolean} isDebug - Whether in debug mode. + */ +function handleLCPMetric( metric, isDebug ) { + for ( const entry of metric.entries ) { + // Look only for LCP entries that have a URL and a corresponding element which is not an IMG or VIDEO. + if ( + ! entry.url || + ! ( entry.element instanceof HTMLElement ) || + entry.element instanceof HTMLImageElement || + entry.element instanceof HTMLVideoElement + ) { + continue; + } + + // Always ignore data: URLs. + if ( entry.url.startsWith( 'data:' ) ) { + continue; + } + + // Skip elements that have the background image defined inline. + // These are handled by Image_Prioritizer_Background_Image_Styled_Tag_Visitor. + if ( entry.element.style.backgroundImage ) { + continue; + } + + // Skip URLs that are excessively long. This is the maxLength defined in image_prioritizer_add_element_item_schema_properties(). + if ( entry.url.length > 500 ) { + if ( isDebug ) { + log( `Skipping very long URL: ${ entry.url }` ); + } + return; + } + + // Also skip Custom Elements which have excessively long tag names. This is the maxLength defined in image_prioritizer_add_element_item_schema_properties(). + if ( entry.element.tagName.length > 100 ) { + if ( isDebug ) { + log( + `Skipping very long tag name: ${ entry.element.tagName }` + ); + } + return; + } + + // Note that getAttribute() is used instead of properties so that null can be returned in case of an absent attribute. + // The maxLengths are defined in image_prioritizer_add_element_item_schema_properties(). + const id = entry.element.getAttribute( 'id' ); + if ( typeof id === 'string' && id.length > 100 ) { + if ( isDebug ) { + log( `Skipping very long ID: ${ id }` ); + } + return; + } + const className = entry.element.getAttribute( 'class' ); + if ( typeof className === 'string' && className.length > 500 ) { + if ( isDebug ) { + log( `Skipping very long className: ${ className }` ); + } + return; + } + + // The id and className allow the tag visitor to detect whether the element is still in the document. + // This is used instead of having a full XPath which is likely not available since the tag visitor would not + // know to return true for this element since it has no awareness of which elements have external backgrounds. + const externalBackgroundImage = { + url: entry.url, + tag: entry.element.tagName, + id, + class: className, + }; + + if ( isDebug ) { + log( + 'Detected external LCP background image:', + externalBackgroundImage + ); + } + + externalBackgroundImages.push( externalBackgroundImage ); + } +} + +/** + * Finalizes extension. + * + * @since 0.3.0 + * + * @type {FinalizeCallback} + * @param {FinalizeArgs} args Args. + */ +export async function finalize( { extendRootData, isDebug } ) { + if ( externalBackgroundImages.length === 0 ) { + return; + } + + // Get the last detected external background image which is going to be for the LCP element (or very likely will be). + const lcpElementExternalBackgroundImage = externalBackgroundImages.pop(); + + if ( isDebug ) { + log( + 'Sending external background image for LCP element:', + lcpElementExternalBackgroundImage + ); + } + + extendRootData( { lcpElementExternalBackgroundImage } ); +} diff --git a/plugins/image-prioritizer/helper.php b/plugins/image-prioritizer/helper.php index a62677c542..789a6a8439 100644 --- a/plugins/image-prioritizer/helper.php +++ b/plugins/image-prioritizer/helper.php @@ -14,11 +14,12 @@ * Initializes Image Prioritizer when Optimization Detective has loaded. * * @since 0.2.0 + * @access private * * @param string $optimization_detective_version Current version of the optimization detective plugin. */ function image_prioritizer_init( string $optimization_detective_version ): void { - $required_od_version = '0.7.0'; + $required_od_version = '0.9.0'; if ( ! version_compare( (string) strtok( $optimization_detective_version, '-' ), $required_od_version, '>=' ) ) { add_action( 'admin_notices', @@ -52,6 +53,7 @@ static function (): void { * See {@see 'wp_head'}. * * @since 0.1.0 + * @access private */ function image_prioritizer_render_generator_meta_tag(): void { // Use the plugin slug as it is immutable. @@ -62,6 +64,7 @@ function image_prioritizer_render_generator_meta_tag(): void { * Registers tag visitors. * * @since 0.1.0 + * @access private * * @param OD_Tag_Visitor_Registry $registry Tag visitor registry. */ @@ -77,14 +80,255 @@ function image_prioritizer_register_tag_visitors( OD_Tag_Visitor_Registry $regis $registry->register( 'image-prioritizer/video', $video_visitor ); } +/** + * Filters the list of Optimization Detective extension module URLs to include the extension for Image Prioritizer. + * + * @since 0.3.0 + * @access private + * + * @param string[]|mixed $extension_module_urls Extension module URLs. + * @return string[] Extension module URLs. + */ +function image_prioritizer_filter_extension_module_urls( $extension_module_urls ): array { + if ( ! is_array( $extension_module_urls ) ) { + $extension_module_urls = array(); + } + $extension_module_urls[] = plugins_url( add_query_arg( 'ver', IMAGE_PRIORITIZER_VERSION, image_prioritizer_get_asset_path( 'detect.js' ) ), __FILE__ ); + return $extension_module_urls; +} + +/** + * Filters additional properties for the element item schema for Optimization Detective. + * + * @since 0.3.0 + * @access private + * + * @param array $additional_properties Additional properties. + * @return array Additional properties. + */ +function image_prioritizer_add_element_item_schema_properties( array $additional_properties ): array { + $additional_properties['lcpElementExternalBackgroundImage'] = array( + 'type' => 'object', + 'properties' => array( + 'url' => array( + 'type' => 'string', + 'format' => 'uri', // Note: This is excessively lax, as it is used exclusively in rest_sanitize_value_from_schema() and not in rest_validate_value_from_schema(). + 'pattern' => '^https?://', + 'required' => true, + 'maxLength' => 500, // Image URLs can be quite long. + ), + 'tag' => array( + 'type' => 'string', + 'required' => true, + 'minLength' => 1, + // The longest HTML tag name is 10 characters (BLOCKQUOTE and FIGCAPTION), but SVG tag names can be longer + // (e.g. feComponentTransfer). This maxLength accounts for possible Custom Elements that are even longer, + // although the longest known Custom Element from HTTP Archive is 32 characters. See data from . + 'maxLength' => 100, + 'pattern' => '^[a-zA-Z0-9\-]+\z', // Technically emoji can be allowed in a custom element's tag name, but this is not supported here. + ), + 'id' => array( + 'type' => array( 'string', 'null' ), + 'maxLength' => 100, // A reasonable upper-bound length for a long ID. + 'required' => true, + ), + 'class' => array( + 'type' => array( 'string', 'null' ), + 'maxLength' => 500, // There can be a ton of class names on an element. + 'required' => true, + ), + ), + ); + return $additional_properties; +} + +/** + * Validates URL for a background image. + * + * @since 0.3.0 + * @access private + * + * @param string $url Background image URL. + * @return true|WP_Error Validity. + */ +function image_prioritizer_validate_background_image_url( string $url ) { + $parsed_url = wp_parse_url( $url ); + if ( false === $parsed_url || ! isset( $parsed_url['host'] ) ) { + return new WP_Error( + 'background_image_url_lacks_host', + __( 'Supplied background image URL does not have a host.', 'image-prioritizer' ) + ); + } + + $allowed_hosts = array_map( + static function ( $host ) { + return wp_parse_url( $host, PHP_URL_HOST ); + }, + get_allowed_http_origins() + ); + + // Obtain the host of an image attachment's URL in case a CDN is pointing all images to an origin other than the home or site URLs. + $image_attachment_query = new WP_Query( + array( + 'post_type' => 'attachment', + 'post_mime_type' => 'image', + 'post_status' => 'inherit', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'no_found_rows' => true, + 'update_post_term_cache' => false, // Note that update_post_meta_cache is not included as well because wp_get_attachment_image_src() needs postmeta. + ) + ); + if ( isset( $image_attachment_query->posts[0] ) && is_int( $image_attachment_query->posts[0] ) ) { + $src = wp_get_attachment_image_src( $image_attachment_query->posts[0] ); + if ( is_array( $src ) ) { + $attachment_image_src_host = wp_parse_url( $src[0], PHP_URL_HOST ); + if ( is_string( $attachment_image_src_host ) ) { + $allowed_hosts[] = $attachment_image_src_host; + } + } + } + + // Validate that the background image URL is for an allowed host. + if ( ! in_array( $parsed_url['host'], $allowed_hosts, true ) ) { + return new WP_Error( + 'disallowed_background_image_url_host', + sprintf( + /* translators: %s is the list of allowed hosts */ + __( 'Background image URL host is not among allowed: %s.', 'image-prioritizer' ), + join( ', ', array_unique( $allowed_hosts ) ) + ) + ); + } + + // Validate that the URL points to a valid resource. + $r = wp_safe_remote_head( + $url, + array( + 'redirection' => 3, // Allow up to 3 redirects. + ) + ); + if ( $r instanceof WP_Error ) { + return $r; + } + $response_code = wp_remote_retrieve_response_code( $r ); + if ( $response_code < 200 || $response_code >= 400 ) { + return new WP_Error( + 'background_image_response_not_ok', + sprintf( + /* translators: %s is the HTTP status code */ + __( 'HEAD request for background image URL did not return with a success status code: %s.', 'image-prioritizer' ), + $response_code + ) + ); + } + + // Validate that the Content-Type is an image. + $content_type = (array) wp_remote_retrieve_header( $r, 'content-type' ); + if ( ! is_string( $content_type[0] ) || ! str_starts_with( $content_type[0], 'image/' ) ) { + return new WP_Error( + 'background_image_response_not_image', + sprintf( + /* translators: %s is the content type of the response */ + __( 'HEAD request for background image URL did not return an image Content-Type: %s.', 'image-prioritizer' ), + $content_type[0] + ) + ); + } + + /* + * Validate that the Content-Length is not too massive, as it would be better to err on the side of + * not preloading something so weighty in case the image won't actually end up as LCP. + * The value of 2MB is chosen because according to Web Almanac 2022, the largest image by byte size + * on a page is 1MB at the 90th percentile: . + * The 2MB value is double this 1MB size. + */ + $content_length = (array) wp_remote_retrieve_header( $r, 'content-length' ); + if ( ! is_numeric( $content_length[0] ) ) { + return new WP_Error( + 'background_image_content_length_unknown', + __( 'HEAD request for background image URL did not include a Content-Length response header.', 'image-prioritizer' ) + ); + } elseif ( (int) $content_length[0] > 2 * MB_IN_BYTES ) { + return new WP_Error( + 'background_image_content_length_too_large', + sprintf( + /* translators: %s is the content length of the response */ + __( 'HEAD request for background image URL returned Content-Length greater than 2MB: %s.', 'image-prioritizer' ), + $content_length[0] + ) + ); + } + + return true; +} + +/** + * Sanitizes the lcpElementExternalBackgroundImage property from the request URL Metric storage request. + * + * This removes the lcpElementExternalBackgroundImage from the URL Metric prior to it being stored if the background + * image URL is not valid. Removal of the property is preferable to invalidating the entire URL Metric because then + * potentially no URL Metrics would ever be collected if, for example, the background image URL is pointing to a + * disallowed origin. Then none of the other optimizations would be able to be applied. + * + * @since 0.3.0 + * @access private + * + * @phpstan-param WP_REST_Request> $request + * + * @param WP_REST_Response|WP_HTTP_Response|WP_Error|mixed $response Result to send to the client. + * Usually a WP_REST_Response or WP_Error. + * @param array $handler Route handler used for the request. + * @param WP_REST_Request $request Request used to generate the response. + * + * @return WP_REST_Response|WP_HTTP_Response|WP_Error|mixed Result to send to the client. + * @noinspection PhpDocMissingThrowsInspection + */ +function image_prioritizer_filter_rest_request_before_callbacks( $response, array $handler, WP_REST_Request $request ) { + if ( + $request->get_method() !== 'POST' + || + // The strtolower() and outer trim are due to \WP_REST_Server::match_request_to_handler() using case-insensitive pattern match and using '$' instead of '\z'. + OD_REST_API_NAMESPACE . OD_URL_METRICS_ROUTE !== rtrim( strtolower( ltrim( $request->get_route(), '/' ) ) ) + ) { + return $response; + } + + $lcp_external_background_image = $request['lcpElementExternalBackgroundImage']; + if ( is_array( $lcp_external_background_image ) && isset( $lcp_external_background_image['url'] ) && is_string( $lcp_external_background_image['url'] ) ) { + $image_validity = image_prioritizer_validate_background_image_url( $lcp_external_background_image['url'] ); + if ( is_wp_error( $image_validity ) ) { + /** + * 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: 1: error message. 2: image url */ + __( 'Error: %1$s. Background image URL: %2$s.', 'image-prioritizer' ), + rtrim( $image_validity->get_error_message(), '.' ), + $lcp_external_background_image['url'] + ) + ); + unset( $request['lcpElementExternalBackgroundImage'] ); + } + } + + return $response; +} + /** * Gets the path to a script or stylesheet. * - * @since n.e.x.t + * @since 0.3.0 + * @access private * * @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 image_prioritizer_get_asset_path( string $src_path, ?string $min_path = null ): string { if ( null === $min_path ) { @@ -95,6 +339,11 @@ function image_prioritizer_get_asset_path( string $src_path, ?string $min_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( @@ -121,6 +370,7 @@ function image_prioritizer_get_asset_path( string $src_path, ?string $min_path = * Handles 'autoplay' and 'preload' attributes accordingly. * * @since 0.2.0 + * @access private * * @return string Lazy load script. */ @@ -134,7 +384,8 @@ function image_prioritizer_get_video_lazy_load_script(): string { * * Load the background image when it approaches the viewport using an IntersectionObserver. * - * @since n.e.x.t + * @since 0.3.0 + * @access private * * @return string Lazy load script. */ @@ -146,7 +397,8 @@ function image_prioritizer_get_lazy_load_bg_image_script(): string { /** * Gets the stylesheet to lazy-load background images. * - * @since n.e.x.t + * @since 0.3.0 + * @access private * * @return string Lazy load stylesheet. */ diff --git a/plugins/image-prioritizer/hooks.php b/plugins/image-prioritizer/hooks.php index 62d2fd3158..6365908758 100644 --- a/plugins/image-prioritizer/hooks.php +++ b/plugins/image-prioritizer/hooks.php @@ -11,3 +11,6 @@ } add_action( 'od_init', 'image_prioritizer_init' ); +add_filter( 'od_extension_module_urls', 'image_prioritizer_filter_extension_module_urls' ); +add_filter( 'od_url_metric_schema_root_additional_properties', 'image_prioritizer_add_element_item_schema_properties' ); +add_filter( 'rest_request_before_callbacks', 'image_prioritizer_filter_rest_request_before_callbacks', 10, 3 ); diff --git a/plugins/image-prioritizer/load.php b/plugins/image-prioritizer/load.php index 8fa0be02be..ce40dff411 100644 --- a/plugins/image-prioritizer/load.php +++ b/plugins/image-prioritizer/load.php @@ -6,7 +6,7 @@ * Requires at least: 6.6 * Requires PHP: 7.2 * Requires Plugins: optimization-detective - * Version: 0.2.0 + * Version: 0.3.0 * Author: WordPress Performance Team * Author URI: https://make.wordpress.org/performance/ * License: GPLv2 or later @@ -71,7 +71,7 @@ static function ( string $global_var_name, string $version, Closure $load ): voi } )( 'image_prioritizer_pending_plugin', - '0.2.0', + '0.3.0', static function ( string $version ): void { if ( defined( 'IMAGE_PRIORITIZER_VERSION' ) ) { return; diff --git a/plugins/image-prioritizer/readme.txt b/plugins/image-prioritizer/readme.txt index 37ea1fb918..36a942c819 100644 --- a/plugins/image-prioritizer/readme.txt +++ b/plugins/image-prioritizer/readme.txt @@ -2,26 +2,33 @@ Contributors: wordpressdotorg Tested up to: 6.7 -Stable tag: 0.2.0 +Stable tag: 0.3.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, optimization, image, lcp, lazy-load -Prioritizes the loading of images and videos based on how visible they are to actual visitors; adds fetchpriority and applies lazy-loading. +Prioritizes the loading of images and videos based on how visible they are to actual visitors; adds fetchpriority and applies lazy loading. == Description == -This plugin optimizes the loading of images (and videos) with prioritization, lazy loading, and more accurate image size selection. +This plugin optimizes the loading of images (and videos) with prioritization to improve [Largest Contentful Paint](https://web.dev/articles/lcp) (LCP), lazy loading, and more accurate image size selection. The current optimizations include: -1. Ensure `fetchpriority=high` is only added to an `IMG` when it is the Largest Contentful Paint (LCP) element across all responsive breakpoints. -2. Add breakpoint-specific `fetchpriority=high` preload links for the LCP elements which are `IMG` elements or elements with a CSS `background-image` inline style. -3. Apply lazy-loading to `IMG` tags based on whether they appear in any breakpoint’s initial viewport. (Additionally, [`sizes=auto`](https://make.wordpress.org/core/2024/10/18/auto-sizes-for-lazy-loaded-images-in-wordpress-6-7/) is then also correctly applied.) -4. Implement lazy-loading of CSS background images added via inline `style` attributes. -5. 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. 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`. + 2. The first `SOURCE` element with a `type` attribute in a `PICTURE` element. (Art-directed `PICTURE` elements using media queries are not supported.) + 3. An element with a CSS `background-image` inline `style` attribute. + 4. An element with a CSS `background-image` applied with a stylesheet (when the image is from an allowed origin). + 5. A `VIDEO` element's `poster` image. +2. Ensure `fetchpriority=high` is only added to an `IMG` when it is the LCP element across all responsive breakpoints. +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. +4. Lazy loading: + 1. Apply lazy loading to `IMG` tags based on whether they appear in any breakpoint’s initial viewport. + 2. Implement lazy loading of CSS background images added via inline `style` attributes. + 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. +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. 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). -7. 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. **This plugin requires the [Optimization Detective](https://wordpress.org/plugins/optimization-detective/) plugin as a dependency.** Please refer to that plugin for additional background on how this plugin works as well as additional developer options. @@ -63,6 +70,16 @@ The [plugin source code](https://github.com/WordPress/performance/tree/trunk/plu == Changelog == += 0.3.0 = + +**Enhancements** + +* Add preload links LCP picture elements. ([1707](https://github.com/WordPress/performance/pull/1707)) +* Harden validation of user-submitted LCP background image URL. ([1713](https://github.com/WordPress/performance/pull/1713)) +* Lazy load background images added via inline style attributes. ([1708](https://github.com/WordPress/performance/pull/1708)) +* Preload image URLs for LCP elements with external background images. ([1697](https://github.com/WordPress/performance/pull/1697)) +* Serve unminified scripts when `SCRIPT_DEBUG` is enabled. ([1643](https://github.com/WordPress/performance/pull/1643)) + = 0.2.0 = **Enhancements** diff --git a/plugins/image-prioritizer/tests/test-cases/fetch-priority-high-on-lcp-image-common-on-mobile-and-desktop-with-url-metrics-missing-in-other-groups.php b/plugins/image-prioritizer/tests/test-cases/fetch-priority-high-on-lcp-image-common-on-mobile-and-desktop-with-url-metrics-missing-in-other-groups.php new file mode 100644 index 0000000000..251a550fad --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/fetch-priority-high-on-lcp-image-common-on-mobile-and-desktop-with-url-metrics-missing-in-other-groups.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; + } + ); + + 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' => 375, + 'elements' => array( + array( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', + 'isLCP' => true, + ), + ), + ) + ) + ); + + 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( + 'xpath' => '/*[1][self::HTML]/*[2][self::BODY]/*[1][self::IMG]', + 'isLCP' => true, + ), + ), + ) + ) + ); + }, + 'buffer' => ' + + + + ... + + + Foo + + + ', + 'expected' => ' + + + + ... + + + + + Foo + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-complete-samples-but-element-absent.php b/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-complete-samples-but-element-absent.php new file mode 100644 index 0000000000..f4047e739a --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-complete-samples-but-element-absent.php @@ -0,0 +1,74 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + add_filter( + 'od_breakpoint_max_widths', + static function () { + return array( 480, 600, 782 ); + } + ); + + $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + + $bg_images = array( + 'https://example.com/mobile.jpg', + 'https://example.com/tablet.jpg', + 'https://example.com/phablet.jpg', + 'https://example.com/desktop.jpg', + ); + + // Fully populate all viewport groups, but for all except desktop record that the LCP element had a different tag, id, or class. + foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $i => $viewport_width ) { + for ( $j = 0; $j < $sample_size; $j++ ) { + OD_URL_Metrics_Post_Type::store_url_metric( + $slug, + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'elements' => array(), + 'extended_root' => array( + 'lcpElementExternalBackgroundImage' => array( + 'url' => $bg_images[ $i ], + 'tag' => 0 === $i ? 'DIV' : 'HEADER', + 'id' => 1 === $i ? 'foo' : 'masthead', + 'class' => 2 === $i ? 'bar' : 'banner', + ), + ), + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + + + + + + ', + 'expected' => ' + + + + ... + + + + + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-present-in-document-and-fully-populated-samples.php b/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-present-in-document-and-fully-populated-samples.php new file mode 100644 index 0000000000..d73c475ec3 --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-present-in-document-and-fully-populated-samples.php @@ -0,0 +1,77 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + add_filter( + 'od_breakpoint_max_widths', + static function () { + return array( 480, 600, 782 ); + } + ); + + $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + + $bg_images = array( + 'https://example.com/mobile.jpg', + 'https://example.com/tablet.jpg', + 'https://example.com/phablet.jpg', + 'https://example.com/desktop.jpg', + ); + + // Fully populate all viewport groups. + foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $i => $viewport_width ) { + for ( $j = 0; $j < $sample_size; $j++ ) { + OD_URL_Metrics_Post_Type::store_url_metric( + $slug, + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'elements' => array(), + 'extended_root' => array( + 'lcpElementExternalBackgroundImage' => array( + 'url' => $bg_images[ $i ], + 'tag' => 'HEADER', + 'id' => 'masthead', + 'class' => 'banner', + ), + ), + ) + ) + ); + } + } + }, + 'buffer' => ' + + + + ... + + + + + + + ', + 'expected' => ' + + + + ... + + + + + + + + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-present-in-document-and-partially-populated-samples.php b/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-present-in-document-and-partially-populated-samples.php new file mode 100644 index 0000000000..c967d5e9c9 --- /dev/null +++ b/plugins/image-prioritizer/tests/test-cases/lcp-element-external-background-image-present-in-document-and-partially-populated-samples.php @@ -0,0 +1,101 @@ + static function ( Test_Image_Prioritizer_Helper $test_case ): void { + add_filter( + 'od_breakpoint_max_widths', + static function () { + return array( 480, 600, 782 ); + } + ); + + $slug = od_get_url_metrics_slug( od_get_normalized_query_vars() ); + $sample_size = od_get_url_metrics_breakpoint_sample_size(); + + $bg_images = array( + 'https://example.com/mobile.jpg', + 'https://example.com/tablet.jpg', + 'https://example.com/phablet.jpg', + 'https://example.com/desktop.jpg', + ); + + $viewport_sample_sizes = array( + $sample_size, + $sample_size - 1, + 0, + $sample_size, + ); + + // Partially populate all viewport groups. + foreach ( array_merge( od_get_breakpoint_max_widths(), array( 1000 ) ) as $i => $viewport_width ) { + for ( $j = 0; $j < $viewport_sample_sizes[ $i ]; $j++ ) { + OD_URL_Metrics_Post_Type::store_url_metric( + $slug, + $test_case->get_sample_url_metric( + array( + 'viewport_width' => $viewport_width, + 'elements' => array(), + 'extended_root' => array( + 'lcpElementExternalBackgroundImage' => array( + 'url' => $bg_images[ $i ], + 'tag' => 'HEADER', + 'id' => 'masthead', + 'class' => 'banner', + ), + ), + ) + ) + ); + } + } + + // Store one more URL metric for desktop which has a different background image. + OD_URL_Metrics_Post_Type::store_url_metric( + $slug, + $test_case->get_sample_url_metric( + array( + 'viewport_width' => 1000, + 'elements' => array(), + 'extended_root' => array( + 'lcpElementExternalBackgroundImage' => array( + 'url' => 'https://example.com/desktop-alt.jpg', + 'tag' => 'HEADER', + 'id' => 'masthead', + 'class' => 'banner', + ), + ), + ) + ) + ); + }, + 'buffer' => ' + + + + ... + + + + + + + ', + 'expected' => ' + + + + ... + + + + + + + + + ', +); diff --git a/plugins/image-prioritizer/tests/test-helper.php b/plugins/image-prioritizer/tests/test-helper.php index b6054fbb17..dfd05b8549 100644 --- a/plugins/image-prioritizer/tests/test-helper.php +++ b/plugins/image-prioritizer/tests/test-helper.php @@ -33,7 +33,7 @@ public function data_provider_to_test_image_prioritizer_init(): array { 'expected' => false, ), 'with_new_version' => array( - 'version' => '0.7.0', + 'version' => '99.0.0', 'expected' => true, ), ); @@ -84,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 @@ -97,7 +97,7 @@ public function data_provider_test_filter_tag_visitors(): array { * @param callable|string $buffer Content before. * @param callable|string $expected Expected content after. */ - public function test_image_prioritizer_register_tag_visitors( callable $set_up, $buffer, $expected ): void { + public function test_end_to_end( callable $set_up, $buffer, $expected ): void { $set_up( $this, $this::factory() ); $buffer = is_string( $buffer ) ? $buffer : $buffer(); @@ -219,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 = '...'; @@ -236,30 +236,727 @@ public function test_auto_sizes( array $element_metrics, string $buffer, string ); } + /** + * 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->assertGreaterThan( 0, strlen( image_prioritizer_get_video_lazy_load_script() ) ); + $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->assertGreaterThan( 0, strlen( image_prioritizer_get_lazy_load_bg_image_script() ) ); + $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->assertGreaterThan( 0, strlen( image_prioritizer_get_lazy_load_bg_image_stylesheet() ) ); + $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-html-tag-processor.php b/plugins/optimization-detective/class-od-html-tag-processor.php index 7fc8967118..48ad11d126 100644 --- a/plugins/optimization-detective/class-od-html-tag-processor.php +++ b/plugins/optimization-detective/class-od-html-tag-processor.php @@ -497,7 +497,7 @@ public function release_bookmark( $name ): bool { * A breadcrumb consists of a tag name and its sibling index. * * @since 0.4.0 - * @since n.e.x.t Renamed from get_breadcrumbs() to get_indexed_breadcrumbs(). + * @since 0.9.0 Renamed from get_breadcrumbs() to get_indexed_breadcrumbs(). * * @return Generator Breadcrumb. */ @@ -513,7 +513,7 @@ private function get_indexed_breadcrumbs(): Generator { * 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 n.e.x.t + * @since 0.9.0 * @see WP_HTML_Processor::get_breadcrumbs() * * @return string[] Array of tag names representing path to matched node. @@ -645,8 +645,15 @@ public function get_updated_html(): string { * * @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-url-metric-group-collection.php b/plugins/optimization-detective/class-od-url-metric-group-collection.php index 9b50846eb6..56d257402e 100644 --- a/plugins/optimization-detective/class-od-url-metric-group-collection.php +++ b/plugins/optimization-detective/class-od-url-metric-group-collection.php @@ -38,7 +38,7 @@ final class OD_URL_Metric_Group_Collection implements Countable, IteratorAggrega /** * The current ETag. * - * @since n.e.x.t + * @since 0.9.0 * @var non-empty-string */ private $current_etag; @@ -186,7 +186,7 @@ public function __construct( array $url_metrics, string $current_etag, array $br /** * Gets the current ETag. * - * @since n.e.x.t + * @since 0.9.0 * * @return non-empty-string Current ETag. */ @@ -225,7 +225,7 @@ public function get_last_group(): OD_URL_Metric_Group { } /** - * Clear result cache. + * Clears result cache. * * @since 0.3.0 */ @@ -234,7 +234,7 @@ public function clear_cache(): void { } /** - * Create groups. + * Creates groups. * * @since 0.1.0 * @@ -427,6 +427,7 @@ public function get_groups_by_lcp_element( string $xpath ): array { * Gets common LCP element. * * @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. */ @@ -437,38 +438,40 @@ public function get_common_lcp_element(): ?OD_Element { $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 ( $lcp_element instanceof OD_Element ) { - $groups_by_lcp_element_xpath[ $lcp_element->get_xpath() ][] = $group; - $lcp_elements_by_xpath[ $lcp_element->get_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; diff --git a/plugins/optimization-detective/class-od-url-metric-group.php b/plugins/optimization-detective/class-od-url-metric-group.php index 9f026f9ff0..1e81641fdc 100644 --- a/plugins/optimization-detective/class-od-url-metric-group.php +++ b/plugins/optimization-detective/class-od-url-metric-group.php @@ -24,6 +24,8 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * URL Metrics. * + * @since 0.1.0 + * * @var OD_URL_Metric[] */ private $url_metrics; @@ -31,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 */ @@ -39,6 +43,8 @@ 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 */ @@ -47,6 +53,8 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * Sample size for URL Metrics for a given breakpoint. * + * @since 0.1.0 + * * @var int * @phpstan-var positive-int */ @@ -55,6 +63,8 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * Freshness age (TTL) for a given URL Metric. * + * @since 0.1.0 + * * @var int * @phpstan-var 0|positive-int */ @@ -63,6 +73,8 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * Collection that this instance belongs to. * + * @since 0.3.0 + * * @var OD_URL_Metric_Group_Collection */ private $collection; @@ -70,6 +82,8 @@ final class OD_URL_Metric_Group implements IteratorAggregate, Countable, JsonSer /** * Result cache. * + * @since 0.3.0 + * * @var array{ * get_lcp_element?: OD_Element|null, * is_complete?: bool, @@ -145,6 +159,8 @@ public function __construct( array $url_metrics, int $minimum_viewport_width, in /** * 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. */ @@ -155,6 +171,8 @@ 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. */ @@ -163,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. @@ -178,6 +224,8 @@ public function is_viewport_width_in_range( int $viewport_width ): bool { /** * 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. * * @param OD_URL_Metric $url_metric URL Metric. @@ -189,8 +237,7 @@ public function add_url_metric( OD_URL_Metric $url_metric ): void { ); } - $this->result_cache = array(); - $this->collection->clear_cache(); + $this->clear_cache(); $url_metric->set_group( $this ); $this->url_metrics[] = $url_metric; @@ -217,7 +264,8 @@ static function ( OD_URL_Metric $a, OD_URL_Metric $b ): int { * A group is complete if it has the full sample size of URL Metrics * and all of these URL Metrics are fresh. * - * @since n.e.x.t If the current environment's generated ETag does not match the URL Metric's ETag, the URL Metric is considered stale. + * @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. */ @@ -258,6 +306,8 @@ public function is_complete(): bool { /** * Gets the LCP element in the viewport group. * + * @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. */ @@ -335,7 +385,7 @@ public function get_lcp_element(): ?OD_Element { /** * Gets all elements from all URL Metrics in the viewport group keyed by the elements' XPaths. * - * @since n.e.x.t + * @since 0.9.0 * * @return array> Keys are XPaths and values are the element instances. */ @@ -361,7 +411,7 @@ public function get_xpath_elements_map(): array { /** * Gets the max intersection ratios of all elements in the viewport group and its captured URL Metrics. * - * @since n.e.x.t + * @since 0.9.0 * * @return array Keys are XPaths and values are the intersection ratios. */ @@ -389,7 +439,7 @@ public function get_all_element_max_intersection_ratios(): array { /** * Gets the max intersection ratio of an element in the viewport group and its captured URL Metrics. * - * @since n.e.x.t + * @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). @@ -401,6 +451,8 @@ public function get_element_max_intersection_ratio( string $xpath ): ?float { /** * Returns an iterator for the URL Metrics in the group. * + * @since 0.1.0 + * * @return ArrayIterator ArrayIterator for OD_URL_Metric instances. */ public function getIterator(): ArrayIterator { @@ -410,12 +462,24 @@ public function getIterator(): ArrayIterator { /** * Counts the URL Metrics in the group. * + * @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. * diff --git a/plugins/optimization-detective/class-od-url-metric.php b/plugins/optimization-detective/class-od-url-metric.php index d3b0d984f7..0a164edc9e 100644 --- a/plugins/optimization-detective/class-od-url-metric.php +++ b/plugins/optimization-detective/class-od-url-metric.php @@ -156,7 +156,7 @@ public function set_group( OD_URL_Metric_Group $group ): void { * Gets JSON schema for URL Metric. * * @since 0.1.0 - * @since n.e.x.t Added the 'etag' property to the schema. + * @since 0.9.0 Added the 'etag' property to the schema. * * @todo Cache the return value? * @@ -431,7 +431,7 @@ public function get_uuid(): string { /** * Gets ETag. * - * @since n.e.x.t + * @since 0.9.0 * * @return non-empty-string|null ETag. */ @@ -511,6 +511,15 @@ function ( array $element ): OD_Element { * @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 40d91375c7..ded7ff898b 100644 --- a/plugins/optimization-detective/detect.js +++ b/plugins/optimization-detective/detect.js @@ -1,6 +1,11 @@ /** * @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 @@ -228,6 +233,11 @@ function extendElementData( xpath, properties ) { 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. * @@ -245,7 +255,7 @@ function extendElementData( xpath, properties ) { * @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( { minViewportAspectRatio, @@ -264,7 +274,21 @@ export default async function detect( { urlMetricGroupCollection, } ) { if ( isDebug ) { - log( 'Stored URL Metric group collection:', urlMetricGroupCollection ); + 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 ); + } + } + 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. @@ -335,6 +359,14 @@ export default async function detect( { { 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 ) { @@ -352,23 +384,57 @@ export default async function detect( { /** @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. It's important to pass webVitalsLibrarySrc to the extension so that onLCP, onCLS, or onINP can be obtained. + // 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 ) { - extension.initialize( { isDebug } ); + 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 initialize extension '${ extensionModuleUrl }':`, + `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} */ @@ -424,8 +490,6 @@ export default async function detect( { } ); } - const { onLCP } = await import( webVitalsLibrarySrc ); - /** @type {LCPMetric[]} */ const lcpMetricCandidates = []; @@ -529,27 +593,55 @@ export default async function detect( { } 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 { - await extension.finalize( { + const finalizePromise = extension.finalize( { isDebug, getRootData, getElementData, extendElementData, extendRootData, } ); + if ( finalizePromise instanceof Promise ) { + extensionFinalizePromises.push( finalizePromise ); + finalizingExtensionModuleUrls.push( + extensionModuleUrl + ); + } } catch ( err ) { error( - `Unable to finalize module '${ extensionModuleUrl }':`, + `Unable to start finalizing extension '${ extensionModuleUrl }':`, 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 diff --git a/plugins/optimization-detective/detection.php b/plugins/optimization-detective/detection.php index 2fa2a6dee7..116249704d 100644 --- a/plugins/optimization-detective/detection.php +++ b/plugins/optimization-detective/detection.php @@ -71,7 +71,7 @@ function od_get_cache_purge_post_id(): ?int { */ 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' ); + $web_vitals_lib_src = plugins_url( add_query_arg( 'ver', $web_vitals_lib_data['version'], 'build/web-vitals.js' ), __FILE__ ); /** * Filters the list of extension script module URLs to import when performing detection. @@ -118,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__ ) . od_get_asset_path( 'detect.js' ) ) ), + wp_json_encode( plugins_url( add_query_arg( 'ver', OPTIMIZATION_DETECTIVE_VERSION, od_get_asset_path( 'detect.js' ) ), __FILE__ ) ), wp_json_encode( $detect_args ) ), array( 'type' => 'module' ) diff --git a/plugins/optimization-detective/helper.php b/plugins/optimization-detective/helper.php index b9dc348f94..27073205d1 100644 --- a/plugins/optimization-detective/helper.php +++ b/plugins/optimization-detective/helper.php @@ -68,11 +68,13 @@ function od_render_generator_meta_tag(): void { /** * Gets the path to a script or stylesheet. * - * @since n.e.x.t + * @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 ) { @@ -83,6 +85,11 @@ function od_get_asset_path( string $src_path, ?string $min_path = null ): string $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( diff --git a/plugins/optimization-detective/load.php b/plugins/optimization-detective/load.php index 7000e184e8..81b60cb75f 100644 --- a/plugins/optimization-detective/load.php +++ b/plugins/optimization-detective/load.php @@ -5,7 +5,7 @@ * 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.6 * Requires PHP: 7.2 - * Version: 0.9.0-alpha + * Version: 0.9.0 * Author: WordPress Performance Team * Author URI: https://make.wordpress.org/performance/ * License: GPLv2 or later @@ -70,7 +70,7 @@ static function ( string $global_var_name, string $version, Closure $load ): voi } )( 'optimization_detective_pending_plugin', - '0.9.0-alpha', + '0.9.0', static function ( string $version ): void { if ( defined( 'OPTIMIZATION_DETECTIVE_VERSION' ) ) { return; diff --git a/plugins/optimization-detective/optimization.php b/plugins/optimization-detective/optimization.php index 8e5e524744..fa72a6194e 100644 --- a/plugins/optimization-detective/optimization.php +++ b/plugins/optimization-detective/optimization.php @@ -98,7 +98,7 @@ function_exists( 'perflab_server_timing_use_output_buffer' ) * Determines whether the current response can be optimized. * * @since 0.1.0 - * @since n.e.x.t Response is optimized for admin users as well when in 'plugin' development mode. + * @since 0.9.0 Response is optimized for admin users as well when in 'plugin' development mode. * * @access private * @@ -170,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() || @@ -206,7 +210,7 @@ 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 ); + $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, diff --git a/plugins/optimization-detective/readme.txt b/plugins/optimization-detective/readme.txt index dbe6afd220..5f7751c34f 100644 --- a/plugins/optimization-detective/readme.txt +++ b/plugins/optimization-detective/readme.txt @@ -2,7 +2,7 @@ Contributors: wordpressdotorg Tested up to: 6.7 -Stable tag: 0.8.0 +Stable tag: 0.9.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, optimization, rum @@ -17,7 +17,7 @@ This plugin is a dependency which does not provide end-user functionality on its = 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). @@ -40,6 +40,33 @@ There are currently **no settings** and no user interface for this plugin since 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 = **Action:** `od_init` (argument: plugin version) @@ -230,7 +257,7 @@ For example: add_filter( 'od_extension_module_urls', static function ( array $extension_module_urls ): array { - $extension_module_urls[] = add_query_arg( 'ver', '1.0', plugin_dir_url( __FILE__ ) . 'detect.js' ); + $extension_module_urls[] = plugins_url( add_query_arg( 'ver', '1.0', 'detect.js' ), __FILE__ ); return $extension_module_urls; } ); @@ -292,6 +319,25 @@ The [plugin source code](https://github.com/WordPress/performance/tree/trunk/plu == Changelog == += 0.9.0 = + +**Enhancements** + +* Add `fetchpriority=high` to `IMG` when it is the LCP element on desktop and mobile with other viewport groups empty. ([1723](https://github.com/WordPress/performance/pull/1723)) +* Improve debugging stored URL Metrics in Optimization Detective. ([1656](https://github.com/WordPress/performance/pull/1656)) +* Incorporate page state into ETag computation. ([1722](https://github.com/WordPress/performance/pull/1722)) +* Mark existing URL Metrics as stale when a new tag visitor is registered. ([1705](https://github.com/WordPress/performance/pull/1705)) +* Set development mode to 'plugin' in the dev environment and allow pages to be optimized when admin is logged-in (when in plugin dev mode). ([1700](https://github.com/WordPress/performance/pull/1700)) +* Add `get_xpath_elements_map()` helper methods to `OD_URL_Metric_Group_Collection` and `OD_URL_Metric_Group`, and add `get_all_element_max_intersection_ratios`/`get_element_max_intersection_ratio` methods to `OD_URL_Metric_Group`. ([1654](https://github.com/WordPress/performance/pull/1654)) +* Add `get_breadcrumbs()` method to `OD_HTML_Tag_Processor`. ([1707](https://github.com/WordPress/performance/pull/1707)) +* Add `get_sample_size()` and `get_freshness_ttl()` methods to `OD_URL_Metric_Group`. ([1697](https://github.com/WordPress/performance/pull/1697)) +* Expose `onTTFB`, `onFCP`, `onLCP`, `onINP`, and `onCLS` from web-vitals.js to extension JS modules via args their `initialize` functions. ([1697](https://github.com/WordPress/performance/pull/1697)) + +**Bug Fixes** + +* Prevent submitting URL Metric if viewport size changed. ([1712](https://github.com/WordPress/performance/pull/1712)) +* Fix construction of XPath expressions for implicitly closed paragraphs. ([1707](https://github.com/WordPress/performance/pull/1707)) + = 0.8.0 = **Enhancements** 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 814abaac94..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 @@ -115,6 +115,7 @@ public static function get_post( string $slug ): ?WP_Post { * * @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__; @@ -123,6 +124,11 @@ public static function get_url_metrics_from_post( WP_Post $post ): array { 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 ); }; diff --git a/plugins/optimization-detective/storage/data.php b/plugins/optimization-detective/storage/data.php index a773ff6f1f..367637f00d 100644 --- a/plugins/optimization-detective/storage/data.php +++ b/plugins/optimization-detective/storage/data.php @@ -140,28 +140,105 @@ function od_get_url_metrics_slug( array $query_vars ): string { return md5( (string) wp_json_encode( $query_vars ) ); } +/** + * Gets the current template for a block theme or a classic theme. + * + * @since 0.9.0 + * @access private + * + * @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. * - * The ETag is a hash based on the IDs of the registered tag visitors - * in the current environment. It is used for marking the URL Metrics as stale - * when its value changes. + * 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 n.e.x.t + * @since 0.9.0 * @access private * - * @param OD_Tag_Visitor_Registry $tag_visitor_registry Tag visitor registry. + * @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 ): string { +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 ) ), + '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 n.e.x.t + * @since 0.9.0 * * @param array $data Data. */ @@ -176,7 +253,7 @@ function od_get_current_url_metrics_etag( OD_Tag_Visitor_Registry $tag_visitor_r * This is used in the REST API to authenticate the storage of new URL Metrics from a given URL. * * @since 0.8.0 - * @since n.e.x.t Introduced the `$current_etag` parameter. + * @since 0.9.0 Introduced the `$current_etag` parameter. * @access private * * @see od_verify_url_metrics_storage_hmac() @@ -197,7 +274,7 @@ function od_get_url_metrics_storage_hmac( string $slug, string $current_etag, st * Verifies HMAC for storing URL Metrics for a specific slug. * * @since 0.8.0 - * @since n.e.x.t Introduced the `$current_etag` parameter. + * @since 0.9.0 Introduced the `$current_etag` parameter. * @access private * * @see od_get_url_metrics_storage_hmac() diff --git a/plugins/optimization-detective/storage/rest-api.php b/plugins/optimization-detective/storage/rest-api.php index 4de890f960..09ce02501e 100644 --- a/plugins/optimization-detective/storage/rest-api.php +++ b/plugins/optimization-detective/storage/rest-api.php @@ -94,7 +94,7 @@ function od_register_endpoint(): void { return new WP_Error( 'url_metric_storage_locked', __( 'URL Metric storage is presently locked for the current IP.', 'optimization-detective' ), - array( 'status' => 403 ) + array( 'status' => 403 ) // TODO: Consider 423 Locked status code. ); } return true; @@ -163,6 +163,7 @@ function od_handle_rest_request( WP_REST_Request $request ) { $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() ) { @@ -201,7 +202,7 @@ function od_handle_rest_request( WP_REST_Request $request ) { return new WP_Error( 'rest_invalid_param', sprintf( - /* translators: %s is exception name */ + /* translators: %s is exception message */ __( 'Failed to validate URL Metric: %s', 'optimization-detective' ), $e->getMessage() ), @@ -214,9 +215,19 @@ 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; 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 c90df29ef2..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(); } @@ -286,56 +288,268 @@ public function test_od_get_url_metrics_slug(): void { } } + /** + * Data provider. + * + * @return array + */ + 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'] ); + }; + }, + ), + ); + } + /** * 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(): void { - remove_all_filters( 'od_current_url_metrics_etag_data' ); - $registry = new OD_Tag_Visitor_Registry(); - - $captured_etag_data = array(); + public function test_od_get_current_url_metrics_etag( Closure $set_up ): void { + $captured_etag_data = null; add_filter( 'od_current_url_metrics_etag_data', static function ( array $data ) use ( &$captured_etag_data ) { - $captured_etag_data[] = $data; + $captured_etag_data = $data; return $data; }, PHP_INT_MAX ); - $etag1 = od_get_current_url_metrics_etag( $registry ); - $this->assertMatchesRegularExpression( '/^[a-z0-9]{32}\z/', $etag1 ); - $etag2 = od_get_current_url_metrics_etag( $registry ); - $this->assertSame( $etag1, $etag2 ); - $this->assertCount( 2, $captured_etag_data ); - $this->assertSame( array( 'tag_visitors' => array() ), $captured_etag_data[0] ); - $this->assertSame( $captured_etag_data[ count( $captured_etag_data ) - 2 ], $captured_etag_data[ count( $captured_etag_data ) - 1 ] ); + $registry = new OD_Tag_Visitor_Registry(); $registry->register( 'foo', static function (): void {} ); $registry->register( 'bar', static function (): void {} ); $registry->register( 'baz', static function (): void {} ); - $etag3 = od_get_current_url_metrics_etag( $registry ); - $this->assertNotEquals( $etag2, $etag3 ); - $this->assertNotEquals( $captured_etag_data[ count( $captured_etag_data ) - 2 ], $captured_etag_data[ count( $captured_etag_data ) - 1 ] ); - $this->assertSame( array( 'tag_visitors' => array( 'foo', 'bar', 'baz' ) ), $captured_etag_data[ count( $captured_etag_data ) - 1 ] ); - add_filter( - 'od_current_url_metrics_etag_data', - static function ( $data ): array { - $data['last_modified'] = '2024-03-02T01:00:00'; - return $data; - } - ); - $etag4 = od_get_current_url_metrics_etag( $registry ); - $this->assertNotEquals( $etag3, $etag4 ); - $this->assertNotEquals( $captured_etag_data[ count( $captured_etag_data ) - 2 ], $captured_etag_data[ count( $captured_etag_data ) - 1 ] ); - $this->assertSame( - array( - 'tag_visitors' => array( 'foo', 'bar', 'baz' ), - 'last_modified' => '2024-03-02T01:00:00', + $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' ), ), - $captured_etag_data[ count( $captured_etag_data ) - 1 ] ); + + $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 ); + } } /** diff --git a/plugins/optimization-detective/tests/storage/test-rest-api.php b/plugins/optimization-detective/tests/storage/test-rest-api.php index ad514aa487..3671b5784d 100644 --- a/plugins/optimization-detective/tests/storage/test-rest-api.php +++ b/plugins/optimization-detective/tests/storage/test-rest-api.php @@ -28,6 +28,18 @@ 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 (): array { @@ -35,17 +47,8 @@ public function data_provider_to_test_rest_request_good_params(): array { }, ), 'extended' => array( - 'set_up' => function (): array { - add_filter( - 'od_url_metric_schema_root_additional_properties', - static function ( array $properties ): array { - $properties['extra'] = array( - 'type' => 'string', - ); - return $properties; - } - ); - + 'set_up' => function () use ( $add_root_extra_property ): array { + $add_root_extra_property( 'extra' ); $params = $this->get_valid_params(); $params['extra'] = 'foo'; return $params; @@ -96,12 +99,15 @@ function ( OD_URL_Metric_Store_Request_Context $context ) use ( &$stored_context $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 ); @@ -112,20 +118,20 @@ function ( OD_URL_Metric_Store_Request_Context $context ) use ( &$stored_context $expected_data = $valid_params; 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() ); - $cache_purge_post_id = $stored_context->request->get_param( 'cache_purge_post_id' ); - if ( isset( $valid_params['cache_purge_post_id'] ) ) { - $scheduled = wp_next_scheduled( 'od_trigger_page_cache_invalidation', array( $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 ); } 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 066f34f8b2..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 @@ -199,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(). * @@ -721,45 +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 = '/*[1][self::HTML]/*[2][self::BODY]/*[1][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->assertInstanceOf( OD_Element::class, $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 ); + } } /** 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 2b1538c7a5..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 * @@ -121,6 +123,8 @@ public function test_construction( array $url_metrics, int $minimum_viewport_wid $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 ) ); } diff --git a/plugins/optimization-detective/tests/test-helper.php b/plugins/optimization-detective/tests/test-helper.php index c28dfdd306..42bcf62dd8 100644 --- a/plugins/optimization-detective/tests/test-helper.php +++ b/plugins/optimization-detective/tests/test-helper.php @@ -25,6 +25,8 @@ static function ( string $version ) use ( &$passed_version ): void { } /** + * Data provider. + * * @return array> */ public function data_to_test_od_generate_media_query(): array { diff --git a/plugins/optimization-detective/tests/test-optimization.php b/plugins/optimization-detective/tests/test-optimization.php index a76d3f0452..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 { diff --git a/plugins/optimization-detective/types.ts b/plugins/optimization-detective/types.ts index fc4e375b60..d92c532143 100644 --- a/plugins/optimization-detective/types.ts +++ b/plugins/optimization-detective/types.ts @@ -1,6 +1,8 @@ // 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; @@ -28,11 +30,22 @@ export interface URLMetricGroupStatus { 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 ) => void; +export type InitializeCallback = ( args: InitializeArgs ) => Promise< void >; export type FinalizeArgs = { readonly getRootData: () => URLMetric; diff --git a/plugins/performance-lab/includes/admin/load.php b/plugins/performance-lab/includes/admin/load.php index 8416f3e74a..c85db6d815 100644 --- a/plugins/performance-lab/includes/admin/load.php +++ b/plugins/performance-lab/includes/admin/load.php @@ -216,7 +216,7 @@ function perflab_dismiss_wp_pointer_wrapper(): void { /** * Gets the path to a script or stylesheet. * - * @since n.e.x.t + * @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. @@ -264,7 +264,7 @@ function perflab_enqueue_features_page_scripts(): void { // 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' ), + plugins_url( perflab_get_asset_path( 'includes/admin/plugin-activate-ajax.js' ), PERFLAB_MAIN_FILE ), array( 'wp-i18n', 'wp-a11y', 'wp-api-fetch' ), PERFLAB_VERSION, true diff --git a/plugins/performance-lab/includes/admin/plugins.php b/plugins/performance-lab/includes/admin/plugins.php index c3633c5d60..003138e52e 100644 --- a/plugins/performance-lab/includes/admin/plugins.php +++ b/plugins/performance-lab/includes/admin/plugins.php @@ -22,12 +22,12 @@ 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 ] ) ) { + 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( - 'plugin_not_found', - __( 'Plugin not found in cached API response.', 'performance-lab' ) + $plugins[ $plugin_slug ]['error']['code'], + $plugins[ $plugin_slug ]['error']['message'] ); } return $plugins[ $plugin_slug ]; // Return cached plugin info if found. @@ -54,58 +54,94 @@ 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(); - $plugin_queue = perflab_get_standalone_plugins(); + $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' ), + ), + ); - // Index the plugins from the API response by their slug for efficient lookup. - $all_performance_plugins = array_column( $response->plugins, null, 'slug' ); + foreach ( perflab_get_standalone_plugins() as $standalone_plugin ) { + $plugins[ $standalone_plugin ] = $plugins[ $plugin_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 ); + $has_errors = true; + } else { + $plugin_queue = perflab_get_standalone_plugins(); - if ( isset( $plugins[ $current_plugin_slug ] ) ) { - continue; - } + // Index the plugins from the API response by their slug for efficient lookup. + $all_performance_plugins = array_column( $response->plugins, null, 'slug' ); - if ( ! isset( $all_performance_plugins[ $current_plugin_slug ] ) ) { - return new WP_Error( - 'plugin_not_found', - __( 'Plugin not found in WordPress.org API response.', 'performance-lab' ) - ); + // 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'] ); + } + } } - $plugin_data = $all_performance_plugins[ $current_plugin_slug ]; - $plugins[ $current_plugin_slug ] = wp_array_slice_assoc( $plugin_data, $fields ); + 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' ), + ), + ); - // 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'] ); + $has_errors = true; } } - 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 ] ) ) { + if ( isset( $plugins[ $plugin_slug ]['error'] ) ) { return new WP_Error( - 'plugin_not_found', - __( 'Plugin not found in API response.', 'performance-lab' ) + $plugins[ $plugin_slug ]['error']['code'], + $plugins[ $plugin_slug ]['error']['message'] ); } @@ -256,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 ), + ) + ); + ?> +

+ . // See webpack config in the WordPress/performance repo: . - 'lib' => wp_parse_url( plugin_dir_url( __FILE__ ), PHP_URL_PATH ) . 'build/', + 'lib' => wp_parse_url( plugins_url( 'build/', __FILE__ ), PHP_URL_PATH ), ); if ( WP_DEBUG && SCRIPT_DEBUG ) { diff --git a/plugins/web-worker-offloading/load.php b/plugins/web-worker-offloading/load.php index 831e965dab..a540a131da 100644 --- a/plugins/web-worker-offloading/load.php +++ b/plugins/web-worker-offloading/load.php @@ -5,7 +5,7 @@ * 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.6 * Requires PHP: 7.2 - * Version: 0.1.1 + * 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.1' ); +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 db93c984cd..e2d6623c09 100644 --- a/plugins/web-worker-offloading/readme.txt +++ b/plugins/web-worker-offloading/readme.txt @@ -2,7 +2,7 @@ Contributors: wordpressdotorg Tested up to: 6.7 -Stable tag: 0.1.1 +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 @@ -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,18 @@ 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** 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 95fc15e18e..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 ), ); @@ -414,7 +414,7 @@ function webp_uploads_is_fallback_enabled(): bool { /** * Checks if the `perflab_generate_all_fallback_sizes` option is enabled. * - * @since n.e.x.t + * @since 2.4.0 * * @return bool Whether the option is enabled. Default is false. */ diff --git a/plugins/webp-uploads/hooks.php b/plugins/webp-uploads/hooks.php index 0e36bbca5a..3f5e5014d2 100644 --- a/plugins/webp-uploads/hooks.php +++ b/plugins/webp-uploads/hooks.php @@ -784,7 +784,7 @@ function webp_uploads_init(): void { /** * Automatically opt into extra image sizes when generating fallback images. * - * @since n.e.x.t + * @since 2.4.0 * * @global array $_wp_additional_image_sizes Associative array of additional image sizes. */ @@ -810,7 +810,7 @@ function webp_uploads_opt_in_extra_image_sizes(): void { /** * Enables additional MIME type support for all image sizes based on the generate all fallback sizes settings. * - * @since n.e.x.t + * @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. diff --git a/plugins/webp-uploads/load.php b/plugins/webp-uploads/load.php index b474812a31..3ba80c299a 100644 --- a/plugins/webp-uploads/load.php +++ b/plugins/webp-uploads/load.php @@ -5,7 +5,7 @@ * Description: Converts images to more modern formats such as WebP or AVIF during upload. * Requires at least: 6.6 * Requires PHP: 7.2 - * Version: 2.3.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.3.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/readme.txt b/plugins/webp-uploads/readme.txt index 9c793785c5..559b3c23a7 100644 --- a/plugins/webp-uploads/readme.txt +++ b/plugins/webp-uploads/readme.txt @@ -2,7 +2,7 @@ Contributors: wordpressdotorg Tested up to: 6.7 -Stable tag: 2.3.0 +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,14 @@ 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** diff --git a/plugins/webp-uploads/settings.php b/plugins/webp-uploads/settings.php index cad0352e11..fa27269c47 100644 --- a/plugins/webp-uploads/settings.php +++ b/plugins/webp-uploads/settings.php @@ -204,7 +204,7 @@ function webp_uploads_generate_webp_jpeg_setting_callback(): void { /** * Renders the settings field for generating all fallback image sizes. * - * @since n.e.x.t + * @since 2.4.0 */ function webp_uploads_generate_all_fallback_sizes_callback(): void { $all_fallback_sizes_enabled = webp_uploads_should_generate_all_fallback_sizes(); diff --git a/plugins/webp-uploads/tests/test-helper.php b/plugins/webp-uploads/tests/test-helper.php index a66154b647..5165aaa21a 100644 --- a/plugins/webp-uploads/tests/test-helper.php +++ b/plugins/webp-uploads/tests/test-helper.php @@ -365,7 +365,7 @@ public function test_it_should_return_default_transforms_when_filter_returns_non $this->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 8935827664..1aa09f5626 100644 --- a/plugins/webp-uploads/tests/test-load.php +++ b/plugins/webp-uploads/tests/test-load.php @@ -1087,4 +1087,36 @@ public function test_it_should_generate_fallback_images_for_all_sizes_when_gener 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/tests/class-optimization-detective-test-helpers.php b/tests/class-optimization-detective-test-helpers.php index c7924480a0..bfe551fed8 100644 --- a/tests/class-optimization-detective-test-helpers.php +++ b/tests/class-optimization-detective-test-helpers.php @@ -27,7 +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() ); // Note: Tests rely on the od_current_url_metrics_etag_data filter to set the desired value. + $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++ ) { @@ -81,11 +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() ), // Note: Tests rely on the od_current_url_metrics_etag_data filter to set the desired value. + '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 ); @@ -94,7 +95,7 @@ public function get_sample_url_metric( array $params ): OD_URL_Metric { $params['elements'][] = $params['element']; } - return new OD_URL_Metric( + $data = array_merge( array( 'etag' => $params['etag'], 'url' => $params['url'], @@ -118,8 +119,10 @@ function ( array $element ): array { }, $params['elements'] ), - ) + ), + $params['extended_root'] ); + return new OD_URL_Metric( $data ); } /** diff --git a/webpack.config.js b/webpack.config.js index 47744964dc..faaaa32677 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -132,6 +132,10 @@ const imagePrioritizer = ( env ) => { 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`,